From 3d8d787cc6a2026dcc772ee276b098fecf2a4e0a Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 24 Aug 2026 20:07:26 -0400 Subject: [PATCH] feat(unit-search): read the index helm splits into chunks helm publishes the library as a spine and a file of columns per group, so the row a screen reads is assembled here: art, figures, paperwork and combat, joined to the spine by position with the build digest on both ends checked. Four of the seven, because quirks and the loadout are nothing this filters on yet. A prefix written before the split carries the columns on the spine and has no side documents, and is read exactly as it always was. The site and the index are deployed separately, so a page that only worked against one of the two layouts would be broken for as long as that took. The curve loader now goes through the same reader rather than its own copy of the fetch and the two checks. --- web/src/units.ts | 209 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 180 insertions(+), 29 deletions(-) diff --git a/web/src/units.ts b/web/src/units.ts index fd5b3d5..f32555f 100644 --- a/web/src/units.ts +++ b/web/src/units.ts @@ -55,6 +55,16 @@ export interface Unit { firepower: number | null; max_range: number | null; heat_efficiency: number | null; + /** + * Whether the design is built to fight at arm's length: a hatchet, a claw, + * spikes, a shield, triple strength myomer. + * + * `null` for infantry and battle armour, and that is not "no". Every + * platoon can swarm a Mek, so the question is about infantry rather than + * about this platoon, and helm declines to answer it - which is why a + * filter on this returns no infantry whichever way it is asked. + */ + melee: boolean | null; } /** @@ -100,6 +110,12 @@ export interface UnitIndex { helm: string | null; stats_producer: string; build?: Build; + /** + * The side documents this index is published with, where it is one that + * was split into them. Absent in a prefix written before the split, which + * is what tells the two layouts apart. + */ + chunks?: string[]; units: Unit[]; } @@ -110,8 +126,142 @@ interface Chunk { rows: T[]; } +/** What each side document holds, for the columns this site reads. */ +interface Figures { + mass: number | null; + bv: number | null; + cost: number | null; + weight_class: number | null; + total_armor: number | null; + omni: boolean | null; + clan: boolean | null; +} +interface Paperwork { + year: number | null; + tech_base: string | null; + rules_level: string | null; + role: string | null; + source: string | null; +} +interface CombatColumns { + walk_mp: number | null; + run_mp: number | null; + jump_mp: number | null; + firepower: number | null; + max_range: number | null; + heat_efficiency: number | null; + melee?: boolean; +} + let loading: Promise | null = null; +/** + * One of helm's side documents, checked against the index it belongs to. + * + * `null` for anything that is not the document asked for: a prefix written + * before helm split the index has none of these at all, and a screen asking + * for one is asking for something to draw rather than for something to fail + * over. + * + * Both halves of the identity are checked, because either alone can be right + * by accident: two builds of the same library have the same length, and a + * chunk of the right length could still be the wrong column. + */ +async function readChunk( + indexUrl: string, + name: string, + index: UnitIndex, +): Promise { + const url = indexUrl.replace(/units\.json$/, `${name}.json`); + if (url === indexUrl) return null; + + let chunk: Chunk; + try { + const response = await fetch(url); + // A 404 is the old layout rather than a fault. + if (!response.ok) return null; + chunk = (await response.json()) as Chunk; + } catch (error: unknown) { + console.warn(`units: ${name} could not be read`, url, error); + return null; + } + + if (chunk.chunk !== name || chunk.rows.length !== index.units.length) { + console.warn(`units: the ${name} document does not match the index`, url); + return null; + } + if (index.build && chunk.build?.id !== index.build.id) { + console.warn(`units: the ${name} document is from another build`, url); + return null; + } + return chunk.rows; +} + +/** + * Fill the columns a split index keeps in its side documents. + * + * helm publishes the library as a spine - which designs there are - and a + * group of columns per file, so a page fetches what it filters on rather than + * everything. This site reads four of the seven: what a design is called, its + * picture, its figures, its paperwork and how it fights. + * + * The join is positional, row *n* of each document being design *n* of the + * spine, which is what the build digest on both ends is for. + * + * A prefix written before the split carries these columns on the spine + * already and has no side documents, so this finds nothing to do and the + * index is what it always was. That is deliberate: the site and the index are + * deployed separately, and a page that only worked against one of the two + * layouts would be broken for as long as that took. + */ +async function fillFromChunks( + indexUrl: string, + index: UnitIndex, +): Promise { + if (!Array.isArray(index.chunks) || index.chunks.length === 0) return; + + const [art, figures, paperwork, combat] = await Promise.all([ + readChunk(indexUrl, "art", index), + readChunk(indexUrl, "figures", index), + readChunk(indexUrl, "paperwork", index), + readChunk(indexUrl, "combat", index), + ]); + + index.units.forEach((unit, at) => { + if (art) unit.sprite = art[at] ?? null; + const money = figures?.[at]; + if (money) { + unit.mass = money.mass ?? null; + unit.bv = money.bv ?? null; + unit.cost = money.cost ?? null; + unit.weight_class = money.weight_class ?? null; + unit.total_armor = money.total_armor ?? null; + unit.omni = money.omni ?? null; + unit.clan = money.clan ?? null; + } + const paper = paperwork?.[at]; + if (paper) { + unit.year = paper.year ?? null; + unit.tech_base = paper.tech_base ?? null; + unit.rules_level = paper.rules_level ?? null; + unit.role = paper.role ?? null; + unit.source = paper.source ?? null; + } + const fight = combat?.[at]; + if (fight) { + unit.walk_mp = fight.walk_mp ?? null; + unit.run_mp = fight.run_mp ?? null; + unit.jump_mp = fight.jump_mp ?? null; + unit.firepower = fight.firepower ?? null; + unit.max_range = fight.max_range ?? null; + unit.heat_efficiency = fight.heat_efficiency ?? null; + // Absent rather than false for a design helm does not ask, so the + // column has to keep the difference: `?? null`, never `?? false`. + unit.melee = fight.melee ?? null; + } + }); +} + /** * The index, fetched once. * @@ -127,7 +277,9 @@ export function loadUnits(url: string): Promise { if (!response.ok) { throw new Error(`unit index: ${response.status}`); } - return (await response.json()) as UnitIndex; + const index = (await response.json()) as UnitIndex; + await fillFromChunks(url, index); + return index; }) .catch((error: unknown) => { loading = null; @@ -160,36 +312,12 @@ export async function loadCurves( index: UnitIndex, ): Promise> { const empty = new Map(); - const url = indexUrl.replace(/units\.json$/, "curve.json"); - if (url === indexUrl) return empty; - - let chunk: Chunk; - try { - const response = await fetch(url); - // A 404 is the old layout rather than a fault, and is the common case - // until the split index is what is deployed. - if (!response.ok) return empty; - chunk = (await response.json()) as Chunk; - } catch (error: unknown) { - console.warn("units: the firepower curves could not be read", url, error); - return empty; - } - - // Both halves of the identity, because either one alone can be right by - // accident: two builds of the same library have the same length, and a - // chunk of the right length could still be the wrong column. - if (chunk.chunk !== "curve" || chunk.rows.length !== index.units.length) { - console.warn("units: the curve document does not match the index", url); - return empty; - } - if (index.build && chunk.build?.id !== index.build.id) { - console.warn("units: the curve document is from another build", url); - return empty; - } + const rows = await readChunk(indexUrl, "curve", index); + if (!rows) return empty; const curves = new Map(); index.units.forEach((unit, at) => { - const row = chunk.rows[at]; + const row = rows[at]; if (Array.isArray(row)) curves.set( unit, @@ -411,7 +539,7 @@ const FIELDS: Record< string, { column: keyof Unit; - kind: "number" | "text"; + kind: "number" | "text" | "flag"; /** * Read this instead of the column, where what a player asks about is not * what the index stores. Only `heat` needs it, and it needs it because @@ -450,6 +578,17 @@ const FIELDS: Record< tech: { column: "tech_base", kind: "text" }, role: { column: "role", kind: "text" }, type: { column: "unit_type", kind: "text" }, + melee: { column: "melee", kind: "flag" }, +}; + +/** What a player may write for yes and for no. */ +const FLAGS: Record = { + yes: true, + y: true, + true: true, + no: false, + n: false, + false: false, }; /** Weight classes by the name a player uses rather than by MegaMek's code. */ @@ -553,6 +692,18 @@ export function matchesFilter(unit: Unit, filter: Filter): boolean { : text.includes(filter.value); } + // A flag is asked with a word rather than compared with a number, and a + // design that has no answer is returned by neither side of it. `melee:no` + // is designs that carry nothing for a physical exchange, not designs the + // question was never put to - every platoon can swarm a Mek, so helm + // declines to answer for infantry, and passing that through as "no" would + // put two thousand platoons in a list of machines that cannot brawl. + if (field.kind === "flag") { + const want = FLAGS[filter.value]; + if (want === undefined) return false; + return held === want; + } + // A design the library has no figure for cannot satisfy a comparison // against one. Kept out rather than let through: a filter that quietly // includes the rows it knows nothing about is a filter nobody can trust. -- 2.51.2