diff --git a/plan/daily-challenge.md b/plan/daily-challenge.md index e4a15f1..f4d6ba8 100644 --- a/plan/daily-challenge.md +++ b/plan/daily-challenge.md @@ -161,6 +161,14 @@ use spends their attention once. real MegaMek. - [x] **The archive**, at `#daily`: every challenge whose day has come, with what was on the card, playable and explicitly not scored. +- [x] **A way to play it.** Play is on the card and on every archive row. It + is the lobby screen narrowed rather than a screen of its own — see + [scenarios](scenarios.md) — with the day's scenario fixed, the player on + the challenger's side and the bot on the other, and no chat. A press + while signed out asks for an account first and comes back into the same + fight; a spent attempt disables the button and says what it scored. + Blue is the player and red the opposition, on the card's map and on the + forces alike. - [x] **Scoring, attempts and reaping** — see the decisions above. - [x] **A scheduled check**, `scripts/daily.sh`. Not a rollover: which fight is today's is computed from the schedule by both readers, so nothing has to diff --git a/plan/scenarios.md b/plan/scenarios.md index 1ea9d7f..cd8c011 100644 --- a/plan/scenarios.md +++ b/plan/scenarios.md @@ -61,8 +61,18 @@ units are still whatever the scenario file says. picker to two-slot scenarios; Scenario shows the whole catalog. Nothing else differs yet — seat count is still uncapped by Mode (MAX_LINEUP is arena's one-browser-per-match limit, unrelated), and opponents, camo and slot rules are -the same either way. Whether Mode should mean more than a filter is a question -for this epic, since the filter is a property of the catalog. +the same either way. + +**Whether a mode should mean more than a filter: yes, and the daily challenge +is the answer.** It is the third entry into `lobbyScreen`, and it narrows the +screen rather than the catalog — the scenario is fixed, both seats are fixed, +and chat is gone, because a solo fight against the bot has nothing to +negotiate. It is not one more `ChallengeMode`, though: Duel and Scenario are +values of one field on the fresh-open case, and the daily is a case of its own +in `LobbyEntry`, which is what lets it carry a slug and skip `openLobby` +entirely. The pattern to copy for the next one is that — a case of the entry +union, not a mode of the picker — and the thing to hold is that it stays one +screen. ## Done diff --git a/web/scripts/daily-play.test.mjs b/web/scripts/daily-play.test.mjs new file mode 100644 index 0000000..3044b18 --- /dev/null +++ b/web/scripts/daily-play.test.mjs @@ -0,0 +1,293 @@ +/** + * Play, on the daily card and on every archive row: the three states the + * button has, and the stash that carries a signed-out press through sign-in. + * + * The stash is real code against a fake localStorage, the same shape + * lobby-invite.test.mjs uses for its own — peek without consuming, then + * consume once, is genuine logic. Everything about the button is structural + * (there is no DOM under `npm test`, and daily.ts cannot even be imported + * here: it reads a JSON module and two image URL modules a bundler resolves). + * That is the same trade lobby-screen.test.mjs makes, for the same reason: + * every one of these renders fine when it is wrong. A button left enabled + * after an attempt is spent looks exactly like one that is not, and only says + * so on the press, by which point the API has already refused it. + * + * Run with `npm test`. + */ +import { test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +class MemoryStorage { + constructor() { + this.store = new Map(); + } + getItem(key) { + return this.store.has(key) ? this.store.get(key) : null; + } + setItem(key, value) { + this.store.set(key, String(value)); + } + removeItem(key) { + this.store.delete(key); + } +} +globalThis.localStorage = new MemoryStorage(); + +const { hasPendingDailyPlay, stashDailyPlay, takePendingDailyPlay } = + await import("../src/daily-play-stash.ts"); + +beforeEach(() => { + globalThis.localStorage.store.clear(); +}); + +test("nothing is pending before anything stashes", () => { + assert.equal(hasPendingDailyPlay(), false); + assert.equal(takePendingDailyPlay(), null); +}); + +test("stashing makes it pending, and peeking does not consume it", () => { + stashDailyPlay("parity"); + assert.equal(hasPendingDailyPlay(), true); + assert.equal( + hasPendingDailyPlay(), + true, + "a peek must not consume the stash", + ); +}); + +test("taking returns the stashed slug exactly once", () => { + stashDailyPlay("parity"); + assert.equal(takePendingDailyPlay(), "parity"); + assert.equal(hasPendingDailyPlay(), false, "the stash was not cleared"); + assert.equal(takePendingDailyPlay(), null, "a second take must find nothing"); +}); + +test("the daily stash and the lobby stash are different keys", async () => { + // Both survive the same redirect and both live in localStorage. One key for + // the two would have a shared lobby link resolve as a challenge slug, or the + // other way round, and neither is a failure anything would report. + const daily = await readFile( + fileURLToPath(new URL("../src/daily-play-stash.ts", import.meta.url)), + "utf8", + ); + const lobby = await readFile( + fileURLToPath(new URL("../src/lobby-invite-stash.ts", import.meta.url)), + "utf8", + ); + const key = (source) => /PENDING_KEY = "([^"]+)"/.exec(source)?.[1]; + assert.ok(key(daily), "daily-play-stash.ts no longer names its key"); + assert.ok(key(lobby), "lobby-invite-stash.ts no longer names its key"); + assert.notEqual(key(daily), key(lobby), "the two stashes share a key"); +}); + +test("a storage failure is swallowed, not thrown", () => { + const real = globalThis.localStorage; + globalThis.localStorage = { + getItem() { + throw new Error("blocked"); + }, + setItem() { + throw new Error("blocked"); + }, + removeItem() { + throw new Error("blocked"); + }, + }; + try { + assert.doesNotThrow(() => stashDailyPlay("parity")); + assert.equal(hasPendingDailyPlay(), false); + assert.equal(takePendingDailyPlay(), null); + } finally { + globalThis.localStorage = real; + } +}); + +// --- the button's three states (structural, see this file's own doc) ------- + +const web = fileURLToPath(new URL("../", import.meta.url)); +const daily = await readFile(`${web}src/screens/daily.ts`, "utf8"); +const archive = await readFile(`${web}src/screens/daily-archive.ts`, "utf8"); +const router = await readFile(`${web}src/router.ts`, "utf8"); + +const control = daily.match( + /export function playControl\(challenge: Challenge, day: string\): PlayControl \{[\s\S]*?\n\}/, +); + +test("playable: Play is pressable before the control plane has said anything", () => { + assert.ok(control, "playControl moved, was renamed, or was never added"); + const button = control[0].match(/const play = el\("button", \{[\s\S]*?\}\);/); + assert.ok(button, "the Play button moved or was renamed"); + assert.doesNotMatch( + button[0], + /disabled: true/, + "Play starts disabled - the card is served from S3 and is up whenever " + + "the bucket is, so a button that waits for /api/daily is a button " + + "that is dead during an outage the rest of the card survives", + ); +}); + +test("attempted: a spent attempt disables Play and says what it scored", () => { + assert.ok(control, "playControl moved, was renamed, or was never added"); + const apply = control[0].match( + /applyStatus\(status: DailyStatus\) \{[\s\S]*?\n {4}\},/, + ); + assert.ok(apply, "applyStatus moved or was restructured"); + assert.match( + apply[0], + /if \(!scored \|\| !status\.attempted\) return;/, + "applyStatus no longer leaves Play alone for an unspent attempt, or no " + + "longer exempts a backlog challenge - a past fight costs no attempt " + + "and can be replayed", + ); + assert.match( + apply[0], + /play\.disabled = true;/, + "a spent attempt no longer disables Play", + ); + assert.match( + apply[0], + /number\.format\(status\.score\)/, + "the note no longer says what the player scored - dailyStatus carries " + + "it, and saying only that they are done throws it away", + ); +}); + +test("the card hands its dailyStatus answer to the button", () => { + // The card already asks for the attempt count; the same answer carries + // `attempted` and `score`. Dropping this wiring costs nothing visible - the + // count still fills in, and Play simply stays pressable for a player who + // has already had their go. + const card = daily.match( + /export function dailyCard\(\): HTMLElement \| null \{[\s\S]*?\n\}/, + ); + assert.ok(card, "dailyCard moved or was renamed"); + assert.match( + card[0], + /play\.applyStatus\(status\)/, + "the card no longer tells its Play button what dailyStatus said", + ); +}); + +test("signed out: the first press is a sign-in that stashes the slug", () => { + assert.ok(control, "playControl moved, was renamed, or was never added"); + const handler = control[0].match( + /play\.addEventListener\("click", \(\) => \{[\s\S]*?\n {2}\}\);/, + ); + assert.ok(handler, "Play's click handler moved"); + assert.match( + handler[0], + /if \(!session\) \{/, + "Play no longer checks for a session before starting anything", + ); + assert.match( + handler[0], + /openSignIn\(\{ onBeforeConnect: \(\) => stashDailyPlay\(challenge\.slug\) \}\)/, + "a signed-out press no longer stashes the slug before the OAuth " + + "redirect - the reader would come back to the front page instead of " + + "into the fight they pressed for", + ); + assert.match( + handler[0], + /lobbyScreen\(session, \{ kind: "daily", slug: challenge\.slug \}\)/, + "a signed-in press no longer opens the lobby screen's daily mode", + ); +}); + +test("the lobby screen is imported on the press, not by the front page", () => { + // preview.ts's own note in this file: the front page must not carry the + // match-setup screen in front of the hero for a button most readers of the + // page never press. analytics.test.mjs holds the same line by size. + assert.match( + daily, + /await import\("\.\/lobby"\)/, + "the lobby screen is no longer imported lazily from the card", + ); + assert.doesNotMatch( + daily, + /^import .*from "\.\/lobby"/m, + "the lobby screen is imported statically; it would ship ahead of the " + + "front page for every reader", + ); +}); + +test("the archive carries the same control, and says it does not score", () => { + assert.match( + archive, + /\.\.\.playControl\(challenge, today\)\.nodes/, + "the archive rows no longer carry a Play control - this file's own doc " + + "says a backlog challenge is playable, and nothing here let anyone", + ); + assert.ok(control, "playControl moved, was renamed, or was never added"); + assert.match( + control[0], + /textContent: scored \? "" : "Does not score\."/, + "a challenge whose day has passed no longer says on its face that " + + "playing it is not scored", + ); +}); + +test("a signed-out press comes back into the fight, not onto the front page", () => { + // The OAuth callback lands on the bare root with no hash, so the stash is + // the only thing that says where the reader was going. Peek to route, + // consume in start() - the same split hasPendingCamo() and + // hasPendingLobbyInvite() use. + assert.match( + router, + /if \(hasPendingDailyPlay\(\)\) return "daily";/, + "routeFromLocation no longer notices a pending daily press", + ); + const branch = router.match(/if \(route === "daily"\) \{[\s\S]*?\n {2}\}/); + assert.ok(branch, "the router's daily branch moved or was restructured"); + assert.match( + branch[0], + /const pending = takePendingDailyPlay\(\);/, + "the daily route no longer consumes the stash - an abandoned sign-in " + + "would leave one to hijack a later visit", + ); + assert.match( + branch[0], + /lobbyScreen\(player, \{ kind: "daily", slug: pending \}\)/, + "a restored press no longer lands in the fight it was for", + ); + assert.match( + branch[0], + /markEntered\("lobby"\)/, + "the router no longer syncs what is on screen - the daily mode has no " + + "address of its own, so nothing else would", + ); +}); + +test("the two forces are one list, and the opposition is red rather than orange", () => { + const colours = daily.match(/export const SIDE_COLOURS[\s\S]*?\n\];/); + assert.ok( + colours, + "SIDE_COLOURS moved, was renamed, or stopped being exported", + ); + const triples = [...colours[0].matchAll(/\[(\d+), (\d+), (\d+)\]/g)].map( + (m) => m.slice(1).map(Number), + ); + assert.equal( + triples.length, + 2, + "SIDE_COLOURS is no longer exactly two sides", + ); + + const [player, opponent] = triples; + assert.ok( + player[2] > player[0] && player[2] > player[1], + `the player's side is [${player}], which is not blue-dominant`, + ); + assert.ok( + opponent[0] > opponent[1] && opponent[0] > opponent[2], + `the opposition is [${opponent}], which is not red-dominant`, + ); + // What separates a red from the burnt orange this replaced: an orange has a + // green channel well clear of its blue one. #b05c48 was 92 against 72. + assert.ok( + opponent[1] - opponent[2] <= 12, + `the opposition is [${opponent}]: green sits ${opponent[1] - opponent[2]} ` + + `above blue, which reads as orange rather than red`, + ); +}); diff --git a/web/scripts/daily.test.mjs b/web/scripts/daily.test.mjs index ee29877..4a31fa2 100644 --- a/web/scripts/daily.test.mjs +++ b/web/scripts/daily.test.mjs @@ -174,6 +174,33 @@ test("every scheduled fight lands in an era the card can name", async () => { } }); +test("every challenge names the player's side first, and the bot's second", async () => { + // The API seats a daily off two constants and never parses the scenario to + // find them (services/api/src/matches/daily.rs). The lobby's daily mode + // seats it off this file's own slot order instead, because that is what it + // has. The two only meet at launch: a challenge whose sides came out the + // other way round would draw the player's blue on the force the API hands + // to Princess, and nothing would say so. + const rust = await readFile( + `${repo}/services/api/src/matches/daily.rs`, + "utf8", + ); + const slot = (name) => + new RegExp(`pub const ${name}: &str = "([^"]+)"`).exec(rust)?.[1]; + const player = slot("PLAYER_SLOT"); + const bot = slot("BOT_SLOT"); + assert.ok(player, "daily.rs no longer declares PLAYER_SLOT as a literal"); + assert.ok(bot, "daily.rs no longer declares BOT_SLOT as a literal"); + + for (const { slug, sides } of challenges) { + assert.deepEqual( + sides.map((side) => side.name), + [player, bot], + `${slug}: the sides are not the API's PLAYER_SLOT then BOT_SLOT`, + ); + } +}); + test("every challenge is downloadable, and every download is a challenge", async () => { const published = (await readdir(`${src}../public/daily`)).filter((n) => n.endsWith(".mms"), diff --git a/web/scripts/lobby-screen.test.mjs b/web/scripts/lobby-screen.test.mjs index 8f111d7..9c0d733 100644 --- a/web/scripts/lobby-screen.test.mjs +++ b/web/scripts/lobby-screen.test.mjs @@ -43,6 +43,12 @@ test("lobbyScreen takes a LobbyEntry, not a bare ChallengeMode", () => { /kind:\s*"join";\s*matchId:\s*string/, "LobbyEntry no longer has a join case carrying a matchId", ); + assert.match( + entryType[0], + /kind:\s*"daily";\s*slug:\s*string/, + "LobbyEntry no longer has a daily case carrying a slug - the daily " + + "challenge is a mode of this screen, not a screen of its own", + ); }); test("only the fresh path narrows the pool by Mode; a join gets the whole catalog", () => { @@ -507,15 +513,16 @@ test("a deployed lobby lands on the waiting screen, from either path in", () => ); }); -test("Deploy calls deployLobby and shows the server's message on failure", () => { +test("Deploy launches through launch() and shows the server's message on failure", () => { const handler = source.match( /deploy\.addEventListener\("click", \(\) => \{[\s\S]*?\n \}\);/, ); assert.ok(handler, "Deploy's click handler moved"); assert.match( handler[0], - /deployLobby\(id\)/, - "Deploy no longer calls deployLobby", + /const launched = launch\(\);/, + "Deploy no longer asks launch() what to start - the daily and lobby " + + "paths would have to fork the whole handler instead", ); assert.match( handler[0], @@ -524,6 +531,24 @@ test("Deploy calls deployLobby and shows the server's message on failure", () => ); }); +test("launch() deploys a lobby, and starts a daily through its own endpoint", () => { + const fn = source.match( + /function launch\(\): Promise \| null \{[\s\S]*?\n \}/, + ); + assert.ok(fn, "launch moved, was renamed, or was never added"); + assert.match( + fn[0], + /startDaily\(daily\.slug\)/, + "a daily no longer launches through POST /api/daily/ - the lobby " + + "path would open a lobby row for a fight that has no seats to arrange", + ); + assert.match( + fn[0], + /deployLobby\(id\)/, + "an ordinary lobby no longer deploys through deployLobby", + ); +}); + test("the socket is closed on teardown, and marked left before it closes", () => { const teardown = source.match(/onTeardown\(\(\) => \{[\s\S]*?\n {2}\}\);/); assert.ok( @@ -635,6 +660,119 @@ test("the fresh path syncs the router's shown state before rendering, and again ); }); +// --- the daily challenge: the same screen, narrowed ------------------------ +// +// Every one of these is a thing that renders perfectly well when it is wrong. +// A seat select left in a daily still looks like a lobby, and a player who +// swaps sides gets a different fight scored against everybody else's; a chat +// box left in still draws, and just never has anyone in it. + +test("a daily seats both sides itself, from the scenario's own slot order", () => { + const branch = source.match( + /if \(entry\.kind === "daily"\) \{[\s\S]*?\n {2}\} else if/, + ); + assert.ok(branch, "the daily branch moved or was restructured"); + assert.match( + branch[0], + /current = dailyScenario\(daily\)/, + "a daily no longer fixes the scenario to the day's own fight", + ); + assert.match( + branch[0], + /slot: current\.slots\[0\]\?\.name[\s\S]*?control: "human",[\s\S]*?did: session\.did/, + "the player no longer takes the first slot - every daily scenario names " + + "the player's faction first (services/api/src/matches/daily.rs's " + + "PLAYER_SLOT), and the API seats it that way whatever this screen shows", + ); + assert.match( + branch[0], + /slot: current\.slots\[1\]\?\.name[\s\S]*?control: "bot"/, + "the bot no longer takes the second slot (daily.rs's BOT_SLOT)", + ); +}); + +test("a daily opens no lobby and connects no socket", () => { + const branch = source.match( + /if \(entry\.kind === "daily"\) \{[\s\S]*?\n {2}\} else if/, + ); + assert.ok(branch, "the daily branch moved or was restructured"); + assert.doesNotMatch( + branch[0], + /openLobby\(|connectLobby\(|fetchScenarios\(/, + "a daily must not open a lobby row, connect a socket, or fetch the " + + "catalog - its scenario is compiled into the API and is not in it", + ); +}); + +test("a daily offers no way to change the scenario or to move either seat", () => { + const list = source.match( + /function renderScenarioList\(\): void \{[\s\S]*?\n \}/, + ); + assert.ok(list, "renderScenarioList moved or was renamed"); + const dailyList = list[0].match(/if \(daily\) \{[\s\S]*?\n {6}return;/); + assert.ok(dailyList, "renderScenarioList no longer special-cases a daily"); + assert.doesNotMatch( + dailyList[0], + /type: "radio"|setScenario/, + "a daily still renders a scenario radio - picking another fight would " + + "score a different one against everybody else's day", + ); + + // Both seats are filled by the branch above, so seatActions is unreachable + // in a daily; Kick is the one control that would still render on a claimed + // seat, and it is what a player would use to unseat themselves and swap. + const holder = source.match( + /function seatHolder\(seat: MatchPlayer\)[\s\S]*?\n \}/, + ); + assert.ok(holder, "seatHolder moved or was renamed"); + assert.match( + holder[0], + /\.\.\.\(daily \? \[\] : \[kick\]\)/, + "Kick is back on a daily's seats - there is no lobby to kick anyone " + + "from, and unseating yourself is how a fixed side stops being fixed", + ); +}); + +test("a daily shows no chat and no invite link", () => { + const tree = source.match(/return \[\n {4}el\("h1"[\s\S]*?\n {2}\];/); + assert.ok(tree, "the screen's returned node tree moved or was restructured"); + assert.match( + tree[0], + /\.\.\.\(daily\s*\n?\s*\?\s*\[\]\s*\n?\s*:\s*\[\s*\n?\s*el\("div", \{ className: "choice camo-pick" \}, \[\s*\n?\s*el\("strong", \{ textContent: "Chat" \}\)/, + "the chat block is no longer dropped for a daily - it is one player " + + "against a bot, and there is nobody on the other end", + ); + assert.match( + tree[0], + /\.\.\.\(daily\s*\?\s*\[\]\s*:\s*\[el\("div", \{ className: "row" \}, \[copyLink, copyStatus\]\)\]\)/, + "the invite link is back on a daily - there is no lobby to invite " + + "anyone into", + ); +}); + +test("a daily paints its two forces in the card's own colours", () => { + const fn = source.match( + /async function camoForSlot\(slotName: string\)[\s\S]*?\n \}/, + ); + assert.ok(fn, "camoForSlot moved or was renamed"); + assert.match( + fn[0], + /if \(daily\) return \(await ownCamo\(seat\)\) \?\? sideCamo\(slotName\);/, + "a daily's forces no longer fall back to their side colour - the " + + "opposition would draw bare sprite grey against the player's blue", + ); + const side = source.match( + /function sideCamo\(slotName: string\): ImageData \{[\s\S]*?\n \}/, + ); + assert.ok(side, "sideCamo moved, was renamed, or was never added"); + assert.match( + side[0], + /SIDE_COLOURS\[/, + "the lobby's side colours are no longer daily.ts's SIDE_COLOURS - two " + + "lists for the same two forces is one list that can be wrong", + ); +}); + test("the invite link is disabled until a matchId exists, on both entry paths", () => { const copyLink = source.match(/const copyLink = el\("button", \{[^}]*\}\)/s); assert.ok(copyLink, "the copy-invite-link button moved or was renamed"); diff --git a/web/src/api.ts b/web/src/api.ts index c190010..ba8121d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -370,6 +370,43 @@ export async function dailyStatus(slug: string): Promise { return (await response.json()) as DailyStatus; } +/** A daily challenge that has been started. */ +export interface DailyStart { + matchId: string; + /** + * Whether this one counts. False for a challenge whose day has passed: it + * is playable, it spends no attempt, and it is not ranked. The server + * decides it against its own clock, so this is what actually happened, not + * what the card guessed. + */ + scored: boolean; +} + +/** + * Take on a challenge: the API claims the player's one attempt and launches + * the fight against the bot in a single call. + * + * Not the lobby's openLobby/deployLobby pair. A daily has no seats to arrange + * — both are fixed, and the scenario is compiled into the API rather than + * being in the catalog — so there is nothing for a lobby row to hold. 401 is + * signed out and 409 is an attempt already spent; both arrive as the server's + * own message. + */ +export async function startDaily(slug: string): Promise { + const response = await call(`/api/daily/${encodeURIComponent(slug)}`, { + method: "POST", + }); + const body = (await response.json().catch(() => ({}))) as { + matchId?: string; + scored?: boolean; + message?: string; + }; + if (!response.ok || !body.matchId) { + throw new ApiError(body.message ?? "The challenge could not be started."); + } + return { matchId: body.matchId, scored: body.scored === true }; +} + /** The catalog is static for the life of a deploy, so one fetch per page. */ let scenarios: Promise | null = null; diff --git a/web/src/daily-play-stash.ts b/web/src/daily-play-stash.ts new file mode 100644 index 0000000..5b42f67 --- /dev/null +++ b/web/src/daily-play-stash.ts @@ -0,0 +1,66 @@ +/** + * Carrying "the daily challenge this reader pressed Play on" across an OAuth + * redirect. + * + * The card is on the front page for everybody, so the first press is often a + * press by somebody who is not signed in — and the round trip to their + * provider and back loses everything the page was holding, hash included (see + * router.ts's note on camo/editor.ts's hasPendingCamo()). This is the third + * user of that same mechanism, after the camo editor's unsaved work and + * lobby-invite-stash.ts's shared lobby id, and it holds one slug. + * + * Its own module, importing nothing, for the same two reasons + * lobby-invite-stash.ts has: router.ts has to peek and consume it on every + * landing regardless of which screen ends up rendered, and a file with no + * imports is one `node --test` can load directly. + */ + +const PENDING_KEY = "lance.blue:daily:pending"; + +/** + * Set immediately before the OAuth redirect fires, from the sign-in dialog the + * Play control opens (onBeforeConnect). Set-and-forget: the tab is about to + * leave, so there is nothing to clean up on failure. + */ +export function stashDailyPlay(slug: string): void { + try { + localStorage.setItem(PENDING_KEY, slug); + } catch { + /* Private mode or a full quota: sign-in still works and the reader lands + * on the front page, where the card and its Play button are. */ + } +} + +/** + * Whether a stash exists, without consuming it. routeFromLocation() only ever + * peeks: deciding the route must never by itself discard a stash nothing has + * used yet. Mirrors hasPendingLobbyInvite() and hasPendingCamo(). + */ +export function hasPendingDailyPlay(): boolean { + try { + return localStorage.getItem(PENDING_KEY) !== null; + } catch { + return false; + } +} + +/** + * Reads and clears the stash in one step. Called exactly once per landing on + * the "daily" route, whether or not the slug it returns is used — so a visit + * to the archive for any other reason clears a stash an abandoned sign-in + * left, and an old Play press cannot hijack an unrelated later sign-in. + */ +export function takePendingDailyPlay(): string | null { + let slug: string | null = null; + try { + slug = localStorage.getItem(PENDING_KEY); + } catch { + /* ignored, same as stashDailyPlay */ + } + try { + localStorage.removeItem(PENDING_KEY); + } catch { + /* ignored */ + } + return slug; +} diff --git a/web/src/router.ts b/web/src/router.ts index a0f5398..79f14dc 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -26,6 +26,7 @@ import { capturePageview } from "./analytics"; import { ApiError, currentSession } from "./api"; import { camoEditor, hasPendingCamo } from "./camo/editor"; import { footer, markCurrent, type Route } from "./chrome"; +import { hasPendingDailyPlay, takePendingDailyPlay } from "./daily-play-stash"; import { type AboutPageId, isAllowed, @@ -78,6 +79,12 @@ export function routeFromLocation(): Route { // use. if (hasPendingLobbyInvite()) return "lobby"; + // And the third: a reader who pressed Play on the daily card while signed + // out. "daily" rather than "lobby" because the slug, not a lobby id, is + // what came back — see the "daily" branch in start(), which consumes it and + // decides between the fight and the archive. Peek only, same as both above. + if (hasPendingDailyPlay()) return "daily"; + // An address with no route of its own. Home is what is put up for the bare // root; for anything else the guard below has the last word, and start() is // careful not to treat this answer as a destination before then. @@ -205,6 +212,29 @@ export async function start(): Promise { // does not wait on /api/session either — a list of fights that have already // happened is the same for whoever is reading it. if (route === "daily") { + // Always taken, the same posture takePendingLobbyInvite() has below: a + // visit to the archive for any other reason clears a stash an abandoned + // sign-in left, so an old Play press cannot hijack a later one. + const pending = takePendingDailyPlay(); + if (pending) { + // The one thing here that does need a session, and the only reason this + // branch ever reads one. A failed read falls through to the archive + // rather than to an error screen: the reader asked to play a fight, and + // the page that lists them all is a better answer than nothing. + const player = await currentSession().catch((err: unknown) => { + console.warn("start: the session could not be read", err); + return null; + }); + if (player) { + // Rendered directly, with no address to navigate to — the daily mode + // of the lobby screen has none, the same as the fresh lobby + // matches.ts opens — so the router's own idea of what is on screen + // has to be set by hand here. See nav.ts's markShown. + markEntered("lobby"); + render(...lobbyScreen(player, { kind: "daily", slug: pending })); + return; + } + } render(...dailyArchiveScreen()); return; } diff --git a/web/src/screens/daily-archive.ts b/web/src/screens/daily-archive.ts index 76b4293..97fe3f2 100644 --- a/web/src/screens/daily-archive.ts +++ b/web/src/screens/daily-archive.ts @@ -21,6 +21,7 @@ import { archive, boardCanvas, eraStat, + playControl, scenarioHref, utcDay, type Challenge, @@ -63,6 +64,13 @@ function entry(challenge: Challenge, today: string): HTMLElement { ]), ]), el("p", { className: "daily-actions" }, [ + // The same control the front-page card carries. This page's own doc + // already said a backlog challenge is playable; until now nothing here + // let anyone play one, and the control says on its face that it does + // not score. It asks the control plane nothing to render, so this page + // still draws with the API down — a press is the first thing that + // needs it. + ...playControl(challenge, today).nodes, el("a", { className: "daily-download", href: scenarioHref(challenge.slug), diff --git a/web/src/screens/daily.ts b/web/src/screens/daily.ts index 7618509..9faa6d7 100644 --- a/web/src/screens/daily.ts +++ b/web/src/screens/daily.ts @@ -23,8 +23,15 @@ * rather than an impression of it. */ -import { dailyStatus } from "../api"; -import { el } from "../dom"; +import { + currentSession, + dailyStatus, + type DailyStatus, + type Scenario, +} from "../api"; +import { stashDailyPlay } from "../daily-play-stash"; +import { el, render } from "../dom"; +import { nav } from "../nav"; import { SPRITE_H, SPRITE_W } from "../camo/mech"; import dailyJson from "../content/daily.json"; import { eraFor } from "../content/eras"; @@ -103,6 +110,35 @@ export function archive(day: string): Challenge[] { .sort((a, b) => (a.date < b.date ? 1 : -1)); } +/** One challenge by its slug, or null when the site does not have it. */ +export function challengeBySlug(slug: string): Challenge | null { + return challenges.find((c) => c.slug === slug) ?? null; +} + +/** + * The day's fight in the shape the lobby screen draws forces from. + * + * A daily scenario is not in `/api/scenarios` at all — the files are compiled + * into the API and carried in the launch manifest — so the lobby's daily mode + * builds its one Scenario from the same bundled file the card draws rather + * than asking a catalog that has never heard of it. + * + * The sides come out in the file's own order, which is the order the API's + * `PLAYER_SLOT` and `BOT_SLOT` are in: the player's force first, the bot's + * second. daily.test.mjs is what says the two still agree. + */ +export function dailyScenario(challenge: Challenge): Scenario { + return { + path: scenarioHref(challenge.slug), + name: challenge.name, + description: challenge.blurb, + slots: challenge.sides.map((side) => ({ + name: side.name, + units: side.units.map((unit) => unit.name), + })), + }; +} + /** Where a challenge's scenario file is served from. */ export function scenarioHref(slug: string): string { return `/daily/${slug}.mms`; @@ -205,13 +241,28 @@ function drawMap(canvas: HTMLCanvasElement, challenge: Challenge): void { * Flat colours so the two forces read apart at map size. Not the player's own * camo: the card is drawn for everybody, signed in or not, and the same fight * has to look the same to all of them. + * + * Blue is the player and red is the opposition, in that order, because every + * daily scenario names its sides in that order — so index 0 is always the + * side the player takes. The lobby's daily mode paints the same two forces + * from this same list rather than keeping a second copy of it. + * + * The red is the site's own `--danger` (#b3261e) opened up a little to sit at + * the blue's weight on a dark map tile. It is a red rather than the burnt + * orange it replaces: orange and the blue both read as "a colour", where red + * and blue read as two sides. */ -const SIDE_COLOURS: readonly (readonly [number, number, number])[] = [ +export const SIDE_COLOURS: readonly (readonly [number, number, number])[] = [ [86, 132, 199], - [176, 92, 72], + [186, 60, 52], ]; -function flatCamo([r, g, b]: readonly [number, number, number]): ImageData { +/** A whole sprite in one colour, for a force with no camo of its own. */ +export function flatCamo([r, g, b]: readonly [ + number, + number, + number, +]): ImageData { const camo = new ImageData(SPRITE_W, SPRITE_H); for (let i = 0; i < camo.data.length; i += 4) { camo.data[i] = r; @@ -285,6 +336,85 @@ export function boardCanvas(challenge: Challenge): HTMLCanvasElement { return map; } +/** The Play button and the line beside it, plus a way to tell it what the + * control plane said about this reader's attempt. */ +export type PlayControl = { + nodes: Node[]; + applyStatus(status: DailyStatus): void; +}; + +/** + * Play: the way into the fight, on the card and on every archive row. + * + * One control rather than two, because the card and the archive start the same + * fight the same way. What differs is only what each knows: the card already + * asks for `dailyStatus` and hands it here, and the archive deliberately asks + * the control plane nothing, so a spent attempt there comes back as the API's + * own 409 message instead. + * + * Signed out, the first press is a sign-in. The slug is stashed + * (daily-play-stash.ts) so the round trip to the provider comes back into the + * fight rather than onto the front page — the mechanism the camo editor and a + * shared lobby link already use, pointed at a slug. + * + * Signed in, this renders the lobby screen's daily mode directly rather than + * navigating: that screen has no address of its own, the same call matches.ts + * makes for its Duel and Scenario cards, and nav.markShown is what keeps the + * router's idea of what is on screen from going stale behind it. The screen is + * imported on the press, not at the top of this file, so the front page does + * not carry the whole lobby in front of the hero. + */ +export function playControl(challenge: Challenge, day: string): PlayControl { + const scored = challenge.date === day; + const play = el("button", { + type: "button", + className: "small-btn daily-play", + textContent: "Play", + }); + const note = el("span", { + className: "hint daily-play-note", + textContent: scored ? "" : "Does not score.", + }); + + play.addEventListener("click", () => { + play.disabled = true; + void currentSession() + .then(async (session) => { + if (!session) { + play.disabled = false; + const { openSignIn } = await import("../signin-modal"); + openSignIn({ onBeforeConnect: () => stashDailyPlay(challenge.slug) }); + return; + } + const { lobbyScreen } = await import("./lobby"); + nav.markShown!("lobby"); + render( + ...lobbyScreen(session, { kind: "daily", slug: challenge.slug }), + ); + }) + .catch((error: unknown) => { + console.warn("daily: the challenge could not be opened", error); + play.disabled = false; + note.textContent = "The control plane could not be reached."; + }); + }); + + return { + nodes: [play, note], + applyStatus(status: DailyStatus) { + // Only the day's own attempt is spent. A challenge whose day has passed + // can be played again as often as anyone likes, and its note already + // says what that is worth. + if (!scored || !status.attempted) return; + play.disabled = true; + note.textContent = + status.score === null + ? "Already played." + : `You scored ${number.format(status.score)}.`; + }, + }; +} + /** * The card, for whichever fight today's is. * @@ -297,11 +427,16 @@ export function dailyCard(): HTMLElement | null { if (!challenge) return null; const played = el("p", { className: "daily-value", textContent: "—" }); - // The one figure that is not in the bundle. A card that waited for it would - // be a card that goes blank when the control plane does. + const play = playControl(challenge, day); + // The one figure that is not in the bundle, and now also the one answer that + // says whether this reader has already had their go. A card that waited for + // either would be a card that goes blank when the control plane does, so + // Play starts pressable and a spent attempt disables it a moment later — + // and if the answer never comes, the API refuses the press itself. void dailyStatus(challenge.slug) .then((status) => { played.textContent = number.format(status.attempts); + play.applyStatus(status); }) .catch(() => { /* the count is the least of what is on this card */ @@ -332,6 +467,7 @@ export function dailyCard(): HTMLElement | null { ]), ]), el("p", { className: "daily-actions" }, [ + ...play.nodes, el("a", { className: "daily-download", href: scenarioHref(challenge.slug), diff --git a/web/src/screens/lobby.ts b/web/src/screens/lobby.ts index 4671cce..c4b4bd1 100644 --- a/web/src/screens/lobby.ts +++ b/web/src/screens/lobby.ts @@ -6,6 +6,7 @@ import { fetchScenarios, openLobby, setDefaultCamo, + startDaily, type ChatLine, type LobbySocket, type MatchPlayer, @@ -19,6 +20,13 @@ import { el, onTeardown, render } from "../dom"; import { withHandleTypeahead } from "../handle-typeahead"; import { nav } from "../nav"; import { camoPicker, remoteCamoPixels } from "./camo-picker"; +import { + challengeBySlug, + dailyScenario, + flatCamo, + SIDE_COLOURS, + type Challenge, +} from "./daily"; import { scenarioPool, type ChallengeMode } from "./scenario-pool"; import { waitingScreen } from "./waiting"; @@ -42,9 +50,17 @@ function at(handle: string): string { * is joining whatever scenario is already live there, not choosing an * intent. A joiner's picker still needs a pool to render from if it stays * interactive, so it gets the whole, unnarrowed catalog instead. + * + * The third is the daily challenge, which is this screen with almost + * everything about it already decided — see the daily paragraph of + * lobbyScreen's own doc below. It carries the slug rather than the challenge + * itself because the slug is what survives a sign-in round trip, and turning + * one into the other is a lookup in a file this screen already has. */ export type LobbyEntry = - { kind: "fresh"; mode: ChallengeMode } | { kind: "join"; matchId: string }; + | { kind: "fresh"; mode: ChallengeMode } + | { kind: "join"; matchId: string } + | { kind: "daily"; slug: string }; /** * Pick a scenario together, claim seats one at a time, and Deploy once @@ -110,8 +126,43 @@ export type LobbyEntry = * `deployed` fires — Back leaves through `nav.start!()` instead, since this * screen now has a real address of its own to fix on the way out (see the * Back handler's own comment). + * + * ## The daily challenge + * + * `{ kind: "daily" }` is this same screen with everything a lobby exists to + * negotiate already settled, and it narrows rather than forks: one screen + * that knows how to show a scenario, its forces and your camo, with the + * pieces that have nothing to decide taken out. What it keeps is what a + * player still wants — the scenario, both forces with their units painted, + * and their own camo on the force they are taking. + * + * What it drops, and why each one has nothing to do here: the scenario + * picker (the day's fight is the point of the day's fight), both seat + * resolvers and Kick (the player takes the challenger's side and the bot + * takes the other, every day, for everybody — a swap would be a different + * fight to the same score), the invite link, and chat, which is the plain + * one: it is one player against a bot and there is nobody on the other end. + * + * It also opens no lobby and connects no socket. There is no id to share and + * no state for two clients to agree on, and `POST /api/daily/` is a + * single call that claims the attempt and launches the match — see `launch()` + * below, which is the one thing Deploy does differently here. + * + * The scenario itself does not come from `/api/scenarios`: a daily is + * compiled into the API and carried in the launch manifest, so `daily.ts`'s + * bundled copy is what this screen draws (`dailyScenario`), and the two + * forces paint in that card's own two colours (`SIDE_COLOURS`) rather than in + * a second list that could disagree with it. */ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { + /** + * The day's fight, when this is a daily. Null on every other path, and null + * for a slug this build's `daily.json` has never heard of — a stash left by + * a much older visit, which the note below says so rather than pretending + * to set up. + */ + const daily: Challenge | null = + entry.kind === "daily" ? challengeBySlug(entry.slug) : null; const status = el("p", { className: "error", role: "alert" }); const scenarioNote = el("p", { className: "hint", @@ -120,7 +171,9 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { const scenarioList = el("div", { className: "scenario-list" }); const seatNote = el("p", { className: "hint", - textContent: "Any slot you leave alone stays open for someone to claim.", + textContent: daily + ? `You take ${daily.sides[0]?.name ?? ""}; the bot takes ${daily.sides[1]?.name ?? ""}. Both are fixed.` + : "Any slot you leave alone stays open for someone to claim.", }); const seatList = el("div", { className: "slot-list" }); const deploy = el("button", { @@ -192,6 +245,20 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { function renderScenarioList(): void { scenarioList.replaceChildren(); + if (daily) { + // Named, not offered. The same two lines a radio would carry, without + // the radio: there is one fight today and picking another one would + // make it a different challenge to the same score. + scenarioList.append( + el("div", { className: "choice" }, [ + el("div", {}, [ + el("strong", { textContent: daily.name }), + el("p", { className: "hint", textContent: daily.blurb }), + ]), + ]), + ); + return; + } for (const option of pool) { const input = el("input", { type: "radio", @@ -246,12 +313,33 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { * Deploy settles it. */ async function camoForSlot(slotName: string): Promise { const seat = seats.find((s) => s.slot === slotName); + // A daily is two sides before it is anything else, so neither force draws + // bare: the player's blue and the opposition's red, off the same list the + // card's own map paints from (daily.ts's SIDE_COLOURS). A camo the player + // has picked still wins on the force they are taking — that is the "full + // camo" half of what this screen is for — and the bot has none to bring. + if (daily) return (await ownCamo(seat)) ?? sideCamo(slotName); + if (!seat || seat.did !== session.did) return null; + return ownCamo(seat); + } + + /** The camo the player has picked, on their own seat and nowhere else. */ + async function ownCamo( + seat: MatchPlayer | undefined, + ): Promise { if (!seat || seat.did !== session.did) return null; const url = typeof camo.rkey === "string" ? camo.images.get(camo.rkey) : undefined; return url ? remoteCamoPixels(url) : null; } + /** A daily force's flat colour: its position in the scenario picks it, and + * the player's side is always the first one. */ + function sideCamo(slotName: string): ImageData { + const index = current?.slots.findIndex((slot) => slot.name === slotName); + return flatCamo(SIDE_COLOURS[index ?? 0] ?? SIDE_COLOURS[0]!); + } + /** * A claimed seat's face, name, and a Kick action — always rendered, the * same "show it, let the server decide" posture Deploy already has on @@ -283,7 +371,9 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { className: "pilot-tile-handle", textContent: isBot ? "AI" : seat.handle ? at(seat.handle) : "Claimed", }), - kick, + // Nothing to kick in a daily: both seats are fixed, there is no lobby + // for anyone else to be in, and no socket to send it on. + ...(daily ? [] : [kick]), ]); } @@ -546,12 +636,31 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { !current.slots.every((slot) => seats.some((s) => s.slot === slot.name)); } - deploy.addEventListener("click", () => { + /** + * What Deploy starts, and the only thing the daily path does differently + * once the screen is up. + * + * A daily has no lobby row to deploy: `POST /api/daily/` claims the + * player's one attempt and launches the fight against the bot in the same + * call, and it is also where a spent attempt is refused (409) and a + * signed-out caller is turned away (401). Both come back as the server's own + * message, through the same catch below. + * + * Null is "there is nothing to deploy yet" — a fresh open whose id has not + * arrived — which is what the click handler checks instead of matchId. + */ + function launch(): Promise | null { + if (daily) return startDaily(daily.slug).then((started) => started.matchId); const id = matchId; - if (!id) return; + return id ? deployLobby(id) : null; + } + + deploy.addEventListener("click", () => { + const launched = launch(); + if (!launched) return; deploy.disabled = true; deploy.textContent = "Deploying…"; - deployLobby(id) + launched .then((newMatchId) => { render(...waitingScreen(session, newMatchId)); }) @@ -613,7 +722,34 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { }, }; - if (entry.kind === "join") { + if (entry.kind === "daily") { + // Everything this screen shows is already in the bundle, so there is + // nothing to wait for and nothing to connect to: the scenario is the + // day's, and both seats are taken before anyone has pressed anything. + // Deploy is enabled by the same updateDeploy() rule every other path + // uses — every slot claimed — which these two seats satisfy outright. + if (daily) { + current = dailyScenario(daily); + seats = [ + { + slot: current.slots[0]?.name ?? "", + control: "human", + did: session.did, + handle: session.handle, + }, + { + slot: current.slots[1]?.name ?? "", + control: "bot", + did: null, + handle: null, + }, + ]; + renderScenarioList(); + renderSeats(); + } else { + scenarioNote.textContent = "That challenge is not on this site."; + } + } else if (entry.kind === "join") { const joinId = entry.matchId; matchId = joinId; copyLink.disabled = false; @@ -701,11 +837,12 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { // this duplicating that bookkeeping by hand. It renders the Matches // screen itself, tearing this one down through the same onTeardown // above, which is what closes the socket. - window.history.replaceState( - {}, - "", - `${window.location.pathname}${window.location.search}#matches`, - ); + // + // A daily was not reached from Matches and has no address of its own + // either, so it goes back to the front page, which is where the card that + // opened it is. + const here = `${window.location.pathname}${window.location.search}`; + window.history.replaceState({}, "", daily ? here : `${here}#matches`); void nav.start!(); }); @@ -714,13 +851,20 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { // the card under it says what to do next. The hidden h1 names the page // for a screen reader without repeating it on screen. return [ - el("h1", { className: "visually-hidden", textContent: "Lobby" }), + el("h1", { + className: "visually-hidden", + textContent: daily ? "Daily challenge" : "Lobby", + }), el("section", { className: "card challenge" }, [ el("h2", { textContent: "Set up the match" }), - el("div", { className: "row" }, [copyLink, copyStatus]), + // No link to copy in a daily: there is no lobby for anyone to join. + ...(daily + ? [] + : [el("div", { className: "row" }, [copyLink, copyStatus])]), el("div", { className: "choice camo-pick" }, [ el("strong", { textContent: "Scenario" }), - scenarioNote, + // The note is the connecting/opening line, and a daily is neither. + ...(daily ? [] : [scenarioNote]), scenarioList, ]), el("div", { className: "choice camo-pick" }, [ @@ -729,11 +873,16 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { seatList, ]), camo.node, - el("div", { className: "choice camo-pick" }, [ - el("strong", { textContent: "Chat" }), - chatScroll, - chatForm, - ]), + // No chat in a daily: one player, one bot, nobody to talk to. + ...(daily + ? [] + : [ + el("div", { className: "choice camo-pick" }, [ + el("strong", { textContent: "Chat" }), + chatScroll, + chatForm, + ]), + ]), el("div", { className: "row" }, [deploy, back]), status, ]), diff --git a/web/src/styles.css b/web/src/styles.css index 906eedb..3ef3cf9 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1229,9 +1229,9 @@ body:has(.hero) { font-size: 0.85rem; } -/* The row under the card: the scenario file, and the way to the archive. - A download is the one thing here that leaves the site, so it is the one - thing styled as a button. */ +/* The row under the card: the way into the fight, the scenario file, and the + way to the archive. Play is the filled button every other primary action on + the site is; the download is outlined beside it, since it leaves the site. */ .daily-actions { display: flex; flex-wrap: wrap; @@ -1241,6 +1241,19 @@ body:has(.hero) { font-size: 0.9rem; } +/* The site's button rule sets align-self: flex-start, which is right in a card + column and wrong in this row of links. */ +.daily-play { + align-self: center; +} + +/* Why Play is disabled, or what it is worth. Empty on the day's own challenge + until the control plane says otherwise, and an empty one must not leave the + row's gap on both sides of nothing. */ +.daily-play-note:empty { + display: none; +} + .daily-download { border: 1px solid var(--rule); border-radius: 0.35rem;