# Agent field notes — scrabble-game

Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
memory, not a spec — **verify any named file / flag / function against the current code before acting
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.

## Codegen & build

- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
  reorder output. After running it, revert the churn on tables you did not touch and commit only your
  table's change.
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
  flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
  another flatc version.
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
  `go build` / `go test`, not the editor squiggles.
- **go-jet type mapping:** SQL `numeric` → `float64`, `interval` → `string`. Never store money as
  `numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
  **int seconds**, not `interval`.
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
  bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
  prod bump goes through a solver **PR → master + a published tag**, not a local replace.
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
  Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
  `${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
  pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.

## Native Android build (Capacitor)

The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:

- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
  run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
  `brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
  non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
  `/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
  (`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
  `android-36.1` does NOT satisfy compileSdk 36.
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
  --cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
  Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
  ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
  → `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
  (dodges the corepack flake).
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
  `emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
  android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
  → run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
  full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
  each tap advances one step — then Hint places a suggested word; commit → the robot replies).
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
  edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
  On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
  under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
  button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
  Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
  `insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
  `--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
  that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
  (2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
  **header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
  EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
  (`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
  per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
  is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
  BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
  `adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
  | head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
  element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
  uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
  keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
  `scrabble-solver/dawg` (those are the solver's pinned test fixtures).

## Wire / schema evolution (client ↔ gateway ↔ backend)

- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
  delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
  and breaks older readers.
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
  and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
  both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
  DTOs. Change both or they drift.
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
  then bumps it from the live event. Don't expect it on the shared broadcast.

## UI / Svelte 5

- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
  subscription. Rename (e.g. `view`).
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
  `{' : '}`.
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
  tokens (mirror `NewGame`'s `.invite`).
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
  with `$state.snapshot(...)` before storing.

## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)

- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
  `showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
  Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
  client-generated file by **copying it to the clipboard**.
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
  (routes to `vk.com/away.php`). Verify on-device.
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
  matrix; do not re-litigate it without new on-device facts.
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
  syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
  gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
  glyph fallback for old rendering.
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
  those live on the deployed contour, not in the e2e.
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
  Subscribe stream, cosmetic, deliberately left as-is.

## Testing

- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
  e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
  intercepts Playwright taps.
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
  eyeball per-variant tiles.
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
  plugin-config gotcha in wiring them).
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
  with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
  **CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
  browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
  already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
  fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
  its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
  `@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
  **replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
  `window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
  `Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
  `window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
  `initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
  Use **testcontainers** for container-backed tests.
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
  or the service crash-loops on start.

## Deploy / test contour (operational)

- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
  `curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
  prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
  PR, one deploy) so deploys don't clobber each other.
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
  prerelease step in `PRERELEASE.md`.
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
  syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
  locale.
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
  bot-link silently dies. Diagnose from inside the netns.
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
  socket-inspect trick (single-service recreate).
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
  caddy; don't trust "deploy green" for an edge-config change.
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
  to the landing catch-all. Add a CI probe for the route.
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
  telemetry log or Tempo, not caddy.
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
  `/assets/main-*.js`.
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
  clients cache it ~30 days. Fix with `Alt-Svc: clear`.
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
  produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
  the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
  their pinned version. The **owner** does the upload.
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
  re-run, it's not your code.

## Repo workflow

- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
  / add a plan line — not file a tracker issue.
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
  agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
  stale-mergeable trap (re-check mergeability right before merging).
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
  **bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
  gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
  case — watch the post-merge runs too.
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
  local feature branch.
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
  wedged contour can be recovered by recreating the host container set.

## Domain semantics (not obvious from the code)

- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
  owner on every deletion point before wiring it.
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
  account row is created at the **code-request** step, not at confirmation.
- **The robot has two distinct time windows:** a **sleep window** (~00:00–07:00, gates its moves and
  nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
  **sleep** window.

## Production topology

- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
  SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
  health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
  the source of truth for IPs/roles).
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
  owner-side step **before** a deploy, not after.

## Feature-area pointers (state lives in the linked docs/plans, not here)

- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
  `docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
  `deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
  `--pg1-user=scrabble`.
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
  of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
  offline-first bundles the dictionaries (see `ANDROID_PLAN.md`).
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
  App, server-side confidential code exchange.
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate,
  offline-first, RuStore).
