Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 226 additions & 13 deletions .github/extensions/commit-and-sip/booth-engine.mjs

Large diffs are not rendered by default.

11 changes: 3 additions & 8 deletions .github/extensions/commit-and-sip/booth-panel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
2 changes: 1 addition & 1 deletion .github/extensions/commit-and-sip/canvas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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."]
};
Expand Down
1 change: 1 addition & 0 deletions .github/extensions/commit-and-sip/renderer/booth.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ <h1>Invent your drink.</h1>
<button id="name-submit" class="primary" type="submit">Add it to the menu</button>
</fieldset>
</form>
<p id="name-checking" class="notice" role="status" hidden>Checking your drink and adding it to the menu...</p>
<p id="name-problem" class="notice error-notice" role="alert" hidden></p>
</section>

Expand Down
4 changes: 4 additions & 0 deletions .github/extensions/commit-and-sip/renderer/booth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -231,6 +234,7 @@
$("name-problem").textContent = error.message;
$("name-problem").hidden = false;
} finally {
$("name-checking").hidden = true;
busy = false;
controls();
}
Expand Down
27 changes: 25 additions & 2 deletions .github/extensions/commit-and-sip/services/booth-menu.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions .github/extensions/commit-and-sip/services/leaderboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 33 additions & 14 deletions .github/extensions/commit-and-sip/services/name-score.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions .github/skills/order-up/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions .github/steps/1-name-a-drink.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion booth/CHEAT-SHEET.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading