Add a daily glossary quiz with a Go backend and high scores #21

Merged
olli merged 1 commit from feat/glossary-quiz into dev 2026-10-15 13:31:42 +03:00
Collaborator

A daily quiz on the glossary's terms at /quiz/, with today's and this week's high scores under public nicknames. It needs state the static site cannot keep, so this brings in ROADMAP's phases 2 and 3 together: a Go server beside Caddy, which proxies /api/ to it, and PostgreSQL.

What players get

  • Today's round: ten questions, the same for everyone that day, one round per player. A reload carries on where it was.
  • Practice rounds: new questions each time, never on the tables.
  • 30 seconds per question, counted from when the question is shown, so reading the answer card afterwards costs nothing. A late answer counts as wrong. After each answer the page shows the right term's card, linked to the glossary.
  • Tables: today, and this week from Monday (Europe/Helsinki). The weekly table adds up the daily scores, so playing every day counts. Ties go to less time spent answering. There is no all-time table.
  • Nicknames: anonymous_NNNN until the player chooses one. The page says, in bold and in both languages, that nicknames are public.

How it works

  • Scoring happens on the server. It sends one question at a time without its answer, checks every answer itself, and enforces the time limit, so no score can be posted from a browser console. The day's questions are dealt from a random seed rather than one derived from the date, so nobody can compute tomorrow's round from the public source.
  • Questions come from graph.json (internal/quiz/questions.go). Five kinds: a term and its summary, a summary and its term, an example and its term, an abbreviation and its expansion, and a cloud service and its counterpart on the other cloud.
    • Only facts about one term, or a counterpart-of edge, make a question: a missing edge does not prove an answer wrong.
    • The asked term's own names are blanked out of the text.
    • Terms that share a name or distinguish each other never appear in the same question.
  • A test builds every question every term can give. It caught LDAP showing inside "OpenLDAP", ffmpeg giving away FFmpeg, and both IAM terms offered in one question. A glossary edit that would produce a bad question now fails CI.
  • Stored data:
    • A player is a random token in an HttpOnly, SameSite=Strict cookie; the database keeps only its SHA-256.
    • Rounds are deleted after two weeks. Players not seen for 60 days are deleted along with their scores.
    • Addresses are counted in memory for rate limits and never stored. The limits are sized for a whole office playing from one address.
    • Nicknames allow Latin letters only, so a Cyrillic look-alike can't pass as another player.
  • Stack:
    • pgx plus sqlc. The generated code is committed and CI checks it is current; sqlc is pinned in tools/go.mod.
    • Migrations are embedded in the binary and applied at startup. They only add to the schema, so a rolled-back image still works against it.
    • The image is distroless static and runs as nonroot.

CI

A new server job runs gofmt, go vet and sqlc diff, then go test against a PostgreSQL it starts in the job. It then builds and smoke-tests the staged binary and pushes olli.puhakka.dev-server:<sha> from dev. release.yml checks that both images exist.

⚠️ Before merging

CI / server will fail until the kulpsin changes are in. They are being done separately:

  1. Runner image: Go 1.27.2 and the PostgreSQL 18 server packages (initdb, pg_ctl, psql).
  2. site_deploy for olli / olli-dev:
    • a server service beside web, with the healthcheck /app/server healthcheck;
    • its own Postgres on an internal network, with a password-file secret;
    • the deployer releasing and rolling back both images of a commit together.
  3. Forgejo: add CI / server (push) to main's required checks.

Until step 2 is done the site keeps working, and the quiz page says the quiz is unavailable.

Checked locally

  • go test against PostgreSQL 18.6: 10 tests, covering the daily flow, time limit, answer rules, nicknames, both tables and pruning.
  • html-validate and check-glossary pass.
  • The new CI job's steps were run locally with the same commands.
  • docker compose -f compose.dev.yml up runs the full stack.
  • Full rounds were played in headless Chromium: desktop in light theme, and 320px in dark theme in Finnish. Keyboard play works (1–4 to answer, Enter for the next question), there is no horizontal scroll, and the console shows no errors.
