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.