From 2b8161b9a52fb542bfccf0f91332eeb52cd4554c Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 12:28:13 -0400 Subject: [PATCH] feat(forces)!: resolve a unit's art from the index, not a generated map web/src/scenario/unit-art.ts named the 61 designs the scenario catalog fields and had to be regenerated whenever one changed. The index carries all 8,456, so unit-art.ts asks for the handful a screen draws: /api/units/art answers in a few hundred bytes, and the index itself is the fallback when the API cannot be reached - the same data, shared with the force builder. library() moves out of megamek.ts, which is imported by tests that run under plain node and cannot resolve an extensionless import of api.ts. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ib5bd24049aa96dbe056ac301ad4d6a6bb37449d3 --- services/api/src/routes.rs | 126 ++++++++++++++++++++++++++ services/api/src/units.rs | 19 ++++ web/package.json | 1 - web/scripts/daily.test.mjs | 44 +++++---- web/scripts/megamek-assets.test.mjs | 10 -- web/scripts/megamek-units.mjs | 112 ----------------------- web/scripts/scenario-sprites.test.mjs | 39 +++++--- web/scripts/unit-index.mjs | 32 +++++++ web/src/api.ts | 18 ++++ web/src/library.ts | 35 +++++++ web/src/megamek.ts | 26 ------ web/src/scenario/preview.ts | 18 ++-- web/src/scenario/unit-art.ts | 85 ----------------- web/src/screens/hangar.ts | 3 +- web/src/unit-art.ts | 92 +++++++++++++++++++ web/src/units.ts | 15 +++ 16 files changed, 394 insertions(+), 281 deletions(-) delete mode 100644 web/scripts/megamek-units.mjs create mode 100644 web/scripts/unit-index.mjs create mode 100644 web/src/library.ts delete mode 100644 web/src/scenario/unit-art.ts create mode 100644 web/src/unit-art.ts diff --git a/services/api/src/routes.rs b/services/api/src/routes.rs index 524b60d..a70f4fd 100644 --- a/services/api/src/routes.rs +++ b/services/api/src/routes.rs @@ -96,6 +96,7 @@ pub fn app(state: AppState) -> Router { // ~1.4MB on the wire; 2MB covers that and the words around it. post(create_flare).layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024)), ) + .route("/api/units/art", get(unit_art)) .route("/api/scenarios", get(list_scenarios)) .route("/api/daily/{slug}", get(daily_status).post(start_daily)) .route("/api/opponents", get(list_opponents)) @@ -166,6 +167,55 @@ async fn version(State(state): State) -> Response { .into_response() } +/// The most designs one lookup will answer for. +/// +/// A scenario preview asks about the handful a scenario fields; the front +/// page's daily card asks about six. This is far above either and bounds +/// what a stranger can make this service do per request. +const ART_LOOKUP_MAX: usize = 64; + +/// Which picture each of these designs is drawn with. +/// +/// The fast path for a screen that draws a few units. The whole index is +/// public and a screen can read it - the force builder does, because it +/// needs all of it - but 300KB to resolve six names is not a trade the front +/// page should make. This answers in a few hundred bytes. +/// +/// A path relative to `data/images/units/`, the same form the index carries, +/// because the caller turns it into an address with the release it already +/// asked `/api/version` for. Sending a whole URL from here would put a second +/// address-builder in this service, and `web/src/megamek.ts` is the one that +/// exists. +/// +/// Unknown names are absent from the answer rather than null: the caller +/// falls back to the index, which is the same question asked of the same +/// data, so an empty answer costs correctness nothing. +async fn unit_art( + State(state): State, + Query(asked): Query>, +) -> Response { + let Some(units) = state.units.as_ref() else { + // No index loaded is not an error: it is this deployment having no + // library, and the caller's fallback finds the same nothing. + return Json(serde_json::json!({})).into_response(); + }; + let names: Vec<&str> = asked + .iter() + .filter(|(key, _)| key == "name") + .map(|(_, value)| value.as_str()) + .take(ART_LOOKUP_MAX) + .collect(); + ( + // Long enough that a second page load reuses it, short enough that a + // deployment moving to a new library is not serving last release's + // answers all afternoon. The paths are stable across most releases; + // the ones that are not are why this is not immutable. + [(header::CACHE_CONTROL, "public, max-age=300")], + Json(serde_json::json!(units.sprites_for(names))), + ) + .into_response() +} + fn message(status: StatusCode, text: &str) -> Response { (status, Json(serde_json::json!({ "message": text }))).into_response() } @@ -2445,6 +2495,82 @@ mod tests { assert_eq!(body["library"]["helm"], "main-0bb2bf14a1a3"); } + /// The fast path a screen drawing a few units takes, so the front page + /// does not fetch 300KB of index to resolve six names. + #[tokio::test] + async fn unit_art_answers_for_the_names_asked() { + let (_dir, mut state) = loopback_state().await; + state.units = Some(std::sync::Arc::new(crate::units::for_test(&[ + ("Atlas AS7-D", "meks/atlas.png"), + ("Locust LCT-1V", "meks/locust.png"), + ]))); + let response = app(state) + .oneshot( + Request::get("/api/units/art?name=Atlas+AS7-D&name=Nothing+Here") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + body_json(response).await, + // The unknown name is absent, not null: the caller is asking what + // this library knows, and it does not know that one. + serde_json::json!({ "Atlas AS7-D": "meks/atlas.png" }) + ); + } + + /// A deployment with no index answers an empty object rather than an + /// error: the caller's fallback reads the same library and finds the same + /// nothing, so a failure here would only make it retry for no reason. + #[tokio::test] + async fn unit_art_without_an_index_is_empty() { + let (_dir, state) = loopback_state().await; + let response = app(state) + .oneshot( + Request::get("/api/units/art?name=Atlas+AS7-D") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await, serde_json::json!({})); + } + + /// Bounded, because the query string is a stranger's to write. + #[tokio::test] + async fn unit_art_answers_at_most_a_batch() { + let (_dir, mut state) = loopback_state().await; + let names: Vec<(String, String)> = (0..200) + .map(|n| (format!("Design {n}"), format!("meks/{n}.png"))) + .collect(); + let borrowed: Vec<(&str, &str)> = names + .iter() + .map(|(n, s)| (n.as_str(), s.as_str())) + .collect(); + state.units = Some(std::sync::Arc::new(crate::units::for_test(&borrowed))); + let query = (0..200) + .map(|n| format!("name=Design+{n}")) + .collect::>() + .join("&"); + let response = app(state) + .oneshot( + Request::get(format!("/api/units/art?{query}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = body_json(response).await; + assert_eq!( + body.as_object().expect("an object").len(), + ART_LOOKUP_MAX, + "the batch cap did not hold" + ); + } + /// No index is null rather than absent, so the site can tell an API that /// has none from one too old to say - and falls back to its own constants /// in both cases. diff --git a/services/api/src/units.rs b/services/api/src/units.rs index d3fc1e9..1fbcfeb 100644 --- a/services/api/src/units.rs +++ b/services/api/src/units.rs @@ -176,6 +176,25 @@ impl UnitIndex { (&self.version, self.helm.as_deref()) } + /// The sprites for a batch of designs, for the screens that draw a + /// handful and should not fetch the whole index to do it. + /// + /// Names the index does not carry are left out rather than answered with + /// a null: the caller is asking what this library knows, and a design it + /// has never heard of is not a fact about that design. + pub fn sprites_for<'a>( + &self, + names: impl IntoIterator, + ) -> std::collections::BTreeMap<&str, &str> { + names + .into_iter() + .filter_map(|name| { + let (key, sprite) = self.by_name.get_key_value(name)?; + Some((key.as_str(), sprite.as_str())) + }) + .collect() + } + /// The sprite a design is drawn with, or `None` for one the index does /// not carry - every vehicle, platoon and fighter, today. pub fn design(&self, name: &str) -> Option<&str> { diff --git a/web/package.json b/web/package.json index 0f22c0a..764e8e0 100644 --- a/web/package.json +++ b/web/package.json @@ -21,7 +21,6 @@ "favicons": "node scripts/favicons.mjs", "social-card": "node scripts/social-card.mjs", "megamek-camo": "node scripts/megamek-camo.mjs", - "megamek-units": "node scripts/megamek-units.mjs", "daily-public": "node scripts/daily-public.mjs", "dev:local-auth": "VITE_LOCAL_AUTH=1 astro dev", "lexgen": "lex build --lexicons ../lexicons --out src/lexicons --index-file --clear", diff --git a/web/scripts/daily.test.mjs b/web/scripts/daily.test.mjs index e5eee97..698d170 100644 --- a/web/scripts/daily.test.mjs +++ b/web/scripts/daily.test.mjs @@ -30,7 +30,8 @@ const { challenges } = JSON.parse( const FACINGS = ["N", "NE", "SE", "S", "SW", "NW"]; /** The card paints through preview.ts, whose map is unit-art.ts. */ -import { UNIT_SPRITES } from "../src/scenario/unit-art.ts"; +import { candidates } from "../src/units.ts"; +import { SKIP, unitIndex } from "./unit-index.mjs"; test("there is a schedule, one challenge per day, in order", () => { assert.ok(challenges.length, "no challenges are scheduled"); @@ -60,27 +61,32 @@ test("every challenge has a name and a line about it", () => { } }); -test("every unit named is one the card has art for", () => { - // Collected rather than asserted one at a time. An assert inside the loop - // stops at the first machine with no art, so a challenge missing two of - // them reported one, and the fix looked finished when half of it was. - const missing = []; - for (const challenge of challenges) { - for (const side of challenge.sides) { - for (const unit of side.units) { - if (!UNIT_SPRITES[unit.name]) { - missing.push(`${challenge.slug}: ${unit.name}`); +const sprites = await unitIndex(); + +test( + "every unit named is one the card has art for", + { skip: sprites ? false : SKIP }, + () => { + // Collected rather than asserted one at a time. An assert inside the loop + // stops at the first machine with no art, so a challenge missing two of + // them reported one, and the fix looked finished when half of it was. + const missing = []; + for (const challenge of challenges) { + for (const side of challenge.sides) { + for (const unit of side.units) { + if (!candidates(unit.name).some((name) => sprites.get(name))) { + missing.push(`${challenge.slug}: ${unit.name}`); + } } } } - } - assert.deepEqual( - missing, - [], - "not in unit-art.ts, so the hex draws empty — regenerate it from a " + - "`helm art` report with `npm run megamek-units -- art.json`", - ); -}); + assert.deepEqual( + missing, + [], + "helm's index has no art for these, so the hex draws empty", + ); + }, +); test("every unit stands on the board, and no row is crowded enough to hide one", () => { for (const challenge of challenges) { diff --git a/web/scripts/megamek-assets.test.mjs b/web/scripts/megamek-assets.test.mjs index 453a089..156ca80 100644 --- a/web/scripts/megamek-assets.test.mjs +++ b/web/scripts/megamek-assets.test.mjs @@ -15,7 +15,6 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { MEGAMEK_VERSION, megamekAsset, unitSprite } from "../src/megamek.ts"; -import { UNIT_SPRITES } from "../src/scenario/unit-art.ts"; const web = fileURLToPath(new URL("../", import.meta.url)); @@ -45,15 +44,6 @@ test("a leading slash does not double up", () => { ); }); -test("every catalog unit's sprite is a path under units/", () => { - const entries = Object.entries(UNIT_SPRITES); - assert.ok(entries.length > 50, `only ${entries.length} units have art`); - for (const [name, sprite] of entries) { - assert.ok(sprite && !sprite.startsWith("/"), `${name}: ${sprite}`); - assert.match(sprite, /\.(png|gif|jpg)$/i, `${name}: ${sprite}`); - } -}); - test("no unit art is committed to the repository any more", async () => { // The point of the bucket. A PNG creeping back into src/ is a second copy // of MegaMek's data in a repository that deliberately holds none. diff --git a/web/scripts/megamek-units.mjs b/web/scripts/megamek-units.mjs deleted file mode 100644 index 8753e37..0000000 --- a/web/scripts/megamek-units.mjs +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Rebuilds src/scenario/unit-art.ts: which picture each catalog unit is drawn - * with. - * - * helm art --megamek /path/to/MegaMek --out art.json - * npm run megamek-units -- art.json - * npm run megamek-units -- art.json --check - * - * The pictures are not in this repository any more. They are served from the - * assets bucket at the path they have inside a release, so what has to be - * generated is the mapping, and this is text rather than 360KB of PNG. - * - * The resolution itself belongs to helm, which reads MegaMek's own mekset.txt - * - an `exact` line per variant, a `chassis` line as the family fallback, and - * a `default_*` silhouette where neither exists. Doing it here as well would - * be a second implementation of a rule that is already subtle: uppercased - * comparisons, a chassis key that folds a Clan design's second name in, and a - * `Thor (Summoner) A` that gets the wrong machine when a filename is guessed - * from a name. - * - * Names come from the scenario files, the same two places as before: the - * API's vendored catalog and the dailies beside it. A few of them are written - * in a form helm does not key a design by - a `×N` count on a repeated unit, - * or a `(Standard)` model in parentheses - and those keep whatever the file - * already says, since the art they name was checked against the release when - * it was written. A name that is new and unresolvable is reported, and - * `--check` fails on it. - */ -import { readFile, readdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { scenarioSources, scenarioUnits } from "./scenario-files.mjs"; - -const repo = fileURLToPath(new URL("../..", import.meta.url)); -const out = join(repo, "web/src/scenario/unit-art.ts"); -const args = process.argv.slice(2); -const check = args.includes("--check"); -const report = args.find((a) => !a.startsWith("--")); - -if (!report) { - console.error( - "usage: npm run megamek-units -- [--check]\n" + - " art.json comes from `helm art --megamek --out art.json`.", - ); - process.exit(2); -} - -const art = JSON.parse(await readFile(report, "utf8")); -const byName = new Map(art.units.map((u) => [u.name, u.sprite])); - -const catalog = scenarioUnits( - await scenarioSources(repo, readFile, readdir, join), -); -const dailyDir = join(repo, "services/api/src/matches/daily"); -const dailyFiles = (await readdir(dailyDir)).filter((f) => f.endsWith(".mms")); -const daily = scenarioUnits( - await Promise.all( - dailyFiles.map((file) => readFile(join(dailyDir, file), "utf8")), - ), -); -const units = [...new Set([...catalog, ...daily])].sort(); - -// What the committed file already resolved, so a name helm does not key keeps -// the answer it was given rather than losing its picture. -const existing = new Map( - [...(await readFile(out, "utf8")).matchAll(/^ {2}"(.+?)": "(.+?)",$/gm)].map( - (m) => [m[1], m[2]], - ), -); - -const resolved = new Map(); -const missing = []; -for (const unit of units) { - const sprite = byName.get(unit) ?? existing.get(unit); - if (sprite) resolved.set(unit, sprite); - else missing.push(unit); -} - -const header = (await readFile(out, "utf8")).split( - "export const UNIT_SPRITES", -)[0]; -const body = [...resolved.entries()] - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([name, sprite]) => ` ${JSON.stringify(name)}: ${JSON.stringify(sprite)},`, - ) - .join("\n"); -const text = `${header}export const UNIT_SPRITES: Record = {\n${body}\n};\n`; - -if (missing.length) { - console.error( - `no sprite for ${missing.length} unit(s):\n ${missing.join("\n ")}\n` + - "helm resolves a design by chassis and model; a scenario name in another " + - "form needs its line kept in unit-art.ts.", - ); - if (check) process.exit(1); -} - -if (check) { - const have = await readFile(out, "utf8"); - if (have !== text) { - console.error(`${out} is not what this run would write; regenerate it.`); - process.exit(1); - } - console.log( - `unit-art.ts is current (${resolved.size} units, megamek ${art.megamek})`, - ); -} else { - await writeFile(out, text); - console.log(`wrote ${out} (${resolved.size} units, megamek ${art.megamek})`); -} diff --git a/web/scripts/scenario-sprites.test.mjs b/web/scripts/scenario-sprites.test.mjs index d2c1b5e..781304d 100644 --- a/web/scripts/scenario-sprites.test.mjs +++ b/web/scripts/scenario-sprites.test.mjs @@ -1,13 +1,13 @@ /** - * Every unit the scenario catalog names has a sprite in the preview. + * Every unit the scenario catalog names resolves in helm's index. * * The catalog is the vendored `.mms` files in the API - * (services/api/src/matches/scenarios/) and the sprite map is - * web/src/scenario/unit-art.ts, keyed by the exact unit strings the catalog - * serves. Nothing at runtime checks the join — a missing key is just a bare - * hex — so this does, from the sources on both sides. + * (services/api/src/matches/scenarios/) and the library is what helm + * publishes. Nothing at runtime checks the join — a name the index does not + * key is just a bare hex — so this does, from the sources on both sides, + * through the same `candidates` rule the browser resolves with. * - * Run with `npm test`. + * Needs an index: see `unit-index.mjs`. Run with `npm test`. */ import { test } from "node:test"; import assert from "node:assert/strict"; @@ -17,7 +17,8 @@ import { fileURLToPath } from "node:url"; import { scenarioSources, scenarioUnits } from "./scenario-files.mjs"; const repo = fileURLToPath(new URL("../..", import.meta.url)); -import { UNIT_SPRITES } from "../src/scenario/unit-art.ts"; +import { candidates } from "../src/units.ts"; +import { SKIP, unitIndex } from "./unit-index.mjs"; const units = scenarioUnits( await scenarioSources(repo, readFile, readdir, join), @@ -30,11 +31,19 @@ test("the catalog parse finds the units", () => { assert.ok(units.has("Wasp WSP-1A"), "a known unit is missing"); }); -test("every catalog unit has a sprite", () => { - const missing = [...units].filter((unit) => !UNIT_SPRITES[unit]); - assert.deepEqual( - missing, - [], - "regenerate unit-art.ts from a `helm art` report for these units", - ); -}); +const sprites = await unitIndex(); + +test( + "every catalog unit has a sprite", + { skip: sprites ? false : SKIP }, + () => { + const missing = [...units].filter( + (unit) => !candidates(unit).some((name) => sprites.get(name)), + ); + assert.deepEqual( + missing, + [], + "helm's index has no art for these, so the preview draws a bare hex", + ); + }, +); diff --git a/web/scripts/unit-index.mjs b/web/scripts/unit-index.mjs new file mode 100644 index 0000000..72efcbb --- /dev/null +++ b/web/scripts/unit-index.mjs @@ -0,0 +1,32 @@ +/** + * A published unit index, for the tests that check the catalog resolves in it. + * + * Not committed: it is 3.7MB of MegaMek-derived data and it belongs to the + * assets bucket, so a checkout does not have one. Point `UNITS_INDEX` at a + * file `helm index` wrote, or at one pulled from the bucket: + * + * curl -o /tmp/units.json \ + * https://lance.blue/assets/helm///units.json + * UNITS_INDEX=/tmp/units.json npm test + * + * Absent, the tests that need it skip and say so. They guard a join between + * two things this repository does not own — the scenario files it vendors and + * the library helm publishes — so a skip is honest: there is nothing local to + * check them against. + */ +import { readFile } from "node:fs/promises"; + +/** Design name to sprite path, or null when no index was named. */ +export async function unitIndex() { + const path = process.env.UNITS_INDEX; + if (!path) return null; + const doc = JSON.parse(await readFile(path, "utf8")); + const sprites = new Map(); + for (const unit of doc.units ?? []) { + if (unit.sprite) sprites.set(unit.name, unit.sprite); + } + return sprites; +} + +export const SKIP = + "set UNITS_INDEX to a units.json from `helm index` or the assets bucket"; diff --git a/web/src/api.ts b/web/src/api.ts index e661a8b..ac3062d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -280,6 +280,24 @@ export async function fetchVersion(): Promise { return (await response.json()) as ApiVersion; } +/** + * Which picture each of these designs is drawn with. + * + * Paths relative to `data/images/units/`, keyed by the names that resolved; + * a name this library has never heard of is simply absent. The caller turns + * a path into an address — see `unit-art.ts`, which also holds the fallback + * for when this cannot be reached at all. + */ +export async function fetchUnitArt( + names: string[], +): Promise> { + const query = names.map((n) => `name=${encodeURIComponent(n)}`).join("&"); + const response = await call(`/api/units/art?${query}`); + if (!response.ok) + throw new ApiError(`Unexpected response (${response.status}).`); + return (await response.json()) as Record; +} + /** * The same answer, fetched once per page load and shared. * diff --git a/web/src/library.ts b/web/src/library.ts new file mode 100644 index 0000000..9899901 --- /dev/null +++ b/web/src/library.ts @@ -0,0 +1,35 @@ +/** + * Which unit library this page reads. + */ + +import { apiVersion } from "./api"; +import { HELM_RELEASE, MEGAMEK_VERSION } from "./megamek"; + +/** + * Which library to read, asked of the API rather than compiled in. + * + * `megamek.ts`'s constants are a fallback and nothing more. helm publishes under a + * prefix named for its own release, and this file naming one meant a helm + * deploy needed a site build to match it - which it did not always get, and + * then the force builder and the match reports read different indexes with + * nothing to say so. The API loads an index and reports what it loaded, so + * asking it is what makes the two agree. + * + * `/api/version` is already fetched once per page load for the footer, and + * `apiVersion` shares that one promise, so this costs no request of its own. + * + * Its own module rather than part of `megamek.ts`, because that file is + * imported by tests that run under plain `node --test`: it builds addresses + * 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 }> { + // 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. + const info = await apiVersion(); + return { + helm: info?.library?.helm ?? HELM_RELEASE, + megamek: info?.library?.megamek ?? MEGAMEK_VERSION, + }; +} diff --git a/web/src/megamek.ts b/web/src/megamek.ts index 0b80994..06334b6 100644 --- a/web/src/megamek.ts +++ b/web/src/megamek.ts @@ -26,8 +26,6 @@ * content hashes, so the two cannot collide. */ -import { apiVersion } from "./api"; - /** * The MegaMek whose files are served. * @@ -62,30 +60,6 @@ export const MEGAMEK_VERSION = "0.51.0"; */ export const HELM_RELEASE = "main-bf06475572ec"; -/** - * Which library to read, asked of the API rather than compiled in. - * - * The constants above are a fallback and nothing more. helm publishes under a - * prefix named for its own release, and this file naming one meant a helm - * deploy needed a site build to match it - which it did not always get, and - * then the force builder and the match reports read different indexes with - * nothing to say so. The API loads an index and reports what it loaded, so - * asking it is what makes the two agree. - * - * `/api/version` is already fetched once per page load for the footer, and - * `apiVersion` shares that one promise, so this costs no request of its own. - */ -export async function library(): Promise<{ helm: string; megamek: string }> { - // 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. - const info = await apiVersion(); - return { - helm: info?.library?.helm ?? HELM_RELEASE, - megamek: info?.library?.megamek ?? MEGAMEK_VERSION, - }; -} - /** Where a release's files are served from. */ const PREFIX = "/assets/megamek"; diff --git a/web/src/scenario/preview.ts b/web/src/scenario/preview.ts index af1fe32..2452170 100644 --- a/web/src/scenario/preview.ts +++ b/web/src/scenario/preview.ts @@ -18,8 +18,7 @@ import type { Scenario } from "../api"; import { applyCamo, over } from "../camo/mech"; import { paintWhenReady, tile } from "../megamek-art"; import { el } from "../dom"; -import { unitSprite } from "../megamek"; -import { UNIT_SPRITES } from "./unit-art"; +import { spriteUrlFor } from "../unit-art"; // The neutral board tile from arena's skin, the same one the camo editor // previews against: it is what a lobby actually looks like. @@ -28,20 +27,15 @@ import battlespaceUrl from "../camo/backgrounds/battlespace.png?url"; // --- unit art --------------------------------------------------------------- // // Real MegaMek unit sprites, served from the assets bucket rather than -// committed here: `megamek.ts` turns a path inside an install into an address -// on this site, and `unit-art.ts` says which path each catalog unit uses. +// committed here: which picture a design gets is helm's index's answer, and +// `unit-art.ts` is what asks. This module used to hold a generated map of the +// 61 designs the scenario catalog names; the index carries all 8,456, so a +// unit the catalog has never fielded now draws as itself too. // // Same terms as before, CC BY-NC-SA 4.0, and the attribution is in the repo // README with the rest of the MegaMek art. What changed is where the bytes // live, not whose they are. -const SPRITE_URLS: Record = Object.fromEntries( - Object.entries(UNIT_SPRITES).map(([name, sprite]) => [ - name, - unitSprite(sprite), - ]), -); - // The caption's short name, and the variant beside it. Defined in // designation.ts because the daily card wants them without this module's // sprite map; re-exported here so lobby.ts's force cards keep their import. @@ -61,7 +55,7 @@ export async function paintUnitSprite( unit: string, camo: ImageData | null, ): Promise { - const spriteUrl = SPRITE_URLS[unit]; + const spriteUrl = await spriteUrlFor(unit); if (!spriteUrl) return null; const sprite = await tile(spriteUrl); return camo ? applyCamo(sprite, camo) : sprite; diff --git a/web/src/scenario/unit-art.ts b/web/src/scenario/unit-art.ts deleted file mode 100644 index 629d0b2..0000000 --- a/web/src/scenario/unit-art.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Which picture each unit in the scenario catalog is drawn with. - * - * A path inside MegaMek's `data/images/units/`, resolved from the release's - * own `mekset.txt` by `helm art` — an `exact` line where a variant has art of - * its own, the `chassis` line where it does not, and a `default_*` silhouette - * where neither exists. So a variant sharing its chassis' picture here is - * MegaMek sharing it too, rather than a guess made in this file. - * - * Names are the catalog's, which is what the scenario files write. A handful - * carry a form helm does not key a design by — a `×N` count, or a `(Standard)` - * model in parentheses — and those were resolved by matching the art that used - * to be committed here against the release, which finds the same file MegaMek - * would. - * - * Listed one by one rather than resolved by chassis at runtime, so a unit this - * file has never heard of fails visibly instead of quietly borrowing a picture. - * - * Regenerate with `npm run megamek-units` against a `helm art` report; the - * pictures themselves are served from the assets bucket and are not in this - * repository. - */ -export const UNIT_SPRITES: Record = { - "Alpha Wolf A": "meks/AlphaWolf_A.png", - "Amarok 3": "meks/Amarok_3.png", - "Archer ARC-2R": "meks/archer.png", - "Archer ARC-4M": "meks/archer.png", - "Argus AGS-4D": "meks/Argus.png", - "Atlas AS7-D": "meks/atlas.png", - "Atlas AS8-D": "meks/atlas_8d.png", - "Avatar AV1-OC": "meks/Avatar_OC.png", - "Barghest BGS-3T": "meks/Barghest_3T.png", - "Black Hawk (Nova) A": "meks/BlackHawk_A.png", - "Bushwacker BSW-L1": "meks/Bushwacker_L1.png", - "Cicada CDA-3C": "meks/cicada_3c.png", - "Clint CLNT-5U": "meks/Clint_5U.png", - "Commando COM-7S": "meks/Commando.png", - "Cougar H": "meks/Cougar_H.png", - "Crusader CRD-3R": "meks/Crusader.png", - "Drillson Heavy Hover Tank (Standard)": "vehicles/Drillson.png", - "Enfield END-6J": "meks/Enfield_6J.png", - "Exterminator EXT-5E": "meks/Exterminator_5E.png", - "Fafnir FNR-5": "meks/Fafnir.png", - "Falconer FLC-8R": "meks/Falconer.png", - "Firefly FFL-3PP": "meks/Firefly_3PP.png", - "Firefly FFL-3PP3": "meks/Firefly_3SLE.png", - "Flea FLE-4": "meks/Flea_4.png", - "Griffin GRF-1N": "meks/griffin.png", - "Heavy LRM Carrier (Standard)": "vehicles/HeavyCarrier_LRM.png", - "Hellhound (Conjurer) (Standard)": "meks/Hellhound.png", - "Hellspawn HSN-7D": "meks/Hellspawn.png", - "Hussar HSR-500-D": "meks/Hussar.png", - "Javelin JVN-11D": "meks/javelin_11d.png", - "Locust LCT-1S": "meks/locust_1s.png", - "Locust LCT-1V": "meks/locust.png", - "Lynx LNX-9Q": "meks/Lynx.png", - "Marauder MAD-3R": "meks/marauder.png", - "Marauder MAD-5S": "meks/marauder_5s.png", - "Osiris OSR-3D": "meks/Osiris.png", - "Rakshasa MDG-1A": "meks/Rakshasa.png", - "Razorback RZK-9S": "meks/Razorback.png", - "Sagittaire SGT-8R": "meks/Sagittaire.png", - "Savannah Master Hovercraft (Standard)": "vehicles/SavannahMaster.png", - "Shadow Hawk SHD-2H": "meks/Shadowhawk.png", - "Spider SDR-5D": "meks/Spider_5D.png", - "Spider SDR-5K": "meks/Spider_5K.png", - "Stiletto STO-4A": "meks/Stiletto.png", - "Stinger STG-3G": "meks/stinger_3g.png", - "Stinger STG-3R": "meks/stinger.png", - "Templar TLR1-OB": "meks/Templar_B.png", - "Tessen TSN-1C": "meks/Tessen.png", - "Thanatos TNS-4S": "meks/Thanatos.png", - "Thor (Summoner) A": "meks/thor_a.png", - "Thor (Summoner) D": "meks/thor_d.png", - "Thug THG-12E": "meks/Thug.png", - "Thunderbolt TDR-5S": "meks/thunderbolt.png", - "Turkina D": "meks/Turkina_D.png", - "Uziel UZL-3S": "meks/Uziel_3S.png", - "Warhammer WHM-6R": "meks/warhammer.png", - "Wasp WSP-1A": "meks/wasp.png", - "Wasp WSP-1W": "meks/wasp_1w.png", - "Wolverine WVR-6R": "meks/wolverine.png", - "Wraith TR1": "meks/Wraith.png", - "Zeus ZEU-6T": "meks/Zeus_6T.png", -}; diff --git a/web/src/screens/hangar.ts b/web/src/screens/hangar.ts index c07df07..d0352d4 100644 --- a/web/src/screens/hangar.ts +++ b/web/src/screens/hangar.ts @@ -20,7 +20,8 @@ import { footer, pageHead } from "../chrome"; import { el, render } from "../dom"; -import { library, unitIndexUrl, unitSprite } from "../megamek"; +import { library } from "../library"; +import { unitIndexUrl, unitSprite } from "../megamek"; import { NO_TARGET, type Pick, diff --git a/web/src/unit-art.ts b/web/src/unit-art.ts new file mode 100644 index 0000000..2ee7596 --- /dev/null +++ b/web/src/unit-art.ts @@ -0,0 +1,92 @@ +/** + * Which picture a design is drawn with, for the screens that draw a few. + * + * Two ways to the same answer, in this order: + * + * 1. `/api/units/art`, which resolves the names asked for out of the index + * the API already has in memory. A few hundred bytes. + * 2. The index itself, fetched and kept the way the force builder fetches + * it — 300KB gzipped, but shared with every other screen on the page and + * cached by the browser, so a second reader pays nothing. + * + * The fast path is what keeps the front page's daily card from pulling the + * whole library to draw six units. The fallback is what keeps it drawing at + * all when the API is unreachable, and it is the same data answering the same + * question rather than a second source of truth. + * + * This replaced `scenario/unit-art.ts`, a generated map of the 61 designs the + * scenario catalog names. The index carries all 8,456, so the map was a copy + * of a column somebody had to remember to regenerate. + */ + +import { fetchUnitArt } from "./api"; +import { library } from "./library"; +import { unitIndexUrl, unitSprite } from "./megamek"; +import { candidates, loadUnits } from "./units"; + +/** Resolved paths, relative to `data/images/units/`. Null means "asked, and + * this library has never heard of it" — cached so it is asked once. */ +const known = new Map(); + +/** Names waiting for the next flush, and the callers waiting on them. */ +let queued = new Set(); +let flushing: Promise | null = null; + +/** + * One request per tick rather than one per unit. + * + * A force card paints its machines in a loop, so without this a lance is + * four requests and Bug Company is twelve. Everything asked for before the + * microtask runs goes in one query. + */ +function flush(): Promise { + flushing ??= Promise.resolve().then(async () => { + const names = [...queued]; + queued = new Set(); + flushing = null; + if (!names.length) return; + + const wanted = new Set(names.flatMap(candidates)); + let found: Record = {}; + try { + found = await fetchUnitArt([...wanted]); + } catch { + // 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)); + for (const unit of index.units) { + if (unit.sprite && wanted.has(unit.name)) + found[unit.name] = unit.sprite; + } + } catch { + // Neither reachable. Leave the names unresolved rather than + // remembering a failure: the next caller tries again. + return; + } + } + for (const name of names) { + const hit = candidates(name).find((c) => found[c]); + known.set(name, (hit ? found[hit] : null) ?? null); + } + }); + return flushing; +} + +/** + * The address of one design's picture, or null where this library has no + * entry for it — every caller draws something else in that case rather than + * a broken image. + */ +export async function spriteUrlFor(name: string): Promise { + if (!known.has(name)) { + queued.add(name); + await flush(); + } + const sprite = known.get(name); + if (!sprite) return null; + const { megamek } = await library(); + return unitSprite(sprite, megamek); +} diff --git a/web/src/units.ts b/web/src/units.ts index 1f36f57..6720891 100644 --- a/web/src/units.ts +++ b/web/src/units.ts @@ -76,6 +76,21 @@ export function loadUnits(url: string): Promise { return loading; } +/** + * The names to try for one design, best first. + * + * Scenario files write a stock design as `Savannah Master Hovercraft + * (Standard)`, and helm keys it as `Savannah Master Hovercraft` — the model + * is the empty one and the parenthetical is the file's way of saying so. + * Both spellings are in the wild, so both are asked for; two designs in + * 0.51.0 really do end in `(Standard)`, which is why the exact name is tried + * first rather than stripped unconditionally. + */ +export function candidates(name: string): string[] { + const bare = name.replace(/\s*\(Standard\)$/, ""); + return bare === name ? [name] : [name, bare]; +} + /** * Designs whose name a player is typing. * -- 2.51.2