diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e1b12d --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# The quiz's expert deck, sign-in and leaderboard (/play). Without these the +# site still builds, and the open quiz still plays; the rest stays off. +# See content/quiz/README.md. + +# Supabase project settings: Project Settings > API. Public by design. +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Server only. Never prefix it with NEXT_PUBLIC_. +SUPABASE_SERVICE_ROLE_KEY= diff --git a/.gitignore b/.gitignore index e272c39..23e4cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ next-env.d.ts # misc .DS_Store npm-debug.log* + +# the expert deck: never in the public repo (see content/quiz/README.md) +/private/ diff --git a/README.md b/README.md index c1242a9..b6a4867 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Please read [CONTRIBUTING.md](CONTRIBUTING.md) first: it covers the status rules ## How it's built -A small [Next.js](https://nextjs.org) app with no backend, no database and no tracking. Every page is static, built from the files in [`content/`](content/). +A small [Next.js](https://nextjs.org) app with no tracking. Every page of the guide is static, built from the files in [`content/`](content/). The one exception is the [quiz](https://ax.guide/play): its open deck plays in the browser, while sign-in, the expert deck and the leaderboard run on [Supabase](https://supabase.com). Without Supabase keys the site still builds, and the open quiz still plays. See [`content/quiz/README.md`](content/quiz/README.md). ``` content/ @@ -74,9 +74,12 @@ content/ pairs.json the pairs people mix up (/compare) guide.json which one do I need (/which) home.json, site.json page text: titles, nav, footer, status labels + quiz/open.json the quiz's open deck (the expert deck is kept out of the repo) contribute.md the /contribute page lib/schema.ts the rules every standard's front matter must follow lib/standards.ts reads, checks and numbers the standards +lib/quiz*.ts the quiz: cards, scoring, dealing, expert runs +supabase/migrations/ the quiz's database: players, the expert deck, runs app/ pages and share images (Next.js App Router) components/ small presentational components tests/ checks every content file and this README's table @@ -101,6 +104,7 @@ npm run dev # http://localhost:3000 | `npm run lint` | ESLint | | `npm run typecheck` | TypeScript | | `npm run readme` | Regenerate the table of standards in this README | +| `npm run quiz:sql` | Check `private/quiz-expert.json` and write the SQL that loads it into Supabase | ### Deploy diff --git a/app/api/play/me/route.ts b/app/api/play/me/route.ts new file mode 100644 index 0000000..707d560 --- /dev/null +++ b/app/api/play/me/route.ts @@ -0,0 +1,10 @@ +import { json } from "@/lib/play-route"; +import { playerName } from "@/lib/quiz-expert"; +import { currentUserId, playBackendEnabled } from "@/lib/supabase-server"; + +/** Whether expert runs are on, and who is signed in. */ +export async function GET() { + if (!playBackendEnabled()) return json({ enabled: false, player: null }); + const userId = await currentUserId(); + return json({ enabled: true, player: userId ? { name: (await playerName(userId)) ?? "player" } : null }); +} diff --git a/app/api/play/runs/[id]/route.ts b/app/api/play/runs/[id]/route.ts new file mode 100644 index 0000000..04dade0 --- /dev/null +++ b/app/api/play/runs/[id]/route.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { withPlayer } from "@/lib/play-route"; +import { answerCard, nextCard, PlayError } from "@/lib/quiz-expert"; + +const step = z.discriminatedUnion("action", [ + z.strictObject({ action: z.literal("answer"), position: z.int().min(0), choice: z.int().min(0).max(3).nullable() }), + z.strictObject({ action: z.literal("next"), position: z.int().min(0) }), +]); + +/** One step of an expert run: answer the open card, or deal the next one. */ +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withPlayer(async (userId) => { + const parsed = step.safeParse(await request.json().catch(() => undefined)); + if (!parsed.success) throw new PlayError(400, "Malformed step."); + const body = parsed.data; + return body.action === "answer" + ? answerCard(userId, id, body.position, body.choice) + : nextCard(userId, id, body.position); + }); +} diff --git a/app/api/play/runs/route.ts b/app/api/play/runs/route.ts new file mode 100644 index 0000000..f7e1d43 --- /dev/null +++ b/app/api/play/runs/route.ts @@ -0,0 +1,7 @@ +import { withPlayer } from "@/lib/play-route"; +import { startRun } from "@/lib/quiz-expert"; + +/** Start an expert run: deals its first card. */ +export async function POST() { + return withPlayer((userId) => startRun(userId)); +} diff --git a/app/api/play/signout/route.ts b/app/api/play/signout/route.ts new file mode 100644 index 0000000..1e97421 --- /dev/null +++ b/app/api/play/signout/route.ts @@ -0,0 +1,7 @@ +import { json } from "@/lib/play-route"; +import { signOut } from "@/lib/supabase-server"; + +export async function POST() { + await signOut(); + return json({ ok: true }); +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..f575434 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { safeNext } from "@/lib/quiz"; +import { exchangeAuthCode } from "@/lib/supabase-server"; + +/** Where GitHub, Google and email sign-in links land: set the session, go back to the game. */ +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const next = safeNext(url.searchParams.get("next")); + const ok = code ? await exchangeAuthCode(code) : false; + const target = new URL(next, url.origin); + if (!ok) target.searchParams.set("signin", "failed"); + return NextResponse.redirect(target); +} diff --git a/app/home.css b/app/home.css index a624173..04fee6f 100644 --- a/app/home.css +++ b/app/home.css @@ -266,7 +266,12 @@ @media (min-width: 48rem) { .next__list { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (min-width: 72rem) { + .next__list { + grid-template-columns: repeat(4, minmax(0, 1fr)); } } diff --git a/app/play/leaderboard/page.tsx b/app/play/leaderboard/page.tsx new file mode 100644 index 0000000..4314c0c --- /dev/null +++ b/app/play/leaderboard/page.tsx @@ -0,0 +1,78 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { leaderboard } from "@/lib/quiz-expert"; +import "../play.css"; + +export const metadata: Metadata = { + title: "Quiz leaderboard", + description: "The best expert runs on the ax.guide agentic web quiz.", + alternates: { canonical: "/play/leaderboard" }, +}; + +// Read fresh at most once a minute. +export const revalidate = 60; + +type Props = { searchParams: Promise<{ period?: string }> }; + +/** Each player's best expert run: the expert deck is server-side, so these scores were earned. */ +export default async function LeaderboardPage({ searchParams }: Props) { + const period = (await searchParams).period === "week" ? "week" : "all"; + const rows = await leaderboard(period); + return ( +
+
+

The ax.guide quiz

+

Leaderboard

+

+ Each player's best expert run: 18 cards from a deck whose answers aren't public. Equal scores share a + place, and the earlier run is listed first. +

