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. *