Files
scrabble-game/.claude/CLAUDE.md
T
Ilia Denisov 53b33073ac docs(android): fix plan (dict source, Capacitor 8 / SDK 36 / JDK 21) and record scaffolding progress + native-build notes
Correct ANDROID_PLAN.md: bundled offline dictionaries come from the scrabble-dictionary release (DICT_VERSION), not scrabble-solver; pin the toolchain to Capacitor 8 (compileSdk/targetSdk 36, minSdk 24, JDK 21 — @capacitor/android compiles at Java 21). Add a Progress section marking the scaffolding milestone done, and capture the native-Android build recipe + toolchain gotchas in .claude/CLAUDE.md so a fresh session resumes without re-deriving them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:34:25 +02:00

14 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 (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 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).
  • 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).
  • 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 (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).