diff --git a/web/scripts/hangar.test.mjs b/web/scripts/hangar.test.mjs index 8c26e70..13f52cb 100644 --- a/web/scripts/hangar.test.mjs +++ b/web/scripts/hangar.test.mjs @@ -247,7 +247,7 @@ test("the camo strip works signed out, without offering saved camo", async () => const picker = await readFile(`${web}src/screens/camo-picker.ts`, "utf8"); assert.match( picker, - /export function camoPicker\(session: Session \| null\)/, + /export function camoPicker\(\s*session: Session \| null,/, "the picker takes a session again, so a signed-out screen has no strip", ); assert.match( @@ -264,8 +264,16 @@ test("the camo strip works signed out, without offering saved camo", async () => test("Hangar renders a camo strip whether or not anyone is signed in", async () => { const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); - assert.match(hangar, /camoPicker\(null\)/, "the signed-out strip is gone"); - assert.match(hangar, /camoPicker\(session\)/, "the signed-in strip is gone"); + assert.match( + hangar, + /camoPicker\(null, "random"\)/, + "the signed-out strip is gone", + ); + assert.match( + hangar, + /camoPicker\(session, "random"\)/, + "the signed-in strip is gone", + ); }); test("a query is words and filters together", () => { diff --git a/web/src/screens/camo-picker.ts b/web/src/screens/camo-picker.ts index 25d3716..04860bd 100644 --- a/web/src/screens/camo-picker.ts +++ b/web/src/screens/camo-picker.ts @@ -80,12 +80,17 @@ export type Picker = { * same on every screen that shows this. * * What arrives selected is the account's default if it has one, and the newest - * camo otherwise. A player who has marked one in the editor gets it every + * camo otherwise. With nothing of the player's to select at all - signed out, + * or signed in with an empty collection - `paint` decides: `"none"` stands on + * the grey factory scheme, `"random"` rolls one here and wears it. A player who has marked one in the editor gets it every * time without thinking about it; a player who has not gets the one they most * likely just finished. Either way the choice is one click to change and zero * to accept. */ -export function camoPicker(session: Session | null): Picker { +export function camoPicker( + session: Session | null, + paint: "none" | "random" = "none", +): Picker { const picker: Picker = { rkey: undefined, rolled: null, @@ -174,7 +179,7 @@ export function camoPicker(session: Session | null): Picker { * the same empty tile and the whole thing looked like it was not rolling at * all. */ - function randomOption(): void { + function randomOption(): { rollFor: (userInitiated: boolean) => void } { // A tile and a die, not one button doing both. The tile is the camo: click // it to wear the one you already rolled, the same as every saved camo // beside it. The die is the only thing that rolls, so looking at a roll @@ -213,8 +218,9 @@ export function camoPicker(session: Session | null): Picker { // -lifetime handle, and rolling twenty times should not leak twenty. let url: string | null = null; - function roll(): void { + function roll(userInitiated = true): void { if (die.disabled) return; + if (!userInitiated && chosenByPlayer) return; die.disabled = true; void (async () => { const { randomCamo, renderCamoPng } = await import("../camo/random"); @@ -228,10 +234,15 @@ export function camoPicker(session: Session | null): Picker { // it left the placeholder sitting above the camo it stood in for. mark.remove(); held = blob; - chosenByPlayer = true; + // A roll the strip made for a player who has nothing saved must not + // count as a choice, or their first click on None would be overwritten + // by a roll that was already in flight - and in a lobby it would write + // an account default nobody asked for. + if (!userInitiated && chosenByPlayer) return; + chosenByPlayer ||= userInitiated; // No rkey: there is no record to point at, which is what "ephemeral" // means here and what keeps it out of the account default. - choose(btn, null, true, blob); + choose(btn, null, userInitiated, blob); })() .catch((error: unknown) => { console.warn("camo picker: a camo could not be rolled", error); @@ -242,7 +253,9 @@ export function camoPicker(session: Session | null): Picker { }); } - die.addEventListener("click", roll); + die.addEventListener("click", () => { + roll(); + }); btn.addEventListener("click", () => { // Before the first roll there is nothing to select, so the tile rolls // once to make one. After that it only ever selects. @@ -253,8 +266,28 @@ export function camoPicker(session: Session | null): Picker { chosenByPlayer = true; choose(btn, null, true, held); }); + + return { rollFor: roll }; + } + const random = randomOption(); + + /** + * What stands selected when there is nothing of the player's to select. + * + * A roll rather than None. None is the grey factory scheme, and a screen + * that opens on it opens on the least interesting thing it can draw; a + * player who wants it is one click away, and a player who wants their own + * camo has either marked a default or is about to sign in. The roll is + * ephemeral either way, so nothing is written and nothing is kept. + */ + function fallback(): void { + if (chosenByPlayer) return; + if (paint === "none") { + preselect(plain, null); + return; + } + random.rollFor(false); } - randomOption(); /** * The way out of an empty strip. @@ -287,7 +320,7 @@ export function camoPicker(session: Session | null): Picker { // consolation. if (!session) { note.textContent = "Sign in to fight in a camo you saved."; - preselect(plain, null); + fallback(); return picker; } @@ -299,7 +332,7 @@ export function camoPicker(session: Session | null): Picker { if (!camos.length) { note.textContent = "You have not made a camo yet."; makeOne(); - preselect(plain, null); + fallback(); return; } note.textContent = "Your units will be painted in this."; diff --git a/web/src/screens/hangar.ts b/web/src/screens/hangar.ts index f8139bc..7078d42 100644 --- a/web/src/screens/hangar.ts +++ b/web/src/screens/hangar.ts @@ -19,7 +19,7 @@ */ import { currentSession } from "../api"; -import { footer, pageHead } from "../chrome"; +import { footer } from "../chrome"; import { el, render } from "../dom"; import { library } from "../library"; import { unitIndexUrl, unitSprite } from "../megamek"; @@ -716,7 +716,7 @@ export function hangarScreen(): Node[] { // and kept nowhere, which needs no account — and says what an account would // add. A dead prompt where a control belongs is worse than either. const camo = el("section", { className: "card hangar-camo" }, [ - camoPicker(null).node, + camoPicker(null, "random").node, ]); const screen = el("div", { className: "hangar" }, [ @@ -774,7 +774,7 @@ export function hangarScreen(): Node[] { void currentSession() .then((session) => { if (!session) return; - camo.replaceChildren(camoPicker(session).node); + camo.replaceChildren(camoPicker(session, "random").node); }) .catch((error: unknown) => { // The API being down costs the camo strip and nothing else: the library @@ -804,10 +804,9 @@ export function hangarScreen(): Node[] { ); return [ - pageHead( - "Hangar", - "Build a force to a target: search the library, add designs, watch what it is worth.", - ), + // No page head. The masthead highlights Hangar and the URL says it, so a + // heading that repeats the tab and a line describing the screen to + // somebody already looking at it are both just space above the work. screen, footer(), ]; diff --git a/web/src/units.ts b/web/src/units.ts index 495b5d8..3e0ee5d 100644 --- a/web/src/units.ts +++ b/web/src/units.ts @@ -150,32 +150,70 @@ 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. This is a - * spread rather than a favourites list: the heaviest few designs of each - * weight class, which is a recognisable set nobody had to curate and which - * makes the shape of the library obvious at a glance. + * still loading, and says nothing about what is in the library. */ export function suggestions(units: Unit[], perClass = 3): Unit[] { - const classes = new Map(); - for (const unit of units) { - const weight = unit.weight_class ?? 0; - const held = classes.get(weight) ?? []; - held.push(unit); - classes.set(weight, held); - } + const byName = new Map(units.map((unit) => [unit.name, unit])); const out: Unit[] = []; - for (const weight of [...classes.keys()].sort((a, b) => b - a)) { - const best = classes - .get(weight)! - .slice() - .sort((a, b) => (b.bv ?? 0) - (a.bv ?? 0)) + for (const [weight, named] of ICONIC) { + const row = named + .map((name) => byName.get(name)) + .filter((unit): unit is Unit => unit !== undefined) .slice(0, perClass); - out.push(...best); + + 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; }