Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
32 kB · 875 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876/** * 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/<release>/<megamek>/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<T> { 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<UnitIndex> | 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<T>( indexUrl: string, name: string, index: UnitIndex,): Promise<T[] | null> { const url = indexUrl.replace(/units\.json$/, `${name}.json`); if (url === indexUrl) return null;
let chunk: Chunk<T>; 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<T>; } 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<void> { if (!Array.isArray(index.chunks) || index.chunks.length === 0) return;
const [art, figures, paperwork, combat] = await Promise.all([ readChunk<string | null>(indexUrl, "art", index), readChunk<Figures>(indexUrl, "figures", index), readChunk<Paperwork>(indexUrl, "paperwork", index), readChunk<CombatColumns>(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<UnitIndex> { 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<Map<Unit, number[]>> { const empty = new Map<Unit, number[]>(); const rows = await readChunk<number[]>(indexUrl, "curve", index); if (!rows) return empty;
const curves = new Map<Unit, number[]>(); 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<string, number> = { 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<string, boolean> = { 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<string>(); 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; }}