diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9df477 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +# The gate in front of Railway. Railway's build runs `tsc --noEmit`, but nothing +# else: a red test suite or a broken level bank reaches production and only +# surfaces after the deploy. These five steps run first, on every push to main +# and every pull request. +# +# No Postgres service here on purpose: no test file imports `src/db/index.ts` or +# reads DATABASE_URL, so the suite is stateless and stays fast. The day a test +# needs a database, add the service then — not in advance. +name: CI + +on: + push: + branches: [main] + pull_request: + +# A new push to the same ref makes the run in flight pointless. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Same literal as `packageManager` in package.json — CI and Railway build + # on one version of bun, or the pin means nothing. + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + + - run: bun install --frozen-lockfile + + # --max-warnings=12 is the invariant from AGENTS.md made executable: the + # dozen react(only-export-components) warnings are inherent to TanStack + # route modules, a thirteenth is a regression. + - run: bun run lint + + # `test` and `build` each compile paraglide first; src/paraglide is + # gitignored, so no separate generation step is needed. + - run: bun run test + + - run: bun run verify + + - run: bun run build diff --git a/.railway/railway.ts b/.railway/railway.ts index 1fba785..3265fb5 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -25,7 +25,9 @@ export default defineRailway(() => { build: "bun run build", start: "bun run start", preDeployCommand: ["bun run db:migrate"], - healthcheckPath: "/", + // `/` is the SPA shell: it answers 200 with the database on the floor. + // /api/health runs `select 1` first. + healthcheckPath: "/api/health", env: { DATABASE_URL: Postgres.env.DATABASE_URL, // secrets set out-of-band (dashboard / CLI) — keep their remote values diff --git a/AGENTS.md b/AGENTS.md index 1cfc1e6..f5ef38a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,8 +67,10 @@ routing, and build were migrated. `/profile/$username` the player pages. Session state that used to be lifted (sound, mute) is per-route via `usePersistedSound`; progression is read from localStorage by `useBestScores` in whichever route needs it. - `__root.tsx` is the HTML shell (`lang="fr"`, theme-color `#14110E`, - `src/index.css`). + `__root.tsx` is the HTML shell (`lang` from the request's resolved locale, + theme-color `#14110E`, `src/index.css`) and carries the app's two route + boundaries, `notFoundComponent` and `errorComponent`. + `/sitemap.xml` and `/robots.txt` are server routes too (see "Don't recreate"). - React Compiler kept, applied via `@rolldown/plugin-babel` + `reactCompilerPreset` from `@vitejs/plugin-react` (plugin-react v6 in rolldown-vite has **no** `babel` option — do not try `viteReact({ babel })`). @@ -100,10 +102,15 @@ routing, and build were migrated. - `bun run db:generate` / `db:migrate` — Drizzle migrations (needs `DATABASE_URL`) - `bun run auth:generate` — regenerate the Better Auth tables via the CLI (`bunx`, overwrites `src/db/schema.ts` — re-append our tables from git after: - `dailyPuzzle`, `dailyScore`, `levelScore`) -- `bun run lint` — oxlint + `dailyPuzzle`, `dailyScore`, `dailyView`, `levelScore`) +- `bun run lint` — oxlint, capped at `--max-warnings=12` (see "Key decisions") - `bun run generate-routes` — regenerate the route tree (`tsr generate`) +CI (`.github/workflows/ci.yml`) runs `lint` → `test` → `verify` → `build` on +every push to `main` and every pull request, on the bun version pinned by +`packageManager`. No Postgres service: no test imports `src/db/index.ts` or +reads `DATABASE_URL`, and the suite stays stateless on purpose. + ### Environment variables The pure game needs none, but the daily-level / accounts / leaderboard layer @@ -113,15 +120,22 @@ Copy `.env.example` → `.env` for local dev (bun auto-loads it); Railway inject the real values. - `DATABASE_URL` — Postgres connection. Local dev via `docker-compose.yml`; on - Railway use `${{Postgres.DATABASE_URL}}` (private host, no SSL). + Railway use `${{Postgres.DATABASE_URL}}` (private host, no SSL). The compose + image is `postgres:18-alpine`, matching production's major. It was 17: a data + directory written by 17 will not start under 18, so a checkout that predates + the bump needs `docker compose down -v` then `bun run db:migrate`. - `BETTER_AUTH_SECRET` — `openssl rand -base64 32`. - `BETTER_AUTH_URL` — the app's public origin (also added to `trustedOrigins`). + Doubles as the origin for every absolute URL the server emits: `og:image` and + the two crawler documents. Unset, `/sitemap.xml` answers 404 rather than serve + relative ``s, and `robots.txt` drops its `Sitemap:` line. ## Server layer — daily level, accounts, leaderboard A shared daily puzzle (Wordle-style) with email/password accounts, a per-day -leaderboard, and public player profiles. Components never render server-side -(`defaultSsr: "data-only"`); data flows through server functions + auth/OG API +leaderboard, and public player profiles. The public profile is the one route +that renders server-side; everywhere else `defaultSsr: "data-only"` keeps the +components client-only and data flows through server functions + auth/OG API routes. Profiles: `username` plugin (Better Auth) gives each account a unique handle; `/profile/me` (private) and `/profile/$username` (public) show a daily contribution grid + streaks; `/api/og/$username` renders a dynamic Open Graph @@ -131,8 +145,8 @@ import that (or `db`) from a module the client route tree pulls in. - **DB**: Postgres + Drizzle (`pg` pool). `src/db/index.ts` (pool + client), `src/db/schema.ts` (auth tables generated by the Better Auth CLI + our - `daily_puzzle` / `daily_score`). Migrations in `src/db/migrations/`, config in - `src/db/drizzle.config.ts`. + `daily_puzzle` / `daily_score` / `daily_view` / `level_score`). Migrations in + `src/db/migrations/`, config in `src/db/drizzle.config.ts`. - **Auth**: Better Auth, email + password, Drizzle adapter (`provider: "pg"`). `src/lib/auth.ts` (server) — `tanstackStartCookies()` MUST be the **last** plugin. `src/lib/auth-client.ts` (`better-auth/react`). Mounted at @@ -149,10 +163,11 @@ import that (or `db`) from a module the client route tree pulls in. the server derives both the move count and the correction count for the clean-solve rank). Tested in `src/server/replay.test.ts`. - **Generation** (`src/solver/`): `hunt.ts` (extracted from `generate.ts` — - `randomLevel` + certified search, importable), `generate-daily.ts` (cron CLI: + random board draw + certified search behind the single `hunt` export), + `generate-daily.ts` (cron CLI: hunt today + J+1/J+2, insert, **close the pool and `exit(0)`**). -- **UI**: `/daily` route plays the day's level through `PlayScreen` in daily - mode. The shared `LeaderboardRail` (bound to the day via `DailyBoard`, to a +- **UI**: the `/daily/$tier` route plays the day's level through `PlayScreen` in + daily mode. The shared `LeaderboardRail` (bound to the day via `DailyBoard`, to a campaign level via `LevelBoard`) reads the board, gates on an account (`AuthPanel`), auto-submits the winning trace, and shows the standing; `DailyOverlay` is now the daily win celebration only. `useGame` records the @@ -160,38 +175,41 @@ import that (or `db`) from a module the client route tree pulls in. ### Don't recreate -| Concern | Lives in | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| who drives the board (game vs tutorial) | `src/ui/plateDriver.ts` — the `PlateDriver` interface + its two PURE adapters (`playingPlate`, `guidedPlate`). PlayScreen binds ONE; never branch on "is the tutorial live?" anywhere else | -| the "clean pull" mark | two forms, one idea: `src/ui/components/CleanSeal.tsx` on the BOARDS (ranked rows, standing footer), and the `sp-ink-frame` class in `src/index.css` on the EDITION (a plate's record frame changes ink instead of wearing a badge) | -| profile distinctions (families + thresholds) | `src/lib/distinctions.ts` — the SOLE owner of what a family measures, where its four face values sit, and which day postmarks a tier. `Stamp.tsx` draws what it is handed; `profileData.ts` only gathers dates. Put no threshold in either | -| the profile stamp artwork | `src/ui/components/Stamp.tsx` — the four engravings, the perforation, the burelage, the empty album mount. The OG card must reuse it rather than paint a second version. No `mix-blend-mode`: satori cannot render it, so the two inks are painted explicitly | -| the tutorial's on-board content | `src/ui/components/DemoOverlay.tsx` | -| progression ledger + stamps + chapter folding | `src/ui/progression.ts` (`Ledger`, `recordWin`, `markHinted`, `plate`, `setPulled`, `atPar`). `useBestScores` is only its React/localStorage adapter — put no rule there | -| discovery-anchor lifecycle + the measurement | `src/server/discovery.ts` (`AnchorStore`, `claimAnchor`, `shownAnchor`, `discoveryTime`); the Postgres adapter is `anchorsFor()` in `src/server/daily.ts` | -| the daily clock's three-state reconciliation | `useDailyClock` in `src/ui/hooks/useDiscoveryClock.ts` | -| auth guard on score WRITES | `requireUserId()` in `src/server/leaderboard.ts` (reads keep `currentUserId`, which answers a signed-out caller instead of throwing) | -| pg pool + drizzle client | `src/db/index.ts` | -| DB tables (auth + daily + campaign) | `src/db/schema.ts` | -| Better Auth server / client | `src/lib/auth.ts` / `src/lib/auth-client.ts` | -| auth HTTP mount | `src/routes/api/auth/$.ts` | -| daily server functions | `src/server/daily.ts` | -| campaign leaderboard functions | `src/server/campaign.ts` | -| trace replay + shape validation | `src/server/replay.ts` (`validateTrace`, `validateTraceShape`) | -| level generation core | `src/solver/hunt.ts` | -| daily cron entry | `src/solver/generate-daily.ts` | -| daily win overlay / auth form | `src/ui/components/DailyOverlay.tsx` / `AuthPanel.tsx` | -| leaderboard row list | `src/ui/components/LeaderboardRows.tsx` | -| leaderboard rail (shared) | `src/ui/components/LeaderboardRail.tsx` (bound by `DailyBoard.tsx` / `LevelBoard.tsx`) | -| ranking rule (upsert guard + ORDER BY + rank) | `src/server/ranking.ts` — the ONLY place "fewer moves, then fewer corrections" lives | -| score upsert + board types + currentUserId | `src/server/leaderboard.ts` (`upsertBestScore`, `LeaderRow`/`MyResult`/`BoardData`) | -| merge equation (`a == b + off`) | `merges()` in `src/engine/state.ts` — sole owner, used by successors AND decalage | -| per-level compiled mechanics | `compiled()` in `src/engine/mechanics/registry.ts` (WeakMap; single-owner guard for `resolveMove`/`mapDirB`/`settle`) | -| mechanic necessity semantics | `removedWith`/`mustBeNeeded` on each mechanic; probe = `isRequired()` in `src/solver/bfs.ts` | -| verb → sound/haptic signature | `inputSignature()` in `src/ui/signatures.ts` (game + tutorial both consume it) | -| alt-gesture rule (split/world) | `altGesture()` in `src/ui/altGesture.ts` | -| solve value + submission policy | `src/ui/submissionPolicy.ts` (`Solve`, `decideSubmission` — pure, owns the UI status) | -| hold-to-confirm (state, then control) | `src/ui/hooks/useHold.ts` + `src/ui/components/HoldButton.tsx` — reset is HELD, never clicked. The state lives in `PlayScreen` so the button and the R key share ONE sweep; `useKeyboard` takes `resetDown`/`resetUp` rather than a `reset` callback. | +| Concern | Lives in | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| who drives the board (game vs tutorial) | `src/ui/plateDriver.ts` — the `PlateDriver` interface + its two PURE adapters (`playingPlate`, `guidedPlate`). PlayScreen binds ONE; never branch on "is the tutorial live?" anywhere else | +| the "clean pull" mark | two forms, one idea: `src/ui/components/CleanSeal.tsx` on the BOARDS (ranked rows, standing footer), and the `sp-ink-frame` class in `src/index.css` on the EDITION (a plate's record frame changes ink instead of wearing a badge) | +| the "ever solved cleanly" fact | `level_score.ever_clean` — set by `markEverClean` in `src/server/campaign.ts` (a SECOND statement: the upsert's guard skips a clean run that isn't a best), read by `asWin` in `src/ui/progressSync.ts`. Progression, not ranking: boards seal what they show | +| profile distinctions (families + thresholds) | `src/lib/distinctions.ts` — the SOLE owner of what a family measures, where its four face values sit, and which day postmarks a tier. `Stamp.tsx` draws what it is handed; `profileData.ts` only gathers dates. Put no threshold in either | +| the profile stamp artwork | `src/ui/components/Stamp.tsx` — the four engravings, the perforation, the burelage, the empty album mount. The OG card must reuse it rather than paint a second version. No `mix-blend-mode`: satori cannot render it, so the two inks are painted explicitly | +| the 404 AND the error screen | `src/ui/screens/FallbackScreen.tsx` — ONE screen, parameterised by title/body/action, mounted twice from `__root.tsx` (`notFoundComponent`, `errorComponent`). It never renders the error itself: a loader failure carries server detail the visitor can't act on. Touches no browser API (the root is `ssr: true`) | +| what crawlers are told (sitemap + robots) | `src/server/seo.ts` (`sitemapXml`, `robotsTxt`) — pure text, served by `src/routes/sitemap[.]xml.ts` / `robots[.]txt.ts` (brackets escape the dot). Routes, not `public/` files: both need the absolute origin from `BETTER_AUTH_URL`. Static paths only — no profile is ever enumerated there | +| the tutorial's on-board content | `src/ui/components/DemoOverlay.tsx` | +| progression ledger + stamps + chapter folding | `src/ui/progression.ts` (`Ledger`, `recordWin`, `markHinted`, `plate`, `setPulled`, `atPar`). `useBestScores` is only its React/localStorage adapter — put no rule there | +| discovery-anchor lifecycle + the measurement | `src/server/discovery.ts` (`AnchorStore`, `claimAnchor`, `shownAnchor`, `discoveryTime`); the Postgres adapter is `anchorsFor()` in `src/server/daily.ts` | +| the daily clock's three-state reconciliation | `useDailyClock` in `src/ui/hooks/useDiscoveryClock.ts` | +| auth guard on score WRITES | `requireUserId()` in `src/server/leaderboard.ts` (reads keep `currentUserId`, which answers a signed-out caller instead of throwing) | +| pg pool + drizzle client | `src/db/index.ts` | +| DB tables (auth + daily + campaign) | `src/db/schema.ts` | +| Better Auth server / client | `src/lib/auth.ts` / `src/lib/auth-client.ts` | +| auth HTTP mount | `src/routes/api/auth/$.ts` | +| daily server functions | `src/server/daily.ts` | +| campaign leaderboard functions | `src/server/campaign.ts` | +| trace replay + shape validation | `src/server/replay.ts` (`validateTrace`, `validateTraceShape`) | +| level generation core | `src/solver/hunt.ts` | +| daily cron entry | `src/solver/generate-daily.ts` | +| daily win overlay / auth form | `src/ui/components/DailyOverlay.tsx` / `AuthPanel.tsx` | +| leaderboard row list | `src/ui/components/LeaderboardRows.tsx` | +| leaderboard rail (shared) | `src/ui/components/LeaderboardRail.tsx` (bound by `DailyBoard.tsx` / `LevelBoard.tsx`) | +| ranking rule (upsert guard + ORDER BY + rank) | `src/server/ranking.ts` — the ONLY place "fewer moves, then fewer corrections" lives | +| score upsert + board types + currentUserId | `src/server/leaderboard.ts` (`upsertBestScore`, `LeaderRow`/`MyResult`/`BoardData`) | +| merge equation (`a == b + off`) | `merges()` in `src/engine/state.ts` — sole owner, used by successors AND decalage | +| per-level compiled mechanics | `compiled()` in `src/engine/mechanics/registry.ts` (WeakMap; single-owner guard for `resolveMove`/`mapDirB`/`settle`) | +| mechanic necessity semantics | `removedWith`/`mustBeNeeded` on each mechanic; probe = `isRequired()` in `src/solver/bfs.ts` | +| verb → sound/haptic signature | `inputSignature()` in `src/ui/signatures.ts` (game + tutorial both consume it) | +| alt-gesture rule (split/world) | `altGesture()` in `src/ui/altGesture.ts` | +| solve value + submission policy | `src/ui/submissionPolicy.ts` (`Solve`, `decideSubmission` — pure, owns the UI status) | +| hold-to-confirm (state, then control) | `src/ui/hooks/useHold.ts` + `src/ui/components/HoldButton.tsx` — reset is HELD, never clicked. The state lives in `PlayScreen` so the button and the R key share ONE sweep; `useKeyboard` takes `resetDown`/`resetUp` rather than a `reset` callback. | ### Deployment @@ -210,7 +228,7 @@ NOT serve the client assets alongside the handler — that's why we use Nitro.) Topology in `.railway/railway.ts`: - **web** — `source: github(...)`, build `bun run build`, start `bun run start`, - `preDeployCommand: bun run db:migrate`, healthcheck `/`. Env: `DATABASE_URL` + `preDeployCommand: bun run db:migrate`, healthcheck `/api/health`. Env: `DATABASE_URL` from the Postgres ref; `BETTER_AUTH_SECRET` / `BETTER_AUTH_URL` set out of band (secrets, `preserve()`d in IaC). - **Postgres** — `postgres("Postgres")`; other services reference its @@ -220,6 +238,19 @@ Topology in `.railway/railway.ts`: `restartPolicyType: "NEVER"`. The generator closes the pool and `exit(0)` or the next run is skipped. +`/api/health` (`src/routes/api/health.ts` → `src/server/health.ts`) runs +`select 1` against the pool and answers 200 `{"status":"ok"}` or 503 +`{"status":"degraded"}`, `cache-control: no-store`. The old target, `/`, served +the SPA shell and answered 200 to an instance that could not reach its database. +`pg` has no timeout configured, so the probe races the query against a 2 s +deadline — that race is in `probe()`, not on the shared pool; putting a deadline +on every query is a separate decision. The failure reason goes to the logs, not +the body: the route is public and a driver error names the host and user. + +Railpack builds `web`; it reads `packageManager` from `package.json`, which is +why bun is pinned there (`bun@1.4.0`) and why CI's `setup-bun` repeats the same +literal. + Secrets to set once (dashboard/CLI, not in the file): `BETTER_AUTH_SECRET` (`openssl rand -base64 32`) and `BETTER_AUTH_URL` (the web service's public domain). @@ -233,9 +264,15 @@ Runtime network dependency: the Instrument Serif web font (Google Fonts). explicit relative imports with `.ts`/`.tsx` extensions — keep that convention. - `tsconfig.json` has `allowJs`/`allowArbitraryExtensions` for the Paraglide `.js` output, and excludes `src/solver` + `scripts` (Node/bun context). -- One oxlint warning on `__root.tsx` (`react/only-export-components`) is inherent - to TanStack file-route modules (they export both `Route` and a component); the - same warning fires on every route file (`daily.tsx`, etc.). +- The dozen `react/only-export-components` oxlint warnings are inherent to + TanStack file-route modules (they export both `Route` and a component), so + `bun run lint` is green with them; `__root.tsx` alone accounts for three (the + shell plus the two boundaries). A THIRTEENTH is a regression, and + `--max-warnings=12` now enforces that rather than leaving it to this + paragraph. The rule counts about one warning per route module, not one per + stray export: adding a non-component export to a file that already warns moves + the warning without adding one. What reaches 13 is a NEW route module — so + raise the cap deliberately when you add a route, never to get back to green. - `@better-auth/core` must resolve to the **same** version as `better-auth` (1.6.23). `@better-auth/cli` lags (1.4.21) and drags an old core, so it's NOT a dependency — `auth:generate` runs it via `bunx @better-auth/cli@latest`. @@ -245,15 +282,6 @@ Runtime network dependency: the Instrument Serif web font (Google Fonts). ### Next steps -- **Residual gap — a clean run that was never your best row can't be - recovered.** `getMyLevelScores` now returns `undos` alongside `moves`, and - `asWin` (`src/ui/progressSync.ts`) turns a stored row into a ledger win with - `clean: undos === 0`, so a new device recovers both records and seals. What it - still cannot recover: the server keeps ONE row per level, not a history, so a - player whose clean run was not their best row has no "ever solved cleanly" - fact for the server to return. Closing that needs a column - (`level_score.ever_clean`, set on any correction-free submission), which is a - migration — not worth it until someone asks. - The route split is done (see "Stack & toolchain"); `` and its screen router are gone. - Old project instructions live in `superposition-old/CLAUDE.md` (engine diff --git a/README.md b/README.md index ed66cec..f19a4e8 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ same way.

