Files
scrabble-game/.claude/CLAUDE.md
T
Ilia Denisov 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
chore(android): de-anchor ANDROID_PLAN.md from the docs, re-enable the APK workflow
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).
2026-07-13 14:05:43 +02:00

18 KiB
Raw Blame History

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 numericfloat64, intervalstring. 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 assembleDebugui/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 placesgame.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 sandboxplaywright 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:0007: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 (E0E9). 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).