+
+ + {rows === undefined ? ( +

The leaderboard opens with the expert deck, soon.

+ ) : rows.length === 0 ? ( +

No expert runs yet. The top spot is open.

+ ) : ( + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
#Player + Right + + Points +
{row.rank} + {row.name} + + {row.correct}/{row.total} + {row.score.toLocaleString("en-US")}
+ )} + + Play + +
+ ); +} diff --git a/app/play/page.tsx b/app/play/page.tsx new file mode 100644 index 0000000..4242421 --- /dev/null +++ b/app/play/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { QuizGame } from "@/components/quiz/quiz-game"; +import { getOpenDeck } from "@/lib/quiz-open"; +import { getStandards } from "@/lib/standards"; +import "./play.css"; + +export const metadata: Metadata = { + title: "The agentic web quiz", + description: + "How well do you know the standards that let AI agents find, use, sign in to, shop on and pay websites? Twelve cards, twenty seconds each.", + alternates: { canonical: "/play" }, +}; + +/** + * The quiz. Static: the open deck ships with the page and an open run plays in + * the browser. Expert runs talk to /api/play. + */ +export default function PlayPage() { + const standardNames = Object.fromEntries(getStandards().map((s) => [s.id, s.name])); + return ( +
+ +
+ ); +} diff --git a/app/play/play.css b/app/play/play.css new file mode 100644 index 0000000..e4d54e2 --- /dev/null +++ b/app/play/play.css @@ -0,0 +1,485 @@ +/* + * The quiz. Same poster as the guide: thick rules, hard shadows, and the + * accent still means one thing, right. Wrong is hatched, like a draft. + */ + +.play { + padding-block: var(--space-12) var(--space-24); +} + +.play mark { + background: var(--accent); + color: var(--on-accent); + padding: 0 0.12em; +} + +.play-link { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 3px; + cursor: pointer; +} +.play-link:hover { + text-decoration-color: var(--accent); +} + +.play-notice { + max-width: var(--measure); + margin-block: var(--space-6) 0; + padding: var(--space-3) var(--space-4); + border: var(--border-thin); + background: var(--color-card); +} +.play-notice--error { + border-width: 3px; +} + +/* Intro */ + +.play-intro__title { + max-width: 14ch; +} + +.play-decks { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); + gap: var(--space-8); + margin-top: var(--space-10); +} + +.play-deck { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-6); + border: var(--border); + background: var(--color-card); + box-shadow: var(--shadow); +} +.play-deck--expert { + background-image: repeating-linear-gradient(-45deg, var(--color-hatch) 0 2px, transparent 2px 10px); +} +.play-deck p { + margin: 0; + max-width: 34rem; +} +.play-deck__count { + font-family: var(--font-mono); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.play-deck__name { + margin: 0; + font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem); + font-weight: 900; + font-stretch: 112%; + text-transform: uppercase; + line-height: 1; +} +.play-deck__go { + align-self: flex-start; + margin-top: auto; +} +.play-deck__soon, +.play-deck__who, +.play-deck__unlock { + font-size: var(--text-sm); +} +.play-deck__unlock { + font-weight: 800; +} + +.play-intro__foot { + margin-top: var(--space-8); + color: var(--color-ink-muted); + font-size: var(--text-sm); +} +.play-intro__foot a { + color: var(--color-ink); + font-weight: 700; +} + +/* Sign in */ + +.signin { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.signin__providers, +.signin__email { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} +.signin__email input { + flex: 1 1 12rem; + min-height: 3rem; + padding: var(--space-2) var(--space-3); + border: var(--border); + background: var(--color-paper); + color: var(--color-ink); + font: inherit; +} +.signin__email input:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 2px; +} +.signin__note, +.signin__error { + color: var(--color-ink-muted); + font-size: var(--text-xs); +} +.signin__error { + color: var(--color-ink); + font-weight: 700; +} + +/* The board */ + +.play-board { + max-width: 46rem; + margin-inline: auto; +} + +.play-hud { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-5); +} +.play-hud__score { + display: flex; + align-items: baseline; + gap: var(--space-3); + margin: 0; + font-family: var(--font-mono); + font-size: var(--text-sm); +} +.play-hud__points strong { + font-size: 1.5em; +} +.play-hud__streak { + padding: 0 var(--space-2); + background: var(--accent); + color: var(--on-accent); + font-weight: 700; +} + +/* One square a card, filled like the share grid. */ +.play-progress { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} +.play-progress li { + width: 14px; + height: 14px; + border: 2px solid var(--color-rule); +} +.play-progress li[data-state="right"] { + background: var(--accent); +} +.play-progress li[data-state="wrong"] { + background: var(--color-ink); +} +.play-progress li[data-state="now"] { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.play-progress--big { + gap: 6px; + margin-block: var(--space-6); +} +.play-progress--big li { + width: clamp(16px, 5.8vw, 28px); + height: clamp(16px, 5.8vw, 28px); + border-width: 3px; +} + +.play-card { + position: relative; + padding: var(--space-6); + border: var(--border); + background: var(--color-card); + box-shadow: var(--shadow); +} +@media (min-width: 40rem) { + .play-card { + padding: var(--space-8) var(--space-10); + } +} +.play-card__kind { + display: flex; + justify-content: space-between; + margin: 0 0 var(--space-3); + font-family: var(--font-mono); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.play-card__prompt { + margin: var(--space-4) 0 var(--space-6); + font-size: clamp(1.25rem, 1.05rem + 0.9vw, 1.75rem); + font-weight: 800; + line-height: 1.2; + text-wrap: pretty; +} +.play-card__prompt:focus { + outline: none; +} + +/* The clock: a bar that empties over the card's time. */ +.play-clock { + height: 8px; + background: var(--accent); + border: 2px solid var(--color-rule); + transform-origin: left; + animation: play-clock linear forwards; +} +@keyframes play-clock { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + +.play-options { + display: grid; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} +.play-option { + display: flex; + align-items: center; + gap: var(--space-4); + width: 100%; + min-height: 3.5rem; + padding: var(--space-3) var(--space-4); + border: var(--border); + background: var(--color-paper); + color: var(--color-ink); + font: inherit; + font-size: var(--text-base); + font-weight: 700; + text-align: left; + cursor: pointer; + transition: transform 120ms ease, box-shadow 120ms ease; +} +@media (hover: hover) { + .play-option:not(:disabled):hover { + transform: translate(-2px, -2px); + box-shadow: var(--shadow-sm); + } +} +.play-option:not(:disabled):active { + transform: translate(1px, 1px); + box-shadow: none; +} +.play-option:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 3px; +} +.play-option:disabled { + cursor: default; +} +.play-option__key { + flex: none; + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border: var(--border-thin); + font-family: var(--font-mono); + font-size: var(--text-sm); +} +.play-option[data-state="answer"] { + background: var(--accent); + color: var(--on-accent); + border-color: var(--on-accent); +} +.play-option[data-state="answer"] .play-option__key { + border-color: var(--on-accent); +} +.play-option[data-state="chosen"] { + background-image: repeating-linear-gradient(-45deg, var(--color-hatch) 0 2px, transparent 2px 8px); + text-decoration: line-through; + text-decoration-thickness: 2px; +} +.play-option[data-state="other"] { + opacity: 0.45; +} + +.play-reveal { + margin-top: var(--space-6); + padding-top: var(--space-5); + border-top: var(--border); + animation: play-in 200ms ease-out; +} +.play-reveal__verdict { + margin: 0; + font-size: var(--text-lede); + font-weight: 900; + text-transform: uppercase; + font-stretch: 112%; +} +.play-card[data-state="right"] .play-reveal__verdict { + display: inline-block; + padding: 0 var(--space-2); + background: var(--accent); + color: var(--on-accent); +} +.play-reveal__explain { + margin: var(--space-3) 0 0; + max-width: var(--measure); +} +.play-reveal__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + margin-top: var(--space-5); +} +.play-reveal__actions .play-link { + font-size: var(--text-sm); +} + +@keyframes play-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +/* Results */ + +.play-results { + max-width: 46rem; + margin-inline: auto; +} +.play-results__score { + margin: var(--space-2) 0 0; + font-size: var(--text-poster); + font-weight: 900; + font-stretch: 125%; + line-height: 0.9; + letter-spacing: -0.03em; +} +.play-results__pts { + font-size: 0.3em; + letter-spacing: 0; +} +.play-results__line { + margin: var(--space-4) 0 0; + font-size: var(--text-lede); +} +.play-results__rank { + margin: var(--space-2) 0 0; +} +.play-results__share { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} +.play-results__misses, +.play-results__next { + margin-top: var(--space-12); +} +.play-results__misses ul { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-5); + margin: var(--space-3) 0 0; + padding: 0; + list-style: none; +} +.play-results__misses a { + color: inherit; + font-weight: 700; +} +.play-results__next { + padding: var(--space-6); + border: var(--border); + background-image: repeating-linear-gradient(-45deg, var(--color-hatch) 0 2px, transparent 2px 10px); +} +.play-results__next p { + max-width: var(--measure); +} + +/* Leaderboard and share pages */ + +.play-board-table { + width: 100%; + max-width: 46rem; + margin-top: var(--space-6); + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} +.play-board-table th, +.play-board-table td { + padding: var(--space-3) var(--space-2); + border-bottom: var(--border-thin); + text-align: left; +} +.play-board-table th { + font-family: var(--font-mono); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.play-board-table td:first-child { + width: 3.5rem; + font-weight: 900; +} +.play-board-table tr:nth-child(-n + 3) td:first-child { + color: var(--accent); +} +.play-board-table .num { + text-align: right; +} +.play-board-table a { + color: inherit; +} + +.play-tabs { + display: flex; + gap: var(--space-3); + margin-top: var(--space-6); +} +.play-tabs a[aria-current="page"] { + background: var(--color-ink); + color: var(--color-paper); +} + +.play-share { + max-width: 46rem; +} +.play-share .button { + margin-top: var(--space-8); +} + +@media (prefers-reduced-motion: reduce) { + .play-clock { + animation-timing-function: steps(20, end); + } + .play-reveal { + animation: none; + } +} diff --git a/app/play/r/[id]/opengraph-image.tsx b/app/play/r/[id]/opengraph-image.tsx new file mode 100644 index 0000000..eb64086 --- /dev/null +++ b/app/play/r/[id]/opengraph-image.tsx @@ -0,0 +1,18 @@ +import { OG_SIZE } from "@/lib/og"; +import { quizShareImage } from "@/lib/og-quiz"; +import { finishedRun } from "@/lib/quiz-expert"; + +export const size = OG_SIZE; +export const contentType = "image/png"; +export const alt = "A verified expert run on the ax.guide agentic web quiz"; + +export default async function Image({ params }: { params: Promise<{ id: string }> }) { + const run = await finishedRun((await params).id); + return quizShareImage({ + kicker: run ? `${run.name} · expert run` : "ax.guide quiz · expert run", + score: run?.score ?? 0, + correct: run?.correct ?? 0, + total: run?.total ?? 0, + results: run?.results ?? [], + }); +} diff --git a/app/play/r/[id]/page.tsx b/app/play/r/[id]/page.tsx new file mode 100644 index 0000000..cf41ac7 --- /dev/null +++ b/app/play/r/[id]/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { finishedRun } from "@/lib/quiz-expert"; +import { ShareBody } from "@/components/quiz/share-body"; +import "../../play.css"; + +type Props = { params: Promise<{ id: string }> }; + +export async function generateMetadata({ params }: Props): Promise { + const run = await finishedRun((await params).id); + if (!run) return {}; + return { + title: `${run.name}: ${run.correct}/${run.total} on the expert run`, + description: `${run.score.toLocaleString("en-US")} points on the ax.guide expert deck, ranked. Can you beat it?`, + robots: { index: false }, + }; +} + +/** A finished expert run, read from the database: a score the server counted. */ +export default async function ExpertRun({ params }: Props) { + const run = await finishedRun((await params).id); + if (!run) notFound(); + return ; +} diff --git a/app/play/s/[code]/opengraph-image.tsx b/app/play/s/[code]/opengraph-image.tsx new file mode 100644 index 0000000..01e5f1f --- /dev/null +++ b/app/play/s/[code]/opengraph-image.tsx @@ -0,0 +1,18 @@ +import { OG_SIZE } from "@/lib/og"; +import { quizShareImage } from "@/lib/og-quiz"; +import { decodeShare } from "@/lib/quiz"; + +export const size = OG_SIZE; +export const contentType = "image/png"; +export const alt = "A score on the ax.guide agentic web quiz"; + +export default async function Image({ params }: { params: Promise<{ code: string }> }) { + const shared = decodeShare((await params).code) ?? { score: 0, results: [] }; + return quizShareImage({ + kicker: "ax.guide quiz · quick run", + score: shared.score, + correct: shared.results.filter(Boolean).length, + total: shared.results.length, + results: shared.results, + }); +} diff --git a/app/play/s/[code]/page.tsx b/app/play/s/[code]/page.tsx new file mode 100644 index 0000000..d834f39 --- /dev/null +++ b/app/play/s/[code]/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { ShareBody } from "@/components/quiz/share-body"; +import { decodeShare } from "@/lib/quiz"; +import "../../play.css"; + +type Props = { params: Promise<{ code: string }> }; + +export async function generateMetadata({ params }: Props): Promise { + const shared = decodeShare((await params).code); + if (!shared) return {}; + const correct = shared.results.filter(Boolean).length; + return { + title: `${correct}/${shared.results.length} on the agentic web quiz`, + description: `${shared.score.toLocaleString("en-US")} points. Twelve cards on the standards that let AI agents use the web. Can you beat it?`, + robots: { index: false }, + }; +} + +/** A shared open-run score. The score lives in the address, so this page needs no database. */ +export default async function SharedScore({ params }: Props) { + const shared = decodeShare((await params).code); + if (!shared) notFound(); + const correct = shared.results.filter(Boolean).length; + return ; +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 306e7b3..8509331 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -6,7 +6,7 @@ export default function sitemap(): MetadataRoute.Sitemap { const base = currentSiteUrl(); const at = (path: string) => new URL(path, base).toString(); return [ - ...["/", "/compare", "/which", "/contribute"].map((path) => ({ url: at(path) })), + ...["/", "/compare", "/which", "/play", "/play/leaderboard", "/contribute"].map((path) => ({ url: at(path) })), ...getStandards().map((s) => ({ url: at(`/standards/${s.id}`), lastModified: s.last_verified })), ]; } diff --git a/components/quiz/dealers.ts b/components/quiz/dealers.ts new file mode 100644 index 0000000..2cd2fc4 --- /dev/null +++ b/components/quiz/dealers.ts @@ -0,0 +1,128 @@ +import { + CARD_SECONDS, + type CardResult, + cardPoints, + type DealtCard, + type Deck, + dealCard, + learnMore, + openGroup, + optionOrder, + pickCards, + RUN_CAPS, + RUN_LENGTH, +} from "@/lib/quiz"; +import type { QuizQuestion } from "@/lib/quiz-schema"; + +/** The end of a run, whichever deck it was. */ +export interface Summary { + deck: Deck; + score: number; + correct: number; + total: number; + results: boolean[]; + /** Expert runs only: the run's share page, and where it placed. */ + runId?: string; + rank?: number; + players?: number; +} + +/** + * Deals one run. The game screen doesn't know whether the cards come from the + * page (open runs) or the server (expert runs, which the server times). + */ +export interface Dealer { + deck: Deck; + total: number; + first(): Promise; + answer(position: number, choice: number | null, ms: number): Promise<{ result: CardResult; summary?: Summary }>; + next(position: number): Promise; +} + +/** An open run, played entirely in the browser from the public deck. */ +export function openDealer(deck: QuizQuestion[], standardNames: Record): Dealer { + const picked = pickCards(deck, RUN_LENGTH.open, RUN_CAPS.open, openGroup); + const orders = picked.map((q) => optionOrder(q)); + const results: boolean[] = []; + let score = 0; + let streak = 0; + const card = (i: number) => dealCard(picked[i] as QuizQuestion, orders[i] ?? []); + return { + deck: "open", + total: picked.length, + first: async () => card(0), + next: async (position) => card(position), + async answer(position, choice, ms) { + const question = picked[position] as QuizQuestion; + const shown = (orders[position] ?? []).indexOf(question.answer); + const correct = choice === shown && ms <= CARD_SECONDS * 1000; + streak = correct ? streak + 1 : 0; + const points = cardPoints(correct, ms, streak); + score += points; + results.push(correct); + const result: CardResult = { + correct, + answer: shown, + explain: question.explain, + learnMore: learnMore(question, question.standard ? standardNames[question.standard] : undefined), + points, + score, + streak, + }; + if (results.length < picked.length) return { result }; + return { + result, + summary: { deck: "open", score, correct: results.filter(Boolean).length, total: picked.length, results: [...results] }, + }; + }, + }; +} + +class PlayRequestError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +async function post(path: string, body?: unknown): Promise { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as T & { error?: string }; + if (!response.ok) throw new PlayRequestError(response.status, data.error ?? "Something went wrong. Try again."); + return data; +} + +interface RunState { + runId: string; + total: number; + position: number; + card: DealtCard; +} + +/** + * An expert run, dealt and scored by the server. Starting it deals the first + * card, so `first` only hands that card over. + */ +export async function expertDealer(): Promise { + const start = await post("/api/play/runs"); + const url = `/api/play/runs/${start.runId}`; + return { + deck: "expert", + total: start.total, + first: async () => start.card, + next: async (position) => (await post(url, { action: "next", position })).card, + async answer(position, choice) { + const { result, summary } = await post<{ + result: CardResult; + summary?: Omit; + }>(url, { action: "answer", position, choice }); + return { result, summary: summary && { ...summary, deck: "expert" } }; + }, + }; +} diff --git a/components/quiz/quiz-game.tsx b/components/quiz/quiz-game.tsx new file mode 100644 index 0000000..98fcc1e --- /dev/null +++ b/components/quiz/quiz-game.tsx @@ -0,0 +1,363 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CARD_SECONDS, type CardResult, type DealtCard, encodeShare, KIND_LABELS, RUN_LENGTH } from "@/lib/quiz"; +import type { QuizQuestion } from "@/lib/quiz-schema"; +import { type Dealer, expertDealer, openDealer, type Summary } from "./dealers"; +import { Results } from "./results"; +import { SignIn } from "./sign-in"; + +type Phase = + | { name: "intro" } + | { name: "loading" } + | { name: "card"; card: DealtCard; position: number; dealtAt: number } + | { name: "revealed"; card: DealtCard; position: number; choice: number | null; result: CardResult; summary?: Summary } + | { name: "done"; summary: Summary; misses: CardResult["learnMore"][] }; + +interface Me { + enabled: boolean; + player: { name: string } | null; +} + +const LETTERS = ["A", "B", "C", "D"]; + +export function QuizGame({ deck, standardNames }: { deck: QuizQuestion[]; standardNames: Record }) { + const [phase, setPhase] = useState({ name: "intro" }); + const [me, setMe] = useState(); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + const [results, setResults] = useState([]); + const [score, setScore] = useState(0); + const [total, setTotal] = useState(0); + const dealer = useRef(undefined); + const misses = useRef([]); + const busy = useRef(false); + const promptRef = useRef(null); + const nextRef = useRef(null); + const expertRef = useRef(null); + + useEffect(() => { + fetch("/api/play/me") + .then((r) => (r.ok ? (r.json() as Promise) : { enabled: false, player: null })) + .catch(() => ({ enabled: false, player: null })) + .then((found) => { + setMe(found); + if (new URLSearchParams(window.location.search).get("signin") === "failed") { + setNotice("That sign-in link didn't work. It may have expired; try again."); + } + }); + }, []); + + // Back from signing in for the expert deck: put the start button in reach. + useEffect(() => { + if (me?.player && new URLSearchParams(window.location.search).get("deck") === "expert") expertRef.current?.focus(); + }, [me]); + + async function begin(make: () => Promise) { + if (busy.current) return; + busy.current = true; + setError(undefined); + setPhase({ name: "loading" }); + try { + const d = await make(); + dealer.current = d; + misses.current = []; + setResults([]); + setScore(0); + setTotal(d.total); + const card = await d.first(); + setPhase({ name: "card", card, position: 0, dealtAt: performance.now() }); + } catch (e) { + setError(e instanceof Error ? e.message : "The run could not start."); + setPhase({ name: "intro" }); + } finally { + busy.current = false; + } + } + + const answer = useCallback( + async (choice: number | null) => { + if (phase.name !== "card" || !dealer.current || busy.current) return; + busy.current = true; + const ms = performance.now() - phase.dealtAt; + try { + const { result, summary } = await dealer.current.answer(phase.position, choice, ms); + if (!result.correct) misses.current.push(result.learnMore); + setResults((r) => [...r, result.correct]); + setScore(result.score); + setPhase({ name: "revealed", card: phase.card, position: phase.position, choice, result, summary }); + } catch (e) { + setError(e instanceof Error ? e.message : "That answer didn't go through."); + } finally { + busy.current = false; + } + }, + [phase], + ); + + const advance = useCallback(async () => { + if (phase.name !== "revealed" || !dealer.current || busy.current) return; + if (phase.summary) { + const unique = [...new Map(misses.current.map((m) => [m.href, m])).values()]; + setPhase({ name: "done", summary: phase.summary, misses: unique }); + return; + } + busy.current = true; + try { + const position = phase.position + 1; + const card = await dealer.current.next(position); + setPhase({ name: "card", card, position, dealtAt: performance.now() }); + } catch (e) { + setError(e instanceof Error ? e.message : "The next card didn't come."); + } finally { + busy.current = false; + } + }, [phase]); + + // The clock: running out counts as a wrong answer. + useEffect(() => { + if (phase.name !== "card") return; + const timer = window.setTimeout(() => answer(null), CARD_SECONDS * 1000); + return () => window.clearTimeout(timer); + }, [phase, answer]); + + // Focus follows the game, so it plays from the keyboard and reads well aloud. + useEffect(() => { + if (phase.name === "card") promptRef.current?.focus(); + if (phase.name === "revealed") nextRef.current?.focus(); + }, [phase]); + + // 1-4 or A-D answer; Enter or the right arrow moves on. + useEffect(() => { + function onKey(event: KeyboardEvent) { + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return; + if (phase.name === "card") { + const key = event.key.toUpperCase(); + const index = /^[1-4]$/.test(key) ? Number(key) - 1 : LETTERS.indexOf(key); + if (index >= 0 && index < phase.card.options.length) { + event.preventDefault(); + answer(index); + } + } else if (phase.name === "revealed" && event.key === "ArrowRight") { + event.preventDefault(); + advance(); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [phase, answer, advance]); + + async function signOut() { + await fetch("/api/play/signout", { method: "POST" }); + setMe((m) => (m ? { ...m, player: null } : m)); + } + + const playOpen = () => begin(async () => openDealer(deck, standardNames)); + const playExpert = () => begin(expertDealer); + + if (phase.name === "intro" || phase.name === "loading") { + const loading = phase.name === "loading"; + return ( +
+
+

The ax.guide quiz

+

+ How well do you know the agentic web? +

+

+ {RUN_LENGTH.open} cards on the standards that let AI agents find, use, sign in to, shop on and pay websites, + and the web plumbing under them. {CARD_SECONDS} seconds a card. Fast answers score more, and so does a streak. +

+
+ + {notice && ( +

+ {notice} +

+ )} + {error && ( +

+ {error} +

+ )} + +
+
+

{RUN_LENGTH.open} cards

+

+ Quick run +

+

No sign-up. A random dozen from the open deck, then a score to share.

+ +
+ +
+

{RUN_LENGTH.expert} cards · ranked

+

+ Expert run +

+

+ A second deck of 60 harder cards: headers, handshakes and the pairs everyone mixes up. Its answers aren't + on GitHub, so the leaderboard means something. +

+ {me === undefined ? null : !me.enabled ? ( +

The expert deck and leaderboard open soon.

+ ) : me.player ? ( + <> + +

+ Playing as {me.player.name}.{" "} + +

+ + ) : ( + <> +

Sign in to unlock it:

+ + + )} +
+
+ +

+ {me?.enabled && ( + <> + See the leaderboard ·{" "} + + )} + Every answer links to the page that explains it. +

+
+ ); + } + + if (phase.name === "done") { + return ( + + Try the expert deck + + ) : ( + + ) + ) : undefined + } + /> + ); + } + + const revealed = phase.name === "revealed" ? phase : undefined; + return ( +
+
+
    + {Array.from({ length: total }, (_, i) => ( +
  1. + ))} +
+

+ + {score.toLocaleString("en-US")} pts + + {revealed && revealed.result.streak > 1 && {revealed.result.streak} in a row} +

+
+ +
+

+ {KIND_LABELS[phase.card.kind]} + + {phase.position + 1}/{total} + +

+ {!revealed &&
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/quiz/results.tsx b/components/quiz/results.tsx new file mode 100644 index 0000000..438e481 --- /dev/null +++ b/components/quiz/results.tsx @@ -0,0 +1,125 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { shareGrid, verdict } from "@/lib/quiz"; +import type { Summary } from "./dealers"; + +export function Results({ + summary, + shareCode, + misses, + onAgain, + expert, +}: { + summary: Summary; + /** Open runs: the code in /play/s/. Expert runs share /play/r/. */ + shareCode?: string; + misses: { href: string; label: string }[]; + onAgain: () => void; + /** The way into the expert deck, after an open run. */ + expert?: React.ReactNode; +}) { + const [copied, setCopied] = useState(false); + const path = summary.runId ? `/play/r/${summary.runId}` : `/play/s/${shareCode ?? ""}`; + const url = typeof window === "undefined" ? path : `${window.location.origin}${path}`; + const deckName = summary.deck === "expert" ? "Expert run" : "Agentic web quiz"; + const text = `${deckName} on ax.guide: ${summary.correct}/${summary.total}, ${summary.score.toLocaleString("en-US")} pts\n${shareGrid(summary.results)}`; + + async function share() { + if (navigator.share) { + try { + await navigator.share({ text, url }); + return; + } catch { + // Cancelled, or not allowed here: copy instead. + } + } + await navigator.clipboard.writeText(`${text}\n${url}`); + setCopied(true); + window.setTimeout(() => setCopied(false), 2500); + } + + const encoded = encodeURIComponent(`${text}\n${url}`); + return ( +
+

{deckName}

+

+ Score: + {summary.score.toLocaleString("en-US")} + pts +

+

+ + {summary.correct} of {summary.total} + {" "} + right. {verdict(summary.correct, summary.total)} +

+ {summary.rank !== undefined && ( +

+ That places you #{summary.rank} of {summary.players} on the{" "} + leaderboard (each player's best run counts). +

+ )} +
    + {summary.results.map((right, i) => ( +
  1. + + Card {i + 1}: {right ? "right" : "wrong"} + +
  2. + ))} +
+ +
+ + + Post on X + + + LinkedIn + + +
+ + {misses.length > 0 && ( +
+

+ Brush up on +

+ +
+ )} + + {expert && ( +
+

+ Ready for harder? +

+

+ The expert deck is 60 tougher cards, 18 a run, ranked on the leaderboard. Its answers aren't public, so + the top of the board is earned. +

+ {expert} +
+ )} +
+ ); +} diff --git a/components/quiz/share-body.tsx b/components/quiz/share-body.tsx new file mode 100644 index 0000000..3361689 --- /dev/null +++ b/components/quiz/share-body.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import { verdict } from "@/lib/quiz"; + +/** A shared score: the result, the grid, and a dare to beat it. */ +export function ShareBody({ + kicker, + score, + correct, + results, +}: { + kicker: string; + score: number; + correct: number; + results: boolean[]; +}) { + return ( +
+

{kicker}

+

+ {score.toLocaleString("en-US")} + pts +

+

+ + {correct} of {results.length} + {" "} + right. {verdict(correct, results.length)} +

+
    + {results.map((right, i) => ( +
  1. + + Card {i + 1}: {right ? "right" : "wrong"} + +
  2. + ))} +
+

How well do you know the standards that let AI agents find, use, sign in to, shop on and pay websites?

+ + Beat this score + +
+ ); +} diff --git a/components/quiz/sign-in.tsx b/components/quiz/sign-in.tsx new file mode 100644 index 0000000..22a8297 --- /dev/null +++ b/components/quiz/sign-in.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { createBrowserClient } from "@supabase/ssr"; +import { useState } from "react"; +import { supabasePublicEnv } from "@/lib/supabase-env"; + +function browserClient() { + const env = supabasePublicEnv(); + return env ? createBrowserClient(env.url, env.anonKey) : undefined; +} + +function callbackUrl(): string { + return `${window.location.origin}/auth/callback?next=${encodeURIComponent("/play?deck=expert")}`; +} + +/** GitHub, Google or an emailed link. Each comes back through /auth/callback. */ +export function SignIn() { + const [email, setEmail] = useState(""); + const [state, setState] = useState<"idle" | "sending" | "sent" | "error">("idle"); + + async function withProvider(provider: "github" | "google") { + const client = browserClient(); + if (!client) return setState("error"); + const { error } = await client.auth.signInWithOAuth({ provider, options: { redirectTo: callbackUrl() } }); + if (error) setState("error"); + } + + async function withEmail(event: React.FormEvent) { + event.preventDefault(); + const client = browserClient(); + if (!client) return setState("error"); + setState("sending"); + const { error } = await client.auth.signInWithOtp({ email, options: { emailRedirectTo: callbackUrl() } }); + setState(error ? "error" : "sent"); + } + + if (state === "sent") { + return ( +

+ Check {email} for a sign-in link. It brings you straight back here. +

+ ); + } + + return ( +
+
+ + +
+
+ + setEmail(e.target.value)} + /> + +
+ {state === "error" && ( +

+ Sign-in didn't start. Try again, or another way. +

+ )} +

+ Only your GitHub handle, or your first name and an initial, appear on the leaderboard. Never your email. +

+
+ ); +} diff --git a/content/home.json b/content/home.json index 159379a..1c2b56f 100644 --- a/content/home.json +++ b/content/home.json @@ -40,6 +40,11 @@ "body": "Start from what you're building, not from the acronym.", "href": "/which" }, + { + "title": "Test yourself", + "body": "Twelve cards, twenty seconds each. Then share your score.", + "href": "/play" + }, { "title": "Fix or add a standard", "body": "Every page is a file. Open a pull request.", diff --git a/content/quiz/README.md b/content/quiz/README.md new file mode 100644 index 0000000..59faa0a --- /dev/null +++ b/content/quiz/README.md @@ -0,0 +1,57 @@ +# The quiz + +[ax.guide/play](https://ax.guide/play) is a card game about the agentic web: the twelve standards on the map and the web plumbing they build on. It has two decks. + +| Deck | Where it lives | A run | Who can play | +| --- | --- | --- | --- | +| **Open** | [`open.json`](open.json), plus one "Real or hype?" card per standard, written from its page | 12 cards, played in the browser | Anyone, no sign-up | +| **Expert** | Supabase, never this repository | 18 cards, dealt and scored by the server | Signed-in players; ranked on the leaderboard | + +The expert deck is kept out of the repository on purpose. If its answers were on GitHub, the leaderboard would mean nothing. + +## Cards + +Each card is checked against [`lib/quiz-schema.ts`](../../lib/quiz-schema.ts), and `npm test` fails on a broken card. A card has: + +- `id`: lowercase words and hyphens, starting with `o-` in the open deck and `x-` in the expert deck. +- `kind`: one of these four. + - `quick`: a plain question. + - `which`: "Which standard am I?" + - `fake`: "Spot the fake". `answer` points at the made-up option. + - `real`: "Real or hype?" +- `prompt`, `options` (two to four) and `answer` (the index of the right option). +- `explain`: one or two sentences shown after the answer. Make the player smarter even when they got it right. +- `standard`: the standard the card is about, if any. "Read more" links to its page. +- `source`: the primary source for the fact. +- `checked`: the day someone last checked the card. + +Options are shuffled when a card is dealt, so an option can never depend on its position ("both of the above"). The exceptions are `real` cards and True/False cards, which keep their written order. + +**Write cards that stay true.** Ask how a standard works, who made it, what it is for, and what it gets confused with. Avoid version numbers, deployment counts and "latest draft" dates: they change monthly. A standard's status is already asked by its generated "Real or hype?" card, whose answer changes the day its page does. The house style of [CONTRIBUTING.md](../../CONTRIBUTING.md) applies: plain words, no em dashes. + +## Scoring + +A right answer earns 100 points. Answering inside 3 seconds adds up to 50 more, sliding to nothing at 20 seconds. Each right answer in an unbroken streak adds 10 more, up to 50. A wrong answer, or running out of time, earns nothing and breaks the streak. The rules are in [`lib/quiz.ts`](../../lib/quiz.ts). + +On an expert run the server deals each card and keeps time with its own clock. A card's clock stops when it is answered and starts again only when the next card is dealt, so reading an explanation costs nothing. A card can't be answered twice, and asking for it again doesn't restart its clock. + +## Setting up the expert deck, sign-in and the leaderboard + +The open quiz needs none of this. Without it, `/play` shows the expert deck and leaderboard as "soon". + +1. **Create a Supabase project.** Open its SQL editor and run [`supabase/migrations/20261030000000_quiz.sql`](../../supabase/migrations/20261030000000_quiz.sql), or use `supabase db push` with the Supabase CLI. Every table has row level security and no policies, so only the site's server can read them. +2. **Turn on sign-in** (Authentication > Sign In / Providers): GitHub, Google and Email. + - **GitHub:** create an OAuth app at github.com/settings/developers whose callback URL is `https://.supabase.co/auth/v1/callback`. + - **Google:** create an OAuth client in Google Cloud with the same callback. + - **Email:** Supabase sends the magic links itself. Its built-in sender is rate-limited, so add your own SMTP (for example Resend) before launch. +3. **Set the redirect URLs** (Authentication > URL Configuration): + - **Site URL:** `https://ax.guide` + - **Redirect URLs:** `https://ax.guide/auth/callback`, `https://*-.vercel.app/auth/callback` for previews, and `http://localhost:3000/auth/callback` +4. **Add the three variables** from [`.env.example`](../../.env.example) in Vercel (Project > Settings > Environment Variables). The service role key is a secret: keep it server-only. +5. **Load the expert deck.** Keep it in `private/quiz-expert.json`; the `private/` folder is gitignored. Run `npm run quiz:sql`: it checks the deck with the same tests as the open deck, then writes `private/quiz-expert.sql`. Run that file in the SQL editor. Re-running it updates changed cards, adds new ones and switches off cards you removed. Runs already in progress keep working. + +Store the master copy of `quiz-expert.json` somewhere private, such as a private repository, a password manager or a private gist. The database holds a copy, but not a convenient one to edit. + +## Players' data + +A player's leaderboard name is their GitHub handle, or their first name and last initial, or `player-` and six characters. It is never their email. Runs are kept to rank them. Deleting a user in Supabase Auth deletes their profile and runs. Open runs never leave the browser. diff --git a/content/quiz/open.json b/content/quiz/open.json new file mode 100644 index 0000000..666f1b4 --- /dev/null +++ b/content/quiz/open.json @@ -0,0 +1,513 @@ +[ + { + "id": "o-ard-which", + "kind": "which", + "prompt": "Your travel agent needs a weather tool it has never heard of, so it asks a registry that searches sites' tool lists. Which standard is at work?", + "options": ["NLWeb", "WebMCP", "ARD", "Poppy"], + "answer": 2, + "explain": "ARD is a search engine for agent tools: sites publish /.well-known/ard.json and registries collect those files to answer plain-language searches. A high ranking still isn't proof a tool is trustworthy.", + "standard": "ard", + "source": "https://agenticresourcediscovery.org/spec/", + "checked": "2026-10-30" + }, + { + "id": "o-ard-vs-ai-catalog", + "kind": "quick", + "prompt": "ARD and ai-catalog agreed to stay loosely linked. How do they split the work?", + "options": ["ARD publishes, ai-catalog discovers", "ai-catalog publishes, ARD discovers", "ARD replaced ai-catalog outright", "They merged into one shared file"], + "answer": 1, + "explain": "ai-catalog is the publishing format, your site's list of its AI offerings; ARD is the discovery format that registries crawl. The practical tip: serve the same file at both ard.json and ai-catalog.json.", + "standard": "ard", + "source": "https://github.com/ards-project/ard-spec/blob/main/adr/0010-ard-and-ai-catalog-loose-coupling.md", + "checked": "2026-10-30" + }, + { + "id": "o-ai-catalog-which", + "kind": "which", + "prompt": "A restaurant lists its booking tool and its jobs tool in one JSON file, like a table of contents for AI. Which standard?", + "options": ["ai-catalog", "NLWeb", "UCP", "auth.md"], + "answer": 0, + "explain": "ai-catalog is one JSON list at /.well-known/ai-catalog.json. Like a table of contents, it doesn't hold the tools themselves; each entry says what kind of thing it is and links to its full description or includes it.", + "standard": "ai-catalog", + "source": "https://ai-catalog.io/", + "checked": "2026-10-30" + }, + { + "id": "o-ai-catalog-still-serve", + "kind": "quick", + "prompt": "ARD calls ai-catalog its predecessor. True or false: it still pays to serve ai-catalog.json.", + "options": ["True", "False"], + "answer": 0, + "explain": "ai-catalog was split off, not retired. MCP's Server Cards point discovery at ai-catalog.json and many publishers only use that address, so serve one file at both ai-catalog.json and ard.json.", + "standard": "ai-catalog", + "source": "https://ax.guide/standards/ai-catalog", + "checked": "2026-10-30" + }, + { + "id": "o-nlweb-which", + "kind": "which", + "prompt": "Your recipe site answers \"vegetarian dinners under 30 minutes, no nuts\" at /ask for people and /mcp for agents. Which standard?", + "options": ["WebMCP", "ARD", "UCP", "NLWeb"], + "answer": 3, + "explain": "NLWeb indexes the structured data a site already has, like Schema.org tags and RSS, and answers only from that site. Every NLWeb site is also an MCP server, so agents can ask the same questions.", + "standard": "nlweb", + "source": "https://developers.cloudflare.com/ai-search/how-to/nlweb", + "checked": "2026-10-30" + }, + { + "id": "o-nlweb-w3c-group", + "kind": "quick", + "prompt": "NLWeb has a W3C community group. What does that make NLWeb?", + "options": ["A W3C Recommendation", "Still not a W3C standard", "A W3C Candidate Recommendation", "An official W3C working draft"], + "answer": 1, + "explain": "Community groups are open forums that anyone can propose, and they aren't a standards track. W3C standards come out of working groups, so NLWeb is an open spec with a W3C discussion space.", + "standard": "nlweb", + "source": "https://www.w3.org/community/nlweb", + "checked": "2026-10-30" + }, + { + "id": "o-guha-two-standards", + "kind": "quick", + "prompt": "R.V. Guha helped create RSS and Schema.org. Which two agentic-web standards also carry his name?", + "options": ["UCP and ACP", "x402 and MPP", "ARD and NLWeb", "WebMCP and Poppy"], + "answer": 2, + "explain": "Guha conceived NLWeb at Microsoft and co-authored ARD with Google and Hugging Face. NLWeb even reuses his earlier work: it indexes a site's Schema.org data.", + "standard": "nlweb", + "source": "https://ax.guide/standards/nlweb", + "checked": "2026-10-30" + }, + { + "id": "o-webmcp-which", + "kind": "which", + "prompt": "A salon's page calls document.modelContext.registerTool() so the agent in your browser can book_appointment. Which standard?", + "options": ["NLWeb", "WebMCP", "Poppy", "UCP"], + "answer": 1, + "explain": "WebMCP lets a page hand typed tools, with names, descriptions and fields, to the agent in your browser. The agent stops hunting for the calendar widget and just calls the tool.", + "standard": "webmcp", + "source": "https://webmachinelearning.github.io/webmcp/", + "checked": "2026-10-30" + }, + { + "id": "o-webmcp-where-it-runs", + "kind": "quick", + "prompt": "When an agent calls a WebMCP tool, where does the work happen?", + "options": ["In your own signed-in browser tab", "On a separate MCP server", "Inside an agent search registry", "On the card network's servers"], + "answer": 0, + "explain": "WebMCP tools are JavaScript functions on the page, running in your tab and your session, so you can watch and step in. A regular MCP server serves agents that never load your page.", + "standard": "webmcp", + "source": "https://developer.chrome.com/docs/ai/webmcp", + "checked": "2026-10-30" + }, + { + "id": "o-webmcp-tool-text-risk", + "kind": "quick", + "prompt": "WebMCP tools come with plain-English descriptions the AI reads. What's the catch?", + "options": ["Descriptions slow pages down", "Only Chrome can read them", "They must be translated", "A shady site can trick the agent"], + "answer": 3, + "explain": "Tool descriptions are text the model reads, so a malicious page can try prompt injection. Chrome says agents must defend against this and gives sites ways to flag untrusted text.", + "standard": "webmcp", + "source": "https://developer.chrome.com/docs/ai/webmcp/secure-tools", + "checked": "2026-10-30" + }, + { + "id": "o-web-bot-auth-which", + "kind": "which", + "prompt": "A news site lets a verified shopping agent in but blocks a scraper wearing the exact same user-agent label. Which standard tells them apart?", + "options": ["auth.md", "Poppy", "Web Bot Auth", "ARD"], + "answer": 2, + "explain": "With Web Bot Auth, the bot's operator publishes public keys and signs every request. Anyone can copy a label, but nobody can fake a valid signature without the private key.", + "standard": "web-bot-auth", + "source": "https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/", + "checked": "2026-10-30" + }, + { + "id": "o-web-bot-auth-rfc", + "kind": "quick", + "prompt": "Web Bot Auth didn't invent its own crypto. Which existing IETF standard does it sign requests with?", + "options": ["JSON Web Tokens", "HTTP Message Signatures", "TLS client certificates", "OAuth device flow"], + "answer": 1, + "explain": "It reuses RFC 9421, HTTP Message Signatures, which signs chosen parts of a request. Web Bot Auth adds where a bot's keys live and how a site finds them.", + "standard": "web-bot-auth", + "source": "https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/", + "checked": "2026-10-30" + }, + { + "id": "o-card-networks-web-bot-auth", + "kind": "quick", + "prompt": "True or false: Visa's Trusted Agent Protocol and Mastercard's Agent Pay build on Web Bot Auth.", + "options": ["True", "False"], + "answer": 0, + "explain": "Both build on Web Bot Auth's signed requests and add a tag saying whether the agent is browsing or buying. The card networks reused the bot ID layer instead of inventing their own.", + "standard": "web-bot-auth", + "source": "https://blog.cloudflare.com/secure-agentic-commerce/", + "checked": "2026-10-30" + }, + { + "id": "o-auth-md-which", + "kind": "which", + "prompt": "Your coding agent calls a scraping API you've never used, gets \"401: who are you?\" and a file explaining how to sign you up. Which standard?", + "options": ["auth.md", "Poppy", "Web Bot Auth", "AP2"], + "answer": 0, + "explain": "auth.md is a sign-up page written for robots, served at yourapp.com/auth.md. However the agent signs you up, it ends with a normal short-lived, revocable OAuth token tied to you.", + "standard": "auth-md", + "source": "https://github.com/workos/auth.md", + "checked": "2026-10-30" + }, + { + "id": "o-auth-md-fake-path", + "kind": "fake", + "prompt": "Spot the fake: which is NOT one of auth.md's ways for an agent to sign you up?", + "options": ["Vouched for by the agent's maker", "Type a code on the service's page", "Pay a $1 fee to prove you're human", "Start anonymous, claim it later"], + "answer": 2, + "explain": "auth.md has three paths: the agent's maker vouches for you, you sign in and type a short code on the service's own page, or the agent starts anonymous and you claim the account later. No fee involved.", + "standard": "auth-md", + "source": "https://github.com/workos/auth.md", + "checked": "2026-10-30" + }, + { + "id": "o-poppy-which", + "kind": "which", + "prompt": "It's a valet key for your personal agent: it can sign in and act for you, but can't open the glovebox. Which standard?", + "options": ["auth.md", "Web Bot Auth", "AP2", "Poppy"], + "answer": 3, + "explain": "Poppy, the Personal Agent Protocol, lets your agent sign in to a company as you, with only the permissions you granted. The company explains how at /.well-known/poppy.json.", + "standard": "poppy", + "source": "https://personalagentprotocol.org/docs/spec", + "checked": "2026-10-30" + }, + { + "id": "o-poppy-design-partners", + "kind": "real", + "prompt": "True or false: OpenAI, Visa and PayPal are Poppy design partners, so they already support it.", + "options": ["True", "False"], + "answer": 1, + "explain": "Design partners help shape a draft; that's not the same as shipping it. Before believing a logo, check whether the company actually serves a poppy.json.", + "standard": "poppy", + "source": "https://sierra.ai/blog/poppy", + "checked": "2026-10-30" + }, + { + "id": "o-poppy-fake-pairing", + "kind": "fake", + "prompt": "Spot the fake: which standard is paired with the wrong creators?", + "options": ["Poppy: Google + Visa", "UCP: Google + Shopify", "ACP: OpenAI + Stripe", "MPP: Stripe + Tempo"], + "answer": 0, + "explain": "Poppy came from Sierra and Meta, announced October 6, 2026. The rest are right: Google and Shopify launched UCP, OpenAI and Stripe launched ACP, and Stripe and Tempo launched MPP.", + "standard": "poppy", + "source": "https://sierra.ai/blog/poppy", + "checked": "2026-10-30" + }, + { + "id": "o-ucp-which", + "kind": "which", + "prompt": "A universal power socket for shopping: any assistant plugs into any store, from catalog to cart to checkout. Which standard?", + "options": ["ACP", "UCP", "AP2", "Poppy"], + "answer": 1, + "explain": "UCP defines the common steps of buying and carries them over ordinary web APIs, MCP or A2A. A store describes what it supports at /.well-known/ucp, and the retailer stays the legal seller.", + "standard": "ucp", + "source": "https://ucp.dev/", + "checked": "2026-10-30" + }, + { + "id": "o-ucp-council-seat", + "kind": "real", + "prompt": "True or false: Amazon sits on UCP's technical council, so Amazon sells through UCP.", + "options": ["True", "False"], + "answer": 1, + "explain": "A council seat isn't a storefront. Companies join councils to shape a spec; whether they deploy it is a separate question you check store by store.", + "standard": "ucp", + "source": "https://ucp.dev/documentation/announcements/", + "checked": "2026-10-30" + }, + { + "id": "o-acp-which", + "kind": "which", + "prompt": "You want your products shown in ChatGPT with accurate, current prices, and you already use Stripe. Which standard?", + "options": ["UCP", "NLWeb", "ACP", "ARD"], + "answer": 2, + "explain": "ACP comes from OpenAI and Stripe. Merchants push product feeds and promotions so ChatGPT can show current products, prices and comparisons. UCP is the rival built for any assistant.", + "standard": "acp", + "source": "https://openai.com/index/powering-product-discovery-in-chatgpt/", + "checked": "2026-10-30" + }, + { + "id": "o-acp-main-job", + "kind": "quick", + "prompt": "ACP launched alongside \"Instant Checkout\" in ChatGPT. What is its main job today?", + "options": ["Paying with stablecoins", "Signing users in to stores", "Ranking stores by trust", "Product discovery in ChatGPT"], + "answer": 3, + "explain": "In March 2026 OpenAI expanded ACP to product discovery, while checkout moved back to the merchant's side. The headline feature, buying inside the chat, is the part that was scaled back.", + "standard": "acp", + "source": "https://openai.com/index/powering-product-discovery-in-chatgpt/", + "checked": "2026-10-30" + }, + { + "id": "o-ap2-which", + "kind": "which", + "prompt": "A signed, notarized shopping list with a spending limit the bank can check before paying. Which standard?", + "options": ["AP2", "x402", "MPP", "ACP"], + "answer": 0, + "explain": "AP2 records what you approved as signed \"mandates,\" so the bank and merchant can answer \"did a real person agree to this?\" It authorizes payments; other rails actually move the money.", + "standard": "ap2", + "source": "https://github.com/google-agentic-commerce/AP2", + "checked": "2026-10-30" + }, + { + "id": "o-ap2-human-not-present", + "kind": "quick", + "prompt": "AP2 supports \"Human Not Present\" purchases. What does that mean?", + "options": ["The agent pays with no limits", "You pre-sign rules; it buys later", "The merchant signs on your behalf", "A bot buys with no user at all"], + "answer": 1, + "explain": "You sign the rules up front, say \"concert tickets, under $200,\" and the agent buys later when the conditions are met, even at 3 a.m. The signed limits are what make that safe for the bank.", + "standard": "ap2", + "source": "https://github.com/google-agentic-commerce/AP2", + "checked": "2026-10-30" + }, + { + "id": "o-ap2-fake-mandate", + "kind": "fake", + "prompt": "Spot the fake: AP2 records your consent in signed mandates. Which one is made up?", + "options": ["Open checkout mandate", "Closed checkout mandate", "Refund mandate", "Payment mandate"], + "answer": 2, + "explain": "AP2 has checkout mandates (open while they hold your limits, closed once they lock exact items and price) and payment mandates for how you pay. There's no refund mandate.", + "standard": "ap2", + "source": "https://github.com/google-agentic-commerce/AP2", + "checked": "2026-10-30" + }, + { + "id": "o-mpp-which", + "kind": "which", + "prompt": "A research agent opens a pay-as-you-go \"session\" on Tempo, pays a fraction of a cent per article, and settles the total when it finishes. Which standard?", + "options": ["AP2", "ACP", "x402", "MPP"], + "answer": 3, + "explain": "MPP, from Stripe and Tempo, answers with HTTP 402 like x402 but adds pay-as-you-go \"sessions\" and subscriptions. Sessions run on stablecoin rails like Tempo; cards through Stripe cover one-off charges.", + "standard": "mpp", + "source": "https://mpp.dev/", + "checked": "2026-10-30" + }, + { + "id": "o-mpp-individual-draft", + "kind": "quick", + "prompt": "MPP's core spec is an \"individual\" IETF Internet-Draft. What does \"individual\" tell you?", + "options": ["No working group has taken it on", "The IETF has approved it", "Only one company may use it", "It's already a finished RFC"], + "answer": 0, + "explain": "Anyone can post an Internet-Draft. An individual draft has no formal standing until a working group adopts it, so \"it's at the IETF\" isn't the same as \"the IETF is standardizing it.\"", + "standard": "mpp", + "source": "https://datatracker.ietf.org/doc/draft-httpauth-payment/", + "checked": "2026-10-30" + }, + { + "id": "o-x402-which", + "kind": "which", + "prompt": "A parking meter on every web page: drop in a cent of USDC, the gate opens, no account needed. Which standard?", + "options": ["MPP", "x402", "AP2", "ACP"], + "answer": 1, + "explain": "x402, created by Coinbase and now run by a Linux Foundation project, answers with HTTP 402 and a price, and the client attaches a signed stablecoin payment. MPP is the close cousin that also takes cards.", + "standard": "x402", + "source": "https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md", + "checked": "2026-10-30" + }, + { + "id": "o-x402-billion-402s", + "kind": "real", + "prompt": "True or false: Cloudflare sees about a billion 402 responses a day, so x402 handles a billion payments a day.", + "options": ["True", "False"], + "answer": 1, + "explain": "A 402 is a request for payment, not a payment, and most never get paid. To size x402, count settled dollars, not 402 responses or transaction counts.", + "standard": "x402", + "source": "https://blog.cloudflare.com/x402/", + "checked": "2026-10-30" + }, + { + "id": "o-x402-facilitator", + "kind": "quick", + "prompt": "In x402, the client attaches a signed USDC payment. What does the \"facilitator\" do?", + "options": ["Issues the stablecoin", "Signs the user in", "Settles it on a blockchain", "Ranks paid APIs"], + "answer": 2, + "explain": "The facilitator checks the payment and settles it on-chain in seconds, so the server doesn't run blockchain plumbing itself. Fees are a fraction of a cent, which card networks can't match.", + "standard": "x402", + "source": "https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md", + "checked": "2026-10-30" + }, + { + "id": "o-http-402-reserved", + "kind": "quick", + "prompt": "HTTP has had a 402 Payment Required code since the 1990s. What does RFC 9110 say it does?", + "options": ["Redirects to a checkout", "Blocks unpaid crawlers", "It's reserved for future use", "Asks for a card number"], + "answer": 2, + "explain": "RFC 9110 just says 402 is \"reserved for future use,\" and it sat unused for decades. x402 and MPP finally give it a job: answer with a price and how to pay.", + "source": "https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.3", + "checked": "2026-10-30" + }, + { + "id": "o-robots-txt-location", + "kind": "quick", + "prompt": "Where must a site put its robots.txt file?", + "options": ["/robots.txt", "/.well-known/robots.txt", "/meta/robots.txt", "/api/robots.txt"], + "answer": 0, + "explain": "RFC 9309 says the file must be /robots.txt at the top level. It's older than the /.well-known/ folder (RFC 8615) that newer files like ard.json and poppy.json use.", + "source": "https://www.rfc-editor.org/rfc/rfc9309.html", + "checked": "2026-10-30" + }, + { + "id": "o-robots-txt-request", + "kind": "quick", + "prompt": "True or false: robots.txt rules are a polite request, not a lock.", + "options": ["True", "False"], + "answer": 0, + "explain": "RFC 9309 says the rules \"are not a form of access authorization.\" Good crawlers follow them; a bot that ignores them isn't stopped, which is why sites want signed bot identity like Web Bot Auth.", + "source": "https://www.rfc-editor.org/rfc/rfc9309.html", + "checked": "2026-10-30" + }, + { + "id": "o-oauth-device-code", + "kind": "quick", + "prompt": "Your TV says \"Go to example.com/device and enter WDJB-MJHT.\" Which OAuth flow is that?", + "options": ["Client credentials grant", "Device authorization grant", "Implicit grant", "Refresh token grant"], + "answer": 1, + "explain": "The device authorization grant (RFC 8628) lets a device with no good keyboard get your approval on another screen. Poppy uses the same pattern when you approve an agent on another device with a code.", + "source": "https://www.rfc-editor.org/rfc/rfc8628.html", + "checked": "2026-10-30" + }, + { + "id": "o-oauth-pkce", + "kind": "quick", + "prompt": "PKCE, pronounced \"pixy,\" is added to OAuth's sign-in code flow. What problem does it solve?", + "options": ["Users forgetting passwords", "Tokens that never expire", "Bots solving CAPTCHAs", "Someone stealing the sign-in code"], + "answer": 3, + "explain": "The app makes a one-time secret, sends a hashed version at the start, and reveals the secret when trading the code for a token. A thief who grabs the code can't use it without that secret.", + "source": "https://www.rfc-editor.org/rfc/rfc7636.html", + "checked": "2026-10-30" + }, + { + "id": "o-mcp-who", + "kind": "quick", + "prompt": "Who introduced the Model Context Protocol (MCP) in November 2024?", + "options": ["Anthropic", "OpenAI", "Google", "Microsoft"], + "answer": 0, + "explain": "Anthropic open-sourced MCP on November 25, 2024 as one standard way to plug AI apps into tools and data. Lots here builds on it: every NLWeb site is an MCP server, and WebMCP borrows its idea of tools.", + "source": "https://www.anthropic.com/news/model-context-protocol", + "checked": "2026-10-30" + }, + { + "id": "o-mcp-json-rpc", + "kind": "quick", + "prompt": "Under the hood, what message format do MCP clients and servers speak?", + "options": ["GraphQL", "SOAP", "JSON-RPC 2.0", "gRPC"], + "answer": 2, + "explain": "MCP messages are JSON-RPC 2.0: small JSON objects with a method, params and an id. That keeps them simple to carry over a local pipe or over HTTP.", + "source": "https://modelcontextprotocol.io/specification/2025-06-18", + "checked": "2026-10-30" + }, + { + "id": "o-mcp-fake-primitive", + "kind": "fake", + "prompt": "Spot the fake: MCP servers offer three core building blocks. Which isn't one?", + "options": ["Tools", "Actions", "Resources", "Prompts"], + "answer": 1, + "explain": "MCP servers expose tools (functions the model can call), resources (data it can read) and prompts (reusable templates). \"Actions\" sounds right but isn't an MCP building block.", + "source": "https://modelcontextprotocol.io/specification/2025-06-18/server", + "checked": "2026-10-30" + }, + { + "id": "o-a2a-agent-card-path", + "kind": "quick", + "prompt": "Google's A2A protocol lets agents talk to agents. Where does an agent publish its Agent Card?", + "options": ["/.well-known/ai-plugin.json", "/agent/card.xml", "/.well-known/agents.txt", "/.well-known/agent-card.json"], + "answer": 3, + "explain": "An A2A Agent Card is a JSON file at /.well-known/agent-card.json saying what the agent can do and how to reach it. Google launched A2A in April 2025 and later gave it to the Linux Foundation.", + "source": "https://a2a-protocol.org/latest/specification/", + "checked": "2026-10-30" + }, + { + "id": "o-mcp-vs-a2a", + "kind": "quick", + "prompt": "MCP and A2A get mixed up a lot. What's the usual split?", + "options": ["MCP links tools, A2A links agents", "MCP links agents, A2A links tools", "MCP handles payments, A2A logins", "MCP runs in browsers, A2A servers"], + "answer": 0, + "explain": "MCP connects an AI app to tools and data; A2A lets independent agents find each other and hand off tasks. Google pitched A2A as a complement to MCP, not a replacement.", + "source": "https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/", + "checked": "2026-10-30" + }, + { + "id": "o-llms-txt-format", + "kind": "quick", + "prompt": "Jeremy Howard proposed /llms.txt in September 2024. What's inside it?", + "options": ["Crawl rules in robots syntax", "A Markdown guide to the site", "A JSON list of API keys", "Signed bot public keys"], + "answer": 1, + "explain": "llms.txt is a Markdown file with a short summary and links to the pages worth reading, so a model finds the good stuff fast. It guides models; it doesn't set rules the way robots.txt does.", + "source": "https://llmstxt.org/", + "checked": "2026-10-30" + }, + { + "id": "o-user-agent-fakeable", + "kind": "quick", + "prompt": "A request says \"User-Agent: ChatGPT-User\". Why can't a site trust that alone?", + "options": ["It expires after an hour", "Browsers strip it out", "Anyone can type any value", "Only Google can read it"], + "answer": 2, + "explain": "A user-agent string is a self-written label, so a scraper can copy it word for word. IP checks break easily too, which is why Web Bot Auth adds cryptographic signatures.", + "standard": "web-bot-auth", + "source": "https://ax.guide/standards/web-bot-auth", + "checked": "2026-10-30" + }, + { + "id": "o-jwks-contents", + "kind": "quick", + "prompt": "Sign-in servers publish a JWKS so others can check their signatures. What's in a JWKS?", + "options": ["A set of public keys", "A list of allowed bots", "Encrypted user passwords", "Signed access tokens"], + "answer": 0, + "explain": "A JWK Set (RFC 7517) is a JSON object with a \"keys\" array of public keys. Sharing public keys is safe; the private keys stay with the owner and do the signing.", + "source": "https://www.rfc-editor.org/rfc/rfc7517.html", + "checked": "2026-10-30" + }, + { + "id": "o-usdc-issuer", + "kind": "quick", + "prompt": "x402 payments usually move USDC, a dollar stablecoin. Which company issues USDC?", + "options": ["Coinbase", "Tether", "Stripe", "Circle"], + "answer": 3, + "explain": "Circle issues USDC, a token designed to stay worth one US dollar. Coinbase, which created x402, is a major USDC partner, hence the common mix-up.", + "source": "https://www.circle.com/usdc", + "checked": "2026-10-30" + }, + { + "id": "o-rfc9421-fake-header", + "kind": "fake", + "prompt": "Spot the fake: which header is NOT defined by RFC 9421, HTTP Message Signatures?", + "options": ["Signature", "Signature-Input", "Accept-Signature", "Signature-Owner"], + "answer": 3, + "explain": "RFC 9421 defines Signature-Input (what was signed and how), Signature (the signature itself) and Accept-Signature (a server asking for one). Web Bot Auth builds on these.", + "source": "https://www.rfc-editor.org/rfc/rfc9421.html", + "checked": "2026-10-30" + }, + { + "id": "o-cors-fake-header", + "kind": "fake", + "prompt": "Spot the fake CORS header: which one doesn't exist?", + "options": ["Access-Control-Allow-Agents", "Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Max-Age"], + "answer": 0, + "explain": "CORS headers tell browsers which other sites' scripts may read a response. There's no agent switch: CORS is about browser origins, not bots, so bot identity needs something like Web Bot Auth.", + "source": "https://fetch.spec.whatwg.org/#http-cors-protocol", + "checked": "2026-10-30" + }, + { + "id": "o-oauth-fake-well-known", + "kind": "fake", + "prompt": "Spot the fake: three of these OAuth discovery files are registered under /.well-known/. Which is invented?", + "options": ["openid-configuration", "oauth-agent-login", "oauth-authorization-server", "oauth-protected-resource"], + "answer": 1, + "explain": "openid-configuration and oauth-authorization-server describe a sign-in server; oauth-protected-resource (RFC 9728) tells a client which server guards an API. auth.md and MCP lean on these same files.", + "source": "https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml", + "checked": "2026-10-30" + }, + { + "id": "o-http-fake-status", + "kind": "fake", + "prompt": "Spot the fake HTTP status code: which one isn't in the official registry?", + "options": ["402 Payment Required", "451 Unavailable For Legal Reasons", "432 Bot Not Verified", "425 Too Early"], + "answer": 2, + "explain": "402, 425 (RFC 8470) and 451 (RFC 7725, a nod to Fahrenheit 451) are all registered. There's no status code for unverified bots; that job falls to signatures, not a new number.", + "source": "https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml", + "checked": "2026-10-30" + } +] diff --git a/content/site.json b/content/site.json index 045ff06..bb9da5a 100644 --- a/content/site.json +++ b/content/site.json @@ -27,6 +27,10 @@ "label": "Which one?", "href": "/which" }, + { + "label": "Quiz", + "href": "/play" + }, { "label": "Contribute", "href": "/contribute" diff --git a/lib/og-quiz.tsx b/lib/og-quiz.tsx new file mode 100644 index 0000000..0dacd89 --- /dev/null +++ b/lib/og-quiz.tsx @@ -0,0 +1,48 @@ +import { BRAND } from "./brand"; +import { OgBody, ogImage } from "./og"; + +/** A quiz result's share image: the score, the grid, and a dare. */ +export function quizShareImage({ + kicker, + score, + correct, + total, + results, +}: { + kicker: string; + score: number; + correct: number; + total: number; + results: boolean[]; +}) { + const square = total > 12 ? 44 : 60; + return ogImage( + +
{kicker}
+
+ {score.toLocaleString("en-US")} + PTS +
+
+ {correct} of {total} right on the agentic web quiz +
+
+ {results.map((right, i) => ( +
+ ))} +
+
+ Can you beat it? + ax.guide/play +
+ , + ); +} diff --git a/lib/play-route.ts b/lib/play-route.ts new file mode 100644 index 0000000..60326f6 --- /dev/null +++ b/lib/play-route.ts @@ -0,0 +1,24 @@ +import "server-only"; +import { NextResponse } from "next/server"; +import { PlayError } from "./quiz-expert"; +import { currentUserId } from "./supabase-server"; + +/** No quiz response is ever cached: each one belongs to one player at one moment. */ +const NO_STORE = { "Cache-Control": "private, no-store" }; + +export function json(body: unknown, status = 200): NextResponse { + return NextResponse.json(body, { status, headers: NO_STORE }); +} + +/** Run a quiz handler for the signed-in player, turning PlayErrors into JSON errors. */ +export async function withPlayer(handler: (userId: string) => Promise): Promise { + try { + const userId = await currentUserId(); + if (!userId) return json({ error: "Sign in to play an expert run." }, 401); + return json(await handler(userId)); + } catch (error) { + if (error instanceof PlayError) return json({ error: error.message }, error.status); + console.error(error); + return json({ error: "Something went wrong. Try again." }, 500); + } +} diff --git a/lib/quiz-expert.ts b/lib/quiz-expert.ts new file mode 100644 index 0000000..b8c6b2b --- /dev/null +++ b/lib/quiz-expert.ts @@ -0,0 +1,273 @@ +import "server-only"; +import { z } from "zod"; +import { + CARD_SECONDS, + type CardResult, + cardPoints, + type DealtCard, + dealCard, + LATENCY_GRACE_MS, + learnMore, + optionOrder, + pickCards, + RUN_CAPS, + RUN_LENGTH, +} from "./quiz"; +import { type QuizQuestion, quizQuestion } from "./quiz-schema"; +import { getStandard } from "./standards"; +import { adminClient } from "./supabase-server"; + +/* + * Expert runs, dealt and scored on the server so the leaderboard can trust + * them. A run moves one way: deal a card (its clock starts), answer it (the + * clock stops, the answer is revealed), deal the next. Every update is + * guarded by the run's position and phase, so a card can't be answered twice + * or have its clock restarted. + */ + +/** Runs a player may start in a day: plenty to improve, too few to farm the deck in an afternoon. */ +const RUNS_PER_DAY = 20; + +const runCards = z.array(z.strictObject({ id: z.string(), order: z.array(z.int()) })); + +interface RunRow { + id: string; + user_id: string; + cards: unknown; + position: number; + served_at: string | null; + score: number; + correct: number; + streak: number; + results: string; + finished_at: string | null; +} + +export class PlayError extends Error { + constructor( + readonly status: 400 | 401 | 404 | 409 | 429 | 503, + message: string, + ) { + super(message); + } +} + +function db() { + const client = adminClient(); + if (!client) throw new PlayError(503, "Expert runs are not switched on here yet."); + return client; +} + +/** Active expert cards, checked against the schema; a broken row is skipped, not dealt. */ +async function loadQuestions(ids?: string[]): Promise> { + let query = db().from("quiz_questions").select("id, data").eq("active", true); + if (ids) query = query.in("id", ids); + const { data, error } = await query; + if (error) throw new PlayError(503, "The expert deck could not be read."); + const out = new Map(); + for (const row of data ?? []) { + const parsed = quizQuestion.safeParse(row.data); + if (parsed.success && parsed.data.id === row.id) out.set(row.id, parsed.data); + else console.error(`quiz_questions row "${row.id}" is not a valid card; skipped`); + } + return out; +} + +export interface RunState { + runId: string; + total: number; + position: number; + card: DealtCard; +} + +export interface RunSummary { + runId: string; + score: number; + correct: number; + total: number; + results: boolean[]; + rank: number; + players: number; +} + +export async function startRun(userId: string): Promise { + const since = new Date(Date.now() - 86_400_000).toISOString(); + const { count } = await db() + .from("quiz_runs") + .select("id", { count: "exact", head: true }) + .eq("user_id", userId) + .gte("started_at", since); + if ((count ?? 0) >= RUNS_PER_DAY) throw new PlayError(429, `That's ${RUNS_PER_DAY} expert runs today. Come back tomorrow.`); + + const deck = [...(await loadQuestions()).values()]; + if (deck.length < RUN_LENGTH.expert) throw new PlayError(503, "The expert deck isn't loaded yet."); + const picked = pickCards(deck, RUN_LENGTH.expert, RUN_CAPS.expert); + const cards = picked.map((q) => ({ id: q.id, order: optionOrder(q) })); + + const { data, error } = await db() + .from("quiz_runs") + .insert({ user_id: userId, cards, served_at: new Date().toISOString() }) + .select("id") + .single(); + if (error || !data) throw new PlayError(503, "The run could not be started."); + const first = picked[0] as QuizQuestion; + return { runId: data.id, total: cards.length, position: 0, card: dealCard(first, cards[0]?.order ?? []) }; +} + +async function loadRun(userId: string, runId: string): Promise }> { + if (!z.uuid().safeParse(runId).success) throw new PlayError(404, "No such run."); + const { data } = await db().from("quiz_runs").select("*").eq("id", runId).eq("user_id", userId).maybeSingle(); + if (!data) throw new PlayError(404, "No such run."); + return { ...data, cardList: runCards.parse(data.cards) }; +} + +/** Deal the card at `position`, after the one before it was answered. */ +export async function nextCard(userId: string, runId: string, position: number): Promise { + const run = await loadRun(userId, runId); + const slot = run.cardList[position]; + if (run.finished_at || run.served_at || run.position !== position || !slot) { + throw new PlayError(409, "That card isn't next."); + } + const question = (await loadQuestions([slot.id])).get(slot.id); + if (!question) throw new PlayError(503, "A card of this run is no longer in the deck."); + const { data } = await db() + .from("quiz_runs") + .update({ served_at: new Date().toISOString() }) + .eq("id", runId) + .eq("position", position) + .is("served_at", null) + .select("id"); + if (!data?.length) throw new PlayError(409, "That card isn't next."); + return { runId, total: run.cardList.length, position, card: dealCard(question, slot.order) }; +} + +/** + * Answer the open card. `choice` is the option's index as shown, or null when + * the clock ran out. The server's own clock decides whether it was in time. + */ +export async function answerCard( + userId: string, + runId: string, + position: number, + choice: number | null, +): Promise<{ result: CardResult; summary?: RunSummary }> { + const run = await loadRun(userId, runId); + const slot = run.cardList[position]; + if (run.finished_at || !run.served_at || run.position !== position || !slot) { + throw new PlayError(409, "That card isn't open."); + } + const question = (await loadQuestions([slot.id])).get(slot.id); + if (!question) throw new PlayError(503, "A card of this run is no longer in the deck."); + + const ms = Date.now() - Date.parse(run.served_at); + const inTime = ms <= CARD_SECONDS * 1000 + LATENCY_GRACE_MS; + const shownAnswer = slot.order.indexOf(question.answer); + const correct = inTime && choice === shownAnswer; + const streak = correct ? run.streak + 1 : 0; + const points = cardPoints(correct, ms, streak); + const done = position + 1 === run.cardList.length; + const now = new Date().toISOString(); + + const update = { + position: position + 1, + served_at: null, + score: run.score + points, + correct: run.correct + (correct ? 1 : 0), + streak, + results: run.results + (correct ? "1" : "0"), + finished_at: done ? now : null, + }; + const { data } = await db() + .from("quiz_runs") + .update(update) + .eq("id", runId) + .eq("position", position) + .not("served_at", "is", null) + .select("id"); + if (!data?.length) throw new PlayError(409, "That card was already answered."); + + const result: CardResult = { + correct, + answer: shownAnswer, + explain: question.explain, + learnMore: learnMore(question, question.standard ? getStandard(question.standard)?.name : undefined), + points, + score: update.score, + streak, + }; + if (!done) return { result }; + const { rank, players } = await rankOf(update.score); + return { + result, + summary: { + runId, + score: update.score, + correct: update.correct, + total: run.cardList.length, + results: [...update.results].map((c) => c === "1"), + rank, + players, + }, + }; +} + +/** Where a score places among every player's best. */ +async function rankOf(score: number): Promise<{ rank: number; players: number }> { + const { data } = await db().rpc("quiz_rank", { for_score: score }).maybeSingle<{ rank: number; players: number }>(); + return { rank: Number(data?.rank ?? 1), players: Number(data?.players ?? 1) }; +} + +export interface LeaderboardRow { + rank: number; + name: string; + score: number; + correct: number; + total: number; + runId: string; +} + +/** Each player's best expert run, all time or in the last seven days. */ +export async function leaderboard(period: "all" | "week", limit = 50): Promise { + const client = adminClient(); + if (!client) return undefined; + const since = period === "week" ? new Date(Date.now() - 7 * 86_400_000).toISOString() : "-infinity"; + const { data, error } = await client.rpc("quiz_leaderboard", { since, max_rows: limit }); + if (error) return undefined; + return (data as { rank: number; name: string; score: number; correct: number; total: number; run_id: string }[]).map((r) => ({ + rank: Number(r.rank), + name: r.name, + score: r.score, + correct: r.correct, + total: r.total, + runId: r.run_id, + })); +} + +/** A finished run, for its share page. Public: name, score and the grid only. */ +export async function finishedRun(runId: string): Promise< + { name: string; score: number; correct: number; total: number; results: boolean[]; finishedAt: string } | undefined +> { + const client = adminClient(); + if (!client || !z.uuid().safeParse(runId).success) return undefined; + const { data } = await client + .from("quiz_runs") + .select("score, correct, results, finished_at, user_id") + .eq("id", runId) + .not("finished_at", "is", null) + .maybeSingle<{ score: number; correct: number; results: string; finished_at: string; user_id: string }>(); + if (!data) return undefined; + const { data: profile } = await client.from("profiles").select("name").eq("id", data.user_id).maybeSingle<{ name: string }>(); + return { + name: profile?.name ?? "A player", + score: data.score, + correct: data.correct, + total: data.results.length, + results: [...data.results].map((c) => c === "1"), + finishedAt: data.finished_at, + }; +} + +export async function playerName(userId: string): Promise { + const { data } = await db().from("profiles").select("name").eq("id", userId).maybeSingle<{ name: string }>(); + return data?.name; +} diff --git a/lib/quiz-open.ts b/lib/quiz-open.ts new file mode 100644 index 0000000..bc91a26 --- /dev/null +++ b/lib/quiz-open.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import openJson from "@/content/quiz/open.json"; +import { statusLabel } from "./content"; +import { type QuizQuestion, quizDeck } from "./quiz-schema"; +import { STATUS_IDS } from "./schema"; +import { getStandards, type Standard } from "./standards"; + +/** The status options of a "Real or hype?" card, in map legend order. */ +const STATUS_OPTIONS = STATUS_IDS.map((status) => statusLabel(status)); + +/** Markdown links and code to plain text, for a card's explanation. */ +function plain(markdown: string): string { + return markdown.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/`([^`]+)`/g, "$1"); +} + +/** The reality check's opening, which states the status and why: up to two sentences that fit a card. */ +function realityOpening(markdown: string): string { + const sentences = plain(markdown).match(/[^.!?]+[.!?]+(?=\s|$)/g) ?? [markdown]; + let out = sentences[0]?.trim() ?? ""; + const second = sentences[1]?.trim(); + if (second && out.length + second.length + 1 <= 320) out += ` ${second}`; + return out.slice(0, 320); +} + +/** + * One "Real or hype?" card per standard, written from its page: the answer is + * its status today, so the card changes the day the page does and never goes + * stale on its own. + */ +export function statusCard(s: Standard): QuizQuestion { + return { + id: `o-status-${s.id}`, + kind: "real", + prompt: `${s.full_name ? `${s.name} (${s.full_name})` : s.name}: ${s.phrase.charAt(0).toLowerCase()}${s.phrase.slice(1)}. How real is it today?`, + options: STATUS_OPTIONS, + answer: STATUS_IDS.indexOf(s.status), + explain: realityOpening(s.reality_check), + standard: s.id, + source: `https://ax.guide/standards/${s.id}`, + checked: s.last_verified, + }; +} + +/** The open deck: content/quiz/open.json plus a status card per standard. */ +export function getOpenDeck(): QuizQuestion[] { + return [...openCards(), ...getStandards().map(statusCard)]; +} + +let cache: QuizQuestion[] | undefined; + +/** content/quiz/open.json, checked. */ +export function openCards(): QuizQuestion[] { + if (cache) return cache; + const result = quizDeck.safeParse(openJson); + if (!result.success) throw new Error(`content/quiz/open.json is not valid:\n${z.prettifyError(result.error)}`); + cache = result.data; + return cache; +} diff --git a/lib/quiz-schema.ts b/lib/quiz-schema.ts new file mode 100644 index 0000000..154765b --- /dev/null +++ b/lib/quiz-schema.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { STANDARD_ID } from "./schema"; + +/** + * The kinds of card, each with its own label on the card: + * quick a plain question + * which "Which standard am I?": a description, a file or a header; name the standard + * fake "Spot the fake": one option is made up; `answer` points at it + * real "Real or hype?": how real something is today + */ +export const QUIZ_KINDS = ["quick", "which", "fake", "real"] as const; +export type QuizKind = (typeof QUIZ_KINDS)[number]; + +/** A question id: lowercase words and hyphens, unique across both decks. */ +export const QUESTION_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +const text = z.string().trim().min(1); + +/** + * One card. Options are shuffled when the card is dealt, so no option may + * depend on its position ("both of the above"). + */ +export const quizQuestion = z + .strictObject({ + id: z.string().regex(QUESTION_ID, "lowercase words joined by hyphens"), + kind: z.enum(QUIZ_KINDS), + /** The question, short enough to read on a phone in a few seconds. */ + prompt: text.max(220), + /** Two to four answers, roughly the same length so the longest isn't a tell. */ + options: z.array(text.max(90)).min(2).max(4), + /** Index into `options` of the right answer (for `fake`, of the made-up one). */ + answer: z.int().min(0), + /** Shown after answering: why the answer is right, in one or two sentences. */ + explain: text.max(320), + /** The ax.guide standard the card is about, linked as "Read more". */ + standard: z.string().regex(STANDARD_ID).optional(), + /** The primary source for the fact the card tests. */ + source: z.url({ protocol: /^https$/ }), + /** The day someone last checked the card against its source (YYYY-MM-DD). */ + checked: z.iso.date(), + }) + .refine((q) => q.answer < q.options.length, { message: "answer must point at one of the options", path: ["answer"] }) + .refine((q) => new Set(q.options.map((o) => o.toLowerCase())).size === q.options.length, { + message: "options must differ", + path: ["options"], + }); + +export type QuizQuestion = z.infer; + +export const quizDeck = z.array(quizQuestion).min(1); diff --git a/lib/quiz.ts b/lib/quiz.ts new file mode 100644 index 0000000..c0c8402 --- /dev/null +++ b/lib/quiz.ts @@ -0,0 +1,186 @@ +import type { QuizKind, QuizQuestion } from "./quiz-schema"; + +/* + * The game's rules, shared by the browser (open runs) and the server + * (expert runs), so both score a card the same way. + */ + +/** Cards in a run of each deck. */ +export const RUN_LENGTH = { open: 12, expert: 18 } as const; +export type Deck = keyof typeof RUN_LENGTH; + +/** Seconds a card stays open. Running out counts as a wrong answer. */ +export const CARD_SECONDS = 20; +/** Answers inside this many seconds earn the whole speed bonus. */ +const FULL_BONUS_SECONDS = 3; +/** Extra time the server allows for the network before it calls an answer late. */ +export const LATENCY_GRACE_MS = 2_000; + +export const POINTS = { correct: 100, speed: 50, streakStep: 10, streakMax: 50 } as const; + +/** + * Points for one card. A right answer earns 100, up to 50 more for speed + * (all of it inside 3 seconds, none at 20), and 10 more for each right answer + * before it in an unbroken streak, up to 50. A wrong answer earns nothing. + */ +export function cardPoints(correct: boolean, ms: number, streak: number): number { + if (!correct) return 0; + const seconds = Math.max(0, ms) / 1000; + const speed = Math.min(1, Math.max(0, (CARD_SECONDS - seconds) / (CARD_SECONDS - FULL_BONUS_SECONDS))); + const streakBonus = Math.min(POINTS.streakMax, POINTS.streakStep * Math.max(0, streak - 1)); + return POINTS.correct + Math.round(POINTS.speed * speed) + streakBonus; +} + +/** The most a run can score, for the share code's sanity check. */ +export function maxScore(cards: number): number { + let total = 0; + for (let i = 1; i <= cards; i++) total += cardPoints(true, 0, i); + return total; +} + +/** What a player sees of a card: no answer, options in dealt order. */ +export interface DealtCard { + id: string; + kind: QuizKind; + prompt: string; + options: string[]; +} + +/** What a player learns after answering. */ +export interface CardResult { + correct: boolean; + /** The right option, in dealt order. */ + answer: number; + explain: string; + /** "Read more": the standard's page, or the card's source. */ + learnMore: { href: string; label: string }; + points: number; + score: number; + streak: number; +} + +export const KIND_LABELS: Record = { + quick: "Quick check", + which: "Which standard am I?", + fake: "Spot the fake", + real: "Real or hype?", +}; + +/** A random integer in [0, n), from the platform's secure source on both sides. */ +function randomBelow(n: number): number { + const buf = new Uint32Array(1); + crypto.getRandomValues(buf); + return (buf[0] ?? 0) % n; +} + +/** Fisher-Yates, on a copy. */ +export function shuffle(list: readonly T[]): T[] { + const out = [...list]; + for (let i = out.length - 1; i > 0; i--) { + const j = randomBelow(i + 1); + [out[i], out[j]] = [out[j] as T, out[i] as T]; + } + return out; +} + +/** + * Pick a run's cards at random, with at most `caps[group]` from a group (by + * default a card's kind), so a run never turns into twelve of one kind. When + * the caps leave too few cards, the rest are filled from what was skipped. + */ +export function pickCards( + deck: readonly T[], + count: number, + caps: Record = {}, + groupOf: (card: T) => string = (card) => card.kind, +): T[] { + const picked: T[] = []; + const skipped: T[] = []; + const perGroup = new Map(); + for (const card of shuffle(deck)) { + if (picked.length === count) break; + const group = groupOf(card); + const used = perGroup.get(group) ?? 0; + if (used >= (caps[group] ?? Infinity)) { + skipped.push(card); + continue; + } + perGroup.set(group, used + 1); + picked.push(card); + } + return [...picked, ...skipped].slice(0, count); +} + +/** Caps per run, by group: a status card is its own group in the open deck. */ +export const RUN_CAPS = { + open: { status: 2, real: 2, which: 4, fake: 3 }, + expert: { real: 3, which: 5, fake: 4 }, +} as const; + +/** The group of an open card for RUN_CAPS: status cards are capped on their own. */ +export function openGroup(card: Pick): string { + return card.id.startsWith("o-status-") ? "status" : card.kind; +} + +/** + * The order to show a card's options in: order[shownIndex] = index in the + * question. Shuffled, except where the written order is the natural one: a + * scale ("Real or hype?") or True and False. + */ +export function optionOrder(question: Pick): number[] { + const indexes = question.options.map((_, i) => i); + const trueFalse = question.options.length === 2 && question.options.every((o) => /^(true|false)$/i.test(o)); + return question.kind === "real" || trueFalse ? indexes : shuffle(indexes); +} + +export function dealCard(question: QuizQuestion, order: number[]): DealtCard { + return { + id: question.id, + kind: question.kind, + prompt: question.prompt, + options: order.map((i) => question.options[i] ?? ""), + }; +} + +export function learnMore(question: Pick, standardName?: string): CardResult["learnMore"] { + return question.standard + ? { href: `/standards/${question.standard}`, label: `Read about ${standardName ?? question.standard} on ax.guide` } + : { href: question.source, label: `Read the source: ${new URL(question.source).hostname.replace(/^www\./, "")}` }; +} + +/** + * The share code of an open run, "1840-110111011101": the score, then a 1 or + * 0 per card. It is not proof of anything (open runs are played in the + * browser); expert runs are shared by their run id instead. + */ +export function encodeShare(score: number, results: boolean[]): string { + return `${score}-${results.map((r) => (r ? "1" : "0")).join("")}`; +} + +export function decodeShare(code: string): { score: number; results: boolean[] } | undefined { + const match = /^(\d{1,5})-([01]{1,24})$/.exec(code); + if (!match) return undefined; + const score = Number(match[1]); + const results = [...(match[2] ?? "")].map((c) => c === "1"); + return score <= maxScore(results.length) ? { score, results } : undefined; +} + +/** The emoji row of a share: orange for right, black for wrong. */ +export function shareGrid(results: boolean[]): string { + return results.map((r) => (r ? "🟧" : "⬛")).join(""); +} + +/** A verdict line for a share of right answers, so a score reads like a result. */ +export function verdict(correct: number, total: number): string { + const share = total ? correct / total : 0; + if (share === 1) return "Flawless. You could maintain this guide."; + if (share >= 0.83) return "Agent-ready. You know the map."; + if (share >= 0.6) return "Solid. A couple of standards to brush up on."; + if (share >= 0.4) return "Getting there. The map has a few surprises left."; + return "Plenty to learn. Good news: that's what the guide is for."; +} + +/** A same-site path to return to after signing in; anything else goes to /play. */ +export function safeNext(value: string | null): string { + return value && /^\/(?!\/)[\w\-./?=&%]*$/.test(value) ? value : "/play"; +} diff --git a/lib/supabase-env.ts b/lib/supabase-env.ts new file mode 100644 index 0000000..77eda32 --- /dev/null +++ b/lib/supabase-env.ts @@ -0,0 +1,10 @@ +/** + * Supabase's public settings. Next.js inlines NEXT_PUBLIC_ variables into the + * browser bundle at build time. Without them the site still builds and the + * open game still plays; sign-in, expert runs and the leaderboard stay off. + */ +export function supabasePublicEnv(): { url: string; anonKey: string } | undefined { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + return url && anonKey ? { url, anonKey } : undefined; +} diff --git a/lib/supabase-server.ts b/lib/supabase-server.ts new file mode 100644 index 0000000..41140ba --- /dev/null +++ b/lib/supabase-server.ts @@ -0,0 +1,69 @@ +import "server-only"; +import { createServerClient } from "@supabase/ssr"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { cookies } from "next/headers"; +import { supabasePublicEnv } from "./supabase-env"; + +/** True when sign-in, expert runs and the leaderboard can work. */ +export function playBackendEnabled(): boolean { + return supabasePublicEnv() !== undefined && Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY); +} + +/** + * The visitor's session, from their cookies. In a route handler it also + * writes refreshed tokens back; in a server component, where cookies are + * read-only, the browser client refreshes them instead. + */ +async function sessionClient(): Promise { + const env = supabasePublicEnv(); + if (!env) return undefined; + const store = await cookies(); + return createServerClient(env.url, env.anonKey, { + cookies: { + getAll: () => store.getAll(), + setAll: (list) => { + try { + for (const { name, value, options } of list) store.set(name, value, options); + } catch { + // Server components can't set cookies; route handlers can. + } + }, + }, + }); +} + +/** The signed-in user's id, checked with Supabase Auth, or undefined. */ +export async function currentUserId(): Promise { + const client = await sessionClient(); + if (!client) return undefined; + const { data } = await client.auth.getUser(); + return data.user?.id; +} + +/** Trade the code from a sign-in link or OAuth redirect for a session cookie. */ +export async function exchangeAuthCode(code: string): Promise { + const client = await sessionClient(); + if (!client) return false; + const { error } = await client.auth.exchangeCodeForSession(code); + return !error; +} + +export async function signOut(): Promise { + const client = await sessionClient(); + await client?.auth.signOut(); +} + +let admin: SupabaseClient | undefined; + +/** + * The server's own client, with the service role key. Every quiz table has + * row level security and no policies, so only this client can read them. + * Never send its key or its results unfiltered to a browser. + */ +export function adminClient(): SupabaseClient | undefined { + const env = supabasePublicEnv(); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!env || !key) return undefined; + admin ??= createClient(env.url, key, { auth: { persistSession: false, autoRefreshToken: false } }); + return admin; +} diff --git a/next.config.ts b/next.config.ts index a2056ac..bdcfb6e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,6 +11,12 @@ const nextConfig: NextConfig = { poweredByHeader: false, // Do not let `next dev` write AGENTS.md and CLAUDE.md into the repo. agentRules: false, + // The quiz's server routes read standard names and the share image font + // from disk at request time, so ship those files with them. + outputFileTracingIncludes: { + "/api/play/**": ["./content/standards/**"], + "/play/**": ["./content/standards/**", "./assets/fonts/**"], + }, async headers() { return [{ source: "/:path*", headers: SECURITY_HEADERS }]; }, diff --git a/package-lock.json b/package-lock.json index a9c3b21..615ed45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@supabase/ssr": "0.12.7", + "@supabase/supabase-js": "2.118.0", "marked": "18.1.0", "next": "16.3.8", "react": "19.3.0", @@ -1721,6 +1723,110 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.118.0.tgz", + "integrity": "sha512-xWfPXyhS2KDSfAneMtI6p/tBnbdiz/5vaRKXEqUfzfC9WCniKr2Ds5o5G7RIGig0bom4z6wUY9zaV07WDVTfnQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.118.0.tgz", + "integrity": "sha512-4ofletPa/noKAU6KHeqsd3FztGzRPUEJRey1MKY0o+PIHaVePqRPShIXtl37gcVyIWaFM+zNg6r9zh0uUSFOSA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.118.0.tgz", + "integrity": "sha512-JXy+37KPApHsyotaQACovv/Jy9E22rfiWLRuT+2UjoEAcAIelDFIrsF/FY47Dy19mO7lI0Tq+zDCX8oJ2Ozfiw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.118.0.tgz", + "integrity": "sha512-pzCIEWjqD5/xqEZQerpt+q9rsgYkcrkgCJgf0W2ULqC4fYUeB/p9CFa3t9vn+IVh6dMN9r4ZCE262lZvUC+Bfw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.7.tgz", + "integrity": "sha512-wiBtEie1KkRJi9RrZWY3R2imRhX1JY7qMyUCH2z9AUk15gQebNEplM+urbCKamdxaTJLXUU6LlpkJsaxhojCEg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.114.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.118.0.tgz", + "integrity": "sha512-5MNkKt2Moy7Jud/sPX7/28BwdDxZMCVHGJT00b8op3KRa9LKSjOAYU5IcfGWIstaasAqY8GkbA6oI3IRDgB1YQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.118.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.118.0.tgz", + "integrity": "sha512-FxI8UXMVC2UG7IkeiI8fuQZqq2oI3JeSuN8MPIC2RLVaoEAxKS0nqE6kii9Ewpq8f9gNPM6git1Y8f0vN/QLFw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.118.0", + "@supabase/functions-js": "2.118.0", + "@supabase/postgrest-js": "2.118.0", + "@supabase/realtime-js": "2.118.0", + "@supabase/storage-js": "2.118.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -2950,6 +3056,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4345,6 +4464,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index ff82130..15a655d 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,12 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "readme": "UPDATE_README=1 vitest run tests/readme.test.ts", - "pins": "UPDATE_PINS=1 vitest run tests/spec-pins.test.ts" + "pins": "UPDATE_PINS=1 vitest run tests/spec-pins.test.ts", + "quiz:sql": "UPDATE_QUIZ_SQL=1 vitest run tests/quiz.test.ts" }, "dependencies": { + "@supabase/ssr": "0.12.7", + "@supabase/supabase-js": "2.118.0", "marked": "18.1.0", "next": "16.3.8", "react": "19.3.0", diff --git a/supabase/migrations/20261030000000_quiz.sql b/supabase/migrations/20261030000000_quiz.sql new file mode 100644 index 0000000..d8a638b --- /dev/null +++ b/supabase/migrations/20261030000000_quiz.sql @@ -0,0 +1,130 @@ +-- The ax.guide quiz (/play): signed-in players, the expert deck, runs and the +-- leaderboard. Open runs never reach the database; they are played in the +-- browser from content/quiz/open.json. +-- +-- Every table has row level security on and no policies: only the site's +-- server, with the service role key, reads or writes them. The browser only +-- ever talks to Supabase Auth. + +-- A player's public name. Filled when they first sign in: a GitHub handle, +-- or a first name and an initial ("Ada L."), or "player-1a2b3c". Never an +-- email address. +create table public.profiles ( + id uuid primary key references auth.users (id) on delete cascade, + name text not null check (char_length(name) between 1 and 40), + created_at timestamptz not null default now() +); +alter table public.profiles enable row level security; + +create or replace function public.quiz_handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + meta jsonb := coalesce(new.raw_user_meta_data, '{}'::jsonb); + full_name text := nullif(trim(coalesce(meta ->> 'full_name', meta ->> 'name', '')), ''); + first_name text := split_part(full_name, ' ', 1); + last_name text := nullif(split_part(full_name, ' ', 2), ''); +begin + insert into public.profiles (id, name) + values ( + new.id, + left(coalesce( + nullif(meta ->> 'user_name', ''), + nullif(first_name || coalesce(' ' || left(last_name, 1) || '.', ''), ''), + 'player-' || left(replace(new.id::text, '-', ''), 6) + ), 40) + ) + on conflict (id) do nothing; + return new; +end; +$$; + +create trigger quiz_on_auth_user_created + after insert on auth.users + for each row execute function public.quiz_handle_new_user(); + +-- The expert deck. `data` is one card in the shape of lib/quiz-schema.ts; +-- load it with the SQL that `npm run quiz:sql` writes. Kept here, not in the +-- public repository, so ranked players can't look the answers up. +create table public.quiz_questions ( + id text primary key, + data jsonb not null, + active boolean not null default true, + updated_at timestamptz not null default now() +); +alter table public.quiz_questions enable row level security; + +-- One expert run. `cards` holds each card's id and the order its options were +-- shown in. While a card is open, `served_at` says when it was dealt; it is +-- null between an answer and the next card, so reading an explanation never +-- counts against the clock. `results` is a 1 or 0 per answered card. +create table public.quiz_runs ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + cards jsonb not null, + position int not null default 0, + served_at timestamptz, + score int not null default 0, + correct int not null default 0, + streak int not null default 0, + results text not null default '', + started_at timestamptz not null default now(), + finished_at timestamptz +); +alter table public.quiz_runs enable row level security; +create index quiz_runs_user_started on public.quiz_runs (user_id, started_at desc); +create index quiz_runs_finished_score on public.quiz_runs (finished_at, score desc) where finished_at is not null; + +-- Each player's best finished run since a moment, ranked. Equal scores share +-- a rank; the earlier run is listed first. +create or replace function public.quiz_leaderboard(since timestamptz default '-infinity', max_rows int default 50) +returns table ( + rank bigint, + name text, + score int, + correct int, + total int, + run_id uuid, + finished_at timestamptz +) +language sql +stable +set search_path = '' +as $$ + with best as ( + select distinct on (r.user_id) r.user_id, r.id, r.score, r.correct, length(r.results) as total, r.finished_at + from public.quiz_runs r + where r.finished_at is not null and r.finished_at >= since + order by r.user_id, r.score desc, r.finished_at asc + ) + select rank() over (order by b.score desc), p.name, b.score, b.correct, b.total, b.id, b.finished_at + from best b + join public.profiles p on p.id = b.user_id + order by b.score desc, b.finished_at asc + limit max_rows; +$$; + +-- Where a score would place among players' best scores since a moment, and +-- how many players there are. +create or replace function public.quiz_rank(for_score int, since timestamptz default '-infinity') +returns table (rank bigint, players bigint) +language sql +stable +set search_path = '' +as $$ + with best as ( + select max(r.score) as score + from public.quiz_runs r + where r.finished_at is not null and r.finished_at >= since + group by r.user_id + ) + select count(*) filter (where best.score > for_score) + 1, count(*) + from best; +$$; + +revoke all on function public.quiz_leaderboard(timestamptz, int) from public, anon, authenticated; +revoke all on function public.quiz_rank(int, timestamptz) from public, anon, authenticated; +revoke all on function public.quiz_handle_new_user() from public, anon, authenticated; diff --git a/tests/quiz-expert.test.ts b/tests/quiz-expert.test.ts new file mode 100644 index 0000000..d95263b --- /dev/null +++ b/tests/quiz-expert.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RUN_LENGTH } from "@/lib/quiz"; +import type { QuizQuestion } from "@/lib/quiz-schema"; + +/* + * Expert runs against an in-memory stand-in for the few Supabase calls + * lib/quiz-expert.ts makes, so the run's guards (one answer per card, no + * restarting the clock, late answers) are checked without a database. The + * SQL itself lives in supabase/migrations. + */ + +type Row = Record; +type Filter = (row: Row) => boolean; + +const tables: Record = { quiz_questions: [], quiz_runs: [], profiles: [] }; + +class Query implements PromiseLike<{ data: unknown; error: null; count?: number }> { + private filters: Filter[] = []; + private op: "select" | "insert" | "update" = "select"; + private payload: Row = {}; + private head = false; + private one: "single" | "maybe" | undefined; + + constructor(private readonly table: string) {} + + select(_columns?: string, options?: { head?: boolean }) { + this.head = options?.head ?? false; + return this; + } + insert(row: Row) { + this.op = "insert"; + this.payload = row; + return this; + } + update(values: Row) { + this.op = "update"; + this.payload = values; + return this; + } + eq(column: string, value: unknown) { + this.filters.push((row) => row[column] === value); + return this; + } + gte(column: string, value: string) { + this.filters.push((row) => String(row[column]) >= value); + return this; + } + in(column: string, values: unknown[]) { + this.filters.push((row) => values.includes(row[column])); + return this; + } + is(column: string, value: null) { + this.filters.push((row) => row[column] === value); + return this; + } + not(column: string, _op: "is", value: null) { + this.filters.push((row) => row[column] !== value); + return this; + } + single() { + this.one = "single"; + return this; + } + maybeSingle() { + this.one = "maybe"; + return this; + } + + private run(): { data: unknown; error: null; count?: number } { + const rows = tables[this.table] as Row[]; + const matching = rows.filter((row) => this.filters.every((f) => f(row))); + let data: Row[]; + if (this.op === "insert") { + const row = { id: crypto.randomUUID(), position: 0, score: 0, correct: 0, streak: 0, results: "", finished_at: null, started_at: new Date().toISOString(), ...this.payload }; + rows.push(row); + data = [row]; + } else if (this.op === "update") { + for (const row of matching) Object.assign(row, this.payload); + data = matching; + } else { + data = matching; + } + const copies = data.map((row) => structuredClone(row)); + if (this.head) return { data: null, error: null, count: matching.length }; + if (this.one) return { data: copies[0] ?? null, error: null }; + return { data: copies, error: null }; + } + + then(onFulfilled?: (value: { data: unknown; error: null; count?: number }) => A | PromiseLike, onRejected?: (reason: unknown) => B | PromiseLike) { + return Promise.resolve(this.run()).then(onFulfilled, onRejected); + } +} + +const fakeClient = { + from: (table: string) => new Query(table), + rpc: (name: string, args: { for_score: number }) => ({ + maybeSingle: async () => { + expect(name).toBe("quiz_rank"); + const best = new Map(); + for (const run of tables.quiz_runs ?? []) { + if (run.finished_at) best.set(run.user_id, Math.max(best.get(run.user_id) ?? 0, Number(run.score))); + } + const ahead = [...best.values()].filter((s) => s > args.for_score).length; + return { data: { rank: ahead + 1, players: best.size }, error: null }; + }, + }), +}; + +vi.mock("@/lib/supabase-server", () => ({ adminClient: () => fakeClient })); + +const { answerCard, nextCard, PlayError, startRun } = await import("@/lib/quiz-expert"); + +const ALICE = "00000000-0000-4000-8000-00000000000a"; +const BOB = "00000000-0000-4000-8000-00000000000b"; + +function question(i: number): QuizQuestion { + return { + id: `x-test-${i}`, + kind: "quick", + prompt: `Question ${i}?`, + options: ["right", "wrong one", "wrong two", "wrong three"], + answer: 0, + explain: "Because.", + source: "https://example.com/spec", + checked: "2026-10-30", + }; +} + +/** The option to press, as shown, for the card at `position` of a run. */ +function rightChoice(runId: string, position: number): number { + const run = tables.quiz_runs?.find((r) => r.id === runId) as { cards: { id: string; order: number[] }[] }; + const slot = run.cards[position] as { id: string; order: number[] }; + return slot.order.indexOf(0); +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + if (error instanceof PlayError) return error.status; + throw error; + } + return undefined; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-30T12:00:00Z")); + tables.quiz_runs = []; + tables.profiles = []; + tables.quiz_questions = Array.from({ length: 30 }, (_, i) => ({ id: `x-test-${i}`, data: question(i), active: true })); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("expert runs", () => { + it("deal cards without their answers", async () => { + const run = await startRun(ALICE); + expect(run.total).toBe(RUN_LENGTH.expert); + expect(run.position).toBe(0); + expect(Object.keys(run.card).sort()).toEqual(["id", "kind", "options", "prompt"]); + expect([...run.card.options].sort()).toEqual(["right", "wrong one", "wrong three", "wrong two"]); + }); + + it("score a fast right answer, then deal the next card only once", async () => { + const run = await startRun(ALICE); + vi.advanceTimersByTime(1500); + const { result } = await answerCard(ALICE, run.runId, 0, rightChoice(run.runId, 0)); + expect(result).toMatchObject({ correct: true, points: 150, score: 150, streak: 1 }); + expect(await rejection(answerCard(ALICE, run.runId, 0, 0))).toBe(409); + + await nextCard(ALICE, run.runId, 1); + // Asking again must not restart the card's clock. + expect(await rejection(nextCard(ALICE, run.runId, 1))).toBe(409); + }); + + it("call an answer late by the server's clock, whatever was chosen", async () => { + const run = await startRun(ALICE); + vi.advanceTimersByTime(23_000); + const { result } = await answerCard(ALICE, run.runId, 0, rightChoice(run.runId, 0)); + expect(result).toMatchObject({ correct: false, points: 0 }); + }); + + it("keep a run to its player", async () => { + const run = await startRun(ALICE); + expect(await rejection(answerCard(BOB, run.runId, 0, 0))).toBe(404); + expect(await rejection(answerCard(ALICE, "not-a-uuid", 0, 0))).toBe(404); + }); + + it("finish with a summary and a rank, then accept nothing more", async () => { + const run = await startRun(ALICE); + let summary; + for (let position = 0; position < run.total; position++) { + if (position > 0) await nextCard(ALICE, run.runId, position); + const choice = position % 3 === 0 ? (rightChoice(run.runId, position) + 1) % 4 : rightChoice(run.runId, position); + ({ summary } = await answerCard(ALICE, run.runId, position, choice)); + } + expect(summary).toMatchObject({ total: run.total, correct: 12, rank: 1, players: 1 }); + expect(summary?.results).toHaveLength(run.total); + expect(await rejection(nextCard(ALICE, run.runId, run.total))).toBe(409); + }); + + it("skip a broken card instead of dealing it", async () => { + tables.quiz_questions?.push({ id: "x-broken", data: { id: "x-broken", prompt: "?" }, active: true }); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const run = await startRun(ALICE); + const dealt = (tables.quiz_runs?.[0]?.cards as { id: string }[]).map((c) => c.id); + expect(dealt).not.toContain("x-broken"); + expect(run.card.id).not.toBe("x-broken"); + spy.mockRestore(); + }); + + it("limit runs a day", async () => { + for (let i = 0; i < 20; i++) await startRun(ALICE); + expect(await rejection(startRun(ALICE))).toBe(429); + expect(await rejection(startRun(BOB))).toBeUndefined(); + }); + + it("refuse to start without enough cards", async () => { + tables.quiz_questions = tables.quiz_questions?.slice(0, RUN_LENGTH.expert - 1) ?? []; + expect(await rejection(startRun(ALICE))).toBe(503); + }); +}); diff --git a/tests/quiz.test.ts b/tests/quiz.test.ts new file mode 100644 index 0000000..04734bc --- /dev/null +++ b/tests/quiz.test.ts @@ -0,0 +1,157 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + cardPoints, + decodeShare, + encodeShare, + maxScore, + openGroup, + optionOrder, + pickCards, + RUN_CAPS, + RUN_LENGTH, + safeNext, +} from "@/lib/quiz"; +import { getOpenDeck, openCards, statusCard } from "@/lib/quiz-open"; +import { type QuizQuestion, quizDeck } from "@/lib/quiz-schema"; +import { STATUS_IDS } from "@/lib/schema"; +import { getStandards } from "@/lib/standards"; + +const EM_DASH = String.fromCharCode(0x2014); +const standardIds = new Set(getStandards().map((s) => s.id)); + +/** The expert deck is kept out of the repository; check it only where it exists. */ +const EXPERT_PATH = join(process.cwd(), "private", "quiz-expert.json"); +const expert: QuizQuestion[] | undefined = existsSync(EXPERT_PATH) + ? quizDeck.parse(JSON.parse(readFileSync(EXPERT_PATH, "utf8"))) + : undefined; + +/** Problems that span a deck: duplicate ids, unknown standards, house style. */ +function deckProblems(deck: QuizQuestion[], prefix: string): string[] { + const problems: string[] = []; + const seen = new Set(); + for (const q of deck) { + if (seen.has(q.id)) problems.push(`${q.id} appears twice`); + seen.add(q.id); + if (!q.id.startsWith(prefix)) problems.push(`${q.id} should start with "${prefix}"`); + if (q.standard && !standardIds.has(q.standard)) problems.push(`${q.id} names a standard "${q.standard}" that has no page`); + if (JSON.stringify(q).includes(EM_DASH)) problems.push(`${q.id} has an em dash; use a colon, a semicolon or a new sentence`); + if (q.options.some((o) => /\b(all|none|both) of the above\b/i.test(o))) problems.push(`${q.id} has an option that depends on order`); + } + return problems; +} + +describe("content/quiz/open.json", () => { + it("is a valid deck in the house style", () => { + expect(deckProblems(openCards(), "o-")).toEqual([]); + }); + + it("has enough cards for a run, with every cap", () => { + const deck = getOpenDeck(); + expect(deck.length).toBeGreaterThanOrEqual(60); + const run = pickCards(deck, RUN_LENGTH.open, RUN_CAPS.open, openGroup); + expect(run).toHaveLength(RUN_LENGTH.open); + expect(run.filter((q) => openGroup(q) === "status").length).toBeLessThanOrEqual(RUN_CAPS.open.status); + }); + + it("asks one status card per standard, answered by its page", () => { + for (const s of getStandards()) { + const card = statusCard(s); + expect(quizDeck.element.safeParse(card).success, card.id).toBe(true); + expect(card.answer).toBe(STATUS_IDS.indexOf(s.status)); + expect(card.explain).not.toMatch(/\]\(|`/); + } + }); +}); + +describe.runIf(expert)("private/quiz-expert.json", () => { + const deck = expert ?? []; + + it("is a valid deck in the house style", () => { + expect(deckProblems(deck, "x-")).toEqual([]); + }); + + it("has enough cards for a run, and shares no id or question with the open deck", () => { + expect(deck.length).toBeGreaterThanOrEqual(RUN_LENGTH.expert * 3); + const open = openCards(); + const openIds = new Set(open.map((q) => q.id)); + const openPrompts = new Set(open.map((q) => q.prompt.toLowerCase())); + expect(deck.filter((q) => openIds.has(q.id) || openPrompts.has(q.prompt.toLowerCase())).map((q) => q.id)).toEqual([]); + }); + + // `npm run quiz:sql` writes the SQL that loads the deck into Supabase. + it.runIf(process.env.UPDATE_QUIZ_SQL)("writes private/quiz-expert.sql", () => { + const quote = (s: string) => `'${s.replace(/'/g, "''")}'`; + const rows = deck.map((q) => ` (${quote(q.id)}, ${quote(JSON.stringify(q))}::jsonb, true, now())`); + const sql = [ + "-- Generated by `npm run quiz:sql` from private/quiz-expert.json. Run it in the Supabase SQL editor.", + "begin;", + "insert into public.quiz_questions (id, data, active, updated_at) values", + `${rows.join(",\n")}`, + "on conflict (id) do update set data = excluded.data, active = true, updated_at = now();", + `update public.quiz_questions set active = false where id not in (${deck.map((q) => quote(q.id)).join(", ")});`, + "commit;", + "", + ].join("\n"); + writeFileSync(join(process.cwd(), "private", "quiz-expert.sql"), sql); + }); +}); + +describe("scoring", () => { + it("rewards right, fast and in a row, and nothing for wrong", () => { + expect(cardPoints(false, 1000, 0)).toBe(0); + expect(cardPoints(true, 1000, 1)).toBe(150); + expect(cardPoints(true, 3000, 1)).toBe(150); + expect(cardPoints(true, 20_000, 1)).toBe(100); + expect(cardPoints(true, 60_000, 1)).toBe(100); + expect(cardPoints(true, 1000, 3)).toBe(170); + expect(cardPoints(true, 1000, 40)).toBe(200); + }); + + it("caps a run's score", () => { + expect(maxScore(1)).toBe(150); + expect(maxScore(12)).toBeGreaterThan(maxScore(11)); + }); +}); + +describe("share codes", () => { + it("round-trip", () => { + const results = [true, false, true, true]; + expect(decodeShare(encodeShare(420, results))).toEqual({ score: 420, results }); + }); + + it("reject malformed and impossible scores", () => { + expect(decodeShare("abc")).toBeUndefined(); + expect(decodeShare("100-")).toBeUndefined(); + expect(decodeShare("100-012")).toBeUndefined(); + expect(decodeShare(`${maxScore(2) + 1}-11`)).toBeUndefined(); + }); +}); + +describe("dealing", () => { + const card = (id: string, kind: QuizQuestion["kind"]) => ({ id, kind }); + + it("respects caps, and fills from skipped cards when it must", () => { + const deck = [...Array.from({ length: 10 }, (_, i) => card(`f${i}`, "fake")), card("q", "quick")]; + const run = pickCards(deck, 4, { fake: 2 }); + expect(run).toHaveLength(4); + expect(run.slice(0, 3).filter((c) => c.kind === "fake")).toHaveLength(2); + }); + + it("keeps the written order of a scale or True and False, and shuffles the rest", () => { + expect(optionOrder({ kind: "real", options: ["a", "b", "c"] })).toEqual([0, 1, 2]); + expect(optionOrder({ kind: "quick", options: ["True", "False"] })).toEqual([0, 1]); + expect(optionOrder({ kind: "quick", options: ["a", "b", "c", "d"] }).sort()).toEqual([0, 1, 2, 3]); + }); +}); + +describe("sign-in return path", () => { + it("stays on this site", () => { + expect(safeNext("/play?deck=expert")).toBe("/play?deck=expert"); + expect(safeNext("//evil.example")).toBe("/play"); + expect(safeNext("https://evil.example")).toBe("/play"); + expect(safeNext("/\\evil.example")).toBe("/play"); + expect(safeNext(null)).toBe("/play"); + }); +});