What changed, and why
Sharing (#88) shipped with a user-typed display name — the only identity that reaches a public run page. A player could type a slur, an impersonation, or a harassing handle and it rode onto that page. The detector floor caught the unforgivable categories; contextual abuse of a self-chosen handle was a documented, open follow-up.
This change closes that hole by construction: the handle becomes generated, never typed. With no free-text input anywhere, there is nothing to type an abusive handle into. A player can reroll to a different generated name, but cannot author one.
The handle also graduates from a per-share field to a per-account property (a new profiles table), minted lazily on first need and carried across the anonymous→account upgrade for free. Two new surfaces give it a home: a /profile page and a /settings account hub. Blast radius is contained: the public-read boundary (getPublicRun) and its tests are untouched; the core game and anonymous sharing are unaffected.
The handle: generated, never typed
The generator is the curated-pool stance applied to identity (the same shape as the objective pool): a finite, hand-held set of safe outputs, never the model or the user inventing one. A handle is an "Adjective Noun" pair drawn from two wordlists themed to the game's historian voice — "Wandering Cartographer", "Gilded Scribe".
Two curated wordlists — clean by construction, historian-flavored.
server/utils/username.ts · 92 lines
⋯ 27 lines hidden (lines 1–27)
⋯ 43 lines hidden (lines 50–92)
Clean lists aren't enough on their own: the combination is screened too, so no pairing reads badly. isWholesomeUsername rejects a non-two-word shape, an explicitly-excluded pair, and a redundant same-root pairing ("Wandering Wanderer" reads as a typo). Generation re-draws on the rare screened-out pair, bounded so it always returns; rng is injected so a test can pin the draw.
The combination screen, and generation that re-draws until it passes.
server/utils/username.ts · 92 lines
⋯ 61 lines hidden (lines 1–61)
A username per account
The handle is keyed by the account subject — the same string balances, runs, and run_snapshots key on. The store mirrors the run-snapshot port/adapter shape: an in-memory default (so dev and tests need nothing) and a Supabase adapter behind one port, chosen by config. getUsername mints lazily on first need; the Supabase insert is guarded so two concurrent first-reads can't both win — a subject always resolves to one stable handle.
The port, and the durable adapter: read, else mint-with-guard, then read back.
server/utils/profile-store.ts · 115 lines
⋯ 24 lines hidden (lines 1–24)
⋯ 24 lines hidden (lines 38–61)
⋯ 28 lines hidden (lines 88–115)
The schema is migration 0009. The table is locked to the server, and the migration also closes the abuse surface for existing data: it nulls every legacy typed share_name, so no self-chosen free-text handle survives on a public page. Those shares stay live (the token is untouched) — they just read anonymous until the owner re-attributes, which now attaches a generated handle.
profiles table, RLS on (server-only), and the legacy-name null-out.
supabase/migrations/0009_profiles.sql · 35 lines
⋯ 16 lines hidden (lines 1–16)
Verification
The whole suite is green — 1037 tests across the node and Nuxt projects — and nuxt typecheck is clean. Tests cover the acceptance directly: generation always yields a clean "Adjective Noun" from the lists, the share path uses the account handle with no typed input (component + store + source-contract tests), reroll, attribution sync, and the anonymous→account carry-through. A representative example pins that the curated lists are clean by construction — every pairing passes the screen, so generation is never biased into the fallback.
Exhaustive: every curated adjective × noun pairing is wholesome.