a067a2fd01
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Reword every code comment and doc that referenced ANDROID_PLAN.md or its stage anchors (§E, G-step-0, O1) so the living docs no longer depend on the plan file: .claude/CLAUDE.md, deploy/README.md, docs/ARCHITECTURE.md, docs/TESTING.md, ui/README.md, ui/android/.gitignore, ui/e2e/native.spec.ts, ui/src/lib/netstate.ts. Re-enable the manual signed-APK workflow (android-build.yaml.disabled -> android-build.yaml; the CI host already has the Android SDK; workflow_dispatch-only, so it does not auto-run on this PR). Add docs/ICONS.md — the single-master icon/logo format reference (web, PWA, Android, future iOS, store listings).
243 lines
18 KiB
Markdown
243 lines
18 KiB
Markdown
# 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 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.
|
||
- **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** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
|
||
design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
|