The last level, Tectonique, played out by the solver: moves, world drift (decalage), fusion (the pawns overlap to white), scission, then the amber lock — "ready to print".

A React web game running on TanStack Start, fully playable in the browser. The -core game is client-only; the optional **daily mode** (accounts, shared board, -leaderboard) is the one part that talks to a server. +core game is client-only. Everything that talks to a server is optional: the +**daily mode** (accounts, shared board, leaderboard), the per-level +leaderboards, and the public player pages. ## The idea @@ -49,28 +50,31 @@ hashable**: no randomness during play, no real time, no hidden information. The direct consequence is that the game and the solver consume exactly the same API (`successors` / `isWin` / `hashState`), so they cannot drift apart. -| Path | Purpose | -| --------------------------------------- | ------------------------------------------------------------------------------------ | -| `src/engine/types.ts` | The contract: the game state and the mechanic protocol | -| `src/engine/{grid,state,successors}.ts` | Geometry, lifecycle, move enumeration | -| `src/engine/mechanics/` | One mechanic = one file + `registry.ts` | -| `src/engine/levels.ts` | The level bank (pure data, 22 boards) | -| `src/solver/` | Rule-agnostic BFS + the `verify` / `gen` CLIs | -| `src/ui/screens/` | The title / select / play screens | -| `src/ui/components/` | Board, InkLayer, RegMark, Wordmark, Hud, Controls… | -| `src/ui/hooks/` | `useGame`, `useSound`, `useKeyboard`, `useSwipe`, `useBestScores` | -| `src/routes/` | TanStack Start file-based routes (single `/` mounts the game; `api/` for daily mode) | -| `src/db/` | Postgres + Drizzle: schema, client, `drizzle.config.ts`, `migrations/` | -| `src/lib/` | Better Auth setup (email + password) | -| `project.inlang/messages/{fr,en}.json` | i18n catalogue (translation source, inlang format) | +| Path | Purpose | +| --------------------------------------- | ------------------------------------------------------------------------------------------- | +| `src/engine/types.ts` | The contract: the game state and the mechanic protocol | +| `src/engine/{grid,state,successors}.ts` | Geometry, lifecycle, move enumeration | +| `src/engine/mechanics/` | One mechanic = one file + `registry.ts` | +| `src/engine/levels.ts` | The level bank (pure data, 22 boards) | +| `src/solver/` | Rule-agnostic BFS + the `verify` / `gen` CLIs | +| `src/ui/screens/` | The title / select / play / profile screens, plus the 404-and-error fallback | +| `src/ui/components/` | Board, InkLayer, RegMark, Wordmark, Hud, Controls… | +| `src/ui/hooks/` | `useGame`, `useSound`, `useKeyboard`, `useSwipe`, `useBestScores` | +| `src/routes/` | TanStack Start file-based routes — one per screen, plus `api/`, `sitemap.xml`, `robots.txt` | +| `src/server/` | Server-only: daily puzzle, leaderboards, trace replay, OG cards, crawler documents | +| `src/db/` | Postgres + Drizzle: schema, client, `drizzle.config.ts`, `migrations/` | +| `src/lib/` | Better Auth setup (email + password), streaks, distinctions | +| `project.inlang/messages/{fr,en}.json` | i18n catalogue (translation source, inlang format) | Data flow: input (keyboard / swipe / buttons) → `useGame.play` → `engine.applyInput` → new state → render. This repo is a TanStack Start (React 19 + Vite + Nitro) shell around the -original game. SSR is disabled app-wide — the game needs `AudioContext`, -`localStorage`, and keyboard/swipe — so the core loop is client-only. See -`AGENTS.md` for the full port history and toolchain notes. +original game. Components render client-side by default — the game needs +`AudioContext`, `localStorage`, and keyboard/swipe — so the core loop is +client-only; loaders and `` still run on the server, and the public +profile page renders there in full so crawlers see it. See `AGENTS.md` for the +full port history and toolchain notes. ## Getting started @@ -95,7 +99,7 @@ bun run db:migrate # apply migrations to the database ```sh bun run dev # dev server bun run build # paraglide + tsc typecheck + vite build -bun run test # Vitest suite (engine only) +bun run test # Vitest: pure (engine, solver, rules) + dom (components) bun run lint # oxlint bun run verify # certify every board in the bank is solvable (via the solver) bun run gen # hunt for new boards @@ -126,8 +130,9 @@ bun run gen -- --mods fusion,scission --size 5 --min 18 --ms 30000 Every displayed string goes through a key in `project.inlang/messages/{fr,en}.json` and is read as `m.key()` (Paraglide). `src/paraglide/` is generated (git-ignored) -and regenerated on build, or by hand with `bun run paraglide`. The default -language follows the browser, with a French fallback. +and regenerated on build, or by hand with `bun run paraglide`. The locale is +resolved from a cookie first, then the browser's preference, with a French +fallback. ## Adding content diff --git a/bun.lock b/bun.lock index bd97357..b7040b4 100644 --- a/bun.lock +++ b/bun.lock @@ -262,8 +262,6 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.73.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw=="], - "@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], - "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], @@ -474,18 +472,10 @@ "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], - "better-sqlite3": ["better-sqlite3@12.11.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA=="], - "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], - - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], @@ -496,8 +486,6 @@ "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -538,12 +526,8 @@ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], - "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], - "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -564,8 +548,6 @@ "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], @@ -584,8 +566,6 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "exsolve": ["exsolve@1.1.0", "", {}, "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw=="], @@ -596,12 +576,8 @@ "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], - "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -610,8 +586,6 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], @@ -630,12 +604,6 @@ "human-id": ["human-id@4.2.0", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], @@ -694,12 +662,6 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], - "motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="], "motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], @@ -712,14 +674,10 @@ "nanostores": ["nanostores@1.4.0", "", {}, "sha512-i0tloweeudshAEuddpDxcg9Ik6pkPfVsHIgKyf143JrgG7/MOh0+q7BypdLXZPoOP7fOYt1eTcwGkyiVmhJFkA=="], - "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - "nf3": ["nf3@0.3.22", "", {}, "sha512-NOcqlHu22X1rUakd0SrqllP8kb3LWFmSAi1svTxaKxz+yB604xlaOmUfI3oO+RkODvaocKDDy8bgthnZL8hrkw=="], "nitro": ["nitro@3.0.260610-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.6", "db0": "^0.3.4", "env-runner": "^0.1.12", "h3": "2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.5", "ofetch": "2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.1.0", "srvx": "^0.11.16", "unenv": "2.0.0-rc.24", "unstorage": "2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.3.0", "dotenv": "*", "giget": "*", "jiti": "^2.7.0", "rollup": "^4.61.1", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-KPb4L5yaF/Rx/xoGMpgHRJvZhbhGiqbRKOwwPLCH9jKTKTsEUHLjnJas85AeCzaswqa8Wi52eQBtRsODC4PS0Q=="], - "node-abi": ["node-abi@3.94.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g=="], - "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], @@ -730,8 +688,6 @@ "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "oxlint": ["oxlint@1.73.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.73.0", "@oxlint/binding-android-arm64": "1.73.0", "@oxlint/binding-darwin-arm64": "1.73.0", "@oxlint/binding-darwin-x64": "1.73.0", "@oxlint/binding-freebsd-x64": "1.73.0", "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", "@oxlint/binding-linux-arm-musleabihf": "1.73.0", "@oxlint/binding-linux-arm64-gnu": "1.73.0", "@oxlint/binding-linux-arm64-musl": "1.73.0", "@oxlint/binding-linux-ppc64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-musl": "1.73.0", "@oxlint/binding-linux-s390x-gnu": "1.73.0", "@oxlint/binding-linux-x64-gnu": "1.73.0", "@oxlint/binding-linux-x64-musl": "1.73.0", "@oxlint/binding-openharmony-arm64": "1.73.0", "@oxlint/binding-win32-arm64-msvc": "1.73.0", "@oxlint/binding-win32-ia32-msvc": "1.73.0", "@oxlint/binding-win32-x64-msvc": "1.73.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ=="], "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], @@ -774,28 +730,20 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "railway": ["railway@3.5.7", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "graphql": "^16.10.0", "tsx": "^4.20.0" }, "bin": { "railway-iac-ts": "dist/iac/bin.js" } }, "sha512-2DdaAw2eSQuCtSn+/VuPNOdnbynxTVmRIQlefhc0maULZTr16Uy/5/FjumnI1lONZgflKG/WMGm9QmUdnApZFw=="], - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -808,8 +756,6 @@ "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "satori": ["satori@0.28.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-FhOx2irXIrxbNpPyE0x/+k3p8G+FVWfaTPAKuwMaB4kk02PehHFumYyJ4NtiLNC7moNAspeDt06S+DqUjJ9Fsg=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], @@ -826,10 +772,6 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], - - "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -850,22 +792,14 @@ "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], - - "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], - "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -888,8 +822,6 @@ "tsx": ["tsx@4.23.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ=="], - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -912,8 +844,6 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], "vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], @@ -936,8 +866,6 @@ "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], @@ -980,14 +908,10 @@ "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], - "buffer/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], "nitro/consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "node-abi/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], diff --git a/docker-compose.yml b/docker-compose.yml index 6229864..9c5ce92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,10 @@ # database to talk to locally. Data lives in a named volume. services: postgres: - image: postgres:17-alpine + # Same major as production (Railway runs postgres-ssl:18). Bumping this + # major invalidates the existing volume: `docker compose down -v` then + # `bun run db:migrate`. + image: postgres:18-alpine restart: unless-stopped environment: POSTGRES_USER: superposition diff --git a/docs/superpowers/specs/2026-08-07-version-finale-design.md b/docs/superpowers/specs/2026-08-07-version-finale-design.md new file mode 100644 index 0000000..551b2c8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-version-finale-design.md @@ -0,0 +1,241 @@ +# La version finale — la passe de finition + +Statut : validé en brainstorming le 2026-08-07. + +## Le problème + +Le dépôt n'est pas en dette au sens habituel. L'état mesuré avant d'écrire cette +spec : + +``` +bun run lint → exit 0 (10 warnings react/only-export-components, inhérents + aux modules de route TanStack) +tsc --noEmit → exit 0 +bun run test → exit 0 — 33 fichiers, 320 tests +bun run build → exit 0 — 665 Ko de JS client brut, sortie Nitro complète +git status → propre +``` + +Passer en 1.0 ne consiste donc pas à réparer, mais à fermer quatre écarts qui +tirent chacun dans une direction différente : + +1. **Aucune frontière de route à la racine.** Ni `notFoundComponent` ni + `errorComponent` sur `__root.tsx` ; seul `daily.$tier` en porte un. Une URL + inconnue, ou une erreur de loader, tombe sur l'écran par défaut du routeur — + hors direction artistique, non localisé, sans issue vers le jeu. C'est le seul + trou objectif de la revue. +2. **L'écart `ever_clean`**, consigné dans « Next steps » d'AGENTS.md depuis la + synchro de progression : le serveur garde UNE ligne par niveau, pas un + historique. Un joueur dont le run sans retouche n'était pas son meilleur score + n'a aucun fait « déjà résolu proprement » à récupérer sur un nouvel appareil. + Son sceau est perdu avec son localStorage. +3. **~30 symboles exportés sans consommateur externe** (`FAMILIES`, `SHADE_HEX`, + `randomLevel`, les `*Relations` du schéma, une douzaine de types internes). + Du bruit dans la surface publique des modules, pas de la dette de + comportement. +4. **Pas de sitemap, `robots.txt` minimal** (`Disallow:` seul, `/api/` ouvert au + crawl, aucune directive `Sitemap:`), et AGENTS.md qui décrit encore l'écart + `ever_clean` comme ouvert. + +## Le découpage retenu + +**Un lot par axe, une branche par lot**, mergés dans l'ordre du risque : +frontières → `ever_clean` → hygiène → release. + +Le lot `ever_clean` touche le schéma Postgres. Le mélanger à une suppression +d'exports dans un même diff donnerait un merge qu'on ne sait plus bisecter ni +révoquer proprement. Quatre branches courtes, chacune révisable et +`git revert`-able seule, contre un cycle de vérification par lot : c'est le bon +échange ici, et c'est ce que demandent déjà les règles git du dépôt (une branche +par changement, ~150 lignes de diff visées). + +--- + +## Lot 1 — Les frontières de route + +Branche `fix/route-boundaries`. + +### La forme + +**Un seul composant pour les deux cas.** `src/ui/screens/FallbackScreen.tsx`, +paramétré par titre, corps et action, monté deux fois depuis `__root.tsx` : +`notFoundComponent` et `errorComponent`. + +Deux écrans séparés dériveraient : c'est la même page — « tu es sorti de +l'atelier, voici la porte » — avec deux textes. Le dépôt tranche déjà ce genre de +question par un propriétaire unique (cf. le tableau « Don't recreate » +d'AGENTS.md) ; un écran de repli ne fait pas exception. + +### Le contenu + +Direction artistique table lumineuse, comme le reste. Textes ajoutés à +`project.inlang/messages/fr.json` et `en.json` — rien en dur dans le composant. + +- **404** : le titre, une ligne, un retour vers `/levels`. +- **Erreur** : le titre, une ligne, un « réessayer » qui appelle + `router.invalidate()`, et le même retour vers `/levels`. + +L'`errorComponent` **n'affiche jamais la stack ni le message de l'erreur**. Une +erreur de loader porte des détails serveur (requête, chemin, parfois un fragment +de SQL) ; les rendre au visiteur est une fuite d'information pour un gain nul — +il ne peut rien en faire. L'erreur reste dans les logs serveur. + +### Le rendu côté serveur + +La racine est en `ssr: true` (pour que le profil public puisse rendre +server-side). Le `FallbackScreen` ne touche donc aucune API navigateur : +pas d'`AudioContext`, pas de `localStorage`, pas de `window` au premier rendu. +C'est une contrainte du composant, pas une option. + +### Vérification + +`FallbackScreen.test.tsx`, projet `dom` : rend les deux modes, vérifie que le +texte vient bien des messages et que l'action de retour est câblée. Plus un +passage manuel sur `/nimportequoi`. + +--- + +## Lot 2 — L'écart `ever_clean` + +Branche `feat/ever-clean`. + +### Le schéma + +Une colonne sur `level_score` : + +```ts +everClean: boolean("ever_clean").notNull().default(false), +``` + +Migration générée par `drizzle-kit generate`, **plus un backfill écrit à la main +dans le même fichier SQL** : + +```sql +UPDATE level_score SET ever_clean = true WHERE undos = 0; +``` + +Sans ce backfill, tout joueur dont la meilleure ligne actuelle est propre +perdrait son sceau à la première resynchro — la migration créerait l'écart +qu'elle est censée fermer. + +### Le point délicat : l'écriture ne peut pas passer par `upsertBestScore` + +`upsertBestScore` garde son `onConflictDoUpdate` par `beatenBy(...)`. Un run sans +retouche qui **ne bat pas** la ligne stockée n'écrirait donc rien — et c'est +exactement le cas que ce lot existe pour fermer. Y greffer le `ever_clean` +donnerait une colonne qui ne se remplit que dans les cas déjà couverts. + +Donc **deux instructions** dans `submitLevelScore` : + +1. l'upsert existant, inchangé ; +2. puis, si `result.corrections === 0`, un + `UPDATE level_score SET ever_clean = true WHERE level_id = ? AND user_id = ?`. + +Idempotent, et la ligne existe forcément après l'étape 1. Un aller-retour DB de +plus sur le seul chemin d'une soumission propre. + +**Ce code vit dans `src/server/campaign.ts`, pas dans `leaderboard.ts`.** +`leaderboard.ts` est le module partagé entre les deux tableaux ; la colonne +n'existe que sur `level_score`. L'y mettre imposerait un troisième garde +`everCleanColumn(table)` à côté de `elapsedColumn` pour une règle qui n'a qu'un +seul appelant. + +### La lecture + +`getMyLevelScores` renvoie `everClean` en plus de `moves` et `undos`. +`ServerScore` (dans `src/ui/progressSync.ts`) le porte. `asWin` lit +`clean: s.everClean` au lieu de `s.undos === 0`. + +### Ce qui ne bouge pas, volontairement + +- **Le sceau des tableaux.** `LeaderRow.clean` et `MyResult.clean` restent + dérivés de la ligne affichée (`undos === 0`). Un tableau qui classerait une + ligne sur ses coups tout en la scellant d'après un autre run mentirait sur ce + qu'il montre. `ever_clean` est un fait de **progression** (le registre local), + pas un fait de **classement**. +- **`planUploads`.** Sa règle de départage — sur égalité de coups, seul un tracé + local propre vaut un envoi — porte sur la ligne stockée, qui garde sa + sémantique. Aucune raison de la toucher. + +### Vérification + +- `progressSync.test.ts` étendu sur `asWin`. +- **Limite assumée** : le dépôt n'a pas de harnais de test contre Postgres. La + partie SQL — la migration, le backfill, la collance de l'`UPDATE` — sera + vérifiée à la main contre le Postgres de `docker-compose.yml`, pas par un test + automatisé. Le scénario manuel : résoudre proprement sans battre son record, + vider le localStorage, se reconnecter, constater que le sceau revient. + +### Documentation + +La note « Residual gap » de « Next steps » dans AGENTS.md disparaît ; une ligne +entre au tableau « Don't recreate » : le fait « déjà résolu proprement » a un +propriétaire unique, la colonne `level_score.ever_clean` écrite par +`submitLevelScore`. + +--- + +## Lot 3 — L'hygiène + +Branche `chore/dead-exports`. + +Les ~30 symboles exportés sans consommateur hors de leur propre fichier, triés en +trois : + +- **contrat de framework** (`startInstance`, `getRouter`, les `*Relations` de + Drizzle) — on ne touche pas ; +- **type interne** — on retire le `export` ; +- **réellement mort** — on supprime. + +Zéro changement de comportement. Vérifié par `tsc --noEmit`, les 320 tests et le +build : un symbole retiré à tort casse la compilation, ce qui rend ce lot sûr par +construction. + +**Hors périmètre, signalé** : `src/ui/screens/PlayScreen.tsx` fait 422 lignes, le +plus gros fichier du dépôt, et porte visiblement plusieurs responsabilités. Le +découper est un refactor à part entière — pas de l'hygiène, et pas quelque chose +qu'on glisse dans une passe de finition. La note reste, la décision est +ultérieure. + +--- + +## Lot 4 — La release + +Branche `chore/release-1.0`. + +### `sitemap.xml` et `robots.txt` deviennent des routes serveur + +La directive `Sitemap:` exige une **URL absolue** : les deux fichiers ont donc +besoin de l'origine, que seul le serveur connaît (`BETTER_AUTH_URL`). Les garder +statiques dans `public/` obligerait à coder l'origine de production en dur dans +le dépôt. Deux routes, montées à côté des routes API existantes ; +`public/robots.txt` est supprimé (une route et un fichier statique de même nom se +disputeraient la même URL). + +Le sitemap liste **les routes statiques seulement** : `/`, `/levels`, +`/level/1` à `/level/22`, `/align`. Les profils sont des données utilisateur — +publiables une par une, mais pas énumérables dans un fichier que l'on sert à tout +le monde. `robots.txt` ferme `/api/`. + +### Documentation et livraison + +README et AGENTS.md remis en phase avec le dépôt. Puis merge des quatre branches +dans `main` dans l'ordre ci-dessus, et tag `v1.0.0`. Le déploiement Railway reste +un geste de l'auteur. + +--- + +## Vérification, par lot + +Après chaque lot, les quatre commandes et leurs codes de sortie collés au +rapport : + +``` +bun run lint +tsc --noEmit +bun run test +bun run build +``` + +Le lot 2 ajoute `bun run db:migrate` contre le Postgres local et le scénario +manuel décrit plus haut. diff --git a/package.json b/package.json index c67a48b..22a2d46 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "superposition", "private": true, "type": "module", + "packageManager": "bun@1.4.0", "imports": { "#/*": "./src/*" }, @@ -13,7 +14,7 @@ "gen:icons": "bun scripts/gen-icons.ts", "start": "bun .output/server/index.mjs", "preview": "vite preview", - "lint": "oxlint", + "lint": "oxlint --max-warnings=12", "test": "bun run paraglide && vitest run", "verify": "bun src/solver/verify.ts", "gen": "bun src/solver/generate.ts", diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 4f0f7d6..3da8264 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -133,6 +133,12 @@ "offline_daily_back": "back to the boards", "error_daily_title": "Something went wrong", "error_daily_body": "The daily challenge failed to load. Try again in a moment.", + "fallback_not_found_title": "No such plate", + "fallback_not_found_body": "This page was never pulled. The edition, though, is still on the table.", + "fallback_error_title": "The pull failed", + "fallback_error_body": "Something slipped out of register on the way. We can run the sheet again.", + "fallback_retry": "run the sheet again", + "fallback_back": "back to the boards", "daily_solved": [ { "declarations": ["input count", "local countPlural = count: plural"], @@ -215,6 +221,7 @@ "profile_more": "more", "profile_empty": "No prints yet — solve today's puzzle.", "profile_signin_prompt": "Sign in to see your prints.", + "profile_signout": "leave the table", "profile_edition_title": "The edition", "profile_bat_total": "ready to print", "profile_clean_total": "no touch-ups", @@ -230,6 +237,9 @@ "profile_stamp_pending": "to come", "profile_stamp_goal": "{next} to reach", "profile_stamp_earned": "{family} · {threshold}, earned on {date}", + "profile_stamp_sissi": "Sissi", + "profile_stamp_commemorative": "{name} — special issue, earned on {date}", + "profile_horsserie_title": "Special issue", "flash_no_split": "Can't split here.", "flash_no_shift": "Shift blocked — capped at ±{max}.", "rule_decalage_aligned": "⇄ World (or {alt}) slides the magenta film. The registration marks must line up at the end.", diff --git a/project.inlang/messages/fr.json b/project.inlang/messages/fr.json index 5b8d696..cb7bf7b 100644 --- a/project.inlang/messages/fr.json +++ b/project.inlang/messages/fr.json @@ -133,6 +133,12 @@ "offline_daily_back": "retour aux planches", "error_daily_title": "Une erreur est survenue", "error_daily_body": "Le défi du jour n'a pas pu se charger. Réessayez dans un instant.", + "fallback_not_found_title": "Planche introuvable", + "fallback_not_found_body": "Cette page n'a jamais été tirée. L'édition, elle, est toujours sur la table.", + "fallback_error_title": "Le tirage a raté", + "fallback_error_body": "Quelque chose s'est décalé en chemin. On peut repasser la feuille.", + "fallback_retry": "repasser la feuille", + "fallback_back": "retour aux planches", "daily_solved": [ { "declarations": ["input count", "local countPlural = count: plural"], @@ -215,6 +221,7 @@ "profile_more": "plus", "profile_empty": "Aucun tirage encore — résous le défi du jour.", "profile_signin_prompt": "Connecte-toi pour voir tes tirages.", + "profile_signout": "quitter la table", "profile_edition_title": "L'édition", "profile_bat_total": "bons à tirer", "profile_clean_total": "sans retouche", @@ -230,6 +237,9 @@ "profile_stamp_pending": "à venir", "profile_stamp_goal": "{next} à atteindre", "profile_stamp_earned": "{family} · {threshold}, décroché le {date}", + "profile_stamp_sissi": "Sissi", + "profile_stamp_commemorative": "{name} — hors-série, décroché le {date}", + "profile_horsserie_title": "Hors-série", "flash_no_split": "Scission impossible ici.", "flash_no_shift": "Décalage impossible — borné à ±{max}.", "rule_decalage_aligned": "⇄ Monde (ou {alt}) glisse le film magenta. Les croix de repérage doivent finir alignées.", diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index e9e57dc..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/src/db/migrations/0010_foamy_emma_frost.sql b/src/db/migrations/0010_foamy_emma_frost.sql new file mode 100644 index 0000000..77fd8ab --- /dev/null +++ b/src/db/migrations/0010_foamy_emma_frost.sql @@ -0,0 +1,7 @@ +ALTER TABLE "level_score" ADD COLUMN "ever_clean" boolean DEFAULT false NOT NULL;--> statement-breakpoint +-- Hand-written data step. Without it the column lands false everywhere, and any +-- player whose current best row is already clean would lose their seal on the +-- first resync — the migration would dig the very gap it closes. A row with zero +-- corrections IS proof of a "sans retouche" run, so the backfill reads it +-- straight off. Idempotent. +UPDATE "level_score" SET "ever_clean" = true WHERE "undos" = 0; diff --git a/src/db/migrations/meta/0010_snapshot.json b/src/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..a91d2df --- /dev/null +++ b/src/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,747 @@ +{ + "id": "42259754-60da-4bd4-aa91-8f71c255ce25", + "prevId": "71b0e97e-bee2-41b5-afa3-22e1b6a4a742", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_puzzle": { + "name": "daily_puzzle", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "level": { + "name": "level", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "generated": { + "name": "generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "optimal": { + "name": "optimal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_puzzle_date_tier_pk": { + "name": "daily_puzzle_date_tier_pk", + "columns": [ + "date", + "tier" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_score": { + "name": "daily_score", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moves": { + "name": "moves", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "undos": { + "name": "undos", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_ms": { + "name": "elapsed_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "daily_score_date_tier_user": { + "name": "daily_score_date_tier_user", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "daily_score_user_id_user_id_fk": { + "name": "daily_score_user_id_user_id_fk", + "tableFrom": "daily_score", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "daily_score_date_tier_daily_puzzle_date_tier_fk": { + "name": "daily_score_date_tier_daily_puzzle_date_tier_fk", + "tableFrom": "daily_score", + "tableTo": "daily_puzzle", + "columnsFrom": [ + "date", + "tier" + ], + "columnsTo": [ + "date", + "tier" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_view": { + "name": "daily_view", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "served_at": { + "name": "served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "certified": { + "name": "certified", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "daily_view_user_id_user_id_fk": { + "name": "daily_view_user_id_user_id_fk", + "tableFrom": "daily_view", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "daily_view_date_tier_user_id_pk": { + "name": "daily_view_date_tier_user_id_pk", + "columns": [ + "date", + "tier", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.level_score": { + "name": "level_score", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "level_id": { + "name": "level_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moves": { + "name": "moves", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "undos": { + "name": "undos", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ever_clean": { + "name": "ever_clean", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "level_score_level_user": { + "name": "level_score_level_user", + "columns": [ + { + "expression": "level_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "level_score_user_id_user_id_fk": { + "name": "level_score_user_id_user_id_fk", + "tableFrom": "level_score", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 58b3ee7..d7493fc 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1785426776812, "tag": "0009_conscious_tempest", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1786135862775, + "tag": "0010_foamy_emma_frost", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index d86e1d4..e5e850c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -218,6 +218,11 @@ export const levelScore = pgTable( .references(() => user.id, { onDelete: "cascade" }), moves: integer("moves").notNull(), undos: integer("undos").notNull().default(0), + // The row is a BEST score, not a history: a "sans retouche" run that does + // not beat the stored row leaves no trace in it. This flag keeps the "ever + // solved cleanly" fact across every replacement of the row — outside the + // player's own localStorage it is the only memory of the seal. + everClean: boolean("ever_clean").notNull().default(false), // physical column is legacy-named "inputs"; it stores the full TraceStep[] trace: jsonb("inputs").$type().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), diff --git a/src/engine/mechanics/registry.ts b/src/engine/mechanics/registry.ts index 4c44fb5..4f2e1c9 100644 --- a/src/engine/mechanics/registry.ts +++ b/src/engine/mechanics/registry.ts @@ -22,7 +22,7 @@ export const MECHANICS: Record = { /** Per-level derived mechanics, computed once — successors() runs per BFS * expansion, so anything re-derivable from the immutable level is hoisted here. */ -export interface CompiledMechanics { +interface CompiledMechanics { mechs: Mechanic[]; resolveMove?: (pos: Pos, dir: Pos, ctx: MoveCtx) => Pos; mapDirB?: (dir: Pos) => Pos; diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 2e08948..45bdb42 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -4,6 +4,6 @@ import { createAuthClient } from "better-auth/react"; import { usernameClient } from "better-auth/client/plugins"; -export const authClient = createAuthClient({ plugins: [usernameClient()] }); +const authClient = createAuthClient({ plugins: [usernameClient()] }); export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/src/lib/commemoratives.ts b/src/lib/commemoratives.ts new file mode 100644 index 0000000..de01d66 --- /dev/null +++ b/src/lib/commemoratives.ts @@ -0,0 +1,27 @@ +// The hors-série: stamps that are NOT part of the current series. +// +// The four families of `distinctions.ts` are the série courante — always four, +// bounded by construction, and every profile shows all four (earned, or as an +// empty album mount). A commemorative is the opposite on every count: it has no +// tiers, no threshold and no face value to climb; you either hold it or you do +// not; and a profile that does not hold one shows NOTHING, because a stamp that +// was never put on sale cannot be missing from an album. +// +// That asymmetry is why this is its own module rather than a fifth family: +// issuing one here can never widen the series row, so the page's layout stays +// fixed however many commemoratives get struck. +// +// Nothing grants `sissi` yet. The easter egg that hands it over is not designed, +// so the stamp is issued but unobtainable and every profile carries an empty +// list. When the trigger exists it only has to fill that list — the artwork, the +// layout and the profile plumbing are already in place. + +type Commemorative = "sissi"; + +/** A commemorative a player holds, with the day it was struck — the postmark. + * Mirrors what a series stamp carries, so `Stamp` can render either. */ +export interface Held { + readonly key: Commemorative; + /** YYYY-MM-DD (UTC). */ + readonly earnedOn: string; +} diff --git a/src/lib/contribGrid.ts b/src/lib/contribGrid.ts index 20f0ff0..d0cbb58 100644 --- a/src/lib/contribGrid.ts +++ b/src/lib/contribGrid.ts @@ -1,26 +1,23 @@ -// The contribution grid geometry, shared by the on-screen ContributionGraph and -// the server-rendered OG card so the two can't drift. Pure: it lays out one -// calendar year (Jan–Dec) of played days as Monday-first week columns, GitHub -// style. Colour/markup is the caller's job — this owns only the shape. +// The contribution grid geometry behind the on-screen ContributionGraph. Pure: +// it lays out one calendar year (Jan–Dec) of played days as Monday-first week +// columns, GitHub style. Colour/markup is the caller's job — this owns only the +// shape. import { shiftDay } from "./day.ts"; -export interface DayCount { +interface DayCount { date: string; count: number; } -export interface GridCell { +interface GridCell { date: string; count: number; spacer: boolean; // a pad cell: outside the year, or a future day not yet played } // The completion ramp, indexed by tiers solved (0 = empty base … 4 = amber lock). -// Two representations of the SAME ramp, kept adjacent so a retint touches both: -// the on-screen grid blends alpha over its panel (Tailwind opacity classes), the -// OG card has no alpha-over-background so it needs pre-blended opaque hex. Keep -// these two arrays visually in sync. +// Tailwind opacity classes: the on-screen grid blends alpha over its panel. export const SHADE_CLASSES = [ "bg-paper/[0.07]", "bg-paper/25", @@ -28,13 +25,6 @@ export const SHADE_CLASSES = [ "bg-paper/70", "bg-tape/90", ] as const; -export const SHADE_HEX = [ - "#2a2620", - "#4a453c", - "#7d766a", - "#b8b0a2", - "#e8b84b", -] as const; /** Monday-first weekday index (0 = Monday … 6 = Sunday). */ function mondayIndex(date: string): number { diff --git a/src/lib/distinctions.ts b/src/lib/distinctions.ts index c1b40d6..9402b0c 100644 --- a/src/lib/distinctions.ts +++ b/src/lib/distinctions.ts @@ -13,14 +13,6 @@ import { shiftDay } from "./day.ts"; export type Family = "regularite" | "maitrise" | "rarete" | "edition"; -/** The four families, in the order they are printed on the sheet. */ -export const FAMILIES: readonly Family[] = [ - "regularite", - "maitrise", - "rarete", - "edition", -] as const; - /** Each family's four face values. Ascending, always four. */ export const THRESHOLDS: Record = { regularite: [7, 30, 100, 365], @@ -122,9 +114,9 @@ function resolve( }; } -/** The four distinctions, always all four and always in `FAMILIES` order. A - * family the player has not opened yet comes back at tier 0 with `next` set — - * that is the empty album mount, not an absence. */ +/** The four distinctions, always all four and always in the order they are + * printed on the sheet. A family the player has not opened yet comes back at + * tier 0 with `next` set — that is the empty album mount, not an absence. */ export function distinctions(input: DistinctionInput): Distinction[] { const runs = runsOf(input.days); diff --git a/src/lib/streak.ts b/src/lib/streak.ts index d3ad598..830fc25 100644 --- a/src/lib/streak.ts +++ b/src/lib/streak.ts @@ -4,7 +4,7 @@ import { shiftDay } from "./day.ts"; -export interface Streaks { +interface Streaks { total: number; // distinct days played current: number; // consecutive days ending today (or yesterday, grace window) longest: number; // longest consecutive run ever diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b521e43..c6df1f0 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,6 +9,8 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' +import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as LevelsRouteImport } from './routes/levels' import { Route as AlignRouteImport } from './routes/align' import { Route as IndexRouteImport } from './routes/index' @@ -16,10 +18,21 @@ import { Route as ProfileMeRouteImport } from './routes/profile.me' import { Route as ProfileUsernameRouteImport } from './routes/profile.$username' import { Route as LevelPlateRouteImport } from './routes/level.$plate' import { Route as DailyTierRouteImport } from './routes/daily.$tier' +import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiReplaySplatRouteImport } from './routes/api/replay/$' import { Route as ApiOgUsernameRouteImport } from './routes/api/og/$username' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ + id: '/sitemap.xml', + path: '/sitemap.xml', + getParentRoute: () => rootRouteImport, +} as any) +const RobotsDottxtRoute = RobotsDottxtRouteImport.update({ + id: '/robots.txt', + path: '/robots.txt', + getParentRoute: () => rootRouteImport, +} as any) const LevelsRoute = LevelsRouteImport.update({ id: '/levels', path: '/levels', @@ -55,6 +68,11 @@ const DailyTierRoute = DailyTierRouteImport.update({ path: '/daily/$tier', getParentRoute: () => rootRouteImport, } as any) +const ApiHealthRoute = ApiHealthRouteImport.update({ + id: '/api/health', + path: '/api/health', + getParentRoute: () => rootRouteImport, +} as any) const ApiReplaySplatRoute = ApiReplaySplatRouteImport.update({ id: '/api/replay/$', path: '/api/replay/$', @@ -75,6 +93,9 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/align': typeof AlignRoute '/levels': typeof LevelsRoute + '/robots.txt': typeof RobotsDottxtRoute + '/sitemap.xml': typeof SitemapDotxmlRoute + '/api/health': typeof ApiHealthRoute '/daily/$tier': typeof DailyTierRoute '/level/$plate': typeof LevelPlateRoute '/profile/$username': typeof ProfileUsernameRoute @@ -87,6 +108,9 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/align': typeof AlignRoute '/levels': typeof LevelsRoute + '/robots.txt': typeof RobotsDottxtRoute + '/sitemap.xml': typeof SitemapDotxmlRoute + '/api/health': typeof ApiHealthRoute '/daily/$tier': typeof DailyTierRoute '/level/$plate': typeof LevelPlateRoute '/profile/$username': typeof ProfileUsernameRoute @@ -100,6 +124,9 @@ export interface FileRoutesById { '/': typeof IndexRoute '/align': typeof AlignRoute '/levels': typeof LevelsRoute + '/robots.txt': typeof RobotsDottxtRoute + '/sitemap.xml': typeof SitemapDotxmlRoute + '/api/health': typeof ApiHealthRoute '/daily/$tier': typeof DailyTierRoute '/level/$plate': typeof LevelPlateRoute '/profile/$username': typeof ProfileUsernameRoute @@ -114,6 +141,9 @@ export interface FileRouteTypes { | '/' | '/align' | '/levels' + | '/robots.txt' + | '/sitemap.xml' + | '/api/health' | '/daily/$tier' | '/level/$plate' | '/profile/$username' @@ -126,6 +156,9 @@ export interface FileRouteTypes { | '/' | '/align' | '/levels' + | '/robots.txt' + | '/sitemap.xml' + | '/api/health' | '/daily/$tier' | '/level/$plate' | '/profile/$username' @@ -138,6 +171,9 @@ export interface FileRouteTypes { | '/' | '/align' | '/levels' + | '/robots.txt' + | '/sitemap.xml' + | '/api/health' | '/daily/$tier' | '/level/$plate' | '/profile/$username' @@ -151,6 +187,9 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AlignRoute: typeof AlignRoute LevelsRoute: typeof LevelsRoute + RobotsDottxtRoute: typeof RobotsDottxtRoute + SitemapDotxmlRoute: typeof SitemapDotxmlRoute + ApiHealthRoute: typeof ApiHealthRoute DailyTierRoute: typeof DailyTierRoute LevelPlateRoute: typeof LevelPlateRoute ProfileUsernameRoute: typeof ProfileUsernameRoute @@ -162,6 +201,20 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/sitemap.xml': { + id: '/sitemap.xml' + path: '/sitemap.xml' + fullPath: '/sitemap.xml' + preLoaderRoute: typeof SitemapDotxmlRouteImport + parentRoute: typeof rootRouteImport + } + '/robots.txt': { + id: '/robots.txt' + path: '/robots.txt' + fullPath: '/robots.txt' + preLoaderRoute: typeof RobotsDottxtRouteImport + parentRoute: typeof rootRouteImport + } '/levels': { id: '/levels' path: '/levels' @@ -211,6 +264,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DailyTierRouteImport parentRoute: typeof rootRouteImport } + '/api/health': { + id: '/api/health' + path: '/api/health' + fullPath: '/api/health' + preLoaderRoute: typeof ApiHealthRouteImport + parentRoute: typeof rootRouteImport + } '/api/replay/$': { id: '/api/replay/$' path: '/api/replay/$' @@ -239,6 +299,9 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AlignRoute: AlignRoute, LevelsRoute: LevelsRoute, + RobotsDottxtRoute: RobotsDottxtRoute, + SitemapDotxmlRoute: SitemapDotxmlRoute, + ApiHealthRoute: ApiHealthRoute, DailyTierRoute: DailyTierRoute, LevelPlateRoute: LevelPlateRoute, ProfileUsernameRoute: ProfileUsernameRoute, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 5c5921c..2f1a555 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,7 +1,14 @@ import { useEffect } from "react"; -import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; +import { + HeadContent, + Scripts, + createRootRoute, + useNavigate, + useRouter, +} from "@tanstack/react-router"; import appCss from "../index.css?url"; +import { FallbackScreen } from "../ui/screens/FallbackScreen.tsx"; import { m } from "../paraglide/messages.js"; import { cookieName, @@ -88,9 +95,44 @@ export const Route = createRootRoute({ ], }; }, + // The app's two boundaries, mounted here because an unknown URL matches no + // child route: without them the visitor lands on the router's default screen — + // outside the art direction, unlocalised, with no way back into the game. One + // screen renders both. + notFoundComponent: NotFound, + errorComponent: RouteError, shellComponent: RootDocument, }); +function NotFound() { + const navigate = useNavigate(); + return ( + navigate({ to: "/levels" })} + /> + ); +} + +// The error itself is never rendered: a loader failure carries server detail +// (the query, a path, sometimes a fragment of SQL) the visitor cannot act on and +// has no business seeing. It stays in the logs. +function RouteError() { + const router = useRouter(); + const navigate = useNavigate(); + return ( + navigate({ to: "/levels" })} + // invalidate() reruns the loader AND rearms the boundary; merely + // remounting the component would replay the same failure + onRetry={() => router.invalidate()} + /> + ); +} + function RootDocument({ children }: { children: React.ReactNode }) { // One-time migration off the old localStorage strategy. Before the switch to // the cookie strategy the chosen locale lived in localStorage; those visitors diff --git a/src/routes/api/health.ts b/src/routes/api/health.ts new file mode 100644 index 0000000..9e19b44 --- /dev/null +++ b/src/routes/api/health.ts @@ -0,0 +1,38 @@ +// Deployment healthcheck (`healthcheckPath` in .railway/railway.ts). Answers +// 200 only if this instance can reach Postgres; 503 otherwise. The old target, +// `/`, served the SPA shell and answered 200 to an instance with no database. +// +// The body stays opaque on failure: this route is public, and a driver error +// message carries the host and user of the connection. The reason goes to the +// logs instead. + +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/api/health")({ + server: { + handlers: { + GET: async () => { + // import.meta.env.SSR is statically false in the client build, so this + // branch — and the `pg` pool it imports — is dead-code-eliminated from + // the client bundle. Same shape as /api/replay/$. + if (!import.meta.env.SSR) return new Response(null, { status: 404 }); + + const [{ probe }, { pool }] = await Promise.all([ + import("../../server/health.ts"), + import("../../db/index.ts"), + ]); + const health = await probe(pool); + if (!health.ok) + console.error("[health] database unreachable:", health.error); + + return Response.json( + { status: health.ok ? "ok" : "degraded" }, + { + status: health.ok ? 200 : 503, + headers: { "cache-control": "no-store" }, + }, + ); + }, + }, + }, +}); diff --git a/src/routes/profile.me.tsx b/src/routes/profile.me.tsx index 337ff0e..9974269 100644 --- a/src/routes/profile.me.tsx +++ b/src/routes/profile.me.tsx @@ -7,7 +7,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { m } from "../paraglide/messages.js"; import { getMyDailyHistory, type DailyHistory } from "../server/profile.ts"; -import { useSession } from "../lib/auth-client.ts"; +import { signOut, useSession } from "../lib/auth-client.ts"; import { utcDay } from "../lib/day.ts"; import { ProfileScreen } from "../ui/screens/ProfileScreen.tsx"; import { AuthPanel } from "../ui/components/AuthPanel.tsx"; @@ -41,9 +41,26 @@ function MeRoute() { const back = () => navigate({ to: "/levels" }); + // Better Auth clears its session store on /sign-out exactly as it fills it on + // /sign-in, so `useSession` flips and this page would fall through to its own + // sign-in gate — a private page inviting the player back in, with the account + // they just put down. Leave for the edition instead: that is already where + // the gate's own way out points. Navigating from onSuccess so a request that + // never landed leaves both the session and the page as they were. + const leave = () => { + void signOut({ fetchOptions: { onSuccess: back } }); + }; + // signed in with history loaded — the real page if (session && history) - return ; + return ( + + ); // the fetch failed while signed in — offer a way out rather than a dead-end // blank table (a transient error would otherwise strand the page forever) diff --git a/src/routes/robots[.]txt.ts b/src/routes/robots[.]txt.ts new file mode 100644 index 0000000..a787228 --- /dev/null +++ b/src/routes/robots[.]txt.ts @@ -0,0 +1,20 @@ +// robots.txt, served as a route rather than a file in public/ because its +// `Sitemap:` directive needs an absolute URL, and only the server knows the +// origin (BETTER_AUTH_URL). The brackets escape the dot, which file-based +// routing would otherwise read as a path separator (/robots/txt). +// +// The document itself is built in server/seo.ts, pure and tested there. + +import { createFileRoute } from "@tanstack/react-router"; +import { robotsTxt } from "../server/seo.ts"; + +export const Route = createFileRoute("/robots.txt")({ + server: { + handlers: { + GET: () => + new Response(robotsTxt(process.env.BETTER_AUTH_URL), { + headers: { "content-type": "text/plain; charset=utf-8" }, + }), + }, + }, +}); diff --git a/src/routes/sitemap[.]xml.ts b/src/routes/sitemap[.]xml.ts new file mode 100644 index 0000000..683d977 --- /dev/null +++ b/src/routes/sitemap[.]xml.ts @@ -0,0 +1,23 @@ +// sitemap.xml, a route for the same reason as robots.txt: every must be +// absolute, and the origin lives in BETTER_AUTH_URL. The brackets escape the +// dot for file-based routing. +// +// The document itself is built in server/seo.ts, pure and tested there; with no +// origin configured it declines to exist rather than emit relative URLs. + +import { createFileRoute } from "@tanstack/react-router"; +import { sitemapXml } from "../server/seo.ts"; + +export const Route = createFileRoute("/sitemap.xml")({ + server: { + handlers: { + GET: () => { + const body = sitemapXml(process.env.BETTER_AUTH_URL); + if (body === null) return new Response(null, { status: 404 }); + return new Response(body, { + headers: { "content-type": "application/xml; charset=utf-8" }, + }); + }, + }, + }, +}); diff --git a/src/server/campaign.ts b/src/server/campaign.ts index b45734a..96ac037 100644 --- a/src/server/campaign.ts +++ b/src/server/campaign.ts @@ -4,7 +4,7 @@ // through the pure engine (see replay.ts) before a row is written. import { createServerFn } from "@tanstack/react-start"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { levelScore } from "../db/schema.ts"; import { db } from "../db/index.ts"; import { LEVELS } from "../engine/levels.ts"; @@ -66,6 +66,26 @@ export interface SubmitResult { moves: number; } +/** + * Stamps the "ever solved cleanly" fact onto a player's row for a level. + * + * A second statement rather than one more field on `upsertBestScore`, because + * that upsert's `onConflictDoUpdate` is gated by `beatenBy(...)`: a "sans + * retouche" run that does NOT beat the stored row writes nothing — precisely + * the case this column exists to cover. Grafted onto the upsert, it would only + * ever fill in where the seal was already recoverable. + * + * It lives here and not in leaderboard.ts because the column exists on + * `level_score` alone; sharing it would mean a per-table guard (cf. + * `elapsedColumn`) for a rule with exactly one caller. + */ +async function markEverClean(levelId: string, userId: string): Promise { + await db + .update(levelScore) + .set({ everClean: true }) + .where(and(eq(levelScore.levelId, levelId), eq(levelScore.userId, userId))); +} + /** * Records a player's best result for a campaign level. The server re-resolves * the level from the code bank (never from the client) and replays the full @@ -94,6 +114,9 @@ export const submitLevelScore = createServerFn({ method: "POST" }) trace: data.trace, }); + // the row is guaranteed to exist after the upsert, so this always lands + if (result.corrections === 0) await markEverClean(data.levelId, userId); + return { ok: true, moves: result.moves }; }); @@ -101,13 +124,16 @@ export const submitLevelScore = createServerFn({ method: "POST" }) * shape: returns `[]` when not signed in (no throw). Feeds the client's * login-time progress reconciliation (useProgressSync). * - * `undos` rides along so a player arriving on a new device recovers their - * "sans retouche" seals and not just their move records — the local ledger is - * the only other place that flag lives, and localStorage does not travel. It - * is the correction count of the STORED BEST row, which is what the boards - * already rank and seal on. */ + * Two facts, two columns, and both are needed. `undos` is the correction count + * of the STORED BEST row — what the boards rank and seal on, and what + * `planUploads` breaks a tie of equal moves on. `everClean` is the progression + * fact: the level has been solved "sans retouche" at least once, true even + * when that run is not the row the server kept. It is the one the local ledger + * restores on a new device, where localStorage does not travel. */ export const getMyLevelScores = createServerFn({ method: "GET" }).handler( - async (): Promise<{ levelId: string; moves: number; undos: number }[]> => { + async (): Promise< + { levelId: string; moves: number; undos: number; everClean: boolean }[] + > => { const userId = await currentUserId(); if (!userId) return []; return db @@ -115,6 +141,7 @@ export const getMyLevelScores = createServerFn({ method: "GET" }).handler( levelId: levelScore.levelId, moves: levelScore.moves, undos: levelScore.undos, + everClean: levelScore.everClean, }) .from(levelScore) .where(eq(levelScore.userId, userId)); diff --git a/src/server/daily.ts b/src/server/daily.ts index 8962920..3b60bc2 100644 --- a/src/server/daily.ts +++ b/src/server/daily.ts @@ -57,7 +57,7 @@ function isSubmittableDay(date: string): boolean { } /** What the board needs to start: the grid, and the clock it starts. */ -export interface DailyOpening { +interface DailyOpening { /** Null only for an absent weekend épreuve — the route redirects. */ puzzle: DailyPuzzle | null; /** The immutable anchor for this player — null whenever this result will not diff --git a/src/server/dailyPuzzle.ts b/src/server/dailyPuzzle.ts index d5d99e9..05c2c06 100644 --- a/src/server/dailyPuzzle.ts +++ b/src/server/dailyPuzzle.ts @@ -92,10 +92,7 @@ export async function fetchRow( /** A campaign-band tier (0–2): the DB row, else the deterministic bank fallback, * so /daily is always playable. NOT for the weekend tier (it has no fallback — * use `puzzleFor`, which may resolve null). */ -export async function resolveDaily( - date: string, - tier: number, -): Promise { +async function resolveDaily(date: string, tier: number): Promise { return (await fetchRow(date, tier)) ?? fallbackPuzzle(date, tier); } diff --git a/src/server/health.test.ts b/src/server/health.test.ts new file mode 100644 index 0000000..71b77c7 --- /dev/null +++ b/src/server/health.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { probe, type Queryable } from "./health.ts"; + +/** A client whose single query settles however the test says. */ +function client(behaviour: Promise): Queryable { + return { query: () => behaviour }; +} + +describe("probe", () => { + it("reports ok, with a latency, when the query answers", async () => { + const health = await probe( + client(Promise.resolve({ rows: [{ "?column?": 1 }] })), + ); + expect(health.ok).toBe(true); + if (health.ok) expect(health.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it("reports the failure instead of throwing when the query rejects", async () => { + const health = await probe( + client(Promise.reject(new Error("ECONNREFUSED"))), + ); + expect(health).toEqual({ ok: false, error: "ECONNREFUSED" }); + }); + + // the case the healthcheck exists for: `pg` has no timeout, so an unreachable + // database hangs rather than errors, and a probe without a deadline hangs with it + it("gives up on a hanging query instead of waiting for it", async () => { + const health = await probe(client(new Promise(() => {})), 20); + expect(health.ok).toBe(false); + if (!health.ok) expect(health.error).toContain("timed out"); + }); + + // a query that rejects after losing the race must not escape as an unhandled + // rejection — that would crash the server the healthcheck is meant to watch + it("swallows a rejection that arrives after the deadline", async () => { + const late = new Promise((_, reject) => + setTimeout(() => reject(new Error("too late")), 40), + ); + const health = await probe(client(late), 10); + expect(health.ok).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 60)); + }); +}); diff --git a/src/server/health.ts b/src/server/health.ts new file mode 100644 index 0000000..ccf5cb9 --- /dev/null +++ b/src/server/health.ts @@ -0,0 +1,58 @@ +// Liveness probe for the deployment healthcheck. `/` serves the SPA shell and +// answers 200 with Postgres on the floor, so it says nothing about whether this +// instance can do any work; `select 1` does. +// +// The client is a parameter rather than an import of `db/index.ts` for two +// reasons: that module throws at import time when DATABASE_URL is unset, which +// would make this file untestable in CI (no database there, by design), and a +// probe that cannot be pointed at a failing client cannot be tested failing. +// The route passes the real pool. +// +// The race against a timer lives here rather than on the shared Pool. `pg` has +// no timeout configured, so an unreachable database leaves the connection +// hanging and the healthcheck would stall instead of failing. Putting a +// deadline on every query the server makes is a different change, with +// different blast radius — decide it separately. + +/** The one thing a probe needs from a `pg` Pool. Structural, so a test can pass + * a hand-written stub and never touch a database. */ +export type Queryable = { query(sql: string): Promise }; + +export type Health = + { ok: true; latencyMs: number } | { ok: false; error: string }; + +const TIMEOUT_MS = 2_000; + +export async function probe( + client: Queryable, + timeoutMs: number = TIMEOUT_MS, +): Promise { + const started = performance.now(); + let timer: ReturnType | undefined; + + // The query keeps running after the timer wins the race. Attaching a handler + // here — not in the race — marks it handled, so a late rejection does not + // surface as an unhandled rejection and take the server down. + const query = client.query("select 1"); + query.catch(() => {}); + + try { + await Promise.race([ + query, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); + return { ok: true, latencyMs: Math.round(performance.now() - started) }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timer); + } +} diff --git a/src/server/profileData.ts b/src/server/profileData.ts index 141913f..208c03e 100644 --- a/src/server/profileData.ts +++ b/src/server/profileData.ts @@ -15,9 +15,10 @@ import { USERNAME_RE } from "../lib/username.ts"; import { PAR } from "../engine/par.ts"; import { WEEKEND_TIER } from "./dailyPuzzle.ts"; import type { DistinctionInput } from "../lib/distinctions.ts"; +import type { Held } from "../lib/commemoratives.ts"; /** One campaign board the player has put on the record. */ -export interface PlateRecord { +interface PlateRecord { levelId: string; moves: number; undos: number; @@ -33,6 +34,10 @@ export interface DailyHistory { plates: PlateRecord[]; /** Correction-free solves, daily and campaign — one of the four figures. */ cleanCount: number; + /** The hors-série this account holds. Always empty for now: no easter egg + * grants one yet, so the Sissi stamp is issued but unobtainable. When the + * trigger exists it fills this list and nothing else has to change. */ + commemoratives: Held[]; } const FIELDS = { @@ -120,6 +125,7 @@ async function historyFor(row: { cleanCount: daily.filter((r) => r.undos === 0).length + campaign.filter((r) => r.undos === 0).length, + commemoratives: [], }; } diff --git a/src/server/ranking.ts b/src/server/ranking.ts index 34cce92..8dc33b0 100644 --- a/src/server/ranking.ts +++ b/src/server/ranking.ts @@ -39,7 +39,7 @@ import { /** The columns the rule reads. Both score tables expose the first three; * `elapsedMs` is the daily's alone. */ -export interface RankColumns { +interface RankColumns { moves: Column; undos: Column; createdAt: Column; diff --git a/src/server/replay.ts b/src/server/replay.ts index 0a05511..56eecde 100644 --- a/src/server/replay.ts +++ b/src/server/replay.ts @@ -27,9 +27,9 @@ function assertValidInput(step: { kind?: unknown; dir?: unknown }): void { // Hard ceiling on submitted length: a raw trace carries corrections (undo/reset) // so it runs longer than a clean solution, but no honest play is near this long // and it bounds replay work per request. -export const MAX_TRACE = 2000; +const MAX_TRACE = 2000; -export interface TraceResult { +interface TraceResult { ok: boolean; moves: number; // length of the final winning line (corrections don't count) corrections: number; // undos on the winning attempt, resets excluded (0 = clean solve) @@ -37,7 +37,7 @@ export interface TraceResult { const TRACE_REJECT: TraceResult = { ok: false, moves: 0, corrections: 0 }; -export interface ReplayRun { +interface ReplayRun { states: GameState[]; // initial state first, then one per surviving input corrections: number; // undos since the last reset (a reset zeroes the tally) } diff --git a/src/server/replay/gif.ts b/src/server/replay/gif.ts index 676a4b9..bf4a753 100644 --- a/src/server/replay/gif.ts +++ b/src/server/replay/gif.ts @@ -19,7 +19,7 @@ export interface GifFrame { delayCs: number; } -export interface GifOptions { +interface GifOptions { width: number; height: number; palette: Palette; diff --git a/src/server/seo.test.ts b/src/server/seo.test.ts new file mode 100644 index 0000000..2f436c1 --- /dev/null +++ b/src/server/seo.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { LEVELS } from "../engine/levels.ts"; +import { robotsTxt, sitemapXml } from "./seo.ts"; + +const ORIGIN = "https://superposition.example"; + +function locs(xml: string): string[] { + return [...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]!); +} + +describe("sitemapXml", () => { + it("lists every plate in the bank, plus the two static screens", () => { + const urls = locs(sitemapXml(ORIGIN)!); + expect(urls).toHaveLength(LEVELS.length + 2); + expect(urls).toContain(`${ORIGIN}/`); + expect(urls).toContain(`${ORIGIN}/levels`); + expect(urls).toContain(`${ORIGIN}/level/1`); + expect(urls).toContain(`${ORIGIN}/level/${LEVELS.length}`); + }); + + // a throwaway prototype, never wired into the screen flow: listing it would + // invite a crawler to index a test page as if it were the game + it("does not list the align prototype", () => { + expect(sitemapXml(ORIGIN)!).not.toContain("/align"); + }); + + it("emits only absolute URLs on the configured origin", () => { + for (const url of locs(sitemapXml(ORIGIN)!)) { + expect(url.startsWith(`${ORIGIN}/`)).toBe(true); + } + }); + + // profiles are user data: publishable one by one, not enumerable in a file + // served to everyone + it("names no profile and no api path", () => { + const xml = sitemapXml(ORIGIN)!; + expect(xml).not.toContain("/profile"); + expect(xml).not.toContain("/api"); + expect(xml).not.toContain("/daily"); + }); + + it("does not double the slash of a trailing-slash origin", () => { + expect(locs(sitemapXml(`${ORIGIN}/`)!)).toEqual(locs(sitemapXml(ORIGIN)!)); + }); + + // relative s are invalid; no sitemap beats a broken one + it("declines to exist without an origin", () => { + expect(sitemapXml(undefined)).toBeNull(); + expect(sitemapXml("")).toBeNull(); + }); +}); + +describe("robotsTxt", () => { + it("closes /api/ and points at the absolute sitemap", () => { + const txt = robotsTxt(ORIGIN); + expect(txt).toContain("User-agent: *"); + expect(txt).toContain("Disallow: /api/"); + expect(txt).toContain(`Sitemap: ${ORIGIN}/sitemap.xml`); + }); + + it("does not double the slash of a trailing-slash origin", () => { + expect(robotsTxt(`${ORIGIN}/`)).toBe(robotsTxt(ORIGIN)); + }); + + // the Disallow rules stand on their own, so only the directive that needs an + // absolute URL drops out + it("keeps its rules but drops the directive without an origin", () => { + const txt = robotsTxt(undefined); + expect(txt).toContain("Disallow: /api/"); + expect(txt).not.toContain("Sitemap:"); + }); +}); diff --git a/src/server/seo.ts b/src/server/seo.ts new file mode 100644 index 0000000..f38a793 --- /dev/null +++ b/src/server/seo.ts @@ -0,0 +1,65 @@ +// The two crawler documents, as pure text builders so they can be tested +// without a server (their routes are thin: read the env, pick a status, set a +// Content-Type). +// +// Both hinge on an ORIGIN the repo cannot know: a sitemap's must be +// absolute by protocol, and robots' `Sitemap:` directive too — which is why +// these are routes reading BETTER_AUTH_URL and not files in public/. The origin +// is normalised here, the same way __root.tsx and profile.$username.tsx +// normalise it for og:image: a hand-written `https://host/` would otherwise +// yield `https://host//levels`, a protocol-relative URL pointing at the host +// `levels`. +// +// The sitemap lists STATIC routes only. Profiles are user data: publishable one +// by one from a leaderboard row, but enumerating every account in a file served +// to everyone is a different act, and not one a sitemap should perform. + +import { LEVELS } from "../engine/levels.ts"; + +/** Trailing slashes stripped; an unset or blank origin answers null so each + * caller decides what a document with no absolute URL should be. */ +function normalise(raw: string | undefined): string | null { + const origin = (raw ?? "").replace(/\/+$/, ""); + return origin === "" ? null : origin; +} + +/** Every crawlable path, derived from the bank so adding a plate to LEVELS + * extends the sitemap on its own. + * + * `/align` is deliberately absent: it is a throwaway prototype route, not + * wired into the app's screen flow, and a sitemap is a claim that a page is + * worth landing on. */ +function paths(): string[] { + return ["/", "/levels", ...LEVELS.map((_, i) => `/level/${i + 1}`)]; +} + +/** The sitemap, or null when no origin is configured (local dev): a sitemap of + * relative s is invalid, and an invalid one is worse than none — the + * route answers 404 rather than teaching a crawler a broken URL. */ +export function sitemapXml(rawOrigin: string | undefined): string | null { + const origin = normalise(rawOrigin); + if (origin === null) return null; + const urls = paths() + .map((p) => ` ${origin}${p}`) + .join("\n"); + return ` + +${urls} + +`; +} + +/** robots.txt. Unlike the sitemap it stays useful without an origin — the + * Disallow rules are relative by design — so a missing origin only drops the + * `Sitemap:` line instead of failing the document. + * + * /api/ is closed: auth endpoints, replay GIFs and OG cards are machinery, not + * pages. Nothing else is hidden here — robots.txt is public, so listing a path + * to keep it private would advertise it instead. */ +export function robotsTxt(rawOrigin: string | undefined): string { + const origin = normalise(rawOrigin); + const sitemap = origin === null ? "" : `\nSitemap: ${origin}/sitemap.xml\n`; + return `User-agent: * +Disallow: /api/ +${sitemap}`; +} diff --git a/src/solver/bfs.ts b/src/solver/bfs.ts index 456fb20..d725a28 100644 --- a/src/solver/bfs.ts +++ b/src/solver/bfs.ts @@ -6,7 +6,7 @@ import { hashState, initialState, isWin } from "../engine/state.ts"; import { successors } from "../engine/successors.ts"; import { MECHANICS } from "../engine/mechanics/registry.ts"; -export interface Solution { +interface Solution { inputs: Input[]; } diff --git a/src/solver/hunt.ts b/src/solver/hunt.ts index e09b671..3f46db4 100644 --- a/src/solver/hunt.ts +++ b/src/solver/hunt.ts @@ -10,7 +10,7 @@ import { levelSignature } from "./signature.ts"; const rnd = (n: number) => Math.floor(Math.random() * n); -export function randomLevel(mods: MechanicId[], size: number): Level { +function randomLevel(mods: readonly MechanicId[], size: number): Level { const cell = (): Pos => [rnd(size), rnd(size)]; const ck = (p: Pos) => p[0] * size + p[1]; const aStart = cell(); @@ -33,7 +33,9 @@ export function randomLevel(mods: MechanicId[], size: number): Level { ch: "GEN", name: "gen", size, - mods, + // copied, not aliased: `Level.mods` is mutable across the engine, and every + // generated level would otherwise share the caller's one array + mods: [...mods], a: { start: aStart, goal: aGoal, @@ -66,8 +68,11 @@ export interface Found { sig: string; // symmetry-aware canonical signature (see signature.ts) } -export interface HuntOpts { - mods: MechanicId[]; +interface HuntOpts { + // readonly: the hunt only ever reads this list, and every caller passes a + // literal (`["fusion", "scission"] as const` in the tests, the tier tables in + // generate-daily). A mutable array here rejected all of them. + mods: readonly MechanicId[]; size: number; minLen: number; budgetMs: number; diff --git a/src/ui/altGesture.ts b/src/ui/altGesture.ts index d493d0f..93d435c 100644 --- a/src/ui/altGesture.ts +++ b/src/ui/altGesture.ts @@ -7,7 +7,7 @@ import type { GameState, Input, Level } from "../engine/types.ts"; -export type AltKind = "split" | "shift"; +type AltKind = "split" | "shift"; /** The alt gesture `st` offers on `level`, or null when there is none. */ export function altGesture(st: GameState, level: Level): AltKind | null { diff --git a/src/ui/components/Stamp.tsx b/src/ui/components/Stamp.tsx index 2bd1657..ee6cdb1 100644 --- a/src/ui/components/Stamp.tsx +++ b/src/ui/components/Stamp.tsx @@ -13,6 +13,7 @@ import { m } from "../../paraglide/messages.js"; import type { Distinction, Family } from "../../lib/distinctions.ts"; +import type { Held } from "../../lib/commemoratives.ts"; // One plate colour per family. The four stamps carry four different engravings, // so it is the FAMILY that needs telling apart at a glance; the tier is read off @@ -254,87 +255,219 @@ function Engraving({ d, ink }: { d: Distinction; ink: string }) { } } -// ─── The stamp ─────────────────────────────────────────────── +// ─── Sissi ─────────────────────────────────────────────────── +// The workshop's cat, loafed across a board. Not an allegory of anything: it is +// what she actually does, which is sit on whatever you are working on. Drawn +// solid grey and white — she is a bicolour, not a tabby, so there is not a +// single stripe on her anywhere. + +const CAT = "#55504a"; +const CAT_FUR = "#f7f3ec"; +const CAT_EAR = "#e2a8ad"; +const CAT_NOSE = "#e08a9a"; +const CAT_EYE = "#8ca55e"; +const CAT_LID = "#e6c2c4"; + +/** One eye: a wide green ring around a big round pupil, the lid dark only along + * the top. Ringing it all round is what made earlier passes read as a cartoon. */ +function CatEye() { + return ( + + + + + + + + + ); +} + +const SKULL = + "M0 -13.5 C9 -13.5 14 -7 14 0 C14 8 8 13 0 13 C-8 13 -14 8 -14 0 C-14 -7 -9 -13.5 0 -13.5 Z"; + +/** Her head. The markings are the likeness: a grey cap that comes down to a + * POINT between the eyes, split by a white blaze running up to the right ear — + * not a left/right split, which is what several earlier passes drew. */ +function CatHead() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** The board she is lying on: 5 × 5 SQUARE cells, because the game's board is + * square. An earlier pass stretched it to 13.6 × 10 and it read as wrong. */ +function SissiPlate() { + return ( + + + + + {/* the two inks, on a square she has not taken yet */} + + + + + + + + + + + + + + + + + + ); +} + +// ─── The sheet ─────────────────────────────────────────────── const W = 120; const H = 148; -export function Stamp({ - distinction: d, - width = 116, +/** The stamp itself, minus its subject: perforation, paper, burelage, frame, + * issuer, footer band and cancellation. Shared so a commemorative and a series + * value are the same object with a different engraving in the middle. */ +function Sheet({ + uid, + ink, + width, + label, + face, + alt, + mark, + thickInner = false, + children, }: { - distinction: Distinction; - width?: number; + uid: string; + ink: string; + width: number; + label: string; + face: string; + alt: string; + mark: { day: string; year: string } | null; + thickInner?: boolean; + children: React.ReactNode; }) { const height = Math.round((width * H) / W); - const name = FAMILY_NAME[d.family](); - - // an unopened family: the empty album mount, with what it takes written in - if (d.tier === 0) { - const goal = d.next === null ? "" : m.profile_stamp_goal({ next: d.next }); - return ( - - {`${name} — ${goal}`} - - - - - - {m.profile_stamp_pending()} - - - {goal.toUpperCase()} - - - ); - } - - const ink = INK[d.family]; - const uid = `st-${d.family}`; - const face = String(d.threshold); // a four-figure value needs a wider cartouche than "5" does const wide = face.length > 3; const cartoucheX = wide ? 70 : 76; const cartoucheW = wide ? 32 : 26; - const mark = d.earnedOn ? postmark(d.earnedOn) : null; - const alt = m.profile_stamp_earned({ - family: name, - threshold: d.threshold ?? 0, - date: d.earnedOn ?? "", - }); return ( // xmlns is redundant in the DOM but required once this same markup is @@ -416,8 +549,6 @@ export function Stamp({ /> - {/* frame — the top value thickens the inner rule, the only difference - between two values of one series besides the figure itself */} @@ -452,7 +583,7 @@ export function Stamp({ SUPERPOSITION - + {children} {/* footer band: label left, face value right — they can never collide */} - {FAMILY_LABEL[d.family]()} + {label} - + ); } + +// ─── The two kinds of stamp ────────────────────────────────── + +export function Stamp({ + distinction: d, + width = 116, +}: { + distinction: Distinction; + width?: number; +}) { + const name = FAMILY_NAME[d.family](); + + // an unopened family: the empty album mount, with what it takes written in + if (d.tier === 0) { + const height = Math.round((width * H) / W); + const goal = d.next === null ? "" : m.profile_stamp_goal({ next: d.next }); + return ( + + {`${name} — ${goal}`} + + + + + + {m.profile_stamp_pending()} + + + {goal.toUpperCase()} + + + ); + } + + return ( + + + + ); +} + +/** A hors-série. It carries no face value to climb, so its cartouche reads + * "H.S." — the philatelic mark for an issue outside the current series. */ +export function CommemorativeStamp({ + held, + width = 116, +}: { + held: Held; + width?: number; +}) { + const name = m.profile_stamp_sissi(); + return ( + + + + ); +} diff --git a/src/ui/demos.ts b/src/ui/demos.ts index cf74608..783f1a6 100644 --- a/src/ui/demos.ts +++ b/src/ui/demos.ts @@ -242,7 +242,7 @@ export const DEMOS: Demo[] = [ /** One played input: the resulting state (unchanged when `blocked`), plus flags * the player turns into feedback — `blocked` bumps the box, `merged` blooms. */ -export interface DemoStep { +interface DemoStep { input: Input; state: GameState; blocked: boolean; diff --git a/src/ui/hooks/useDemo.ts b/src/ui/hooks/useDemo.ts index 09f9a47..db08fbe 100644 --- a/src/ui/hooks/useDemo.ts +++ b/src/ui/hooks/useDemo.ts @@ -51,7 +51,7 @@ export function useSeenDemos() { /** Which control to light up next. `arm`: the ✕/world control must be pressed * first; `dir`: the arrow to press; `any`: every arrow works — pick one. */ -export interface DemoGuidance { +interface DemoGuidance { arm: boolean; dir: Pos | null; any: boolean; diff --git a/src/ui/hooks/useDiscoveryClock.ts b/src/ui/hooks/useDiscoveryClock.ts index f3e82f6..6406bf8 100644 --- a/src/ui/hooks/useDiscoveryClock.ts +++ b/src/ui/hooks/useDiscoveryClock.ts @@ -10,7 +10,7 @@ import { useCallback, useEffect, useState } from "react"; /** Where the clock reads from: the server's anchor for this player, and the * server's own time when it replied. */ -export interface ClockSource { +interface ClockSource { servedAt: string | null; serverNow: string; } @@ -61,7 +61,7 @@ export function useDiscoveryClock( * · null — ranked and unmeasured: there is no time to show; * · a number — ranked and measured. */ -export type Recorded = number | null | undefined; +type Recorded = number | null | undefined; export const recordedFrom = ( mine: { elapsedMs?: number | null } | null, diff --git a/src/ui/hooks/useKeyboard.ts b/src/ui/hooks/useKeyboard.ts index fd20184..aaba1f6 100644 --- a/src/ui/hooks/useKeyboard.ts +++ b/src/ui/hooks/useKeyboard.ts @@ -16,7 +16,7 @@ const DIRS: Record = { // turns the release into "R", the abandon is missed, and the sweep confirms anyway. const isReset = (e: KeyboardEvent) => e.key.toLowerCase() === "r"; -export interface KeyHandlers { +interface KeyHandlers { play: (dir: Pos, wantAlt: boolean) => void; undo: () => void; resetDown: () => void; diff --git a/src/ui/hooks/useProgressSync.ts b/src/ui/hooks/useProgressSync.ts index b7c0ab1..d7f2554 100644 --- a/src/ui/hooks/useProgressSync.ts +++ b/src/ui/hooks/useProgressSync.ts @@ -42,10 +42,9 @@ export function useProgressSync(uid: string | null, deps: Deps) { // object, so this costs a reference comparison. No trace is sent — the // ledger drops any stale one, since it would no longer match the record. // - // A row seals only when the STORED BEST was clean. A player whose clean - // run was not their best row keeps their seal locally but cannot recover - // it on a new device: the server holds one row per level, not a history, - // so "ever solved cleanly" is not a fact it has. + // A row seals on `everClean`, the column the server carries for exactly + // this moment — so a player whose "sans retouche" run was never their + // best row still gets their seal back here. for (const s of server) record(asWin(s)); // independent per-level upserts: fire together rather than serializing N // round-trips. A stale/invalid trace must not break the others — swallow diff --git a/src/ui/progressSync.test.ts b/src/ui/progressSync.test.ts index 7b186fc..6e46fb8 100644 --- a/src/ui/progressSync.test.ts +++ b/src/ui/progressSync.test.ts @@ -20,12 +20,14 @@ const L = ( traces: Record = {}, ): Ledger => ({ ...emptyLedger, best, traces }); -// `undos` is irrelevant to the upload decision (the server re-derives it from -// the trace we send), so the rows here carry a plain zero +// `undos` and `everClean` are irrelevant to the upload decision (the server +// re-derives the corrections from the trace we send), so the rows here carry +// plain defaults const remote = (levelId: string, moves: number): ServerScore => ({ levelId, moves, undos: 0, + everClean: false, }); describe("planUploads", () => { @@ -80,49 +82,80 @@ describe("planUploads", () => { }); describe("asWin — a stored row offered to the ledger", () => { - it("carries the record and seals a row solved with no correction", () => { - expect(asWin({ levelId: "a", moves: 4, undos: 0 })).toEqual({ + it("carries the record and seals a level ever solved cleanly", () => { + expect( + asWin({ levelId: "a", moves: 4, undos: 0, everClean: true }), + ).toEqual({ levelId: "a", moves: 4, clean: true, }); }); - it("does not seal a row whose best solve used corrections", () => { - expect(asWin({ levelId: "a", moves: 4, undos: 2 })).toMatchObject({ - clean: false, - }); + it("does not seal a level never solved cleanly", () => { + expect( + asWin({ levelId: "a", moves: 4, undos: 2, everClean: false }), + ).toMatchObject({ clean: false }); + }); + + it("seals on the level's history, not on the stored row's corrections", () => { + // THE gap this column closes: the clean run was not the best row, so the + // stored best carries corrections — the seal must come from `everClean` + expect( + asWin({ levelId: "a", moves: 4, undos: 2, everClean: true }), + ).toMatchObject({ clean: true }); + }); + + it("withholds the seal from a clean-looking row the server never marked", () => { + // the mirror case: `undos === 0` is the boards' rule, not progression's. + // Reading it here would resurrect the old spelling behind the new column. + expect( + asWin({ levelId: "a", moves: 4, undos: 0, everClean: false }), + ).toMatchObject({ clean: false }); }); it("carries no trace — the ledger drops any that no longer fits", () => { - expect(asWin({ levelId: "a", moves: 4, undos: 0 }).trace).toBeUndefined(); + expect( + asWin({ levelId: "a", moves: 4, undos: 0, everClean: true }).trace, + ).toBeUndefined(); }); it("restores records and seals when folded over a fresh ledger", () => { // the new-device path: an empty ledger, every stored row offered to it const server: ServerScore[] = [ - { levelId: "accord", moves: 4, undos: 0 }, - { levelId: "retenue", moves: 9, undos: 3 }, + { levelId: "accord", moves: 4, undos: 0, everClean: true }, + { levelId: "retenue", moves: 9, undos: 3, everClean: false }, ]; const restored = server.reduce( (acc, s) => recordWin(acc, asWin(s)), emptyLedger, ); expect(plate(restored, "accord")).toMatchObject({ record: 4, sans: true }); - expect(plate(restored, "retenue")).toMatchObject({ record: 9, sans: false }); + expect(plate(restored, "retenue")).toMatchObject({ + record: 9, + sans: false, + }); }); it("leaves a better local record alone while still raising its seal", () => { // partial local progress: the ledger's own min rule keeps the local 3, and // the seal rises anyway because it is sticky and ungated const local = recordWin(emptyLedger, { levelId: "accord", moves: 3 }); - const merged = recordWin(local, asWin({ levelId: "accord", moves: 5, undos: 0 })); + const merged = recordWin( + local, + asWin({ levelId: "accord", moves: 5, undos: 0, everClean: true }), + ); expect(plate(merged, "accord")).toMatchObject({ record: 3, sans: true }); }); it("costs nothing when the row tells the ledger nothing new", () => { const l = recordWin(emptyLedger, { levelId: "accord", moves: 4 }); // same reference back: React bails out, no render - expect(recordWin(l, asWin({ levelId: "accord", moves: 9, undos: 1 }))).toBe(l); + expect( + recordWin( + l, + asWin({ levelId: "accord", moves: 9, undos: 1, everClean: false }), + ), + ).toBe(l); }); }); diff --git a/src/ui/progressSync.ts b/src/ui/progressSync.ts index 886fd67..4480683 100644 --- a/src/ui/progressSync.ts +++ b/src/ui/progressSync.ts @@ -16,12 +16,16 @@ import type { TraceStep } from "../engine/types.ts"; import type { Ledger, Win } from "./progression.ts"; import { undosOf } from "./submissionPolicy.ts"; -/** One of the caller's stored rows. `undos` is the correction count of that - * stored best — zero means the row earned the clean seal. */ +/** One of the caller's stored rows. Two facts, deliberately not one: `undos` is + * the correction count of that STORED BEST — what the boards rank and seal on, + * and what `planUploads` breaks a tie on — while `everClean` says the level was + * once solved with no correction at all, true even when that run is not the row + * the server kept. */ export interface ServerScore { levelId: string; moves: number; undos: number; + everClean: boolean; } /** @@ -31,18 +35,19 @@ export interface ServerScore { * store a trace for a record it may not match. The ledger drops any stale one * instead, and the upload path re-reads the trace from the server anyway. * - * The seal comes from the row's own correction count, which is what the boards - * already rank and seal on. Note what this cannot recover: the server keeps one - * row per level, not a history, so a player whose clean run was NOT their best - * row has no "ever solved cleanly" fact for the server to return. + * The seal comes from `everClean`, NOT from the row's own correction count. The + * server keeps one best row per level, not a history, so reading the row would + * drop the seal of a player whose "sans retouche" run was not their record — + * the very gap that column was added to close. It remembers the fact on the + * player's behalf; here the ledger just takes it, sticky as ever. */ export const asWin = (s: ServerScore): Win => ({ levelId: s.levelId, moves: s.moves, - clean: s.undos === 0, + clean: s.everClean, }); -export interface Upload { +interface Upload { levelId: string; trace: TraceStep[]; } diff --git a/src/ui/screens/FallbackScreen.test.tsx b/src/ui/screens/FallbackScreen.test.tsx new file mode 100644 index 0000000..932fbd7 --- /dev/null +++ b/src/ui/screens/FallbackScreen.test.tsx @@ -0,0 +1,68 @@ +// The one screen both route boundaries mount. What is worth pinning is not the +// layout but the three things that made it exist: the copy comes from the +// message catalogue rather than the component, the way back to the game is +// always wired, and the error mode gains a retry WITHOUT ever printing the +// error — a loader failure carries server detail the visitor must not see. + +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { m } from "../../paraglide/messages.js"; +import { FallbackScreen } from "./FallbackScreen.tsx"; + +// the two callsites of __root.tsx, mirrored: same messages, and the retry is +// what tells them apart +const notFound = (onBack: () => void) => ( + +); +const failed = (onBack: () => void, onRetry: () => void) => ( + +); + +const back = () => screen.getByRole("button", { name: m.fallback_back() }); +const retry = () => screen.queryByRole("button", { name: m.fallback_retry() }); + +describe("FallbackScreen", () => { + it("prints the 404 copy from the messages, with the way back wired", () => { + const onBack = vi.fn(); + render(notFound(onBack)); + + expect(screen.getByRole("heading").textContent).toBe( + m.fallback_not_found_title(), + ); + expect(screen.getByText(m.fallback_not_found_body())).toBeDefined(); + + fireEvent.click(back()); + expect(onBack).toHaveBeenCalledOnce(); + }); + + it("offers no retry on a 404 — there is nothing to reload", () => { + render(notFound(vi.fn())); + expect(retry()).toBeNull(); + }); + + it("prints the error copy and wires both the retry and the way back", () => { + const onBack = vi.fn(); + const onRetry = vi.fn(); + render(failed(onBack, onRetry)); + + expect(screen.getByRole("heading").textContent).toBe( + m.fallback_error_title(), + ); + expect(screen.getByText(m.fallback_error_body())).toBeDefined(); + + fireEvent.click(retry()!); + expect(onRetry).toHaveBeenCalledOnce(); + + fireEvent.click(back()); + expect(onBack).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/ui/screens/FallbackScreen.tsx b/src/ui/screens/FallbackScreen.tsx new file mode 100644 index 0000000..dc12867 --- /dev/null +++ b/src/ui/screens/FallbackScreen.tsx @@ -0,0 +1,76 @@ +// The workshop door: one page for both of the root's route boundaries — a URL +// that names no plate, and a load that failed. Twin screens would drift apart; +// only the title, the body line and the presence of a retry differ, so they get +// a single owner. +// +// Mounted from `__root.tsx`, which is `ssr: true`: this component touches no +// browser API on the first render — no AudioContext, no localStorage, no window. +// It animates nothing either: motion serialises its `initial` into the server's +// HTML and would ship a blank page until hydration (the reason for +// ProfileScreen's `hydrated` flag), and the way back into the game is precisely +// what has to be readable on the first frame. The composition stays the +// edition's — same caisson, same type — so the visitor stays in the workshop +// rather than being thrown out of it. + +import { m } from "../../paraglide/messages.js"; +import { Room } from "../components/Room.tsx"; +import { Wordmark } from "../components/Wordmark.tsx"; +import { reducedMotion as reduced } from "../motion.ts"; + +export function FallbackScreen({ + title, + body, + onBack, + onRetry, +}: { + title: string; + body: string; + /** The way out, always there: back to the edition. */ + onBack: () => void; + /** Error side only — `router.invalidate()`, which reruns the loader and rearms + * the boundary. Absent on a 404: there is nothing to reload. */ + onRetry?: () => void; +}) { + return ( +
+ {/* the same room as the rest of the game (variant 0: the warm lamp) */} + + +
+ {/* the mark left out of register — that IS the page's state: the two + films never superposed. Everywhere else it is `aligned`. */} +
+ +
+ +
+

