/** * helm's index of MegaMek's unit library, as the browser reads it. * * 4,279 designs with the columns a force screen filters and sorts on, plus * the sprite each one is drawn with. Published by helm's own deploy beside * the art it names — `/assets/helm///units.json` — so the * index and the pictures are always the same release. See `megamek.ts` for * why that pairing is the whole point. * * 174KB gzipped, fetched once and kept for the life of the page. A screen * asks for it and gets the same promise every time, so two components * mounting together share one request. * * The address comes from `megamek.ts`, which owns every path into the assets * bucket; this file is deliberately free of imports so its parsing and its * search can be exercised without one. */ /** One design, exactly as the index carries it. */ export interface Unit { /** Chassis and model as two fields, which is the pair a `.mul` records. */ chassis: string; model: string; /** `chassis (clanname) model`, MegaMek's display name. Not a key. */ name: string; /** masterunitlist.info's record id, where the design has one. */ mul_id: number | null; unit_type: string | null; /** Biped, Quad, Tripod, LAM — the chassis arrangement. */ config: string | null; tech_base: string | null; rules_level: string | null; role: string | null; /** The sourcebooks the design appears in, comma-separated. */ source: string | null; year: number | null; mass: number | null; walk_mp: number | null; /** * MegaMek's own running figure, not walk rounded up by half. MASC, a * supercharger and an industrial chassis all move it, and 481 designs in * 0.51.0 run at something other than the arithmetic. */ run_mp: number | null; jump_mp: number | null; total_armor: number | null; bv: number | null; cost: number | null; weight_class: number | null; /** An OmniMech, whose pods can be swapped between configurations. */ omni: boolean | null; clan: boolean | null; /** Relative to `data/images/units/`, for `unitSprite`. */ sprite: string | null; 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; /** * Whether every mirrored pair of locations holds the same things: both * arms, both side torsos, both legs, and for a quad both pairs of legs. * * `null` for anything that is not a Mek, and that is not "no". A tank has a * left side and a right side and no rules-level notion of mirroring them, * so helm declines to answer - which is why a filter on this returns * nothing but Meks whichever way it is asked. */ symmetrical: boolean | null; } /** * The heat one alpha strike leaves behind: positive is heat the sinks cannot * shed in the turn it was made. * * The index carries the difference the other way up, as dissipation less the * heat of firing — helm calls it efficiency, and a positive figure there is a * design that can keep firing. That is the wrong way round for a player, who * is asking how much trouble one alpha strike buys and reads a bigger number * as more of it. Flipped in one place, so the chip on a row and the `heat` * filter cannot come to disagree about which way is bad. * * Weapon heat only. Walking, running and jumping all add to it, and none of * them are in the index's figure. */ export function excessHeat(unit: Unit): number | null { return unit.heat_efficiency === null ? null : -unit.heat_efficiency; } /** * How many legs a design stands on, as one of three words. * * `null` for anything with no configuration, and that is not "biped": only a * Mek has one, so only a Mek is returned by either side of a filter on it. * * Read the way helm reads it, which is not the way the string reads. A * QuadVee stands on four legs and a LAM on two, and neither says so; the * `OmniMek` and `FrankenMek` suffixes say how a design was built rather than * what shape it is, and are ignored. */ export function legs(unit: Unit): "biped" | "quad" | "tripod" | null { if (!unit.config) return null; const config = unit.config.toLowerCase(); // Tripod first only because nothing else contains it; QuadVee gives the // same answer either way round. if (config.includes("tripod")) return "tripod"; if (config.includes("quad")) return "quad"; return "biped"; } /** * Walk, run and jump, in the order a record sheet prints them. * * The running figure is the index's own rather than walking rounded up by a * half: MASC, a supercharger and an industrial chassis all move it, and 481 * designs in 0.51.0 run at something other than the arithmetic. * * Where it runs *faster* than the arithmetic, both figures are printed the way * a record sheet prints them - `10/15(20)`. The bracket is a Fire Moth's MASC * or a Caesar's supercharger, and it is not free: a player who reads 20 as * this machine's speed has missed that reaching it is a roll. Only a boost is * written this way. A design that runs slower than the arithmetic is wearing * something that costs it movement, and has no second figure to offer. */ export function movement(unit: Unit): string { const walk = unit.walk_mp ?? 0; const run = unit.run_mp ?? 0; const plain = Math.ceil(walk * 1.5); const said = run > plain ? `${plain}(${run})` : `${run}`; return `${walk}/${said}/${unit.jump_mp ?? 0}`; } /** * What built one of helm's documents. * * `id` is a digest of the whole library — the MegaMek release, the rules * version, and every design's name in order — and every document helm writes * in one go carries the same one. Two documents that agree on it describe the * same designs in the same order, which is what makes a join by position safe; * two that do not are from different builds and must not be joined at all. * * Optional because a prefix written before helm split the index has no build * block, and those prefixes are still deployed: helm never deletes one. */ export interface Build { megamek: string; stats_producer: string; units: number; id: string; } /** The file itself: what it describes, and what built it. */ export interface UnitIndex { megamek: string; 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[]; } /** One of helm's side documents: the same designs, in the same order. */ interface Chunk { build: Build; chunk: string; 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; symmetrical?: 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; unit.symmetrical = fight.symmetrical ?? null; } }); } /** * The index, fetched once. * * A failed fetch is not cached: the promise is dropped so the next caller * tries again, the same rule the scenario preview's tile cache follows. A * screen that cannot load it has nothing to offer, so the error is the * caller's to show rather than something to paper over with an empty list. */ export function loadUnits(url: string): Promise { if (!loading) { loading = fetch(url) .then(async (response) => { if (!response.ok) { throw new Error(`unit index: ${response.status}`); } const index = (await response.json()) as UnitIndex; await fillFromChunks(url, index); return index; }) .catch((error: unknown) => { loading = null; throw error; }); } return loading; } /** * Every design's firepower curve, joined to the index by position. * * helm publishes the curves as a document of their own beside `units.json`, * because most of the site never asks for them: twenty-four numbers a design * is 65KB gzipped over the library, on top of the index's 174KB, and only the * screens that draw a curve should pay for it. The join is positional — row *n* of the chunk is * design *n* of the index — which is why both carry the same build digest and * why a mismatch is refused rather than reconciled. * * An empty map is a legitimate answer and the reason nothing here throws. A * prefix written before helm split the index has no `curve.json` at all, and a * screen that asks is asking for something to draw, not for something to * fail over: it draws the rest of the row and no chart. * * Tenths on the wire, points here. helm keeps the curves as integers to stay * out of floating point in JSON, and a caller wants the damage. */ export async function loadCurves( indexUrl: string, index: UnitIndex, ): Promise> { const empty = new Map(); const rows = await readChunk(indexUrl, "curve", index); if (!rows) return empty; const curves = new Map(); index.units.forEach((unit, at) => { const row = rows[at]; if (Array.isArray(row)) curves.set( unit, row.map((tenths) => tenths / 10), ); }); return curves; } /** * 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]; } /** * The second name a dual-named design carries, or null. * * MegaMek keeps a Clan design's two names in separate fields and renders them * together — `chassis:Dasher`, `clanname:Fire Moth`, displayed as `Dasher * (Fire Moth) Prime`. The index ships the pair a `.mul` records plus that * display name, so the second name is what is left of `name` once the chassis * and the model are taken off it. Taking the first parenthetical instead would * be wrong 229 times in 0.51.0: `Archer (Wolf)` and `Atlas AS7-00 (Jurn)` are * models, not names, and 11 designs carry both — `Dasher (Fire Moth) * (Aletha)`. * * 434 of the 4,279 designs have one, and a screen that shows the chassis alone * is a screen where a search for `fire moth` answers with a list of Dashers * and nothing to say they are the same mech. */ export function secondName(unit: Unit): string | null { const { chassis, model, name } = unit; if (!name.startsWith(chassis)) return null; let rest = name.slice(chassis.length); if (model && rest.endsWith(model)) rest = rest.slice(0, -model.length); const inner = /^\((.+)\)$/.exec(rest.trim()); return inner?.[1] ?? null; } /** * Designs whose name a player is typing, best match first. * * Matched on chassis, model and display name together, because a player types * what they remember: `nova prime` is `Black Hawk (Nova) Prime`, whose chassis * is "Black Hawk" and whose model is "Prime" — neither field holds the words * that were typed, and the display name holds both. Every word has to appear * somewhere, in any order, so `prime nova` and `nova prime` are one search. * * Filters gate and words rank: `an bv<=1500` is every design whose name has * `an` in it and costs no more than 1,500, best name match first. A query of * filters alone is legitimate and sorts alphabetically. * * Ranked, because a plain substring match answers badly on short queries: `an` * matched `Albatross ALB-5W (Dantalion)` before `Annihilator`, on a word * nobody typed and inside a bracket. A design whose chassis *starts* with what * was typed comes first, then one where a later word does, then the rest — * and alphabetically within a rank, which is the only tiebreak a reader can * predict. Sorting by battle value there instead put Anubis and Antlion above * Annihilator for `an`, which is not what anybody typing two letters meant. */ export function search(units: Unit[], query: string, limit = 40): Unit[] { const { words, filters } = parseQuery(query); const [first] = words; if (!first && !filters.length) return []; const scored: { unit: Unit; rank: number }[] = []; for (const unit of units) { if (!filters.every((filter) => matchesFilter(unit, filter))) continue; const chassis = unit.chassis.toLowerCase(); const haystack = `${unit.name} ${unit.chassis} ${unit.model}`.toLowerCase(); if (!words.every((word) => haystack.includes(word))) continue; // A query that is only filters has nothing to rank by name, so every // design in it sorts together and the alphabet decides. const rank = !first ? 0 : chassis.startsWith(first) ? 0 : startsAWord(chassis, first) ? 1 : chassis.includes(first) ? 2 : 3; scored.push({ unit, rank }); } scored.sort( (a, b) => a.rank - b.rank || a.unit.name.localeCompare(b.unit.name), ); return scored.slice(0, limit).map((hit) => hit.unit); } /** Whether any word of `text` starts with `prefix`. */ function startsAWord(text: string, prefix: string): boolean { return text.split(/[^a-z0-9]+/).some((word) => word.startsWith(prefix)); } /** * The designs an empty box opens on, heaviest class first. * * Named rather than derived. The first version took the dearest few of every * weight class, which put a superheavy nobody has heard of at the top of the * screen and told a player nothing about what they could build: the point of * the opening list is to be recognised, so it is four machines a player can * name from each of the four classes a match is fought in. Ultralight and * superheavy are not in it at all. * * A release that has dropped one of these still fills its row, from the * dearest designs of that class, so the list is never short. */ const ICONIC: [number, string[]][] = [ [ 4, ["Atlas AS7-D", "BattleMaster BLR-1G", "Awesome AWS-8Q", "Stalker STK-3F"], ], [ 3, [ "Warhammer WHM-6R", "Marauder MAD-3R", "Catapult CPLT-C1", "Rifleman RFL-3N", ], ], [ 2, [ "Phoenix Hawk PXH-1", "Shadow Hawk SHD-2H", "Hunchback HBK-4G", "Griffin GRF-1N", ], ], [1, ["Locust LCT-1V", "Jenner JR7-D", "Panther PNT-9R", "Commando COM-2D"]], ]; /** * What to show before anything has been typed. * * An empty search box with an empty list under it reads as a screen that is * still loading, and says nothing about what is in the library. */ export function suggestions(units: Unit[], perClass = 3): Unit[] { const byName = new Map(units.map((unit) => [unit.name, unit])); const out: Unit[] = []; for (const [weight, named] of ICONIC) { const row = named .map((name) => byName.get(name)) .filter((unit): unit is Unit => unit !== undefined) .slice(0, perClass); if (row.length < perClass) { const held = new Set(row.map((unit) => unit.name)); const filler = units .filter((unit) => unit.weight_class === weight && !held.has(unit.name)) .sort((a, b) => (b.bv ?? 0) - (a.bv ?? 0)) .slice(0, perClass - row.length); row.push(...filler); } out.push(...row); } return out; } // --- the query language ---------------------------------------------------- // // A search box that only matches names answers half the questions a player // has. The other half are about numbers and labels — everything under 1,500 // battle value, nothing later than 3025, Clan heavies that jump — and they are // exactly the columns the index carries. // // So the box takes both, in one string, the way Scryfall's does: bare words // match the name, and `key:value` or `key<=value` filters a column. Terms // combine with and; anything unparseable stays a word, because a player typing // a colon into a name should get a search rather than an error. /** What a term compares with. */ type Op = ":" | "=" | "<" | "<=" | ">" | ">="; /** One `key:value` term. */ export interface Filter { key: string; op: Op; value: string; } /** A parsed query: words to match by name, filters to match by column. */ export interface Query { words: string[]; filters: Filter[]; } /** Weight classes by the name a player uses rather than by MegaMek's code. */ const CLASSES: Record = { ultralight: 0, light: 1, medium: 2, heavy: 3, assault: 4, superheavy: 5, colossal: 5, }; /** The four a player means, in the order a player thinks of them. */ const CLASS_NAMES = ["light", "medium", "heavy", "assault"] as const; /** * Whether a design stands on the named legs, where it is a design the * question applies to at all. */ function shaped(unit: Unit, want: string): boolean | null { const held = legs(unit); return held === null ? null : held === want; } /** * The columns a filter can name, and what each one is called in a query. * * Aliased where the word a player reaches for is not the column's name: * `mass`, `tons`, `tonnage` and `weight` are one question, and so are `armor` * and `armour`. * * `era` is deliberately absent. It is not another word for `year`: a year is * when a design was introduced, an era is a period it was available in, and * a design introduced in 2750 is a Star League machine and a Dark Age one * both. Answering that needs the introduction, extinction and reintroduction * dates the index does not carry, so the key is unclaimed rather than wrong. */ const FIELDS: Record< string, { column: keyof Unit; kind: "number" | "text" | "flag"; /** * Read this instead of the column, where what a player asks about is not * what the index stores: `heat` is stored the other way up, and the legs * a design stands on are one word of its configuration. */ derive?: (unit: Unit) => number | boolean | null; /** * The values this column takes, where it takes a closed set of them. * * A picker offers these rather than an empty box, because a player who * does not already know that a flag is asked with `yes` learns it from a * control instead of from an empty result. `library` is the same closed * set, read off the index rather than written down here: MegaMek's roles * and tech bases are its list to change, and a copy of one here would go * stale silently. */ values?: readonly string[] | "library"; } > = { year: { column: "year", kind: "number" }, bv: { column: "bv", kind: "number" }, mass: { column: "mass", kind: "number" }, tons: { column: "mass", kind: "number" }, tonnage: { column: "mass", kind: "number" }, weight: { column: "mass", kind: "number" }, walk: { column: "walk_mp", kind: "number" }, run: { column: "run_mp", kind: "number" }, jump: { column: "jump_mp", kind: "number" }, armor: { column: "total_armor", kind: "number" }, armour: { column: "total_armor", kind: "number" }, fp: { column: "firepower", kind: "number" }, firepower: { column: "firepower", kind: "number" }, damage: { column: "firepower", kind: "number" }, reach: { column: "max_range", kind: "number" }, range: { column: "max_range", kind: "number" }, heat: { column: "heat_efficiency", kind: "number", derive: excessHeat, }, cost: { column: "cost", kind: "number" }, class: { column: "weight_class", kind: "number", values: CLASS_NAMES }, chassis: { column: "chassis", kind: "text" }, model: { column: "model", kind: "text" }, name: { column: "name", kind: "text" }, tech: { column: "tech_base", kind: "text", values: "library" }, role: { column: "role", kind: "text", values: "library" }, type: { column: "unit_type", kind: "text", values: "library" }, melee: { column: "melee", kind: "flag" }, symmetrical: { column: "symmetrical", kind: "flag" }, omni: { column: "omni", kind: "flag" }, // The configuration as three questions with two answers, because a player // asking for a quad is not asking to type one. `config` itself is not a // key: it carries suffixes that say how a design was built rather than what // shape it is, and matching it as text would make `quad` mean QuadVee too. biped: { column: "config", kind: "flag", derive: (unit) => shaped(unit, "biped"), }, quad: { column: "config", kind: "flag", derive: (unit) => shaped(unit, "quad"), }, tripod: { column: "config", kind: "flag", derive: (unit) => shaped(unit, "tripod"), }, // Exact where `tech:clan` is a substring of the tech base that happens to // agree with it: both answer 2,009 of 8,666 designs today, because every // spelling the column carries with "Clan" in it - `Clan Level 2`, // `Mixed (Clan Chassis)` - is one MegaMek also flags. This is the flag // itself, which is MegaMek's judgement rather than a match on how the // column is spelled. clan: { column: "clan", 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, }; /** Every key a query may name, for a screen that wants to say so. */ export const FILTER_KEYS = Object.keys(FIELDS).sort(); /** Every key a query may name whose answer is yes or no. */ export const FLAG_KEYS = FILTER_KEYS.filter( (key) => FIELDS[key]!.kind === "flag", ); /** * What kind of question one key asks, for a control that has to offer it. * * The picker beside the search box is built from this rather than from a * second list of its own: a key added to the table is offered with the right * control by construction, and the two cannot come to disagree about what a * column takes. */ export function filterKind(key: string): "number" | "text" | "flag" | null { return FIELDS[key]?.kind ?? null; } /** * The values a key takes, where it takes a closed set of them, and nothing * where a player types what they like. * * `library` columns are read off the index in hand, so the roles and tech * bases offered are the ones the designs actually carry rather than a copy of * MegaMek's list that would go stale without saying so. */ export function filterValues(key: string, units: Unit[]): string[] { const field = FIELDS[key]; if (!field) return []; // Every flag takes the same two words, so the table does not repeat them. if (field.kind === "flag") return ["yes", "no"]; if (!field.values) return []; if (field.values !== "library") return [...field.values]; const seen = new Set(); for (const unit of units) { const held = unit[field.column]; if (typeof held === "string" && held) seen.add(held); } return [...seen].sort(); } const TERM = /^([a-z]+)(<=|>=|<|>|=|:)(.*)$/; /** * Split a query into tokens, keeping quoted runs together. * * `chassis:"black hawk"` is one term and `"shadow hawk"` is one word, which * is what a library full of two-word names needs: without this, every name * with a space in it is two words that both have to appear somewhere, and * `shadow hawk` matches a Shadow Hawk and also a Hawk Moth beside a Shadow * Cat. Quotes are the only escape there is; a quote that is never closed runs * to the end of the input, because a player is still typing it. */ function tokenize(text: string): string[] { const tokens: string[] = []; let token = ""; let quoted = false; for (const char of text) { if (char === '"') { quoted = !quoted; // The quote itself is a marker and not part of the value, but it does // hold an empty token open: `chassis:""` is a term with no value. token += "\u0000"; continue; } if (!quoted && /\s/.test(char)) { if (token) tokens.push(token); token = ""; continue; } token += char; } if (token) tokens.push(token); return tokens.map((one) => one.replaceAll("\u0000", "")); } /** * Split a query into words and filters. * * A token that is not a term this knows stays a word, including one with a * colon in it: a player typing a name should get a search rather than a * syntax error. */ export function parseQuery(text: string): Query { const words: string[] = []; const filters: Filter[] = []; for (const token of tokenize(text.toLowerCase())) { const term = TERM.exec(token); if (term && FIELDS[term[1]!] && term[3]) { filters.push({ key: term[1]!, op: term[2] as Op, value: term[3] }); } else { words.push(token); } } return { words, filters }; } /** * A query written back out as text. * * The box is the one source of truth for what is being searched — the pills * above it are a view of this string, and editing one rewrites it — so * anything that changes a filter goes through here. Values with a space in * them come back quoted, which is what makes the round trip hold. */ export function stringifyQuery(query: Query): string { const quote = (value: string) => (/\s/.test(value) ? `"${value}"` : value); return [ ...query.words.map(quote), ...query.filters.map((f) => `${f.key}${f.op}${quote(f.value)}`), ].join(" "); } /** Whether one design satisfies one term. */ export function matchesFilter(unit: Unit, filter: Filter): boolean { const field = FIELDS[filter.key]; if (!field) return true; const held = field.derive ? field.derive(unit) : unit[field.column]; if (field.kind === "text") { const text = String(held ?? "").toLowerCase(); return filter.op === "=" ? text === filter.value : 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. const named = filter.key === "class" && filter.value in CLASSES ? CLASSES[filter.value]! : Number.parseFloat(filter.value); if (!Number.isFinite(named)) return false; if (typeof held !== "number") return false; switch (filter.op) { case "<": return held < named; case "<=": return held <= named; case ">": return held > named; case ">=": return held >= named; default: return held === named; } }