# 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/`. ## 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 `` navigates away** and strands the SPA. Deliver files by **Web Share on mobile**, and only use a `` Blob path on **desktop**. - **Android TG/VK WebViews** lack `navigator.share` and ignore ``. 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). - **`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).