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).
18 KiB
18 KiB
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/jetgenregenerates 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 asnumeric(float precision loss) — use bigint minor units + aMoneytype; store durations as int seconds, notinterval. go mod tidychokes on dot-free local module paths (scrabble/...). Hand-editgo.modwhen a bump is needed. The solver (../scrabble-solver) is consumed viago.workreplace 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
/tmpas 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 checkoccasionally 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/androidcompiles atVERSION_21; a JDK 17 Gradle run dies witherror: invalid source release: 21. Install sudo-free via the Homebrew formulabrew install openjdk@21(thetemurin@21cask wants sudo for a system.pkg, unusable non-interactively) + a user symlink into~/Library/Java/JavaVirtualMachines/so/usr/libexec/java_home -v 21finds it. JDK 17 stays fine forsdkmanager/avdmanager. - Cap 8 SDK pins: compileSdk/targetSdk 36, minSdk 24, Gradle 8.14.3, AGP 8.13.0
(
ui/android/variables.gradle). Installplatforms;android-36— Android Studio's newerandroid-36.1does NOT satisfy compileSdk 36. - SDK is Android Studio's at
~/Library/Android/sdk(nocmdline-toolsby default →brew install --cask android-commandlinetools, thensdkmanager/avdmanager --sdk_root=$HOME/Library/Android/sdk). Gradle finds it viaANDROID_HOME(local.propertiesis gitignored, machine-specific). - Build recipe:
cd ui && pnpm build && node_modules/.bin/cap sync android; thencd 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 localui/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 needssleep→ run it as a background Bash task (foregroundsleepis blocked). Browsers/WebView are cached, so a full offline vs_ai turn is drivable viaadb 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 theenv()=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 Playwrightchromium.connectOverCDP('http://localhost:9222')→page.evaluate(read each element'sgetBoundingClientRect().top, and--safe-area-inset-*vsenv(...)). sharpis whitelisted inui/pnpm-workspace.yaml(allowBuilds: sharp: true) —@capacitor/assetsuses it forpnpm android:assets(launcher icon/splash); else pnpm 11 raisesERR_PNPM_IGNORED_BUILDS.- Bundled offline dicts come from the
scrabble-dictionaryrelease (scrabble-dawg-<DICT_VERSION>.tar.gz, keyed on theDICT_VERSIONGitea var — the same source the backend image + CIcurl), NOTscrabble-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
0the 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.Servicelive 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
$statevariablestate.svelte-checkthen misreads$stateas 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/.ghostbutton classes. Style buttons per-component with scoped CSS + design tokens (mirrorNewGame's.invite). - A
$stateproxy 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
showPopupeats the user-activation. Share / clipboard called from inside ashowPopupcallback fail (no gesture). Use your ownModalfor 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.shareand ignore<a download>. Deliver a client-generated file by copying it to the clipboard. - VK Android WebView ignores
target=_blank. Open external links throughlib/links.ts(routes tovk.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-jspolyfill loads only on old engines, andindex.htmlhas a boot gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also avminglyph 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 gameG1hard-codes one; flipG1'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 testrunner can't fetch browsers in this sandbox —playwright installdies withEBADFagainst the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in CI (theuijob installs chromium+webkit), or drive a state live through the Playwright MCP browser against a localvite --mode mockserver for visual verification. But if chromium/webkit are already cached in~/Library/Caches/ms-playwright/,playwright testruns 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/dawgvia the webServer'sbundle-dicts.mjsfallback. - A native (Capacitor) e2e must inject
window.androidBridge, NOTwindow.Capacitor.getPlatform.@capacitor/core(pulled in during boot byinitNativeShell's dynamic@capacitor/appimport) replaces any pre-setwindow.Capacitorwith its own shim and derives the platform fromwindow.androidBridge(android) /window.webkit.messageHandlers(ios) — so a bare injectedCapacitor.getPlatform: () => 'android'is clobbered toweband the boot falls to/login. Injectwindow.androidBridge = { postMessage(){} }in anaddInitScript(seee2e/native.spec.tssimulateNative);initNativeShellis written to tolerate the stub bridge (try/catchround the@capacitor/appaddListener). 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-sidecurlto 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 inPRERELEASE.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_codeseed. Clear client prefs too when testing locale. DNS=inTEST_AWG_CONFpins 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-recreatefor caddy; don't trust "deploy green" for an edge-config change. - A new gateway edge route MUST be added to the Caddyfile
@gatewaymatcher 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: h3with no UDP/443 open; clients cache it ~30 days. Fix withAlt-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,
/_gmexempt, 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/dictionaryupload, 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. teaCLI for all Gitea ops (PRs, secrets, variables, dispatch).ghdoes 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; itsALL GREENalready covers the gated deploy job. Pass--no-runs 600when 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_zoneisNOT 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 aliasesscrabble-main-ops/scrabble-tg-ops. Deploy is manual dispatch only, rolling + health-gated + auto-rollback. Hosts are provisioned bydeploy/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 thepostgresuser 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).