diff --git a/.github/extensions/commit-and-sip/booth-engine.mjs b/.github/extensions/commit-and-sip/booth-engine.mjs index 37af1d1..0cd561a 100644 --- a/.github/extensions/commit-and-sip/booth-engine.mjs +++ b/.github/extensions/commit-and-sip/booth-engine.mjs @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; import { DomainError, requireValue, exactInput, validRunId, generateHandle } from "./domain.mjs"; -import { PLACEMENTS } from "./services/coffee-name.mjs"; +import { coffeeNameId, normalizeCoffeeName, PLACEMENTS } from "./services/coffee-name.mjs"; import { validateLeaderboardUrl } from "./services/public-url.mjs"; -import { addDrink, leaderboard, removeDrink, seedMenu, standingFor } from "./services/booth-menu.mjs"; +import { addDrink, findDrink, leaderboard, prepareDrink, removeDrink, SCORE_TAKEN_MESSAGE, seedMenu, standingFor } from "./services/booth-menu.mjs"; import { confirmedSync, failedSync, initialSync, newPublicationToken, publicRef, retractionCause, retractionFailure, submissionFor, syncView, tokenHashOf, validateLeaderboardClient, validateReceipt, } from "./services/leaderboard.mjs"; @@ -33,6 +33,57 @@ const SEND_CLAIM_MS = 60_000; const liveClaims = (sync, at = Date.now()) => Object.values(sync?.sending ?? {}).filter(since => at - Date.parse(since) < SEND_CLAIM_MS).length; +// With an event leaderboard, a drink is served only once the board admits it, +// so two drinks never share a score anywhere and the menu never shows a drink +// the board refused. These are what the attendee is told when it does not. +const SERVED_MESSAGE = entry => `${entry.name} is on the menu and scored ${entry.score} out of 5000.`; +const UNREACHABLE_MESSAGE = "The event leaderboard can't be reached right now, so your drink isn't on the menu yet. " + + "Try again in a moment."; +const ADMISSION_BUSY_MESSAGE = "Your drink is already being checked with the event leaderboard. Wait a moment."; +// The same words the booth uses for a blocklist hit or a local takedown, so +// nobody can tell which it was (see services/booth-menu.mjs). +const UNAVAILABLE_MESSAGE = "That name is not available. Try another."; + +// The service's own refusals of a drink. A resend of a drink the board already +// admitted is answered as a success, so one of these proves this publication +// is not on the board, however many times it was sent. +const BOARD_REFUSALS = ["duplicate_drink", "handle_taken", "score_taken", "unavailable_drink"]; + +// A definite "no" from the service: the drink was not admitted, and sending it +// again cannot change that. Any other 4xx (a proxy's 429, a stopped app's 404, +// a rotated key's 401, a validation code) proves only that this one request +// was turned away, so it is definite only for the first send of a new +// admission. On a resend the earlier send may have landed, so it stays in +// doubt, as does no answer, a 5xx, or a reply the booth cannot trust. +const refusedByBoard = (error, firstSend) => { + if (error instanceof DomainError || !Number.isInteger(error?.status) || error.status < 400 || error.status >= 500) { + return false; + } + return BOARD_REFUSALS.includes(error.code) || firstSend; +}; + +function admissionRefusal(code, entry) { + switch (code) { + case "score_taken": + return { code, message: SCORE_TAKEN_MESSAGE(entry.name, entry.score, "the event leaderboard"), status: 409 }; + case "duplicate_drink": + return { code, message: `Another barista already published ${entry.name} on the event leaderboard. Invent a different drink.`, status: 409 }; + case "unavailable_drink": + case "rejected_name": + return { code: "unavailable_drink", message: UNAVAILABLE_MESSAGE, status: 409 }; + case "handle_taken": + return { code, message: "The event leaderboard could not list that drink under your handle. Try a different name.", status: 409 }; + default: + return { code: "leaderboard_refused", status: 502, + message: "The event leaderboard refused this drink, so it isn't on the menu. Ask booth staff for help." }; + } +} + +// The ID a typed name would take, or null for a name the rules would refuse. +const typedId = name => { + try { return coffeeNameId(normalizeCoffeeName(name).toLowerCase()); } catch { return null; } +}; + // Whether a removed drink's ID may be on the public board, from any booth. // The board is keyed by drink ID, not by run, so this run never having been // sent proves little: "duplicate_drink" means another booth's entry holds @@ -233,6 +284,7 @@ export class BoothEngine { this.publicationSweep ??= (async () => { try { if (!this.leaderboardClient) return 0; + await this.resolveAdmissions(); const data = await this.store.read(); const owed = Object.entries(data.runs) .filter(([, run]) => ["pending", "failed"].includes(run.sync?.state) && !run.removed) @@ -429,12 +481,19 @@ export class BoothEngine { // give every owed takedown one more try... await this.drainPublications(); await this.retryRetractions().catch(() => {}); + await this.resolveAdmissions().catch(() => {}); return this.store.transaction(async data => { // ...and refuse if a publish started in the meantime, here or in another // process. Its claim is in this ledger, under this lock. - const claimed = Object.values(data.runs).some(run => liveClaims(run.sync)); + const claimed = Object.values(data.runs).some(run => liveClaims(run.sync) || liveClaims(run.admission)); requireValue(!this.inFlight?.size && !claimed, "publications_in_flight", "A drink is still being sent to the public leaderboard. Try again in a few seconds. Nothing was changed.", 409); + // A drink whose admission is in doubt may be on the public board with no + // menu entry here. Wiping would lose the only record of it. + const unsettled = Object.values(data.runs).filter(run => run.admission).length; + requireValue(unsettled === 0, "admissions_unsettled", + `${unsettled === 1 ? "One drink is" : `${unsettled} drinks are`} still waiting for the event leaderboard to say whether it was admitted. ` + + "Press Refresh and retry once the leaderboard can be reached. Nothing was changed.", 409); // ...and refuse to wipe while any is still owed. const owed = this.removalLog(data).filter(record => !SETTLED.includes(record.published) && mayBePublic(data, record, Boolean(this.leaderboardClient))); requireValue(owed.length === 0, "takedowns_owed", @@ -782,6 +841,7 @@ export class BoothEngine { requireValue(["submit_name", "complete"].includes(action), "unknown_action", "That action is not available at this booth.", 400); exactInput(input, action === "complete" ? [] : ["name", "mascot", "placement"]); + if (action === "submit_name" && this.leaderboardClient) return this.submitName(runId, input); return this.store.transaction(data => { const run = Object.hasOwn(data.runs, runId) ? data.runs[runId] : null; requireValue(run, "run_missing", "That run does not exist. Start a new one.", 404); @@ -806,25 +866,178 @@ export class BoothEngine { requireValue(run.phase === "naming", "already_served", "You have already added your drink. Each attendee invents one.", 409); + // A standalone booth is its own board, so it refuses a score another + // drink on its menu already holds. A booth with an event leaderboard + // never reaches here: submitName asks the board instead. const entry = addDrink(this.houseMenu(data), { choice: { mascot: input.mascot, placement: input.placement }, handle: run.handle, rawName: input.name, removedIds: this.removalLog(data).map(record => record.id), - rules: this.rules, runId, + rules: this.rules, runId, uniqueScore: true, }); - run.submission = { - breakdown: entry.breakdown, id: entry.id, name: entry.name, placement: entry.placement, - price: entry.price, score: entry.score, serving: entry.serving, - }; + run.submission = submissionView(entry); run.phase = "served"; - run.sync = initialSync(Boolean(this.leaderboardClient)); - // Minted with the served drink, in the same transaction, so the QR link - // is personal from the first screen and every later send reuses it. - if (this.leaderboardClient) run.sync.token = newPublicationToken(); + run.sync = initialSync(false); run.events.push({ type: "served", at: entry.createdAt }); - run.statusMessage = `${entry.name} is on the menu and scored ${entry.score} out of 5000.`; + run.statusMessage = SERVED_MESSAGE(entry); return this.present(run, data); }); } + + // --- Board-first serving ---------------------------------------------------- + // With an event leaderboard, the order is score, admit, serve. The board is + // the only place that sees every booth's drinks, so only it can refuse a + // score another booth's drink already holds. Serving first and publishing + // later would put a drink on this menu that the board then refuses. + // + // The attempt is recorded in the ledger (`run.admission`) before anything is + // sent, with its publication token, so a lost reply is resolved by resending + // the same publication: the service answers a resend of an admitted drink as + // a success, and the drink is served once, never twice. While an admission + // is in doubt it is resolved before anything else is sent for that run. + async submitName(runId, input) { + // One submission per station at a time. A double tap must not send two + // publications; another process is stopped by the ledger claim instead. + this.admitting ??= new Set(); + requireValue(!this.admitting.has(runId), "admission_in_progress", ADMISSION_BUSY_MESSAGE, 409); + this.admitting.add(runId); + try { + // At most two rounds: an earlier attempt still in doubt, then this name. + for (let round = 0; round < 2; round += 1) { + const claim = await this.claimAdmission(runId, input); + // A resend of a different, earlier name that the board refused frees + // the attendee to try the name they just typed. + const earlierName = claim.earlier && claim.entry.id !== typedId(input.name); + const outcome = await this.tracked(this.sendAdmission(claim)); + const settled = await this.settleAdmission(runId, claim, outcome, { earlierName }); + if (settled.view) return settled.view; + if (settled.refusal && earlierName) continue; + if (settled.refusal) throw new DomainError(settled.refusal.code, settled.refusal.message, settled.refusal.status); + throw new DomainError("leaderboard_unreachable", UNREACHABLE_MESSAGE, 503); + } + throw new DomainError("leaderboard_unreachable", UNREACHABLE_MESSAGE, 503); + } finally { + this.admitting.delete(runId); + } + } + + // Claims the run's admission under the ledger lock: the earlier one still in + // doubt, or a new one for the typed name once it passes every local check. + // `input` null resumes an earlier admission only (the retry sweep). + async claimAdmission(runId, input) { + const attempt = randomUUID(); + return this.store.transaction(data => { + const run = Object.hasOwn(data.runs, runId) ? data.runs[runId] : null; + if (!input) { + if (!run?.admission || liveClaims(run.admission)) return null; + } else { + requireValue(run, "run_missing", "That run does not exist. Start a new one.", 404); + requireValue(run.mode === "booth", "run_conflict", "That run belongs to a different exercise mode."); + requireValue(PHASES.includes(run.phase), "invalid_phase", "This run has an unusable state. Ask booth staff."); + requireValue(run.phase === "naming", "already_served", + "You have already added your drink. Each attendee invents one.", 409); + requireValue(!liveClaims(run.admission), "admission_in_progress", ADMISSION_BUSY_MESSAGE, 409); + } + const now = new Date().toISOString(); + if (run.admission) { + run.admission.sending = { [attempt]: now }; + return { attempt, earlier: Boolean(input), entry: run.admission.entry, token: run.admission.token }; + } + // Every local check (name rules, blocklist, local duplicates and + // takedowns, the mascot and placement choice) runs here, before anything + // is sent. Nothing joins the menu yet. + const entry = prepareDrink(this.houseMenu(data), { + choice: { mascot: input.mascot, placement: input.placement }, + handle: run.handle, rawName: input.name, removedIds: this.removalLog(data).map(record => record.id), + rules: this.rules, runId, + }); + run.admission = { entry, sending: { [attempt]: now }, startedAt: now, token: newPublicationToken() }; + run.events.push({ type: "admission_requested", at: now }); + return { attempt, earlier: false, entry, fresh: true, token: run.admission.token }; + }); + } + + async sendAdmission(claim) { + const submission = submissionFor(claim.entry, claim.token); + try { + return { ok: true, receipt: validateReceipt(await this.leaderboardClient.publish(submission), submission) }; + } catch (error) { + return { error, ok: false }; + } + } + + // Records what the board said, under the ledger lock. Admitted: serve it. + // Refused: forget the attempt and keep the attendee naming. No answer: keep + // the attempt, so the next try resends the same publication. + async settleAdmission(runId, claim, outcome, { earlierName = false } = {}) { + return this.store.transaction(data => { + const run = Object.hasOwn(data.runs, runId) ? data.runs[runId] : null; + const admission = run?.admission; + // Settled by someone else after this claim lapsed: report what they found. + if (!admission || admission.token !== claim.token) { + return run?.submission ? { view: this.present(run, data) } : {}; + } + const { [claim.attempt]: _done, ...others } = admission.sending ?? {}; + admission.sending = others; + const at = new Date().toISOString(); + if (outcome.ok) { + const entry = { ...admission.entry, createdAt: at }; + const menu = this.houseMenu(data); + if (!findDrink(menu, entry.id)) menu.push(entry); + run.submission = submissionView(entry); + // Staff may have closed the station while the answer was on its way. + if (run.phase === "naming") run.phase = "served"; + run.sync = confirmedSync({ attempts: 0, state: "pending", token: admission.token, updatedAt: at }, outcome.receipt, at); + delete run.admission; + run.events.push({ type: "served", at }); + run.statusMessage = earlierName + ? `Your earlier name, ${entry.name}, reached the event leaderboard, so it is your drink. It scored ${entry.score} out of 5000.` + : SERVED_MESSAGE(entry); + return { view: this.present(run, data) }; + } + // The first send of a new admission, with no other send of it recorded. + const firstSend = claim.fresh === true && !admission.attempts && Object.keys(others).length === 0; + if (refusedByBoard(outcome.error, firstSend)) { + const refusal = admissionRefusal(outcome.error.code, admission.entry); + delete run.admission; + // The service's own code is kept for staff; the attendee sees the + // refusal's wording only. + run.events.push({ type: "admission_refused", at, code: refusedCode(outcome.error) }); + if (run.phase === "naming") run.statusMessage = refusal.message; + return { refusal }; + } + admission.attempts = (admission.attempts ?? 0) + 1; + admission.reason = typeof outcome.error?.message === "string" ? outcome.error.message.slice(0, 200) : "Unknown leaderboard error."; + admission.updatedAt = at; + if (run.phase === "naming") run.statusMessage = UNREACHABLE_MESSAGE; + return { inDoubt: true }; + }); + } + + // Resolves every admission left in doubt: an attendee who walked away, or a + // station staff closed, after the board could not be reached. A drink the + // board turns out to have admitted is served; one it refused is dropped. + async resolveAdmissions() { + if (!this.leaderboardClient) return; + const data = await this.store.read(); + const runIds = Object.entries(data.runs) + .filter(([runId, run]) => run.admission && !liveClaims(run.admission) && !this.admitting?.has(runId)) + .map(([runId]) => runId); + for (const runId of runIds) { + const claim = await this.claimAdmission(runId, null); + if (!claim) continue; + const outcome = await this.tracked(this.sendAdmission(claim)); + await this.settleAdmission(runId, claim, outcome).catch(() => {}); + } + } } +function submissionView(entry) { + return { + breakdown: entry.breakdown, id: entry.id, name: entry.name, placement: entry.placement, + price: entry.price, score: entry.score, serving: entry.serving, + }; +} + +const refusedCode = error => (typeof error?.code === "string" && /^[a-z_]{1,40}$/.test(error.code) ? error.code : null); + export { DomainError }; diff --git a/.github/extensions/commit-and-sip/booth-panel.mjs b/.github/extensions/commit-and-sip/booth-panel.mjs index 8a5cf68..0074448 100644 --- a/.github/extensions/commit-and-sip/booth-panel.mjs +++ b/.github/extensions/commit-and-sip/booth-panel.mjs @@ -93,15 +93,10 @@ export class BoothPanel { } if (this.runId) await this.currentRun(); requireValue(this.runId, "not_started", "Start an order at this station first.", 409); + // With an event leaderboard, submit_name answers only once the board has + // admitted the drink (and it is served) or said why not, so there is no + // background publish left to start here. const state = await this.engine.dispatch(this.runId, action, input); - // Serving commits locally first; publishing to the event leaderboard is a - // separate step that must not be able to fail or slow the attendee's - // submission. It runs in the background: the screen answers at once with - // the drink "still being confirmed", and polls until the service answers. - if (action === "submit_name") { - this.backgroundPublish = this.engine.publish(this.runId).catch(() => {}); - return this.decorate(state); - } // Completing hands the station to the next attendee. The run itself stays // finished in the store; only this panel's cursor is released. if (action === "complete") { diff --git a/.github/extensions/commit-and-sip/canvas.mjs b/.github/extensions/commit-and-sip/canvas.mjs index 722c24e..1de413b 100644 --- a/.github/extensions/commit-and-sip/canvas.mjs +++ b/.github/extensions/commit-and-sip/canvas.mjs @@ -18,7 +18,7 @@ export function boothCanvasDefinition({ engine, guarded = fn => fn(), reportErro placement: { enum: PLACEMENTS } }, required: ["name"], additionalProperties: false - }, "Add the attendee's invented drink to the house menu and score it. A declared mascot or placement is checked against the name."], + }, "Score the attendee's invented drink and add it to the house menu. With an event leaderboard configured, it is served only once the board admits it: a score another drink holds (score_taken) or an unreachable board (leaderboard_unreachable) keeps the attendee naming. A declared mascot or placement is checked against the name."], complete: [empty, "Finish this attendee and clear the station for the next one. The drink stays on the menu and leaderboard."], refresh: [empty, "Read the current station state, house menu, and leaderboard."] }; diff --git a/.github/extensions/commit-and-sip/renderer/booth.html b/.github/extensions/commit-and-sip/renderer/booth.html index 8a7adf9..4611ed4 100644 --- a/.github/extensions/commit-and-sip/renderer/booth.html +++ b/.github/extensions/commit-and-sip/renderer/booth.html @@ -50,6 +50,7 @@
Checking your drink and adding it to the menu...
diff --git a/.github/extensions/commit-and-sip/renderer/booth.js b/.github/extensions/commit-and-sip/renderer/booth.js index ec3474e..2f2f879 100644 --- a/.github/extensions/commit-and-sip/renderer/booth.js +++ b/.github/extensions/commit-and-sip/renderer/booth.js @@ -219,6 +219,9 @@ event.preventDefault(); if (busy || !$("name-form").reportValidity()) return; $("name-problem").hidden = true; + // A booth with an event leaderboard serves only once the board admits the + // drink, so the answer can take a few seconds. Say what is happening. + $("name-checking").hidden = false; busy = true; controls(); try { @@ -231,6 +234,7 @@ $("name-problem").textContent = error.message; $("name-problem").hidden = false; } finally { + $("name-checking").hidden = true; busy = false; controls(); } diff --git a/.github/extensions/commit-and-sip/services/booth-menu.mjs b/.github/extensions/commit-and-sip/services/booth-menu.mjs index 591c320..5ffe650 100644 --- a/.github/extensions/commit-and-sip/services/booth-menu.mjs +++ b/.github/extensions/commit-and-sip/services/booth-menu.mjs @@ -21,7 +21,21 @@ export function findDrink(menu, id) { return menu.find(entry => entry.id === id) ?? null; } -export function addDrink(menu, { rawName, rules, runId, handle, choice = {}, removedIds = [], now = new Date().toISOString() }) { +// The attendee's way forward when their score is taken: a different name +// almost always lands on a different total, because Letter value moves in +// single points. +export const SCORE_TAKEN_MESSAGE = (name, score, where = "the house menu") => + `${name} scored ${score}, but another drink on ${where} already has that score, and no two drinks may share one. ` + + "Tweak your name (add, swap, or respell a word) and submit again."; + +// Every check a drink must pass and the entry it would become, without adding +// it. A booth with an event leaderboard serves only once the board admits the +// drink, so it prepares here, asks the board, and pushes afterwards. +// +// `uniqueScore` refuses a score another drink on this menu already holds. A +// standalone booth is its own board, so it sets this. A booth with an event +// leaderboard leaves it to the service, which sees every booth's drinks. +export function prepareDrink(menu, { rawName, rules, runId, handle, choice = {}, removedIds = [], uniqueScore = false, now = new Date().toISOString() }) { requireValue(Array.isArray(menu), "menu_invalid", "The house menu is unavailable. Ask booth staff to restore it."); requireValue(Array.isArray(removedIds), "menu_invalid", "The removal record is unavailable. Ask booth staff to restore it."); requireValue(typeof runId === "string" && runId.length > 0, "invalid_run", "A run ID is required to add a drink.", 400); @@ -59,6 +73,13 @@ export function addDrink(menu, { rawName, rules, runId, handle, choice = {}, rem const drink = buildAttendeeOrder(rawName, rules, runId); const entry = { ...drink, breakdown, createdAt: now, example: false, handle, mascot, placement, runId, score }; requireValue(entry.id === id, "menu_invalid", "The drink ID changed between checks. Try the name again."); + requireValue(!uniqueScore || !menu.some(item => !item.example && item.score === score), + "score_taken", SCORE_TAKEN_MESSAGE(name, score), 409); + return entry; +} + +export function addDrink(menu, options) { + const entry = prepareDrink(menu, options); menu.push(entry); return entry; } @@ -95,7 +116,9 @@ export function removeDrink(menu, removals, id, { removedBy, reason, now = new D } // Every drink gets its own place, so an event cannot end in a tie for first. -// An equal score goes to the drink that got there first: on the event board +// New drinks never share a score (the leaderboard service, or a standalone +// booth, refuses a taken one), but a menu or board kept from before that rule +// can still hold a tie. An equal score goes to the drink that got there first: on the event board // that is when the service admitted it, at a booth when the booth served it. // The ID only separates two drinks stamped in the same millisecond, so the // order never depends on the order the menu happens to be stored in. A row diff --git a/.github/extensions/commit-and-sip/services/leaderboard.mjs b/.github/extensions/commit-and-sip/services/leaderboard.mjs index 11f190e..07660a4 100644 --- a/.github/extensions/commit-and-sip/services/leaderboard.mjs +++ b/.github/extensions/commit-and-sip/services/leaderboard.mjs @@ -3,18 +3,21 @@ import { requireValue } from "../domain.mjs"; // The booth is authoritative for its own menu; the event leaderboard is a // separate service that no booth can see into. Everything here is a client -// seam: the local entry is always durable first, and a submission that fails -// can never cost an attendee the drink they invented or the score they earned. +// seam. A new drink is served only once the board admits it (see submitName in +// booth-engine.mjs); the sync states below track drinks already on the menu: +// ones served before this booth was configured, sent again by a rebuild, or +// left pending by a booth build that served first. export const SYNC_STATES = ["disabled", "pending", "confirmed", "failed", "rejected"]; // Refusals that retrying cannot change: another attendee already holds the -// name, staff took it down, or both handles this publication can use (its -// own and its canonical form, the same two on every retry) are taken. They +// name, staff took it down, both handles this publication can use (its +// own and its canonical form, the same two on every retry) are taken, or +// another drink already holds its score. They // become "rejected" and are never sent again, not even by a rebuild. // Otherwise, after the service lost its data, the booth that lost a clash // could publish first and take the name from the attendee who legitimately // holds it, and a handle clash would be resent forever. -export const TERMINAL_REJECTIONS = ["duplicate_drink", "handle_taken", "unavailable_drink"]; +export const TERMINAL_REJECTIONS = ["duplicate_drink", "handle_taken", "score_taken", "unavailable_drink"]; // A random value minted once per publication and kept with the run, so every // retry of one publication carries the same token. It lets the service tell a diff --git a/.github/extensions/commit-and-sip/services/name-score.mjs b/.github/extensions/commit-and-sip/services/name-score.mjs index 6c3c839..8e52682 100644 --- a/.github/extensions/commit-and-sip/services/name-score.mjs +++ b/.github/extensions/commit-and-sip/services/name-score.mjs @@ -5,6 +5,13 @@ import { validateCoffeeName } from "./coffee-name.mjs"; // so the same name always scores the same, a rank can be recomputed server-side // from stored names, and staff can explain any score at the booth from the // returned breakdown. No model call is involved; this is a rubric, not a judge. +// +// Every category maximum can be earned by one valid name at the same time +// (a test proves it), so 5,000 is a real ceiling, not a sum on paper. Letter +// value pays in single points, so scores are not stuck on steps of 50 and two +// different names rarely land on the same total. Rarely is not never: a +// finite range cannot give every name its own score, so the leaderboard and a +// standalone booth refuse a drink whose score another drink already holds. export const MAX_SCORE = 5000; // The seeded house drinks are worked examples shown to attendees. They are never @@ -44,8 +51,8 @@ function alliterationPoints(name, mascot) { function craftPoints(name) { const found = new Set(tokens(name).filter(token => COFFEE_TERMS.includes(token))); - if (found.size >= 2) return { points: 900, found: [...found] }; - return { points: found.size === 1 ? 600 : 0, found: [...found] }; + if (found.size >= 2) return { points: 800, found: [...found] }; + return { points: found.size === 1 ? 500 : 0, found: [...found] }; } function wordplayPoints(name, mascot) { @@ -55,25 +62,26 @@ function wordplayPoints(name, mascot) { // A blend fuses the mascot into a longer word, such as "Monachino", wherever // that word sits in the name. if (list.some(token => token !== mascot && token.includes(mascot))) { - points += 450; + points += 400; signals.push("blend"); } // Internal rhyme: two words sharing a three-letter ending. const endings = list.filter(token => token.length >= 3).map(token => token.slice(-3)); if (new Set(endings).size < endings.length) { - points += 450; + points += 400; signals.push("rhyme"); } - return { points: Math.min(points, 900), signals }; + return { points: Math.min(points, 800), signals }; } -// Two to four words is the house shape. This band is deliberately flat so a +// Two to five words is the house shape. This band is deliberately flat, and +// wide enough to hold a mascot, two coffee terms and two invented words, so a // richer name is not penalised for having room to be inventive. function economyPoints(name) { const count = words(name).length; - if (count >= 2 && count <= 4) return 600; - if (count === 5) return 350; - return count === 1 ? 250 : 150; + if (count >= 2 && count <= 5) return 500; + if (count === 6) return 300; + return count === 1 ? 200 : 100; } // Same unbroken-text rule validateCoffeeName uses, so crew credit never exceeds @@ -95,7 +103,17 @@ function rarityPoints(name, mascots) { const inventive = new Set(tokens(name).filter(token => !mascots.some(mascot => token.includes(mascot)) && !COFFEE_TERMS.includes(token) && !COMMON_WORDS.includes(token))); - return { points: Math.min(inventive.size, 3) * 200, inventive: [...inventive] }; + return { points: Math.min(inventive.size, 2) * 250, inventive: [...inventive] }; +} + +// Each letter is worth its place in the alphabet (A=1 ... Z=26), capped. The +// only category that pays in single points, so it is what keeps two different +// names off the same total. Digits, spaces and punctuation are worth nothing. +const LETTER_VALUE_MAX = 400; +function letterPoints(name) { + const total = [...name.toLowerCase()].reduce((sum, char) => + sum + (char >= "a" && char <= "z" ? char.charCodeAt(0) - 96 : 0), 0); + return Math.min(total, LETTER_VALUE_MAX); } export function scoreCoffeeName(raw, rules) { @@ -110,11 +128,12 @@ export function scoreCoffeeName(raw, rules) { const breakdown = [ { label: "Valid house name", max: 600, points: 600 }, { label: "Alliteration", max: 800, points: alliterationPoints(name, mascot) }, - { label: "Coffee craft", max: 900, points: craft.points, detail: craft.found }, - { label: "Wordplay", max: 900, points: play.points, detail: play.signals }, - { label: "House shape", max: 600, points: economyPoints(name) }, + { label: "Coffee craft", max: 800, points: craft.points, detail: craft.found }, + { label: "Wordplay", max: 800, points: play.points, detail: play.signals }, + { label: "House shape", max: 500, points: economyPoints(name) }, { label: "Mascot crew", max: 600, points: crewPoints(crew), detail: crew }, - { label: "Invention", max: 600, points: rarity.points, detail: rarity.inventive }, + { label: "Invention", max: 500, points: rarity.points, detail: rarity.inventive }, + { label: "Letter value", max: LETTER_VALUE_MAX, points: letterPoints(name) }, ]; const total = breakdown.reduce((sum, part) => sum + part.points, 0); // Clamped so a rubric change can never emit an out-of-range competition score. diff --git a/.github/skills/order-up/SKILL.md b/.github/skills/order-up/SKILL.md index 01b3b0f..eaf2a93 100644 --- a/.github/skills/order-up/SKILL.md +++ b/.github/skills/order-up/SKILL.md @@ -38,6 +38,8 @@ Every drink must carry `mona`, `ducky`, or `copilot`, anywhere in the name. A ba The menu is first come, first served after normalizing case, spacing, and punctuation. A duplicate is reported as already on the menu; treat that as a normal outcome, not an attendee error. +No two drinks share a score. If another drink already holds the score a name earns, the drink is refused with `score_taken` and the attendee stays on the naming screen: let them tweak the name themselves (add, swap, or respell a word). With an event leaderboard configured, a drink joins the menu only once the board admits it. If the board cannot be reached, nothing is served: the attendee tries again in a moment, and a retry never adds the drink twice. + The 1-to-5,000 score comes from a deterministic local rubric. The same name always earns the same score. No model writes the feedback and no model assigns the score. Never present the rubric breakdown as Copilot commentary, and never impersonate a judge. Copilot commentary is unavailable unless a real attributed, moderated, trusted judge is configured. Most invented names land well short of 5,000. Say so before the score appears, so a mid-range result reads as normal rather than as failure. diff --git a/.github/steps/1-name-a-drink.md b/.github/steps/1-name-a-drink.md index fb23f04..a067967 100644 --- a/.github/steps/1-name-a-drink.md +++ b/.github/steps/1-name-a-drink.md @@ -22,11 +22,11 @@ There is nothing else to pick. The station reads the mascot and where it sits st Type your drink name and choose Add it to the menu. A bare mascot on its own is rejected, because Mona or Ducky alone would let the first attendee permanently claim a name nobody invented. Add something of your own to it. -The menu is first come, first served. If someone has already used your name, the station says so and you try another. Names are compared after normalizing case, spacing, and punctuation, so Mona Moonrise and mona-moonrise are the same drink. +The menu is first come, first served. If someone has already used your name, the station says so and you try another. No two drinks share a score either: if another drink already has the score yours earns, tweak your name (add, swap, or respell a word) and submit again. If the event leaderboard can't be reached, your drink isn't added yet; try again in a moment. Names are compared after normalizing case, spacing, and punctuation, so Mona Moonrise and mona-moonrise are the same drink. ## Read your score -A rubric scores the name out of 5,000 and shows you the breakdown: base credit, alliteration, craft, wordplay, how well it fits the house style, mascot crew (a bonus for using two or all three mascots), and invention. The rubric is deterministic. The same name always earns the same score, and no model generates your feedback or your score. +A rubric scores the name out of 5,000 and shows you the breakdown: base credit, alliteration, craft, wordplay, how well it fits the house style, mascot crew (a bonus for using two or all three mascots), invention, and letter value (every letter counts, A as 1 up to Z as 26, to a cap). One name can earn every category's maximum and reach 5,000. The rubric is deterministic. The same name always earns the same score, and no model generates your feedback or your score. A mid-range score is a normal result. Most invented names land well below the maximum, because every category has to be earned. diff --git a/README.md b/README.md index 85ed869..af090cd 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ npm run check - Treat a published rubric as something you can reason about and improve against, rather than guessing at a black box. - See their own contribution land on a shared artifact that the rest of the event can see. -Scores run from 1 to **5,000** and come from a deterministic rubric in code, not a language model. The same name always scores the same. Speed, retries, and accessibility assistance do not reduce a score. Mona Latte, Copilot Cortado, and Ducky Cold Brew are worked examples: never scored, never ranked. +Scores run from 1 to **5,000**, a score one valid name can actually reach, and come from a deterministic rubric in code, not a language model. The same name always scores the same, and no two drinks share a score: if another drink already holds yours, tweak the name and submit again. With the event leaderboard configured, a drink joins the menu only once the leaderboard admits it. Speed, retries, and accessibility assistance do not reduce a score. Mona Latte, Copilot Cortado, and Ducky Cold Brew are worked examples: never scored, never ranked. ## Documentation diff --git a/booth/CHEAT-SHEET.md b/booth/CHEAT-SHEET.md index 9480a62..bfb2f2f 100644 --- a/booth/CHEAT-SHEET.md +++ b/booth/CHEAT-SHEET.md @@ -55,7 +55,8 @@ Only a **confirmed event rank** describes the shared leaderboard; a booth rank i | Connection lost | Choose **Refresh connection**; reopen the canvas if needed. Saved drinks and scores persist. | | Attendee leaves mid-order | Staff dashboard: **Open stations**, select the station, give your name, and close it. Do not wipe the event. | | Name rejected | Read the actual message. Let the attendee try another; do not guess whether a generic rejection was moderation or a previous removal. | -| Offline or event rank unconfirmed | Local naming continues. Check the displayed sync reason; use **Refresh and retry** on the staff dashboard. Never promise publication until confirmed. | +| Offline or leaderboard unreachable | With a leaderboard configured, nothing is served until it admits the drink: the attendee stays naming and tries again in a moment, and a retry never adds the drink twice. Use **Refresh and retry** on the staff dashboard. | +| "Another drink already has that score" | Normal. Let the attendee tweak the name (add, swap, or respell a word) and submit again. | | Drink needs removal | **Take a drink down**: select it, give your name and reason. Confirm the public outcome. Pending takedowns need **Refresh and retry on this same booth**, with deployed staff keys; follow the [takedown guide](RUNBOOK.md#taking-a-drink-down). | For extension failures, inspect privately: diff --git a/booth/RUNBOOK.md b/booth/RUNBOOK.md index 459aff4..4610ae4 100644 --- a/booth/RUNBOOK.md +++ b/booth/RUNBOOK.md @@ -91,7 +91,7 @@ The blocklist is one layer. The name charset already rejects non-ASCII, so homog 6. Point out that "rank 1 of 1 at this booth" is this booth's menu only. Only a confirmed event line reflects the wider competition. 7. Finish with **I'm done — hand over to the next barista**. -Scores run from 1 to 5,000. Speed, retries, and accessibility assistance never reduce a score. Mona Latte, Copilot Cortado, and Ducky Cold Brew are worked examples: never scored, never ranked. Never describe a rubric score as model output, and never invent a judge's commentary. +Scores run from 1 to 5,000, and 5,000 is reachable. No two drinks share a score: if another drink already holds the one a name earns, the attendee is asked to tweak the name (add, swap, or respell a word) and stays on the naming screen. Let them choose the tweak. Speed, retries, and accessibility assistance never reduce a score. Mona Latte, Copilot Cortado, and Ducky Cold Brew are worked examples: never scored, never ranked. Never describe a rubric score as model output, and never invent a judge's commentary. ## Reset between attendees @@ -189,9 +189,10 @@ If a panel is closed or the extension reloads mid-run, reopen the canvas. Saved | Symptom | Staff response | | --- | --- | -| No network | Keep running. The booth is local-first and needs no network. Only the event leaderboard line is unavailable, and the canvas says so rather than inventing a rank. | +| No network | With no `leaderboardApi`, keep running: the booth needs no network. With one configured, a drink is served only once the event leaderboard admits it, so an attendee is told the leaderboard can't be reached and to try again in a moment, and stays on the naming screen. Trying again never adds the drink twice. If the outage lasts, close the station from the dashboard; the booth keeps resending the attempt and settles it once the board answers. | +| "Another drink already has that score" | Normal. Every drink needs its own score. Let the attendee tweak their name and submit again. | | Name rejected unexpectedly | Check the blocklist for an over-broad `substring` entry. Do not read the matched term aloud or add exceptions mid-session. | -| Entries stay unconfirmed on the event leaderboard | Expected while no `leaderboardApi` is configured. Otherwise the sync reason names the refusal: `score_mismatch` means the service and booth run different rubric versions, and is retried. A sync state of `rejected` with code `duplicate_drink` (another attendee holds the name), `unavailable_drink` (staff took it down), or `handle_taken` (both handles this drink could use are taken) is final: it is never resent, even by `leaderboard:republish`. | +| Entries stay unconfirmed on the event leaderboard | Expected while no `leaderboardApi` is configured. Otherwise the sync reason names the refusal: `score_mismatch` means the service and booth run different rubric versions, and is retried. A sync state of `rejected` with code `duplicate_drink` (another attendee holds the name), `unavailable_drink` (staff took it down), `handle_taken` (both handles this drink could use are taken), or `score_taken` (another drink holds the score) is final: it is never resent, even by `leaderboard:republish`. | | Public board lost, or `EVENT_ID` changed while booths still hold the event | A board the service creates from nothing starts **closed to drinks**: the service holds every booth's publishing, rebuilds and ordinary submissions alike, with a retryable `board_rebuilding`, while it still takes takedowns. Rebuild in three steps across the event. (1) Run `npm run leaderboard:republish -- --takedowns` on **every** booth machine. (2) Only when all of them have finished, run `npm run leaderboard:republish -- --open` once, on any staff machine. (3) Run `npm run leaderboard:republish -- --drinks` on each booth; drinks held during the rebuild are also sent by the booths' own retries. (For a single-booth event, `npm run leaderboard:republish -- --all` does all three steps on that one machine: takedowns, then it opens the board, then drinks. Never use `--all` with more than one booth, because it opens the board before the other booths have replayed. If drinks are held, run `--drinks` again: a drinks phase that did not finish keeps its place.) Each booth holds the authoritative copy of its own drinks and takedowns, so no drink may reach the new board before every booth has reserved the names it took down. The service enforces that until you open it, and the drinks phase refuses on a booth that skipped its own takedown phase. Deciding that every booth has finished step 1 is your call. **Only a machine with the staff key can replay takedowns.** If any takedown is not reserved again, whether on a booth-only machine or with the service unreachable, the command sends no drink at all, fails, and says so. | | Starting a new event on a fresh board | First **End the event (archive and reset) on every booth machine**, while the service still serves the old board, so no booth holds a drink or a takedown that the new board would need. (Archiving retries owed takedowns against whichever board is being served, so resetting after the change would carry them onto the new board.) Then change `eventId` and redeploy the infrastructure (see [Leaderboard service operations](#leaderboard-service-operations)), and run `npm run leaderboard:republish -- --open` once. With every booth empty there is nothing to replay. If any booth was not archived and reset, do not take this shortcut: follow the row above (`--takedowns` on every booth, then `--open`, then `--drinks`). Names taken down at the previous event are not carried over; to keep refusing one, add it to the blocklist. Without a redeploy, the same `eventId` can be emptied instead: reset every booth, then **Clear the public leaderboard** on a staff machine's dashboard (step 6 of [Ending an event](#ending-an-event)). | | Panel connection lost | Reopen the canvas. Saved state persists. Do not share loopback URLs or tickets. | @@ -215,13 +216,13 @@ If ownership is uncertain, leave the lock in place and escalate. If JSON is corr ## Event leaderboard submission -The booth is local-first. A drink is committed to this booth's menu before anything is sent anywhere, so an unreachable event leaderboard cannot fail an attendee's submission or lose a name they earned. If submission fails, the attendee still sees their drink, their score, and their booth standing; only the event place is missing, and the canvas says so rather than inventing a rank. +With `leaderboardApi` configured, a drink is served only once the event leaderboard admits it. The screen says it is checking while it waits. If the board refuses it (another drink holds the name or the score, or staff took the name down), the attendee stays on the naming screen with the reason. If the board cannot be reached, nothing is served and the attendee is asked to try again in a moment. The attempt is saved before it is sent, so if the board took the drink but the answer was lost, the next try (or the dashboard's **Refresh and retry**) is recognised as the same drink and it is served once. **End the event** refuses while any such attempt is still waiting for an answer. The booth publishes only when `leaderboardApi` is configured; otherwise the canvas shows no event line at all. The client owns a 4-second timeout, so a booth never waits on a slow service. -A failed submission is retried on the booth screen's refresh, on the staff dashboard's **Refresh and retry**, and automatically from the idle screen at most every 30 seconds. That includes attendees who have already handed over, so a brief network outage recovers without staff. A refusal the service will not change (`rejected`) is never retried. A receipt whose handle, ID, name, or score does not match what was sent (the handle may instead be its canonical form, when another booth used the phrase first) is recorded as a failure, not displayed: a service answering about a different entry must never be shown as this attendee's rank. Check the extension log if entries stay unconfirmed. +A drink already on the menu whose publication failed (one served before this booth was configured) is retried on the booth screen's refresh, on the staff dashboard's **Refresh and retry**, and automatically from the idle screen at most every 30 seconds. That includes attendees who have already handed over, so a brief network outage recovers without staff. A refusal the service will not change (`rejected`) is never retried. A receipt whose handle, ID, name, or score does not match what was sent (the handle may instead be its canonical form, when another booth used the phrase first) is recorded as a failure, not displayed: a service answering about a different entry must never be shown as this attendee's rank. Check the extension log if entries stay unconfirmed. -Read ranks carefully when helping an attendee. "Rank 1 of 1 at this booth" is this booth's own menu and nothing more. Only a confirmed event line reflects the wider competition. No two drinks share a place: a tied score goes to the drink that reached the event board first, so there is always a single first place. At a booth, a tie goes to the drink this booth served first. +Read ranks carefully when helping an attendee. "Rank 1 of 1 at this booth" is this booth's own menu and nothing more. Only a confirmed event line reflects the wider competition. No two drinks share a score, so there is always a single first place. (A board or menu from before that rule may hold a tie; it goes to the drink that got there first.) ## Leaderboard service operations diff --git a/docs/architecture.md b/docs/architecture.md index 1403a24..3df0d68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ There are no pull requests, GitHub writes, issues, reviewers, merges, or checks A name must match the allowed character set, contain at least one of the three mascots, spelled out unbroken (in any declared placement, when an API caller declares one), stay within length limits, avoid duplicates already on the house menu, and pass the blocklist. Duplicate detection compares normalized names so spacing and case cannot smuggle a repeat onto the menu. -Scoring is a deterministic rubric in `services/name-score.mjs`, not a language model. The same name always scores the same, a rank can be recomputed from stored names, and staff can explain any score from the returned breakdown. The component maximums sum to exactly 5,000 and a test pins that total, so "out of 5,000" stays true. The three seeded drinks are worked examples and are never scored or ranked. Never describe a rubric score as Copilot's opinion. +Scoring is a deterministic rubric in `services/name-score.mjs`, not a language model. The same name always scores the same, a rank can be recomputed from stored names, and staff can explain any score from the returned breakdown. The category maximums sum to exactly 5,000, and a test shows one valid name earning every maximum and scoring 5,000, so "out of 5,000" is reachable, not just a sum. Letter value (A=1 to Z=26, capped at 400) moves the score in single points, so ties are rare; a tie that does occur is refused, not ranked: the leaderboard service refuses a different drink with an active score (`409 score_taken`), and a standalone booth does the same on its own menu. The three seeded drinks are worked examples and are never scored or ranked. Never describe a rubric score as Copilot's opinion. The rubric lives in code rather than in a data file. `booth/scoring-rubric.json` described the retired pull-request flow, was read by nothing, and was removed. An attendee-facing published rubric would need a test pinning it to the scorer to prevent drift. @@ -58,7 +58,7 @@ A blocklist only catches what someone predicted. `removeDrink` covers the rest: ## Leaderboard seam -Publication is local-first. The engine commits the served result inside the store transaction, releases the lock, and only then attempts a remote publication. A failed publication never blocks or alters the attendee's local result; a refresh retries it. +With an event leaderboard, serving is board-first: score, admit, then serve. Only the service sees every booth's drinks, so only it can refuse a score another booth's drink holds; serving first would show drinks the board then refuses. `submitName` records the attempt and its publication token in the ledger (`run.admission`) under the store lock, sends it outside the lock, and records the answer under the lock again. Admitted: the drink joins the menu and the attendee is served. Refused: the attempt is dropped and the attendee keeps naming. No answer: nothing is served, the attempt is kept, and the next try, the retry sweep, or a wipe resends the same publication, so a lost reply is served once. An event wipe refuses (`admissions_unsettled`) while any attempt is in doubt. Drinks already on the menu with a pending publication (served before a booth was configured) still publish after the fact and retry on refresh. Receipts are accepted only when the handle, ID, name, and score match what was submitted (the handle may instead be its canonical form, when another booth used the phrase first). A mismatched receipt is recorded as a failure and never displayed. The payload deliberately excludes the run ID, device, and booth identity. @@ -109,9 +109,9 @@ Keep regression coverage for: - Handle generation, uniqueness, and the curated collision suffix. - Name validation: character set, length, mascot presence and placement, duplicates, and blocklist evasion. -- Rubric determinism and the 5,000-point total. +- Rubric determinism, and one valid name reaching every category maximum and 5,000. - Phase transitions, including that completing clears the counter without erasing the served result or house menu. -- Local-first publication, receipt mismatch rejection, and retry on refresh. +- Board-first serving: `score_taken` across booths and in a race, an unreachable board, lost replies served once, an earlier attempt settled first, and a standalone booth's own score check; receipt mismatch rejection, and retry on refresh. - Staff takedown: name reservation after removal, attendee-facing withdrawal of every success claim, and that removal stays out of the dispatched action set. - Event lifecycle: archive-then-verify-then-wipe ordering, refusal on an active station, an empty booth, a missing confirmation, or an archive that did not read back, that a failed archive leaves no file behind, no-overwrite on artifacts, and that a closed station keeps its served drink. - Station reconciliation: a station closed by staff, or one whose event was archived, returns to idle and accepts the next attendee rather than refusing as `already_started` or failing as `run_missing`. diff --git a/docs/integration-contract.md b/docs/integration-contract.md index ffd3073..9266146 100644 --- a/docs/integration-contract.md +++ b/docs/integration-contract.md @@ -28,7 +28,7 @@ The booth exercise is a creative naming competition run entirely inside the Copi The score is a pure function of the submitted name, so the same name always scores the same, a rank can be recomputed server-side from stored names, and staff can explain any score at the booth from the returned breakdown. This preserves rather than amends the existing rule that primary rank is deterministic and server-recomputable. -No model call is involved and no model output is implied. Copilot commentary remains unavailable unless a real attributed, moderated, trusted judge is configured, and would remain commentary rather than rank. The component maximums sum to exactly 5000, which a test pins, so attendees can only be told "out of 5000" while that remains true. +No model call is involved and no model output is implied. Copilot commentary remains unavailable unless a real attributed, moderated, trusted judge is configured, and would remain commentary rather than rank. The category maximums sum to exactly 5000, and a test shows one valid name earning every maximum at once and scoring 5000, so "out of 5000" is a score an attendee can actually reach. Most categories move in steps, but letter value (A=1 to Z=26, summed and capped at 400) moves in single points, so two everyday names rarely land on the same score. A finite score range cannot keep every pair of names apart, so the rubric does not promise to: a collision is handled by refusing the second drink (below). The three house drinks are worked examples. They are seeded on the menu unscored, are refused for scoring, and never appear on the leaderboard, so nobody competes against the demo entries. @@ -36,11 +36,11 @@ The three house drinks are worked examples. They are seeded on the menu unscored One booth is authoritative only for its own menu and cannot know what was invented elsewhere, so cross-booth uniqueness and the global ranking belong to the leaderboard service. `addDrink` takes the menu as input, so a caller may merge remotely known IDs before calling and have the same duplicate check cover both. Stored entries carry the run ID, handle, name, and score needed to reconcile a booth with the service. -Booth standings rank by score, and no two drinks share a place: a tied score goes to the drink that got there first, then to the lower drink ID if both have the same timestamp. At a booth, "first" means the booth served it first. On the event board, it means the service admitted it first. The service ranks with the same `leaderboard()`, so a booth and the event never disagree about what breaks a tie. +No two active drinks share a score. The service refuses to admit a different drink whose score another drink on the board already holds (`409 score_taken`), checked inside the admission under the board's write lock, so concurrent requests on one instance or several cannot both land. A resend of the same drink is still a success, and taking a drink down frees its score. A standalone booth (no `leaderboardApi`) applies the same rule to its own menu. Standings still rank by score with a deterministic order for equal scores, for boards and menus written before this rule: the drink that got there first, then the lower drink ID. The service ranks with the same `leaderboard()`. Handles are unique within a booth. `generateHandle` draws from curated word lists and, once the phrase pool is exhausted, keeps the curated phrase with a deterministic eight-hex suffix. Global uniqueness across booths is the service's responsibility. The service keeps the first entry under a phrase, and stores a later one under `canonicalHandle(handle, id)` (`services/leaderboard.mjs`): the phrase plus eight hex characters derived from the drink ID. It returns that value in its receipt. `validateReceipt` accepts the submitted handle or exactly that canonical form, the attendee's QR link uses the confirmed handle, and the booth never regenerates one. -`services/leaderboard.mjs` is the client seam. A client implements `publish(submission)` and owns its own timeout, and may implement `retract(id)`. `services/leaderboard-client.mjs` is the HTTP client for [the leaderboard service](leaderboard-service.md); it is built only when staff configure `leaderboardApi`, and `leaderboardClient` is null otherwise. The flow is local-first: `submit_name` commits the drink inside the store transaction, the lock is released, and only then is the entry published. A slow or unreachable service therefore cannot hold up the counter, fail an attendee's submission, or lose a drink that was already earned. +`services/leaderboard.mjs` is the client seam. A client implements `publish(submission)` and owns its own timeout, and may implement `retract(id)`. `services/leaderboard-client.mjs` is the HTTP client for [the leaderboard service](leaderboard-service.md); it is built only when staff configure `leaderboardApi`, and `leaderboardClient` is null otherwise. With `leaderboardApi` configured, serving is board-first: `submit_name` scores the drink and runs every local check, records the attempt and its publication token in the ledger (`run.admission`), sends it, and serves it only once the service admits it. A refusal (`409 score_taken`, `duplicate_drink`, `handle_taken`, or `unavailable_drink`) keeps the attendee naming with the reason; `score_taken` asks them to tweak the name. Any other `4xx` (a proxy's `429`, a stopped app's `404`, a validation code) is a refusal only for the first send of a new attempt (`502 leaderboard_refused`, ask staff); on a resend it proves nothing about the earlier send, so the attempt stays in doubt. No answer, a `5xx`, `board_rebuilding`, or a receipt that does not match serves nothing and returns `503 leaderboard_unreachable` ("try again in a moment"); the attempt is kept, and the next try, the retry sweep, or an event wipe resends the same publication, so a reply that was lost after the board admitted the drink is served once, never twice. If the attendee typed a different name in the meantime, the earlier attempt is settled first: if the board took it, that is their drink. One admission per station runs at a time (`409 admission_in_progress`). Without `leaderboardApi`, the booth is its own board and refuses a score another drink on its menu holds, also with `score_taken`. A receipt is accepted only when its handle, ID, name, and score match what was sent (the handle may instead be its canonical form, when another booth used the phrase first); anything else is recorded as a failure rather than shown, so a service answering about a different entry can never rank this attendee. Failures are retried on the next refresh, so a network blip recovers without staff. The submitted payload is deliberately minimal — handle, ID, name, score, and a random per-publication token that stays the same across retries, so the service can tell a retry from another attendee who drew the same handle and name — carrying no run ID, device, or booth identity, since an anonymous handle is all a public board needs. @@ -119,25 +119,25 @@ Registered canvas ID: `commit-and-sip`. Open it with omitted input or `{}`; it a | Action | Input | Meaning | | --- | --- | --- | | `begin` | `{}` | Mint a barista handle and start one attendee's order. Refused if the station already has one. | -| `submit_name` | `{"name":"Mona Moonrise","mascot":"mona","placement":"start"}` | Validate, score, and add the drink. `mascot` and `placement` are optional; when supplied they are checked against the typed name rather than overriding it. | +| `submit_name` | `{"name":"Mona Moonrise","mascot":"mona","placement":"start"}` | Validate, score, and add the drink. With an event leaderboard it is served only once the board admits it; `score_taken`, another refusal, or `leaderboard_unreachable` keeps the attendee naming. `mascot` and `placement` are optional; when supplied they are checked against the typed name rather than overriding it. | | `complete` | `{}` | Finish this attendee and clear the station. Idempotent against a double click. | | `refresh` | `{}` | Read the current station state, house menu, and standings. Retries a failed leaderboard publication. | Unexpected input properties are rejected. Actions other than `begin` and `refresh` require a started order and otherwise return `not_started`. -Publication happens after the store transaction commits and the lock is released, so a slow or unreachable leaderboard can never hold the counter, fail a submission, or lose a drink that was already earned. +With an event leaderboard, a submission answers only once the board has admitted the drink or said why not, and the network call is made outside the store lock, so other stations are never held up. The attendee screen says it is checking while it waits. An unreachable board serves nothing and asks the attendee to try again in a moment; the attempt is kept in the ledger, so trying again never adds the drink twice. ## Implementer requirements for a leaderboard service [The leaderboard service](leaderboard-service.md) implements these requirements and records the decisions taken: hosting, booth authentication, retraction, and server-side moderation. It is deployed and was verified end to end with a staff test entry. -A client implements `publish(submission)` and owns its own timeout. The booth must never wait on a slow service. +A client implements `publish(submission)` and owns its own timeout, so an attendee never waits more than a few seconds for an answer. The submitted payload is deliberately minimal — handle, drink ID, name, score, and a random per-publication token that stays the same across retries, so the service can tell a retry from another attendee who drew the same handle and name. The booth saves the token before the first send. It carries no run ID, device, or booth identity, because an anonymous handle is all a public board needs. -A receipt is accepted only when its handle, ID, name, and score match what was sent (the handle may instead be its canonical form, when another booth used the phrase first). Anything else is recorded as a failure and never displayed, so a service answering about a different entry cannot be shown as this attendee's rank. Failed publications retry on the next refresh. +A receipt is accepted only when its handle, ID, name, and score match what was sent (the handle may instead be its canonical form, when another booth used the phrase first). Anything else is treated as no answer and never displayed, so a service answering about a different entry cannot be shown as this attendee's rank. An admission left in doubt is resent with the same token on the next try, the retry sweep, or an event wipe, which refuses (`admissions_unsettled`) until every one is settled. -Cross-booth uniqueness and global ranking belong to the service. One booth is authoritative only for its own menu and cannot know what was invented elsewhere. `addDrink` takes the menu as input, so a caller may merge remotely known IDs before calling and have the same duplicate check cover both. A tied score goes to the drink the service admitted first. +Cross-booth uniqueness and global ranking belong to the service. One booth is authoritative only for its own menu and cannot know what was invented elsewhere. `addDrink` takes the menu as input, so a caller may merge remotely known IDs before calling and have the same duplicate check cover both. The service must refuse a different drink whose score an active drink already holds (`409 score_taken`), atomically with the admission, and must answer a resend of an admitted drink as a success; the booth relies on both to serve exactly once. Any configured URL must pass `validateLeaderboardUrl` in `services/public-url.mjs`: HTTPS only, no credentials, fragment, or nonstandard port, and no private, loopback, reserved, or reserved-suffix host. `BoothEngine` refuses a non-conforming URL at construction rather than rendering a code that scans to nothing. `verifyPublicUrl` additionally performs an unauthenticated, non-redirecting HEAD with DNS pinned at socket creation, so a second lookup cannot turn a previously public address into an internal target. diff --git a/docs/leaderboard-service.md b/docs/leaderboard-service.md index 19f6c17..4bda2f3 100644 --- a/docs/leaderboard-service.md +++ b/docs/leaderboard-service.md @@ -8,9 +8,9 @@ This document began as a proposal. It now records what was built, the decisions ## What the booth guarantees -- **It saves first, always.** A failed submission can never cost an attendee their drink or their score. +- **It serves only what the board admitted.** With the service configured, a drink joins the booth's menu only once the service has admitted it. A refusal keeps the attendee naming with the reason, and an unreachable service serves nothing and asks them to try again. The attempt and its token are saved before the send, so a lost reply is settled by resending the same publication and the drink is served once. - **The payload is `{handle, id, name, score, token}`** and nothing else. The `token` is a random per-publication token that stays the same across retries, so the service can tell a retry from another attendee who drew the same handle and name. It identifies the publication, never the booth, device or run, and the service keeps only its hash. No run ID, no device, no booth identity. -- **The client owns its timeout** (4 seconds). A booth queue never waits on a slow service. +- **The client owns its timeout** (4 seconds), so an attendee waits at most a few seconds for an answer, and other stations are never held up. - **A local booth rank and an event rank are never conflated.** Until a receipt arrives, the canvas says the place is still being confirmed. ## Decisions @@ -56,11 +56,12 @@ A key proves which service a request is allowed into, not that the booth code is - `scoreCoffeeName` rechecks the name rules, the blocklist, and the house-example exclusion, and recomputes the score. The request is refused with `422 score_mismatch` if the score or ID differ, and with `422 rejected_name` if the name is refused. - The handle must be built from `booth/handle-words.json`, optionally with the eight-hex collision suffix, or the request is refused with `422 invalid_handle`. -- Ranking is the booth's own `leaderboard()`, and no two drinks share a place. A tied score goes to the drink the service admitted first (its stored `createdAt`). The store stamps that time inside the admission itself, under the write lock, and always later than every drink already on the board, so entry times follow admission order even when a request waited for the lock or a second instance's clock runs behind. Two rows with the same time, possible only on a board written before that rule, are ordered by drink ID. The booth and the board break a tie by the same rule. Admission time comes from the service's own clock, never from the booth, so a leaked booth key cannot claim an earlier place. A rebuild (`npm run leaderboard:republish -- --drinks`) admits every drink again, so after one, tied drinks keep the order in which the booths replayed them, not the order of the original event. +- **No two active drinks share a score.** A different drink whose score another drink on the board already holds is refused with `409 score_taken`. The check runs inside the admission, under the write lock, so two requests racing for one score, on one instance or two, never both land. A resend of the same drink is still `200`, and a takedown frees the score. Scores are never re-scored on load, so a board written before this rule may still hold a tie. +- Ranking is the booth's own `leaderboard()`, and no two drinks share a place. On a board that still holds a tie from before `score_taken`, the tied score goes to the drink the service admitted first (its stored `createdAt`). The store stamps that time inside the admission itself, under the write lock, and always later than every drink already on the board, so entry times follow admission order even when a request waited for the lock or a second instance's clock runs behind. Two rows with the same time, possible only on a board written before that rule, are ordered by drink ID. The booth and the board break a tie by the same rule. Admission time comes from the service's own clock, never from the booth, so a leaked booth key cannot claim an earlier place. A rebuild (`npm run leaderboard:republish -- --drinks`) admits every drink again, so after one, tied drinks keep the order in which the booths replayed them, not the order of the original event. The service moderates with the blocklist deployed alongside it. Updating the blocklist means redeploying the service. `/healthz` reports `moderation: "placeholder"` whenever the list is unreviewed, and the service then logs a warning at start-up. -**Version skew is real.** A rubric change that reaches the service before the booths makes their submissions fail with `score_mismatch`. The booth shows those drinks as still being confirmed, and the reason is in its sync record. Deploy the service from the same commit the booths run. +**Version skew is real.** A rubric change that reaches the service before the booths makes their submissions fail with `score_mismatch`. A booth then serves nothing and its attendees are told to try again, so deploy the service and every booth from the same commit, together. A booth build from before board-first serving serves first and publishes after, so it would show drinks the service then refuses with `score_taken`: never run one against a board that enforces it. ### Retraction: delete the entry, reserve the name everywhere @@ -78,7 +79,7 @@ The service replies with `{handle, id, name, score, rank, entries}`. `id`, `name ### The public page -`GET /` shows rank, drink, barista handle, and score, for the top 20 plus the true total. First place is highlighted with a **Leader** badge and the time its drink was entered, because a tied top score goes to the drink that reached the board first. That time is the only one the API publishes (`leaderEnteredAt`, the leader's stored `createdAt`); rows carry none. With `?handle=…&ref=…` it also shows "your drink" wherever it ranks. **Both are required:** a handle is unique only at one booth, so a handle alone returns no personal row, and `ref` is the opaque publication reference the booth puts in the attendee's QR link. It shows no booth identity, run IDs, or staff attribution, and **no mascot art**, because `mona.png` is not cleared for publication. It polls every 10 seconds. If polling fails, it keeps the last board and says how old it is, so a dropped connection gives a visibly stale board rather than a silently wrong one. +`GET /` shows rank, drink, barista handle, and score, for the top 20 plus the true total. First place is highlighted with a **Leader** badge and the time its drink was entered, because on a board from before `score_taken`, a tied top score goes to the drink that reached the board first. That time is the only one the API publishes (`leaderEnteredAt`, the leader's stored `createdAt`); rows carry none. With `?handle=…&ref=…` it also shows "your drink" wherever it ranks. **Both are required:** a handle is unique only at one booth, so a handle alone returns no personal row, and `ref` is the opaque publication reference the booth puts in the attendee's QR link. It shows no booth identity, run IDs, or staff attribution, and **no mascot art**, because `mona.png` is not cleared for publication. It polls every 10 seconds. If polling fails, it keeps the last board and says how old it is, so a dropped connection gives a visibly stale board rather than a silently wrong one. Security measures: @@ -104,7 +105,7 @@ GET /api/board[?handle=&ref=] { asOf, boardId, captured, leaderEnteredAt, leaderEnteredAt: when the first-place drink was admitted, or null on an empty board. POST /api/entries booth key. 201 new, 200 same entry again (handle may be canonical), 409 duplicate_drink | - unavailable_drink | handle_taken, + unavailable_drink | handle_taken | score_taken, 422 score_mismatch | rejected_name | invalid_handle POST /api/retractions staff key. Body exactly { id }. 204 retracted, 200 {"retraction":"absent"}. Reserves either way. The ID is never in a URL, so web-server diff --git a/leaderboard-service/app.mjs b/leaderboard-service/app.mjs index ecec630..806bfdd 100644 --- a/leaderboard-service/app.mjs +++ b/leaderboard-service/app.mjs @@ -5,7 +5,7 @@ import { leaderboard } from "../.github/extensions/commit-and-sip/services/booth import { canonicalHandle, PUBLICATION_TOKEN, publicRef, tokenHashOf } from "../.github/extensions/commit-and-sip/services/leaderboard.mjs"; import { blocklistStatus } from "../.github/extensions/commit-and-sip/services/moderation.mjs"; import { scoreCoffeeName } from "../.github/extensions/commit-and-sip/services/name-score.mjs"; -import { BoardChangedError, ClosedError, HandleTakenError, KeyMismatchError, ReservedError } from "./store.mjs"; +import { BoardChangedError, ClosedError, HandleTakenError, KeyMismatchError, ReservedError, ScoreTakenError } from "./store.mjs"; // The public leaderboard for the Commit & Sip booth. // @@ -15,8 +15,11 @@ import { BoardChangedError, ClosedError, HandleTakenError, KeyMismatchError, Res // with the booth's own modules, so even a leaked booth key cannot post a score // the rubric did not award, a name the blocklist refuses, or a handle that was // not built from the curated word lists. Ranking is the booth's own -// `leaderboard()`, so the booth and this board break a tie by the same rule: -// the drink that got there first, which here is the one admitted first. +// `leaderboard()`. Two different drinks never share a score: admission refuses +// a score another drink already holds (409 `score_taken`), so the booth asks +// the attendee to tweak the name. A board written before that rule may still +// hold a tie, which the booth and this board break by the same rule: the drink +// that got there first, which here is the one admitted first. const MAX_BODY_BYTES = 2048; const BOARD_SIZE = 20; @@ -185,6 +188,10 @@ export function createApp({ store, rules, words, boothKey, staffKey, reservation // Retryable: the booth keeps the drink and sends it again once open. if (error instanceof ClosedError) throw new HttpError(503, "board_rebuilding", "The event board is being rebuilt. The drink is kept and sent again later."); if (error instanceof HandleTakenError) throw new HttpError(409, "handle_taken", "That handle is already on the board."); + // Final for this name: the booth keeps the attendee naming and asks + // for a tweak. Never sent for a resend of the same publication, which + // is judged below as a retry before any score is compared. + if (error instanceof ScoreTakenError) throw new HttpError(409, "score_taken", "Another drink on the board already has that score."); throw error; } if (!admitted.created) { diff --git a/leaderboard-service/store.mjs b/leaderboard-service/store.mjs index 3269e22..db65e96 100644 --- a/leaderboard-service/store.mjs +++ b/leaderboard-service/store.mjs @@ -51,6 +51,18 @@ export class HandleTakenError extends Error { } } +// No two drinks on a board share a score, so a rank is never a tie. The +// rubric pays in single points, but a finite range cannot give every name its +// own total, so a different drink whose score is already taken is refused and +// the attendee tweaks the name. A takedown frees the score. +export class ScoreTakenError extends Error { + constructor() { + super("Another drink on the board already has that score."); + this.name = "ScoreTakenError"; + this.code = "score_taken"; + } +} + // The board is closed to new entries while it is being rebuilt. Takedowns // are replayed first, and no drink may land before every booth has reserved // the names it took down, so admission waits (a booth keeps the drink and @@ -148,10 +160,15 @@ export class MemoryStore { const board = () => [...this.entries.values()].map(item => ({ ...item })); const existing = this.entries.get(entry.id); if (existing) return { created: false, entries: board(), entry: { ...existing } }; + // Checked here, inside the one admission step (under the write lock, for + // the file store), so two drinks racing for the same score cannot both win. + if ([...this.entries.values()].some(item => item.score === entry.score)) throw new ScoreTakenError(); const used = new Set([...this.entries.values()].map(item => item.handle)); const handle = handles.find(candidate => !used.has(candidate)); if (!handle) throw new HandleTakenError(); - // A tied score goes to the drink admitted first, so entry times must follow + // Admission refuses a taken score, but a board written before that rule + // can still hold a tie, and it goes to the drink admitted first. So entry + // times must follow // admission order. Stamped inside the admission (under the write lock, for // the file store), not when the request arrived: a request that waited for // the lock could otherwise carry an earlier time than one admitted before diff --git a/tests/board-first.test.mjs b/tests/board-first.test.mjs new file mode 100644 index 0000000..e3c867b --- /dev/null +++ b/tests/board-first.test.mjs @@ -0,0 +1,245 @@ +// With an event leaderboard, a booth serves a drink only once the board has +// admitted it, and the board refuses a score another active drink holds. These +// cover the booth side of that contract against the real service, including +// the lost replies, retries, and races the attendee never sees. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../leaderboard-service/app.mjs"; +import { MemoryStore } from "../leaderboard-service/store.mjs"; +import { BoothEngine } from "../.github/extensions/commit-and-sip/booth-engine.mjs"; +import { loadCatalog } from "../.github/extensions/commit-and-sip/domain.mjs"; +import { RunStore } from "../.github/extensions/commit-and-sip/store.mjs"; +import { loadNameRules } from "../.github/extensions/commit-and-sip/services/coffee-name.mjs"; +import { createLeaderboardClient } from "../.github/extensions/commit-and-sip/services/leaderboard-client.mjs"; +import { validateBlocklist } from "../.github/extensions/commit-and-sip/services/moderation.mjs"; +import { scoreCoffeeName } from "../.github/extensions/commit-and-sip/services/name-score.mjs"; +import { WIPE_CONFIRMATION } from "../.github/extensions/commit-and-sip/services/event-archive.mjs"; + +const BOOTH_KEY = "b".repeat(24) + "-booth-key-for-tests"; +const STAFF_KEY = "s".repeat(24) + "-staff-key-for-tests"; +const reviewed = validateBlocklist({ + review: { placeholder: false, reviewedAt: "2026-01-01", reviewedBy: "test" }, + entries: [{ match: "substring", term: "zzqq" }], +}); +const rules = { ...(await loadNameRules()), blocklist: reviewed }; +const catalog = await loadCatalog(); +const words = JSON.parse(await readFile(new URL("../booth/handle-words.json", import.meta.url), "utf8")); + +// The same words in another order: different drinks, the same score. +const TIED = ["Mona Velvet Mocha", "Mona Mocha Velvet"]; + +async function service(t) { + const store = new MemoryStore(); + const app = createApp({ boothKey: BOOTH_KEY, reservationKey: "r".repeat(40), rules, staffKey: STAFF_KEY, store, words }); + const server = createServer(app).listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise(resolve => server.close(resolve))); + return { store, url: `http://127.0.0.1:${server.address().port}` }; +} + +async function booth(t, leaderboardClient = null) { + const directory = await mkdtemp(join(tmpdir(), "sip-first-")); + t.after(() => rm(directory, { force: true, recursive: true })); + return new BoothEngine({ catalog, leaderboardClient, rules, store: new RunStore(directory) }); +} + +const clientFor = url => createLeaderboardClient({ boothKey: BOOTH_KEY, staffKey: STAFF_KEY, url }); +const boardOf = async url => (await fetch(`${url}/api/board`)).json(); + +test("the tied names really tie, so these tests prove a refusal and not a typo", () => { + const [a, b] = TIED.map(name => scoreCoffeeName(name, rules).score); + assert.equal(a, b); +}); + +test("a score another booth's drink holds keeps this attendee naming, and a tweak lands", async t => { + const { url } = await service(t); + const boothA = await booth(t, clientFor(url)); + const boothB = await booth(t, clientFor(url)); + await boothA.open({ runId: "a-1" }); + const first = await boothA.dispatch("a-1", "submit_name", { name: TIED[0] }); + assert.equal(first.phase, "served"); + assert.equal(first.sync.state, "confirmed", "served only with the board's receipt"); + + await boothB.open({ runId: "b-1" }); + await assert.rejects(boothB.dispatch("b-1", "submit_name", { name: TIED[1] }), error => { + assert.equal(error.code, "score_taken"); + assert.equal(error.status, 409); + assert.match(error.message, /Tweak your name/); + return true; + }); + const waiting = await boothB.get("b-1"); + assert.equal(waiting.phase, "naming", "the attendee is still on the naming screen"); + assert.match(waiting.statusMessage, /Tweak your name/); + assert.equal(waiting.houseMenu.some(item => item.name === TIED[1]), false, "nothing was served"); + + const tweaked = await boothB.dispatch("b-1", "submit_name", { name: `${TIED[1]}s` }); + assert.equal(tweaked.phase, "served"); + assert.deepEqual((await boardOf(url)).entries.map(row => row.name).sort(), [TIED[0], `${TIED[1]}s`].sort()); +}); + +test("two booths racing for one score serve exactly one drink", async t => { + const { url } = await service(t); + const booths = await Promise.all([booth(t, clientFor(url)), booth(t, clientFor(url))]); + await Promise.all(booths.map((engine, index) => engine.open({ runId: `race-${index}` }))); + const results = await Promise.allSettled(booths.map((engine, index) => + engine.dispatch(`race-${index}`, "submit_name", { name: TIED[index] }))); + assert.deepEqual(results.map(result => result.status).sort(), ["fulfilled", "rejected"]); + assert.equal(results.find(result => result.status === "rejected").reason.code, "score_taken"); + const served = await Promise.all(booths.map((engine, index) => engine.get(`race-${index}`))); + assert.deepEqual(served.map(view => view.phase).sort(), ["naming", "served"]); + assert.equal((await boardOf(url)).total, 1); +}); + +test("a taken-down drink frees its score for the next attendee", async t => { + const { url } = await service(t); + const engine = await booth(t, clientFor(url)); + await engine.open({ runId: "r-1" }); + const first = await engine.dispatch("r-1", "submit_name", { name: TIED[0] }); + await engine.removeDrink({ id: first.submission.id, reason: "test", removedBy: "lead" }); + await engine.open({ runId: "r-2" }); + assert.equal((await engine.dispatch("r-2", "submit_name", { name: TIED[1] })).phase, "served"); +}); + +test("an admission whose reply was lost is served once when the attendee tries again", async t => { + const { url } = await service(t); + const real = clientFor(url); + let loseReply = true; + const sent = []; + const client = { + ...real, + async publish(submission) { + sent.push(submission.token); + const receipt = await real.publish(submission); // the board admits it... + if (loseReply) { loseReply = false; throw new Error("socket hang up"); } // ...and the answer is lost + return receipt; + }, + }; + const engine = await booth(t, client); + await engine.open({ runId: "r-1" }); + await assert.rejects(engine.dispatch("r-1", "submit_name", { name: "Mona Moonrise Mocha" }), + { code: "leaderboard_unreachable", status: 503, message: /Try again in a moment/ }); + assert.equal((await engine.get("r-1")).phase, "naming", "not served on a guess"); + assert.equal((await boardOf(url)).total, 1, "though the board did take it"); + + const served = await engine.dispatch("r-1", "submit_name", { name: "Mona Moonrise Mocha" }); + assert.equal(served.phase, "served"); + assert.equal(served.sync.state, "confirmed"); + assert.deepEqual(sent, [sent[0], sent[0]], "the retry resent the same publication"); + assert.equal((await boardOf(url)).total, 1, "and the board counted it once"); + assert.equal(served.houseMenu.filter(item => item.name === "Mona Moonrise Mocha").length, 1); + await assert.rejects(engine.dispatch("r-1", "submit_name", { name: "Mona Moonrise Mocha" }), { code: "already_served" }); +}); + +test("an earlier name the board took is the attendee's drink, even if they typed a new one", async t => { + const { url } = await service(t); + const real = clientFor(url); + let loseReply = true; + const client = { ...real, async publish(submission) { + const receipt = await real.publish(submission); + if (loseReply) { loseReply = false; throw new Error("timeout"); } + return receipt; + } }; + const engine = await booth(t, client); + await engine.open({ runId: "r-1" }); + await assert.rejects(engine.dispatch("r-1", "submit_name", { name: "Mona Moonrise Mocha" }), { code: "leaderboard_unreachable" }); + const served = await engine.dispatch("r-1", "submit_name", { name: "Ducky Dawn Drizzle" }); + assert.equal(served.submission.name, "Mona Moonrise Mocha", "the board holds the earlier name, so that is what is served"); + assert.match(served.statusMessage, /Your earlier name, Mona Moonrise Mocha, reached the event leaderboard/); + assert.deepEqual((await boardOf(url)).entries.map(row => row.name), ["Mona Moonrise Mocha"]); +}); + +test("a proxy's 4xx to a resend keeps the admission in doubt, so the attendee never gets two drinks", async t => { + const { url } = await service(t); + const real = clientFor(url); + const sent = []; + const client = { ...real, async publish(submission) { + sent.push(submission.name); + if (sent.length === 1) { await real.publish(submission); throw new Error("timeout"); } // landed, answer lost + if (sent.length === 2) throw Object.assign(new Error("Too many requests"), { status: 429 }); + return real.publish(submission); + } }; + const engine = await booth(t, client); + await engine.open({ runId: "r-1" }); + await assert.rejects(engine.dispatch("r-1", "submit_name", { name: TIED[0] }), { code: "leaderboard_unreachable" }); + await assert.rejects(engine.dispatch("r-1", "submit_name", { name: "Mona Latte Lagoon" }), + { code: "leaderboard_unreachable", status: 503 }, "a 429 on a resend proves nothing about the first send"); + assert.equal((await engine.get("r-1")).phase, "naming"); + assert.equal((await engine.store.read()).runs["r-1"].admission.entry.name, TIED[0], "the admission is still in the ledger"); + + const served = await engine.dispatch("r-1", "submit_name", { name: "Mona Latte Lagoon" }); + assert.equal(served.submission.name, TIED[0], "the drink the board already holds is the attendee's drink"); + assert.deepEqual(sent, [TIED[0], TIED[0], TIED[0]]); + assert.deepEqual((await boardOf(url)).entries.map(row => row.name), [TIED[0]], "one drink on the board, not two"); +}); + +test("a 4xx to the first send of a new admission is a refusal, not a doubt", async t => { + const client = { async publish() { throw Object.assign(new Error("Too many requests"), { status: 429 }); } }; + const engine = await booth(t, client); + await engine.open({ runId: "r-1" }); + await assert.rejects(engine.dispatch("r-1", "submit_name", { name: "Mona Moonrise Mocha" }), + { code: "leaderboard_refused", status: 502 }); + assert.equal((await engine.get("r-1")).phase, "naming"); + assert.equal((await engine.store.read()).runs["r-1"].admission, undefined, "nothing can be on the board, so nothing is kept"); +}); + +test("an earlier name the board refuses frees the attendee's new name", async t => { + const sent = []; + const client = { async publish(submission) { + sent.push(submission.name); + if (sent.length === 1) throw new Error("timeout"); + if (sent.length === 2) throw Object.assign(new Error("taken"), { code: "score_taken", status: 409 }); + return { ...submission, entries: 1, rank: 1 }; + } }; + const engine = await booth(t, client); + await engine.open({ runId: "r-1" }); + await assert.rejects(engine.dispatch("r-1", "submit_name", { name: "Mona Moonrise Mocha" }), { code: "leaderboard_unreachable" }); + const served = await engine.dispatch("r-1", "submit_name", { name: "Ducky Dawn Drizzle" }); + assert.deepEqual(sent, ["Mona Moonrise Mocha", "Mona Moonrise Mocha", "Ducky Dawn Drizzle"]); + assert.equal(served.submission.name, "Ducky Dawn Drizzle"); +}); + +test("a rebuilding board is not a refusal: nothing is served and the attempt is kept", async t => { + const client = { async publish() { throw Object.assign(new Error("rebuilding"), { code: "board_rebuilding", status: 503 }); } }; + const engine = await booth(t, client); + await engine.open({ runId: "r-1" }); + await assert.rejects(engine.dispatch("r-1", "submit_name", { name: "Mona Moonrise Mocha" }), { code: "leaderboard_unreachable" }); + const run = (await engine.store.read()).runs["r-1"]; + assert.equal(run.phase, "naming"); + assert.ok(run.admission, "resent, never forgotten, until the board answers"); +}); + +test("the event cannot be wiped while an admission is still in doubt", async t => { + let online = false; + const client = { async publish(submission) { + if (!online) throw new Error("offline"); + return { ...submission, entries: 1, rank: 1 }; + } }; + const engine = await booth(t, client); + await engine.open({ runId: "r-1" }); + await assert.rejects(engine.dispatch("r-1", "submit_name", { name: "Mona Moonrise Mocha" }), { code: "leaderboard_unreachable" }); + await engine.closeStation({ runId: "r-1", closedBy: "lead" }); + await assert.rejects(engine.archiveAndWipe({ archivedBy: "lead", confirm: WIPE_CONFIRMATION }), { code: "admissions_unsettled" }); + assert.ok((await engine.store.read()).runs["r-1"].admission, "nothing was changed"); + online = true; + assert.ok((await engine.archiveAndWipe({ archivedBy: "lead", confirm: WIPE_CONFIRMATION })).archive, + "the wipe settles the admission first, then proceeds"); +}); + +test("a standalone booth refuses a score its own menu already holds", async t => { + const engine = await booth(t); + await engine.open({ runId: "r-1" }); + const first = await engine.dispatch("r-1", "submit_name", { name: TIED[0] }); + assert.equal(first.sync.state, "disabled"); + await engine.open({ runId: "r-2" }); + await assert.rejects(engine.dispatch("r-2", "submit_name", { name: TIED[1] }), + { code: "score_taken", message: /Tweak your name/ }); + assert.equal((await engine.get("r-2")).phase, "naming"); + await engine.removeDrink({ id: first.submission.id, reason: "test", removedBy: "lead" }); + assert.equal((await engine.dispatch("r-2", "submit_name", { name: TIED[1] })).phase, "served", + "a takedown frees the score"); +}); diff --git a/tests/booth-engine.test.mjs b/tests/booth-engine.test.mjs index 1c3b6d0..8f21350 100644 --- a/tests/booth-engine.test.mjs +++ b/tests/booth-engine.test.mjs @@ -329,7 +329,10 @@ async function serveMany(engine, count) { for (let index = 0; index < count; index += 1) { const runId = `booth-many-${index}`; await engine.open({ runId }); - const name = `Mona Brew ${index + 1}`; + // No two drinks share a score, and digits earn no letter value, so the + // suffix is letters: each one moves the score by a point or two. + const letter = String.fromCharCode(97 + index); + const name = `Mona Brew ${letter}${letter}`; await engine.dispatch(runId, "submit_name", { name }); await engine.dispatch(runId, "complete", {}); names.push(name); diff --git a/tests/booth-menu.test.mjs b/tests/booth-menu.test.mjs index 2d83215..f98af9d 100644 --- a/tests/booth-menu.test.mjs +++ b/tests/booth-menu.test.mjs @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { loadCatalog } from "../.github/extensions/commit-and-sip/domain.mjs"; import { loadNameRules } from "../.github/extensions/commit-and-sip/services/coffee-name.mjs"; -import { addDrink, leaderboard, seedMenu, standingFor } from "../.github/extensions/commit-and-sip/services/booth-menu.mjs"; +import { addDrink, leaderboard, prepareDrink, seedMenu, standingFor } from "../.github/extensions/commit-and-sip/services/booth-menu.mjs"; const rules = await loadNameRules(); const catalog = await loadCatalog(); @@ -96,8 +96,10 @@ test("the leaderboard ranks by score and gives a tie to the drink served first", const at = (rawName, runId, handle, now) => addDrink(menu, { rawName, rules, runId, handle, now }); // Served in the opposite order to the names' alphabetical and ID order, so // only the time can put the first-served drink on top. - at("Mona Moonlight Mocha", "run-1", "brave-brewing-otter", "2026-01-01T09:00:00.000Z"); - at("Ducky Dawn Drip", "run-2", "keen-frothing-vole", "2026-01-01T09:05:00.000Z"); + // Word order swapped: two different drinks with one score, as a menu kept + // from before scores had to be unique could hold. + at("Mona Velvet Mocha", "run-1", "brave-brewing-otter", "2026-01-01T09:00:00.000Z"); + at("Mona Mocha Velvet", "run-2", "keen-frothing-vole", "2026-01-01T09:05:00.000Z"); at("Mona Nebula", "run-3", "calm-pouring-heron", "2026-01-01T09:10:00.000Z"); const board = leaderboard(menu); @@ -106,7 +108,7 @@ test("the leaderboard ranks by score and gives a tie to the drink served first", // here with an obvious reason rather than somewhere downstream. assert.equal(board[0].score, board[1].score, "this fixture relies on the top two names tying"); assert.notEqual(board[1].score, board[2].score); - assert.deepEqual(board.map(row => row.name), ["Mona Moonlight Mocha", "Ducky Dawn Drip", "Mona Nebula"], + assert.deepEqual(board.map(row => row.name), ["Mona Velvet Mocha", "Mona Mocha Velvet", "Mona Nebula"], "the tied drink served first ranks above the one served later"); assert.deepEqual(board.map(row => row.rank), [1, 2, 3], "no two drinks share a place"); @@ -119,19 +121,41 @@ test("the leaderboard ranks by score and gives a tie to the drink served first", test("a tie in score and time still ranks the same however the menu is stored", () => { const menu = seedMenu(catalog); // `add` stamps every drink with the same instant, so only the ID separates these two. - const first = add(menu, "Mona Moonlight Mocha", "run-1", "brave-brewing-otter"); - const second = add(menu, "Ducky Dawn Drip", "run-2", "keen-frothing-vole"); + const first = add(menu, "Mona Velvet Mocha", "run-1", "brave-brewing-otter"); + const second = add(menu, "Mona Mocha Velvet", "run-2", "keen-frothing-vole"); assert.equal(first.score, second.score, "this fixture relies on the two names tying"); // An ID that would sort first, so only the missing time can put it last. menu.push({ ...first, createdAt: undefined, handle: "calm-pouring-heron", id: "aaa-undated", name: "Undated Drink", runId: "run-3" }); const board = leaderboard(menu); assert.deepEqual(board.map(row => row.rank), [1, 2, 3]); - assert.deepEqual(board.map(row => row.name), ["Ducky Dawn Drip", "Mona Moonlight Mocha", "Undated Drink"], + assert.deepEqual(board.map(row => row.name), ["Mona Mocha Velvet", "Mona Velvet Mocha", "Undated Drink"], "the same instant falls back to the drink ID, and a row with no time comes last"); assert.deepEqual(leaderboard([...menu].reverse()), board, "storage order never changes the board"); }); +test("a standalone menu refuses a score another drink holds, until a takedown frees it", () => { + const menu = seedMenu(catalog); + const unique = (rawName, runId, handle) => addDrink(menu, { rawName, rules, runId, handle, uniqueScore: true, + now: "2026-01-01T00:00:00.000Z" }); + const first = unique("Mona Velvet Mocha", "run-1", "brave-brewing-otter"); + let error; + assert.throws(() => unique("Mona Mocha Velvet", "run-2", "keen-frothing-vole"), thrown => { error = thrown; return true; }); + assert.deepEqual([error.code, error.status], ["score_taken", 409]); + assert.match(error.message, /Tweak your name/, "the attendee is told what to do next"); + assert.equal(menu.length, 4, "a refused drink never joins the menu"); + // House examples carry no score, so they never block one. + assert.ok(unique("Mona Moonrise Mocha", "run-3", "calm-pouring-heron")); + // Preparing checks everything and builds the entry, but adds nothing. + const prepared = prepareDrink(menu, { rawName: "Ducky Dawn Drizzle", rules, runId: "run-4", handle: "glad-pouring-stoat", uniqueScore: true }); + assert.equal(prepared.id, "ducky-dawn-drizzle"); + assert.equal(menu.length, 5); + assert.throws(() => prepareDrink(menu, { rawName: "Mona Mocha Velvet", rules, runId: "run-2", handle: "keen-frothing-vole", uniqueScore: true }), + { code: "score_taken" }); + menu.splice(menu.indexOf(first), 1); + assert.equal(unique("Mona Mocha Velvet", "run-2", "keen-frothing-vole").score, first.score, "a removed drink frees its score"); +}); + test("a drink cannot be added without a run and handle to attribute it to", () => { const menu = seedMenu(catalog); assert.throws(() => addDrink(menu, { rawName: "Mona Mocha", rules, runId: "", handle: "h" }), { code: "invalid_run" }); diff --git a/tests/leaderboard-service.test.mjs b/tests/leaderboard-service.test.mjs index 138ec11..6a99b34 100644 --- a/tests/leaderboard-service.test.mjs +++ b/tests/leaderboard-service.test.mjs @@ -103,10 +103,10 @@ test("a booth publishes, the board shows it, and a takedown clears it", async t assert.equal((await board(url)).total, 0, "a takedown must reach the public board"); assert.equal((await store.read()).removals[0].published, "retracted", "the outcome is recorded for staff"); - // The usual case: staff catch a name before it ever syncs. The service has - // nothing to delete, and that is a clean outcome, not a failure to retry. - await engine.open({ runId: "booth-2" }); - const unsynced = await engine.dispatch("booth-2", "submit_name", { name: "Copilot Cinder Crema" }); + // A drink served before this booth had a leaderboard, caught before it ever + // synced. The service has nothing to delete, and that is a clean outcome, + // not a failure to retry. + const unsynced = await servedPending(store, "booth-2", "Copilot Cinder Crema"); const early = await engine.removeDrink({ id: unsynced.submission.id, reason: "test", removedBy: "lead" }); assert.equal(early.published, "absent"); assert.deepEqual(await engine.retryRetractions(), [], "an absent entry is not queued for retry"); @@ -242,39 +242,80 @@ test("a retried submission is a success, but a clashing name is not", async t => assert.equal((await board(url)).entries[0].handle, HANDLE); }); -test("a tie goes to the drink the board admitted first, by the booth's own rule", async t => { - // A clock that moves a second per admission, so admission order is the only - // thing separating the tied pair, never two arrivals in one millisecond. - let tick = Date.parse("2026-10-28T09:00:00.000Z"); - const { url, store } = await service(t, { now: () => new Date(tick += 1000) }); - const candidates = ["Mocha", "Latte", "Crema", "Brew", "Roast", "Drip", "Chai", "Frappe"].flatMap(coffee => - ["Moonrise", "Sunset", "Velvet", "Ember", "Harbor", "Meadow"].map(word => `Mona ${word} ${coffee}`)); - const byScore = new Map(); - for (const name of candidates) { - const { score } = scoreCoffeeName(name, rules); - byScore.set(score, [...(byScore.get(score) ?? []), name]); - } - // A tie with something strictly above and below it, so the tied pair must - // land on 2 and 3, and nothing under them may skip a place. - const top = Math.max(...byScore.keys()); - const [tiedScore, tied] = [...byScore.entries()].find(([score, names]) => names.length >= 2 && score < top) ?? []; - assert.ok(tied, "the candidate list should contain a tie below the top score"); - const higher = [...byScore.entries()].find(([score]) => score > tiedScore); - const lower = [...byScore.entries()].find(([score]) => score < tiedScore); - assert.ok(lower, "the candidate list should contain a score below the tie"); - // Admit the tied drink whose ID sorts later first, so neither ID nor name - // order can explain it winning the tie. - const [laterId, earlierId] = tied.slice(0, 2).sort((a, b) => - scoreCoffeeName(b, rules).id.localeCompare(scoreCoffeeName(a, rules).id)); +// Swapping two words keeps every rubric signal, so these two different drinks +// genuinely share a score. Any real collision would do; this one is easy to see. +const TIED = ["Mona Velvet Mocha", "Mona Mocha Velvet"]; - const handles = [HANDLE, OTHER_HANDLE, `${words.adjectives[2]}-${words.verbs[2]}-${words.nouns[2]}`, - `${words.adjectives[3]}-${words.verbs[3]}-${words.nouns[3]}`]; - const sent = [laterId, earlierId, higher[1][0], lower[1][0]].map((name, index) => submission(name, handles[index])); - for (const body of sent) assert.equal((await post(url, body)).status, 201); - // Receipts are read after every entry has landed. A receipt issued while an - // entry was still alone on the board would say rank 1 whatever the rule is. +test("a different drink whose score is already on the board is refused, until a takedown frees it", async t => { + const { url } = await service(t); + const [first, rival] = TIED.map((name, index) => submission(name, [HANDLE, OTHER_HANDLE][index])); + assert.equal(first.score, rival.score, "this fixture relies on the two names tying"); + assert.notEqual(first.id, rival.id); + assert.equal((await post(url, first)).status, 201); + + const refused = await post(url, rival); + assert.equal(refused.status, 409); + assert.equal((await refused.json()).error, "score_taken"); + assert.deepEqual((await board(url)).entries.map(row => row.name), [first.name], "the refused drink never lands"); + + const resent = await post(url, first); + assert.equal(resent.status, 200, "resending the drink that holds the score is still a retry, not a clash"); + assert.equal((await resent.json()).rank, 1); + + assert.equal((await del(url, first.id)).status, 204); + assert.equal((await post(url, rival)).status, 201, "a takedown frees its score for another drink"); + assert.deepEqual((await board(url)).entries.map(row => row.score), [rival.score]); +}); + +test("two tied drinks racing for one score never both land, on one instance or two", async t => { + const { url } = await service(t); + const racers = TIED.map((name, index) => submission(name, [HANDLE, OTHER_HANDLE][index])); + const answers = await Promise.all(racers.map(body => post(url, body))); + assert.deepEqual(answers.map(response => response.status).sort(), [201, 409]); + const loser = answers.find(response => response.status === 409); + assert.equal((await loser.json()).error, "score_taken"); + assert.equal((await board(url)).total, 1); + + // Two FileStores on one share stand in for two App Service instances. The + // check runs inside the write lock, so it holds across instances too. + const { ScoreTakenError } = await import("../leaderboard-service/store.mjs"); + const directory = await tempDirectory(t); + const [a, b] = await Promise.all([openStore({ directory }), openStore({ directory })]); + const tied = id => ({ ...entry(id), score: 2222 }); + const outcomes = await Promise.allSettled(Array.from({ length: 6 }, (_, index) => + (index % 2 ? a : b).admit(tied(`mona-racer-${index}`), { fingerprint: fp(`racer-${index}`), handles: [tied(`mona-racer-${index}`).handle] }))); + assert.equal(outcomes.filter(outcome => outcome.status === "fulfilled").length, 1, "exactly one racer takes the score"); + assert.ok(outcomes.filter(outcome => outcome.status === "rejected").every(outcome => outcome.reason instanceof ScoreTakenError)); + assert.equal((await (await openStore({ directory })).list()).length, 1); +}); + +test("a tie left on a board from before score_taken goes to the drink admitted first, by the booth's own rule", async t => { + const { url, store } = await service(t); + // Written straight into the store, as an older build would have admitted + // them. Each carries the token hash a resend is matched against. + const legacy = async (name, handle, createdAt) => { + const sent = submission(name, handle); + const { token, ...fields } = sent; + await store.create({ ...fields, createdAt, tokenHash: tokenHashOf(token) }); + return sent; + }; + // The tied drink whose ID sorts later is admitted first, so neither ID nor + // name order can explain it winning the tie. + const [laterId, earlierId] = [...TIED].sort((a, b) => + scoreCoffeeName(b, rules).id.localeCompare(scoreCoffeeName(a, rules).id)); + const sent = [ + await legacy(laterId, HANDLE, "2026-10-28T09:00:01.000Z"), + await legacy(earlierId, OTHER_HANDLE, "2026-10-28T09:00:02.000Z"), + await legacy("Mona Moonrise Mocha", `${words.adjectives[2]}-${words.verbs[2]}-${words.nouns[2]}`, "2026-10-28T09:00:03.000Z"), + await legacy("Mona Sunset Brew", `${words.adjectives[3]}-${words.verbs[3]}-${words.nouns[3]}`, "2026-10-28T09:00:04.000Z"), + ]; + assert.ok(sent[2].score > sent[0].score && sent[3].score < sent[0].score, "one drink above the tie and one below"); const receipts = []; - for (const body of sent) receipts.push(await (await post(url, body)).json()); + for (const body of sent) { + const resent = await post(url, body); + assert.equal(resent.status, 200, "a resend of a legacy tied drink is still a retry"); + receipts.push(await resent.json()); + } assert.equal(receipts[2].rank, 1); assert.equal(receipts[0].rank, 2, "the tied drink admitted first takes the higher place"); assert.equal(receipts[1].rank, 3, "the tied drink admitted second sits directly below it"); @@ -290,35 +331,24 @@ test("the board says when its leader was entered, and no other drink's time", as const { url, store } = await service(t, { now: () => new Date(tick += 1000) }); assert.equal((await board(url)).leaderEnteredAt, null, "an empty board has no leader"); - const byScore = new Map(); - for (const name of ["Mocha", "Latte", "Crema", "Brew", "Roast", "Drip"].flatMap(coffee => - ["Moonrise", "Sunset", "Velvet", "Ember"].map(word => `Mona ${word} ${coffee}`))) { - const { score } = scoreCoffeeName(name, rules); - byScore.set(score, [...(byScore.get(score) ?? []), name]); - } - // The top of this board is a tie, so only admission order picks the leader. - const [tiedScore, tied] = [...byScore.entries()].sort(([a], [b]) => b - a).find(([, names]) => names.length >= 2) ?? []; - assert.ok(tied, "the candidate list should contain a tie"); - const lower = [...byScore.entries()].find(([score]) => score < tiedScore); - assert.ok(lower, "the candidate list should contain a score below the tie"); - // The ID that sorts later goes in first, so only its time can make it lead. - const [first, second] = tied.slice(0, 2).sort((a, b) => - scoreCoffeeName(b, rules).id.localeCompare(scoreCoffeeName(a, rules).id)); + // Admitted lowest score first, so the leader is never simply the first in. + const names = ["Mona Sunset Brew", "Mona Velvet Mocha", "Mona Moonrise Mocha"]; const handles = [HANDLE, OTHER_HANDLE, `${words.adjectives[2]}-${words.verbs[2]}-${words.nouns[2]}`]; - const sent = [first, second, lower[1][0]].map((name, index) => submission(name, handles[index])); + const sent = names.map((name, index) => submission(name, handles[index])); + assert.ok(sent[0].score < sent[1].score && sent[1].score < sent[2].score); for (const body of sent) assert.equal((await post(url, body)).status, 201); const enteredAt = async id => (await store.list()).find(entry => entry.id === id).createdAt; const shown = await board(url); - assert.equal(shown.entries[0].name, first, "the tied drink entered first leads"); - assert.equal(shown.leaderEnteredAt, await enteredAt(sent[0].id), "and the board says when it was entered"); + assert.equal(shown.entries[0].name, names[2], "the highest score leads"); + assert.equal(shown.leaderEnteredAt, await enteredAt(sent[2].id), "and the board says when it was entered"); assert.deepEqual(shown.entries.map(row => Object.keys(row).sort()), shown.entries.map(() => ["handle", "name", "rank", "score"]), "no row carries a time of its own"); // Take the leader down: the next drink leads, and its own time is shown. - assert.equal((await del(url, sent[0].id)).status, 204); + assert.equal((await del(url, sent[2].id)).status, 204); const after = await board(url); - assert.equal(after.entries[0].name, second); + assert.equal(after.entries[0].name, names[1]); assert.equal(after.leaderEnteredAt, await enteredAt(sent[1].id)); }); @@ -439,7 +469,17 @@ const POSIX = process.platform; // A reservation as the service stores it: a 64-hex HMAC-SHA256 fingerprint. const fp = label => createHash("sha256").update(label).digest("hex"); // Each row its own handle, as the service guarantees: the phrase plus a suffix from the ID. -const entry = (id, score = 1000) => ({ createdAt: "2026-01-01T00:00:00Z", handle: `${HANDLE}-${fp(id).slice(0, 8)}`, id, name: id, score }); +// Two different drinks on one board never share a score, so each fixture ID +// gets its own, stable across calls so a resend still matches. +const fixtureScores = new Map(); +const scoreOf = id => { + if (!fixtureScores.has(id)) fixtureScores.set(id, 4000 - fixtureScores.size); + return fixtureScores.get(id); +}; +const entry = (id, score = scoreOf(id)) => ({ createdAt: "2026-01-01T00:00:00Z", handle: `${HANDLE}-${fp(id).slice(0, 8)}`, id, name: id, score }); +// A board written before admission refused a taken score could still hold a +// tie. Levelling every score shows how such a board ranks: by entry time. +const levelled = stored => stored.map(item => ({ ...item, score: 1000 })); test("the file store survives a restart and never overwrites on create", async t => { const directory = await tempDirectory(t); @@ -496,6 +536,19 @@ async function engineWith(t, client) { return { engine: new BoothEngine({ catalog, leaderboardClient: client, rules, store }), store }; } +// A drink already on the menu whose publication is still pending: served +// before this booth had a leaderboard, or by a booth build that served first +// and published after. The retry, rebuild, and takedown paths still handle +// these, so they are seeded directly rather than through board-first serving. +async function servedPending(store, runId, name, before = () => {}) { + const local = new BoothEngine({ catalog, rules, store }); + await local.open({ runId }); + await store.transaction(data => before(data.runs[runId])); + const served = await local.dispatch(runId, "submit_name", { name }); + await store.transaction(data => { data.runs[runId].sync = { attempts: 0, state: "pending", updatedAt: new Date().toISOString() }; }); + return served; +} + test("a takedown during an in-flight publish still leaves the board clean", async t => { let release; let onWire; @@ -510,9 +563,8 @@ test("a takedown during an in-flight publish still leaves the board clean", asyn }, async retract(id) { retracted.push(id); return "retracted"; }, }; - const { engine } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const { engine, store } = await engineWith(t, client); + const served = await servedPending(store, "booth-1", "Mona Moonrise Mocha"); const publishing = engine.publish("booth-1"); // Wait until the submission is genuinely on the wire. Removing any earlier // would make publish() find no entry and return, and the test would prove @@ -599,9 +651,8 @@ test("a booth rebuilds its part of a lost board, including drinks served before test("a booth without a staff key says the takedown did not reach the board", async t => { const client = createLeaderboardClient({ boothKey: BOOTH_KEY, url: "https://leaderboard.example.org" }); assert.equal(client.retract, undefined); - const { engine } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const { engine, store } = await engineWith(t, client); + const served = await servedPending(store, "booth-1", "Mona Moonrise Mocha"); const record = await engine.removeDrink({ id: served.submission.id, reason: "test", removedBy: "lead" }); assert.equal(record.published, "not-configured"); }); @@ -1035,7 +1086,7 @@ test("the token is saved before the first send and reused on every retry", async let engine; const client = { async publish(submission) { - const saved = (await engine.store.read()).runs["booth-1"].sync.token; + const saved = (await engine.store.read()).runs["booth-1"].admission.token; sent.push({ saved, token: submission.token }); attempt += 1; if (attempt === 1) throw new Error("network dropped the response"); @@ -1044,12 +1095,16 @@ test("the token is saved before the first send and reused on every retry", async }; ({ engine } = await engineWith(t, client)); await engine.open({ runId: "booth-1" }); - await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); - await engine.publish("booth-1"); - await engine.publish("booth-1"); + await assert.rejects(engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }), + { code: "leaderboard_unreachable" }); + const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); assert.equal(sent[0].saved, sent[0].token, "persisted before it left the booth"); assert.equal(sent[1].token, sent[0].token, "the retry is the same publication"); - assert.equal((await engine.store.read()).runs["booth-1"].sync.state, "confirmed"); + assert.equal(served.phase, "served"); + const run = (await engine.store.read()).runs["booth-1"]; + assert.equal(run.sync.state, "confirmed"); + assert.equal(run.sync.token, sent[0].token, "the served drink keeps the token the board knows"); + assert.equal(run.admission, undefined); }); test("the stored board keeps only a hash of the publication token", async t => { @@ -1113,12 +1168,17 @@ test("a refusal retrying cannot change is recorded as final and never resent", a const client = serviceUrl => createLeaderboardClient({ boothKey: BOOTH_KEY, staffKey: STAFF_KEY, url: serviceUrl }); const boothA = new BoothEngine({ catalog, rules, store: new RunStore(directoryA), leaderboardClient: client(url) }); const boothB = new BoothEngine({ catalog, rules, store: new RunStore(directoryB), leaderboardClient: client(url) }); + assert.equal(failedSync({ attempts: 0 }, Object.assign(new Error("x"), { code: "score_taken" })).state, "rejected"); const a = await boothA.open({ runId: "a-1" }); await boothA.dispatch("a-1", "submit_name", { name: "Mona Moonrise Mocha" }); - await boothA.publish("a-1"); - await boothB.open({ runId: "b-1" }); - await boothB.store.transaction(data => { data.runs["b-1"].handle = a.handle; }); // the shared phrase - await boothB.dispatch("b-1", "submit_name", { name: "Mona Moonrise Mocha" }); + // Board-first: the same name typed at booth B is refused before it is served. + await boothB.open({ runId: "b-0" }); + await boothB.store.transaction(data => { data.runs["b-0"].handle = a.handle; }); + await assert.rejects(boothB.dispatch("b-0", "submit_name", { name: "Mona Moonrise Mocha" }), { code: "duplicate_drink" }); + assert.equal((await boothB.get("b-0")).phase, "naming"); + await boothB.closeStation({ runId: "b-0", closedBy: "lead" }); + // A drink booth B served before it was configured meets the same refusal on publish. + await servedPending(boothB.store, "b-1", "Mona Moonrise Mocha", run => { run.handle = a.handle; }); // the shared phrase await boothB.publish("b-1"); const bSync = (await boothB.store.read()).runs["b-1"].sync; assert.deepEqual([bSync.state, bSync.code], ["rejected", "duplicate_drink"]); @@ -1171,8 +1231,7 @@ test("retrying on a booth without a staff key does not claim nothing is waiting" const boothOnly = createLeaderboardClient({ boothKey: BOOTH_KEY, url: "https://leaderboard.example.org" }); const { engine } = await engineWith(t, boothOnly); assert.deepEqual(await retryReport(engine), { exitCode: 0, text: "No takedowns are waiting to reach the public leaderboard.\n" }); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const served = await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); await engine.removeDrink({ id: served.submission.id, reason: "test", removedBy: "lead" }); const report = await retryReport(engine); assert.equal(report.exitCode, 1, "an outstanding public takedown is not a success"); @@ -1229,7 +1288,7 @@ test("a drink that waited for another instance's write is entered after it, not let tick = Date.parse("2026-10-28T09:00:00.000Z"); const now = () => new Date(tick += 1000); // The interleaving from the review: B holds the lock, admitting one drink, - // while a tied drink that arrived earlier waits at A for that lock. Each + // while a drink that arrived earlier waits at A for that lock. Each // carries the time it arrived, which is what used to be stored. const arrived = seconds => new Date(Date.parse("2026-10-28T08:00:00.000Z") + seconds * 1000).toISOString(); let release; @@ -1248,8 +1307,8 @@ test("a drink that waited for another instance's write is entered after it, not const stored = await (await openStore({ directory })).list(); const at = id => Date.parse(stored.find(item => item.id === id).createdAt); assert.ok(at("mona-waited") > at("mona-admitted-first"), "entered when it was admitted, after the drink ahead of it"); - assert.deepEqual(leaderboard(stored).map(row => row.name), ["mona-admitted-first", "mona-waited"], - "the tie goes to the drink the board admitted first"); + assert.deepEqual(leaderboard(levelled(stored)).map(row => row.name), ["mona-admitted-first", "mona-waited"], + "a tie on a board from before score_taken goes to the drink the board admitted first"); }); test("entry times follow admission order across instances, whatever either clock says", async t => { @@ -1267,8 +1326,8 @@ test("entry times follow admission order across instances, whatever either clock const times = order.map(([id]) => Date.parse(stored.find(item => item.id === id).createdAt)); times.slice(1).forEach((time, index) => assert.ok(time > times[index], `${order[index + 1][0]} is entered after ${order[index][0]}`)); assert.equal(times[0], start + 60 * 60 * 1000, "a drink on an empty board takes its instance's clock"); - assert.deepEqual(leaderboard(stored).map(row => row.name), order.map(([id]) => id), - "every drink scores 1,000, so the board follows admission order"); + assert.deepEqual(leaderboard(levelled(stored)).map(row => row.name), order.map(([id]) => id), + "tied, as a board from before score_taken could be, the board follows admission order"); }); test("a resent drink keeps the place and entry time it was first given", async t => { @@ -1286,7 +1345,8 @@ test("a resent drink keeps the place and entry time it was first given", async t assert.equal(resent.entry.createdAt, "2026-10-28T09:00:00.000Z", "and is answered with its original entry time"); const stored = await (await openStore({ directory })).list(); assert.equal(stored.find(item => item.id === "mona-leader").createdAt, "2026-10-28T09:00:00.000Z", "the stored time is untouched"); - assert.deepEqual(leaderboard(stored).map(row => row.name), ["mona-leader", "mona-runner-up"], "so it keeps the lead"); + assert.deepEqual(leaderboard(levelled(stored)).map(row => row.name), ["mona-leader", "mona-runner-up"], + "so even tied, as a board from before score_taken could be, it keeps the lead"); }); test("a lock left by a dead instance is taken over; a live one is waited for", async t => { @@ -1439,14 +1499,20 @@ test("a removed name never appears in a request path the web server logs", async test("a refused publication gets no QR code that would open someone else's row", async t => { const refusing = { - async publish() { throw Object.assign(new Error("clash"), { code: "duplicate_drink" }); }, + async publish() { throw Object.assign(new Error("clash"), { code: "duplicate_drink", status: 409 }); }, }; const directory = await tempDirectory(t); - const engine = new BoothEngine({ catalog, rules, store: new RunStore(directory), + const store = new RunStore(directory); + const engine = new BoothEngine({ catalog, rules, store, leaderboardClient: refusing, leaderboardUrl: "https://sip.example.com/board" }); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); - assert.ok(served.attendeeUrl, "before the refusal a link is offered"); + // Board-first: a refused drink is never served, so there is no link at all. + await engine.open({ runId: "booth-0" }); + await assert.rejects(engine.dispatch("booth-0", "submit_name", { name: "Mona Moonrise Mocha" }), { code: "duplicate_drink" }); + assert.equal((await engine.get("booth-0")).attendeeUrl, null); + // A drink served before this booth was configured loses its link on refusal. + await servedPending(store, "booth-1", "Ducky Dawn Drizzle"); + await store.transaction(data => { data.runs["booth-1"].sync.token = "0123456789abcdef0123456789abcdef"; }); + assert.ok((await engine.get("booth-1")).attendeeUrl, "before the refusal a link is offered"); await engine.publish("booth-1"); const view = await engine.get("booth-1"); assert.equal(view.sync.state, "rejected"); @@ -1946,8 +2012,7 @@ test("a late failure from an earlier publish cannot undo a confirmation", async }, }; const { engine, store } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + await servedPending(store, "booth-1", "Mona Moonrise Mocha"); const slow = engine.publish("booth-1"); while (calls < 1) await new Promise(resolve => setImmediate(resolve)); await engine.publish("booth-1", { again: true }); @@ -1957,23 +2022,29 @@ test("a late failure from an earlier publish cannot undo a confirmation", async assert.equal((await store.read()).runs["booth-1"].sync.state, "confirmed", "the late failure does not downgrade it"); }); -test("the booth screen's refresh retries a publication that failed", async t => { +test("the booth screen serves only once the board admits, and trying again lands it once", async t => { const { BoothPanel } = await import("../.github/extensions/commit-and-sip/booth-panel.mjs"); let online = false; - const client = { async publish(sent) { if (!online) throw new Error("offline"); return { ...sent, entries: 1, rank: 1 }; } }; + const sent = []; + const client = { async publish(item) { sent.push(item); if (!online) throw new Error("offline"); return { ...item, entries: 1, rank: 1 }; } }; const { engine } = await engineWith(t, client); const panel = new BoothPanel(engine, { renderQr: async () => null }); panel.engine.lastPublicationSweep = Date.now(); // keep the background sweep out of this test await panel.dispatch("begin", {}); - await panel.dispatch("submit_name", { name: "Mona Moonrise Mocha" }); - await panel.backgroundPublish; - assert.equal((await panel.get()).sync.state, "failed"); + await assert.rejects(panel.dispatch("submit_name", { name: "Mona Moonrise Mocha" }), + { code: "leaderboard_unreachable", message: /Try again in a moment/ }); + const waiting = await panel.get(); + assert.equal(waiting.phase, "naming", "nothing is served while the board is unreachable"); + assert.equal(waiting.houseMenu.some(item => item.name === "Mona Moonrise Mocha"), false); online = true; - const refreshed = await panel.dispatch("refresh", {}); - assert.equal(refreshed.sync.state, "confirmed", "refresh on the real panel reaches the retry"); + const served = await panel.dispatch("submit_name", { name: "Mona Moonrise Mocha" }); + assert.equal(served.phase, "served"); + assert.equal(served.sync.state, "confirmed"); + assert.equal(new Set(sent.map(item => item.token)).size, 1, "the retry was the same publication"); + assert.equal(served.houseMenu.filter(item => item.name === "Mona Moonrise Mocha").length, 1); }); -test("a failed publication is still retried after the attendee has handed over", async t => { +test("an admission left in doubt is still resolved after the station is closed", async t => { const { BoothPanel } = await import("../.github/extensions/commit-and-sip/booth-panel.mjs"); const { AdminPanel } = await import("../.github/extensions/commit-and-sip/admin-panel.mjs"); let online = false; @@ -1982,31 +2053,34 @@ test("a failed publication is still retried after the attendee has handed over", const panel = new BoothPanel(engine, { renderQr: async () => null }); panel.engine.lastPublicationSweep = Date.now(); await panel.dispatch("begin", {}); - await panel.dispatch("submit_name", { name: "Mona Moonrise Mocha" }); - await panel.backgroundPublish; + // The reply was lost: the booth cannot tell whether the board took the drink. + await assert.rejects(panel.dispatch("submit_name", { name: "Mona Moonrise Mocha" }), { code: "leaderboard_unreachable" }); const runId = panel.runId; - await panel.dispatch("complete", {}); - assert.equal((await store.read()).runs[runId].sync.state, "failed"); + await engine.closeStation({ runId, closedBy: "lead" }); + assert.ok((await store.read()).runs[runId].admission, "the attempt is kept until the board answers"); online = true; - // The idle screen's own poll starts the retry once the sweep interval passes. + // The idle screen's own poll resends it once the sweep interval passes. panel.engine.lastPublicationSweep = 0; await panel.get(); // Wait only for a sweep the poll itself started. Starting one here would // make the test pass whether or not the poll did anything. assert.ok(engine.publicationSweep, "the idle poll started a retry"); await engine.publicationSweep; - assert.equal((await store.read()).runs[runId].sync.state, "confirmed", "no screen was showing it, and it still landed"); + const settled = (await store.read()).runs[runId]; + assert.equal(settled.sync.state, "confirmed", "no screen was showing it, and it still settled"); + assert.equal(settled.admission, undefined); + assert.equal(settled.phase, "complete", "closing the station is not undone"); // And staff refresh does the same for anything still owed. const { engine: other, store: otherStore } = await engineWith(t, client); online = false; await other.open({ runId: "r" }); - await other.dispatch("r", "submit_name", { name: "Ducky Dawn Drizzle" }); - await other.publish("r"); + await assert.rejects(other.dispatch("r", "submit_name", { name: "Ducky Dawn Drizzle" }), { code: "leaderboard_unreachable" }); online = true; await new AdminPanel(other).dispatch("refresh", {}); assert.equal((await otherStore.read()).runs.r.sync.state, "confirmed"); + assert.equal((await otherStore.read()).runs.r.phase, "served"); }); test("the idle poll starts at most one retry sweep per interval", async t => { @@ -2106,53 +2180,48 @@ test("the dashboard flags an interrupted removal, including one this booth never // --- Review round 15 --------------------------------------------------------- -test("serving a drink never waits on the leaderboard service", async t => { +test("a poll or a second tap never sends another admission while one is on the wire", async t => { const { BoothPanel } = await import("../.github/extensions/commit-and-sip/booth-panel.mjs"); const stalled = gate(); let sends = 0; const onWire = gate(); const client = { async publish(sent) { sends += 1; onWire.open(); await stalled.opened; return { ...sent, entries: 1, rank: 1 }; } }; const { engine } = await engineWith(t, client); - // Count publish requests and the attempts actually started, and know when - // the sweep has made its request. - let requests = 0; - let attempts = 0; - const secondRequest = gate(); - const publish = engine.publish.bind(engine); - engine.publish = (...args) => { requests += 1; if (requests === 2) secondRequest.open(); return publish(...args); }; - const publishOnce = engine.publishOnce.bind(engine); - engine.publishOnce = (...args) => { attempts += 1; return publishOnce(...args); }; const panel = new BoothPanel(engine, { renderQr: async () => null }); panel.engine.lastPublicationSweep = 0; // let the poll's sweep run too await panel.dispatch("begin", {}); - const served = await panel.dispatch("submit_name", { name: "Mona Moonrise Mocha" }); - assert.equal(served.phase, "served", "the attendee's answer came back while the service is still silent"); - assert.equal(served.sync.state, "pending"); + const submitting = panel.dispatch("submit_name", { name: "Mona Moonrise Mocha" }); + await onWire.opened; const polled = await panel.get(); // the screen's poll, which also starts a sweep - assert.equal(polled.sync.state, "pending"); - const sweep = engine.publicationSweep; - assert.ok(sweep, "the poll started a sweep"); - await secondRequest.opened; // the sweep has asked to publish this run - await onWire.opened; // and the first attempt is on the wire - assert.equal(attempts, 1, "the sweep joined the publish already in flight rather than starting another"); - assert.equal(sends, 1, "so the drink was sent once"); + assert.equal(polled.phase, "naming", "not served until the board answers"); + await engine.publicationSweep; + await assert.rejects(panel.dispatch("submit_name", { name: "Mona Moonrise Mocha" }), { code: "admission_in_progress" }); + // Another engine on the same ledger (the republish command) sees the claim. + const twin = new BoothEngine({ catalog, rules, store: engine.store, leaderboardClient: client }); + await assert.rejects(twin.dispatch(panel.runId, "submit_name", { name: "Mona Moonrise Mocha" }), { code: "admission_in_progress" }); + await twin.retryPublications(); + assert.equal(sends, 1, "the drink was sent once"); stalled.open(); - await Promise.all([sweep, panel.backgroundPublish]); - assert.equal((await panel.get()).sync.state, "confirmed"); + const served = await submitting; + assert.equal(served.phase, "served"); + assert.equal(served.sync.state, "confirmed"); }); test("the attendee's link is personal from the first screen, before any send", async t => { - const stalled = gate(); - const client = { async publish(sent) { await stalled.opened; return { ...sent, entries: 1, rank: 1 }; } }; + const tokens = []; const directory = await tempDirectory(t); const store = new RunStore(directory); + const client = { async publish(sent) { + tokens.push((await store.read()).runs["booth-1"].admission.token); + return { ...sent, entries: 1, rank: 1 }; + } }; const engine = new BoothEngine({ catalog, rules, store, leaderboardClient: client, leaderboardUrl: "https://sip.example.com/board" }); await engine.open({ runId: "booth-1" }); const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); const token = (await store.read()).runs["booth-1"].sync.token; - assert.match(token, /^[0-9a-f]{32}$/, "minted and saved with the served drink"); + assert.match(token, /^[0-9a-f]{32}$/, "minted and saved before the send"); + assert.deepEqual(tokens, [token]); assert.equal(new URL(served.attendeeUrl).searchParams.get("ref"), publicRef(tokenHashOf(token))); - stalled.open(); }); test("the served screen keeps polling while its drink is being confirmed", async () => { @@ -2193,8 +2262,7 @@ test("the event cannot be wiped while a publish is on the wire", async t => { async retract(id) { retracted.push(id); return retracted.length === 1 ? "absent" : "retracted"; }, }; const { engine, store } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const served = await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); const publishing = engine.publish("booth-1"); await onWire.opened; // the send has left, past its claim await engine.removeDrink({ id: served.submission.id, reason: "test", removedBy: "lead" }); @@ -2231,8 +2299,7 @@ test("a publish whose response was lost is still followed by a second retraction async retract(id) { retracted.push(id); return retracted.length === 1 ? "absent" : "retracted"; }, }; const { engine, store } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const served = await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); const publishing = engine.publish("booth-1"); await onWire.opened; // the send has left, past its claim await engine.removeDrink({ id: served.submission.id, reason: "test", removedBy: "lead" }); @@ -2291,8 +2358,7 @@ test("only the service's own answer settles a takedown as absent", async t => { "an answer that is not the service's does not prove the entry is gone"); } const { engine } = await engineWith(t, clientFor(answer(404, { error: "not_found" }))); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const served = await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); await engine.drainPublications(); await engine.store.transaction(data => { data.runs["booth-1"].sync = { state: "confirmed" }; }); const removal = await engine.removeDrink({ id: served.submission.id, reason: "test", removedBy: "lead" }); @@ -2345,8 +2411,7 @@ test("a rebuild sends no drink until every takedown is reserved again", async t // --- Review round 18 --------------------------------------------------------- async function servedAndHandedOver(engine, runId = "booth-1") { - await engine.open({ runId }); - const served = await engine.dispatch(runId, "submit_name", { name: "Mona Moonrise Mocha" }); + const served = await servedPending(engine.store, runId, "Mona Moonrise Mocha"); await engine.dispatch(runId, "complete", {}); return served; } @@ -2437,7 +2502,7 @@ test("a stored row missing any served field is refused, and the health check say const store = await openStore({ directory }); const { url } = await service(t, { store }); assert.equal((await fetch(`${url}/healthz`)).status, 200); - assert.equal((await board(url)).entries[0].score, 1000); + assert.equal((await board(url)).entries[0].score, entry("mona-a").score); // Corrupted underneath a running instance: the health check fails closed. await writeFile(join(directory, "default.json"), JSON.stringify({ entries: [{ id: "mona-a" }], reserved: [], version: 2 })); @@ -2534,8 +2599,7 @@ test("a takedown answered while its drink was still being sent stays owed across async retract() { answers.push("absent"); return "absent"; }, }; const { engine, store } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const served = await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); void engine.publish("booth-1"); await onWire.opened; const removal = await engine.removeDrink({ id: served.submission.id, reason: "test", removedBy: "lead" }); @@ -2573,8 +2637,7 @@ test("a retraction that left before a send ended does not settle the takedown", }, }; const { engine, store } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const served = await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); const publishing = engine.publish("booth-1"); await onWire.opened; const removing = engine.removeDrink({ id: served.submission.id, reason: "test", removedBy: "lead" }); @@ -2616,8 +2679,7 @@ test("a send that outlives its claim still reopens a takedown that settled meanw async retract() { calls += 1; if (calls === 1) return "absent"; throw new Error("process stopped"); }, }; const { engine, store } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - const served = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const served = await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); const publishing = engine.publish("booth-1"); await onWire.opened; // A send slow enough that its claim has expired. @@ -3096,8 +3158,7 @@ test("a late success never overwrites a refusal, and the resend reports the refu }, }; const { engine, store } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); const stale = engine.publish("booth-1", { again: true }); await onWire.opened; const refused = await engine.publish("booth-1", { again: true }); @@ -3238,8 +3299,7 @@ test("a handle the service cannot place is a final refusal, not an endless retry let sends = 0; const client = { async publish() { sends += 1; throw Object.assign(new Error("409 handle_taken"), { code: "handle_taken", status: 409 }); } }; const { engine, store } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); await engine.publish("booth-1"); assert.equal((await store.read()).runs["booth-1"].sync.state, "rejected"); await engine.retryPublications(); @@ -3417,8 +3477,7 @@ test("a rebuild holds every booth's ordinary publishing until staff open the boa const to = url => createLeaderboardClient({ boothKey: BOOTH_KEY, staffKey: STAFF_KEY, url }); const boothA = (await engineWith(t, to(lost.url))).engine; const boothB = (await engineWith(t, to(lost.url))).engine; - await boothB.open({ runId: "b-1" }); - const takenDown = await boothB.dispatch("b-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const takenDown = await servedPending(boothB.store, "b-1", "Mona Moonrise Mocha"); await boothB.publish("b-1"); await boothB.removeDrink({ id: takenDown.submission.id, reason: "test", removedBy: "lead" }); boothA.leaderboardClient = to(replacement.url); @@ -3426,8 +3485,7 @@ test("a rebuild holds every booth's ordinary publishing until staff open the boa // Mid-rebuild, an attendee at A invents the name B took down. A's ordinary // publish is held, not admitted, though B has not replayed yet. - await boothA.open({ runId: "a-1" }); - await boothA.dispatch("a-1", "submit_name", { name: "Mona Moonrise Mocha" }); + await servedPending(boothA.store, "a-1", "Mona Moonrise Mocha"); await boothA.publish("a-1"); const held = (await boothA.store.read()).runs["a-1"].sync; assert.deepEqual([held.state, held.code], ["failed", "board_rebuilding"], "kept, and retried later"); @@ -3521,11 +3579,9 @@ test("the drinks phase checks the takedowns were replayed onto the board served const first = await service(t, { store: await FileStore.open({ directory: await tempDirectory(t) }) }); const to = url => createLeaderboardClient({ boothKey: BOOTH_KEY, staffKey: STAFF_KEY, url }); const { engine } = await engineWith(t, to(first.url)); - await engine.open({ runId: "booth-1" }); - const removed = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const removed = await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); await engine.removeDrink({ id: removed.submission.id, reason: "test", removedBy: "lead" }); - await engine.open({ runId: "booth-2" }); - await engine.dispatch("booth-2", "submit_name", { name: "Ducky Dawn Drizzle" }); + await servedPending(engine.store, "booth-2", "Ducky Dawn Drizzle"); await engine.republishAll({ drinks: false }); // replayed onto the first replacement assert.equal((await engine.store.read()).rebuild.boardId, (await board(first.url)).boardId); @@ -3587,11 +3643,9 @@ test("a rebuild that cannot name its board replays, opens and sends nothing", as retract: async id => { calls.retract += 1; return real.retract(id); }, }; const { engine } = await engineWith(t, client); - await engine.open({ runId: "booth-1" }); - const removed = await engine.dispatch("booth-1", "submit_name", { name: "Mona Moonrise Mocha" }); + const removed = await servedPending(engine.store, "booth-1", "Mona Moonrise Mocha"); await engine.removeDrink({ id: removed.submission.id, reason: "test", removedBy: "lead" }); - await engine.open({ runId: "booth-2" }); - await engine.dispatch("booth-2", "submit_name", { name: "Ducky Dawn Drizzle" }); + await servedPending(engine.store, "booth-2", "Ducky Dawn Drizzle"); calls.retract = 0; for (const lookup of [new Error("network"), null]) { diff --git a/tests/leaderboard-sync.test.mjs b/tests/leaderboard-sync.test.mjs index 45cf448..a5af614 100644 --- a/tests/leaderboard-sync.test.mjs +++ b/tests/leaderboard-sync.test.mjs @@ -110,56 +110,62 @@ test("a confirmed receipt is recorded and shown as the event rank", async () => } }); -test("a failing service never costs the attendee their drink or score", async () => { +test("an unreachable board serves nothing and keeps the attendee naming", async () => { const { directory, engine } = await booth({ publish: async () => { throw new Error("leaderboard unreachable"); } }); try { - const served = await serve(engine); - // Serving itself must succeed: the entry is durable before any network call. - assert.equal(served.phase, "served"); - assert.equal(served.submission.score > 0, true); - await engine.publish("board-run"); + await engine.open({ runId: "board-run" }); + await assert.rejects(engine.dispatch("board-run", "submit_name", { name: "Mona Moonrise" }), + { code: "leaderboard_unreachable", status: 503, message: /Try again in a moment/ }); const state = await engine.get("board-run"); - assert.equal(state.sync.state, "failed"); - assert.equal(state.sync.eventRank, null); - assert.equal(state.houseMenu.some(item => item.name === "Mona Moonrise"), true); - assert.equal(state.leaderboard[0].name, "Mona Moonrise"); + // Nothing is served until the board admits the drink. + assert.equal(state.phase, "naming"); + assert.equal(state.submission, null); + assert.equal(state.houseMenu.some(item => item.name === "Mona Moonrise"), false); } finally { await rm(directory, { recursive: true, force: true }); } }); -test("a mismatched receipt is treated as a failure, not as a rank", async () => { +test("a mismatched receipt is not an admission, so nothing is served", async () => { const { directory, engine } = await booth({ publish: async submission => ({ ...submission, handle: "someone-else", rank: 1 }) }); try { - await serve(engine); - await engine.publish("board-run"); + await engine.open({ runId: "board-run" }); + await assert.rejects(engine.dispatch("board-run", "submit_name", { name: "Mona Moonrise" }), + { code: "leaderboard_unreachable" }); const state = await engine.get("board-run"); - assert.equal(state.sync.state, "failed"); + assert.equal(state.phase, "naming"); assert.equal(state.sync.eventRank, null); } finally { await rm(directory, { recursive: true, force: true }); } }); -test("a refresh retries a failed submission and can still confirm it", async () => { +test("trying again after an outage resends the same admission and serves once", async () => { + const sent = []; let fail = true; const { directory, engine } = await booth({ publish: async submission => { + sent.push(submission); if (fail) { fail = false; throw new Error("temporary outage"); } return { ...submission, rank: 1, entries: 1 }; } }); try { - await serve(engine); - await engine.publish("board-run"); - assert.equal((await engine.get("board-run")).sync.state, "failed"); - const refreshed = await engine.dispatch("board-run", "refresh", {}); - assert.equal(refreshed.sync.state, "confirmed"); - assert.equal(refreshed.sync.eventRank, 1); + await engine.open({ runId: "board-run" }); + await assert.rejects(engine.dispatch("board-run", "submit_name", { name: "Mona Moonrise" }), + { code: "leaderboard_unreachable" }); + const served = await engine.dispatch("board-run", "submit_name", { name: "Mona Moonrise" }); + assert.equal(served.phase, "served"); + assert.equal(served.sync.state, "confirmed"); + assert.equal(served.sync.eventRank, 1); + assert.equal(sent.length, 2); + // The retry is the same publication, so the board treats it as a resend. + assert.equal(sent[1].token, sent[0].token); + assert.equal(served.houseMenu.filter(item => item.name === "Mona Moonrise").length, 1); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/tests/name-score.test.mjs b/tests/name-score.test.mjs index 3c9487b..c7137ba 100644 --- a/tests/name-score.test.mjs +++ b/tests/name-score.test.mjs @@ -12,6 +12,28 @@ test("the rubric is bounded and its parts sum to the advertised maximum", () => "an attendee can only be told the score is out of 5000 if the parts really reach it"); }); +test("one valid name earns every category maximum, so 5000 is really reachable", () => { + // Summing the caps is not enough: the old rubric's caps also summed to 5000, + // but its word-count band made the last 400 points impossible to earn. + const best = "Monaduckycopilot Moka Mocha Mint Squint"; + assert.ok(best.length <= 40, "the name fits the length limit"); + const { breakdown, score } = scoreCoffeeName(best, rules); + for (const part of breakdown) assert.equal(part.points, part.max, `${part.label} is at its maximum`); + assert.equal(score, MAX_SCORE); +}); + +test("scores move in single points, not steps of 50", () => { + const score = name => scoreCoffeeName(name, rules).score; + const letters = name => scoreCoffeeName(name, rules).breakdown.find(part => part.label === "Letter value"); + // A one-letter respelling changes the score without changing any coarse category. + assert.equal(score("Mona Moonrisf Mocha") - score("Mona Moonrise Mocha"), 1); + assert.equal(letters("Mona Mocha").points, 83, "A=1 ... Z=26, summed over the letters"); + assert.equal(letters(`Mona ${"z".repeat(35)}`).points, 400, "Letter value is capped"); + const seen = new Set(["Mona Mocha", "Mona Moonrise Mocha", "Ducky Dawn Drizzle", "Copilot Cinder Crema", + "Mona Moonlight Mocha", "Mona Nebula", "Ducky Doppio", "Copilot Cosmic Crema"].map(score)); + assert.equal(seen.size, 8, "everyday names do not pile up on the same total"); +}); + test("a score is a pure, reproducible function of the name alone", () => { const first = scoreCoffeeName("Ducky Dawn Drip", rules); // Repeated, re-cased and re-spaced submissions must never disagree, so a