diff --git a/web/scripts/seat-shuffle.test.mjs b/web/scripts/seat-shuffle.test.mjs new file mode 100644 index 0000000..2734591 --- /dev/null +++ b/web/scripts/seat-shuffle.test.mjs @@ -0,0 +1,86 @@ +/** + * The reroll control on a bot seat, on the challenge screen. + * + * A bot seat's camo is rolled once and cached in `rolled`, keyed by + * scenario-path and slot name, so the AI fights in exactly the pattern the + * preview showed. The reroll button's whole job is to clear that one cache + * entry, roll again, and tell the preview to redraw — get any of the three + * wrong and either nothing happens on click, or the preview and the launch + * end up disagreeing about the AI's camo again. + * + * Structural, like this screen's other checks. There is no DOM under `npm + * test`, and a wrong wiring here does not throw — the button still renders + * and still looks clickable, it just does not do the one thing it exists for. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const web = fileURLToPath(new URL("../", import.meta.url)); +const source = await readFile(join(web, "src/screens/challenge.ts"), "utf8"); + +function shuffleBlock() { + const block = source.match( + /const shuffle = el\("button", \{[\s\S]*?\}\);\s*const setShuffle[\s\S]*?shuffle\.addEventListener\("click", \(\) => \{([\s\S]*?)\}\);/, + ); + assert.ok(block, "the reroll button's click handler moved or was renamed"); + return block[1]; +} + +test("reroll clears the cached roll before asking for a new one", () => { + const body = shuffleBlock(); + const deleteAt = body.indexOf("rolled.delete("); + const rerollAt = body.indexOf("rolledFor(slot.name)"); + assert.ok(deleteAt !== -1, "the click handler no longer clears the cache"); + assert.ok(rerollAt !== -1, "the click handler no longer rolls again"); + assert.ok( + deleteAt < rerollAt, + "rolledFor(slot.name) would just hand back the old roll unless the " + + "cache is cleared first", + ); +}); + +test("reroll clears the same key rolledFor reads", () => { + const keyed = source.match( + /function rolledFor\(slot: string\): Promise \{\s*const key = `([^`]*)`;/, + ); + assert.ok(keyed, "rolledFor's cache key moved or was renamed"); + const body = shuffleBlock(); + assert.match( + body, + /rolled\.delete\(`\$\{scenario\?\.path\}\|\$\{slot\.name\}`\)/, + `the click handler clears a different key than rolledFor reads ` + + `(rolledFor uses \`${keyed[1]}\`); a mismatch here leaves the old ` + + "roll in place", + ); +}); + +test("reroll tells the preview to redraw", () => { + const body = shuffleBlock(); + assert.match( + body, + /refreshPreview\(\)/, + "the click handler no longer calls refreshPreview, so the preview " + + "would keep showing the old camo after a reroll", + ); +}); + +test("the reroll button only shows on a bot seat", () => { + assert.match( + source, + /const setShuffle = \(choice: string\): void => \{\s*shuffle\.hidden = choice !== "bot";\s*\};/, + "the reroll button is no longer hidden for self/player seats — it has " + + "nothing to reroll for either", + ); + assert.match( + source, + /select\.addEventListener\("change", \(\) => \{[\s\S]*?setShuffle\(select\.value\);[\s\S]*?\}\);/, + "the seat select's change handler no longer updates the reroll " + + "button's visibility, so it would keep showing after switching a " + + "seat away from the AI", + ); +}); diff --git a/web/src/screens/challenge.ts b/web/src/screens/challenge.ts index 0b59b19..8737314 100644 --- a/web/src/screens/challenge.ts +++ b/web/src/screens/challenge.ts @@ -700,6 +700,25 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { } }; setFace(seats.get(slot.name) ?? "bot"); + // A fresh roll for this seat's bot camo, only meaningful while it is + // actually a bot seat — a self or player seat has nothing here to + // reroll. Hidden rather than absent, so the row does not reflow when + // the select flips it on. + const shuffle = el("button", { + type: "button", + className: "secondary small-btn seat-shuffle", + textContent: "⟳", + "aria-label": `Reroll ${slot.name}'s camo`, + }); + const setShuffle = (choice: string): void => { + shuffle.hidden = choice !== "bot"; + }; + setShuffle(seats.get(slot.name) ?? "bot"); + shuffle.addEventListener("click", () => { + rolled.delete(`${scenario?.path}|${slot.name}`); + void rolledFor(slot.name); + refreshPreview(); + }); const options: [SeatChoice, string][] = [ ["self", `You${session.handle ? ` (${at(session.handle)})` : ""}`], ["bot", "Play against AI"], @@ -721,6 +740,7 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { select.addEventListener("change", () => { seats.set(slot.name, select.value as SeatChoice); setFace(select.value); + setShuffle(select.value); updateNext(); // The seat's camo and caption both follow who holds it. refreshPreview(); @@ -734,7 +754,7 @@ export function challengeScreen(session: Session, mode: ChallengeMode): Node[] { textContent: slot.units.join(", "), }), ]), - el("div", { className: "seat-pick" }, [face, select]), + el("div", { className: "seat-pick" }, [face, select, shuffle]), ]), ); } diff --git a/web/src/styles.css b/web/src/styles.css index e6b436a..6cbd556 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1597,6 +1597,13 @@ footer .debug { object-fit: cover; } +/* Rerolls a bot seat's camo. Chrome from .small-btn; only the glyph is its + own, and line-height keeps a lone character centred in the padding. */ +.seat-shuffle { + padding: 0.35rem 0.5rem; + line-height: 1; +} + .lineup-chip:not([disabled]):hover { border-color: var(--accent); }