+ {title} +

+

+ {body} +

+
+ +
+ {onRetry && ( + + )} + +
+
+
+ ); +} diff --git a/src/ui/screens/PlayScreen.tsx b/src/ui/screens/PlayScreen.tsx index d95382b..61b0c89 100644 --- a/src/ui/screens/PlayScreen.tsx +++ b/src/ui/screens/PlayScreen.tsx @@ -102,7 +102,7 @@ const chip = /** Daily mode: one tier of the day's challenge, played for the shared * per-tier leaderboard rather than the campaign. Swaps the HUD banner and the * win overlay. `tier` is 0 easy · 1 medium · 2 hard. */ -export interface DailyMode { +interface DailyMode { date: string; tier: number; optimal: number; diff --git a/src/ui/screens/ProfileScreen.test.tsx b/src/ui/screens/ProfileScreen.test.tsx new file mode 100644 index 0000000..f1b9dff --- /dev/null +++ b/src/ui/screens/ProfileScreen.test.tsx @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { m } from "../../paraglide/messages.js"; +import { ProfileScreen } from "./ProfileScreen.tsx"; +import type { DailyHistory } from "../../server/profile.ts"; + +// The hors-série row is the only part of the profile no account can reach yet: +// nothing grants a commemorative, so the server always sends an empty list. These +// pin the two halves of that contract — that an empty list prints NOTHING (a +// stamp never put on sale cannot be missing from an album, so there is no empty +// mount for it), and that the row appears the moment the list is filled, which is +// all the future easter egg will have to do. + +const blank: DailyHistory = { + name: "Sissi", + joinedAt: "2026-06-08", + days: [], + marks: { days: [], bat: [], artist: [], plates: [] }, + plates: [], + cleanCount: 0, + commemoratives: [], +}; + +const noop = () => {}; + +describe("ProfileScreen — hors-série", () => { + it("prints no commemorative row when the account holds none", () => { + render(); + expect( + screen.queryByRole("img", { name: /hors-série|special issue/i }), + ).toBeNull(); + }); + + it("prints the stamp once the account holds one", () => { + render( + , + ); + const stamp = screen.getByRole("img", { + name: /Sissi.*(hors-série|special issue)/i, + }); + expect(stamp).toBeDefined(); + }); + + it("still prints the four series stamps beside it", () => { + render( + , + ); + // a blank account opens no family, so all four print as empty album mounts + expect( + screen.getAllByRole("img", { name: /—/ }).length, + ).toBeGreaterThanOrEqual(4); + }); +}); + +// The way out of an account — for a long time there was none, and a shared +// machine kept whoever signed in first. What is worth pinning is that the +// control is wired to the route (which is what calls signOut), that its label +// comes from the catalogue rather than the component, and that it stays off a +// public profile, which is somebody else's sheet. + +const signout = () => + screen.queryByRole("button", { name: m.profile_signout() }); + +describe("ProfileScreen — leaving the table", () => { + it("prints no way out when the page is not the reader's own", () => { + render(); + expect(signout()).toBeNull(); + }); + + it("hands the click to the route, which ends the session", () => { + const onSignOut = vi.fn(); + render( + , + ); + + fireEvent.click(signout()!); + expect(onSignOut).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/ui/screens/ProfileScreen.tsx b/src/ui/screens/ProfileScreen.tsx index a2b7d21..0cfbeb0 100644 --- a/src/ui/screens/ProfileScreen.tsx +++ b/src/ui/screens/ProfileScreen.tsx @@ -20,7 +20,7 @@ import { PAR } from "../../engine/par.ts"; import { Room } from "../components/Room.tsx"; import { LangToggle } from "../components/LangToggle.tsx"; import { ContributionGraph } from "../components/ContributionGraph.tsx"; -import { Stamp } from "../components/Stamp.tsx"; +import { CommemorativeStamp, Stamp } from "../components/Stamp.tsx"; import { PRINT_EASE, reducedMotion as reduced } from "../motion.ts"; import type { DailyHistory } from "../../server/profile.ts"; @@ -146,10 +146,14 @@ export function ProfileScreen({ history, today, onBack, + onSignOut, }: { history: DailyHistory; today: string; onBack: () => void; + /** Ending the session — handed in by /profile/me only. A public profile is + * somebody else's sheet, so the control is simply not printed there. */ + onSignOut?: () => void; }) { // true on the server render and the client render that hydrates it, false on // every subsequent client navigation — gates the entrance animation (below). @@ -203,6 +207,19 @@ export function ProfileScreen({ {m.profile_member_since({ since })} + {/* under the colophon, where the sheet says whose it is — the one + place an account can be put down again. On a shared machine that + matters more than being discreet, so it sits above the fold + rather than at the foot of a very long sheet. */} + {onSignOut && ( + + )} {/* the series — four stamps, or the empty mounts where they will go */} @@ -215,6 +232,22 @@ export function ProfileScreen({ ))} + {/* the hors-série. Shown only to whoever holds one: a commemorative was + never put on sale, so it cannot be missing from anyone's album — no + empty mount here, unlike the series above. The row can grow without + ever widening the series, which is the point of keeping the two + apart (see lib/commemoratives.ts). */} + {history.commemoratives.length > 0 && ( + + +
+ {history.commemoratives.map((h) => ( + + ))} +
+
+ )} + {/* register strip — two figures of presence, two of quality */}