diff --git a/plan/forces.md b/plan/forces.md index 64aa082..1ca7eda 100644 --- a/plan/forces.md +++ b/plan/forces.md @@ -244,6 +244,14 @@ where a player brings something. because putting it at the top of "most armour" would be a lie about the only thing the sort claims to know. +- [x] **A Clan design carries both its names.** Search already matched the + display name, so `fire moth` found seventeen rows that all said "Dasher" + and nothing said they were the same mech. The second name is shown + beside the chassis in the results and the roster, read out of the middle + of the display name rather than as the first parenthetical: 229 designs + in 0.51.0 have a parenthetical *model* — `Archer (Wolf)` — and 11 have + both. + - [x] **A design says what it is on the row it is on.** The results and the roster showed a name, a role, a year, a tonnage and a battle value, and a player choosing between two heavies could not see that one of them diff --git a/web/scripts/hangar.test.mjs b/web/scripts/hangar.test.mjs index 6d73821..a8d1a6e 100644 --- a/web/scripts/hangar.test.mjs +++ b/web/scripts/hangar.test.mjs @@ -27,6 +27,7 @@ import { matchesFilter, parseQuery, search, + secondName, stringifyQuery, suggestions, } from "../src/units.ts"; @@ -78,6 +79,31 @@ test("a Clan design is found by the name a player types", () => { assert.equal(found[0].name, "Black Hawk (Nova) Prime"); }); +test("a Clan design's other name is read out of the display name", () => { + assert.equal(secondName(NOVA), "Nova"); + assert.equal(secondName(ATLAS), null); + // The model is a parenthetical too, and it is not the name. 11 designs in + // 0.51.0 carry both, and 229 more have a parenthetical model and no second + // name at all — `Archer (Wolf)` is one mech, not two. + assert.equal( + secondName({ + chassis: "Dasher", + model: "(Aletha)", + name: "Dasher (Fire Moth) (Aletha)", + }), + "Fire Moth", + ); + assert.equal( + secondName({ chassis: "Archer", model: "(Wolf)", name: "Archer (Wolf)" }), + null, + ); + // No model at all, which is how a base design is written. + assert.equal( + secondName({ chassis: "Baboon", model: "", name: "Baboon (Howler)" }), + "Howler", + ); +}); + test("word order does not matter and case does not either", () => { assert.equal(search([NOVA], "PRIME black").length, 1); }); diff --git a/web/src/screens/hangar.ts b/web/src/screens/hangar.ts index 914eb97..c6f33f9 100644 --- a/web/src/screens/hangar.ts +++ b/web/src/screens/hangar.ts @@ -42,6 +42,7 @@ import { parseQuery, search, stringifyQuery, + secondName, suggestions, type Unit, excessHeat, @@ -220,6 +221,28 @@ function kind(unit: Unit): string { return rest === "Quad" || rest === "Tripod" ? `${rest} ${type}` : rest; } +/** + * The chassis, with the design's other name after it. + * + * A Clan design has two names and MegaMek keeps them in separate fields, so a + * row built from the chassis alone answers a search for `fire moth` with a + * list of Dashers and nothing to say they are the same mech. Dimmed rather + * than given its own line: it is one name, and the chassis is still the word + * the eye scans the column for. + */ +function chassis(unit: Unit): (Node | string)[] { + const other = secondName(unit); + return other + ? [ + unit.chassis, + el("span", { + className: "hangar-second-name", + textContent: ` (${other})`, + }), + ] + : [unit.chassis]; +} + /** * One line of facts under a design's name, ending in what the machine is. * @@ -791,10 +814,7 @@ export function hangarScreen(): Node[] { sprite(pick.unit, 44, state), el("div", { className: "hangar-row-name" }, [ el("div", { className: "hangar-row-title" }, [ - el("div", { - className: "hangar-chassis", - textContent: pick.unit.chassis, - }), + el("div", { className: "hangar-chassis" }, chassis(pick.unit)), el("div", { className: "hint hangar-model", textContent: pick.unit.model ?? "", @@ -939,10 +959,7 @@ export function hangarScreen(): Node[] { return el("li", { className: "hangar-hit" }, [ sprite(unit, SPRITE_W, state), el("div", { className: "hangar-hit-name" }, [ - el("div", { - className: "hangar-chassis", - textContent: unit.chassis, - }), + el("div", { className: "hangar-chassis" }, chassis(unit)), el("div", { className: "hint hangar-model", textContent: unit.model ?? "", diff --git a/web/src/styles.css b/web/src/styles.css index f72b711..5e9b808 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -6563,6 +6563,13 @@ footer .debug { overflow-wrap: anywhere; } +/* A Clan design's other name, beside the chassis rather than under it. Dimmed + because the chassis is what the column is scanned by, and present because it + is half of what gets typed into the search box. */ +.hangar-second-name { + opacity: 0.7; +} + /* The roster column is narrow and a chassis there was being cut off mid-word with a Remove button beside it. Wrapping costs a row a line and shows the whole name, which is the point of the column. */ diff --git a/web/src/units.ts b/web/src/units.ts index d729702..bb1ac77 100644 --- a/web/src/units.ts +++ b/web/src/units.ts @@ -125,6 +125,31 @@ export function candidates(name: string): string[] { 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. *