diff --git a/CLAUDE.md b/CLAUDE.md index 621a9ed..5b7cbbd 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,6 +173,12 @@ Share codes, saved layouts, the templates and the CLI's output are all blueprint are described in `packages/solver/CLAUDE.md`. **Never compare encoded codes to test whether two layouts match** — use `blueprintKey()`. +The one code that is not a blueprint is the game's own: its Export to Optimizer +button writes an `R2X1.` code with the player's whole save, read by +`encoding/gameExport.ts` and mapped onto ours by `data/gameImport.ts`. Its format +is the game's, specified in `docs/game-export.md`; the app side is in +`src/lib/state/CLAUDE.md`. + ### Two halves: UI shell and solver engine - **UI shell** — Svelte 5 components + runes state classes: paint a grid, manage diff --git a/README.md b/README.md index 5333b75..a2a0c60 100755 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ Workers so the board stays interactive while it thinks. 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 judgement the solver cannot make. +- **Load your game.** The game's **Export to Optimizer** button copies a code + with your whole save — every island's cleared obstacles and buildings, your + unlocks, Time Lab and anomaly. Paste it into **Import code** and the optimizer + is set up exactly as you are, with research still in progress either counted + as done or turned into the upgrade plan. The format is in + [`docs/game-export.md`](docs/game-export.md). - **Share codes and links.** Any board — hand-built or solved — compresses to a short code, or a `?bp=` URL. Opening someone's link is a read-only preview until you choose to adopt it. diff --git a/docs/SOLVER.md b/docs/SOLVER.md index cd7f208..9686097 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -9,8 +9,8 @@ the terminal. packages/solver/ src/solver/ the engine: types, island split, simulation, distribution, search src/data/ the authored building tables, the anomaly table, the shipped island codes, - unlock resolution - src/encoding/ the blueprint wire format + unlock resolution, mapping a game export onto all of that + src/encoding/ the blueprint wire format, and a reader for the game's export code src/utils/ the game's number ladder src/grid.ts tile predicates and the ASCII alphabet the tests are written in bin/ the CLI (`npm run solve`) diff --git a/docs/game-export.md b/docs/game-export.md new file mode 100644 index 0000000..065224a --- /dev/null +++ b/docs/game-export.md @@ -0,0 +1,243 @@ +# Export to Optimizer — the game's export code + +Reactor 2's **Export to Optimizer** button produces one text code holding the +player's whole current state; the player pastes it into the optimizer's +**Import code** dialog, which loads every unlocked island (and resets the locked +ones to their untouched maps), the roster, the Time Lab, the anomaly and any +research in progress. + +This is the format's specification. It was written for the game's developer to +implement from, against game 1.1.10, and the game now ships it; every type, +order and encoding rule below is exact. Where something is a recommendation +rather than a rule, it says so. + +- **Writer:** the game. +- **Reader:** `packages/solver/src/encoding/gameExport.ts` decodes a code, and + `packages/solver/src/data/gameImport.ts` maps it onto the optimizer's islands, + roster and plan. +- **Samples:** `packages/solver/tests/gameExports/` holds real codes the game + produced, and `tests/gameExport.test.ts` pins the reader against them. + +The format follows the style of the optimizer's own layout codes ("blueprints"): +one byte per tile, a building implying grass beneath it, deflate + base64url. +Their codec is +[`blueprint.ts`](https://github.com/OGMods/reactor2-optimizer/blob/main/packages/solver/src/encoding/blueprint.ts), +for reference only — this export does **not** reuse its fixed byte tables; it +uses a palette instead (section 3). + +## 1. What the game does + +- A button (suggested: in Settings) labelled **Export to Optimizer**. +- On press: build the code (section 4), copy it to the clipboard + (`GUIUtility.systemCopyBuffer`), show a toast such as + "Code copied — paste it into the optimizer." +- Optional fallback: open the native Share sheet with the same text. +- The export is **read-only and one-way**. It never modifies the save, and the + game never imports anything back. +- No personal data: no player id, account name or device data. + +## 2. Game facts this spec relies on + +Read from the 1.1.10 build, and confirmed against the first real exports: every +coordinate, island boundary, obstacle and transformer in them matches the scene. + +- **One scene, one grid.** Scene `level1` has a single isometric `Grid` + (cell size 0.82 × 0.48). All islands are on it. +- **Three terrain tilemaps** on that grid: + - `Map Tilemap` — `TileGrass` or `TileWater` per cell. + - `Trees Tilemap` — obstacles. Four `ObstacleSO`s: `Map_Forest_Coniferous`, + `Map_Forest_Deciduous`, `Map_Pond`, `Map_Rock`. + - `CantBuildHere Tilemap` — the transformers (one per island). Its three + other cells are decorations outside the islands; ignore them. +- **Islands are `Area`s.** Each `Area` has an `AreaSO` id and a + `PolygonCollider2D` outlining it. Every land cell is inside exactly one + polygon. + + | Area id | Scene object | Land cells | + | -------- | ---------------- | ---------- | + | `area_1` | Island Left - 1 | 74 | + | `area_2` | Island Right - 1 | 90 | + | `area_3` | Island Left - 2 | 115 | + | `area_4` | Island Right - 2 | 162 | + | `area_5` | Island Left - 3 | 180 | + | `area_6` | Island Right - 3 | 200 | + | `area_7` | Island Left - 4 | 230 | + | `area_8` | Island Right - 4 | 265 | + + `area_city` holds no player buildings. **Do not export it.** + +- **Player changes live in the save:** cleared obstacles in + `RemovedObstaclePositions`, clears in progress in `OngoingRemovals`, placed + buildings in `BuildingSaves`. +- **All upgrades are research.** 76 `ResearchSO`s across domains: 39 building + nodes (unlocks and tiers, e.g. `research_building_cooler1`), 25 economy, 11 + Time Lab, 1 city. Research in progress is `ResearchManagerSO`'s + `OngoingResearchData`; several can run at once. +- **Placed buildings have no tier of their own.** They run at the building's + current research level, so the code does not store a tier per building. +- **Buildings only stand on grass** — never on water, an obstacle or a + transformer. The format depends on this (section 4.3). + +## 3. Definitions + +- **Cell** — one Tilemap cell on the shared grid, addressed by integer `(x, y)` + exactly as `Tilemap.WorldToCell` returns it. If the save stores world + positions, convert them to cells before writing. +- **Cell's area** — the `Area` whose `PolygonCollider2D` contains the cell's + centre (`Tilemap.GetCellCenterWorld`). This rule assigns every land cell to + exactly one area on the 1.1.10 map. If the game already has a cell-to-area + lookup, use that instead. +- **Palette** — a list of string ids written once near the start of the code. + Each cell is then stored as **one byte: its index in the palette**. This keeps + the code short (one byte per cell instead of a name) and means there is no + fixed number-per-building table to maintain: the ids are the game's own + strings, and a building added in a future update just appears in the list. + The palette is part of this format, not something that exists in the game. +- **Level** — the number of levels purchased; `0` means none. Never write a + 0-based index. A building research node at level 0 is a building not yet + unlocked. +- **Seconds left** — remaining time at the moment of export, as + `GetRemainingTimeSeconds` (or the equivalent) returns it, rounded down to a + whole number. + +## 4. The code + +### 4.1 Envelope + +``` +R2X1. +``` + +- `R2X1.` — literal prefix. `1` is the format version (section 5). +- `` = base64url( raw deflate( payload bytes ) ). + - **Raw deflate**: `System.IO.Compression.DeflateStream`, which the 1.1.10 + build already ships. It writes raw deflate (no zlib or gzip header). Use + `CompressionLevel.Optimal`. + - **base64url**: standard base64, then replace `+` → `-`, `/` → `_`, and strip + trailing `=`. +- No checksum. A truncated paste fails to decompress, which is how the importer + detects it. + +### 4.2 Payload bytes + +Primitive encodings (what .NET's `BinaryWriter` writes, UTF-8): + +| Type | Bytes | +| -------- | --------------------------------------------------------- | +| `byte` | 1 | +| `int16` | 2, little-endian, signed | +| `uint16` | 2, little-endian | +| `int32` | 4, little-endian, signed | +| `string` | 7-bit-encoded length (LEB128), then that many UTF-8 bytes | + +Six sections, always present, always in this order: + +| # | Section | Contents | +| --- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| 1 | Header | `string` game version (`Application.version`) · `string` anomaly id (`"none"` if none) · `byte` anomaly balance version | +| 2 | Palette | `byte` count · count × `string` id | +| 3 | Islands | `byte` count · per island: see 4.3 | +| 4 | Research | `uint16` count · per `ResearchSO`, all domains: `string` research id · `uint16` level | +| 5 | Research in progress | `uint16` count · per entry: `string` research id · `uint16` level it will reach · `int32` seconds left | +| 6 | Obstacles being cleared | `uint16` count · per entry: `int16` x · `int16` y · `int32` seconds left | + +Rules: + +- **Palette order.** Entries 0–6 are always the terrain, in exactly this order: + `TileWater`, `TileGrass`, `Map_Rock`, `Map_Forest_Coniferous`, + `Map_Forest_Deciduous`, `Map_Pond`, `CantBuildHere`. After them, one entry for + each distinct building id that appears on any exported island, in any order. + Building ids are the ids the game saves buildings under. +- **Research ids** are the ids the game saves research under (`ResearchId`). + Write **every** `ResearchSO`, including those at level 0, so a missing id + always means "this build did not have it". +- **Anomaly balance version** says which balance figures this save's anomaly + runs under, for anomalies rebalanced after saves already used them (as + Tidal Ascendancy was). Write `0` if the game keeps no such distinction. + +### 4.3 One island + +``` +string area id e.g. "area_3" +byte flags bit 0 = unlocked; other bits 0 +int16 minX bounding box of the area's cells +int16 minY +byte width maxX - minX + 1 +byte height maxY - minY + 1 +byte[width*height] cells +``` + +Write all eight areas, `area_1` to `area_8`, locked ones included (their +obstacles matter for planning). + +Cell bytes run row by row: outer loop `y` from `minY` to `minY + height - 1`, +inner loop `x` from `minX` to `minX + width - 1`. For each `(x, y)`, the byte is +the palette index of: + +1. `TileWater` if the cell's area is not this island; +2. else the building's id, if a building stands on the cell; +3. else the obstacle on `Trees Tilemap`, unless `(x, y)` is in + `RemovedObstaclePositions`; +4. else `CantBuildHere` if `CantBuildHere Tilemap` has a transformer there; +5. else `TileGrass` or `TileWater` from `Map Tilemap` (no tile = `TileWater`). + +A building cell implies grass beneath it, so terrain and buildings share one +grid. + +### 4.4 Worked example + +A palette of `TileWater, TileGrass, Map_Rock, Map_Forest_Coniferous, +Map_Forest_Deciduous, Map_Pond, CantBuildHere, cooler2, generator7` and this +3 × 2 island: + +``` +y = minY : water grass rock +y = minY + 1 : grass cooler2 generator7 +``` + +has the cell bytes `00 01 02 01 07 08`. + +## 5. Versioning + +- The format version is the digit in the `R2X1.` prefix. +- **Appending a new section at the end: no version bump.** The importer reads + the sections it knows and ignores trailing bytes. +- **Changing, reordering or removing anything: bump the version** (`R2X2.`). + The importer refuses versions it does not know. +- The game version string in the header tells the importer which building and + balance tables to use, so an old code still reads correctly. +- **Ids are permanent.** Never reuse or rename a building, research, anomaly + or area id; add new ids instead. + +## 6. Expected size + +Measured by encoding the real 1.1.10 map: + +| Save | Code length (characters) | +| --------------------------------------- | ------------------------ | +| Fresh save, no buildings | 2,342 | +| Mid game, 60% of grass built on | 2,852 | +| Late game, boards full | 2,985 | +| Worst case, 42 building kinds at random | 3,342 | + +About 1,100 characters of each code are id strings; the rest is tiles. The +first three real exports came to 2,347–2,460 characters. + +## 7. Notes from the first exports + +- **The two forests are named the other way round from our tiles.** + `Map_Forest_Deciduous` is the optimizer's `tree1` and + `Map_Forest_Coniferous` its `tree2`. Nothing for the game to change; the + reader maps them. +- **Research ids are the game's, verbatim.** Gas plant's node is + `research_gas_plant`, not `research_building_gas_plant`, and two economy ids + end in a space (`DepositsSlotsResearchSO `, `IslandPriceReductionResearchSO `). + The roster also lists `research_building_tidal_plant`, a building the + optimizer does not carry yet. The reader matches ids exactly and skips unknown + ones. +- **A code can pick up stray characters when pasted.** One sample ended in a + stray `ś`. The reader drops anything outside the base64url alphabet before + decoding; a code damaged in the middle still fails, at the inflate. +- **Open question:** one sample reported a research in progress with 0 seconds + left. Either it had finished but was not collected, or the seconds are not + being read from the running timer. diff --git a/packages/solver/src/data/gameImport.ts b/packages/solver/src/data/gameImport.ts new file mode 100644 index 0000000..995b365 --- /dev/null +++ b/packages/solver/src/data/gameImport.ts @@ -0,0 +1,342 @@ +import type { BlueprintPlacement } from "../encoding/blueprint"; +import { decodeBlueprint } from "../encoding/blueprint"; +import type { GameExport, GameExportIsland } from "../encoding/gameExport"; +import type { Tile, TileType } from "../solver/types"; +import { ANOMALIES, DEFAULT_ANOMALY_ID } from "./anomalies"; +import { findBuilding } from "./buildings"; +import { ISLAND_TEMPLATES } from "./maps"; +import { PRESTIGE_UPGRADES } from "./prestige"; +import { MAX_UPGRADE_STEPS, type UpgradeStep } from "./upgradePlan"; + +/** + * Turns a code from the game's Export to Optimizer button + * (`encoding/gameExport.ts`) into the optimizer's own terms: a roster, Time Lab + * levels, an anomaly, an upgrade plan and one board per shipped island. + * + * Everything the game names, it names by its own ids, so this is mostly a + * translation table, and its one rule is **what we cannot place, we report + * rather than guess**. An id with no counterpart here — a building the roster + * does not carry yet, research that does not change a layout — is dropped and + * counted; it is never mapped onto the nearest thing we do have. + */ + +/** The game's terrain ids, as its palette spells them. */ +const GAME_TERRAIN: Readonly> = { + TileWater: "water", + TileGrass: "grass", + Map_Rock: "rock", + // The game's two forests are our two tree sprites, and not in name order: + // its deciduous forest is `tree1`, its coniferous one `tree2`. Read off the + // shipped maps against the first exports the game produced. + Map_Forest_Deciduous: "tree1", + Map_Forest_Coniferous: "tree2", + Map_Pond: "pond", + CantBuildHere: "transformer", +}; + +const OBSTACLES: ReadonlySet = new Set([ + "rock", + "tree1", + "tree2", + "pond", +]); + +/** + * The building a research node unlocks and upgrades, or undefined for + * research that is not a building's. The game's ids are + * `research_building_` with one exception it shipped with — + * `research_gas_plant` — so both spellings are tried. + */ +function buildingForResearch(researchId: string): string | undefined { + for (const prefix of ["research_building_", "research_"]) { + if (!researchId.startsWith(prefix)) continue; + const def = findBuilding(researchId.slice(prefix.length)); + if (def) return def.id; + } + return undefined; +} + +/** + * A purchased level as our 0-based tier index, or undefined for level 0 + * (not bought, which for a building means locked). Clamped to the tiers we + * carry, so a game that has authored a tier past ours rates it at our top one + * rather than failing. + */ +function levelToIndex(level: number, tiers: number): number | undefined { + if (level <= 0) return undefined; + return Math.min(level, tiers) - 1; +} + +/** One research the game had running at the moment of export. */ +export interface ImportedProgress { + /** `building`: a building's unlock or tier. `research`: a Time Lab level. */ + kind: "building" | "research"; + /** Our building id or Time Lab id. */ + id: string; + /** The tier index (or Time Lab level index) it will reach. */ + level: number; + secondsLeft: number; +} + +/** One shipped island as the player has it in the game. */ +export interface ImportedIsland { + templateId: string; + name: string; + /** The template's terrain with the player's cleared obstacles cleared. */ + grid: Tile[][]; + placements: BlueprintPlacement[]; + /** Obstacles the player has cleared. */ + cleared: number; + /** + * Cells where the game's map and ours disagree on something other than a + * cleared obstacle. Our template wins on terrain — a shipped board is fixed + * apart from clearing. + */ + mismatched: number; + /** Buildings dropped because our map has no buildable tile under them. */ + blockedBuildings: number; + /** Buildings on the island the roster does not carry, which were dropped. */ + unknownBuildings: number; +} + +export interface ResolvedGameImport { + gameVersion: string; + /** Our `buildingUpgrades`: building id -> tier index; absent = locked. */ + buildingUpgrades: Record; + /** Our Time Lab levels: research id -> level index; absent = not bought. */ + prestigeLevels: Record; + /** An anomaly id this build knows — `none` when the game's is unknown. */ + anomalyId: string; + /** The game's anomaly id when it is one this build does not know. */ + unknownAnomaly: string | null; + /** Research running at export, soonest first — building and Time Lab only. */ + inProgress: ImportedProgress[]; + /** Unlocked islands, as boards. */ + islands: ImportedIsland[]; + /** + * Templates whose island is still locked in the game. The player has nothing + * there, so an import resets them to their untouched map. + */ + lockedTemplateIds: string[]; +} + +/** + * Builds one board: the shipped template, with the game's cleared obstacles + * cleared and its buildings placed. + */ +async function resolveIsland( + templateCode: string, + link: { originX: number; originY: number }, + island: GameExportIsland, +): Promise> { + const { grid } = await decodeBlueprint(templateCode); + const placements: BlueprintPlacement[] = []; + let cleared = 0; + let mismatched = 0; + let blockedBuildings = 0; + let unknownBuildings = 0; + + const cellAt = (gx: number, gy: number): string => { + const i = gx - island.minX; + const j = gy - island.minY; + if (i < 0 || j < 0 || i >= island.width || j >= island.height) { + return "TileWater"; + } + return island.cells[j * island.width + i]; + }; + + for (const row of grid) { + for (const tile of row) { + const ours = tile.type; + const theirs = cellAt(link.originX - tile.y, link.originY - tile.x); + const terrain = GAME_TERRAIN[theirs]; + + if (terrain === undefined) { + // A building. It stands on grass in the game, so here it needs grass + // too — or an obstacle the player cleared to make room for it. + if (ours !== "grass" && !OBSTACLES.has(ours)) { + mismatched++; + blockedBuildings++; + continue; + } + if (OBSTACLES.has(ours)) { + tile.type = "grass"; + cleared++; + } + if (findBuilding(theirs)) { + placements.push({ x: tile.x, y: tile.y, buildingId: theirs }); + } else { + unknownBuildings++; + } + } else if (terrain === "grass" && OBSTACLES.has(ours)) { + tile.type = "grass"; + cleared++; + } else if ( + OBSTACLES.has(terrain) ? !OBSTACLES.has(ours) : terrain !== ours + ) { + // Which forest stands on a tile is not ours to change, so two kinds + // of obstacle are not a disagreement — only obstacle against open + // ground, or land against water, is. + mismatched++; + } + } + } + + return { + grid, + placements, + cleared, + mismatched, + blockedBuildings, + unknownBuildings, + }; +} + +/** Resolves a decoded game export. See the module header for the rules. */ +export async function resolveGameExport( + exp: GameExport, +): Promise { + const buildingUpgrades: Record = {}; + const prestigeLevels: Record = {}; + + for (const [researchId, level] of Object.entries(exp.research)) { + const buildingId = buildingForResearch(researchId); + if (buildingId) { + const def = findBuilding(buildingId)!; + const index = levelToIndex(level, def.levels.length); + if (index !== undefined) buildingUpgrades[buildingId] = index; + continue; + } + const upgrade = PRESTIGE_UPGRADES.find((u) => u.id === researchId); + if (upgrade) { + const index = levelToIndex(level, upgrade.bonuses.length); + if (index !== undefined) prestigeLevels[upgrade.id] = index; + } + } + + const inProgress: ImportedProgress[] = []; + for (const p of exp.researchInProgress) { + const buildingId = buildingForResearch(p.id); + if (buildingId) { + const index = levelToIndex( + p.level, + findBuilding(buildingId)!.levels.length, + ); + if (index !== undefined) { + inProgress.push({ + kind: "building", + id: buildingId, + level: index, + secondsLeft: p.secondsLeft, + }); + } + continue; + } + const upgrade = PRESTIGE_UPGRADES.find((u) => u.id === p.id); + if (upgrade) { + const index = levelToIndex(p.level, upgrade.bonuses.length); + if (index !== undefined) { + inProgress.push({ + kind: "research", + id: upgrade.id, + level: index, + secondsLeft: p.secondsLeft, + }); + } + } + } + inProgress.sort((a, b) => a.secondsLeft - b.secondsLeft); + + const known = ANOMALIES.some((a) => a.id === exp.anomalyId); + + const islands: ImportedIsland[] = []; + const lockedTemplateIds: string[] = []; + for (const template of ISLAND_TEMPLATES) { + if (!template.game) continue; + const area = exp.islands.find((i) => i.areaId === template.game!.areaId); + if (!area) continue; + if (!area.unlocked) { + lockedTemplateIds.push(template.id); + continue; + } + islands.push({ + templateId: template.id, + name: template.name, + ...(await resolveIsland(template.code, template.game, area)), + }); + } + + return { + gameVersion: exp.gameVersion, + buildingUpgrades, + prestigeLevels, + anomalyId: known ? exp.anomalyId : DEFAULT_ANOMALY_ID, + unknownAnomaly: known ? null : exp.anomalyId, + inProgress, + islands, + lockedTemplateIds, + }; +} + +/** + * What to do with research still running in the game. + * + * - `finished`: count it as done — the roster and Time Lab take its level. + * - `plan`: make the building upgrades among it the upgrade plan, in the order + * they finish, so a solve keeps working through each of them. + * - `ignore`: import today's levels only. + */ +export type InProgressMode = "finished" | "plan" | "ignore"; + +export interface GameImportState { + buildingUpgrades: Record; + prestigeLevels: Record; + /** The new upgrade plan, or null to leave the player's own alone. */ + upgradePlan: UpgradeStep[] | null; +} + +/** + * Applies the chosen handling of research in progress. + * + * A plan can only hold tiers of a building that is already unlocked — + * `resolveUpgradePlan` would ignore a step for a locked one — so under `plan` + * an unlock still in progress is left out rather than added as a step that + * never counts, and so is Time Lab research, which a plan does not hold. The + * import dialog says so beside the choice. + */ +export function applyInProgress( + resolved: ResolvedGameImport, + mode: InProgressMode, +): GameImportState { + const buildingUpgrades = { ...resolved.buildingUpgrades }; + const prestigeLevels = { ...resolved.prestigeLevels }; + + if (mode === "finished") { + for (const p of resolved.inProgress) { + const into = p.kind === "building" ? buildingUpgrades : prestigeLevels; + into[p.id] = Math.max(into[p.id] ?? -1, p.level); + } + return { buildingUpgrades, prestigeLevels, upgradePlan: null }; + } + + if (mode === "plan") { + const upgradePlan = plannableProgress(resolved) + .slice(0, MAX_UPGRADE_STEPS) + .map((p) => ({ buildingId: p.id, level: p.level })); + return { buildingUpgrades, prestigeLevels, upgradePlan }; + } + + return { buildingUpgrades, prestigeLevels, upgradePlan: null }; +} + +/** The research in progress a plan can hold: tier upgrades of unlocked buildings. */ +export function plannableProgress( + resolved: ResolvedGameImport, +): ImportedProgress[] { + return resolved.inProgress.filter( + (p) => + p.kind === "building" && + resolved.buildingUpgrades[p.id] !== undefined && + p.level > resolved.buildingUpgrades[p.id], + ); +} diff --git a/packages/solver/src/data/maps.ts b/packages/solver/src/data/maps.ts index 5e8ee64..7dbcafb 100755 --- a/packages/solver/src/data/maps.ts +++ b/packages/solver/src/data/maps.ts @@ -13,6 +13,18 @@ export interface IslandTemplate { * and any placements, so there is no separate width/height here. */ code: string; + /** + * Where this board sits in the game, for reading a code from the game's + * Export to Optimizer button (`data/gameImport.ts`). Shipped islands only. + * + * The game has one world grid and every island is a region of it, named by + * an area id. Our boards are those regions trimmed and turned: board tile + * `(x, y)` is the game's grid cell `(originX - y, originY - x)` — rows run + * along the game's x axis, columns along its y axis, both reversed. Measured + * against the 1.1.10 scene, where it matches every land cell of all eight + * islands; the same transform holds for all of them. + */ + game?: { areaId: string; originX: number; originY: number }; } /** @@ -30,41 +42,49 @@ export const ISLAND_TEMPLATES: IslandTemplate[] = [ id: "island1", name: "Gale Hills", code: "eJxtjDsOwDAMQo0hndL7X7eKQcrSN4H4vLsGqisQWIC1hMMzpmHGRINFziaRbiRfrPQyY-dB_q8_2tsPPc0Aow", + game: { areaId: "area_1", originX: 7, originY: 4 }, }, { id: "island2", name: "Ash Bay", code: "eJx1jkEOgDAIBDsseDT-_7WmVBqMcU8MJMOe18gwWszHTz4HeBNtBOtAaLqlRVL6PGBuPNU5BeVx5GyisqTHY60fhqNWVavsDVOPAMo", + game: { areaId: "area_2", originX: 17, originY: -12 }, }, { id: "island3", name: "Magma Rift", code: "eJxNjkkSwEAIAm1lckz-_9uUOhu3Fgr5XksFIJtSODA2U4q4CXgyehCWLa98oQtim-VrCDx_XnX59UKZH9LsMnWS8B4dvXkNFX3_AW4kAOo", + game: { areaId: "area_3", originX: -4, originY: -6 }, }, { id: "island4", name: "Core Island", code: "eJyVkFEKwzAMQ_1sZSco7P43HYm9Jt1KafURgpAl2dvbBsIeQT7_HgDdQ8WEBsViCiS5TEJzEpoy1J82p9kRlWNeAUNKd2wSRKSo1nHDokbX8lxwB_KVkdlkVoxeTqvQ5EK4HRE_fl-DvNrJVf-pW9hzP3STAU0", + game: { areaId: "area_4", originX: 6, originY: -24 }, }, { id: "island5", name: "Mirror Expanse", - code: "eJxtkVESgCAIRFkWOkFf3f-gTQmiBV-Ob3kwep1SBSOkK1PC0DAAIADLC0bKkHXMLLNj1jri1QzbKhTycTlpPoZVC6gherrcOJHHtQbVQmn_L7HIN0QRrX303aN9pu0ldkLDN1sL7cSca66OCmhrj1j3P_PAP0w0pDftPwF3", + code: "eNptkUESwCAIAwmBvqCn_v-hHVsUcchNAksGn1tSMEI6mRKGxgMAArBZYHQZpq7VyzmxtK_4MD9tBwo5WE6a_8tyBNQAjSk3LsujrOEqS2Tbg6ST8GJRRDOPfjnaM5VLVIeGszcDHcd2tgxRQHeCHI_uf-rxWwX0BezxAXc", + game: { areaId: "area_5", originX: -18, originY: -14 }, }, { id: "island6", name: "Event Horizon", code: "eJyFkVESAyEIQ3kQe4b2_hftgOu6OnaazwdBgu-PXYKwo_Az_6PN1cfH3iKIMCPa5oZ8F-CJIwFKGwtVFRreC7iSS42p5GGqZcRaKC3wMf8HFuEdsRwvZmvuaiP3vU2NzMCjWtSv3HPQqzKpr7AeM69w-q8MNrhj06XZroNvV-_xc6840U1fE50Bjw", + game: { areaId: "area_6", originX: -8, originY: -41 }, }, { id: "island7", name: "Entropy Isles", code: "eJx1kEEOwzAIBHfN0i_k0P__syI2tokbHxKJgRHs90I-0uTiB8cjSYsPa3kgngiAPEk7kCyZumYJN6Wy3tJvw2mJwuPEPuehZINoUx1LTIcArZXytqiEVVaQ9DiMg9hab-u_Z_xEmGFkGqOH_YaS4ZbYCsr7vZrqbazdxSQNUBQt_iVf0CLM3ph3JhZK68t77zD_xx5J_AClRwHO", + game: { areaId: "area_7", originX: -40, originY: -35 }, }, { id: "island8", name: "Shadowspire", code: "eJytkl0OwCAIgy2UXWL3v-ciDPzL3kZionxqoXrfbQTb30F8ABCA7IJAHxE8qwFg2pnqoQNQ4qC29mL2ff2AGh2N_T1rAFNtukowhacsmVVesqSaAKJF8I6m8OKYh8DE7rZNOp6UKmVSYq4Lre24XTG91vLUSTpJGHSAuWtlkLXxZByEOJ40fTf3_sBWzR_PltdueeaaOwlDg3381tJ_ABV7Ago", + game: { areaId: "area_8", originX: -29, originY: -62 }, }, ]; diff --git a/packages/solver/src/encoding/blueprint.ts b/packages/solver/src/encoding/blueprint.ts index 0eb8dad..964a1e1 100755 --- a/packages/solver/src/encoding/blueprint.ts +++ b/packages/solver/src/encoding/blueprint.ts @@ -10,6 +10,7 @@ import type { Tile, TileType, } from "../solver/types"; +import { base64UrlToUint8Array, pipeThrough } from "./streams"; /** * Binary layout codec — terrain *and* buildings in one payload. @@ -280,24 +281,6 @@ export interface BlueprintRules { // --- Browser Compression & Base64URL Helpers --- -/** - * Runs `bytes` through a transform stream and collects the result. - * - * Note the shape: the payload is piped in from a Blob and the whole stream is - * handed to `Response`, so reading and writing are driven together. Grabbing a - * writer and awaiting `write()`/`close()` before starting to read — the - * obvious-looking version — deadlocks: `close()` cannot settle until the - * readable side is drained, and nothing is draining it yet. - */ -async function pipeThrough( - bytes: Uint8Array, - transform: CompressionStream | DecompressionStream, -): Promise> { - const stream = new Blob([bytes]).stream().pipeThrough(transform); - const buffer = await new Response(stream).arrayBuffer(); - return new Uint8Array(buffer); -} - function compress( bytes: Uint8Array, ): Promise> { @@ -321,19 +304,6 @@ function uint8ArrayToBase64Url(bytes: Uint8Array): string { .replace(/=+$/, ""); } -function base64UrlToUint8Array(code: string): Uint8Array { - let base64 = code.replace(/-/g, "+").replace(/_/g, "/"); - while (base64.length % 4 !== 0) { - base64 += "="; - } - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - // ========================================== // UNIFIED ENCODE & DECODE // ========================================== diff --git a/packages/solver/src/encoding/gameExport.ts b/packages/solver/src/encoding/gameExport.ts new file mode 100644 index 0000000..a2d8c16 --- /dev/null +++ b/packages/solver/src/encoding/gameExport.ts @@ -0,0 +1,257 @@ +import { base64UrlToUint8Array, pipeThrough } from "./streams"; + +/** + * Reader for the code the game's **Export to Optimizer** button writes. + * + * This is the game's format, not ours: it was specified for the game's + * developer (`docs/game-export.md`) and is written by the game with a + * .NET `BinaryWriter`. That is why it differs from a blueprint in the three + * places a blueprint would have been easier to read: + * + * - **Ids are strings, through a palette.** The code opens with a list of the + * terrain and building ids it uses and each cell is one byte indexing into + * it, so the game keeps no fixed byte table and we are handed the game's own + * ids — which `data/gameImport.ts` maps onto ours. + * - **Raw deflate**, which is what `DeflateStream` writes; a blueprint is + * zlib-wrapped. + * - **Grid coordinates are the game's.** Every island is one box cut out of the + * game's single world grid, in that grid's cell coordinates. Turning that + * into one of our boards is `gameImport.ts`'s job too; this file only reads + * the bytes. + * + * Wire format, after the `R2X.` prefix and base64url + raw inflate: + * + * 1. header string game version · string anomaly id · byte balance + * 2. palette byte n · n × string id + * 3. islands byte n · per island: string area id · byte flags (bit 0 = + * unlocked) · int16 minX · int16 minY · byte width · byte height + * · width × height palette indexes, row y = minY first + * 4. research uint16 n · n × (string id · uint16 level) + * 5. ongoing uint16 n · n × (string id · uint16 target level · int32 secs) + * 6. clearing uint16 n · n × (int16 x · int16 y · int32 secs) + * + * Integers are little-endian; a string is a 7-bit-encoded length followed by + * UTF-8. A section appended after these is ignored rather than refused, so the + * game can grow the format without a version bump; anything else is a new + * version, and an unknown version is refused rather than guessed at. + */ + +/** The only format version this build reads. */ +export const GAME_EXPORT_VERSION = 1; + +const PREFIX = /^R2X(\d+)\./; + +/** One island as the game wrote it: a box of the world grid, cell ids resolved. */ +export interface GameExportIsland { + /** The game's area id, `area_1` … `area_8`. */ + areaId: string; + unlocked: boolean; + minX: number; + minY: number; + width: number; + height: number; + /** + * The palette id on each cell, row-major from `(minX, minY)`: a terrain id + * (`TileGrass`, `Map_Rock`, …) or a building id. Cells outside the area are + * `TileWater`. + */ + cells: string[]; +} + +export interface GameExportProgress { + id: string; + /** The level the research will reach — levels purchased, not an index. */ + level: number; + secondsLeft: number; +} + +export interface GameExportClearing { + x: number; + y: number; + secondsLeft: number; +} + +export interface GameExport { + version: number; + gameVersion: string; + /** The game's anomaly id, `"none"` when the timeline has none. */ + anomalyId: string; + anomalyBalance: number; + /** The ids the cell bytes index into, terrain first. */ + palette: string[]; + islands: GameExportIsland[]; + /** Every research node, by the game's id, at its level (0 = not bought). */ + research: Record; + researchInProgress: GameExportProgress[]; + clearing: GameExportClearing[]; +} + +/** + * Whether `text` is a game export rather than a share code — the prefix alone, + * so the import dialog can route a paste before trying to decode it. + */ +export function isGameExportCode(text: string): boolean { + return PREFIX.test(text.trim()); +} + +/** Sequential reader over the payload, failing loudly past the end. */ +class Reader { + #view: DataView; + #bytes: Uint8Array; + #at = 0; + #text = new TextDecoder("utf-8", { fatal: true }); + + constructor(bytes: Uint8Array) { + this.#bytes = bytes; + this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + } + + #need(n: number) { + if (this.#at + n > this.#bytes.length) { + throw new Error("The code ends early — was it cut off when copied?"); + } + } + + u8(): number { + this.#need(1); + return this.#bytes[this.#at++]; + } + + i16(): number { + this.#need(2); + const v = this.#view.getInt16(this.#at, true); + this.#at += 2; + return v; + } + + u16(): number { + this.#need(2); + const v = this.#view.getUint16(this.#at, true); + this.#at += 2; + return v; + } + + i32(): number { + this.#need(4); + const v = this.#view.getInt32(this.#at, true); + this.#at += 4; + return v; + } + + /** `BinaryWriter.Write(string)`: a 7-bit-encoded length, then UTF-8. */ + str(): string { + let length = 0; + for (let shift = 0; ; shift += 7) { + if (shift > 28) throw new Error("The code has a malformed string."); + const b = this.u8(); + length |= (b & 0x7f) << shift; + if (b < 0x80) break; + } + this.#need(length); + const s = this.#text.decode( + this.#bytes.subarray(this.#at, this.#at + length), + ); + this.#at += length; + return s; + } + + bytes(n: number): Uint8Array { + this.#need(n); + const out = this.#bytes.subarray(this.#at, this.#at + n); + this.#at += n; + return out; + } +} + +/** + * Reads a game export. Throws with a message meant to be shown as-is. + * + * Anything outside the base64url alphabet is dropped from the body before + * decoding: a code pasted out of a chat app arrives wrapped in line breaks and + * spaces, and the samples the game produced picked up a stray character at the + * end. A body damaged in the middle still fails, at the inflate. + */ +export async function decodeGameExport(text: string): Promise { + const trimmed = text.trim(); + const match = PREFIX.exec(trimmed); + if (!match) throw new Error("That is not a code from the game."); + + const version = Number(match[1]); + if (version !== GAME_EXPORT_VERSION) { + throw new Error( + `That code is format ${version}; this version of the optimizer reads format ${GAME_EXPORT_VERSION}. Reload the page to update.`, + ); + } + + const body = trimmed.slice(match[0].length).replace(/[^A-Za-z0-9_-]/g, ""); + let bytes: Uint8Array; + try { + bytes = await pipeThrough( + base64UrlToUint8Array(body), + new DecompressionStream("deflate-raw"), + ); + } catch { + throw new Error( + "That code could not be read — it may have been cut off when copied.", + ); + } + + const r = new Reader(bytes); + + const gameVersion = r.str(); + const anomalyId = r.str(); + const anomalyBalance = r.u8(); + + const palette: string[] = []; + for (let n = r.u8(); n > 0; n--) palette.push(r.str()); + + const islands: GameExportIsland[] = []; + for (let n = r.u8(); n > 0; n--) { + const areaId = r.str(); + const unlocked = (r.u8() & 1) === 1; + const minX = r.i16(); + const minY = r.i16(); + const width = r.u8(); + const height = r.u8(); + const cells = Array.from(r.bytes(width * height), (i) => { + const id = palette[i]; + if (id === undefined) { + throw new Error("The code points at a tile type it never listed."); + } + return id; + }); + islands.push({ areaId, unlocked, minX, minY, width, height, cells }); + } + + const research: Record = {}; + for (let n = r.u16(); n > 0; n--) { + const id = r.str(); + research[id] = r.u16(); + } + + const researchInProgress: GameExportProgress[] = []; + for (let n = r.u16(); n > 0; n--) { + researchInProgress.push({ + id: r.str(), + level: r.u16(), + secondsLeft: r.i32(), + }); + } + + const clearing: GameExportClearing[] = []; + for (let n = r.u16(); n > 0; n--) { + clearing.push({ x: r.i16(), y: r.i16(), secondsLeft: r.i32() }); + } + + return { + version, + gameVersion, + anomalyId, + anomalyBalance, + palette, + islands, + research, + researchInProgress, + clearing, + }; +} diff --git a/packages/solver/src/encoding/streams.ts b/packages/solver/src/encoding/streams.ts new file mode 100644 index 0000000..b831753 --- /dev/null +++ b/packages/solver/src/encoding/streams.ts @@ -0,0 +1,37 @@ +/** + * The byte plumbing both wire formats share: `blueprint.ts` (the optimizer's + * own layout codes) and `gameExport.ts` (the code the game's Export to + * Optimizer button writes). Not re-exported from the package entry point — it + * is an implementation detail of the two codecs, not part of either format. + */ + +/** + * Runs `bytes` through a transform stream and collects the result. + * + * Note the shape: the payload is piped in from a Blob and the whole stream is + * handed to `Response`, so reading and writing are driven together. Grabbing a + * writer and awaiting `write()`/`close()` before starting to read — the + * obvious-looking version — deadlocks: `close()` cannot settle until the + * readable side is drained, and nothing is draining it yet. + */ +export async function pipeThrough( + bytes: Uint8Array, + transform: CompressionStream | DecompressionStream, +): Promise> { + const stream = new Blob([bytes]).stream().pipeThrough(transform); + const buffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(buffer); +} + +export function base64UrlToUint8Array(code: string): Uint8Array { + let base64 = code.replace(/-/g, "+").replace(/_/g, "/"); + while (base64.length % 4 !== 0) { + base64 += "="; + } + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/packages/solver/src/index.ts b/packages/solver/src/index.ts index a543793..013d414 100644 --- a/packages/solver/src/index.ts +++ b/packages/solver/src/index.ts @@ -31,11 +31,13 @@ export * from "./solver/solver"; export * from "./data/anomalies"; export * from "./data/buildings"; export * from "./data/effectiveBuildings"; +export * from "./data/gameImport"; export * from "./data/maps"; export * from "./data/prestige"; export * from "./data/upgradePlan"; export * from "./encoding/blueprint"; +export * from "./encoding/gameExport"; export * from "./utils/formatters"; export * from "./grid"; diff --git a/packages/solver/tests/gameExport.test.ts b/packages/solver/tests/gameExport.test.ts new file mode 100644 index 0000000..0cd9187 --- /dev/null +++ b/packages/solver/tests/gameExport.test.ts @@ -0,0 +1,318 @@ +/** + * Reading a code from the game's Export to Optimizer button, and turning it into + * the optimizer's roster, Time Lab, anomaly, plan and boards. + * + * The three fixtures in `gameExports/` are real exports the game produced from + * three saves: city level 23 in a first timeline (five islands, early + * buildings, most obstacles still standing), city level 34 in a third timeline + * (Cryo Nexus, Time Lab research, two islands still locked) and city level 47 + * in a first timeline (everything unlocked, one research running). They are the only evidence of what the game + * actually writes, which is why most of what is pinned here is pinned against + * them rather than against codes built in the test. + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateRawSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { + applyInProgress, + plannableProgress, + resolveGameExport, +} from "../src/data/gameImport"; +import { ISLAND_TEMPLATES } from "../src/data/maps"; +import { decodeGameExport, isGameExportCode } from "../src/encoding/gameExport"; + +const DIR = join(dirname(fileURLToPath(import.meta.url)), "gameExports"); +const city23 = readFileSync(join(DIR, "city23-timeline1.txt"), "utf8"); +const city34 = readFileSync(join(DIR, "city34-timeline3.txt"), "utf8"); +const city47 = readFileSync(join(DIR, "city47-timeline1.txt"), "utf8"); + +/** A `BinaryWriter` in miniature, for codes the game has not written. */ +class Writer { + bytes: number[] = []; + u8(v: number) { + this.bytes.push(v & 0xff); + return this; + } + i16(v: number) { + const b = Buffer.alloc(2); + b.writeInt16LE(v); + this.bytes.push(...b); + return this; + } + u16(v: number) { + const b = Buffer.alloc(2); + b.writeUInt16LE(v); + this.bytes.push(...b); + return this; + } + i32(v: number) { + const b = Buffer.alloc(4); + b.writeInt32LE(v); + this.bytes.push(...b); + return this; + } + str(s: string) { + const utf8 = Buffer.from(s, "utf8"); + let n = utf8.length; + while (n >= 0x80) { + this.u8((n & 0x7f) | 0x80); + n >>= 7; + } + this.u8(n); + this.bytes.push(...utf8); + return this; + } + code(version = 1) { + const body = deflateRawSync(Buffer.from(this.bytes)).toString("base64url"); + return `R2X${version}.${body}`; + } +} + +/** The smallest valid export: one 2x1 island and nothing else. */ +function tinyExport(): Writer { + return new Writer() + .str("1.1.10") + .str("none") + .u8(0) + .u8(2) + .str("TileWater") + .str("TileGrass") + .u8(1) + .str("area_1") + .u8(1) + .i16(-3) + .i16(4) + .u8(2) + .u8(1) + .u8(1) + .u8(0) + .u16(0) + .u16(0) + .u16(0); +} + +describe("decodeGameExport", () => { + it("recognises a game code by its prefix", () => { + expect(isGameExportCode(city34)).toBe(true); + expect(isGameExportCode(" R2X2.abc")).toBe(true); + expect(isGameExportCode(ISLAND_TEMPLATES[0].code)).toBe(false); + }); + + it("reads the game's own export", async () => { + const exp = await decodeGameExport(city34); + expect(exp.version).toBe(1); + expect(exp.gameVersion).toBe("1.1.10"); + expect(exp.anomalyId).toBe("cryo_nexus"); + expect(exp.palette.slice(0, 7)).toEqual([ + "TileWater", + "TileGrass", + "Map_Rock", + "Map_Forest_Coniferous", + "Map_Forest_Deciduous", + "Map_Pond", + "CantBuildHere", + ]); + expect(exp.islands.map((i) => i.areaId)).toEqual([ + "area_1", + "area_2", + "area_3", + "area_4", + "area_5", + "area_6", + "area_7", + "area_8", + ]); + expect(exp.islands.map((i) => i.unlocked)).toEqual([ + true, + true, + true, + true, + true, + true, + false, + false, + ]); + expect(Object.keys(exp.research)).toHaveLength(79); + // Ids are the game's, verbatim — including the trailing space two of them + // shipped with. + expect(exp.research["DepositsSlotsResearchSO "]).toBe(4); + expect(exp.researchInProgress).toEqual([]); + }); + + it("reads research in progress", async () => { + const exp = await decodeGameExport(city47); + expect(exp.researchInProgress).toEqual([ + { id: "research_building_doomStar_reactor", level: 3, secondsLeft: 0 }, + ]); + }); + + it("ignores line breaks and stray characters around the body", async () => { + // The first sample arrived with a stray `ś` after the code. + const messy = ` ${city34.slice(0, 60)}\n${city34.slice(60).trim()}ś \n`; + const exp = await decodeGameExport(messy); + expect(exp.anomalyId).toBe("cryo_nexus"); + }); + + it("refuses a format it does not know", async () => { + await expect(decodeGameExport(tinyExport().code(2))).rejects.toThrow( + /format 2/, + ); + }); + + it("refuses a code that was cut off", async () => { + await expect(decodeGameExport(city34.slice(0, 1200))).rejects.toThrow(); + }); + + it("ignores a section appended after the six it knows", async () => { + const w = tinyExport().str("a later section"); + const exp = await decodeGameExport(w.code()); + expect(exp.islands[0]).toMatchObject({ + areaId: "area_1", + minX: -3, + minY: 4, + cells: ["TileGrass", "TileWater"], + }); + }); +}); + +describe("resolveGameExport", () => { + it("maps research levels to tier indexes", async () => { + const r = await resolveGameExport(await decodeGameExport(city34)); + // Level 9 bought of nine: the top tier, index 8. + expect(r.buildingUpgrades.cooler5).toBe(8); + // Level 1 is the unlock, which is tier 1. + expect(r.buildingUpgrades.cooler6).toBe(0); + // Level 0 is locked: no key. + expect(r.buildingUpgrades).not.toHaveProperty("chaos_core"); + // The one research id the game spells without `building_`. + expect(r.buildingUpgrades.gas_plant).toBe(5); + expect(r.prestigeLevels).toEqual({ + absolute_zero: 4, + infinite_grid: 4, + stellar_forge: 5, + }); + expect(r.anomalyId).toBe("cryo_nexus"); + expect(r.unknownAnomaly).toBeNull(); + }); + + it("leaves locked islands out", async () => { + const r = await resolveGameExport(await decodeGameExport(city34)); + expect(r.islands.map((i) => i.templateId)).toEqual([ + "island1", + "island2", + "island3", + "island4", + "island5", + "island6", + ]); + expect(r.lockedTemplateIds).toEqual(["island7", "island8"]); + }); + + /* + * The alignment test: every land cell of the game's map has to land on the + * matching tile of ours. A wrong origin or a wrong turn would show here as + * hundreds of mismatches. The first exports caught one real error this way: + * island 5 had a tree one tile from where the game has it. + */ + it("lines every island up with our map", async () => { + for (const code of [city23, city34, city47]) { + const r = await resolveGameExport(await decodeGameExport(code)); + for (const island of r.islands) { + expect(island.mismatched, island.templateId).toBe(0); + expect(island.unknownBuildings, island.templateId).toBe(0); + expect(island.blockedBuildings, island.templateId).toBe(0); + } + } + }); + + it("places every building and clears the obstacles under them", async () => { + const exp = await decodeGameExport(city34); + const r = await resolveGameExport(exp); + const area = exp.islands[0]; + const built = area.cells.filter( + (c) => + !c.startsWith("Tile") && !c.startsWith("Map_") && c !== "CantBuildHere", + ); + const island1 = r.islands[0]; + expect(island1.placements).toHaveLength(built.length); + for (const p of island1.placements) { + expect(island1.grid[p.y][p.x].type).toBe("grass"); + } + expect(island1.cleared).toBeGreaterThan(0); + }); + + it("reports an anomaly it does not know rather than guessing", async () => { + const w = new Writer() + .str("9.9.9") + .str("some_future_anomaly") + .u8(0) + .u8(0) + .u8(0) + .u16(0) + .u16(0) + .u16(0); + const r = await resolveGameExport(await decodeGameExport(w.code())); + expect(r.anomalyId).toBe("none"); + expect(r.unknownAnomaly).toBe("some_future_anomaly"); + }); +}); + +describe("research in progress", () => { + it("is a building upgrade the plan can hold", async () => { + const r = await resolveGameExport(await decodeGameExport(city47)); + expect(r.buildingUpgrades.doomStar_reactor).toBe(1); + expect(r.inProgress).toEqual([ + { kind: "building", id: "doomStar_reactor", level: 2, secondsLeft: 0 }, + ]); + expect(plannableProgress(r)).toHaveLength(1); + }); + + it("counts as bought when imported as finished", async () => { + const r = await resolveGameExport(await decodeGameExport(city47)); + const s = applyInProgress(r, "finished"); + expect(s.buildingUpgrades.doomStar_reactor).toBe(2); + expect(s.upgradePlan).toBeNull(); + // The resolved import itself is not changed. + expect(r.buildingUpgrades.doomStar_reactor).toBe(1); + }); + + it("becomes the upgrade plan when imported as a plan", async () => { + const r = await resolveGameExport(await decodeGameExport(city47)); + const s = applyInProgress(r, "plan"); + expect(s.buildingUpgrades.doomStar_reactor).toBe(1); + expect(s.upgradePlan).toEqual([ + { buildingId: "doomStar_reactor", level: 2 }, + ]); + }); + + it("leaves an unlock out of the plan, since a locked building cannot be planned", async () => { + const r = await resolveGameExport(await decodeGameExport(city47)); + const unlocking = { + ...r, + inProgress: [ + { + kind: "building" as const, + id: "ecto_compressor", + level: 0, + secondsLeft: 60, + }, + ...r.inProgress, + ], + }; + expect(plannableProgress(unlocking).map((p) => p.id)).toEqual([ + "doomStar_reactor", + ]); + expect( + applyInProgress(unlocking, "finished").buildingUpgrades.ecto_compressor, + ).toBe(0); + }); + + it("changes nothing when ignored", async () => { + const r = await resolveGameExport(await decodeGameExport(city47)); + const s = applyInProgress(r, "ignore"); + expect(s.buildingUpgrades).toEqual(r.buildingUpgrades); + expect(s.upgradePlan).toBeNull(); + }); +}); diff --git a/packages/solver/tests/gameExports/city23-timeline1.txt b/packages/solver/tests/gameExports/city23-timeline1.txt new file mode 100644 index 0000000..c4788dc --- /dev/null +++ b/packages/solver/tests/gameExports/city23-timeline1.txt @@ -0,0 +1 @@ +R2X1.lVhLc9xEEJ7RY3e98Toh5ZST2HkBBScoEjuPa3ASyCHlYKfgqJKlWe-UtRoxkpyYX8TPoODED-BAceLEhSo4cqBqac1oNCNpdhNm_VD319Pvaa00uPspfD7zUpYSdHHtFU3IN2FBuLj6god5PnoRZsEhi06vVBfPGCd5EeyzlE4JZ2W-aXCfkIjGJTDFnpcsjSf7YVp8XtIk_pJwcgkIOg8L0B9kCRAXQh4FnIRRwfgwYiwh_G79f298QlLCQ0Du6ctdfbm3_pqmcVCU_JimZDQIQU9wF_-7-GexPkGwJiOk1mh9PJrIy8m6IAaCPZbESEgDMamIDUFMBLEGQoIAMbFDiK3DFVaEsgEE6i9lViwpID29hx30x-LSRg2tbfS2LltgX3iglIJv1cdExUdyRIj1b4PXjIpCLXRc5ar-KAMDqX9dxCyzObpgeoCQQNtxN7tFtLv4r8Wfi42LCMMyxLCgGxaWa9CiJDoZanIyNig8nIwNagKkptB4ONGK2lZalOlSHWfXUaQSYPLr-Pbw34tfFlubrRy05Dqa3ra0tfbevpRFoCeGTff7_CYwtd3MiKLsScQddpffZQ_HQ_jRjtTs8QTWGDcuNqUdwk-rOJptRjLQSnoRCpPdRHUbUXO1TlHZ-_i3xa-LzevA8PryS5fb095JiOS5LaYj_nqt3GFh1eswWyrVeWkzsSHYZr4Lr2I6Xk_Qwz7ubcau1-M5va0rXLF54ni2fPVZsHzsuS1G5Y777tXyrI1gE0SqKx6g3xc_LK5e7Qi4lk3VPqeluvGub8Rmdcm4cJ23CFgmDfaRTqJjCrqqwfxau-A7NRP-d9JZA24NYKcF-I2TnR1mgjzPqLzoNfHHw67rNHwspZA4UT42Vx2562Lc5QvDDcP1Xex0PXGE962oKsexJ_jtYAeeK3yvfGmVEuq4pOSrlrFFdtND9OPip8XWNpIhuY51lyuz10U9mR1bE9f6lhBLliFTO-poj7HhAGTLrQEYCl5ddVGzus7iHFa5lv9k1pWmKqNu03bde37TASpGUN_Iqj4y2qRuXhc1powWclFnxshG8gwd2oxqIOzp4-M1bCRaQeXHl5fiNHt-w1bpkqdi1f3efkPHvjkwl0sLfxzPxq5Ohb2NVMvL1nuEfl58v9gWref1ZLSVt3SO9RR4HczQL8-7TGXLzU7xfDvmdBTKZtTFQ828El9cPKznl-qHhtK3VN1Yypqn_MctzMf6e1bLIaSGrhyf8vau1BvzGJrOFzc4EYWnK-s4wj9fGXNVeoxWd82cNoAZfZ2dVibNPBpIZ3659Yz1OqlHdeODHQf3v0sCporj9RpX20LdNRBlg1QsnaLu_5-vbfMHaFs8f-5zEtPi8ZyVaXFIcgJPnrOjA4RuC5SmJy85jcghicuooCzVIgO0o0SOSJIckmmZxiZ8R8H5PsuLF2VS0CyhhGsZD723H-az_TDTvDV0bZ8W50_IPEzj2rbCfLRRYZqxha5XjKOSZ0mZd6QR2oHH8YLDU_SLkILnr0Mem_CWgo8SVuQacdEthbyic1vsCF1_QjKW0yJ_nsJTOzzkm-BVBXYU3wbNClqq-cozTshRRgAz0jJEt57niU5Jb-NthG4cHOdFGCUAz9lZmEgTOtnXDqbThKbkacjTqiwaw-jGVyWNTqtq2AqF0LbADzM7-rEiVMmXROejjxTxNGIpm58vFdxWxEtG0yJv9cgI3Wyjz9OImbGuoU8U0VHDqkpZsr7zNY0JexzXiW-nzkeT8DhnSVmQ4DvCGUKbFUDPSB4ck3NWvW6BDQhdjmYcwgrCKCKJfB2D0EXJTNULHYQuAZSTOJhyNq93Tmg6pSkFAyecxkBnMxAJjmHPKam2fFuGaVHOA_ImmoXpSWVMsTiBgkTS2Pu89jo4rksRdF8uDdANi5B-4TRAt_r4MWXzMM-Vhg8tEkkYnQYzlhAd6E5fDLxneRAxTuwwCxNl5JoNFu_CVmD3VmC7K7C90XLsPlqOPViBPVyBPYIR28diegYHVCfQUs6YsflREXItdKcvRAABQ_MMoLySudmXmSblG63E4sy0zKlu2wHa7os0bx0HtmLqN5Wr4d3RSnhv9e771kZq4Aer4YfWJJ8QVswIn-tmtCT5hIdntICzLWWQ7VTMSEK7qizHb3Yec6Zwiz-z8wyOb56FEanPjsVWSkpQUs2Plb2RlnCL0P0zQB_0ZaBvopJD5wTZOQ_n1USyDIWs6g8aBQV7Tey29IhaPhHysKxySM4hKktqYO6Cs1mYksSauoLGKrXWNjffhvu2tFUTPciq24N283IjdhI2Q2-SF_BFB5yZMi4GsNhZzW_IFytYxBL53eo_ diff --git a/packages/solver/tests/gameExports/city34-timeline3.txt b/packages/solver/tests/gameExports/city34-timeline3.txt new file mode 100644 index 0000000..9922531 --- /dev/null +++ b/packages/solver/tests/gameExports/city34-timeline3.txt @@ -0,0 +1 @@ +R2X1.lVhLc9s2EAZI6mVZlptxJg87z3baUztNYju5pk7S5pBJamfaI4cmIQljilBB0on7i_ozOu2pP6CHTk899dKZ9thDZ9QFQJAgCSktlEjEfh92F7uLtYTuvU_g9elGyC-Yn5C3eYq2B69pTL4OMsLl0-c8SNP-i2DhH7Pw7LJ4eMY4STP_iCV0QjjL0x1D-oSENMpBKNe8Ykk0OgqS7LOcxtEXhJNeyFhM-EHxebgV0XOaEJ-TIMwY35iShPAAnvarx4PxlAfnNOMs8RcxaNueXSwI99NFEBI_BMPjb3IQ53Ot5tK34Ji_YDTJtKjfDeDJv4f_Wf693BwhGP0-0mMwGOjZcDCE5363EEukX8gl0u9pYKCeNVKtGMBTiRg2UHv0euZEvitP72MH_b7c3rLx1g-8ifFGNRUzDLJiuqFm4l2zNUMKxLNmSLNYMwoPsWZoA92Cof0vGDWXMEb1Uc7Vbh_gP5d_LLfGqDcajQzaaDgaDdFQi0ZqdNVsKNHRUOqC8EpsgAciV3ggUDyEFyAYCxS8EE9D-QbLAENYZAWLpZWPgjLc6pWzQS11QFNEo37EpI90ABxzo2p_-_iv5c_LKzu1GNhT2vtPqe4X_xvShqilSe2xaQFCgV0LHZKKHUMRREx-CD6uFsN0LJVsGnI8hpeE5HtJx8NKuGmI63zDYZNvlpjBN_dX8c2ddCt-Ix5Y-9-M1NgirBWyyuwB_nX5y3LnuiV860b7RIxlVOvS0XhkClV2hwXTrMKxZmKDWDK7Np3qtCNk0Vk41LAuRm88rPzU1TbWTHkMtJMFE-mzoRRKpukh0EbArKIntEmmGZqx-md6p2j1HPUkDTVGmyZ1NmkrR_NUrZLJTamqOMS_Lb9fXr3aILh2A16tY4jwuJ58atYIaglWyIQK5x0EW1fuKLFEHJPoekosGajYCXYKIXx6Ld1iuAWAnRrQKZ1srDAD5HmlGrAg1Mg3D7uuU8qxYglVoBabo9i562LclEvDpcDtuNhpeuJI72u7Eo5jT8rrm-16rvRd-FJLJeRxRcrXDWOJqqaH6Iflj8sru0htyXWsq1wVvSbqqeh4K6oAr5ysGAancNSpPMaGAxAttwA6WLjRUVLIWZFnEV1X_Y1xy0LSmkRE3bLsdA8r66GWTcnqlFxdR0aZFMXrotKUUUKuoRgrf6Xc0FGZ0QWEver4eKUYyVLQ8emoR3mavU4p1uFSp6J9yo1gN7DiO5vU215m0-Q5nk0sToW9jHTJq9J7hH5afrfclaXntTiVlXdUjvUUeA3M0K_Ouwplzc1G8jp2zGkoVMVYJQ-V_QrLZ1z1L10P5Ux_Ty5KsmbN0_7jGtbB-ru0rqtaL9V7lu2xUm_0Yyi6jsibnJcdTgCO9K-jjbk6PEapu2ZMS8DcfRGdWiTNOBpIo3-5RY_1GqFHReGDHQeXrdfEdHK8VuFWtlBzdGXaIBQru6j7__tr3fxLtCt_nR5xEtHs8ZzlSXZMUhLwcHbyEqHbEqXJ9BWnITkmUR5mlCUVpY_2NOWExPExmeRJZMJ3NJwesTR7kccZXcSU8IrTQe8dBensKFhUsk107YhmF0_IPEiiwnalc0tgleAuui4EJzlfxHnaYCO0Bz_WMw6_g18EFDx_E_DIhK9o-CRmWVohHrqlkdd0bts7QtefkAVLaZY-TzIirgBM8KoGG4pvg2YNrdR8-Rkn5GRBADPCsoFuPU_jKiSthbcRuvHyNM2CMAZ4zs6DWJnQhC669nIyiWlCngY8EWmpMIxufJnT8Exkw5YohHYlfrywox_piU75it310Yd68jRkCZtfrCTu6skrcZ2RNmrkZh19noTM3Osm-lhPGmqYyJQl6ntf0Yiwx1ER-HroemgUnKYszjPiiyuWDtoRAD0nqX9KLlgS-Rks6KJL4YzDtvwgDEmsbnE8NFbCRF_JOGgboJRE_oSzuVzZQSOaTGhCwcCU0wjmixlQ_FNYc0Z4F23rqx7yNpwFyRSWXKpufyAhoTDmoru88No_LVLhA4nOgwzKVF0jddENC4mH2r8uutXGTymbB2mqNXxgYcRBeObPWEwqRXttGnjPUnl7hawwC2Jt5JoNFtdn99Zg99dgD9Zg-_3V2MFgNXaIV2MP0WrsEbTYNla_FsS2dEaMzU-ygGsSQnfaJAIIGJovAEoF52abM4nzt5USizOTPKVV2XbRbptSXlb2bMks0fvWUijhB_218P761QfrVx9a66yEIUeWIE8Jy2aEz6titAS5cT1rPRUzEtOmKsvxm11E4uZW4RZ_mje_VlsJyUGJ6B9rayPJ4U9EVT9d9H6bA3UT5hwqx19c8GBOI2RrCgtRHzT0M_aG2G01LqitiUyDXMSQXEBHsIQG-i44uwgSEltDl9FIh9Za5m-oaM85P4WD1bGFrX1pDo28pE2DsumN0gy-6IAzE8anotvLlaJ_Q7xYxkIWq9uRfwE diff --git a/packages/solver/tests/gameExports/city47-timeline1.txt b/packages/solver/tests/gameExports/city47-timeline1.txt new file mode 100644 index 0000000..cafd138 --- /dev/null +++ b/packages/solver/tests/gameExports/city47-timeline1.txt @@ -0,0 +1 @@ +R2X1.nVhJc9xEFG7NjKclJU6clFNZ7GxAwYklq3MNTgI5pBLsFBxVstTj6bJGLVqSE_OL-BkUnPgBHChOnLhQBUcOVIle1S2pZ5LQiWe63_f29_ppZqa3PmH_PpvkJEfgVPASZ-ibuEJU7L6gcVn6z-Ii2iPJ0QW-eUIoKqtol-R4hiipy02L-gglOK0ZUci8IHm6vhvn1ec1ztIvEUUwISRDdGcjJWSxX8U0oihOKkJPz7L6tT6EhyhHNGa7HX8aM2J0y_u3-ac5vQ7Y8iHQyw9931dbyLahP5VkfoCQQ4IqXsYAQLFjryMl6yt5yRSy3ZirgqEx0lrorPHEOsi99PS2NwJ_NBtnNN_IIexerTNtbMyL0G9RyD2HmifkBMEj3GahcadleMysJxn4C0dHQkHoWwrBVCRAcLcOWLDyoeek10qLaO94fzV_NmfOimzagpC7EeokqiJMgXadewpDxalqAoUSEZSMi7sfQii5PagxLiA908zGW1V0aTOEoWe55CvnPYvIDvwkE9AplYzvrvd380tzcbOTgzFwrdHaxEnvrolTvt8lAw7p82TUp9rud-hQpn6kz_LND01BPSXNXngfeBYZQvNn2Lmw_rPJ1jKZlcJamb4iQCjhJPbudYrjS9292zZVznmdomnn-1SNDIjAJsrK3vN-a35tNq-A1re3WkPtLkd63o06RNWFkw5NEbviU5dOz2J05mElzQPmAlg044EunBWrLL2itaKyLitdaZug60nYrb0iwkHZBmmdWM6_xXI3gosR6K647_3e_NBcuuRgeKMBcRMnbiPLrDqWmQWBm4GN1T4SBOIlDPjGDAlOgAFfrYgYFYwYChr_39fULuHjyNYVtHatiQO5XdhRIdUI4yEXEfmQCvgLDJV-QYeCKt1sBXiU3DslYQIYc91MgVJuucQlpBoYdBOkyDzkbrDTQBgB3aypNIB3XlZSZDfteD82PzUXt4AMaeys-Hisp3OXPpGjfVm_D1rvTcsbbD1DG4wFuV3z2ktoX-6J4fL6197rramb7Lmpy8hjJ9WzqT0nHfr60XUO_Rj4-1gF2pL1ZtQ5upLdw4xtV7GWPLHegWzpkK33wPu5-b7ZEq3XPt_Ggyfd5A2d47wFitheplYrlANIXLjejWovMntjEazZGISBnkYDq2JemGveXmFPqQwDm9GYg1rEkxPGqOTDpn2G2qNRTlCBeJLNaNcTQY7PQAfaKjFMZnZOJjwjvfBDM8YMwNXJGTq27MFQjjK-6aZSZTIIg86YkmMUqslriwCo-eFgsLEEhGo66tFrB93O1EHjqmdIEA6bZNp76gzX_5ivXfPPwZb4VrlLUYqrhwtS59UeKlFMk_n-cwBuCBTnhy8oTtAeSuukwiQ3LCHY1iz7KMv20KzOUxu-qeFyl5TVszqrcJFhRA1PAM7txuV8Ny4M7Ry4vIurk0doEeepsq2xU-AMxwzhU3CFE_ZrWmR12eMGYJt9ya4o-0r8LMbM81cxTW34oob3M1KVBpmA6xp5iReu2AG48ggVpMRV-TRn3_XZV3cbvKTBnuIbTLOGlmq-8IQitF8ghhV2Oq8_LTOTkoHgDQCuPj8oqzjJGLwgx3EmTWgGCC4_n80ynKPHMc15WQzmgatf1Tg54tVwFQqALYHvFW70I33QJV8SXQg-1IfHCcnJ4mQp45Y-vCA4r8pOj2yAa130aZ4QO9YN8LE-9NQQXilH1re_xikiD1OV-H7q1uODkmR1haLvECUAbHIAH6MyOkAnJE-jigkAcD6ZUxZWFCcJyuQvMACclcRc_zoDwAaDSpRGM0oWSnId5zOcY2bgkOKUnYs5Y4kOmMwR4iLf1nFe1YsIvU7mcX7IjWkSRawgiTT2HlVeRweqFBFjwou4Ym0aFayDqim46mCiifZvCq4P8QNMFnFZag0fODiyODmK5iRDRtH2kI15T8ooIRS5YRJn2shlF8x__rq1Aru9AruzArvrL8fuBcux-6eWYztwOfaAjdghluJjdkFNAh3l7P_yNwI3h0yIIczQomBQyfvi2pDH_sVw6nJmVpfYtO0UbA1Z2h8ancVs0dur4Tv-SvjuamlWnVXw_dXwzsSV5ENEqjmiC9OMjiQf0vgYV-xur7gVc5ThvirH9ZufpJRo3OHP_KRg17cs4gSpu-OwlaOaKeHzw9TM4XZes0eE6Z8peH_Iw_omqSnrnKg4ofGCTyTHUCh4f-Akqsgr5LZlRtTyiVDGNc8hOkHO1LC5y5wt4hxlTrzCqU6ts81fYT6ea3rALtaaK218okcFfzwYN8-3bIdxO_TWy4p90GHOzAgVA1hI8vnN8kUqkpCMf8p6izurP6b-Bw diff --git a/src/lib/components/header/OverflowMenu.svelte b/src/lib/components/header/OverflowMenu.svelte index b4430e0..5882253 100755 --- a/src/lib/components/header/OverflowMenu.svelte +++ b/src/lib/components/header/OverflowMenu.svelte @@ -139,7 +139,7 @@ onclick={() => uiState.openImport()} > - Import a layout + Import code {/if} diff --git a/src/lib/components/modals/ImportModal.svelte b/src/lib/components/modals/ImportModal.svelte index 330c436..49d71cb 100755 --- a/src/lib/components/modals/ImportModal.svelte +++ b/src/lib/components/modals/ImportModal.svelte @@ -1,54 +1,219 @@ {#if uiState.activeModal === "import"} - uiState.closeModal()}> + uiState.closeModal()}>

