diff --git a/plan/unit-search.md b/plan/unit-search.md index fbe1663..27b13d2 100644 --- a/plan/unit-search.md +++ b/plan/unit-search.md @@ -66,6 +66,16 @@ checked. That is why `helm-facet` depends on nothing and does no I/O. ## Done +- [x] **Read helm's split index.** helm publishes the library as a spine and a + document per group of columns, so nothing carries `sprite` on + `units.json` any more. The browser was already reading both layouts; the + API was not, and an API that loads no index reports none on + `/api/version` — which put the site back on the last prefix its own + source named, a pre-split one with no `curve.json` and 4,279 designs + rather than 8,666. Both ends read the split layout now, and the + browser's fallback to the index triggers on "the API has no library" + rather than only on a failed request. + - [x] **A unit index in the browser.** Decided and shipped: the Hangar filters client-side rather than round-tripping every keystroke. `web/src/units.ts` fetches the index helm publishes beside the database — the same data diff --git a/web/scripts/unit-index.mjs b/web/scripts/unit-index.mjs index 72efcbb..c0f4d5d 100644 --- a/web/scripts/unit-index.mjs +++ b/web/scripts/unit-index.mjs @@ -16,15 +16,41 @@ */ import { readFile } from "node:fs/promises"; -/** Design name to sprite path, or null when no index was named. */ +/** + * Design name to sprite path, or null when no index was named. + * + * helm publishes the library split: `units.json` is the spine and the + * pictures are `art.json` beside it, joined by position. An older prefix + * carries the column on the spine, so both are read - the spine having no + * sprite on any design is what tells the two apart. + */ export async function unitIndex() { const path = process.env.UNITS_INDEX; if (!path) return null; const doc = JSON.parse(await readFile(path, "utf8")); + const units = doc.units ?? []; + const sprites = new Map(); - for (const unit of doc.units ?? []) { - if (unit.sprite) sprites.set(unit.name, unit.sprite); + if (units.some((unit) => unit.sprite)) { + for (const unit of units) { + if (unit.sprite) sprites.set(unit.name, unit.sprite); + } + return sprites; } + + const art = JSON.parse( + await readFile(path.replace(/units\.json$/, "art.json"), "utf8"), + ); + // A document of another build joined by position would give designs each + // other's pictures, which is worse than the skip an absent index takes. + if (art.chunk !== "art" || art.rows?.length !== units.length) + throw new Error(`${path}: art.json does not belong to this index`); + if (doc.build && art.build?.id !== doc.build.id) + throw new Error(`${path}: art.json is from another build`); + + units.forEach((unit, at) => { + if (art.rows[at]) sprites.set(unit.name, art.rows[at]); + }); return sprites; } diff --git a/web/src/library.ts b/web/src/library.ts index 9899901..80da355 100644 --- a/web/src/library.ts +++ b/web/src/library.ts @@ -23,7 +23,7 @@ import { HELM_RELEASE, MEGAMEK_VERSION } from "./megamek"; * and nothing else, and giving it an import of `api.ts` broke every one of * them at once. */ -export async function library(): Promise<{ helm: string; megamek: string }> { +export async function library(): Promise { // An unreachable API, or one with no index, is not a reason to show no // designs at all: the constants are the last release this build knew // about, and helm never deletes a prefix, so they still resolve. @@ -31,5 +31,22 @@ export async function library(): Promise<{ helm: string; megamek: string }> { return { helm: info?.library?.helm ?? HELM_RELEASE, megamek: info?.library?.megamek ?? MEGAMEK_VERSION, + read: Boolean(info?.library), }; } + +/** Which library to read, and whether the API is reading it too. */ +export interface Library { + helm: string; + megamek: string; + /** + * Whether the API loaded an index of its own. + * + * False is not a detail: the endpoints that answer out of it answer with + * nothing rather than with an error, so a caller that only fails over on a + * failed request would take that silence for an answer. A screen that has + * a second way to the same column - `unit-art.ts` reads the index itself - + * takes it when this is false. + */ + read: boolean; +} diff --git a/web/src/megamek.ts b/web/src/megamek.ts index 06334b6..518afba 100644 --- a/web/src/megamek.ts +++ b/web/src/megamek.ts @@ -58,7 +58,7 @@ export const MEGAMEK_VERSION = "0.51.0"; * stop being one at the same time: both belong in what a deploy writes * rather than in a file somebody edits. */ -export const HELM_RELEASE = "main-bf06475572ec"; +export const HELM_RELEASE = "main-8faab29cbd6f"; /** Where a release's files are served from. */ const PREFIX = "/assets/megamek"; diff --git a/web/src/unit-art.ts b/web/src/unit-art.ts index 2ee7596..ebd20f5 100644 --- a/web/src/unit-art.ts +++ b/web/src/unit-art.ts @@ -47,16 +47,25 @@ function flush(): Promise { if (!names.length) return; const wanted = new Set(names.flatMap(candidates)); - let found: Record = {}; - try { - found = await fetchUnitArt([...wanted]); - } catch { + const lib = await library(); + let found: Record | null = null; + // An API that loaded no index answers this endpoint with an empty object + // rather than an error, so asking it would look like an answer and every + // card would draw nothing. `read` is what tells the two apart. + if (lib.read) { + try { + found = await fetchUnitArt([...wanted]); + } catch { + found = null; + } + } + if (!found) { // The API is not the only copy: the index is public and the force // builder already reads it. Slower and much larger, but it is the same // answer, and a page that can reach the assets bucket can still draw. try { - const { helm, megamek } = await library(); - const index = await loadUnits(unitIndexUrl(helm, megamek)); + const index = await loadUnits(unitIndexUrl(lib.helm, lib.megamek)); + found = {}; for (const unit of index.units) { if (unit.sprite && wanted.has(unit.name)) found[unit.name] = unit.sprite; @@ -67,9 +76,10 @@ function flush(): Promise { return; } } + const art = found; for (const name of names) { - const hit = candidates(name).find((c) => found[c]); - known.set(name, (hit ? found[hit] : null) ?? null); + const hit = candidates(name).find((c) => art[c]); + known.set(name, (hit ? art[hit] : null) ?? null); } }); return flushing;