diff --git a/web/scripts/hangar.test.mjs b/web/scripts/hangar.test.mjs index 15cca4c..ab56482 100644 --- a/web/scripts/hangar.test.mjs +++ b/web/scripts/hangar.test.mjs @@ -19,9 +19,15 @@ import { shares, standing, targetLine, + targetTerms, withinTarget, } from "../src/force.ts"; -import { search, suggestions } from "../src/units.ts"; +import { + matchesFilter, + parseQuery, + search, + suggestions, +} from "../src/units.ts"; import { HELM_RELEASE, MEGAMEK_VERSION, unitIndexUrl } from "../src/megamek.ts"; const web = fileURLToPath(new URL("../", import.meta.url)); @@ -259,3 +265,109 @@ test("Hangar renders a camo strip whether or not anyone is signed in", async () assert.match(hangar, /camoPicker\(null\)/, "the signed-out strip is gone"); assert.match(hangar, /camoPicker\(session\)/, "the signed-in strip is gone"); }); + +test("a query is words and filters together", () => { + const parsed = parseQuery("warhammer bv<=1500 tech:inner"); + assert.deepEqual(parsed.words, ["warhammer"]); + assert.deepEqual(parsed.filters, [ + { key: "bv", op: "<=", value: "1500" }, + { key: "tech", op: ":", value: "inner" }, + ]); +}); + +test("a colon in a name is a word, not a broken filter", () => { + // A player typing something the parser does not know should get a search + // rather than an error. + const parsed = parseQuery("nova:prime"); + assert.deepEqual(parsed.words, ["nova:prime"]); + assert.equal(parsed.filters.length, 0); +}); + +test("filters gate and words rank", () => { + const library = [ + { + chassis: "Annihilator", + model: "ANH-1A", + name: "Annihilator ANH-1A", + bv: 2178, + mass: 100, + year: 3009, + }, + { + chassis: "Anubis", + model: "ABS-3L", + name: "Anubis ABS-3L", + bv: 806, + mass: 30, + year: 3063, + }, + ]; + const cheap = search(library, "an bv<=1000"); + assert.equal(cheap.length, 1); + assert.equal( + cheap[0].chassis, + "Anubis", + "the expensive one is filtered out, not just ranked down", + ); +}); + +test("a query of filters alone is a search", () => { + const library = [ + { + chassis: "Atlas", + model: "AS7-D", + name: "Atlas AS7-D", + bv: 1897, + mass: 100, + year: 2755, + }, + { + chassis: "Locust", + model: "LCT-1V", + name: "Locust LCT-1V", + bv: 432, + mass: 20, + year: 2499, + }, + ]; + assert.equal(search(library, "mass>=50").length, 1); + assert.equal(search(library, "year<2600")[0].chassis, "Locust"); +}); + +test("a weight class is named, not numbered", () => { + const heavy = { + chassis: "Warhammer", + model: "WHM-6R", + name: "Warhammer WHM-6R", + weight_class: 3, + }; + assert.equal( + matchesFilter(heavy, { key: "class", op: ":", value: "heavy" }), + true, + ); + assert.equal( + matchesFilter(heavy, { key: "class", op: ":", value: "light" }), + false, + ); +}); + +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. + const unknown = { chassis: "X", model: "1", name: "X 1", bv: null }; + assert.equal( + matchesFilter(unknown, { key: "bv", op: "<=", value: "1500" }), + false, + ); +}); + +test("a locked target speaks the same language as the box", () => { + assert.deepEqual(targetTerms({ ...NO_TARGET, year: 3025, mode: "locked" }), [ + "year<=3025", + ]); + assert.deepEqual( + targetTerms({ ...NO_TARGET, year: 3025 }), + [], + "advisory targets impose nothing", + ); +}); diff --git a/web/src/force.ts b/web/src/force.ts index e5449af..5944c41 100644 --- a/web/src/force.ts +++ b/web/src/force.ts @@ -100,6 +100,23 @@ export function targetLine(picks: Pick[], target: Target): number | null { return total > cap ? (cap / total) * 100 : null; } +/** + * The target's constraints, written as query terms. + * + * A locked target is a filter like any other, so it is expressed in the same + * language the box takes rather than as a second mechanism: the screen can + * then show what the target is imposing, and a player can see that + * `year<=3025` in their own query and the one the target added are the same + * kind of thing. Battle value is the exception and stays in code — "what fits + * in what is left" depends on the force, which no static term can say. + */ +export function targetTerms(target: Target): string[] { + if (target.mode !== "locked") return []; + const terms: string[] = []; + if (target.year !== null) terms.push(`year<=${target.year}`); + return terms; +} + /** * Whether a locked target would keep this design out of the results. * diff --git a/web/src/screens/hangar.ts b/web/src/screens/hangar.ts index f7af688..2c31530 100644 --- a/web/src/screens/hangar.ts +++ b/web/src/screens/hangar.ts @@ -30,10 +30,17 @@ import { shares, standing, targetLine, + targetTerms, withinTarget, } from "../force"; import { camoPicker } from "./camo-picker"; -import { loadUnits, search, suggestions, type Unit } from "../units"; +import { + FILTER_KEYS, + loadUnits, + search, + suggestions, + type Unit, +} from "../units"; /** Everything the screen redraws from. */ interface State { @@ -122,10 +129,30 @@ export function hangarScreen(): Node[] { const searchBox = el("input", { type: "search", className: "hangar-search", - placeholder: "Chassis or model", + placeholder: "Chassis, model, or a filter like bv<=1500", "aria-label": "Search the unit library", }) as HTMLInputElement; + /** What the box takes, said once rather than guessed at. */ + const syntax = el("p", { className: "hint hangar-syntax" }, [ + document.createTextNode("Filters: "), + el("code", { textContent: "era<=3025" }), + document.createTextNode(" "), + el("code", { textContent: "bv<=1500" }), + document.createTextNode(" "), + el("code", { textContent: "class:heavy" }), + document.createTextNode(" "), + el("code", { textContent: "tech:clan" }), + document.createTextNode(" "), + el("code", { textContent: "jump>0" }), + document.createTextNode( + `. Any of ${FILTER_KEYS.join(", ")}, with : = < <= > >=.`, + ), + ]); + + /** What the target is imposing on the search, when it is locked. */ + const imposed = el("div", { className: "hangar-imposed" }); + function add(unit: Unit): void { state.picks.push({ unit, id: state.nextId++ }); draw(); @@ -285,8 +312,48 @@ export function hangarScreen(): Node[] { // Every match, not a capped sample: filtering 4,279 rows costs a // fraction of a millisecond, and a count that says "400+" when the answer // is 38 is worse than no count. - const matches = query - ? search(state.units, query, state.units.length).filter((unit) => + // The target's own terms go in front of what was typed, so a locked + // target and a typed filter are one query rather than two mechanisms + // arguing. Battle value stays out of it and is applied below: what fits + // depends on the force, which no static term can say. + const terms = targetTerms(state.target); + const locked = terms.join(" "); + const full = [locked, query].filter(Boolean).join(" "); + + // Shown rather than silent: a search answering with a third of the library + // and no reason given is a screen a player argues with. + imposed.replaceChildren( + ...(state.target.mode === "locked" && + (terms.length || + state.target.battleValue !== null || + state.target.tonnage !== null) + ? [ + el("span", { className: "hint", textContent: "Locked by target:" }), + ...terms.map((term) => + el("code", { className: "hangar-term", textContent: term }), + ), + ...(state.target.battleValue !== null + ? [ + el("code", { + className: "hangar-term", + textContent: `bv<=${NUMBER.format(Math.max(0, now.remaining ?? 0))}`, + }), + ] + : []), + ...(state.target.tonnage !== null + ? [ + el("code", { + className: "hangar-term", + textContent: `tons<=${Math.max(0, state.target.tonnage - now.tonnage)}`, + }), + ] + : []), + ] + : []), + ); + + const matches = full + ? search(state.units, full, state.units.length).filter((unit) => withinTarget(unit, state.target, now), ) : suggestions(state.units).filter((unit) => @@ -294,9 +361,10 @@ export function hangarScreen(): Node[] { ); const hits = matches.slice(0, state.shown); - found.textContent = query - ? `${NUMBER.format(matches.length)} matching` - : `${NUMBER.format(state.units.length)} designs`; + found.textContent = + query || locked + ? `${NUMBER.format(matches.length)} matching` + : `${NUMBER.format(state.units.length)} designs`; if (!hits.length) { results.replaceChildren( @@ -311,7 +379,7 @@ export function hangarScreen(): Node[] { } results.replaceChildren( - ...(query + ...(query || locked ? [] : [ el("p", { @@ -444,6 +512,8 @@ export function hangarScreen(): Node[] { el("div", { className: "hangar-panes" }, [ el("section", { className: "card hangar-library" }, [ el("div", { className: "hangar-search-row" }, [searchBox, found]), + imposed, + syntax, results, el("div", { className: "hangar-ways-in" }, [ el( diff --git a/web/src/styles.css b/web/src/styles.css index 044e224..88ebf82 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -6151,3 +6151,32 @@ footer .debug { align-self: flex-start; margin-top: 0.35rem; } + +/* What the box takes, and what the target is imposing on it. Both are small + print under the search: a player who knows the syntax should not have to + read them, and one who does not should not have to guess. */ +.hangar-syntax { + margin: 0.1rem 0 0.35rem; + font-size: 0.78rem; +} + +.hangar-syntax code, +.hangar-term { + font-family: var(--font-data); + font-size: 0.9em; + padding: 0.05rem 0.25rem; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 3px; +} + +.hangar-imposed { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} + +.hangar-imposed .hangar-term { + border-color: var(--accent); +} diff --git a/web/src/units.ts b/web/src/units.ts index a86c627..55b24f4 100644 --- a/web/src/units.ts +++ b/web/src/units.ts @@ -100,6 +100,10 @@ export function candidates(name: string): string[] { * 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 @@ -109,23 +113,29 @@ export function candidates(name: string): string[] { * Annihilator for `an`, which is not what anybody typing two letters meant. */ export function search(units: Unit[], query: string, limit = 40): Unit[] { - const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const { words, filters } = parseQuery(query); const [first] = words; - if (first === undefined) return []; + 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; - const rank = chassis.startsWith(first) + // 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 - : startsAWord(chassis, first) - ? 1 - : chassis.includes(first) - ? 2 - : 3; + : chassis.startsWith(first) + ? 0 + : startsAWord(chassis, first) + ? 1 + : chassis.includes(first) + ? 2 + : 3; scored.push({ unit, rank }); } @@ -169,3 +179,131 @@ export function suggestions(units: Unit[], perClass = 3): Unit[] { } 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[]; +} + +/** + * 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: + * `era` and `year` are the same question, and so are `mass`, `tons` and + * `tonnage`. + */ +const FIELDS: Record = + { + year: { column: "year", kind: "number" }, + era: { column: "year", kind: "number" }, + bv: { column: "bv", kind: "number" }, + mass: { column: "mass", kind: "number" }, + tons: { column: "mass", kind: "number" }, + tonnage: { column: "mass", kind: "number" }, + walk: { column: "walk_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" }, + reach: { column: "max_range", kind: "number" }, + heat: { column: "heat_efficiency", kind: "number" }, + cost: { column: "cost", kind: "number" }, + class: { column: "weight_class", kind: "number" }, + tech: { column: "tech_base", kind: "text" }, + role: { column: "role", kind: "text" }, + type: { column: "unit_type", kind: "text" }, + }; + +/** 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(); + +const TERM = /^([a-z]+)(<=|>=|<|>|=|:)(.+)$/; + +/** Split a query into words and filters. */ +export function parseQuery(text: string): Query { + const words: string[] = []; + const filters: Filter[] = []; + + for (const token of text.toLowerCase().split(/\s+/).filter(Boolean)) { + const term = TERM.exec(token); + if (term && FIELDS[term[1]!]) { + filters.push({ key: term[1]!, op: term[2] as Op, value: term[3]! }); + } else { + // Not a term this knows: a word, including one with a colon in it. + words.push(token); + } + } + return { words, filters }; +} + +/** 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 = unit[field.column]; + + if (field.kind === "text") { + const text = String(held ?? "").toLowerCase(); + return filter.op === "=" + ? text === filter.value + : text.includes(filter.value); + } + + // 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; + } +}