- Paste a share code. It is added as a new custom island, so nothing you - have built is overwritten. + Paste a share code to add it as a new custom island, or a code from the + game's Export to Optimizer button to load your whole save.

+ aria-label="Share code or game code" + data-autofocus> - {#if atCapacity} + {#if game} +
+
+ + Game save · {game.gameVersion} +
+
+
Islands
+
+ {game.islands.map((i) => i.name).join(", ") || "none"} + {#if game.lockedTemplateIds.length > 0} + + · {game.lockedTemplateIds.length} locked, cleared + + {/if} +
+
Buildings
+
{unlockedCount} unlocked
+
Time Lab
+
{researchNames.join(", ") || "none"}
+
Anomaly
+
{getAnomaly(game.anomalyId).name}
+
+ + {#if game.inProgress.length > 0} +
+ Research in progress +
    + {#each game.inProgress as p (p.kind + p.id)} +
  • + {progressName(p.kind, p.id)} → Lv.{p.level + 1} + · {timeLeft(p.secondsLeft)} +
  • + {/each} +
+
+ {#each MODES as mode (mode.id)} + + {/each} +
+ {#if uiState.gameImportMode === "plan" && unplannable > 0} +

+ A plan only holds upgrades of buildings already unlocked, so {unplannable} + of these {unplannable === 1 ? "is" : "are"} left out. Choose "Count + as done" to include {unplannable === 1 ? "it" : "them"}. +

+ {/if} +
+ {/if} +
+ + {#if game.unknownAnomaly} +
+ + + This version does not know the anomaly "{game.unknownAnomaly}", so + none is set. Reload the page to update. + +
+ {/if} + {#if dropped > 0} +
+ + + {dropped} building{dropped === 1 ? "" : "s"} could not be placed: the + optimizer does not have {dropped === 1 ? "it" : "them"} yet, or its map + differs from the game's there. + +
+ {/if} +
+ + + Replaces your buildings, Time Lab, anomaly and the boards of all + {game.islands.length + game.lockedTemplateIds.length} islands; locked ones + are cleared. Custom islands are not touched. + +
+ {:else if atCapacity}
@@ -56,7 +221,9 @@ to import another.
- {:else if uiState.importError} + {/if} + + {#if uiState.importError}