From 87ff9a6e2cfe002bf88231e565868ce4f3d07d4b Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 24 Aug 2026 21:37:06 -0400 Subject: [PATCH] fix(unit-search): fall back to the index when the api has no library An api with no index answers the art endpoint with an empty object rather than an error, so the browser's fallback never fired and every card drew nothing. /api/version now says whether it loaded one, the compiled-in helm release is the current prefix, and the test helper reads the split layout too. --- plan/unit-search.md | 10 ++++++++++ web/scripts/unit-index.mjs | 32 +++++++++++++++++++++++++++++--- web/src/library.ts | 19 ++++++++++++++++++- web/src/megamek.ts | 2 +- web/src/unit-art.ts | 26 ++++++++++++++++++-------- 5 files changed, 76 insertions(+), 13 deletions(-) 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; -- 2.51.2