A daily quiz on the glossary's terms at `/quiz/`, with today's and this week's high scores under public nicknames. It needs state the static site cannot keep, so this brings in ROADMAP's phases 2 and 3 together: a Go server beside Caddy, which proxies `/api/` to it, and PostgreSQL. ## What players get - **Today's round:** ten questions, the same for everyone that day, one round per player. A reload carries on where it was. - **Practice rounds:** new questions each time, never on the tables. - **30 seconds per question**, counted from when the question is shown, so reading the answer card afterwards costs nothing. A late answer counts as wrong. After each answer the page shows the right term's card, linked to the glossary. - **Tables:** today, and this week from Monday (Europe/Helsinki). The weekly table adds up the daily scores, so playing every day counts. Ties go to less time spent answering. There is no all-time table. - **Nicknames:** `anonymous_NNNN` until the player chooses one. The page says, in bold and in both languages, that nicknames are public. ## How it works - **Scoring happens on the server.** It sends one question at a time without its answer, checks every answer itself, and enforces the time limit, so no score can be posted from a browser console. The day's questions are dealt from a random seed rather than one derived from the date, so nobody can compute tomorrow's round from the public source. - **Questions come from `graph.json`** (`internal/quiz/questions.go`). Five kinds: a term and its summary, a summary and its term, an example and its term, an abbreviation and its expansion, and a cloud service and its counterpart on the other cloud. - Only facts about one term, or a `counterpart-of` edge, make a question: a missing edge does not prove an answer wrong. - The asked term's own names are blanked out of the text. - Terms that share a name or `distinguish` each other never appear in the same question. - **A test builds every question every term can give.** It caught LDAP showing inside "OpenLDAP", `ffmpeg` giving away FFmpeg, and both IAM terms offered in one question. A glossary edit that would produce a bad question now fails CI. - **Stored data:** - A player is a random token in an `HttpOnly`, `SameSite=Strict` cookie; the database keeps only its SHA-256. - Rounds are deleted after two weeks. Players not seen for 60 days are deleted along with their scores. - Addresses are counted in memory for rate limits and never stored. The limits are sized for a whole office playing from one address. - Nicknames allow Latin letters only, so a Cyrillic look-alike can't pass as another player. - **Stack:** - `pgx` plus `sqlc`. The generated code is committed and CI checks it is current; sqlc is pinned in `tools/go.mod`. - Migrations are embedded in the binary and applied at startup. They only add to the schema, so a rolled-back image still works against it. - The image is distroless static and runs as nonroot. ## CI A new `server` job runs `gofmt`, `go vet` and `sqlc diff`, then `go test` against a PostgreSQL it starts in the job. It then builds and smoke-tests the staged binary and pushes `olli.puhakka.dev-server:<sha>` from `dev`. `release.yml` checks that both images exist. ## ⚠️ Before merging `CI / server` **will fail until the kulpsin changes are in**. They are being done separately: 1. **Runner image:** Go 1.27.2 and the PostgreSQL 18 server packages (`initdb`, `pg_ctl`, `psql`). 2. **`site_deploy` for `olli` / `olli-dev`:** - a `server` service beside `web`, with the healthcheck `/app/server healthcheck`; - its own Postgres on an internal network, with a password-file secret; - the deployer releasing and rolling back both images of a commit together. 3. **Forgejo:** add `CI / server (push)` to `main`'s required checks. Until step 2 is done the site keeps working, and the quiz page says the quiz is unavailable. ## Checked locally - `go test` against PostgreSQL 18.6: 10 tests, covering the daily flow, time limit, answer rules, nicknames, both tables and pruning. - `html-validate` and `check-glossary` pass. - The new CI job's steps were run locally with the same commands. - `docker compose -f compose.dev.yml up` runs the full stack. - Full rounds were played in headless Chromium: desktop in light theme, and 320px in dark theme in Finnish. Keyboard play works (1–4 to answer, Enter for the next question), there is no horizontal scroll, and the console shows no errors.
Add a daily glossary quiz with a Go backend and high scores
All checks were successful
CI / lint (push) Successful in 2s
CI / server (push) Successful in 56s
CI / image (push) Successful in 3s
CI / verify-staging (push) Successful in 50s
ffc1615235
A quiz makes the glossary something a team comes back to: ten questions
a day, dealt from graph.json, the same for everyone, with today's and
this week's high scores under public nicknames. It needs state the static
site cannot keep, so this brings in ROADMAP's phases 2 and 3 together:
a Go server beside Caddy, which proxies /api/ to it, and PostgreSQL.

The server deals and scores. It sends one question at a time without its
answer, times each from when the page asks for it, and allows one daily
round per player, so the tables cannot be posted to from a browser
console. Questions only use facts about one term or a counterpart-of
edge, since a missing edge does not prove an answer wrong; the tests
build every question every term can give, so a glossary edit that would
make a question with two right answers fails CI.

Players are a random token in a SameSite=Strict cookie, stored hashed.
Rounds go after two weeks and idle players after sixty days; addresses
are only counted in memory for the rate limits, which are set for a
whole office playing from one address.

CI tests against a PostgreSQL it starts in the job and pushes a second,
distroless image for the same commit. The runner needs Go and PostgreSQL's
server binaries, and the server's compose file the backend and its
database; both live in the kulpsin repository.
olli merged commit ffc1615235 into dev 2026-10-15 13:31:42 +03:00
olli deleted branch feat/glossary-quiz 2026-10-15 13:31:43 +03:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
olli/olli.puhakka.dev!21
No description provided.