From c7747b347ef80be2564c4eea90d584bdba40d567 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 24 Aug 2026 21:44:45 -0400 Subject: [PATCH] feat(forces): ask the legs, and offer the values a column takes `biped`, `quad` and `tripod` join the query language as flags read off the configuration, with a QuadVee counted as a quad and a LAM as a biped. The filter builder now offers the operators a key answers to and a list of values where the column has a closed set of them, and the dialog lists every yes-or-no key in one Booleans section built from the language's own list. --- plan/forces.md | 14 ++++ web/scripts/hangar.test.mjs | 103 ++++++++++++++++++++++++-- web/src/screens/hangar.ts | 128 +++++++++++++++++++++++++------- web/src/styles.css | 7 ++ web/src/units.ts | 141 +++++++++++++++++++++++++++++++----- 5 files changed, 341 insertions(+), 52 deletions(-) diff --git a/plan/forces.md b/plan/forces.md index 68ad31e..6a1f162 100644 --- a/plan/forces.md +++ b/plan/forces.md @@ -292,6 +292,20 @@ where a player brings something. rather than what can be drawn: every design has a battle value, and the 99 with no `mekset.txt` entry keep the default silhouette MegaMek draws them with. Battle armour is the one class with art for every design. +- [x] **A control that says what a column takes.** The filter builder offered + three boxes and no vocabulary: a player who did not already know that a + flag is asked with `yes`, or how MegaMek spells "Missile Boat", learned + it from an empty result. The operators are now the ones the chosen key + answers to — a flag compares with nothing — and a column with a closed + set of values is a list to choose from. The roles, tech bases and unit + types in that list are read off the index in hand rather than copied + into the screen, so they cannot go stale without the library moving. + Three of the flags are new: `biped`, `quad` and `tripod`, the `Config:` + line as three questions, with a QuadVee counted as a quad and a LAM as a + biped the way helm counts them. The dialog gained a Booleans section + listing every yes-or-no key in one place, built from the query + language's own list so a flag cannot be added without appearing there. + - [x] **Order the results.** A filtered search came back alphabetically, so comparing eleven heavies meant reading every row. One control beside the count, and the direction is part of each choice rather than a second diff --git a/web/scripts/hangar.test.mjs b/web/scripts/hangar.test.mjs index 5a85574..34bb501 100644 --- a/web/scripts/hangar.test.mjs +++ b/web/scripts/hangar.test.mjs @@ -24,6 +24,9 @@ import { } from "../src/force.ts"; import { FILTER_KEYS, + FLAG_KEYS, + filterValues, + legs, loadUnits, matchesFilter, parseQuery, @@ -716,6 +719,87 @@ test("symmetrical is a key the box knows and writes back", () => { assert.equal(stringifyQuery(query), "symmetrical:yes class:heavy"); }); +test("the legs a design stands on are read the way helm reads them", () => { + // The two configurations that do not say their own shape are the ones worth + // pinning: a QuadVee stands on four legs and a LAM on two. The `OmniMek` and + // `FrankenMek` suffixes say how a design was built, not what shape it is. + assert.equal(legs({ config: "Biped OmniMek" }), "biped"); + assert.equal(legs({ config: "Biped FrankenMek" }), "biped"); + assert.equal(legs({ config: "LAM" }), "biped"); + assert.equal(legs({ config: "Quad" }), "quad"); + assert.equal(legs({ config: "QuadVee OmniMek" }), "quad"); + assert.equal(legs({ config: "Tripod" }), "tripod"); + // Only a Mek has one, and no answer is not "biped". + assert.equal(legs({ config: null }), null); + + const quad = { chassis: "Goliath", model: "GOL-1H", config: "Quad" }; + const tank = { chassis: "Manticore", model: "Standard", config: null }; + assert.equal( + matchesFilter(quad, { key: "quad", op: ":", value: "yes" }), + true, + ); + assert.equal( + matchesFilter(quad, { key: "biped", op: ":", value: "no" }), + true, + ); + assert.equal( + matchesFilter(quad, { key: "tripod", op: ":", value: "yes" }), + false, + ); + // A design with no configuration is returned by neither answer, the rule + // every other flag follows. + for (const key of ["biped", "quad", "tripod"]) { + for (const value of ["yes", "no"]) { + assert.equal( + matchesFilter(tank, { key, op: ":", value }), + false, + `${key}:${value}`, + ); + } + } +}); + +test("a picker is told which keys take which answers", () => { + // The controls beside the search box are built from the field table, so a + // key that takes a closed set of values is offered as a list rather than as + // an empty box: not every player knows a flag is asked with `yes`. + for (const key of ["biped", "quad", "tripod", "melee", "omni", "clan"]) { + assert.ok(FILTER_KEYS.includes(key), key); + assert.ok(FLAG_KEYS.includes(key), key); + assert.deepEqual(filterValues(key, []), ["yes", "no"]); + } + // Every flag, and nothing that is not one. + assert.deepEqual(FLAG_KEYS, [ + "biped", + "clan", + "melee", + "omni", + "quad", + "symmetrical", + "tripod", + ]); + + // The weight classes a player names, and nothing for a column that takes + // whatever can be typed into it. + assert.deepEqual(filterValues("class", []), [ + "light", + "medium", + "heavy", + "assault", + ]); + assert.deepEqual(filterValues("chassis", []), []); + + // Roles and tech bases are read off the index in hand rather than copied + // from MegaMek's list, so they cannot go stale without the library moving. + const library = [ + { role: "Sniper", tech_base: "Clan" }, + { role: "Brawler", tech_base: "Clan" }, + { role: "Sniper", tech_base: "Inner Sphere" }, + ]; + assert.deepEqual(filterValues("role", library), ["Brawler", "Sniper"]); + assert.deepEqual(filterValues("tech", library), ["Clan", "Inner Sphere"]); +}); + test("a design with no figure fails a comparison rather than passing it", () => { // A filter that quietly includes the rows it knows nothing about is one // nobody can trust. @@ -838,17 +922,26 @@ test("weight asks the same question as mass", () => { }); test("the reference explains every key the box accepts", async () => { - // The list under Filters is the only place a key's meaning is written down, - // so a key the parser takes and the dialog omits is a key nobody can learn. + // The dialog is the only place a key's meaning is written down, so a key + // the parser takes and the dialog omits is a key nobody can learn. Two + // lists: the columns under Filters, and the yes-or-no keys under Booleans, + // which the dialog builds from FLAG_KEYS and this table of one-liners. const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); - const documented = new Set( - [...hangar.matchAll(/filter\(\s*"([^"]+)"/g)].flatMap((m) => + const meanings = /const FLAG_MEANINGS[^{]*\{([\s\S]*?)\n\};/.exec(hangar); + const documented = new Set([ + ...[...hangar.matchAll(/filter\(\s*"([^"]+)"/g)].flatMap((m) => m[1].split(",").map((key) => key.trim()), ), - ); + ...[...meanings[1].matchAll(/^\s*(\w+):/gm)].map((m) => m[1]), + ]); for (const key of FILTER_KEYS) { assert.ok(documented.has(key), `${key} is accepted but never explained`); } + // Every flag has a line of its own rather than falling back to the one + // sentence that is true of all of them. + for (const key of FLAG_KEYS) { + assert.match(meanings[1], new RegExp(`^\\s*${key}:`, "m"), key); + } // And each row carries a term a player can paste into the box. for (const [, doubled, singled] of hangar.matchAll( /filter\(\s*"(?:[^"\\]|\\.)*"\s*,\s*"(?:[^"\\]|\\.)*"\s*,\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*,?\s*\)/g, diff --git a/web/src/screens/hangar.ts b/web/src/screens/hangar.ts index 1c25ab7..094ec85 100644 --- a/web/src/screens/hangar.ts +++ b/web/src/screens/hangar.ts @@ -51,6 +51,9 @@ import { SPRITE_H, SPRITE_W, applyCamo } from "../camo/mech"; import { paintWhenReady, tile } from "../megamek-art"; import { FILTER_KEYS, + FLAG_KEYS, + filterKind, + filterValues, loadCurves, loadUnits, parseQuery, @@ -96,6 +99,9 @@ const NUMBER = new Intl.NumberFormat(); /** How many results are drawn before "Show more" is offered. */ const PAGE = 12; +/** The comparisons a number answers to, in the order the box writes them. */ +const ORDER_OPS = [":", "=", "<", "<=", ">", ">="]; + /** * The orders a list of designs can be put in, and which way each one runs. * @@ -171,6 +177,36 @@ function filter(keys: string, means: string, eg: string): HTMLElement { ]); } +/** + * What each yes-or-no key asks, in one line. + * + * Short on purpose. What `melee` counts, and why infantry answers neither + * way, is helm's reading and takes a paragraph; a player reading this list + * wants to know which questions can be asked at all. + * + * The dialog lists `FLAG_KEYS` rather than this, so a flag added to the query + * language appears here whether or not anybody wrote it a line — with the + * fallback below, which says the only thing that is true of every one of them. + */ +const FLAG_MEANINGS: Record = { + melee: "built to fight at arm's length.", + symmetrical: "mirrored locations hold the same things. Meks only.", + omni: "weapon pods swap between configurations.", + clan: "MegaMek counts the design as Clan.", + biped: "stands on two legs. Meks only.", + quad: "stands on four legs. Meks only.", + tripod: "stands on three legs. Meks only.", +}; + +/** One yes-or-no key: what it asks, without the rules behind the answer. */ +function flag(key: string, means = "takes yes or no."): HTMLElement { + return el("li", {}, [ + el("code", { className: "hangar-key", textContent: key }), + el("span", { className: "hint", textContent: ` ${means}` }), + el("code", { className: "hangar-key-eg", textContent: `${key}:yes` }), + ]); +} + /** One question the library cannot answer yet, and what it would look like. */ function soon(key: string, query: string, means: string): HTMLElement { return el("li", {}, [ @@ -655,27 +691,17 @@ export function hangarScreen(): Node[] { "heat one alpha strike leaves that the sinks cannot shed, so zero or less can keep firing.", "heat<=0", ), - filter( - "melee", - "whether it is built to fight at arm's length: a hatchet, a claw, spikes, a shield, triple strength myomer. Takes yes or no, and returns no infantry either way.", - "melee:yes", - ), - filter( - "symmetrical", - "whether both arms, both side torsos and both legs hold the same things. Takes yes or no, and returns nothing but Meks either way.", - "symmetrical:yes", - ), - filter( - "omni", - "whether its weapon pods swap between configurations. Takes yes or no.", - "omni:yes", - ), - filter( - "clan", - "whether MegaMek counts the design as Clan, which tech does by how the column is spelled. Takes yes or no.", - "clan:yes", - ), ]), + el("h3", { textContent: "Booleans" }), + el("p", { + textContent: + "These are asked with yes or no rather than compared with a number. A design the library does not answer for is returned by neither answer, so no is not everything that is not yes.", + }), + el( + "ul", + { className: "hangar-help-filters" }, + FLAG_KEYS.map((key) => flag(key, FLAG_MEANINGS[key])), + ), el("h3", { textContent: "Not yet" }), el("ul", { className: "hangar-help-soon" }, [ soon( @@ -715,26 +741,67 @@ export function hangarScreen(): Node[] { { className: "hangar-pick", "aria-label": "Filter by" }, FILTER_KEYS.map((name) => el("option", { value: name, textContent: name })), ) as HTMLSelectElement; - const op = el( - "select", - { className: "hangar-pick hangar-op", "aria-label": "How" }, - [":", "=", "<", "<=", ">", ">="].map((symbol) => - el("option", { value: symbol, textContent: symbol }), - ), - ) as HTMLSelectElement; + const op = el("select", { + className: "hangar-pick hangar-op", + "aria-label": "How", + }) as HTMLSelectElement; const value = el("input", { type: "text", className: "hangar-pick hangar-value", placeholder: "value", "aria-label": "Value", }) as HTMLInputElement; + /** + * The value control for a column that takes a closed set of values. + * + * Beside the box rather than instead of it: `chassis` takes anything a + * player can type and `melee` takes two words, and one control cannot be + * both. Whichever fits the chosen key is the one on screen. + */ + const choice = el("select", { + className: "hangar-pick hangar-value", + "aria-label": "Value", + hidden: true, + }) as HTMLSelectElement; + + /** + * Offer the operators and the values the chosen key actually takes. + * + * A flag compares with nothing — it is yes or no — and `melee>=3` is a term + * a player can write and nothing can satisfy. So the operators are narrowed + * to what the column answers to, and a closed set of values is a list to + * choose from rather than a word to guess: not every player knows that a + * flag is asked with `yes`, and an empty result does not teach them. + */ + function dressBuilder(): void { + const kind = filterKind(key.value); + const ops = + kind === "flag" ? [":"] : kind === "text" ? [":", "="] : ORDER_OPS; + const held = op.value; + op.replaceChildren( + ...ops.map((symbol) => + el("option", { value: symbol, textContent: symbol }), + ), + ); + op.value = ops.includes(held) ? held : ops[0]!; + op.disabled = ops.length === 1; + + const values = filterValues(key.value, state.units); + choice.replaceChildren( + ...values.map((one) => el("option", { value: one, textContent: one })), + ); + choice.hidden = values.length === 0; + value.hidden = values.length > 0; + } + key.addEventListener("change", dressBuilder); + const addFilter = el("button", { type: "button", className: "secondary small-btn", textContent: "Add filter", }); addFilter.addEventListener("click", () => { - const typed = value.value.trim(); + const typed = (choice.hidden ? value.value : choice.value).trim(); if (!typed) return; const query = parseQuery(state.query); query.filters.push({ key: key.value, op: op.value as never, value: typed }); @@ -746,8 +813,10 @@ export function hangarScreen(): Node[] { key, op, value, + choice, addFilter, ]); + dressBuilder(); function add(unit: Unit): void { state.picks.push({ unit, id: state.nextId++ }); @@ -1339,6 +1408,9 @@ export function hangarScreen(): Node[] { .then( ({ url, index }) => { state.units = index.units; + // The roles and tech bases the picker offers are read off the index, + // so they cannot be offered until it is here. + dressBuilder(); draw(); // After the screen is drawn rather than before it. Nothing on the // screen waits for the curves: the library is searchable the moment diff --git a/web/src/styles.css b/web/src/styles.css index 18f6ca2..2097961 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -7040,6 +7040,13 @@ footer .debug { border-radius: var(--radius); } +/* A key with one comparison - a flag is yes or no, and nothing else - leaves + the control in place and says it is not a choice, rather than removing it + and moving the two beside it. */ +.hangar-pick:disabled { + opacity: 0.55; +} + .hangar-op { font-family: var(--font-data); width: 4rem; diff --git a/web/src/units.ts b/web/src/units.ts index 669ad46..b8417d0 100644 --- a/web/src/units.ts +++ b/web/src/units.ts @@ -95,6 +95,27 @@ 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"; +} + /** * What built one of helm's documents. * @@ -534,6 +555,29 @@ export interface Query { 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. * @@ -554,11 +598,21 @@ const FIELDS: Record< 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 - * the sign a screen shows and the sign a query takes have to be one - * thing. + * 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. */ - derive?: (unit: Unit) => number | null; + values?: readonly string[] | "library"; } > = { year: { column: "year", kind: "number" }, @@ -583,16 +637,35 @@ const FIELDS: Record< derive: excessHeat, }, cost: { column: "cost", kind: "number" }, - class: { column: "weight_class", 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" }, - role: { column: "role", kind: "text" }, - type: { column: "unit_type", 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`, @@ -612,20 +685,50 @@ const FLAGS: Record = { false: false, }; -/** 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, -}; - /** 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]+)(<=|>=|<|>|=|:)(.*)$/; /** -- 2.51.2