diff --git a/.claude/vk-games-integration.md b/.claude/vk-games-integration.md index 4859e87..f1b999c 100644 --- a/.claude/vk-games-integration.md +++ b/.claude/vk-games-integration.md @@ -83,8 +83,10 @@ incl. the `%2C` comma case for `vk_access_token_settings`). blocked. **Used** as the copy-code / copy-link path. - `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is "auto" (the VK webview's prefers-color-scheme does not track it). **Used.** -- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used — - the bottom home-bar safe area is handled by CSS `env(safe-area-inset-*)` (viewport-fit=cover). +- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used. +- `VKWebAppUpdateInsets` (+ `VKWebAppUpdateConfig`) — device safe-area insets; the app **max'es** them + with CSS `env(safe-area-inset-*)` (viewport-fit=cover) so the bottom home bar is cleared. The bridge + value is needed on Android, where the VK webview exposes no `env()` inset. **Used.** The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 265cfaf..e81e94e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -26,12 +26,12 @@ on: push: branches: [development] -# The dictionary release the test suite validates against — the current -# scrabble-dictionary release. Centralised here so a release bump is one edit; the -# unit/integration jobs inherit it. The deploy job overrides it per contour with -# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md. +# The dictionary release. One Gitea variable is the single source of truth: the +# test suite validates against it here (inherited by the unit/integration jobs) and +# both contours' deploy jobs seed a fresh volume with the same value. A release bump +# is one edit (the variable). See deploy/README.md. env: - DICT_VERSION: v1.3.1 + DICT_VERSION: ${{ vars.DICT_VERSION }} jobs: # changes detects which areas a PR/push touched, so the test jobs can skip when @@ -73,6 +73,9 @@ jobs: go=false; ui=false if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi if echo "$files" | grep -qE '^ui/'; then ui=true; fi + # The render sidecar bundles ui/src/lib, so its dir rides the ui lane (the + # deploy's compose build picks it up either way). + if echo "$files" | grep -qE '^renderer/'; then ui=true; fi # A workflow or deploy change re-runs everything as a safety net. if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi else @@ -199,6 +202,14 @@ jobs: - name: Bundle-size budget run: node scripts/bundle-size.mjs + # The render sidecar executes the shared ui/src/lib/gameimage.ts on skia-canvas; + # its smoke test guards the bundling + skia seam (docs/TESTING.md). + - name: Render sidecar test + working-directory: renderer + run: | + pnpm install --frozen-lockfile + pnpm test + - name: Install Playwright browsers run: pnpm exec playwright install chromium webkit timeout-minutes: 5 @@ -207,11 +218,66 @@ jobs: run: pnpm run test:e2e timeout-minutes: 5 + # conformance proves the client's local move preview (the ported dawg reader + + # validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine: + # a Go step generates golden parity vectors from the release dictionaries, then the + # gated Vitest suite replays them. It spans both toolchains, so it runs whenever the + # Go engine side or the UI side changed. + conformance: + needs: changes + if: ${{ needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true' }} + runs-on: ubuntu-latest + defaults: + run: + shell: bash + env: + GOPRIVATE: gitea.iliadenisov.ru/* + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch dictionary DAWGs + run: | + mkdir -p "${GITHUB_WORKSPACE}/dawg" + curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" + tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg" + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + cache: true + + - name: Generate golden parity vectors + run: | + go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold + go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm@11.0.9 + + - name: Install deps + working-directory: ui + run: pnpm install --frozen-lockfile + + - name: Local-eval conformance (reader + validator vs the Go engine) + working-directory: ui + env: + DICT_DAWG_DIR: ${{ github.workspace }}/dawg + DICT_GOLD_DIR: /tmp/dictgold + DICT_VALID_DIR: /tmp/validgold + run: pnpm exec vitest run src/lib/dict/ + # gate is the single branch-protection required check. It always runs and passes # only when each upstream job succeeded or was skipped (a path-filtered no-op), # failing the merge if any actually failed or was cancelled. gate: - needs: [unit, integration, ui] + needs: [unit, integration, ui, conformance] if: always() runs-on: ubuntu-latest defaults: @@ -221,7 +287,7 @@ jobs: - name: Aggregate required checks run: | fail= - for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do + for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}" "conformance:${{ needs.conformance.result }}"; do name="${r%%:*}"; res="${r#*:}" echo "$name = $res" case "$res" in @@ -263,11 +329,42 @@ jobs: TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }} # VK Mini App protected key (offline HMAC for the launch-param signature); empty # leaves the VK auth path (auth.vk) disabled until the operator sets the secret. - GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }} + # One VK Mini App serves every contour -> unprefixed secret. + GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} + # VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key + # for the server-side confidential code exchange — a SEPARATE VK app from the Mini + # App above. One VK ID "Web" app serves every contour -> unprefixed secret. + GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} + # Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on + # test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off. + GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }} + # Signs the finished-game export download URLs (backend + compose interpolation). + EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }} + # Transactional email via the shared Selectel relay: one account for every + # contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend + # on the log mailer (email disabled) but the contour still boots. + SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }} + SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }} + SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }} + SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }} + SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }} + SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }} + # Operator alerts: backend admin emails (new feedback / complaints) + Grafana + # infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS + # host:port. Empty leaves the alert worker off and Grafana SMTP disabled. + SMTP_RELAY_ADMIN_FROM: ${{ vars.TEST_SMTP_RELAY_ADMIN_FROM }} + ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }} + SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }} + SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }} + GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }} + GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }} + # Canonical public origin for links in the email (this contour's URL); + # required by the backend whenever SMTP_RELAY_HOST is set. + PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }} - GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }} CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }} - TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }} + # TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL and VITE_VK_ID_REDIRECT_URL are derived + # from PUBLIC_BASE_URL in the run step below, not stored as their own variables. TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }} TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }} @@ -280,11 +377,16 @@ jobs: VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} - VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} - # Unset vars render empty -> the compose ":-" defaults apply. + # VK Mini App landing link + VK ID "Web" app id: one value each serves every + # contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID); + # the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below. + VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }} + VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} + # VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the + # compose ":-" empty default. Other unset vars likewise fall to their defaults. POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }} - DICT_VERSION: ${{ vars.TEST_DICT_VERSION }} + DICT_VERSION: ${{ vars.DICT_VERSION }} LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }} run: | # Seed the config files to a stable host path. The runner checks out into @@ -295,8 +397,34 @@ jobs: conf="$HOME/.scrabble-deploy" rm -rf "$conf" mkdir -p "$conf" - cp -r caddy otelcol prometheus tempo grafana "$conf"/ + cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/ export SCRABBLE_CONFIG_DIR="$conf" + # Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA + # overlay is exercised on the test contour too (not only prod). Raised just before + # the recreate and lowered once caddy is back (below); the trap clears it if the + # step fails so the contour never sticks in maintenance (and the reseed above wipes a + # stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered. + maint_flag="$conf/caddy/on" + trap 'rm -f "$maint_flag"' EXIT + # Derive the public URLs from the one canonical origin instead of storing each as + # its own variable (paths are structural SPA routes / the Caddy /_gm sub-path). + # Exported before build so the VK ID redirect is baked into the SPA. + base="${PUBLIC_BASE_URL%/}" + export TELEGRAM_MINIAPP_URL="$base/telegram/" + export GRAFANA_ROOT_URL="$base/_gm/grafana/" + export VITE_VK_ID_REDIRECT_URL="$base/app/" + # Grafana's SMTP from_address must be a BARE address (it rejects the "Name" + # form the backend go-mail accepts) and validates it even when SMTP is disabled — a + # bad value crash-loops Grafana. Split the display-format SERVICE From into a bare + # address + name for Grafana; the backend keeps the full form. + svc_from="${SMTP_RELAY_SERVICE_FROM:-}" + case "$svc_from" in + *"<"*">"*) + export GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')" + export GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;; + *) + export GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;; + esac # Bot-link mTLS material for the test contour: a private CA + gateway/bot # leaves (CN=gateway, the service name the bot dials). Prod supplies these # from PROD_ secrets instead. Regenerated each deploy; both ends redeploy @@ -309,12 +437,23 @@ jobs: # bot on its own host instead (deploy/docker-compose.bot.yml), and the prod # main host omits both. Without the profile they would not start here. docker compose --ansi never --profile telegram-local build --progress plain + # Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it + # actually carries the flag: the running caddy sits on the stale pre-reseed mount (the + # dir was rm'd + recreated — see the force-recreate note below), so a flag written to + # the new dir is invisible until caddy is recreated. With the fresh caddy up, an open + # SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect. + : > "$maint_flag" + docker compose --ansi never up -d --force-recreate --no-deps caddy docker compose --ansi never --profile telegram-local up -d --remove-orphans # The config-only services bind-mount the reseeded config dir. A plain `up -d` # leaves them on the previous bind mount (the dir was rm'd + recreated), so a - # changed Caddyfile or Grafana dashboard is ignored — force-recreate them to - # pick up the fresh config. - docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana + # changed Grafana dashboard is ignored — force-recreate them to pick up the fresh + # config. (Caddy was already recreated above so it would carry the maintenance flag.) + docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana + # Lower the maintenance page: services are back. An open SPA's poll now gets through + # (once the gateway finishes booting) and reloads into the fresh client; the caddy + # probes in the next step see 200. The EXIT trap is a backstop if we failed earlier. + rm -f "$maint_flag" - name: Probe the landing, gateway and backend run: | @@ -340,6 +479,23 @@ jobs: docker logs --tail 50 scrabble-backend || true exit 1 + - name: Probe the /dict edge route reaches the gateway + run: | + set -u + # The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version} + # for the local move preview. If caddy does not route /dict to the gateway the request + # falls to the static landing and the client silently gets a non-dawg blob. Probed + # unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a + # 404/200 from the landing catch-all. + out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)" + echo "$out" | grep -E "HTTP/" || true + if echo "$out" | grep -q " 401"; then + echo "ok: /dict reaches the gateway (401 unauthenticated)" + else + echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?" + exit 1 + fi + - name: Probe the Telegram validator and bot liveness run: | set -u diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index 99602d0..3881010 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -40,11 +40,16 @@ jobs: VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }} - VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }} + # VK Mini App link + VK ID "Web" app id: one value each serves every contour. + VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }} + VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} + # VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default). POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} - TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} - DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} + # PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the + # Mini App URL; both are computed in the build step, not stored variables. + PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} + DICT_VERSION: ${{ vars.DICT_VERSION }} steps: - uses: actions/checkout@v4 with: @@ -58,10 +63,15 @@ jobs: working-directory: deploy run: | export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=. - # The four main-stack images via compose (reuses the build args, incl. VERSION); + # Derive the public URLs from the one canonical origin: the VK ID redirect is a + # build-arg baked into the SPA, and the Mini App URL satisfies the (profiled-out) + # bot service's compose ":?" guard during parse. See deploy/write-prod-env.sh. + base="${PUBLIC_BASE_URL%/}" + export VITE_VK_ID_REDIRECT_URL="$base/app/" TELEGRAM_MINIAPP_URL="$base/telegram/" + # The main-stack images via compose (reuses the build args, incl. VERSION); # the bot separately, since it is profiled out of the prod compose. docker compose -f docker-compose.yml -f docker-compose.prod.yml build - docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator + docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator renderer docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" .. docker push "$REGISTRY/scrabble-telegram-bot:$TAG" @@ -82,18 +92,45 @@ jobs: GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} - GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }} + GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} + # VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at + # runtime) + the app's protected key. Both shared across contours. The redirect URL is + # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. + VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} + GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} + # Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm. + # Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh. + GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} + # Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY). + EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} + # Transactional email via the shared Selectel relay (confirm-codes): one account for + # every contour -> unprefixed host/port/tls/user/pass. + SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }} + SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }} + SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }} + SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }} + SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }} + SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }} + # Operator alerts: backend admin emails + Grafana infra alerts (distinct senders + + # recipients; Grafana uses the relay's STARTTLS host:port). + SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }} + ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }} + SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }} + SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }} + GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }} + GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }} + PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }} PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }} - GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }} CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} - DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} + DICT_VERSION: ${{ vars.DICT_VERSION }} POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }} POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }} - TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} + # TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in + # deploy/write-prod-env.sh, not stored variables. steps: - uses: actions/checkout@v4 with: @@ -121,25 +158,8 @@ jobs: run: | umask 077 mkdir -p stage/certs-main - cat > stage/env.sh < stage/certs-main/ca.crt printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key @@ -150,7 +170,7 @@ jobs: ssh_main 'mkdir -p /opt/scrabble/compose' tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \ | ssh_main 'tar -C /opt/scrabble/compose -xzf -' - tar -C deploy -czf - caddy otelcol prometheus tempo grafana \ + tar -C deploy -czf - caddy otelcol prometheus tempo grafana blackbox \ | ssh_main 'tar -C /opt/scrabble -xzf -' tar -C stage -czf - certs-main \ | ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -' @@ -178,7 +198,8 @@ jobs: PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }} PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} - TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} + # PUBLIC_BASE_URL drives the derived Mini App URL in deploy/write-prod-bot-env.sh. + PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }} TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }} @@ -195,20 +216,8 @@ jobs: run: | umask 077 mkdir -p stage/certs-bot - cat > stage/env.bot.sh < stage/certs-bot/ca.crt printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key diff --git a/.gitea/workflows/prod-rollback.yaml b/.gitea/workflows/prod-rollback.yaml index 2e0a3fa..9f1e71f 100644 --- a/.gitea/workflows/prod-rollback.yaml +++ b/.gitea/workflows/prod-rollback.yaml @@ -51,13 +51,32 @@ jobs: PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }} - GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }} CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} - DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} + DICT_VERSION: ${{ vars.DICT_VERSION }} POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }} POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }} - TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} + PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} + # Full runtime env — parity with prod-deploy's deploy-main so a rollback re-renders + # the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL + # and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. + GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} + VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} + GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} + GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} + EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} + SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }} + SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }} + SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }} + SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }} + SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }} + SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }} + SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }} + ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }} + SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }} + SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }} + GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }} + GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }} INPUT_TARGET: ${{ inputs.target_version }} steps: - uses: actions/checkout@v4 @@ -89,24 +108,9 @@ jobs: run: | umask 077 mkdir -p stage/certs-main - cat > stage/env.sh < the rollback re-renders the FULL + # runtime env (not a subset), so email / VK login / Grafana alerts survive it. + APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key @@ -147,9 +151,11 @@ jobs: PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }} PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} - TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} + # PUBLIC_BASE_URL drives the derived Mini App URL; SUPPORT_CHAT_ID for parity with deploy. + PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }} + TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }} TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }} TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} steps: @@ -163,19 +169,8 @@ jobs: run: | umask 077 mkdir -p stage/certs-bot - cat > stage/env.bot.sh < stage/certs-bot/ca.crt printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key diff --git a/CLAUDE.md b/CLAUDE.md index 75321f4..dc03378 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,9 +125,10 @@ gateway/ # module scrabble/gateway: Connect-RPC edge, embeds ui/ # Svelte + Vite SPA + landing (Node project, not in go.work) pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link) +renderer/ # image-render sidecar (Node + skia-canvas): runs ui/src/lib/gameimage.ts server-side for the finished-game PNG export loadtest/ # module scrabble/loadtest: the load/stress harness docs/ .gitea/workflows/ CLAUDE.md README.md -backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets +backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile renderer/Dockerfile # multi-stage distroless (renderer: node:22-slim + skia-canvas + fonts); gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh ``` @@ -143,6 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080 cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI pnpm start # UI mock mode: lobby -> game, no backend +cd renderer && pnpm install && pnpm test # image-render sidecar (bundles ui/src/lib/gameimage.ts, skia smoke) docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway . diff --git a/assets/icons/README.md b/assets/icons/README.md new file mode 100644 index 0000000..3b8cde5 --- /dev/null +++ b/assets/icons/README.md @@ -0,0 +1,41 @@ +# Erudit — site icons + Open Graph card + +The favicon set and the `og:image` link-preview card for the public landing +(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the +[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`), +with the wordmark on the app's dark board green (`ui/src/app.css` tokens). + +| Output (committed to `ui/public/`) | Purpose | +|------|---------| +| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). | +| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). | +| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. | +| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. | + +## How it works + +`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark +character) from LiberationSans (Arial-metric, the game's font stack) with their +advance widths into `build/glyphs.json` (committed). `build/generate.js` composes +plain SVG from those outlines — no font is needed at generation time — writes +`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the +`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is +assembled in-script (a single PNG entry). Raster bytes therefore depend on the +installed chromium version; the SVG sources are deterministic. + +## Regenerate + +Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright). +`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js` +needs no extra packages**. + +```sh +cd assets/icons + +# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes: +# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf +node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json + +# 2. regenerate everything in ui/public/: +node build/generate.js +``` diff --git a/assets/icons/build/extract.js b/assets/icons/build/extract.js new file mode 100644 index 0000000..0df2800 --- /dev/null +++ b/assets/icons/build/extract.js @@ -0,0 +1,78 @@ +'use strict'; +// Extract the glyph outlines the site icons and the og-image wordmark need from a +// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial +// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down, +// plus the advance width so generate.js can lay out words without the font. +// Same outline conversion as ../../vk/build/extract.js, generalised to a char set. +const opentype = require('opentype.js'); +const fs = require('fs'); + +const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf'; +const b = fs.readFileSync(FONT); +const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); +const FS = 1000; // em scale + +// The tile glyphs («Э», «8») + every character of the og-image wordmark lines +// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance. +const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')]; + +function glyphData(ch) { + const g = font.charToGlyph(ch); + const p = g.getPath(0, 0, FS); // baseline at y=0, y-down + const contours = []; + let cur = null, prev = null; + for (const c of p.commands) { + if (c.type === 'M') { + if (cur) contours.push(cur); + cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }]; + prev = { x: c.x, y: c.y }; + } else if (c.type === 'L') { + cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }); + prev = { x: c.x, y: c.y }; + } else if (c.type === 'C') { + cur[cur.length - 1].o = [c.x1, c.y1]; + cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] }); + prev = { x: c.x, y: c.y }; + } else if (c.type === 'Q') { + const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)]; + const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)]; + cur[cur.length - 1].o = c1; + cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] }); + prev = { x: c.x, y: c.y }; + } else if (c.type === 'Z') { + if (cur && cur.length > 1) { + const last = cur[cur.length - 1], first = cur[0]; + if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) { + first.i = last.i; // fold the duplicate closing point into the first + cur.pop(); + } + } + if (cur) { contours.push(cur); cur = null; } + } + } + if (cur) contours.push(cur); + + let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity; + const out = contours.map(ct => { + const v = [], i = [], o = []; + ct.forEach(pt => { + v.push(pt.v); + i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]); + o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]); + minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]); + miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]); + }); + return { i, o, v, c: true }; + }); + const bbox = out.length + ? { x: minx, y: miny, w: maxx - minx, h: maxy - miny } + : { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline + return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out }; +} + +const glyphs = {}; +for (const ch of CHARS) glyphs[ch] = glyphData(ch); + +const out = __dirname + '/glyphs.json'; +fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs })); +console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join('')); diff --git a/assets/icons/build/generate.js b/assets/icons/build/generate.js new file mode 100644 index 0000000..4ac9928 --- /dev/null +++ b/assets/icons/build/generate.js @@ -0,0 +1,128 @@ +'use strict'; +// Site icons + the Open Graph card for the public landing, drawn from the same +// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's +// board palette (ui/src/app.css). Everything is composed as SVG from the committed +// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build +// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright +// chromium (@playwright/test re-exports the browser API). Outputs go straight to +// ui/public/: +// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small) +// favicon.ico 32x32 PNG-in-ICO render of the same +// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners) +// og-image.png 1200x630 card: tile + wordmark on the board green +const fs = require('fs'); +const path = require('path'); + +const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8')); +const UI = path.resolve(__dirname, '../../../ui'); +const OUT = path.join(UI, 'public'); + +// ---- palette (vk loader tile + app.css board tokens) ------------------------ +const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A'; +const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf'; + +// ---- glyph outlines -> SVG path data ---------------------------------------- +const r2 = n => Math.round(n * 100) / 100; +// pathD renders one glyph's contours scaled by sc and translated by (tx, ty). +function pathD(g, sc, tx, ty) { + const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`; + const Z = [0, 0]; + return g.contours.map(ct => { + const n = ct.v.length; + let d = `M${pt(ct.v[0], Z)}`; + for (let k = 1; k <= n; k++) { + const a = k - 1, b = k % n; + d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`; + } + return d + 'Z'; + }).join(''); +} +// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the +// same placement rule as the vk loader's glyph(). stroke fattens it slightly. +function glyphAt(ch, capPx, cx, cy, stroke, colour) { + const g = G.glyphs[ch], sc = capPx / g.bbox.h; + const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc); + return ``; +} +// textLine lays out a string on a baseline from the per-glyph advances; capPx sets +// the capital height (measured on «Э»). Returns the combined path + the width. +function textLine(str, capPx, x, y, colour) { + const sc = capPx / G.glyphs['Э'].bbox.h; + let d = '', w = 0; + for (const ch of str) { + const g = G.glyphs[ch]; + if (g.contours.length) d += pathD(g, sc, x + w, y); + w += g.adv * sc; + } + return { svg: ``, width: w }; +} +// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with +// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8». +function tile(cx, cy, size, withScore) { + const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52); + let s = ``; + s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH); + if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH); + return s; +} +const svg = (w, h, body) => `${body}`; + +// ---- favicon.svg (the committed vector master) ------------------------------- +const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n'; + +// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ---- +const appleTouch = svg(180, 180, + `` + + `` + + glyphAt('Э', 100, 90, 88, 3, GLYPH) + + glyphAt('8', 26, 146, 142, 1.5, GLYPH)); + +// ---- og-image: tile + wordmark, centred as one group on the board green ------ +function ogImage() { + const W = 1200, H = 630, tileSize = 340, gap = 84; + const l1 = textLine('Эрудит', 112, 0, 0, TEXT); + const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED); + const textW = Math.max(l1.width, l2.width); + const left = (W - (tileSize + gap + textW)) / 2; + const tx = left + tileSize + gap; + // Two baselines around the vertical centre; the tile centre sits between them. + const b1 = 295, b2 = 408; + const body = + `` + + tile(left + tileSize / 2, H / 2, tileSize, true) + + textLine('Эрудит', 112, tx, b1, TEXT).svg + + textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg; + console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`); + return svg(W, H, body); +} + +// ---- rasterisation (Playwright chromium from ui/node_modules) ---------------- +async function shoot(page, markup, w, h, transparent) { + await page.setViewportSize({ width: w, height: h }); + await page.setContent(`${markup}`); + return page.screenshot({ omitBackground: transparent }); +} +// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG). +function icoFromPNG(png, sizePx) { + const h = Buffer.alloc(22); + h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image + h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32 + h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp + h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset + return Buffer.concat([h, png]); +} + +(async () => { + fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon); + const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test')); + const browser = await chromium.launch(); + const page = await browser.newPage(); + const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true); + fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32)); + fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false)); + fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false)); + await browser.close(); + for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) { + console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes'); + } +})(); diff --git a/assets/icons/build/glyphs.json b/assets/icons/build/glyphs.json new file mode 100644 index 0000000..a3baa7a --- /dev/null +++ b/assets/icons/build/glyphs.json @@ -0,0 +1 @@ +{"em":1000,"glyphs":{"8":{"adv":556.15234375,"bbox":{"x":43.45703125,"y":-698.2421875,"w":469.23828125,"h":708.0078125},"contours":[{"i":[[0,-45.247395833333314],[0,0],[40.364583333333314,-35.48177083333333],[75.52083333333331,0],[0,0],[41.50390625,34.830729166666664],[0,64.12760416666666],[0,0],[-25.71614583333333,30.598958333333314],[-40.0390625,6.510416666666686],[0,0],[0,0],[21.647135416666657,29.296875],[0,39.388020833333314],[0,0],[-39.22526041666666,32.55208333333337],[-66.08072916666666,0],[0,0],[-39.225260416666686,-31.90104166666663],[0,-54.36197916666663],[0,0],[21.809895833333314,-29.296875],[37.760416666666686,-7.486979166666686],[0,0],[0,0],[-24.4140625,-30.110677083333314]],"o":[[0,0],[0,63.4765625],[-40.364583333333314,35.48177083333333],[0,0],[-73.56770833333331,0],[-41.50390625,-34.83072916666666],[0,0],[0,-44.921875],[25.71614583333333,-30.598958333333314],[0,0],[0,0],[-37.434895833333314,-8.7890625],[-21.647135416666657,-29.296875],[0,0],[0,-52.40885416666663],[39.22526041666666,-32.55208333333337],[0,0],[67.70833333333331,0],[39.225260416666686,31.90104166666663],[0,0],[0,39.388020833333314],[-21.809895833333314,29.296875],[0,0],[0,0],[43.9453125,7.161458333333314],[24.4140625,30.110677083333314]],"v":[[512.6953125,-191.89453125],[512.6953125,-191.89453125],[452.1484375,-43.45703125],[278.3203125,9.765625],[278.3203125,9.765625],[105.712890625,-42.48046875],[43.45703125,-190.91796875],[43.45703125,-190.91796875],[82.03125,-304.19921875],[180.6640625,-359.86328125],[180.6640625,-359.86328125],[180.6640625,-361.81640625],[92.041015625,-418.9453125],[59.5703125,-521.97265625],[59.5703125,-521.97265625],[118.408203125,-649.4140625],[276.3671875,-698.2421875],[276.3671875,-698.2421875],[436.767578125,-650.390625],[495.60546875,-520.99609375],[495.60546875,-520.99609375],[462.890625,-417.96875],[373.53515625,-362.79296875],[373.53515625,-362.79296875],[373.53515625,-360.83984375],[476.07421875,-304.931640625]],"c":true},{"i":[[0,42.643229166666686],[0,0],[85.28645833333331,0],[0,0],[21.647135416666657,-19.53125],[0,-38.73697916666663],[0,0],[-22.298177083333343,-20.670572916666686],[-40.69010416666666,0],[0,0],[-21.647135416666686,19.04296875]],"o":[[0,0],[0,-77.79947916666663],[0,0],[-41.341145833333314,0],[-21.647135416666657,19.53125],[0,0],[0,39.388020833333314],[22.298177083333343,20.670572916666686],[0,0],[41.341145833333314,0],[21.647135416666686,-19.04296875]],"v":[[404.296875,-516.11328125],[404.296875,-516.11328125],[276.3671875,-632.8125],[276.3671875,-632.8125],[181.884765625,-603.515625],[149.4140625,-516.11328125],[149.4140625,-516.11328125],[182.861328125,-426.025390625],[277.34375,-395.01953125],[277.34375,-395.01953125],[371.826171875,-423.583984375]],"c":true},{"i":[[0,50.130208333333314],[0,0],[25.390625,21.647135416666686],[45.8984375,0],[0,0],[25.065104166666657,-23.274739583333314],[0,-40.69010416666666],[0,0],[-96.6796875,0],[0,0],[-23.4375,22.94921875]],"o":[[0,0],[0,-42.64322916666666],[-25.390625,-21.647135416666686],[0,0],[-44.59635416666666,0],[-25.065104166666657,23.274739583333314],[0,0],[0,94.7265625],[0,0],[47.8515625,0],[23.4375,-22.94921875]],"v":[[421.38671875,-200.1953125],[421.38671875,-200.1953125],[383.30078125,-296.630859375],[276.3671875,-329.1015625],[276.3671875,-329.1015625],[171.875,-294.189453125],[134.27734375,-198.2421875],[134.27734375,-198.2421875],[279.296875,-56.15234375],[279.296875,-56.15234375],[386.23046875,-90.576171875]],"c":true}]},"Э":{"adv":718.75,"bbox":{"x":51.26953125,"y":-698.2421875,"w":632.8125,"h":708.0078125},"contours":[{"i":[[69.3359375,0],[0,0],[35.481770833333314,-21.97265625],[14.6484375,-38.41145833333337],[0,0],[0,0],[-49.15364583333333,28.64583333333337],[-73.2421875,0],[0,0],[-59.24479166666663,-62.17447916666663],[0,-109.70052083333331],[0,0],[26.3671875,-53.7109375],[50.29296875,-28.971354166666664],[69.98697916666663,0],[0,0],[59.89583333333333,121.09375],[0,0],[0,0],[-37.434895833333314,-24.90234375],[-47.200520833333314,0],[0,0],[-43.45703125,44.10807291666666],[-6.184895833333371,74.86979166666666],[0,0],[0,0],[0,0],[0,0],[41.82942708333337,41.17838541666663]],"o":[[0,0],[-49.479166666666686,0],[-35.481770833333314,21.97265625],[0,0],[0,0],[23.111979166666657,-56.31510416666663],[49.153645833333314,-28.64583333333337],[0,0],[104.81770833333331,0],[59.24479166666663,62.17447916666663],[0,0],[0,72.59114583333331],[-26.3671875,53.7109375],[-50.29296875,28.971354166666664],[0,0],[-138.671875,0],[0,0],[0,0],[22.4609375,45.57291666666666],[37.434895833333314,24.90234375],[0,0],[69.3359375,0],[43.45703125,-44.10807291666666],[0,0],[0,0],[0,0],[0,0],[-6.8359375,-74.54427083333331],[-41.829427083333314,-41.17838541666663]],"v":[[348.14453125,-622.0703125],[348.14453125,-622.0703125],[220.703125,-589.111328125],[145.5078125,-498.53515625],[145.5078125,-498.53515625],[57.12890625,-527.83203125],[165.52734375,-655.2734375],[349.12109375,-698.2421875],[349.12109375,-698.2421875],[595.21484375,-604.98046875],[684.08203125,-347.16796875],[684.08203125,-347.16796875],[644.53125,-157.71484375],[529.541015625,-33.69140625],[349.12109375,9.765625],[349.12109375,9.765625],[51.26953125,-171.875],[51.26953125,-171.875],[127.44140625,-209.9609375],[217.28515625,-104.248046875],[344.23828125,-66.89453125],[344.23828125,-66.89453125],[513.427734375,-133.056640625],[587.890625,-311.5234375],[587.890625,-311.5234375],[268.5546875,-311.5234375],[268.5546875,-386.71875],[587.890625,-386.71875],[514.892578125,-560.302734375]],"c":true}]},"р":{"adv":556.15234375,"bbox":{"x":64.453125,"y":-537.59765625,"w":449.70703125,"h":745.1171875},"contours":[{"i":[[0,-93.42447916666663],[0,0],[129.55729166666663,0],[0,0],[27.994791666666657,61.197916666666664],[0,0],[0,0],[0,-52.734375],[0,0],[0,0],[0,0],[0,0],[1.953125,17.578125],[0,0],[0,0],[-0.6510416666666572,-7.975260416666629],[-0.8138020833333428,-16.6015625],[0,-6.184895833333314],[0,0],[0,0],[-25.716145833333343,15.13671875],[-41.9921875,0],[0,0],[-32.2265625,-43.61979166666663]],"o":[[0,0],[0,184.24479166666666],[0,0],[-81.38020833333331,0],[0,0],[0,0],[1.3020833333333428,2.6041666666666714],[0,0],[0,0],[0,0],[0,0],[0,-54.361979166666686],[0,0],[0,0],[0.3255208333333428,1.3020833333333712],[0.6510416666666572,7.975260416666686],[0.8138020833333428,16.6015625],[0,0],[0,0],[15.625,-32.552083333333314],[25.716145833333343,-15.13671875],[0,0],[65.10416666666663,0],[32.2265625,43.619791666666686]],"v":[[514.16015625,-266.6015625],[514.16015625,-266.6015625],[319.82421875,9.765625],[319.82421875,9.765625],[155.76171875,-82.03125],[155.76171875,-82.03125],[153.3203125,-82.03125],[155.2734375,0.9765625],[155.2734375,0.9765625],[155.2734375,207.51953125],[67.3828125,207.51953125],[67.3828125,-420.41015625],[64.453125,-528.3203125],[64.453125,-528.3203125],[149.4140625,-528.3203125],[150.87890625,-514.404296875],[153.076171875,-477.5390625],[154.296875,-443.359375],[154.296875,-443.359375],[156.25,-443.359375],[218.26171875,-514.892578125],[319.82421875,-537.59765625],[319.82421875,-537.59765625],[465.8203125,-472.16796875]],"c":true},{"i":[[0,75.52083333333331],[0,0],[19.856770833333314,31.575520833333314],[43.294270833333314,0],[0,0],[19.694010416666657,-14.6484375],[10.25390625,-31.087239583333314],[0,-49.8046875],[0,0],[-22.135416666666657,-32.87760416666666],[-49.8046875,0],[0,0],[-20.01953125,32.06380208333333]],"o":[[0,0],[0,-73.56770833333331],[-19.856770833333314,-31.575520833333314],[0,0],[-34.830729166666686,0],[-19.694010416666657,14.6484375],[-10.25390625,31.087239583333314],[0,0],[0,69.3359375],[22.135416666666657,32.87760416666666],[0,0],[43.619791666666686,0],[20.01953125,-32.063802083333314]],"v":[[421.875,-264.6484375],[421.875,-264.6484375],[392.08984375,-422.36328125],[297.36328125,-469.7265625],[297.36328125,-469.7265625],[215.576171875,-447.75390625],[170.654296875,-379.150390625],[155.2734375,-257.8125],[155.2734375,-257.8125],[188.4765625,-104.4921875],[296.38671875,-55.17578125],[296.38671875,-55.17578125],[391.845703125,-103.271484375]],"c":true}]},"у":{"adv":500,"bbox":{"x":2.44140625,"y":-528.3203125,"w":495.60546875,"h":735.83984375},"contours":[{"i":[[28.971354166666657,0],[0,0],[16.276041666666664,3.580729166666657],[0,0],[0,0],[-14.973958333333329,0],[0,0],[-31.901041666666657,80.40364583333333],[0,0],[0,0],[0,0],[0,0],[0,0],[-2.278645833333343,-6.34765625],[-12.369791666666657,-36.1328125],[-0.9765625,-4.231770833333329],[0,0],[0,0],[0,0],[0,0],[0,0],[18.880208333333343,-27.506510416666657],[22.94921875,-13.509114583333343]],"o":[[0,0],[-24.088541666666657,0],[0,0],[0,0],[12.369791666666664,1.953125],[0,0],[54.6875,0],[0,0],[0,0],[0,0],[0,0],[0,0],[1.6276041666666572,4.557291666666657],[2.278645833333343,6.34765625],[12.369791666666657,36.1328125],[0,0],[0,0],[0,0],[0,0],[0,0],[-21.809895833333314,56.315104166666664],[-18.880208333333343,27.506510416666657],[-22.94921875,13.509114583333343]],"v":[[93.26171875,207.51953125],[93.26171875,207.51953125],[32.71484375,202.1484375],[32.71484375,202.1484375],[32.71484375,136.23046875],[73.73046875,139.16015625],[73.73046875,139.16015625],[203.61328125,18.5546875],[203.61328125,18.5546875],[211.9140625,-2.44140625],[2.44140625,-528.3203125],[96.19140625,-528.3203125],[207.51953125,-236.328125],[213.37890625,-219.970703125],[235.3515625,-156.25],[255.37109375,-95.703125],[255.37109375,-95.703125],[289.55078125,-191.89453125],[405.2734375,-528.3203125],[498.046875,-528.3203125],[294.921875,0],[233.88671875,125.732421875],[171.142578125,187.255859375]],"c":true}]},"д":{"adv":583.49609375,"bbox":{"x":9.765625,"y":-528.3203125,"w":556.640625,"h":727.5390625},"contours":[{"i":[[0,0],[0,0],[0,0],[14.6484375,-65.59244791666663],[20.833333333333343,-47.20052083333333],[0,0]],"o":[[0,0],[0,0],[-12.044270833333343,88.54166666666663],[-14.6484375,65.59244791666666],[0,0],[0,0]],"v":[[407.2265625,-63.96484375],[407.2265625,-464.35546875],[257.8125,-464.35546875],[217.7734375,-233.154296875],[164.55078125,-63.96484375],[164.55078125,-63.96484375]],"c":true},{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-17.90364583333333,74.86979166666666],[-14.973958333333343,119.140625],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[24.4140625,-40.69010416666666],[17.903645833333343,-74.86979166666663],[0,0],[0,0],[0,0],[0,0]],"v":[[566.40625,-63.96484375],[566.40625,199.21875],[486.81640625,199.21875],[486.81640625,0],[89.35546875,0],[89.35546875,199.21875],[9.765625,199.21875],[9.765625,-63.96484375],[67.87109375,-63.96484375],[131.34765625,-237.3046875],[180.6640625,-528.3203125],[180.6640625,-528.3203125],[495.1171875,-528.3203125],[495.1171875,-63.96484375]],"c":true}]},"и":{"adv":558.59375,"bbox":{"x":69.3359375,"y":-528.3203125,"w":419.921875,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.1393229166666856,23.763020833333314],[-0.9765625,10.091145833333314],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,-11.71875],[1.1393229166666856,-23.763020833333314],[0,0],[0,0],[0,0],[0,0]],"v":[[69.3359375,-528.3203125],[154.296875,-528.3203125],[154.296875,-239.2578125],[149.4140625,-104.00390625],[393.06640625,-528.3203125],[489.2578125,-528.3203125],[489.2578125,0],[405.2734375,0],[405.2734375,-322.265625],[406.982421875,-375.48828125],[410.15625,-426.26953125],[410.15625,-426.26953125],[163.0859375,0],[69.3359375,0]],"c":true}]},"т":{"adv":458.0078125,"bbox":{"x":17.08984375,"y":-528.3203125,"w":423.828125,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[17.08984375,-464.35546875],[17.08984375,-528.3203125],[440.91796875,-528.3203125],[440.91796875,-464.35546875],[272.94921875,-464.35546875],[272.94921875,0],[185.05859375,0],[185.05859375,-464.35546875]],"c":true}]},"С":{"adv":722.16796875,"bbox":{"x":50.78125,"y":-698.2421875,"w":633.30078125,"h":708.0078125},"contours":[{"i":[[48.502604166666686,0],[0,0],[42.317708333333314,-48.99088541666663],[0,-85.28645833333331],[0,0],[-44.10807291666666,-51.26953125],[-75.1953125,0],[0,0],[-48.50260416666663,95.37760416666666],[0,0],[0,0],[51.26953125,-30.924479166666657],[67.70833333333331,0],[0,0],[50.618489583333314,28.80859375],[26.529947916666657,53.54817708333333],[0,73.2421875],[0,0],[-59.24479166666666,62.17447916666663],[-104.81770833333331,0],[0,0],[-49.15364583333337,-28.64583333333337],[-23.11197916666663,-56.31510416666663],[0,0],[0,0],[35.31901041666663,21.15885416666663]],"o":[[0,0],[-76.171875,0],[-42.317708333333314,48.990885416666686],[0,0],[0,84.30989583333331],[44.10807291666666,51.26953125],[0,0],[96.35416666666663,0],[0,0],[0,0],[-28.3203125,59.24479166666666],[-51.26953125,30.924479166666664],[0,0],[-69.3359375,0],[-50.618489583333314,-28.80859375],[-26.529947916666657,-53.548177083333314],[0,0],[0,-109.70052083333331],[59.24479166666666,-62.17447916666663],[0,0],[73.2421875,0],[49.15364583333337,28.64583333333337],[0,0],[0,0],[-15.950520833333371,-40.0390625],[-35.319010416666686,-21.15885416666663]],"v":[[386.71875,-622.0703125],[386.71875,-622.0703125],[208.984375,-548.583984375],[145.5078125,-347.16796875],[145.5078125,-347.16796875],[211.669921875,-143.798828125],[390.625,-66.89453125],[390.625,-66.89453125],[607.91015625,-209.9609375],[607.91015625,-209.9609375],[684.08203125,-171.875],[564.697265625,-36.62109375],[386.23046875,9.765625],[386.23046875,9.765625],[206.298828125,-33.447265625],[90.576171875,-156.982421875],[50.78125,-347.16796875],[50.78125,-347.16796875],[139.6484375,-604.98046875],[385.7421875,-698.2421875],[385.7421875,-698.2421875],[569.3359375,-655.2734375],[677.734375,-527.83203125],[677.734375,-527.83203125],[589.35546875,-498.53515625],[512.451171875,-590.33203125]],"c":true}]},"к":{"adv":437.5,"bbox":{"x":67.3828125,"y":-528.3203125,"w":375,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[-7.8125,2.6041666666666856],[-8.463541666666657,8.30078125],[-10.7421875,14.973958333333314],[-63.4765625,102.5390625],[0,0],[0,0],[0,0],[11.393229166666686,-9.440104166666686],[0,0],[0,0],[0,0],[0,0],[10.579427083333343,-1.953125],[8.463541666666657,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[9.765625,0],[7.8125,-2.6041666666666856],[8.463541666666657,-8.30078125],[10.7421875,-14.973958333333314],[0,0],[0,0],[0,0],[-30.2734375,44.270833333333314],[0,0],[0,0],[0,0],[0,0],[-6.184895833333343,2.604166666666657],[-10.579427083333343,1.953125],[0,0],[0,0],[0,0]],"v":[[67.3828125,0],[67.3828125,-528.3203125],[155.2734375,-528.3203125],[155.2734375,-296.875],[181.640625,-300.78125],[206.0546875,-317.138671875],[234.86328125,-352.05078125],[346.19140625,-528.3203125],[346.19140625,-528.3203125],[437.98828125,-528.3203125],[332.51953125,-369.62890625],[270.01953125,-289.0625],[270.01953125,-289.0625],[442.3828125,0],[344.7265625,0],[208.984375,-242.67578125],[183.837890625,-235.83984375],[155.2734375,-232.91015625],[155.2734375,-232.91015625],[155.2734375,0]],"c":true}]},"э":{"adv":510.25390625,"bbox":{"x":26.85546875,"y":-538.0859375,"w":440.91796875,"h":547.8515625},"contours":[{"i":[[6.8359375,50.45572916666666],[0,0],[0,0],[-21.647135416666657,-16.92708333333333],[-32.552083333333314,0],[0,0],[-23.111979166666686,28.80859375],[-3.90625,61.848958333333314],[0,0],[0,0],[0,0],[0,0],[21.97265625,27.018229166666686],[44.921875,0],[0,0],[10.416666666666657,-63.802083333333314],[0,0],[0,0],[-36.295572916666664,27.180989583333314],[-55.6640625,0],[0,0],[-39.876302083333314,-46.875],[0,-88.54166666666663],[0,0],[39.876302083333314,-46.54947916666666],[75.52083333333331,0],[0,0],[37.109375,29.296875]],"o":[[0,0],[0,0],[5.208333333333329,35.15625],[21.647135416666657,16.92708333333333],[0,0],[41.9921875,0],[23.111979166666686,-28.80859375],[0,0],[0,0],[0,0],[0,0],[-2.6041666666666856,-61.5234375],[-21.97265625,-27.018229166666686],[0,0],[-68.359375,0],[0,0],[0,0],[9.114583333333329,-48.502604166666686],[36.29557291666666,-27.18098958333337],[0,0],[74.86979166666663,0],[39.876302083333314,46.875],[0,0],[0,89.84375],[-39.876302083333314,46.549479166666664],[0,0],[-57.6171875,0],[-37.109375,-29.296875]],"v":[[26.85546875,-153.80859375],[26.85546875,-153.80859375],[115.72265625,-159.66796875],[156.005859375,-81.54296875],[237.3046875,-56.15234375],[237.3046875,-56.15234375],[334.9609375,-99.365234375],[375.48828125,-235.3515625],[375.48828125,-235.3515625],[173.828125,-235.3515625],[173.828125,-299.31640625],[375.48828125,-299.31640625],[338.623046875,-432.12890625],[238.28125,-472.65625],[238.28125,-472.65625],[120.1171875,-376.953125],[120.1171875,-376.953125],[29.78515625,-383.7890625],[97.900390625,-497.314453125],[235.83984375,-538.0859375],[235.83984375,-538.0859375],[407.958984375,-467.7734375],[467.7734375,-264.6484375],[467.7734375,-264.6484375],[407.958984375,-60.05859375],[234.86328125,9.765625],[234.86328125,9.765625],[92.7734375,-34.1796875]],"c":true}]},"б":{"adv":572.75390625,"bbox":{"x":58.59375,"y":-733.3984375,"w":472.16796875,"h":743.1640625},"contours":[{"i":[[0,65.75520833333331],[0,0],[21.484375,31.087239583333314],[48.502604166666686,0],[0,0],[24.251302083333343,-31.575520833333314],[0,-64.77864583333331],[0,0],[-22.623697916666657,-31.73828125],[-48.50260416666666,0],[0,0],[-22.786458333333314,30.436197916666657]],"o":[[0,0],[0,-65.75520833333331],[-21.484375,-31.087239583333314],[0,0],[-51.7578125,0],[-24.251302083333343,31.575520833333314],[0,0],[0,63.15104166666666],[22.623697916666657,31.73828125],[0,0],[52.408854166666686,0],[22.786458333333314,-30.436197916666657]],"v":[[438.4765625,-245.1171875],[438.4765625,-245.1171875],[406.25,-390.380859375],[301.26953125,-437.01171875],[301.26953125,-437.01171875],[187.255859375,-389.6484375],[150.87890625,-245.1171875],[150.87890625,-245.1171875],[184.814453125,-102.783203125],[291.50390625,-55.17578125],[291.50390625,-55.17578125],[404.296875,-100.830078125]],"c":true},{"i":[[-46.875,0],[0,0],[-34.1796875,-42.48046875],[0,-85.28645833333331],[0,0],[40.690104166666686,-42.15494791666666],[77.47395833333331,0],[0,0],[37.923177083333314,52.408854166666664],[0,108.72395833333331],[0,0],[-7.486979166666664,41.829427083333314],[-15.950520833333329,30.2734375],[-25.227864583333343,20.18229166666663],[-38.24869791666666,12.369791666666629],[-124.34895833333331,17.25260416666663],[0,0],[0,0],[40.201822916666686,-15.950520833333371],[16.276041666666657,-34.83072916666663],[1.3020833333333428,-69.3359375],[0,0],[-32.87760416666666,22.786458333333314]],"o":[[0,0],[72.265625,0],[34.1796875,42.48046875],[0,0],[0,86.26302083333331],[-40.690104166666686,42.154947916666664],[0,0],[-80.078125,0],[-37.92317708333333,-52.40885416666666],[0,0],[0,-55.338541666666686],[7.486979166666671,-41.829427083333314],[15.950520833333329,-30.2734375],[25.227864583333343,-20.18229166666663],[38.248697916666686,-12.369791666666629],[0,0],[0,0],[-136.39322916666663,17.578125],[-40.20182291666666,15.950520833333371],[-16.276041666666657,34.830729166666686],[0,0],[13.997395833333343,-40.690104166666686],[32.87760416666666,-22.786458333333314]],"v":[[319.82421875,-501.46484375],[319.82421875,-501.46484375],[479.4921875,-437.744140625],[530.76171875,-246.09375],[530.76171875,-246.09375],[469.7265625,-53.466796875],[292.48046875,9.765625],[292.48046875,9.765625],[115.478515625,-68.84765625],[58.59375,-310.546875],[58.59375,-310.546875],[69.82421875,-456.298828125],[104.98046875,-564.453125],[166.748046875,-640.13671875],[261.962890625,-688.96484375],[505.859375,-733.3984375],[505.859375,-733.3984375],[505.859375,-654.78515625],[240.966796875,-604.4921875],[156.25,-528.3203125],[129.8828125,-372.0703125],[129.8828125,-372.0703125],[200.1953125,-467.28515625]],"c":true}]},"л":{"adv":583.49609375,"bbox":{"x":5.37109375,"y":-528.3203125,"w":508.7890625,"h":538.0859375},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[10.416666666666657,-49.641927083333314],[12.044270833333343,-25.390625],[17.740885416666657,-12.044270833333332],[27.994791666666657,0],[0,0],[14.322916666666664,4.231770833333333],[0,0],[0,0],[-13.997395833333332,0],[0,0],[-10.904947916666671,17.90364583333333],[-8.951822916666671,43.9453125],[-11.393229166666657,85.9375],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[-19.205729166666657,141.92708333333331],[-10.416666666666657,49.64192708333333],[-12.044270833333343,25.390625],[-17.740885416666657,12.044270833333332],[0,0],[-16.276041666666664,0],[0,0],[0,0],[8.138020833333332,2.9296875],[0,0],[17.90364583333333,0],[10.904947916666671,-17.90364583333333],[8.951822916666671,-43.9453125],[0,0],[0,0],[0,0],[0,0]],"v":[[514.16015625,0],[425.78125,0],[425.78125,-464.35546875],[242.67578125,-464.35546875],[198.2421875,-177.001953125],[164.55078125,-64.453125],[119.873046875,-8.30078125],[51.26953125,9.765625],[51.26953125,9.765625],[5.37109375,3.41796875],[5.37109375,3.41796875],[5.37109375,-59.5703125],[38.57421875,-55.17578125],[38.57421875,-55.17578125],[81.787109375,-82.03125],[111.572265625,-174.8046875],[142.08984375,-369.62890625],[142.08984375,-369.62890625],[163.57421875,-528.3203125],[514.16015625,-528.3203125]],"c":true}]},"—":{"adv":1000,"bbox":{"x":0,"y":-287.109375,"w":1000,"h":66.89453125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1000,-220.21484375],[0,-220.21484375],[0,-287.109375],[1000,-287.109375]],"c":true}]},"г":{"adv":364.74609375,"bbox":{"x":69.3359375,"y":-528.3203125,"w":266.11328125,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[69.3359375,-528.3203125],[335.44921875,-528.3203125],[335.44921875,-464.35546875],[157.2265625,-464.35546875],[157.2265625,0],[69.3359375,0]],"c":true}]},"а":{"adv":556.15234375,"bbox":{"x":42.48046875,"y":-538.0859375,"w":513.671875,"h":547.8515625},"contours":[{"i":[[42.64322916666666,0],[0,0],[26.69270833333333,27.994791666666664],[0,48.828125],[0,0],[-35.97005208333333,29.296875],[-80.078125,1.953125],[0,0],[0,0],[0,0],[18.229166666666686,18.5546875],[39.0625,0],[0,0],[17.903645833333343,-13.346354166666686],[3.580729166666657,-29.296875],[0,0],[0,0],[-126.30208333333331,0],[0,0],[-33.528645833333314,-30.43619791666663],[0,-57.6171875],[0,0],[0,0],[-6.8359375,-13.18359375],[-19.205729166666686,0],[0,0],[-10.7421875,2.2786458333333357],[0,0],[0,0],[23.111979166666686,0],[0,0],[14.811197916666686,17.08984375],[1.953125,36.45833333333333],[0,0],[0,0],[29.78515625,-16.764322916666664]],"o":[[0,0],[-53.059895833333314,0],[-26.69270833333333,-27.994791666666664],[0,0],[0,-54.6875],[35.970052083333314,-29.296875],[0,0],[0,0],[0,0],[0,-42.96875],[-18.229166666666686,-18.5546875],[0,0],[-39.388020833333314,0],[-17.903645833333343,13.346354166666686],[0,0],[0,0],[14.973958333333329,-95.05208333333331],[0,0],[66.40625,0],[33.528645833333314,30.436197916666686],[0,0],[0,0],[0,26.041666666666657],[6.8359375,13.18359375],[0,0],[8.463541666666629,0],[0,0],[0,0],[-22.13541666666663,5.208333333333333],[0,0],[-32.552083333333314,0],[-14.811197916666686,-17.08984375],[0,0],[0,0],[-22.4609375,40.36458333333333],[-29.78515625,16.764322916666664]],"v":[[202.1484375,9.765625],[202.1484375,9.765625],[82.51953125,-32.2265625],[42.48046875,-147.4609375],[42.48046875,-147.4609375],[96.435546875,-273.4375],[270.5078125,-320.3125],[270.5078125,-320.3125],[389.16015625,-322.265625],[389.16015625,-351.07421875],[361.81640625,-443.359375],[275.87890625,-471.19140625],[275.87890625,-471.19140625],[189.94140625,-451.171875],[157.71484375,-387.20703125],[157.71484375,-387.20703125],[65.91796875,-395.5078125],[277.83203125,-538.0859375],[277.83203125,-538.0859375],[427.734375,-492.431640625],[478.02734375,-360.3515625],[478.02734375,-360.3515625],[478.02734375,-132.8125],[488.28125,-73.974609375],[527.34375,-54.19921875],[527.34375,-54.19921875],[556.15234375,-57.6171875],[556.15234375,-57.6171875],[556.15234375,-2.9296875],[488.28125,4.8828125],[488.28125,4.8828125],[417.236328125,-20.751953125],[392.08984375,-101.07421875],[392.08984375,-101.07421875],[389.16015625,-101.07421875],[310.791015625,-15.380859375]],"c":true},{"i":[[-28.645833333333343,0],[0,0],[-25.065104166666686,14.6484375],[-14.485677083333314,25.553385416666657],[0,27.018229166666657],[0,0],[0,0],[0,0],[21.321614583333343,-7.8125],[11.393229166666657,-16.276041666666657],[0,-26.3671875],[0,0],[-15.462239583333343,-15.625]],"o":[[0,0],[32.2265625,0],[25.065104166666686,-14.6484375],[14.485677083333314,-25.553385416666657],[0,0],[0,0],[0,0],[-41.341145833333314,0.6510416666666856],[-21.321614583333343,7.8125],[-11.393229166666657,16.276041666666657],[0,0],[0,28.64583333333333],[15.462239583333343,15.625]],"v":[[222.16796875,-56.15234375],[222.16796875,-56.15234375],[308.10546875,-78.125],[367.431640625,-138.427734375],[389.16015625,-217.28515625],[389.16015625,-217.28515625],[389.16015625,-260.7421875],[292.96875,-258.7890625],[198.974609375,-246.09375],[149.90234375,-209.9609375],[132.8125,-145.99609375],[132.8125,-145.99609375],[156.005859375,-79.58984375]],"c":true}]}," ":{"adv":277.83203125,"bbox":{"x":0,"y":0,"w":0,"h":0},"contours":[]},"в":{"adv":531.25,"bbox":{"x":69.3359375,"y":-528.3203125,"w":418.45703125,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[-32.552083333333314,-22.78645833333337],[0,-42.643229166666686],[0,0],[18.5546875,-20.182291666666686],[35.481770833333314,-6.8359375],[0,0],[0,0],[-20.99609375,-21.158854166666686],[0,-34.83072916666666],[0,0],[35.64453125,-26.85546875],[62.825520833333314,0],[0,0],[0,0]],"o":[[0,0],[65.10416666666663,0],[32.552083333333314,22.786458333333314],[0,0],[0,30.2734375],[-18.5546875,20.182291666666686],[0,0],[0,0],[41.9921875,5.533854166666686],[20.99609375,21.158854166666657],[0,0],[0,47.8515625],[-35.64453125,26.85546875],[0,0],[0,0],[0,0]],"v":[[69.3359375,-528.3203125],[275.390625,-528.3203125],[421.875,-494.140625],[470.703125,-395.99609375],[470.703125,-395.99609375],[442.87109375,-320.3125],[361.81640625,-279.78515625],[361.81640625,-279.78515625],[361.81640625,-276.3671875],[456.298828125,-236.328125],[487.79296875,-152.34375],[487.79296875,-152.34375],[434.326171875,-40.283203125],[286.62109375,0],[286.62109375,0],[69.3359375,0]],"c":true},{"i":[[0,0],[0,0],[0,0],[-18.229166666666686,13.834635416666671],[0,30.2734375],[0,0],[19.368489583333314,13.509114583333343],[46.549479166666686,0],[0,0]],"o":[[0,0],[0,0],[44.921875,0],[18.229166666666686,-13.834635416666671],[0,0],[0,-32.87760416666666],[-19.368489583333314,-13.509114583333343],[0,0],[0,0]],"v":[[157.2265625,-241.69921875],[157.2265625,-64.94140625],[272.4609375,-64.94140625],[367.1875,-85.693359375],[394.53125,-151.85546875],[394.53125,-151.85546875],[365.478515625,-221.435546875],[266.6015625,-241.69921875],[266.6015625,-241.69921875]],"c":true},{"i":[[0,0],[0,0],[0,0],[0,0],[-17.740885416666686,11.71875],[0,29.296875],[0,0],[16.6015625,12.369791666666686],[39.388020833333314,0]],"o":[[0,0],[0,0],[0,0],[41.666666666666686,0],[17.740885416666686,-11.71875],[0,0],[0,-26.692708333333314],[-16.6015625,-12.369791666666686],[0,0]],"v":[[269.53125,-461.42578125],[157.2265625,-461.42578125],[157.2265625,-305.17578125],[262.6953125,-305.17578125],[351.806640625,-322.75390625],[378.41796875,-384.27734375],[378.41796875,-384.27734375],[353.515625,-442.87109375],[269.53125,-461.42578125]],"c":true}]},"с":{"adv":500,"bbox":{"x":42.48046875,"y":-538.0859375,"w":431.15234375,"h":547.8515625},"contours":[{"i":[[0,-73.2421875],[0,0],[-22.135416666666657,-33.85416666666666],[-44.59635416666666,0],[0,0],[-20.99609375,16.92708333333333],[-4.8828125,35.15625],[0,0],[0,0],[36.458333333333314,-30.2734375],[55.989583333333314,0],[0,0],[38.899739583333314,46.71223958333333],[0,89.51822916666666],[0,0],[-39.0625,46.712239583333314],[-72.91666666666666,0],[0,0],[-35.64453125,-27.99479166666663],[-9.114583333333314,-49.153645833333314],[0,0],[0,0],[18.5546875,17.252604166666686],[34.1796875,0],[0,0],[20.833333333333343,-30.924479166666686]],"o":[[0,0],[0,70.3125],[22.135416666666657,33.85416666666666],[0,0],[31.25,0],[20.99609375,-16.92708333333333],[0,0],[0,0],[-6.8359375,50.78125],[-36.458333333333314,30.2734375],[0,0],[-73.89322916666666,0],[-38.89973958333333,-46.71223958333333],[0,0],[0,-88.8671875],[39.0625,-46.71223958333337],[0,0],[54.036458333333314,0],[35.64453125,27.994791666666686],[0,0],[0,0],[-4.557291666666686,-29.296875],[-18.5546875,-17.252604166666686],[0,0],[-46.54947916666666,0],[-20.833333333333343,30.924479166666686]],"v":[[134.27734375,-266.6015625],[134.27734375,-266.6015625],[167.48046875,-110.3515625],[267.578125,-59.5703125],[267.578125,-59.5703125],[345.947265625,-84.9609375],[384.765625,-163.0859375],[384.765625,-163.0859375],[473.6328125,-157.2265625],[408.69140625,-35.64453125],[270.01953125,9.765625],[270.01953125,9.765625],[100.830078125,-60.302734375],[42.48046875,-264.6484375],[42.48046875,-264.6484375],[101.07421875,-468.017578125],[269.04296875,-538.0859375],[269.04296875,-538.0859375],[403.564453125,-496.09375],[470.703125,-380.37109375],[470.703125,-380.37109375],[380.37109375,-373.53515625],[345.703125,-443.359375],[266.6015625,-469.23828125],[266.6015625,-469.23828125],[165.52734375,-422.8515625]],"c":true}]},"о":{"adv":556.15234375,"bbox":{"x":41.9921875,"y":-538.0859375,"w":472.16796875,"h":547.8515625},"contours":[{"i":[[0,-93.42447916666663],[0,0],[40.690104166666686,-45.24739583333333],[77.47395833333331,0],[0,0],[39.388020833333314,47.037760416666664],[0,88.8671875],[0,0],[-157.87760416666666,0],[0,0],[-38.0859375,-44.43359375]],"o":[[0,0],[0,92.44791666666666],[-40.690104166666686,45.24739583333333],[0,0],[-77.1484375,0],[-39.38802083333333,-47.03776041666666],[0,0],[0,-182.29166666666663],[0,0],[80.72916666666663,0],[38.0859375,44.43359375]],"v":[[514.16015625,-264.6484375],[514.16015625,-264.6484375],[453.125,-58.10546875],[275.87890625,9.765625],[275.87890625,9.765625],[101.07421875,-60.791015625],[41.9921875,-264.6484375],[41.9921875,-264.6484375],[278.80859375,-538.0859375],[278.80859375,-538.0859375],[457.03125,-471.435546875]],"c":true},{"i":[[0,71.94010416666666],[0,0],[21.647135416666686,33.040364583333314],[51.106770833333314,0],[0,0],[22.94921875,-33.69140625],[0,-71.61458333333331],[0,0],[-22.623697916666657,-34.993489583333314],[-48.50260416666666,0],[0,0],[-22.623697916666686,33.85416666666666]],"o":[[0,0],[0,-72.91666666666663],[-21.647135416666686,-33.040364583333314],[0,0],[-51.43229166666666,0],[-22.94921875,33.69140625],[0,0],[0,69.66145833333331],[22.623697916666657,34.99348958333333],[0,0],[52.734375,0],[22.623697916666686,-33.85416666666666]],"v":[[421.875,-264.6484375],[421.875,-264.6484375],[389.404296875,-423.583984375],[280.2734375,-473.14453125],[280.2734375,-473.14453125],[168.701171875,-422.607421875],[134.27734375,-264.6484375],[134.27734375,-264.6484375],[168.212890625,-107.666015625],[274.90234375,-55.17578125],[274.90234375,-55.17578125],[387.939453125,-105.95703125]],"c":true}]}}} \ No newline at end of file diff --git a/assets/vk/README.md b/assets/vk/README.md new file mode 100644 index 0000000..8ad21b8 --- /dev/null +++ b/assets/vk/README.md @@ -0,0 +1,115 @@ +# Erudit — VK loading-screen logo (Lottie) + +Animated logo for the **VK app loading screen** (shown before the SPA assets load). +The Erudit «Э» tile (score `8`) **drops in under gravity, lands with a soft squash — +its left/right edges bulging into a cushion — then springs back up**, looping. A light +"glint" is caught at the moment of the bounce. + +| File | Purpose | +|------|---------| +| `erudit-loader.json` | The Lottie to upload to VK. | +| `erudit-loader-preview.gif` | Looping preview. Rendered on a green "baize" background **only in the preview** — the real asset has a **transparent** background. | +| `build/` | The reproducible build pipeline (see *Regenerate*). | + +**VK requirements met:** 96×96 px · vector Lottie JSON · ≤ 24 KB (~11 KB) · seamless +~1.1 s loop · transparent background. + +Design reference: a photo of the physical wooden Erudit tile. + +--- + +## How it works + +A single flat layer holds the tile — a rounded-rect **face path**, the two glyphs, and +a thin border — and everything is driven by that layer's transform plus a few colour / +shape tracks. The anchor sits on the tile's **bottom edge**, so the squash happens +against the floor. + +### Bounce (gravity) +`position.y` of the bottom edge goes apex → floor → apex with **no dwell** (the apex is +an instantaneous turn-around). The fall uses a strong **ease-in** (accelerate) and the +rise a strong **ease-out** (decelerate), so it reads as real gravity. While falling fast +the tile slightly **stretches** (tall+thin); that is the squash-and-stretch setup. + +### Soft cushion (squash) +On contact the layer **squashes** (scaleY↓, scaleX↑) about the bottom anchor, with a +touch of skew. Crucially the squash/cushion is **decoupled from the fall speed** — it +eases in and out *slowly* (`ES`), so the landing feels soft even though the fall is +fast. The squash is deliberately gentle (≈ 86 % / 112 %). + +### The cushion shape (the hard part) +The face is **not** a plain rounded rect — it is a path whose left/right contour bows +out into a convex cushion on impact: +- the rest rounded-rect outline is sampled (fine corner arcs + edge mids), and on impact + every point is pushed outward by a smooth **barrel** profile `f(y) = b·(1 − (y/HH)²)` + (max at the middle, fading to zero at the top/bottom); +- so the **whole side — corners included — bows out as one piece**, never just the + straight middle; +- bezier handles are computed as a **chordal spline** (handle length ∝ the adjacent + chord), which stays smooth across the uneven corner/edge point spacing. This is what + keeps the corner↔cushion junction kink-free — both at rest and fully bulged — which a + naïve uniform Catmull-Rom (or moving discrete points with fixed tangents) does not. + +### Glint +At the bounce the face flashes a little lighter (`FACE_GLINT`) and the glyphs warm +toward brown-red (`BLACK_LIT`), then settle back. A cheap, warm "catch the light". + +### Border & background +The tile carries a thin static **border** a touch darker than the face (`BORDER`) so it +reads on any background. The **green baize background is added only when rendering the +preview GIF** — the Lottie itself is transparent. + +### Player compatibility +Only plain 2-D shapes (`ddd:0`) — no 3-D layers, expressions or effects — so every +Lottie player (lottie-web on the web, rlottie on native) renders it identically. + +--- + +## Glyphs + +`Э` (U+042D) and `8` are **real outlines from LiberationSans-Regular** — metric- +compatible with Arial, matching the game's `--font: system-ui … Arial` stack +(see `ui/src/app.css`). `build/extract.js` pulls the contours into `build/glyphs.json` +as Lottie cubic paths; a hair of same-colour stroke adds a touch of weight. + +--- + +## Regenerate + +Requirements: Node ≥ 18. `extract.js` additionally needs `opentype.js` +(`npm i opentype.js`); **`generate.js` has no dependencies**. + +```sh +cd assets/vk + +# 1. (optional) re-extract the glyphs — only if you change the font: +# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf +node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json + +# 2. build the animation (reads build/glyphs.json): +node build/generate.js erudit-loader.json # -> erudit-loader.json + +# 3. (optional) live preview — needs lottie-web (npm i lottie-web): +node build/build-preview.js erudit-loader.json preview.html +# then open preview.html in a browser. +``` + +The preview GIF is assembled by rendering the Lottie frame-by-frame (lottie-web canvas, +filled with the green baize) and stitching with `ffmpeg`; the live HTML preview is the +quickest way to eyeball changes. + +## Tunables (top of `build/generate.js`) + +| Symbol | Meaning | +|--------|---------| +| `OP`, `FR` | loop length (frames) / fps — overall speed | +| `T_HIT / T_PEAK / T_LIFT / T_REC` | beats: contact / squash peak / lift-off / cushion recovered | +| `EI / EO / ES` | easings: fast fall / fast rise / slow soft cushion | +| `APEX`, `FLOOR` | bottom-edge screen-y at the top of the bounce / at rest | +| `HW`, `HH`, `CR` | tile half-width / half-height / corner radius | +| `scl` squash values | the squash amount (scaleX↑ / scaleY↓) | +| `bulge` `b` | cushion depth (how far the sides bow out) | +| `FACE / FACE_GLINT` | wood face / glint flash | +| `BORDER` | tile rim colour | +| `BLACK / BLACK_LIT` | glyph / glyph-at-glint (brown-red) | +| preview bg `#3C7858` | the green-baize colour used **only** in the GIF | diff --git a/assets/vk/build/build-preview.js b/assets/vk/build/build-preview.js new file mode 100644 index 0000000..a490ee4 --- /dev/null +++ b/assets/vk/build/build-preview.js @@ -0,0 +1,31 @@ +'use strict'; +const fs = require('fs'); +const data = fs.readFileSync(process.argv[2] || 'erudit.json', 'utf8'); +const lottiePath = __dirname + '/node_modules/lottie-web/build/player/lottie.min.js'; +const html = `
+ +`; +fs.writeFileSync(process.argv[3] || 'preview.html', html); +console.log('wrote', process.argv[3] || 'preview.html'); diff --git a/assets/vk/build/extract.js b/assets/vk/build/extract.js new file mode 100644 index 0000000..a225381 --- /dev/null +++ b/assets/vk/build/extract.js @@ -0,0 +1,67 @@ +'use strict'; +// Extract the Cyrillic "Э" (U+042D) and the digit "8" outlines from a grotesque +// font (LiberationSans = Arial-metric, matching the game's system-ui/Arial stack) +// and emit Lottie cubic-bezier contours, centred at the origin, y-down. +const opentype = require('opentype.js'); +const fs = require('fs'); + +const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf'; +const b = fs.readFileSync(FONT); +const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); +const FS = 1000; // em scale + +function glyphContours(ch) { + const p = font.charToGlyph(ch).getPath(0, 0, FS); // baseline at y=0, y-down + const contours = []; + let cur = null, prev = null; + for (const c of p.commands) { + if (c.type === 'M') { + if (cur) contours.push(cur); + cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }]; + prev = { x: c.x, y: c.y }; + } else if (c.type === 'L') { + cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }); + prev = { x: c.x, y: c.y }; + } else if (c.type === 'C') { + cur[cur.length - 1].o = [c.x1, c.y1]; + cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] }); + prev = { x: c.x, y: c.y }; + } else if (c.type === 'Q') { + const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)]; + const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)]; + cur[cur.length - 1].o = c1; + cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] }); + prev = { x: c.x, y: c.y }; + } else if (c.type === 'Z') { + if (cur && cur.length > 1) { + const last = cur[cur.length - 1], first = cur[0]; + if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) { + first.i = last.i; // fold the duplicate closing point into the first + cur.pop(); + } + } + if (cur) { contours.push(cur); cur = null; } + } + } + if (cur) contours.push(cur); + + let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity; + const out = contours.map(ct => { + const v = [], i = [], o = []; + ct.forEach(pt => { + v.push(pt.v); + i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]); + o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]); + minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]); + miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]); + }); + return { i, o, v, c: true }; + }); + return { contours: out, bbox: { x: minx, y: miny, w: maxx - minx, h: maxy - miny } }; +} + +const E = glyphContours('Э'); +const D8 = glyphContours('8'); +fs.writeFileSync('glyphs.json', JSON.stringify({ em: FS, E, D8 })); +console.log('Э bbox', E.bbox, 'contours', E.contours.map(c => c.v.length)); +console.log('8 bbox', D8.bbox, 'contours', D8.contours.map(c => c.v.length)); diff --git a/assets/vk/build/generate.js b/assets/vk/build/generate.js new file mode 100644 index 0000000..5e515e9 --- /dev/null +++ b/assets/vk/build/generate.js @@ -0,0 +1,153 @@ +'use strict'; +// VK preloader Lottie: a flat wooden Erudit tile ("Э" + score "8") that drops in +// under gravity, squashes on impact (convex cushion bulging out the left/right edges +// + a touch of skew), and springs back up — looping. A light "glint" is caught at the +// moment of the bounce. Pure 2D shapes (ddd:0). Glyphs are real LiberationSans +// outlines (Arial-metric, = the game's font stack); see extract.js -> glyphs.json. +const fs = require('fs'); +const G = JSON.parse(fs.readFileSync(__dirname + '/glyphs.json', 'utf8')); + +// ---- canvas / timing ------------------------------------------------------- +const W = 96, H = 96, cx = 48; +const FR = 30, OP = 34; // ~1.13 s loop (dynamic, 25% faster) +// keyframe beats (frames): fast fall -> soft squash -> lift -> fast rise -> apex (no dwell) +const T_HIT = 13, T_PEAK = 18, T_LIFT = 19, T_REC = 28; + +// ---- geometry -------------------------------------------------------------- +const HW = 26, HH = 26, CR = 6; // tile half-width / half-height / corner radius +const FLOOR = 90, APEX = 60; // bottom-centre screen-y at rest / at the top of the bounce + +// ---- palette --------------------------------------------------------------- +const col = h => [parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255, parseInt(h.slice(5,7),16)/255]; +const FACE = col('#D9B978'); // wood face (flat) +const FACE_GLINT = col('#E7CD95'); // face flash caught on impact (gentle, not blinding) +const BORDER = col('#B49559'); // tile rim: a touch darker than the face, reads on any bg +const BLACK = col('#1A1A1A'); +const BLACK_LIT = col('#421A0B'); // glyph warms to brown-red on the glint +const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t); + +// ---- property / easing helpers --------------------------------------------- +const still = v => ({ a: 0, k: v }); +const EI = { o: { x: [0.82], y: [0] }, i: { x: [1], y: [1] } }; // strong accelerate (gravity fall) +const EO = { o: { x: [0], y: [0] }, i: { x: [0.18], y: [1] } }; // strong decelerate (rise to apex) +const ES = { o: { x: [0.42], y: [0] }, i: { x: [0.58], y: [1] } }; // soft/slow (the cushion) +function anim(samples) { // samples: {t, v, e?} ; e = easing toward the NEXT key + return { a: 1, k: samples.map((s, idx) => { + const kf = { t: s.t, s: s.v }; + if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; } + return kf; + }) }; +} +const animColor = s => anim(s.map(x => ({ t: x.t, v: [...x.v, 1], e: x.e }))); + +// ---- shape helpers --------------------------------------------------------- +const tr = () => ({ ty: 'tr', p: still([0,0]), a: still([0,0]), s: still([100,100]), r: still(0), o: still(100) }); +const grp = (items, nm) => ({ ty: 'gr', it: [...items, tr()], nm }); +const fill = c => ({ ty: 'fl', c, o: still(100), r: 1, bm: 0 }); +const stroke = (c,w) => ({ ty: 'st', c: still(c), o: still(100), w: still(w), lc: 2, lj: 2, ml: 4, bm: 0 }); +function glyph(gd, hpx, px, py, bold, colour, nm) { // font glyph -> filled+stroked group + const sc = hpx / gd.bbox.h; + const bcx = gd.bbox.x + gd.bbox.w / 2, bcy = gd.bbox.y + gd.bbox.h / 2; + const shapes = gd.contours.map(ct => ({ + ty: 'sh', d: 1, ks: still({ + i: ct.i.map(p => [p[0]*sc, p[1]*sc]), o: ct.o.map(p => [p[0]*sc, p[1]*sc]), + v: ct.v.map(p => [(p[0]-bcx)*sc + px, (p[1]-bcy)*sc + py]), c: true, + }), + })); + return grp([...shapes, fill(colour), stroke(BLACK, bold)], nm); +} + +// Sample the rest rounded-rect outline (clockwise): fine corner arcs + one mid point +// per straight edge, so a smooth interpolant reproduces it cleanly. +function arc(ccx, ccy, a0, a1, n) { + const out = []; + for (let i = 0; i < n; i++) { const a = (a0 + (a1 - a0) * i / (n - 1)) * Math.PI / 180; out.push([ccx + CR*Math.cos(a), ccy + CR*Math.sin(a)]); } + return out; +} +const REST = (() => { + const NC = 7, yi = HH - CR, xi = HW - CR; + const C = [ arc(xi,-yi,-90,0,NC), arc(xi,yi,0,90,NC), arc(-xi,yi,90,180,NC), arc(-xi,-yi,180,270,NC) ]; + const pts = []; + for (let k = 0; k < 4; k++) { + pts.push(...C[k]); + const a = C[k][NC-1], b = C[(k+1)%4][0]; + pts.push([(a[0]+b[0])/2, (a[1]+b[1])/2]); // straight-edge mid point (keeps the edge straight) + } + return pts; +})(); +// The whole left/right contour (corners + side) bows out by one smooth barrel profile; +// Catmull-Rom tangents (from neighbours) make every junction C1-smooth -> no kink, the +// corners follow the cushion and the cushion melts into the corners, bulged or not. +function facePath(b) { + const f = y => b * (1 - (y/HH) * (y/HH)); // 0 at top/bottom, max at the middle + const V = REST.map(([x, y]) => [x + Math.sign(x) * f(y), y]); // push the sides out, top/bottom stay + const n = V.length, I = [], O = []; + for (let k = 0; k < n; k++) { + const p0 = V[(k-1+n)%n], p1 = V[k], p2 = V[(k+1)%n]; + let dx = p2[0]-p0[0], dy = p2[1]-p0[1]; // tangent direction (neighbours) + const dl = Math.hypot(dx, dy) || 1; dx /= dl; dy /= dl; + const ln = Math.hypot(p2[0]-p1[0], p2[1]-p1[1]) / 3; // handles ~ 1/3 of the adjacent chord + const lp = Math.hypot(p1[0]-p0[0], p1[1]-p0[1]) / 3; // -> chordal spline, no overshoot/facets + O.push([dx*ln, dy*ln]); I.push([-dx*lp, -dy*lp]); + } + return { i: I, o: O, v: V, c: true }; +} +const facePathKeys = samples => ({ a: 1, k: samples.map((s, idx) => { + const kf = { t: s.t, s: [facePath(s.b)] }; + if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; } + return kf; +}) }); + +// ---- animation tracks ------------------------------------------------------ +// bottom-centre screen-y (the tile rests/squashes on its bottom edge) +const posY = anim([ + { t: 0, v: [cx, APEX, 0], e: EI }, // apex -> immediately falls (no dwell) + { t: T_HIT, v: [cx, FLOOR, 0], e: ES }, // FAST fall (accelerate) -> floor + { t: T_LIFT, v: [cx, FLOOR, 0], e: EO }, // sit on the floor while the cushion absorbs + { t: OP, v: [cx, APEX, 0] }, // FAST rise (decelerate) -> apex = loop start +]); +// scale about the bottom anchor: stretch while falling, gentle/slow cushion squash on impact +const scl = anim([ + { t: 0, v: [100, 100, 100], e: ES }, + { t: T_HIT-5, v: [95, 106, 100], e: ES }, // stretch while falling fast + { t: T_HIT, v: [104, 95, 100], e: ES }, // touch down + { t: T_PEAK, v: [112, 86, 100], e: ES }, // soft squash peak (gentler, less plче) + { t: T_REC, v: [100, 100, 100], e: ES }, // slow cushion release -> soft landing + { t: OP, v: [100, 100, 100] }, +]); +// a touch of skew through the squash (organic deform), back to 0 +const skw = anim([ + { t: T_HIT, v: [0], e: ES }, { t: T_PEAK, v: [4], e: ES }, { t: T_REC, v: [0] }, { t: OP, v: [0] }, +]); +// left/right cushion bulge: 0 -> gentle peak -> 0 (never inward) +const bulge = facePathKeys([ + { t: T_HIT, b: 0, e: ES }, { t: T_PEAK, b: 4, e: ES }, { t: T_REC, b: 0 }, { t: OP, b: 0 }, +]); +// glint: face + glyph catch the light at the bounce +const faceCol = animColor([ + { t: T_HIT-2, v: FACE, e: ES }, { t: T_PEAK, v: FACE_GLINT, e: ES }, { t: T_REC, v: FACE }, { t: OP, v: FACE }, +]); +const glyphCol = animColor([ + { t: T_HIT-2, v: BLACK, e: ES }, { t: T_PEAK, v: BLACK_LIT, e: ES }, { t: T_REC, v: BLACK }, { t: OP, v: BLACK }, +]); + +// ---- layer (face + glyphs share the bounce/squash transform) --------------- +const faceShape = grp([ { ty: 'sh', d: 1, ks: bulge }, fill(faceCol), stroke(BORDER, 1.8) ], 'face'); +const glyphE = glyph(G.E, 32, 0, -1, 1.0, glyphCol, 'E'); // centred Э +const glyph8 = glyph(G.D8, 8.5, HW-6.5, HH-7.5, 0.5, glyphCol, 'score'); // 8 in the corner + +const tile = { + ddd: 0, ind: 1, ty: 4, nm: 'tile', sr: 1, + ks: { o: still(100), r: still(0), p: posY, a: still([0, HH, 0]), s: scl, sk: skw, sa: still(0) }, + ao: 0, shapes: [ glyph8, glyphE, faceShape ], ip: 0, op: OP, st: 0, bm: 0, +}; + +const root = { + v: '5.7.4', fr: FR, ip: 0, op: OP, w: W, h: H, nm: 'erudit-vk-loader', ddd: 0, + assets: [], layers: [ tile ], markers: [], +}; + +const out = process.argv[2] || 'erudit.json'; +const round = (k, v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : v); +fs.writeFileSync(out, JSON.stringify(root, round)); +console.log('wrote', out, fs.statSync(out).size, 'bytes'); diff --git a/assets/vk/build/glyphs.json b/assets/vk/build/glyphs.json new file mode 100644 index 0000000..0771598 --- /dev/null +++ b/assets/vk/build/glyphs.json @@ -0,0 +1 @@ +{"em":1000,"E":{"contours":[{"i":[[0,0],[0,0],[35.481770833333314,-21.97265625],[14.6484375,-38.41145833333337],[0,0],[0,0],[-49.15364583333333,28.64583333333337],[-73.2421875,0],[0,0],[-59.24479166666663,-62.17447916666663],[0,-109.70052083333331],[0,0],[26.3671875,-53.7109375],[50.29296875,-28.971354166666664],[69.98697916666663,0],[0,0],[59.89583333333333,121.09375],[0,0],[0,0],[-37.434895833333314,-24.90234375],[-47.200520833333314,0],[0,0],[-43.45703125,44.10807291666666],[-6.184895833333371,74.86979166666666],[0,0],[0,0],[0,0],[0,0],[41.82942708333337,41.17838541666663],[69.3359375,0]],"o":[[0,0],[-49.479166666666686,0],[-35.481770833333314,21.97265625],[0,0],[0,0],[23.111979166666657,-56.31510416666663],[49.153645833333314,-28.64583333333337],[0,0],[104.81770833333331,0],[59.24479166666663,62.17447916666663],[0,0],[0,72.59114583333331],[-26.3671875,53.7109375],[-50.29296875,28.971354166666664],[0,0],[-138.671875,0],[0,0],[0,0],[22.4609375,45.57291666666666],[37.434895833333314,24.90234375],[0,0],[69.3359375,0],[43.45703125,-44.10807291666666],[0,0],[0,0],[0,0],[0,0],[-6.8359375,-74.54427083333331],[-41.829427083333314,-41.17838541666663],[0,0]],"v":[[348.14453125,-622.0703125],[348.14453125,-622.0703125],[220.703125,-589.111328125],[145.5078125,-498.53515625],[145.5078125,-498.53515625],[57.12890625,-527.83203125],[165.52734375,-655.2734375],[349.12109375,-698.2421875],[349.12109375,-698.2421875],[595.21484375,-604.98046875],[684.08203125,-347.16796875],[684.08203125,-347.16796875],[644.53125,-157.71484375],[529.541015625,-33.69140625],[349.12109375,9.765625],[349.12109375,9.765625],[51.26953125,-171.875],[51.26953125,-171.875],[127.44140625,-209.9609375],[217.28515625,-104.248046875],[344.23828125,-66.89453125],[344.23828125,-66.89453125],[513.427734375,-133.056640625],[587.890625,-311.5234375],[587.890625,-311.5234375],[268.5546875,-311.5234375],[268.5546875,-386.71875],[587.890625,-386.71875],[514.892578125,-560.302734375],[348.14453125,-622.0703125]],"c":true}],"bbox":{"x":51.26953125,"y":-698.2421875,"w":632.8125,"h":708.0078125}},"D8":{"contours":[{"i":[[0,0],[0,0],[40.364583333333314,-35.48177083333333],[75.52083333333331,0],[0,0],[41.50390625,34.830729166666664],[0,64.12760416666666],[0,0],[-25.71614583333333,30.598958333333314],[-40.0390625,6.510416666666686],[0,0],[0,0],[21.647135416666657,29.296875],[0,39.388020833333314],[0,0],[-39.22526041666666,32.55208333333337],[-66.08072916666666,0],[0,0],[-39.225260416666686,-31.90104166666663],[0,-54.36197916666663],[0,0],[21.809895833333314,-29.296875],[37.760416666666686,-7.486979166666686],[0,0],[0,0],[-24.4140625,-30.110677083333314],[0,-45.247395833333314]],"o":[[0,0],[0,63.4765625],[-40.364583333333314,35.48177083333333],[0,0],[-73.56770833333331,0],[-41.50390625,-34.83072916666666],[0,0],[0,-44.921875],[25.71614583333333,-30.598958333333314],[0,0],[0,0],[-37.434895833333314,-8.7890625],[-21.647135416666657,-29.296875],[0,0],[0,-52.40885416666663],[39.22526041666666,-32.55208333333337],[0,0],[67.70833333333331,0],[39.225260416666686,31.90104166666663],[0,0],[0,39.388020833333314],[-21.809895833333314,29.296875],[0,0],[0,0],[43.9453125,7.161458333333314],[24.4140625,30.110677083333314],[0,0]],"v":[[512.6953125,-191.89453125],[512.6953125,-191.89453125],[452.1484375,-43.45703125],[278.3203125,9.765625],[278.3203125,9.765625],[105.712890625,-42.48046875],[43.45703125,-190.91796875],[43.45703125,-190.91796875],[82.03125,-304.19921875],[180.6640625,-359.86328125],[180.6640625,-359.86328125],[180.6640625,-361.81640625],[92.041015625,-418.9453125],[59.5703125,-521.97265625],[59.5703125,-521.97265625],[118.408203125,-649.4140625],[276.3671875,-698.2421875],[276.3671875,-698.2421875],[436.767578125,-650.390625],[495.60546875,-520.99609375],[495.60546875,-520.99609375],[462.890625,-417.96875],[373.53515625,-362.79296875],[373.53515625,-362.79296875],[373.53515625,-360.83984375],[476.07421875,-304.931640625],[512.6953125,-191.89453125]],"c":true},{"i":[[0,0],[0,0],[85.28645833333331,0],[0,0],[21.647135416666657,-19.53125],[0,-38.73697916666663],[0,0],[-22.298177083333343,-20.670572916666686],[-40.69010416666666,0],[0,0],[-21.647135416666686,19.04296875],[0,42.643229166666686]],"o":[[0,0],[0,-77.79947916666663],[0,0],[-41.341145833333314,0],[-21.647135416666657,19.53125],[0,0],[0,39.388020833333314],[22.298177083333343,20.670572916666686],[0,0],[41.341145833333314,0],[21.647135416666686,-19.04296875],[0,0]],"v":[[404.296875,-516.11328125],[404.296875,-516.11328125],[276.3671875,-632.8125],[276.3671875,-632.8125],[181.884765625,-603.515625],[149.4140625,-516.11328125],[149.4140625,-516.11328125],[182.861328125,-426.025390625],[277.34375,-395.01953125],[277.34375,-395.01953125],[371.826171875,-423.583984375],[404.296875,-516.11328125]],"c":true},{"i":[[0,0],[0,0],[25.390625,21.647135416666686],[45.8984375,0],[0,0],[25.065104166666657,-23.274739583333314],[0,-40.69010416666666],[0,0],[-96.6796875,0],[0,0],[-23.4375,22.94921875],[0,50.130208333333314]],"o":[[0,0],[0,-42.64322916666666],[-25.390625,-21.647135416666686],[0,0],[-44.59635416666666,0],[-25.065104166666657,23.274739583333314],[0,0],[0,94.7265625],[0,0],[47.8515625,0],[23.4375,-22.94921875],[0,0]],"v":[[421.38671875,-200.1953125],[421.38671875,-200.1953125],[383.30078125,-296.630859375],[276.3671875,-329.1015625],[276.3671875,-329.1015625],[171.875,-294.189453125],[134.27734375,-198.2421875],[134.27734375,-198.2421875],[279.296875,-56.15234375],[279.296875,-56.15234375],[386.23046875,-90.576171875],[421.38671875,-200.1953125]],"c":true}],"bbox":{"x":43.45703125,"y":-698.2421875,"w":469.23828125,"h":708.0078125}}} \ No newline at end of file diff --git a/assets/vk/erudit-loader-preview.gif b/assets/vk/erudit-loader-preview.gif new file mode 100644 index 0000000..141df50 Binary files /dev/null and b/assets/vk/erudit-loader-preview.gif differ diff --git a/assets/vk/erudit-loader.json b/assets/vk/erudit-loader.json new file mode 100644 index 0000000..13bd1c2 --- /dev/null +++ b/assets/vk/erudit-loader.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":30,"ip":0,"op":34,"w":96,"h":96,"nm":"erudit-vk-loader","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"tile","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":0,"s":[48,60,0],"o":{"x":[0.82],"y":[0]},"i":{"x":[1],"y":[1]}},{"t":13,"s":[48,90,0],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":19,"s":[48,90,0],"o":{"x":[0],"y":[0]},"i":{"x":[0.18],"y":[1]}},{"t":34,"s":[48,60,0]}]},"a":{"a":0,"k":[0,26,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":8,"s":[95,106,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":13,"s":[104,95,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[112,86,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[100,100,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[100,100,100]}]},"sk":{"a":1,"k":[{"t":13,"s":[0],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[4],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[0],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[0]}]},"sa":{"a":0,"k":0}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.48,-0.43],[0.91,0],[0,0],[0.5,0.42],[0,0.77],[0,0],[-0.31,0.37],[-0.48,0.08],[0,0],[0,0],[0.26,0.35],[0,0.47],[0,0],[-0.47,0.39],[-0.79,0],[0,0],[-0.47,-0.38],[0,-0.65],[0,0],[0.26,-0.35],[0.45,-0.09],[0,0],[0,0],[-0.29,-0.36],[0,-0.54]],"o":[[0,0],[0,0.76],[-0.48,0.43],[0,0],[-0.88,0],[-0.5,-0.42],[0,0],[0,-0.54],[0.31,-0.37],[0,0],[0,0],[-0.45,-0.11],[-0.26,-0.35],[0,0],[0,-0.63],[0.47,-0.39],[0,0],[0.81,0],[0.47,0.38],[0,0],[0,0.47],[-0.26,0.35],[0,0],[0,0],[0.53,0.09],[0.29,0.36],[0,0]],"v":[[22.32,20.33],[22.32,20.33],[21.59,22.11],[19.5,22.75],[19.5,22.75],[17.43,22.12],[16.68,20.34],[16.68,20.34],[17.15,18.98],[18.33,18.31],[18.33,18.31],[18.33,18.29],[17.27,17.6],[16.88,16.37],[16.88,16.37],[17.58,14.84],[19.48,14.25],[19.48,14.25],[21.41,14.82],[22.11,16.38],[22.11,16.38],[21.72,17.61],[20.65,18.28],[20.65,18.28],[20.65,18.3],[21.88,18.97],[22.32,20.33]],"c":true}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[1.02,0],[0,0],[0.26,-0.23],[0,-0.47],[0,0],[-0.27,-0.25],[-0.49,0],[0,0],[-0.26,0.23],[0,0.51]],"o":[[0,0],[0,-0.93],[0,0],[-0.5,0],[-0.26,0.23],[0,0],[0,0.47],[0.27,0.25],[0,0],[0.5,0],[0.26,-0.23],[0,0]],"v":[[21.02,16.44],[21.02,16.44],[19.48,15.04],[19.48,15.04],[18.35,15.39],[17.96,16.44],[17.96,16.44],[18.36,17.52],[19.49,17.89],[19.49,17.89],[20.63,17.55],[21.02,16.44]],"c":true}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.3,0.26],[0.55,0],[0,0],[0.3,-0.28],[0,-0.49],[0,0],[-1.16,0],[0,0],[-0.28,0.28],[0,0.6]],"o":[[0,0],[0,-0.51],[-0.3,-0.26],[0,0],[-0.54,0],[-0.3,0.28],[0,0],[0,1.14],[0,0],[0.57,0],[0.28,-0.28],[0,0]],"v":[[21.22,20.23],[21.22,20.23],[20.76,19.07],[19.48,18.68],[19.48,18.68],[18.23,19.1],[17.77,20.25],[17.77,20.25],[19.51,21.96],[19.51,21.96],[20.8,21.55],[21.22,20.23]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":11,"s":[0.1,0.1,0.1,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[0.26,0.1,0.04,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[0.1,0.1,0.1,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[0.1,0.1,0.1,1]}]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.1,0.1,0.1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0.5},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"score"},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[1.6,-0.99],[0.66,-1.74],[0,0],[0,0],[-2.22,1.29],[-3.31,0],[0,0],[-2.68,-2.81],[0,-4.96],[0,0],[1.19,-2.43],[2.27,-1.31],[3.16,0],[0,0],[2.71,5.47],[0,0],[0,0],[-1.69,-1.13],[-2.13,0],[0,0],[-1.96,1.99],[-0.28,3.38],[0,0],[0,0],[0,0],[0,0],[1.89,1.86],[3.13,0]],"o":[[0,0],[-2.24,0],[-1.6,0.99],[0,0],[0,0],[1.04,-2.55],[2.22,-1.29],[0,0],[4.74,0],[2.68,2.81],[0,0],[0,3.28],[-1.19,2.43],[-2.27,1.31],[0,0],[-6.27,0],[0,0],[0,0],[1.02,2.06],[1.69,1.13],[0,0],[3.13,0],[1.96,-1.99],[0,0],[0,0],[0,0],[0,0],[-0.31,-3.37],[-1.89,-1.86],[0,0]],"v":[[-0.88,-13.56],[-0.88,-13.56],[-6.64,-12.07],[-10.04,-7.97],[-10.04,-7.97],[-14.04,-9.3],[-9.14,-15.06],[-0.84,-17],[-0.84,-17],[10.28,-12.78],[14.3,-1.13],[14.3,-1.13],[12.51,7.43],[7.32,13.04],[-0.84,15],[-0.84,15],[-14.3,6.79],[-14.3,6.79],[-10.86,5.07],[-6.8,9.85],[-1.06,11.54],[-1.06,11.54],[6.59,8.54],[9.95,0.48],[9.95,0.48],[-4.48,0.48],[-4.48,-2.92],[9.95,-2.92],[6.65,-10.77],[-0.88,-13.56]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":11,"s":[0.1,0.1,0.1,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[0.26,0.1,0.04,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[0.1,0.1,0.1,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[0.1,0.1,0.1,1]}]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.1,0.1,0.1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"E"},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":1,"k":[{"t":13,"s":[{"i":[[-6.67,-0.06],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[0,-0.52],[0,-6.67],[0.06,-6.67],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[0.52,0],[6.67,0],[6.67,0.06],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0,0.52],[0,6.67],[-0.06,6.67],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-0.52,0],[-6.67,0]],"o":[[0.52,0],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0.06,6.67],[0,6.67],[0,0.52],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-6.67,0.06],[-6.67,0],[-0.52,0],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[-0.06,-6.67],[0,-6.67],[0,-0.52],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[6.67,-0.06],[6.67,0]],"v":[[20,-26],[21.55,-25.8],[23,-25.2],[24.24,-24.24],[25.2,-23],[25.8,-21.55],[26,-20],[26,0],[26,20],[25.8,21.55],[25.2,23],[24.24,24.24],[23,25.2],[21.55,25.8],[20,26],[0,26],[-20,26],[-21.55,25.8],[-23,25.2],[-24.24,24.24],[-25.2,23],[-25.8,21.55],[-26,20],[-26,0],[-26,-20],[-25.8,-21.55],[-25.2,-23],[-24.24,-24.24],[-23,-25.2],[-21.55,-25.8],[-20,-26],[0,-26]],"c":true}],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[{"i":[[-6.67,-0.06],[-0.53,-0.13],[-0.52,-0.26],[-0.47,-0.37],[-0.39,-0.46],[-0.27,-0.52],[-0.08,-0.55],[0,-6.71],[0.91,-6.65],[0.26,-0.49],[0.38,-0.44],[0.47,-0.37],[0.54,-0.26],[0.56,-0.14],[0.54,-0.01],[6.67,0],[6.67,0.06],[0.53,0.13],[0.52,0.26],[0.47,0.37],[0.39,0.46],[0.27,0.52],[0.08,0.55],[0,6.71],[-0.91,6.65],[-0.26,0.49],[-0.38,0.44],[-0.47,0.37],[-0.54,0.26],[-0.56,0.14],[-0.54,0.01],[-6.67,0]],"o":[[0.54,0.01],[0.56,0.14],[0.54,0.26],[0.47,0.37],[0.38,0.44],[0.26,0.49],[0.91,6.65],[0,6.71],[-0.08,0.55],[-0.27,0.52],[-0.39,0.46],[-0.47,0.37],[-0.52,0.26],[-0.53,0.13],[-6.67,0.06],[-6.67,0],[-0.54,-0.01],[-0.56,-0.14],[-0.54,-0.26],[-0.47,-0.37],[-0.38,-0.44],[-0.26,-0.49],[-0.91,-6.65],[0,-6.71],[0.08,-0.55],[0.27,-0.52],[0.39,-0.46],[0.47,-0.37],[0.52,-0.26],[0.53,-0.13],[6.67,-0.06],[6.67,0]],"v":[[20,-26],[21.62,-25.8],[23.24,-25.2],[24.77,-24.24],[26.07,-23],[27.05,-21.55],[27.63,-20],[30,0],[27.63,20],[27.05,21.55],[26.07,23],[24.77,24.24],[23.24,25.2],[21.62,25.8],[20,26],[0,26],[-20,26],[-21.62,25.8],[-23.24,25.2],[-24.77,24.24],[-26.07,23],[-27.05,21.55],[-27.63,20],[-30,0],[-27.63,-20],[-27.05,-21.55],[-26.07,-23],[-24.77,-24.24],[-23.24,-25.2],[-21.62,-25.8],[-20,-26],[0,-26]],"c":true}],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[{"i":[[-6.67,-0.06],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[0,-0.52],[0,-6.67],[0.06,-6.67],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[0.52,0],[6.67,0],[6.67,0.06],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0,0.52],[0,6.67],[-0.06,6.67],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-0.52,0],[-6.67,0]],"o":[[0.52,0],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0.06,6.67],[0,6.67],[0,0.52],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-6.67,0.06],[-6.67,0],[-0.52,0],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[-0.06,-6.67],[0,-6.67],[0,-0.52],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[6.67,-0.06],[6.67,0]],"v":[[20,-26],[21.55,-25.8],[23,-25.2],[24.24,-24.24],[25.2,-23],[25.8,-21.55],[26,-20],[26,0],[26,20],[25.8,21.55],[25.2,23],[24.24,24.24],[23,25.2],[21.55,25.8],[20,26],[0,26],[-20,26],[-21.55,25.8],[-23,25.2],[-24.24,24.24],[-25.2,23],[-25.8,21.55],[-26,20],[-26,0],[-26,-20],[-25.8,-21.55],[-25.2,-23],[-24.24,-24.24],[-23,-25.2],[-21.55,-25.8],[-20,-26],[0,-26]],"c":true}],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[{"i":[[-6.67,-0.06],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[0,-0.52],[0,-6.67],[0.06,-6.67],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[0.52,0],[6.67,0],[6.67,0.06],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0,0.52],[0,6.67],[-0.06,6.67],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-0.52,0],[-6.67,0]],"o":[[0.52,0],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0.06,6.67],[0,6.67],[0,0.52],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-6.67,0.06],[-6.67,0],[-0.52,0],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[-0.06,-6.67],[0,-6.67],[0,-0.52],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[6.67,-0.06],[6.67,0]],"v":[[20,-26],[21.55,-25.8],[23,-25.2],[24.24,-24.24],[25.2,-23],[25.8,-21.55],[26,-20],[26,0],[26,20],[25.8,21.55],[25.2,23],[24.24,24.24],[23,25.2],[21.55,25.8],[20,26],[0,26],[-20,26],[-21.55,25.8],[-23,25.2],[-24.24,24.24],[-25.2,23],[-25.8,21.55],[-26,20],[-26,0],[-26,-20],[-25.8,-21.55],[-25.2,-23],[-24.24,-24.24],[-23,-25.2],[-21.55,-25.8],[-20,-26],[0,-26]],"c":true}]}]}},{"ty":"fl","c":{"a":1,"k":[{"t":11,"s":[0.85,0.73,0.47,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[0.91,0.8,0.58,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[0.85,0.73,0.47,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[0.85,0.73,0.47,1]}]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.71,0.58,0.35]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.8},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"face"}],"ip":0,"op":34,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index 5a6230b..3755184 100644 --- a/backend/README.md +++ b/backend/README.md @@ -43,8 +43,10 @@ per-user blocks, and per-game chat with nudges folded in as a message kind; chat messages are length-capped, content-filtered (no links/emails/phone numbers, including obfuscated forms) and stored with the sender's IP. Each message carries an `unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead` -clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer` -clears a nudge when its recipient moves — both record the publish-to-read latency. +clears a reader's bit when they open the move history or chat, a wired `NudgeClearer` +clears a nudge when its recipient moves, and a wired `NudgeExpirer` clears **all** of a game's +nudges when it finishes (any completion path) — the first two record the publish-to-read latency, +the completion expiry does not (it is not a read); chat messages stay unread on completion. A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a background reaper drops a robot friend request once its game has been finished for **7 days**. @@ -212,11 +214,13 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p | `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. | | `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. | | `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. | -| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). | -| `BACKEND_SMTP_PORT` | `587` | Email relay port. | -| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. | -| `BACKEND_SMTP_PASSWORD` | — | SMTP password. | -| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. | +| `BACKEND_SMTP_HOST` | — | Confirm-code relay host. **Empty selects the development log mailer** (the code is logged, not sent). | +| `BACKEND_SMTP_PORT` | `587` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | +| `BACKEND_SMTP_TLS` | — | Transport security: `ssl` (implicit TLS from connect) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); set it for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). | +| `BACKEND_SMTP_USERNAME` | — | SMTP AUTH user; empty relays without authentication. | +| `BACKEND_SMTP_PASSWORD` | — | SMTP AUTH password. | +| `BACKEND_SMTP_FROM` | `no-reply@localhost` | From address. A deployed contour must use the prod domain (the relay only accepts its verified sender domain). | +| `BACKEND_PUBLIC_BASE_URL` | — | Canonical public origin (scheme + host) for links in the email. **Required when `BACKEND_SMTP_HOST` is set.** Never derived from a request Host header (anti-injection). | | `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | | `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. | | `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. | diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index c8b4bfa..c85a229 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -12,6 +12,7 @@ import ( "fmt" "log" "os/signal" + "strings" "syscall" "time" @@ -20,6 +21,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/accountmerge" + "scrabble/backend/internal/adminalert" "scrabble/backend/internal/ads" "scrabble/backend/internal/banview" "scrabble/backend/internal/config" @@ -33,6 +35,7 @@ import ( "scrabble/backend/internal/postgres" "scrabble/backend/internal/pushgrpc" "scrabble/backend/internal/ratewatch" + "scrabble/backend/internal/render" "scrabble/backend/internal/robot" "scrabble/backend/internal/server" "scrabble/backend/internal/session" @@ -43,6 +46,10 @@ import ( // telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit. const telemetryShutdownTimeout = 5 * time.Second +// adminAlertInterval is how often the operator-alert worker checks for new feedback / +// complaints; a burst within one interval coalesces into a single digest email. +const adminAlertInterval = 5 * time.Minute + func main() { cfg, err := config.Load() if err != nil { @@ -161,6 +168,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.Duration("interval", cfg.GuestReapInterval), zap.Duration("retention", cfg.GuestRetention)) + // Purge the account-deletion legal dossier past its retention TTL: the + // retained-identities journal, and the feedback thread + dossier PII of long-deleted + // accounts (chat is kept). Checked daily; the TTL is a two-year policy constant. + retentionReaper := account.NewRetentionReaper(accounts, account.RetentionTTL, logger) + go retentionReaper.Run(ctx, 24*time.Hour) + logger.Info("retention reaper started", + zap.Duration("interval", 24*time.Hour), + zap.Duration("retention", account.RetentionTTL)) + // Re-evaluate moderated-chat write access when a temporary block self-expires: // no operator action fires then, so the sweeper emits the chat-access-changed // event for lapsed blocks and the gateway re-pushes the chat-gate command. @@ -173,15 +189,20 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { // Lobby & social domains. Their REST and stream surface lives in the gateway, // so they are handed to the server (like the route groups) for the handlers. mailer := newMailer(cfg.SMTP, logger) - emails := account.NewEmailService(accounts, mailer) + emails := account.NewEmailService(accounts, mailer, cfg.PublicBaseURL) + // Throttle confirm-code sends per recipient: at most one per minute and five per + // rolling hour, guarding against email bombing and the relay's own quota. + emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5)) // Account linking & merge: the orchestrator over the account, merge and // session layers. Wired to the /api/v1/user/link REST surface below. links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc.SetNotifier(hub) socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) - // A nudge the recipient answered by moving is marked read on the move path. + // A nudge the recipient answered by moving is marked read on the move path; every nudge in a + // game is marked read when the game finishes (a stale badge), on any completion path. games.SetNudgeClearer(socialSvc.ClearNudges) + games.SetNudgeExpirer(socialSvc.ExpireNudges) // Reap per-game disguised-robot friend requests once their game is long finished // (the robot ignores them; the row only pins the in-game "request sent" state). robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger) @@ -192,6 +213,18 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts) feedbackSvc.SetNotifier(hub) + // Operator alert emails on new feedback / word complaints, coalesced into one digest + // per interval. Inert unless a distinct admin sender and recipient are configured. + if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" { + consoleURL := "" + if cfg.PublicBaseURL != "" { + consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm" + } + alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger) + go alerts.Run(ctx, adminAlertInterval) + logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval)) + } + // Robot opponent: provision its durable account pool (a hard startup // dependency, like the dictionaries) and start its move driver. The matchmaker // substitutes a pooled robot for a missing human after the wait window. @@ -230,6 +263,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { // block and the banner admin console section. adsSvc := ads.NewService(ads.NewStore(db)) + // The image-render sidecar client for the PNG export artifact; nil (PNG + // download answers 404) when BACKEND_RENDERER_URL is unset. + var renderer *render.Client + if cfg.RendererURL != "" { + renderer = render.New(cfg.RendererURL) + } + srv := server.New(cfg.HTTPAddr, server.Deps{ Logger: logger, DB: db, @@ -251,6 +291,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { BanView: banView, Ads: adsSvc, Notifier: hub, + ExportSignKey: cfg.ExportSignKey, + Renderer: renderer, }) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) diff --git a/backend/cmd/dictgen/main.go b/backend/cmd/dictgen/main.go new file mode 100644 index 0000000..d76c538 --- /dev/null +++ b/backend/cmd/dictgen/main.go @@ -0,0 +1,193 @@ +// Command dictgen dumps golden parity vectors from the committed dawg +// dictionaries so the TypeScript dawg reader can be checked byte-for-byte +// against the authoritative Go dafsa reader. +// +// For each *.dawg file it writes, into the output directory: +// +// - .words.bin — every stored word as alphabet-index bytes, in index +// order, framed as [1-byte length][length index bytes]. The word at stream +// position k has IndexOfB == k. +// - .neg.bin — negative lookups (sequences whose IndexOfB is -1), same +// framing, to exercise the not-found path at varying depths. +// - .meta.json — NumAdded/NumNodes/NumEdges plus the alphabet size, for +// a header-parse sanity cross-check on the TS side. +// +// It is a development tool (not built into any service), analogous to +// cmd/jetgen. Run it from the repository root: +// +// go run ./backend/cmd/dictgen -dawg-dir ../scrabble-solver/dawg -out +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "math/rand" + "os" + "path/filepath" + "sort" + "strings" + + dawg "github.com/iliadenisov/dafsa" +) + +// meta is the per-dictionary sanity payload cross-checked by the TS reader. +type meta struct { + NumAdded int `json:"numAdded"` + NumNodes int `json:"numNodes"` + NumEdges int `json:"numEdges"` + Alphabet int `json:"alphabet"` +} + +func main() { + dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files") + outDir := flag.String("out", "", "output directory for the golden files (required)") + negCount := flag.Int("neg", 20000, "number of negative lookups to emit per dictionary") + flag.Parse() + + if *outDir == "" { + fail("-out is required") + } + if err := os.MkdirAll(*outDir, 0o755); err != nil { + fail("mkdir out: %v", err) + } + + files, err := filepath.Glob(filepath.Join(*dawgDir, "*.dawg")) + if err != nil { + fail("glob: %v", err) + } + sort.Strings(files) + if len(files) == 0 { + fail("no .dawg files in %s", *dawgDir) + } + + for _, f := range files { + if err := process(f, *outDir, *negCount); err != nil { + fail("%s: %v", filepath.Base(f), err) + } + } +} + +// process emits the golden files for a single dawg dictionary. +func process(path, outDir string, negCount int) error { + name := strings.TrimSuffix(filepath.Base(path), ".dawg") + + data, err := os.ReadFile(path) + if err != nil { + return err + } + finder, err := dawg.Read(bytes.NewReader(data), 0) + if err != nil { + return fmt.Errorf("read dawg: %w", err) + } + defer finder.Close() + + // Stream every stored word in index order; keep a decimated sample and the + // maximum alphabet index for negative generation. + wf, err := os.Create(filepath.Join(outDir, name+".words.bin")) + if err != nil { + return err + } + bw := bufio.NewWriter(wf) + + var ( + count int + maxIx byte + sample [][]byte + ) + finder.EnumerateB(func(index int, word []byte, final bool) int { + if !final { + return 0 // Continue + } + if index != count { + panic(fmt.Sprintf("%s: enumerate index gap: got %d want %d", name, index, count)) + } + writeWord(bw, word) + for _, b := range word { + if b > maxIx { + maxIx = b + } + } + if count%4 == 0 && len(sample) < 60000 { + sample = append(sample, append([]byte(nil), word...)) + } + count++ + return 0 // Continue + }) + if err := bw.Flush(); err != nil { + return err + } + if err := wf.Close(); err != nil { + return err + } + if count != finder.NumAdded() { + return fmt.Errorf("word count %d != NumAdded %d", count, finder.NumAdded()) + } + + alphabet := int(maxIx) + 1 + + // Negatives: mutate sampled real words and keep the ones the reader rejects. + nf, err := os.Create(filepath.Join(outDir, name+".neg.bin")) + if err != nil { + return err + } + nbw := bufio.NewWriter(nf) + rng := rand.New(rand.NewSource(1)) + neg := 0 + for neg < negCount && len(sample) > 0 { + base := sample[rng.Intn(len(sample))] + cand := append([]byte(nil), base...) + switch rng.Intn(3) { + case 0: // extend by one index + cand = append(cand, byte(rng.Intn(alphabet))) + case 1: // flip one index + if len(cand) > 0 { + cand[rng.Intn(len(cand))] = byte(rng.Intn(alphabet)) + } + case 2: // drop the tail and flip the new last index + if len(cand) > 1 { + cand = cand[:len(cand)-1] + cand[len(cand)-1] = byte(rng.Intn(alphabet)) + } + } + if finder.IndexOfB(cand) == -1 { + writeWord(nbw, cand) + neg++ + } + } + if err := nbw.Flush(); err != nil { + return err + } + if err := nf.Close(); err != nil { + return err + } + + m := meta{NumAdded: finder.NumAdded(), NumNodes: finder.NumNodes(), NumEdges: finder.NumEdges(), Alphabet: alphabet} + mb, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, name+".meta.json"), mb, 0o644); err != nil { + return err + } + + fmt.Printf("%-12s words=%d negatives=%d alphabet=%d nodes=%d edges=%d\n", + name, count, neg, alphabet, finder.NumNodes(), finder.NumEdges()) + return nil +} + +// writeWord frames one index-byte word as [length][bytes]. +func writeWord(w *bufio.Writer, word []byte) { + if len(word) > 255 { + panic(fmt.Sprintf("word too long to frame: %d", len(word))) + } + w.WriteByte(byte(len(word))) + w.Write(word) +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "dictgen: "+format+"\n", args...) + os.Exit(1) +} diff --git a/backend/cmd/validategen/main.go b/backend/cmd/validategen/main.go new file mode 100644 index 0000000..c47bcca --- /dev/null +++ b/backend/cmd/validategen/main.go @@ -0,0 +1,486 @@ +// Command validategen produces golden conformance fixtures for the TypeScript +// move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays +// greedy games with the authoritative scrabble-solver engine to build realistic +// board positions, then records a battery of candidate plays — the engine's own +// top move, letter-mutated variants, random scatters and (on the empty board) an +// off-centre translation — each paired with the ground-truth result of +// ValidatePlayOpts (legal, score, the words formed). The TS conformance test +// replays these and must agree exactly. +// +// It is a development tool (not built into any service), analogous to +// cmd/dictgen. Run it from the repository root: +// +// go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "math/rand" + "os" + "path/filepath" + + "gitea.iliadenisov.ru/developer/scrabble-solver/board" + "gitea.iliadenisov.ru/developer/scrabble-solver/rack" + "gitea.iliadenisov.ru/developer/scrabble-solver/rules" + "gitea.iliadenisov.ru/developer/scrabble-solver/scrabble" + "gitea.iliadenisov.ru/developer/scrabble-solver/selfplay" + dawg "github.com/iliadenisov/dafsa" +) + +// blankTile marks a blank tile in a drawn hand (matches selfplay). +const blankTile byte = 0xff + +// variantSpec pairs a variant label with its ruleset and dawg file. +type variantSpec struct { + name string + rules *rules.Ruleset + dawg string +} + +// cell is an occupied board square or a placement (alphabet-index letter). +type cell struct { + R, C, Letter int + Blank bool +} + +// word mirrors scrabble.Word in index space. +type word struct { + Row, Col, Dir int + Letters []int + Blanks []bool + Score int +} + +// fixture is one candidate play with the engine's ground-truth verdict. +type fixture struct { + Board int `json:"board"` // index into the boards list + Dir int `json:"dir"` + IgnoreCrossWords bool `json:"ignoreCrossWords"` + Tiles []cell `json:"tiles"` + Legal bool `json:"legal"` + Score int `json:"score"` + Bonus int `json:"bonus"` + Main *word `json:"main,omitempty"` + Cross []word `json:"cross,omitempty"` +} + +// alphaEntry mirrors one row of the per-variant alphabet table the server sends the +// client (index, concrete letter as the ruleset emits it, tile value), so the adapter +// cross-test can drive the letter-space client path exactly as production does. +type alphaEntry struct { + Index int `json:"index"` + Letter string `json:"letter"` + Value int `json:"value"` +} + +// variantFile is the whole conformance payload for one variant. +type variantFile struct { + Variant string `json:"variant"` + Rows int `json:"rows"` + Cols int `json:"cols"` + Center int `json:"center"` + RackSize int `json:"rackSize"` + Bingo int `json:"bingo"` + Values []int `json:"values"` + Premiums []int `json:"premiums"` // row-major rules.Premium codes + Alphabet []alphaEntry `json:"alphabet"` + Boards [][]cell `json:"boards"` + Fixtures []fixture `json:"fixtures"` +} + +func main() { + dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files") + outDir := flag.String("out", "", "output directory for the fixture files (required)") + games := flag.Int("games", 6, "self-play games per (variant, rule)") + plies := flag.Int("plies", 40, "maximum plies captured per game") + flag.Parse() + if *outDir == "" { + fail("-out is required") + } + if err := os.MkdirAll(*outDir, 0o755); err != nil { + fail("mkdir out: %v", err) + } + + specs := []variantSpec{ + {"scrabble_en", rules.English(), "en_sowpods.dawg"}, + {"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"}, + {"erudit_ru", rules.Erudit(), "ru_erudit.dawg"}, + } + for _, sp := range specs { + if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil { + fail("%s: %v", sp.name, err) + } + } +} + +func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error { + data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg)) + if err != nil { + return err + } + finder, err := dawg.Read(bytes.NewReader(data), 0) + if err != nil { + return fmt.Errorf("read dawg: %w", err) + } + defer finder.Close() + + rs := sp.rules + solver := scrabble.NewSolver(rs, finder) + + out := variantFile{ + Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center, + RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values, + Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs), + } + + // Capture under both the standard rule and the single-word rule, building the + // board with the same rule so positions are reachable under it. + for _, ignore := range []bool{false, true} { + opts := scrabble.PlayOptions{IgnoreCrossWords: ignore} + for g := range games { + seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name) + playAndCapture(&out, rs, solver, opts, seed, plies) + } + } + + b, err := json.Marshal(&out) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil { + return err + } + fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures)) + return nil +} + +// playAndCapture greedily self-plays one game, recording candidate plays against +// each board position along the way. +func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) { + rng := rand.New(rand.NewSource(seed)) + bag := selfplay.NewBag(rs, seed) + b := board.New(rs.Rows, rs.Cols) + hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)} + + passes := 0 + for turn := range plies { + p := turn % 2 + rk := rackOf(hands[p], rs.Size()) + moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts) + if len(moves) == 0 { + if passes++; passes >= 4 { + break + } + continue + } + passes = 0 + top := moves[0] + + boardIdx := len(out.Boards) + out.Boards = append(out.Boards, boardCells(b)) + captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng) + + scrabble.Apply(b, top) + hands[p] = removeUsed(hands[p], top) + if need := rs.RackSize - len(hands[p]); need > 0 { + hands[p] = append(hands[p], bag.Draw(need)...) + } + if len(hands[p]) == 0 && bag.Len() == 0 { + break + } + } +} + +// captureCandidates records the engine's top move plus derived candidates for one +// board, each with its ValidatePlayOpts verdict. +func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) { + size := rs.Size() + record := func(tiles []scrabble.Placement) { + if len(tiles) == 0 { + return + } + out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles)) + } + + // The engine's own top move (legal). + record(top.Tiles) + + // Letter-mutated variants: usually reject on the dictionary, occasionally form + // a different legal word. + for range 3 { + mut := clonePlacements(top.Tiles) + i := rng.Intn(len(mut)) + mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size) + record(mut) + } + + // Random scatters: exercise geometry, dictionary and connectivity paths. + for range 3 { + record(randomScatter(b, size, 2+rng.Intn(4), rng)) + } + + // Single tiles abutting the board exercise the direction inference — a single + // tile is ambiguous, its orientation resolved from which axis it extends. + for range 3 { + if t, ok := randomAdjacentSingle(b, size, rng); ok { + record([]scrabble.Placement{t}) + } + } + + // On the empty board, an off-centre translation of the first move exercises the + // first-move centre rule. + if b.IsEmpty() { + shifted := clonePlacements(top.Tiles) + ok := true + for i := range shifted { + shifted[i].Row++ + shifted[i].Col++ + if !b.InBounds(shifted[i].Row, shifted[i].Col) { + ok = false + break + } + } + if ok { + record(shifted) + } + } +} + +// makeFixture validates a candidate against board b and serializes it with its +// ground truth. Word breakdown is recorded only for legal plays (the TS test +// checks words only then); an illegal play records legal=false alone. +func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture { + // Infer the orientation exactly as the backend evaluate does (dir-less), so the + // fixture matches the real eval path and pins the client's ported inference. + dir := playDirectionMirror(solver, b, tiles, opts) + fx := fixture{ + Board: boardIdx, + Dir: int(dir), + IgnoreCrossWords: opts.IgnoreCrossWords, + Tiles: placementCells(tiles), + } + m, err := solver.ValidatePlayOpts(b, dir, tiles, opts) + if err == nil { + fx.Legal = true + fx.Score = m.Score + fx.Bonus = m.Bonus + fx.Main = toWord(m.Main) + for _, cw := range m.Cross { + fx.Cross = append(fx.Cross, *toWord(cw)) + } + } + return fx +} + +func placementCells(ts []scrabble.Placement) []cell { + cs := make([]cell, len(ts)) + for i, t := range ts { + cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank} + } + return cs +} + +func toWord(w scrabble.Word) *word { + letters := make([]int, len(w.Letters)) + for i, l := range w.Letters { + letters[i] = int(l) + } + return &word{ + Row: w.Row, Col: w.Col, Dir: int(w.Dir), + Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score, + } +} + +func alphabetOf(rs *rules.Ruleset) []alphaEntry { + n := rs.Alphabet.Size() + out := make([]alphaEntry, n) + for i := range n { + ch, _ := rs.Alphabet.Character(byte(i)) + out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]} + } + return out +} + +func premiumCodes(rs *rules.Ruleset) []int { + codes := make([]int, rs.Rows*rs.Cols) + for i := range codes { + codes[i] = int(rs.PremiumAt(i)) + } + return codes +} + +func boardCells(b *board.Board) []cell { + var cs []cell + for r := 0; r < b.Rows(); r++ { + for c := 0; c < b.Cols(); c++ { + if b.Filled(r, c) { + v := b.At(r, c) + cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0}) + } + } + } + return cs +} + +func clonePlacements(ts []scrabble.Placement) []scrabble.Placement { + return append([]scrabble.Placement(nil), ts...) +} + +// randomScatter picks n distinct empty in-bounds squares with random letters. +func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement { + seen := map[[2]int]bool{} + var ts []scrabble.Placement + for tries := 0; tries < n*20 && len(ts) < n; tries++ { + r := rng.Intn(b.Rows()) + c := rng.Intn(b.Cols()) + if seen[[2]int{r, c}] || b.Filled(r, c) { + continue + } + seen[[2]int{r, c}] = true + ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}) + } + return ts +} + +// randomAdjacentSingle picks a random empty in-bounds square abutting at least one +// filled square, with a random letter — a single-tile play whose orientation the +// inference must resolve. It returns ok=false on an empty board. +func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) { + var cands [][2]int + for r := 0; r < b.Rows(); r++ { + for c := 0; c < b.Cols(); c++ { + if b.Filled(r, c) { + continue + } + if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) { + cands = append(cands, [2]int{r, c}) + } + } + } + if len(cands) == 0 { + return scrabble.Placement{}, false + } + rc := cands[rng.Intn(len(cands))] + return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true +} + +// playDirectionMirror mirrors engine (*Game).playDirection: the geometric +// resolution, except a single tile under the single-word rule tries both +// orientations through the solver and keeps the higher-scoring legal one (H wins +// ties). It reproduces the orientation the backend evaluate infers. +func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction { + geo := resolveDirectionMirror(b, placements) + if len(placements) != 1 || !opts.IgnoreCrossWords { + return geo + } + best, found, bestScore := geo, false, 0 + for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} { + m, err := solver.ValidatePlayOpts(b, dir, placements, opts) + if err != nil { + continue + } + if !found || m.Score > bestScore { + best, found, bestScore = dir, true, m.Score + } + } + return best +} + +// resolveDirectionMirror mirrors engine.resolveDirection. +func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction { + if len(placements) >= 2 { + row := placements[0].Row + for _, p := range placements[1:] { + if p.Row != row { + return scrabble.Vertical + } + } + return scrabble.Horizontal + } + if len(placements) == 1 { + p := placements[0] + h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal) + v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical) + if v >= 2 && v > h { + return scrabble.Vertical + } + if h >= 2 { + return scrabble.Horizontal + } + if v >= 2 { + return scrabble.Vertical + } + } + return scrabble.Horizontal +} + +// runLengthMirror mirrors engine.runLength. +func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int { + dr, dc := 0, 1 + if dir == scrabble.Vertical { + dr, dc = 1, 0 + } + n := 1 + for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc { + n++ + } + for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc { + n++ + } + return n +} + +// rackOf builds a generation rack from a hand of tiles (reimplemented from the +// unexported selfplay helper). +func rackOf(tiles []byte, size int) rack.Rack { + r := rack.New(size) + for _, t := range tiles { + if t == blankTile { + r.AddBlank() + } else { + r.Add(t) + } + } + return r +} + +// removeUsed returns the hand with the tiles consumed by m removed. +func removeUsed(tiles []byte, m scrabble.Move) []byte { + out := append([]byte(nil), tiles...) + for _, p := range m.Tiles { + want := p.Letter + if p.Blank { + want = blankTile + } + for i, t := range out { + if t == want { + out = append(out[:i], out[i+1:]...) + break + } + } + } + return out +} + +func boolseed(b bool) int64 { + if b { + return 500000 + } + return 0 +} + +func variantSeed(name string) int64 { + var s int64 + for _, r := range name { + s = s*131 + int64(r) + } + return s +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...) + os.Exit(1) +} diff --git a/backend/go.mod b/backend/go.mod index bf06733..eb56b0f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,6 +13,7 @@ require ( github.com/pressly/goose/v3 v3.27.1 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + github.com/wneessen/go-mail v0.7.3 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 @@ -109,7 +110,7 @@ require ( golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index cf9ae83..32f62c9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -279,6 +279,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0= +github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -400,6 +402,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index cd4b5a0..2b10fc8 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -121,13 +121,44 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string } // ProvisionEmail returns the account owning the email identity externalID, creating -// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM" -// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe -// and leaves an existing account untouched, so a returning user's saved zone is never -// overwritten. The email account is created here (the code-request step), not at the -// later login, so this is where its zone is seeded. -func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) { - return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)}) +// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset — +// seeded into its time zone, language seeded from the client's UI language, and its +// display name seeded from the email's local part (so it is not left nameless). Like +// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a +// returning user's saved zone, language and name are never overwritten. The email account is +// created here (the code-request step), not at the later login, so this is where its +// zone and language are seeded. It is created flagged is_guest with an unconfirmed +// email identity: an abandoned, never-confirmed login is then reaped like any guest, +// freeing the reserved address, and confirming the code clears the guest flag. +func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) { + return s.provision(ctx, KindEmail, externalID, provisionSeed{ + displayName: emailDisplayName(externalID), + timeZone: seedZone(browserTZ), + preferredLanguage: supportedLanguage(language), + isGuest: true, + }) +} + +// emailDisplayName derives a display name from an email address — the local part +// before '@', trimmed and capped to the column width — so a new email account is not +// left nameless. It is only the first-contact seed; the user can rename it later. +func emailDisplayName(email string) string { + local, _, _ := strings.Cut(email, "@") + local = strings.TrimSpace(local) + if r := []rune(local); len(r) > maxDisplayName { + local = strings.TrimRight(string(r[:maxDisplayName]), " ") + } + return local +} + +// supportedLanguage returns code normalised to a supported UI language ("en" or +// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It +// accepts region-tagged codes ("ru-RU"). +func supportedLanguage(code string) string { + if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(code)), "-"); lang == "en" || lang == "ru" { + return lang + } + return "" } // ProvisionRobot provisions (or finds) the durable account backing a robot pool @@ -239,6 +270,11 @@ type provisionSeed struct { preferredLanguage string displayName string timeZone string + // isGuest creates the account flagged is_guest. It is set for an email-login + // account, which stays a guest until the address is confirmed (so an abandoned, + // never-confirmed login is reaped and its address freed); confirming clears the + // flag. Platform identities (telegram/vk) are durable from creation. + isGuest bool } // seedZone returns browserTZ when it is a well-formed zone to persist at account @@ -447,8 +483,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis tz = "UTC" } insertAccount := table.Accounts. - INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone). - VALUES(accountID, seed.displayName, lang, tz). + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest). + VALUES(accountID, seed.displayName, lang, tz, seed.isGuest). RETURNING(table.Accounts.AllColumns) var row model.Accounts diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index ae1fb7f..102bd73 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -5,6 +5,7 @@ import ( crand "crypto/rand" "crypto/sha256" "database/sql" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -26,6 +27,22 @@ const ( emailCodeTTL = 15 * time.Minute // emailCodeMaxAttempts caps wrong-code submissions before a code is dead. emailCodeMaxAttempts = 5 + // linkTokenBytes is the entropy of a confirm deeplink token: 256 bits. + linkTokenBytes = 32 + // emailConfirmPath is the SPA route the one-tap confirm deeplink opens (the token + // is appended). The SPA is served under /app/ behind a hash router. + emailConfirmPath = "/app/#/confirm/" +) + +// Confirmation purposes recorded on a pending confirm-code row. They select what +// verifying the code or the deeplink token does: sign in (login), link/confirm the +// address on the current account (link), or replace the account's confirmed email with +// a new address (change). Account deletion adds a further purpose in a later stage. +const ( + purposeLogin = "login" + purposeLink = "link" + purposeChange = "change" + purposeDelete = "delete" ) // Errors returned by the email confirm-code flow. @@ -46,6 +63,12 @@ var ( ErrTooManyAttempts = errors.New("account: too many confirmation attempts") // ErrCodeMismatch is returned when the submitted code does not match. ErrCodeMismatch = errors.New("account: confirmation code does not match") + // ErrTooManyRequests is returned when confirm-code sends to an address are being + // requested too frequently (the resend cooldown or the rolling-hour cap). + ErrTooManyRequests = errors.New("account: too many code requests") + // ErrNoEmail is returned when an email-code step-up is requested for an account that + // holds no confirmed email (the caller must use the typed-phrase path instead). + ErrNoEmail = errors.New("account: no confirmed email") ) // EmailService runs the email confirm-code flow: it issues a 6-digit code over a @@ -55,14 +78,80 @@ var ( // account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow — // and using an email as a login reuses this mechanism. type EmailService struct { - store *Store - mailer Mailer - now func() time.Time + store *Store + mailer Mailer + baseURL string + limiter *SendLimiter + now func() time.Time } -// NewEmailService constructs an EmailService over store, sending via mailer. -func NewEmailService(store *Store, mailer Mailer) *EmailService { - return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }} +// NewEmailService constructs an EmailService over store, sending via mailer. baseURL +// is the canonical public origin (scheme + host) used to build the one-tap confirm +// deeplink and the email footer landing link; an empty baseURL omits the deeplink +// (development / log mailer). +func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService { + return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }} +} + +// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are +// not throttled — production wires a limiter; tests leave it off. +func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l } + +// allowSend reports whether a confirm-code send to email is permitted now, recording +// it when so. A nil limiter permits every send. +func (s *EmailService) allowSend(email string) bool { + return s.limiter == nil || s.limiter.Allow(email) +} + +// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID, +// email), replaces any prior pending confirmation, and mails the branded code in +// locale; purpose selects the email wording and what verifying does. Only the SHA-256 +// hashes of the code and token are stored. +func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error { + code, codeHash, err := generateCode() + if err != nil { + return err + } + token, tokenHash, err := generateLinkToken() + if err != nil { + return err + } + if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil { + return err + } + // Account deletion is never a one-tap link (a prefetch or a stray click must not delete + // an account): the delete code is entered in the app only, so the email omits the + // deeplink and ConfirmByToken refuses a delete token. + deeplink := s.confirmURL(token, locale) + if purpose == purposeDelete { + deeplink = "" + } + msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale) + if err != nil { + return err + } + msg.To = email + return s.mailer.Send(ctx, msg) +} + +// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or "" +// when no public base URL is configured. The locale rides the fragment as ?lang so the +// confirm screen (opened in a browser with no session) renders in the email's language. +func (s *EmailService) confirmURL(token, locale string) string { + if s.baseURL == "" { + return "" + } + return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale) +} + +// accountLocale returns the account's preferred UI language for localising email, +// defaulting to "en" when the account cannot be loaded. +func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string { + acc, err := s.store.GetByID(ctx, accountID) + if err != nil { + return "en" + } + return acc.PreferredLanguage } // RequestCode issues a fresh confirm-code for email to accountID and mails it, @@ -73,6 +162,9 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema if err != nil { return err } + if !s.allowSend(addr) { + return ErrTooManyRequests + } owner, ok, err := s.store.confirmedEmailAccount(ctx, addr) if err != nil { return err @@ -83,16 +175,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema } return ErrEmailTaken } - code, hash, err := generateCode() - if err != nil { - return err - } - if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { - return err - } - subject := "Your Scrabble confirmation code" - body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute)) - return s.mailer.Send(ctx, addr, subject, body) + return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID)) } // ConfirmCode verifies code for accountID and email. On success it attaches a @@ -123,36 +206,35 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil { return Account{}, err } + // Binding the first confirmed email promotes a guest to a durable account, matching the + // link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set). + if err := s.store.ClearGuest(ctx, accountID); err != nil { + return Account{}, err + } return s.store.GetByID(ctx, accountID) } // RequestLoginCode issues a login confirm-code to the account that owns email, -// provisioning a fresh (unconfirmed) durable account when the email is new. It is -// the unauthenticated email-login entry point and, unlike RequestCode, -// does not refuse an already-confirmed email — that is the ordinary returning-user -// login. The code is mailed to the address, so only its real owner can complete -// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset) -// seeds the new account's time zone. It returns the target account id for the -// subsequent LoginWithCode. -func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) { +// provisioning a fresh (unconfirmed) guest account when the email is new — it becomes +// durable once the code is confirmed. It is the unauthenticated email-login entry +// point and, unlike RequestCode, does not refuse an already-confirmed email — that is +// the ordinary returning-user login. The code is mailed to the address, so only its +// real owner can complete the login. On first contact browserTZ (the client's +// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI +// language. It returns the target account id for the subsequent LoginWithCode. +func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) { addr, err := normalizeEmail(email) if err != nil { return uuid.UUID{}, err } - acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ) + if !s.allowSend(addr) { + return uuid.UUID{}, ErrTooManyRequests + } + acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language) if err != nil { return uuid.UUID{}, err } - code, hash, err := generateCode() - if err != nil { - return uuid.UUID{}, err - } - if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { - return uuid.UUID{}, err - } - subject := "Your Scrabble login code" - body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute)) - if err := s.mailer.Send(ctx, addr, subject, body); err != nil { + if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil { return uuid.UUID{}, err } return acc.ID, nil @@ -191,9 +273,104 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil { return Account{}, err } + if err := s.store.ClearGuest(ctx, acc.ID); err != nil { + return Account{}, err + } return s.store.GetByID(ctx, acc.ID) } +// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the +// transport layer must finish. Purpose is the pending row's purpose. For a login, +// Account is the account to sign in. For a link, Account is the account the email was +// (or would be) attached to; NeedsMerge is set when another account (MergeOwner) +// already owns the address, so the caller drives the interactive merge instead of a +// plain link — the token is left unconsumed for that merge step. +type LinkConfirmation struct { + Purpose string + Account uuid.UUID + NeedsMerge bool + MergeOwner uuid.UUID +} + +// IsLogin reports whether the confirmation is a login (the caller mints a session) +// rather than a link (attach the identity, or drive a merge). +func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin } + +// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login +// confirms the email identity, clears the guest flag and returns the account to sign +// in. A link attaches the confirmed email to the pending account when the address is +// free, or reports NeedsMerge when another account already owns it (leaving the token +// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the +// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token +// is high-entropy, so there is no wrong-attempt counter. +func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) { + pend, err := s.store.pendingByTokenHash(ctx, hashCode(token)) + if err != nil { + return LinkConfirmation{}, err + } + if s.now().After(pend.expiresAt) { + return LinkConfirmation{}, ErrCodeExpired + } + switch pend.purpose { + case purposeLogin: + if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil { + return LinkConfirmation{}, err + } + if err := s.store.ClearGuest(ctx, pend.accountID); err != nil { + return LinkConfirmation{}, err + } + return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil + case purposeLink: + owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email) + if err != nil { + return LinkConfirmation{}, err + } + if ok { + if owner == pend.accountID { + if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil { + return LinkConfirmation{}, err + } + return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil + } + return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil + } + if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil { + return LinkConfirmation{}, err + } + // Binding the first email promotes a guest to a durable account, matching the + // code-based link flow (which clears the guest flag in the link service). + if err := s.store.ClearGuest(ctx, pend.accountID); err != nil { + return LinkConfirmation{}, err + } + return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil + case purposeChange: + owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email) + if err != nil { + return LinkConfirmation{}, err + } + if ok && owner != pend.accountID { + // The new address is confirmed by a different account: refuse without + // disclosing it (anti-enumeration). Unlike a link, a change never merges. + return LinkConfirmation{}, ErrEmailTaken + } + if ok && owner == pend.accountID { + if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil { + return LinkConfirmation{}, err + } + return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil + } + if err := s.store.replaceEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil { + return LinkConfirmation{}, err + } + return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil + case purposeDelete: + // Deletion is confirmed in the app with the code, never via a one-tap link. + return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link") + default: + return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose) + } +} + // emailConfirmation is a pending confirm-code row in domain form. type emailConfirmation struct { id uuid.UUID @@ -222,9 +399,30 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U return row.AccountID, true, nil } +// confirmedEmailOf returns the account's confirmed email address and true, or ("", false) +// when it holds none. It backs the deletion step-up, which mails a code to the account's +// own address. +func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, error) { + stmt := postgres.SELECT(table.Identities.ExternalID). + FROM(table.Identities). + WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))). + AND(table.Identities.Confirmed.EQ(postgres.Bool(true))), + ).LIMIT(1) + var row model.Identities + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return "", false, nil + } + return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err) + } + return row.ExternalID, true, nil +} + // replacePendingConfirmation clears any pending code for (accountID, email) and // inserts a fresh one, inside one transaction. -func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error { +func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error { id, err := uuid.NewV7() if err != nil { return fmt.Errorf("account: new confirmation id: %w", err) @@ -241,7 +439,8 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U ins := table.EmailConfirmations.INSERT( table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID, table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt, - ).VALUES(id, accountID, email, codeHash, expiresAt) + table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose, + ).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose) if _, err := ins.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert confirmation: %w", err) } @@ -274,6 +473,54 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU }, nil } +// pendingConfirmation is a pending confirm-code row loaded by its deeplink token, in +// domain form. +type pendingConfirmation struct { + id uuid.UUID + accountID uuid.UUID + email string + purpose string + expiresAt time.Time +} + +// pendingByTokenHash loads the unconsumed confirmation whose deeplink token hashes to +// tokenHash, or ErrNoPendingCode. The high-entropy token needs no attempt counter, so +// a partial-unique index guarantees at most one match. +func (s *Store) pendingByTokenHash(ctx context.Context, tokenHash string) (pendingConfirmation, error) { + stmt := postgres.SELECT(table.EmailConfirmations.AllColumns). + FROM(table.EmailConfirmations). + WHERE( + table.EmailConfirmations.LinkTokenHash.EQ(postgres.String(tokenHash)). + AND(table.EmailConfirmations.ConsumedAt.IS_NULL()), + ).LIMIT(1) + var row model.EmailConfirmations + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return pendingConfirmation{}, ErrNoPendingCode + } + return pendingConfirmation{}, fmt.Errorf("account: load confirmation by token: %w", err) + } + return pendingConfirmation{ + id: row.ConfirmationID, + accountID: row.AccountID, + email: row.Email, + purpose: row.Purpose, + expiresAt: row.ExpiresAt, + }, nil +} + +// consumeConfirmation marks a confirmation consumed without writing an identity, used +// for the idempotent already-linked deeplink path. +func (s *Store) consumeConfirmation(ctx context.Context, id uuid.UUID, now time.Time) error { + upd := table.EmailConfirmations.UPDATE(table.EmailConfirmations.ConsumedAt). + SET(postgres.TimestampzT(now)). + WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id))) + if _, err := upd.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: consume confirmation: %w", err) + } + return nil +} + // bumpConfirmationAttempts increments a code's wrong-attempt counter by one. func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error { stmt := table.EmailConfirmations. @@ -320,6 +567,69 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun return nil } +// replaceEmailIdentity consumes the confirmation, deletes the account's existing email +// identity (freeing the old address) and inserts newEmail as its confirmed email, inside +// one transaction. It backs the change-email flow. A unique-constraint violation — the +// new address was confirmed elsewhere in the meantime — surfaces as ErrEmailTaken. When +// the account holds no email identity yet the delete is a no-op, so this doubles as an +// attach. +func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, newEmail string, now time.Time) error { + identityID, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("account: new identity id: %w", err) + } + err = withTx(ctx, s.db, func(tx *sql.Tx) error { + upd := table.EmailConfirmations. + UPDATE(table.EmailConfirmations.ConsumedAt). + SET(postgres.TimestampzT(now)). + WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID))) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("consume confirmation: %w", err) + } + // Journal the outgoing email before replacing it, so the legal dossier keeps the + // address the account used to hold (see retention.go). + var old model.Identities + sel := postgres.SELECT( + table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt, + ).FROM(table.Identities).WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), + ).LIMIT(1) + switch err := sel.QueryContext(ctx, tx, &old); { + case err == nil: + if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil { + return err + } + case errors.Is(err, qrm.ErrNoRows): + // No prior email (this doubles as an attach); nothing to retain. + default: + return fmt.Errorf("load outgoing email identity: %w", err) + } + del := table.Identities.DELETE().WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), + ) + if _, err := del.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("delete old email identity: %w", err) + } + ins := table.Identities.INSERT( + table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind, + table.Identities.ExternalID, table.Identities.Confirmed, + ).VALUES(identityID, accountID, KindEmail, newEmail, true) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return err + } + return nil + }) + if err != nil { + if isUniqueViolation(err) { + return ErrEmailTaken + } + return fmt.Errorf("account: replace email identity: %w", err) + } + return nil +} + // confirmEmailLogin consumes the login code and marks the existing email // identity confirmed, inside one transaction. The identity already exists (a // login provisioned it), so this updates rather than inserts and is idempotent @@ -367,6 +677,18 @@ func generateCode() (code, hash string, err error) { return code, hashCode(code), nil } +// generateLinkToken returns a fresh opaque one-tap confirm deeplink token (URL-safe +// base64, 256-bit) and its hex SHA-256 hash. Only the hash is stored; the token +// travels only in the emailed link, mirroring the session-token model. +func generateLinkToken() (token, hash string, err error) { + buf := make([]byte, linkTokenBytes) + if _, err := crand.Read(buf); err != nil { + return "", "", fmt.Errorf("account: generate link token: %w", err) + } + token = base64.RawURLEncoding.EncodeToString(buf) + return token, hashCode(token), nil +} + // hashCode returns the hex-encoded SHA-256 of a confirm-code. func hashCode(code string) string { sum := sha256.Sum256([]byte(code)) diff --git a/backend/internal/account/emailtemplate.go b/backend/internal/account/emailtemplate.go new file mode 100644 index 0000000..7bff757 --- /dev/null +++ b/backend/internal/account/emailtemplate.go @@ -0,0 +1,239 @@ +package account + +import ( + "fmt" + "html/template" + "strings" + tmpltext "text/template" + "time" +) + +// emailBrandColor is the single accent used in the confirmation email — a calm +// tile green, matching the "no riot of colours" brief. +const emailBrandColor = "#2f7d4f" + +// confirmEmailView is the fully-localised data the confirmation email templates +// render. Every string is resolved before rendering, so the templates carry no +// localisation logic. +type confirmEmailView struct { + Brand string + Heading string + Intro string + Code string + Expiry string + CTALabel string + DeeplinkURL string + FooterIgnore string + LandingURL string + LandingLabel string + Preheader string + Locale string + Accent string +} + +// emailCopy is the purpose- and locale-specific wording of a confirmation email. +type emailCopy struct { + Subject string + Preheader string + Heading string + Intro string + CTALabel string + FooterIgnore string +} + +// confirmEmailCopy holds the wording per (purpose, locale). Unknown purposes fall +// back to the neutral link wording and unknown locales fall back to English. +var confirmEmailCopy = map[string]map[string]emailCopy{ + purposeLogin: { + "en": { + Subject: "Your Erudit sign-in code", + Preheader: "Your sign-in code", + Heading: "Sign in to Erudit", + Intro: "Enter this code to sign in:", + CTALabel: "Sign in with one tap", + FooterIgnore: "If you didn't request this email, you can safely ignore it.", + }, + "ru": { + Subject: "Код для входа в Эрудит", + Preheader: "Ваш код для входа", + Heading: "Вход в Эрудит", + Intro: "Введите этот код, чтобы войти в игру:", + CTALabel: "Войти одним нажатием", + FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.", + }, + }, + purposeLink: { + "en": { + Subject: "Your Erudit confirmation code", + Preheader: "Your confirmation code", + Heading: "Confirm your e-mail", + Intro: "Enter this code to confirm your address:", + CTALabel: "Confirm with one tap", + FooterIgnore: "If you didn't request this email, you can safely ignore it.", + }, + "ru": { + Subject: "Код подтверждения Эрудит", + Preheader: "Ваш код подтверждения", + Heading: "Подтверждение e-mail", + Intro: "Введите этот код, чтобы подтвердить адрес:", + CTALabel: "Подтвердить одним нажатием", + FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.", + }, + }, + purposeChange: { + "en": { + Subject: "Confirm your new Erudit e-mail", + Preheader: "Confirm your new address", + Heading: "Confirm your new e-mail", + Intro: "Enter this code to switch your account to this address:", + CTALabel: "Confirm with one tap", + FooterIgnore: "If you didn't request this change, you can safely ignore it — your address stays the same.", + }, + "ru": { + Subject: "Подтвердите новый e-mail в Эрудит", + Preheader: "Подтвердите новый адрес", + Heading: "Смена e-mail", + Intro: "Введите этот код, чтобы привязать аккаунт к новому адресу:", + CTALabel: "Подтвердить одним нажатием", + FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.", + }, + }, + purposeDelete: { + "en": { + Subject: "Confirm your Erudit account deletion", + Preheader: "Confirm account deletion", + Heading: "Delete your account", + Intro: "Enter this code in the app to permanently delete your account:", + CTALabel: "", + FooterIgnore: "If you didn't request this, ignore it — your account stays as it is.", + }, + "ru": { + Subject: "Подтвердите удаление аккаунта Эрудит", + Preheader: "Подтверждение удаления аккаунта", + Heading: "Удаление аккаунта", + Intro: "Введите этот код в приложении, чтобы удалить аккаунт без восстановления:", + CTALabel: "", + FooterIgnore: "Если вы не запрашивали удаление, проигнорируйте письмо — аккаунт останется.", + }, + }, +} + +// emailBrand is the brand wordmark per locale. +var emailBrand = map[string]string{"en": "Erudit", "ru": "Эрудит"} + +// emailExpiry formats the code-lifetime line per locale (abbreviated minutes to +// avoid plural agreement). +func emailExpiry(locale string, d time.Duration) string { + min := int(d / time.Minute) + if locale == "ru" { + return fmt.Sprintf("Код действует %d мин.", min) + } + return fmt.Sprintf("The code is valid for %d minutes.", min) +} + +// normalizeLocale maps an account language to a supported email locale, defaulting +// to English. +func normalizeLocale(locale string) string { + if locale == "ru" { + return "ru" + } + return "en" +} + +// renderConfirmationEmail builds the branded confirmation email for purpose in +// locale: a large readable code plus a one-tap deeplink button, with an +// ignore-notice footer and a landing link. Both a plain-text body and an HTML +// alternative are produced. deeplinkURL is the absolute /confirm link and +// landingURL the public landing origin. +func renderConfirmationEmail(purpose, code, deeplinkURL, landingURL, locale string) (Message, error) { + loc := normalizeLocale(locale) + byLocale, ok := confirmEmailCopy[purpose] + if !ok { + byLocale = confirmEmailCopy[purposeLink] + } + cp := byLocale[loc] + view := confirmEmailView{ + Brand: emailBrand[loc], + Heading: cp.Heading, + Intro: cp.Intro, + Code: code, + Expiry: emailExpiry(loc, emailCodeTTL), + CTALabel: cp.CTALabel, + DeeplinkURL: deeplinkURL, + FooterIgnore: cp.FooterIgnore, + LandingURL: landingURL, + LandingLabel: emailBrand[loc], + Preheader: cp.Preheader, + Locale: loc, + Accent: emailBrandColor, + } + var html strings.Builder + if err := confirmEmailHTML.Execute(&html, view); err != nil { + return Message{}, fmt.Errorf("account: render confirmation email (html): %w", err) + } + var text strings.Builder + if err := confirmEmailText.Execute(&text, view); err != nil { + return Message{}, fmt.Errorf("account: render confirmation email (text): %w", err) + } + return Message{Subject: cp.Subject, Text: text.String(), HTML: html.String()}, nil +} + +// confirmEmailHTML is a compact, image-free, mobile-friendly HTML email. Layout is +// table-based for broad mail-client compatibility and all styling is inlined +// because clients strip + + +
+
Э
+

Технические работы

+

Идёт короткое обновление игры. Мы скоро вернёмся — обновите страницу через пару минут.

+

Scrabble is briefly down for an update. Please refresh in a couple of minutes.

+
+ + diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index cc5c82b..434eaec 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -50,6 +50,13 @@ services: limits: memory: 384M + renderer: + image: ${REGISTRY:?set REGISTRY}/scrabble-renderer:${TAG:?set TAG} + deploy: + resources: + limits: + memory: 160M + validator: image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG} deploy: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 34893cb..f6d25c8 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -61,6 +61,36 @@ services: memory: 512M networks: [internal] + # The finished-game image-render sidecar: internal-only, called by the backend for the + # PNG export artifact. It runs the same ui/src/lib/gameimage.ts the web client + # unit-tests, on skia-canvas (renderer/README.md). node:22-slim carries a shell, so — + # uniquely among the built services — it has a real container healthcheck the backend's + # depends_on gates on. + renderer: + container_name: scrabble-renderer + image: scrabble-renderer:latest + build: + context: .. + dockerfile: renderer/Dockerfile + args: + VERSION: ${APP_VERSION:-dev} + restart: unless-stopped + logging: *default-logging + environment: + RENDERER_PORT: "8090" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 3s + retries: 12 + # A render peaks well under 100 MiB (a 2-MP canvas + skia); idle sits near 60 MiB. + deploy: + resources: + limits: + cpus: "1.0" + memory: 192M + networks: [internal] + backend: container_name: scrabble-backend image: scrabble-backend:latest @@ -80,9 +110,15 @@ services: depends_on: postgres: condition: service_healthy + renderer: + condition: service_healthy environment: # search_path=backend matches the migrations (00001 creates the schema). BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend + # The finished-game export: the render sidecar address + the HMAC key signing the + # public download URLs (deploy env: TEST_/PROD_EXPORT_SIGN_KEY). + BACKEND_RENDERER_URL: http://renderer:8090 + BACKEND_EXPORT_SIGN_KEY: ${EXPORT_SIGN_KEY:?set EXPORT_SIGN_KEY — the export download URL signing secret} # The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom # for bursts. Postgres (2 cores / 512 MiB) handles it. BACKEND_POSTGRES_MAX_OPEN_CONNS: "40" @@ -100,6 +136,25 @@ services: # GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the # cgroup quota (the runtime otherwise sees all of the host's cores). GOMAXPROCS: "2" + # Transactional email (confirm-codes) via the shared Selectel relay. An empty + # host makes the backend log codes instead of sending them (dev default). TLS is + # implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it + # (ssl|starttls) — needed for Selectel's non-standard ports (1127 = SSL, 1126 = + # STARTTLS). The From uses the prod domain (Selectel only accepts the verified + # sender domain), so it is the same on every contour. PUBLIC_BASE_URL is the + # canonical origin for links in the email (never a request Host header) — + # required whenever the relay host is set. + BACKEND_SMTP_HOST: ${SMTP_RELAY_HOST:-} + BACKEND_SMTP_PORT: ${SMTP_RELAY_PORT:-465} + BACKEND_SMTP_TLS: ${SMTP_RELAY_TLS:-} + BACKEND_SMTP_USERNAME: ${SMTP_RELAY_USER:-} + BACKEND_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-} + BACKEND_SMTP_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost} + BACKEND_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-} + # Operator alert emails (new feedback / word complaints): a distinct sender and the + # recipient(s) (comma-separated allowed). Both empty disables the alert worker. + BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-} + BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-} # The dictionary lives on a named volume seeded from the image on first boot # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # inherits). The admin console writes new version subdirectories here, and the @@ -134,6 +189,9 @@ services: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} + VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-} + VITE_VK_APP_ID: ${VITE_VK_APP_ID:-} + VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_APP_VERSION: ${APP_VERSION:-dev} # Go binary version (the SPA's VITE_APP_VERSION is the same git tag). @@ -151,6 +209,14 @@ services: # app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables # the VK auth path (auth.vk is then unregistered). GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-} + # VK ID web login (browser VK-identity linking): the confidential OAuth 2.1 code + # exchange runs server-side under the VK ID "Web" app's protected key. This is a + # SEPARATE VK app from the Mini App above, so the credentials are distinct. The app id + # and redirect URL are the same values the SPA builds its authorize URL from (one + # source each). All three empty disables the link.vk.* ops. + GATEWAY_VK_ID_APP_ID: ${VITE_VK_APP_ID:-} + GATEWAY_VK_ID_CLIENT_SECRET: ${GATEWAY_VK_ID_CLIENT_SECRET:-} + GATEWAY_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-} # The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay # reaches the gateway at :9092 (plaintext, internal). In the test contour both # listeners stay on the internal network (the bot shares the VPN netns); in prod @@ -161,10 +227,12 @@ services: GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt # Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the - # honeypot/honeytoken. Off by default: it bans by client IP, which is only - # real in prod — the test contour arrives as one shared NAT address, so a ban - # there would be self-inflicted (the honeypot/honeytoken still log). Prod sets - # these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap. + # honeypot/honeytoken. Off by default: it bans by client IP, which is only real + # in prod — the test contour arrives as one shared NAT address, so a ban there + # would be self-inflicted (the honeypot still logs). The prod deploy forces it on + # in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN + # is the planted bearer trap, fed from the per-contour TEST_/PROD_GATEWAY_HONEYTOKEN + # secret; empty (unset secret) leaves the trap off. GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false} GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-} GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info} @@ -207,6 +275,9 @@ services: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} + VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-} + VITE_VK_APP_ID: ${VITE_VK_APP_ID:-} + VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped @@ -357,6 +428,11 @@ services: GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH} volumes: - ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + # Maintenance page + toggle flag: the caddy config dir holds maintenance.html and the + # `on` flag prod-deploy.sh touches around a rolling swap; the Caddyfile serves a 503 + # from here while the flag exists (read-only mount — the deploy writes the flag on the + # host side). See deploy/caddy/Caddyfile and deploy/prod-deploy.sh. + - ${SCRABBLE_CONFIG_DIR:-.}/caddy:/srv/maint:ro - caddy-data:/data deploy: resources: @@ -444,6 +520,21 @@ services: # caddy's Basic-Auth and re-prompts for the password on every dashboard; the # dashboards poll and do not need Live. GF_LIVE_MAX_CONNECTIONS: "0" + # SMTP for alert emails, reusing the shared relay host + credentials + the SERVICE + # From/recipient. Grafana's client speaks STARTTLS (not the backend's implicit-TLS + # port), so it dials the relay host on its STARTTLS port (GRAFANA_SMTP_PORT). + # Disabled unless GF_SMTP_ENABLED. + GF_SMTP_ENABLED: ${GF_SMTP_ENABLED:-false} + GF_SMTP_HOST: ${SMTP_RELAY_HOST:-}:${GRAFANA_SMTP_PORT:-} + GF_SMTP_USER: ${SMTP_RELAY_USER:-} + GF_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-} + # A BARE address (Grafana rejects the "Name" form); the deploy derives it from + # SMTP_RELAY_SERVICE_FROM, splitting off the display name into GRAFANA_SMTP_FROM_NAME. + GF_SMTP_FROM_ADDRESS: ${GRAFANA_SMTP_FROM_ADDRESS:-} + GF_SMTP_FROM_NAME: ${GRAFANA_SMTP_FROM_NAME:-Erudit Alerts} + GF_SMTP_STARTTLS_POLICY: MandatoryStartTLS + # The alert recipient(s), read by the provisioned contact point via $__env{SERVICE_EMAIL}. + SERVICE_EMAIL: ${SERVICE_EMAIL:-} volumes: - ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro # Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the @@ -494,6 +585,24 @@ services: memory: 64M networks: [internal] + # blackbox_exporter lets Prometheus alert on TLS certificate expiry (a Caddy ACME + # renewal failure) via probe_ssl_earliest_cert_expiry. It probes the edge caddy that + # terminates TLS (prod: the published scrabble-caddy; the test contour has no compose + # caddy, so the probe simply finds no target and the cert metric is absent — the rule is + # absent-safe). See prometheus.yml and grafana alerting rules. + blackbox_exporter: + container_name: scrabble-blackbox-exporter + image: prom/blackbox-exporter:v0.25.0 + restart: unless-stopped + logging: *default-logging + volumes: + - ${SCRABBLE_CONFIG_DIR:-.}/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro + deploy: + resources: + limits: + memory: 64M + networks: [internal, edge] + networks: internal: name: scrabble-internal diff --git a/deploy/grafana/dashboards/users.json b/deploy/grafana/dashboards/users.json index 567538b..e7eb072 100644 --- a/deploy/grafana/dashboards/users.json +++ b/deploy/grafana/dashboards/users.json @@ -4,7 +4,7 @@ "tags": ["scrabble"], "timezone": "", "schemaVersion": 39, - "version": 1, + "version": 2, "refresh": "30s", "time": { "from": "now-7d", "to": "now" }, "panels": [ @@ -29,6 +29,33 @@ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 }, "datasource": { "type": "prometheus", "uid": "prometheus" }, "targets": [{ "refId": "A", "expr": "sum(accounts_created_total) by (kind)", "legendFormat": "{{kind}}" }] + }, + { + "type": "timeseries", + "title": "App opens vs dictionary fills (cumulative)", + "description": "Local move-preview adoption. App opens are client cold starts; dictionary fills are IndexedDB downloads of a dawg. The gap between them is how many launches reuse an already-cached dictionary. Client-reported, best-effort (a batched beacon), so slightly lossy.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { "refId": "A", "expr": "sum(local_eval_cold_start_total)", "legendFormat": "app opens (cold start)" }, + { "refId": "B", "expr": "sum(local_eval_dict_load_total{result=\"fetched\"})", "legendFormat": "dictionary fills (IndexedDB)" } + ] + }, + { + "type": "timeseries", + "title": "Dictionary loads by result (rate)", + "description": "Client dictionary loads for the local preview, by tier: fetched (network download), cache_hit (IndexedDB), miss (a warm-up that failed or timed out — trips the bad-connection breaker).", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(rate(local_eval_dict_load_total[1h])) by (result)", "legendFormat": "{{result}}" }] + }, + { + "type": "timeseries", + "title": "Move previews by path (rate)", + "description": "Where the move preview is computed: local (on-device, the accelerator) vs network (the fallback to game.evaluate). The local share is the backend load shed.", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }] } ] } diff --git a/deploy/grafana/provisioning/alerting/contactpoints.yaml b/deploy/grafana/provisioning/alerting/contactpoints.yaml new file mode 100644 index 0000000..9d398a9 --- /dev/null +++ b/deploy/grafana/provisioning/alerting/contactpoints.yaml @@ -0,0 +1,13 @@ +# Grafana alerting contact point: the operator's alert mailbox, read from the +# SERVICE_EMAIL container env (see docker-compose.yml grafana), so it stays per-contour. +# SERVICE_EMAIL may hold several comma-separated addresses. +apiVersion: 1 +contactPoints: + - orgId: 1 + name: ops-email + receivers: + - uid: ops_email + type: email + settings: + addresses: $__env{SERVICE_EMAIL} + singleEmail: true diff --git a/deploy/grafana/provisioning/alerting/policies.yaml b/deploy/grafana/provisioning/alerting/policies.yaml new file mode 100644 index 0000000..6d2dc0e --- /dev/null +++ b/deploy/grafana/provisioning/alerting/policies.yaml @@ -0,0 +1,10 @@ +# Notification policy: every alert routes to the operator email, grouped so a burst is one +# message, with a 4-hour re-notify while still firing. +apiVersion: 1 +policies: + - orgId: 1 + receiver: ops-email + group_by: ['alertname'] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h diff --git a/deploy/grafana/provisioning/alerting/rules.yaml b/deploy/grafana/provisioning/alerting/rules.yaml new file mode 100644 index 0000000..a63c62b --- /dev/null +++ b/deploy/grafana/provisioning/alerting/rules.yaml @@ -0,0 +1,214 @@ +# Grafana provisioned alert rules for the Scrabble contour. Each rule is a Prometheus +# instant query (refId A) fed into a threshold expression (refId C). noDataState/execErrState +# are OK so an absent metric never raises a false alert — notably cert-expiry, whose blackbox +# probe has no target on the test contour (caddy is HTTP-only there). Metric names are the +# real ones Prometheus exposes (edge_request_* from the gateway via the collector, +# node_*/pg_*/probe_ssl_* from the exporters). All route to the ops-email contact point. +apiVersion: 1 +groups: + - orgId: 1 + name: scrabble-service + folder: Alerts + interval: 1m + rules: + - uid: svc_target_down + title: Scrape target down + condition: C + for: 3m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 300, to: 0 } + datasourceUid: prometheus + model: { refId: A, expr: up, instant: true } + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: lt, params: [1] } + labels: { severity: critical } + annotations: { summary: 'A Prometheus scrape target is down (up < 1).' } + + - uid: edge_error_rate + title: Gateway internal-error rate high + condition: C + for: 10m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 600, to: 0 } + datasourceUid: prometheus + model: + refId: A + expr: sum by (service_name) (rate(edge_request_duration_count{result="internal"}[5m])) + instant: true + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: gt, params: [0.05] } + labels: { severity: warning } + annotations: { summary: 'Sustained internal (5xx-equivalent) errors at the edge.' } + + - uid: edge_latency_p99 + title: Gateway request latency p99 high + condition: C + for: 10m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 600, to: 0 } + datasourceUid: prometheus + model: + refId: A + expr: histogram_quantile(0.99, sum by (le, service_name) (rate(edge_request_duration_bucket[5m]))) + instant: true + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: gt, params: [1] } + labels: { severity: warning } + annotations: { summary: 'Edge request p99 latency above 1s.' } + + - uid: tls_cert_expiry + title: TLS certificate nearing expiry + condition: C + for: 15m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 300, to: 0 } + datasourceUid: prometheus + model: + refId: A + expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 + instant: true + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: lt, params: [20] } + labels: { severity: critical } + annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' } + + - orgId: 1 + name: scrabble-host + folder: Alerts + interval: 1m + rules: + - uid: host_mem_low + title: Host memory low + condition: C + for: 5m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 300, to: 0 } + datasourceUid: prometheus + model: + refId: A + expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes + instant: true + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: lt, params: [0.1] } + labels: { severity: critical } + annotations: { summary: 'Under 10% host memory available.' } + + - uid: host_disk_low + title: Host disk low + condition: C + for: 10m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 300, to: 0 } + datasourceUid: prometheus + model: + refId: A + expr: min(node_filesystem_avail_bytes / node_filesystem_size_bytes) + instant: true + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: lt, params: [0.1] } + labels: { severity: critical } + annotations: { summary: 'A host filesystem is under 10% free.' } + + - uid: host_cpu_high + title: Host CPU saturated + condition: C + for: 10m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 600, to: 0 } + datasourceUid: prometheus + model: + refId: A + expr: 1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) + instant: true + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: gt, params: [0.9] } + labels: { severity: warning } + annotations: { summary: 'Host CPU above 90% for 10 minutes.' } + + - uid: pg_connections_high + title: Postgres connections high + condition: C + for: 5m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 300, to: 0 } + datasourceUid: prometheus + model: + refId: A + expr: sum(pg_stat_activity_count) / max(pg_settings_max_connections) + instant: true + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: gt, params: [0.8] } + labels: { severity: warning } + annotations: { summary: 'Postgres using over 80% of max_connections.' } diff --git a/deploy/prod-deploy.sh b/deploy/prod-deploy.sh index af87062..d26a65c 100755 --- a/deploy/prod-deploy.sh +++ b/deploy/prod-deploy.sh @@ -42,6 +42,15 @@ PREV_STATE_FILE="${PREV_STATE_FILE:-/opt/scrabble/PREVIOUS_TAG}" PG_USER="${POSTGRES_USER:-scrabble}" PG_DB="${POSTGRES_DB:-scrabble}" +# Edge maintenance page. caddy serves a static 503 "works in progress" page for the +# user-facing routes while this flag file exists (deploy/caddy/Caddyfile). We hold it +# across the roll / migration window and clear it on ANY exit — success, a health +# failure + rollback, or an unexpected error — via the trap, so users get a graceful +# page instead of raw mid-swap 502s and the flag can never get stuck on. +MAINT_FLAG="${MAINT_FLAG:-${SCRABBLE_CONFIG_DIR:-/opt/scrabble}/caddy/on}" +maint_off() { rm -f "$MAINT_FLAG" 2>/dev/null || true; } +trap maint_off EXIT + cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; } export REGISTRY # otelcol joins the host docker group to read the socket; the GID varies per host. @@ -119,6 +128,13 @@ if [ -z "$(docker ps -aq -f name=scrabble-backend)" ]; then exit 0 fi +# Existing stack: raise the maintenance page for the whole roll (and any migration +# window). It shows once caddy carries the gate (from this feature's own deploy +# onward); the EXIT trap lowers it however this run ends. +mkdir -p "$(dirname "$MAINT_FLAG")" +: > "$MAINT_FLAG" +echo "maintenance page raised ($MAINT_FLAG)" + # Migration deploy: freeze writes and snapshot a consistent dump before migrating. if [ "$MIGRATION" = 1 ]; then echo "migration deploy: opening maintenance window (stopping the backend = the only writer)" @@ -134,6 +150,7 @@ fi # Roll one service at a time, least -> most dependent; any failure rolls everything back. roll postgres health_postgres || { rollback; exit 1; } +roll renderer health_running scrabble-renderer || { rollback; exit 1; } roll backend health_backend || { rollback; exit 1; } roll gateway health_running scrabble-gateway || { rollback; exit 1; } roll landing health_landing || { rollback; exit 1; } diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml index 6dc3d43..0645427 100644 --- a/deploy/prometheus/prometheus.yml +++ b/deploy/prometheus/prometheus.yml @@ -23,3 +23,21 @@ scrape_configs: - job_name: node static_configs: - targets: ["node_exporter:9100"] + # TLS certificate expiry of the edge caddy (probe_ssl_earliest_cert_expiry), for the + # certificate-renewal-failure alert. Effective on prod, where caddy terminates TLS on + # :443; on the test contour caddy is HTTP-only, so the probe finds nothing and the metric + # is absent (the alert rule is absent-safe). The exporter probes the target passed as a + # scrape parameter and answers on its own :9115. + - job_name: blackbox_tls + metrics_path: /probe + params: + module: [tls_cert] + static_configs: + - targets: ["caddy:443"] + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox_exporter:9115 diff --git a/deploy/write-prod-bot-env.sh b/deploy/write-prod-bot-env.sh new file mode 100755 index 0000000..7f1c9d8 --- /dev/null +++ b/deploy/write-prod-bot-env.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Render the prod bot-host env.bot.sh from the workflow job environment. +# +# Sourced identically by prod-deploy (deploy-bot) and prod-rollback +# (rollback-bot) so the two paths cannot drift (see deploy/write-prod-env.sh +# for the same rationale on the main host). +# +# Usage: BOT_IMAGE= bash deploy/write-prod-bot-env.sh +# +# Every other value comes from the caller's environment (the job `env:` block). +out="${1:?usage: write-prod-bot-env.sh }" + +# The bot's Mini App URL is the same public origin the SPA serves; derive it +# rather than storing a second copy. +base="${PUBLIC_BASE_URL%/}" +TELEGRAM_MINIAPP_URL="$base/telegram/" + +cat > "$out" < bash deploy/write-prod-env.sh +# +# Every other value comes from the caller's environment (the job `env:` block, +# vars.* / secrets.*). Mirrors the compose interpolation contract in +# deploy/.env.example; keep in sync with deploy/docker-compose{,.prod}.yml. +out="${1:?usage: write-prod-env.sh }" + +# Derive the public URLs from the one canonical origin instead of storing each +# under its own variable. The path suffixes are structural (SPA routes / the +# Caddy /_gm sub-path), identical on every contour. +base="${PUBLIC_BASE_URL%/}" +TELEGRAM_MINIAPP_URL="$base/telegram/" +GRAFANA_ROOT_URL="$base/_gm/grafana/" +VITE_VK_ID_REDIRECT_URL="$base/app/" + +# Grafana needs a BARE from-address (it rejects the "Name" form the backend +# go-mail accepts, and validates it even when SMTP is disabled — a bad value +# crash-loops Grafana). Split the display-format SERVICE From into address + name; +# the backend keeps the full form. +svc_from="${SMTP_RELAY_SERVICE_FROM:-}" +GRAFANA_SMTP_FROM_NAME='' +case "$svc_from" in + *"<"*">"*) + GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')" + GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;; + *) GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;; +esac + +cat > "$out" <`, an opaque + 256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the + browser that opens it (magic-link), a link confirms the identity and emits a `notify` + profile-refresh to the in-app session, a change switches the account's email in place, + and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never + sent to the server), so a plain link prefetch cannot reach it, and the manual + six-digit code is the fallback if an aggressive scanner runs the page. An + **email-login** account is created flagged `is_guest` and stays reapable until the + code is confirmed — so an abandoned, never-confirmed login frees its reserved + address — with confirming clearing the flag. Accounts and identities use + application-generated **UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time payment; no purchase flow yet) is carried on the account and ORed on a merge. - **Linking** is initiated from an authenticated profile and proves control of the identity before attaching it: **email** through the confirm-code flow, **Telegram** through the web **Login Widget** (validated by the validator, HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway - passes the trusted `external_id` to the backend, as for `auth.telegram`). The + passes the trusted `external_id` to the backend, as for `auth.telegram`), and **VK** + through **VK ID web login** (`gateway/internal/vkid`): the browser runs the VK ID raw + OAuth 2.1 flow with PKCE — a full-page redirect to VK's hosted login, no `@vkid/sdk` — + and the gateway completes the **confidential code exchange** server-side under the VK + "Web" app's protected key (a distinct VK app from the Mini App) to obtain the trusted + `external_id`. A browser has no signed launch parameters, so unlike the VK Mini App + (offline HMAC, §12) this makes an outbound call to `id.vk.com`, and it is web-only — a + full-page redirect would strand a native Mini App webview. The request step **always** sends/accepts the proof (no pre-send "already taken" signal, so a probe cannot enumerate registered addresses); a required **merge** is revealed **only after** the proof is verified and is performed behind an - explicit, irreversible confirmation. A free identity is simply attached (and a + explicit, irreversible confirmation (for VK, whose authorization code is single-use, + the merge re-authorizes for a fresh code). A free identity is simply attached (and a guest is promoted to durable, clearing `is_guest`). +- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The + backend **refuses removing the last identity** (`ErrLastIdentity`), so an account + never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one + method remains. **Email is never unlinked — it is changed.** +- **Change email** mails a confirm-code (`purpose=change`) to the new address on the + authenticated account and, on confirm (code or one-tap deeplink), **atomically + replaces** the account's email identity with the new one, freeing the old address. A + new address already confirmed by **another** account is refused **without disclosure** + (a neutral "check the address or contact support") and **never merged** — the anti- + enumeration check is only reachable by someone who controls the new mailbox. +- **Deletion is legal retention, not erasure** (`internal/accountdelete`). The account row + survives as a **tombstone** (`accounts.deleted_at`) — its chat/complaint foreign keys + have no cascade, so a hard delete is impossible. `AnonymizeAndTombstone` **journals** + every live identity into an append-only **`retained_identities`** log (the legal dossier) + then removes it, freeing the `(kind, external_id)` for a new account to reuse; it scrubs + the live `display_name` → **`[Deleted]`** (an unspoofable sentinel — the editable-name + rule forbids brackets) while snapshotting the real name into `deleted_display_name`, + anonymises the game-seat snapshots, and drops the account's own friendships / blocks / + invitations / friend-codes / drafts / pending-codes. **Chat, feedback and complaints are + kept.** The orchestration a layer up resigns the account's active games (so opponents are + not stranded), **drops its all-robot games** (no human opponent; children cascade), and + revokes its sessions. Every credential detachment — **unlink, email change, delete and a + merge collision** — writes a `retained_identities` row (`reason`), so the dossier keeps the + full timeline. (A merge that would otherwise leave the survivor with two identities of one + kind — e.g. each account held a confirmed email — keeps the primary's and journals the + secondary's with `reason=merge` before dropping it.) + Step-up is a mailed code (`purpose=delete`, no deeplink — a stray click must not delete; + `ConfirmByToken` refuses a delete token) for an email account, else a typed phrase. + `last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled + to once an hour). A **two-year TTL reaper** (from the event) purges the whole dossier — + the journal, plus a deleted account's feedback thread and dossier PII — while the chat and + the tombstone row stay. - **Merge** retires the account that owns the linked identity into the **current** account, in a single transaction (`internal/accountmerge`): statistics summed (counters incl. moves/hints added, max points kept, and the per-variant best moves @@ -352,6 +408,17 @@ Key points: - History is dictionary-independent (§9.1): the engine emits decoded `MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard` (alphabet only, no dictionary). +- The **client mirrors this validation path for an instant move preview** (the + local eval, `ui/src/lib/dict`): the dawg reader and the + validate/score/direction slice are ported to TypeScript, byte-for-byte against + this engine and pinned by the `conformance` CI job. Composing a move is scored + on-device with no round trip. It is an **advisory accelerator only** — + `submit_play` re-validates on the server, so a wrong or spoofed local result + cannot corrupt a game. The pinned per-game dictionary blob is fetched once + through a **session-gated `GET /dict/{variant}/{version}`** edge route + (immutable; cached in IndexedDB best-effort) and reused across sessions; any + miss, storage eviction or a bad-connection breaker falls back to the network + `evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. ## 6. Game rules @@ -646,7 +713,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); robot instead clears when the robot answers by moving, as for a human. A seat's bit clears when that player **opens the move history or the chat** (`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a history open is not a constant backend call), and a **nudge additionally clears when its - recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is + recipient answers by moving** (the move path calls a wired `NudgeClearer`); and **every nudge in + a game is marked read when that game finishes** — on any completion path (a closing move, a + resignation, a forfeit or a turn-timeout, all funnelling through the shared `commit`), since the + nudge badge is stale once the game is over (a wired `NudgeExpirer`; chat messages stay unread and + this expiry records no read latency). The mask is inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer `unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`** @@ -656,8 +727,9 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); REST-seed-then-event-bump lifecycle (the live-event game-view leaves them false; a nudge event raises only `unread_chat`, a message event raises both). The lobby additionally **floats games with any unread entry to the top** of the your-turn and opponent-turn - sections (the finished section keeps its activity order). On each clear the publish-to-read - latency is recorded; the read time itself is not retained. + sections (the finished section keeps its activity order). On each player-driven clear the + publish-to-read latency is recorded — the completion expiry records none (it is not a read); + the read time itself is not retained. - **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email (confirm-code binding, see §4), **timezone**, the daily **away window**, the **variant preferences** (`variant_preferences`, the matchable-variant set that gates New @@ -769,9 +841,40 @@ the same rows and is likewise self-contained — we ship our own writer (the sol exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` exchanges), plus `#note` lines for resignations and timeouts, which the standard -does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive` -otherwise), so an in-progress journal is never leaked mid-play; the client -shares the `.gcg` file via the Web Share API where available, else downloads it. +does not cover. **Export is offered only on a finished game** (`game.ErrGameActive` +otherwise), so an in-progress journal is never leaked mid-play. + +**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text +and the rendered **PNG of the final position** — travel one uniform route on every +platform: the client calls the authenticated `game.export_url` op, the backend mints a +**relative, HMAC-signed, short-lived path** +(`/dl/{game}/{kind}?e=&…&s=`, 10-minute TTL, +`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no +service ever needs to know the public host) and hands it to the best affordance +each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the +chooser there is the native showPopup — activation-free bridge calls end to end), +Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies +the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both +formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its +DownloadFile hangs regardless of Content-Length/Range — the OS share sheet on a +mobile browser, or a plain anchor download (desktop browsers and the VK desktop +iframe). The gateway serves the bytes via +`http.ServeContent` (Content-Length + Range/206 — Android's system DownloadManager +hangs on chunked bodies of unknown length). The GET is the gateway's +**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the +per-IP public rate limiter): the native download calls carry no cookies or headers, so +the URL's signature — verified by the backend on its `/api/v1/public` group in constant +time, every failure a uniform 404 — is the whole grant, and minting requires an +authenticated caller on a finished game. The PNG is rasterized on demand by the +**`renderer` sidecar** (internal-only Node + skia-canvas running the same +`ui/src/lib/gameimage.ts` the web project unit-tests — one renderer, no drift; +`renderer/README.md`); the backend rebuilds the render payload from the journal + +`engine.AlphabetTable`, and the client's date locale, IANA time zone and UI-localized +non-play labels ride the signed URL so the server render matches the player's +presentation. Nothing is stored: the artifact is re-derived from the immutable +finished journal on each GET. The only degraded platform is a legacy Telegram client +predating `downloadFile`, where the GCG falls back to the old clipboard copy and the +image option is not offered. The alphabet-on-the-wire transport does **not** touch this invariant: the live edge exchanges alphabet indices, but the persisted journal (and everything derived from it — @@ -877,7 +980,10 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets `paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal), and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content* -edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. +edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same +mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's +own account changed out of band (an email confirmed through the one-tap deeplink opened in another +browser), so an open in-app session reflects it at once. > A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into > one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap @@ -908,6 +1014,19 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid both are surfaced on the **Scrabble — Resources** Grafana dashboard, which captures the stress-run resource profile. (`docker_stats` replaced cAdvisor, which on the contour host resolved only the root cgroup — a separate-XFS `/var/lib/docker`.) +- **Alerting.** Grafana emails infra alerts through the shared relay (its own SMTP on the + STARTTLS port) to `SERVICE_EMAIL`, from provisioned rules + (`deploy/grafana/provisioning/alerting/`): a scrape-target down, the gateway's + internal-error rate and p99 latency (`edge_request_*`), host memory/disk/CPU + (node_exporter), Postgres connection saturation (postgres_exporter), and **TLS certificate + expiry < 20 days** — a Caddy ACME-renewal-failure signal from a **`blackbox_exporter`** + probe of the edge caddy (`probe_ssl_earliest_cert_expiry`). Every rule is `noDataState=OK`, + so an absent metric never false-alerts — notably the cert probe, which has no TLS target on + the HTTP-only contour caddy. Separately, the backend's **admin-alert worker** + (`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a + distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive, + **coalescing** a burst into one digest per interval; both recipients may be several + comma-separated addresses. Both paths are inert unless configured. - Per-request server-side timing via gin middleware from day one (the access log carries method, route, status, latency and the active trace id). A client-measured RTT piggybacked on the next request is a later enhancement. @@ -937,6 +1056,20 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid distinct accounts that performed an authenticated edge action in the window. The gauge is single-process by design (single-instance MVP, §10): it is correct for one gateway, resets on restart, and is a live operational figure, not a billing count. +- **Local-preview adoption (client-reported):** three gateway counters measure uptake of + the local move-preview accelerator (§5), fed by a small, best-effort client beacon + (`POST /metrics/local-eval`, session-gated) that batches counter deltas and posts them + on a 60 s timer and when the app is backgrounded — **never on the gameplay path** (the + in-app counters are plain in-memory increments; only the periodic flush touches the + network, fire-and-forget). `local_eval_cold_start_total` counts app cold starts (the + adoption denominator); `local_eval_dict_load_total` (`result` = fetched/cache_hit/miss) + splits a dawg download that fills IndexedDB from a cache hit and from a warm-up that + failed or timed out; `local_eval_preview_total` (`path` = local/network) is the share of + previews computed on-device versus the network `evaluate` fallback — the backend load + shed. It answers the owner's adoption question — how often the app opens versus how often + it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by + design (a dropped beacon just retries its batch on the next flush); it is telemetry, never + a game input. - **Rate-limit observability:** every limiter rejection increments the gateway counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate only, honouring the no-per-user-label discipline above) and logs one **Debug** line; @@ -1048,6 +1181,13 @@ valid-code population). Brute-forcing a 6-digit friend code within these limits accepted MVP risk with low blast radius (an unwanted friendship is removable/blockable); a dedicated redeem sub-limit or a longer code is the hardening step if abuse appears. +**Client source exposure.** The production UI build ships **no sourcemaps** +(`vite.config.ts` gates `build.sourcemap` off when `mode === 'production'`), so the gateway +and landing images serve only minified JS and the full TypeScript/Svelte source is not +recoverable from a served `.map` at the edge. Dev and the `mock` e2e build (`vite build +--mode mock`) keep maps for debugging. This is surface reduction, not secrecy — the single +minified bundle still carries all platform code paths and is reversible with effort. + ## 13. Deployment (informational) Single public origin, path-routed. The Vite build has two entries: a lightweight @@ -1060,7 +1200,14 @@ parameters from the URL and the gateway verifies them in-process — §12); a st gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the `landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build, `deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by -static file serving and never reaches the Go edge. Hash-named `/assets/*` are served +static file serving and never reaches the Go edge. The landing shell carries the site's +static SEO head — Russian title/description, the Open Graph card (Telegram/VK link +previews), JSON-LD and a canonical link — with absolute URLs pinned to the production +origin, so the test contour canonicalises to production instead of being indexed as a +separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no +browser-language detection — crawlers render with arbitrary languages). The favicon set, +`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by +`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `no-cache` so a new deploy is picked up — both containers apply the same caching. An in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0857b04..7bef505 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -16,14 +16,34 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge real-time in-app updates, switching interface language (en/ru) and theme, and a read-only profile. It also handles managing friends (including one-time friend codes) and blocks, friend-game invitations, editing the profile and binding an -email, the statistics screen, and the in-game history viewer with GCG export. +email, the statistics screen, and the in-game history viewer with GCG / PNG-image export. Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. A public **landing page** at the site root introduces the game, switches language and -theme, and links to the matching per-language Telegram channel; the game itself runs at +theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral -(it follows the system scheme, not the saved preference); its language choice is saved. +(it follows the system scheme, not the saved preference); its language choice is saved. The +landing always opens in **Russian** — the browser language is never auto-detected (crawlers +render with arbitrary languages, so detection would make the indexed content +nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head +(title/description, the Open Graph card Telegram/VK link previews use, a canonical link +pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA +shell is `noindex` — the landing is the only indexable page. + +### First-run onboarding +The first time a player opens the app it walks them through the interface with a light +**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a +tail — at a real control, and a **tap anywhere** advances to the next; after the last hint the +overlay is gone for good. Two independent series run. The **lobby** series points at the +⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game +board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange, +🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**, +so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the +lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the +lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the +bubbles re-anchor to wherever the controls move) and the hint texts are localized (en/ru). The +advertising banner hides while the overlay is up and returns when it closes. ### Identity & sessions A player arrives from a platform (Telegram first), via email login, or as an @@ -38,7 +58,19 @@ deployment), the Mini App retries quietly and then shows a small "couldn't load" A **VK Mini App** launch works the same way: it authenticates from VK's signed launch parameters (verified by the gateway), and on first contact seeds the new account's interface language from `vk_language` and its display name from the VK profile (read on the client, since VK does not put -the name in the signed launch). The same quiet-retry "couldn't load" screen applies inside VK. +the name in the signed launch). While the theme preference is "auto" the app follows the VK +client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry +"couldn't load" screen applies inside VK. +**Email** sign-in (web) mails a branded six-digit confirmation code — localized +(en/ru), no images — to the address; entering it signs the player in. A new address +is provisioned on the first request but only becomes a durable account once the code +is confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address +freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap +per address), so a mistyped address or an impatient tap cannot flood an inbox. The email +also carries a **one-tap link** that confirms the address — or signs the player straight +in — in a single tap; opening it in another browser reflects in the app at once, and a +mail scanner that merely fetches the link cannot reach it — the token rides the URL +fragment, which is never sent to the server. Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the @@ -64,10 +96,6 @@ reconnect), and pending reads resume on their own — the interface stays usable flashing a red banner each time. ### Accounts, linking & merge -_Sign-in is currently provider-only, so the in-profile linking UI is temporarily hidden; it -returns once the anonymous `/app/` guest (whose upgrade path this is) ships. The flow below -describes it for when it does._ - First platform contact auto-provisions a durable account. From the profile a player links an email (via a confirm code) or their Telegram (via the web sign-in); a guest who links their first identity becomes a durable account. The "already taken" status @@ -79,6 +107,28 @@ when a guest links an identity that already has a durable account, where the dur account is kept and the guest's games move into it. A merge is blocked only while the two accounts share a game still in progress. +The profile lists the account's **sign-in methods**. On the web a player can add +Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and +back); inside a Mini App the host platform is already linked. Linking a provider that +already belongs to another account offers the same irreversible **merge** as email +linking. A linked provider can be **unlinked** — except the last +remaining sign-in method, which is refused so the account stays reachable. **Email is +never unlinked; it is changed**: the player enters a new address, confirms a code +mailed to it, and the account switches to it atomically, freeing the old address. A new +address that already belongs to another account is refused with a neutral "check the +address or contact support" — the switch never merges and never reveals the other +account. + +A player can **delete their account**. This is a legal-retention removal, not an erasure: +the account is deactivated and its live surfaces anonymised (opponents see "[Deleted]"), its +sign-in methods are freed for reuse, and its sessions are revoked — but a dossier (the +credentials that were linked, the last login, and the player's messages) is retained for +the operator and purged after two years. Confirming deletion needs a mailed code for an +account with an email, or a typed phrase otherwise. Active games are forfeited so opponents +are not stranded; solo games against the AI are removed, while games with a human opponent +are kept under the anonymised name and the player's chat stays. Reopening the app after +deletion simply creates a fresh account. + ### Lobby & matchmaking On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the @@ -125,7 +175,8 @@ and any incidental perpendicular words are ignored and not scored — while on i standard Scrabble. English games are always standard and show no such toggle. In auto-match the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are formed by inviting players from the friend list (an invitation, like a friend code, -is shareable as a Telegram or VK deep link that opens it directly): the inviter chooses the +is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the +Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation expires after seven days. @@ -146,7 +197,10 @@ tile that extends an existing word (down a column or across a row) is accepted. A play is validated against the game's dictionary at submit time and scored; an unlimited preview reports the word(s) a tentative move would form and its score, or that it is not -legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is +legal, and the move is offered for submission only once it is confirmed legal. The preview is +computed **on-device** for an instant response once the game's dictionary has loaded — a +brief warm-up shows on the first open otherwise — and falls back to the server whenever the +local dictionary is unavailable. The dictionary check tool is unlimited and offers a complaint on any result; for a word it finds, it also links out to an external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for English) to look it up. Hints are governed per game — @@ -243,7 +297,8 @@ entry — a message **or a nudge** from an opponent — raises a small **dot** n in the **lobby** and on the game's **score bar**, **red when an unread message is waiting and a softer amber when only nudges are**. Opening the **move history** counts as reading the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge -also clears the moment its recipient **takes their move**. +also clears the moment its recipient **takes their move**, and **all of a game's nudges clear once +the game finishes** (the badge is stale once the game is over) — but unread chat messages stay unread. ### Profile & settings Edit the display name (letters joined by a single space / "." / "_" separator, with an @@ -291,11 +346,44 @@ silently ignores their messages) or clear a thread's messages. This is independe Feedback above — it serves people who write to the bot directly rather than through the app. ### History & statistics -Finished games are archived in a dictionary-independent form and exportable to -GCG; the export is offered **only once a game is finished**, and never for an -honest-AI practice game (a live game's export would leak the move journal; an AI -game is throwaway). The client shares the `.gcg` file where the platform supports -it, otherwise downloads it. Statistics (durable accounts only): +Finished games are archived in a dictionary-independent form and exportable in +**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of +the final position; the export is offered **only once a game is finished**, and +never for an honest-AI practice game (a live game's export would leak the move +journal; an AI game is throwaway). The format chooser is Telegram's **native popup** +on Telegram Android/desktop (safe there: that chain is all bridge calls, which need +no user activation) and the app's own modal elsewhere — on Telegram iOS the delivery +is the OS share sheet, which a native-popup callback cannot open, and VK has no +native chooser. + +The **image** is rendered on the server (the internal render sidecar runs the same +drawing module the web client tests; always the light theme): the final board with +classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour +fills, no text labels — and a compact per-seat scoresheet on the right: each seat's +name and final score in the header (🏆 by the winner), then one row per move +carrying the main word's classic coordinate (an across play is row-first, `8G`; a +down play column-first, `H8` — the GCG convention), the word and its points; extra +words of a multi-word play ride a smaller second line, non-play moves show as +localized notes (pass/exchange/resign/timeout), and a closing ± row shows the +endgame rack settlement when there was one (the final scores are authoritative — +running totals alone do not include it). The footer stamps the site host and the +finish date in the device locale. The scoresheet typography is fixed; a long game +stretches the board (never below its minimum) so the image carries no dead space. + +Delivery is **one signed, short-lived link for both formats on every platform**, +handed to the best affordance each platform has (each branch verified on-device): +Telegram Android/desktop use Telegram's download dialog (whose own preview shares +onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS +takes both formats through VK's download into its native share flow, while on the +**VK Android client** — whose downloader hangs — the image opens in VK's native +photo viewer (saving from its controls) and the GCG copies to the clipboard (the +desktop VK iframe, an ordinary browser, downloads both); a **mobile browser gets +the OS share sheet** (nothing lands in Downloads first); a desktop browser +downloads the file. The link needs no login to fetch (the platforms' downloaders +carry none) and is valid for minutes. The single exception is a legacy Telegram +client without the download dialog (pre-Bot API 8.0): there the GCG falls back to +the old clipboard copy (with the confirming toast) and the image option is not +offered. Statistics (durable accounts only): wins, losses, draws, max points in a game, and max points for a single move (the best play, which already includes every word it formed plus the all-tiles bonus). It also shows the player's **move count** (their plays — passes and exchanges do not diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ce733ac..4e32af7 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -16,15 +16,37 @@ top-1 подсказку, безлимитную проверку слова с профиль только для чтения. Он также включает управление друзьями (в т.ч. одноразовые коды-приглашения) и блоками, дружеские приглашения в игру, редактирование профиля и привязку email, экран статистики и просмотр истории -партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток +партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток (новичок / классика / без текста). Подсказка **выставляет предложенные фишки на доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет. Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии и ограничивает частоту повторов. Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и -тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам +тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам `/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из -системной настройки, а не из сохранённой), выбор языка сохраняется. +системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда +открывается на **русском** — язык браузера не определяется автоматически (роботы +рендерят страницу с произвольным языком, и автоопределение сделало бы +проиндексированный контент недетерминированным); сохранённый выбор 🌐 по-прежнему +главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка +Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес +продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена +`noindex` — индексируется только посадочная страница. + +### Первый запуск: онбординг +При первом открытии приложения игрока один раз проводят по интерфейсу лёгким +**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое +«хвостиком» указывает на реальный элемент управления, а **тап в любом месте** переходит к +следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби** +указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии +игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен, +🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только +**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала; +запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший +по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате. +Обе серии одинаково работают в portrait и landscape (облачка переустанавливаются туда, куда +переезжают элементы), тексты подсказок локализованы (en/ru). Рекламный баннер скрывается, пока +оверлей показан, и возвращается по закрытию. ### Личность и сессии Игрок приходит с платформы (сначала Telegram), через email-вход или как @@ -40,8 +62,20 @@ Mini App** авторизует по подписанным `initData` плат Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте, -так как VK не кладёт имя в подписанный запуск). Тот же экран тихого повтора «не удалось -загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним +так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует +светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран +тихого повтора «не удалось +загрузить» действует и внутри VK. +**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения — +локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом +запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная, +неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая +пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап +не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает +адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в +приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется — +он едет в URL-фрагменте, который не уходит на сервер. +Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный **промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой, @@ -66,10 +100,6 @@ launch-параметрам VK (их проверяет gateway), и при пе рабочим вместо красного баннера каждый раз. ### Аккаунты, привязка и слияние -_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он -вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание -ниже — на этот случай._ - Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже @@ -81,6 +111,27 @@ _Вход сейчас только через провайдера, поэто тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние запрещено, только пока у аккаунтов есть общая незавершённая игра. +В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить +Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и +обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже +принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка +email. Привязанного провайдера можно **отвязать** — кроме последнего +оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым. +**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код, +отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая +старый. Новый адрес, уже принадлежащий другому аккаунту, отклоняется нейтральным +«проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает +аккаунты и не раскрывает чужой. + +Игрок может **удалить аккаунт**. Это удаление с юридическим удержанием, а не стирание: +аккаунт деактивируется, живые поверхности обезличиваются (соперники видят «[Deleted]»), +способы входа освобождаются под повторную регистрацию, сессии отзываются — но досье +(какие креды были привязаны, последний вход, сообщения игрока) сохраняется для оператора и +чистится через два года. Подтверждение удаления — код на почту (если у аккаунта есть +e-mail) либо ввод фразы. Активные игры форфейтятся, чтобы не бросать соперников; одиночные +игры против ИИ удаляются, а игры с людьми сохраняются под обезличенным именем, чат игрока +остаётся. Если снова открыть приложение после удаления — создаётся новый аккаунт. + ### Лобби и подбор При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как @@ -131,7 +182,8 @@ _Вход сейчас только через провайдера, поэто показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, кто выбрал то же правило. Игры с друзьями (2–4) формируются приглашением игроков из списка друзей (приглашение, как и код друга, -можно отправить deep-link'ом в Telegram или VK, который откроет его сразу): инициатор +можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App +никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без ответа приглашение протухает через семь дней. @@ -153,7 +205,9 @@ _Вход сейчас только через провайдера, поэто слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно -отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и +отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве** +и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается +короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), чтобы его посмотреть. Подсказки управляются настройками @@ -251,7 +305,8 @@ _Вход сейчас только через провайдера, поэто **лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую, когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда -его получатель **делает ход**. +его получатель **делает ход**, и **все nudge'и партии гаснут по её завершении** (после конца +партии бейдж неактуален) — но непрочитанные сообщения чата остаются непрочитанными. ### Профиль и настройки Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / @@ -299,11 +354,44 @@ Telegram. канал для тех, кто пишет боту напрямую, а не через приложение. ### История и статистика -Завершённые партии архивируются в независимом от словаря виде и экспортируются -в GCG; экспорт доступен **только после завершения партии** и никогда — для -тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия -с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает, -иначе скачивает его. Статистика (только у постоянных аккаунтов): +Завершённые партии архивируются в независимом от словаря виде и экспортируются в +**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка** +финальной позиции; экспорт доступен **только после завершения партии** и никогда — +для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а +партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на +Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов, +которым user activation не нужна) и собственный модал приложения в остальных +случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из +коллбека нативного попапа не открыть, а у VK нативного чузера нет. + +**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же +модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная +доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой +цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам +справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по +строке на ход: классическая координата главного слова (горизонтальный ход — +цифра первой, `8G`; вертикальный — буква первой, `H8` — конвенция GCG), слово и +его очки; дополнительные слова мультисловного хода — второй мелкой строкой, +не-игровые ходы — локализованными пометками (пас/обмен/сдался/таймаут), а +замыкающая строка ± показывает концовочную поправку за рэк, когда она была +(финальные счета авторитетны — суммы ходов её не содержат). В подвале — хост сайта +и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная +партия растягивает доску (не ниже минимума), так что пустот на картинке нет. + +Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой +платформе**, отданная лучшему механизму, который у платформы реально есть (каждая +ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram +(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со +скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный +share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в +нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер +(десктопный iframe VK — обычный браузер — скачивает оба); **мобильный браузер +получает системную share-шторку** (ничего не оседает в Загрузках); десктопный +браузер скачивает файл. Ссылка не требует логина при +скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное +исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там +GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт +«картинка» не предлагается. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Также показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и diff --git a/docs/TESTING.md b/docs/TESTING.md index 71e1b78..e22de9c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -17,10 +17,26 @@ tests or touching CI. - **UI** — Vitest (unit) + Playwright (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers the FlatBuffers codecs (friend list, invitation, stats), the win-rate - derivation and the GCG share/download choice, plus Playwright specs against the + derivation and the GCG share/copy/download choice, plus Playwright specs against the mock for the friends screen (code issue/redeem, accept a request), the lobby - invitations section, the stats screen, profile editing, and the GCG export's - finished-only visibility. + invitations section, the stats screen, profile editing, and the export chooser's + finished-only visibility + its signed-URL download flow (route-intercepted). +- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared + `ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a + real self-played 35-move game) must rasterize to a plausible PNG + (`pnpm -C renderer test`). The drawing module's pure parts (scoresheet, layout, + notation) stay unit-tested in `ui/` — the sidecar test guards only the + skia/bundling seam. Pixel goldens are deliberately avoided (fonts differ across + hosts). +- **Local-eval conformance** — the client's on-device move preview (the ported + dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the + authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit + golden vectors from the release dictionaries — every stored word plus a battery of + plays across both cross-word rules and all variants, each with the engine's + legality, score, words and inferred direction. The gated Vitest suites + (`ui/src/lib/dict/*.parity.test.ts`, skipped without their `DICT_*` env) replay + them and must agree exactly; the `conformance` CI job runs the whole loop, so a + drift in the port fails the merge. - **Engine** — correctness of scoring and move generation is owned by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on top of the embedded solver: per-variant smoke tests (load all three committed @@ -53,7 +69,10 @@ tests or touching CI. content and block-visibility rules, the nudge turn/rate-limit rules, the invitation flow (all-accept starts the game, decline cancels, lazy expiry, inviter-only cancel), and the email confirm-code flow (request/confirm, taken - email, expiry and attempt-cap) with a fixture mailer. It also covers the + email, expiry and attempt-cap, and the guest-until-confirmed login) with a fixture + mailer. The branded email template render (en/ru subject, code and body) and the + per-recipient send-rate limiter (cooldown + hourly cap) are pure account-package + unit tests, needing no database. It also covers the **befriend-an-opponent** gate (a request needs a shared game), the **permanent decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem, self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 3ca2fd6..5078d9f 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -49,6 +49,32 @@ fast load shows it for ~1.25 s. Each tile **drops in** with a brief scale + fade dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts` (unit-tested); `Splash.svelte` is the renderer. +## First-run onboarding coachmarks (`components/Coachmark.svelte`, `lib/coachmark.ts`) + +A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an +**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings → +✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle → +rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer +captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the +last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself; +a series is marked only after its **last** hint, so an interrupted player replays it from the start. +The lobby series gates on `app.splashDone`, the game series on its first target laying out; both defer +while a modal (`welcomeRedeem` / `staleInvite`) is open. + +Each target carries a **`data-coach` attribute**, so the **same positioning engine anchors it in both +orientations** wherever the layout moves it (the tab bar to the landscape left panel, etc.). The +bubble points at the live `getBoundingClientRect()` of its target, **re-measuring each frame until the +geometry settles** (the route-change slide, the hidden-banner reflow, async fonts) and on resize / +rotation; a target absent in the current state is skipped. The bubble matches the in-game counter text +size (`0.85rem`, larger in landscape), wraps by word and never fills the width; its **tail** sits on +the edge facing the target, centred on it (clamped near a corner). The pure geometry (step lists, +`nextVisibleStep`, `placeBubble`) lives in `lib/coachmark.ts` (unit-tested); `Coachmark.svelte` is the +renderer. While the overlay is up the **advertising banner is hidden** (`app.coachActive`) so its +scroll does not run behind the scrim. The hidden DebugPanel offers a **Reset visited** control that +clears the flags so the walk-through replays on the next launch. In the **mock build** the overlay +does not auto-show (so it cannot block the Playwright smoke); `?coach` forces it on for the dedicated +e2e and the screenshots. + ## Navigation - **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte` @@ -74,7 +100,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib square with an accent underline). A red count **badge** rides the icon's corner — on the lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations keep their own lobby section), and on the Hint tab for the remaining count. No text - selection on nav / tab-bar / buttons (`user-select: none`). + selection on nav / tab-bar / buttons (`user-select: none`), and no tap-highlight flash on any + tappable element (`-webkit-tap-highlight-color: transparent` on `#app`) — Android in-app WebViews + otherwise draw a momentary selection-like box on a clickable node on tap (seen on the header title + and the back chevron). - **Screen transitions** (`App.svelte`): navigation slides directionally — a screen entered from the lobby flies in from the right; returning to the lobby reveals it from the left (back). Transitions are local (so they do not play on first load) and @@ -113,6 +142,22 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib by a background suspend reconnects silently on return — the connection banner is suppressed while hidden and for a short grace after resume (visibilitychange + pageshow/pagehide + Telegram `activated`/`deactivated`). +- **VK integration** (`lib/vk.ts`): inside the VK Mini App the **auto** theme follows the VK + client's light/dark (`VKWebAppUpdateConfig`), as the VK webview's `prefers-color-scheme` does not + track it; explicit light/dark prefs still win. The device safe-area comes from CSS + `env(safe-area-inset-*)` (the meta already sets `viewport-fit=cover`) **max'd** with the VK bridge + insets (`VKWebAppUpdateInsets`, needed on Android, where the VK webview exposes no `env()` inset), + so the layout clears the VK home bar. Share and copy route through the bridge (`VKWebAppShare` / + `VKWebAppCopyText`) because `navigator.share` / `clipboard` are absent in the desktop VK iframe. + Haptics fire the same set as Telegram via VK Bridge taptic (`VKWebAppTapticImpactOccurred` / + `…NotificationOccurred` / `…SelectionChanged`), through the shared `lib/haptics.ts` dispatcher; and + VK's **horizontal swipe-back** is disabled at launch (`VKWebAppSetSwipeSettings`) so it does not + fight the app's own edge-swipe-back and tile drag — parity with Telegram's disabled vertical + swipes, the app owning navigation through its back chevron. VK's **status bar** (and, on Android, + the action / navigation bars) is painted to the app theme via `VKWebAppSetViewSettings` on launch + and every theme change — parity with the Telegram chrome painting (`syncVKChrome` mirrors + `syncTelegramChrome`); the status-bar appearance follows the `--bg` token's luminance + (`appearanceForBg`). ## Tiles & board @@ -137,7 +182,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib the first time. A **swipe down on the zoom-out board** opens the history, but only when the board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict that once retired this gesture) — and it is suppressed on the zoomed board, where the - one-finger drag pans. History also opens on a **tap of the score bar** and closes on a tap or + one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the + viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while + zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also + opens on a **tap of the score bar** and closes on a tap or an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the hint's placement, not the top-left. @@ -166,8 +214,7 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib **Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬 (badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian game with "multiple words per turn" off) centres a **"One word per turn"** label between - those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that - yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two + those two icons; standard games show no label. Each **opponent**'s card also gains two edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend, disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap, swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while @@ -243,7 +290,8 @@ the surroundings in the light theme and a touch lighter in the dark theme, mappe linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get` response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so `Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches -the profile to show or hide it in place. +the profile to show or hide it in place. It is also hidden while a first-run onboarding overlay is up +(`app.coachActive`), reappearing by this same condition once the overlay closes. The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer live outside the components, so navigating between screens (which remounts the view) **continues the @@ -355,13 +403,41 @@ enabled on the first, uncached load) and flip in place when an event refreshes t lobby with a calm modal pointing at the bot (`@`), not a red error on the Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in Safari. -- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid - (the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history - header) shares or downloads the `.gcg` file and appears only once the game is finished — and - never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board: - it closes the history drawer (portrait) and zooms the board out. +- **History / export**: the in-game slide-down history lays each move out in a per-seat grid + (the word(s) and the move score, no running total); the 📤 in the history header appears + only once the game is finished — never in an honest-AI game (throwaway practice) — and + opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe + there, since that chain is all bridge calls needing no user activation (a popup callback + must never lead into `navigator.share`/clipboard, the on-device finding) — and the app's + own modal elsewhere (TG iOS delivers via the OS share sheet, which needs the modal + click's activation; VK has no native chooser; the accent button leads with the image, + both options need the connection). Both formats mint the same signed relative link + (`game.export_url`), resolved against the app's own origin, then delivered per platform + (each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview + shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS + `VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose + DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save + works) and the GCG to the clipboard; the VK desktop iframe plain anchor downloads; a + mobile browser the OS share sheet (the proven fetch-then-share pattern); a desktop + browser an anchor download. A legacy Telegram + client without `downloadFile` keeps the old GCG clipboard copy, hides the image option + and stays on the app modal. Confirming a resign reveals the full board: it closes the + history drawer (portrait) and zooms the board out. +- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar + executes; the browser app no longer draws it): always the light palette (pinned constants + mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as + plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left, + value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is + the scoresheet: per-seat name over the final score (🏆 by the winner), one + fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down = + column-first `H8`), the main word, points right-aligned; extra words of a multi-word play + on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a + closing muted ± row for the endgame rack settlement when present. Footer: + `hostname · finish date` in the **device** locale. The scoresheet's height drives the + board side (never below its minimum): a long game stretches the board, never the + typography, so the image has no dead space. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header - offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and + offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input row is turn-driven: on your turn the message field shows until your one allowed message is @@ -376,6 +452,20 @@ enabled on the first, uncached load) and flip in place when an event refreshes t raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the Info tab. +## Dictionary warm-up overlay (`components/DictWarmup.svelte`) + +Opening a game whose local move-preview dictionary is not yet cached shows a brief, +non-dismissable warm-up overlay while it downloads (a returning player's cached +dictionary loads from IndexedDB in a few ms, so the flash-guard suppresses it then). +A darker-than-onboarding scrim covers +the board; centred on it, a large emoji cycles through a fixed playful sequence — one +per 500 ms tick (300 ms still, then a 200 ms clockwise spin with the current glyph +fading out and the next fading in), looping — under a constant "Loading…" caption. +Reduce-motion drops the spin to a plain cross-fade. A ~120 ms flash-guard suppresses +the overlay for a disk-cached dictionary that loads in a few ms; a cold (network) load +shows it until the dictionary is ready or a 5 s cap elapses, after which the preview +falls back to the network. See docs/ARCHITECTURE.md §5 (the local eval). + ## Caveat Emoji are rendered by the platform's system emoji font, so their exact look varies across diff --git a/gateway/Dockerfile b/gateway/Dockerfile index c8e5bf9..aaf0a12 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -23,18 +23,18 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate # VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev"). ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_LINK= -ARG VITE_TELEGRAM_LINK_EN= -ARG VITE_TELEGRAM_LINK_RU= -ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= -ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= +ARG VITE_TELEGRAM_GAME_CHANNEL_NAME= +ARG VITE_VK_APP_LINK= +ARG VITE_VK_APP_ID= +ARG VITE_VK_ID_REDIRECT_URL= ARG VITE_GATEWAY_URL= ARG VITE_APP_VERSION= ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ - VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \ - VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \ - VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \ - VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \ + VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \ + VITE_VK_APP_LINK=$VITE_VK_APP_LINK \ + VITE_VK_APP_ID=$VITE_VK_APP_ID \ + VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_APP_VERSION=$VITE_APP_VERSION diff --git a/gateway/README.md b/gateway/README.md index 4327888..426c2fc 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -61,6 +61,12 @@ round-trip, then forwards the trusted `vk_user_id` (and the client-read display from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled (`auth.vk` is unregistered). +`link.vk.confirm`/`merge` link a VK identity from a **browser** (not a Mini App) via **VK ID web +login** (`internal/vkid`): the SPA runs the VK ID raw OAuth 2.1 flow (PKCE, no `@vkid/sdk`) and +the gateway completes the **confidential code exchange** at `id.vk.com` under a SEPARATE VK "Web" +app's protected key — the only op here that calls VK (the Mini App path above is offline). All +three `GATEWAY_VK_ID_*` unset leaves the ops unregistered. + The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops; @@ -72,9 +78,10 @@ refetch). The social/account/history ops — `blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`, `stats.get`, `game.gcg`, and the `notify` live event — go through the identical transcode pattern (`transcode_social.go`). Account linking & merge -— `link.email.request/confirm/merge` and `link.telegram.confirm/merge` -(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the -validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These +— `link.email.request/confirm/merge`, `link.telegram.confirm/merge` and +`link.vk.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login +Widget** payload via the validator (`ValidateLoginWidget`), the vk ops complete the VK ID +web code exchange via `internal/vkid`, and both forward the trusted `external_id`. These **superseded** the former `email.bind.*` ops, which were removed. ## Configuration @@ -89,6 +96,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These | `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` | | `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) | | `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) | +| `GATEWAY_VK_ID_APP_ID` / `GATEWAY_VK_ID_CLIENT_SECRET` / `GATEWAY_VK_ID_REDIRECT_URL` | unset | VK ID "Web" app credentials for VK web-login linking (`link.vk.*`): the server-side confidential OAuth 2.1 code exchange. A separate VK app from `GATEWAY_VK_APP_SECRET`; all three required to enable the ops | | `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) | | `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay | | `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) | diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index b108877..0bb975c 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -33,6 +33,7 @@ import ( "scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/session" "scrabble/gateway/internal/transcode" + "scrabble/gateway/internal/vkid" "scrabble/pkg/mtls" botlinkv1 "scrabble/pkg/proto/botlink/v1" telegramv1 "scrabble/pkg/proto/telegram/v1" @@ -190,10 +191,19 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)") } - registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret)) + // VK ID web login (browser VK-identity linking) is optional: build the confidential + // code-exchanger only when fully configured, else leave the interface nil so the + // link.vk.* ops stay unregistered. + var vkidExchanger transcode.VKIDExchanger + if cfg.VKID.Enabled() { + vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI) + logger.Info("vk id web login enabled") + } + registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger)) edge := connectsrv.NewServer(connectsrv.Deps{ Registry: registry, Sessions: sessions, + Backend: backend, Limiter: limiter, Tracker: tracker, Banlist: banlist, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 84ea8cd..2b4d019 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -37,6 +37,11 @@ type ProfileResp struct { // Banner is the advertising-banner block, present only for a viewer eligible to // see the banner. The gateway forwards it verbatim into the Profile payload. Banner *BannerResp `json:"banner,omitempty"` + // Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an + // attached platform identity — they drive the profile's link/unlink/change controls. + Email string `json:"email"` + TelegramLinked bool `json:"telegram_linked"` + VkLinked bool `json:"vk_linked"` } // BannerResp is the advertising-banner block of an eligible viewer's profile: the @@ -276,9 +281,9 @@ func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, // EmailRequest asks the backend to mail a login code, provisioning the account on // first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new // account's time zone, since the email account is created here, not at login. -func (c *Client) EmailRequest(ctx context.Context, email, browserTz string) error { +func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string) error { return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "", - map[string]string{"email": email, "browser_tz": browserTz}, nil) + map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil) } // EmailLogin verifies a login code and mints a session. @@ -289,6 +294,23 @@ func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionRes return out, err } +// ConfirmLinkResp is the backend's outcome of a one-tap deeplink confirmation: for a +// login Session carries the minted credential; for a link Status is "confirmed" or +// "merge_required". +type ConfirmLinkResp struct { + Purpose string `json:"purpose"` + Status string `json:"status"` + Session *SessionResp `json:"session,omitempty"` +} + +// EmailConfirmLink verifies a one-tap deeplink token; the token is the authorization. +func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLinkResp, error) { + var out ConfirmLinkResp + err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/confirm-link", "", "", + map[string]string{"token": token}, &out) + return out, err +} + // ResolveSession maps a token to its account id and guest flag (gateway // session-cache miss). The guest flag lets the edge gate guest-forbidden ops. func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) { @@ -483,6 +505,13 @@ func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []Pl return out, err } +// DictBytes fetches the raw serialized dictionary for a (variant, version) pair, +// backing the client-side local move preview. It returns the blob and the +// backend's Cache-Control header, forwarded so the immutable blob is cached hard. +func (c *Client) DictBytes(ctx context.Context, userID, variant, version string) ([]byte, string, error) { + return c.getRaw(ctx, "/api/v1/user/dict/"+url.PathEscape(variant)+"/"+url.PathEscape(version), userID, "Cache-Control") +} + // CheckWord looks a word up in the game's pinned dictionary. The word is carried as // repeated ?idx= alphabet indices; the backend echoes the decoded concrete word. func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) { diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 52c00f1..1b9596a 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/url" + "strings" ) // The response structs and client methods mirror the backend's social, @@ -334,6 +335,69 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin return out, err } +// LinkVK attaches a gateway-validated VK identity to the caller or reports a required +// merge. externalID is the trusted vk user id resolved from the VK ID code exchange. +func (c *Client) LinkVK(ctx context.Context, userID, externalID string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk", userID, "", + map[string]string{"external_id": externalID}, &out) + return out, err +} + +// LinkVKMerge merges the account owning a gateway-validated VK identity into the +// caller's. +func (c *Client) LinkVKMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk/merge", userID, "", + map[string]string{"external_id": externalID}, &out) + return out, err +} + +// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an +// authenticated email change. +func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/link/email/change/request", userID, "", + map[string]string{"email": email}, nil) +} + +// ChangeEmailConfirm verifies the code and atomically switches the account's email, +// returning the refreshed profile in the result. +func (c *Client) ChangeEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/change/confirm", userID, "", + map[string]string{"email": email, "code": code}, &out) + return out, err +} + +// DeleteRequestResp reports which account-deletion step-up the account uses. +type DeleteRequestResp struct { + Method string `json:"method"` +} + +// DeleteRequest starts account deletion: the backend mails a delete code (email accounts) +// or reports the typed-phrase path. +func (c *Client) DeleteRequest(ctx context.Context, userID string) (DeleteRequestResp, error) { + var out DeleteRequestResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/delete/request", userID, "", nil, &out) + return out, err +} + +// DeleteConfirm verifies the step-up (a mailed code or the typed phrase) and deletes the +// account. +func (c *Client) DeleteConfirm(ctx context.Context, userID, code, phrase string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/delete/confirm", userID, "", + map[string]string{"code": code, "phrase": phrase}, nil) +} + +// LinkUnlink detaches a platform identity (kind = "telegram" | "vk") from the caller +// and returns the refreshed profile in the result. +func (c *Client) LinkUnlink(ctx context.Context, userID, kind string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/unlink", userID, "", + map[string]string{"kind": kind}, &out) + return out, err +} + // Stats returns the caller's lifetime statistics. func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) { var out StatsResp @@ -347,3 +411,28 @@ func (c *Client) ExportGCG(ctx context.Context, userID, gameID string) (GcgResp, err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/gcg"), userID, "", nil, &out) return out, err } + +// ExportURLResp is the minted signed download link for an export artifact. +type ExportURLResp struct { + Path string `json:"path"` + Filename string `json:"filename"` +} + +// ExportURL mints a signed download URL for a finished game's artifact (kind +// "png" or "gcg"). dateLocale and the localized non-play labels ride the URL so +// the server render matches the player's presentation. +func (c *Client) ExportURL(ctx context.Context, userID, gameID, kind, dateLocale, timeZone string, labels []string) (ExportURLResp, error) { + q := url.Values{"kind": {kind}} + if dateLocale != "" { + q.Set("dl", dateLocale) + } + if timeZone != "" { + q.Set("tz", timeZone) + } + if len(labels) > 0 { + q.Set("t", strings.Join(labels, ",")) + } + var out ExportURLResp + err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/export-url")+"?"+q.Encode(), userID, "", nil, &out) + return out, err +} diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go index 50ccbb7..dd7c6b5 100644 --- a/gateway/internal/backendclient/client.go +++ b/gateway/internal/backendclient/client.go @@ -39,10 +39,18 @@ const backendMaxIdleConns = 512 type Client struct { baseURL string http *http.Client - conn *grpc.ClientConn - push pushv1.PushClient + // dl serves the export downloads: the backend may wait on the render sidecar + // for several seconds, so these calls get their own, longer deadline than the + // ordinary REST budget. + dl *http.Client + conn *grpc.ClientConn + push pushv1.PushClient } +// exportDownloadTimeout bounds one export-download fetch end to end (the backend's +// own sidecar budget is 15s). +const exportDownloadTimeout = 30 * time.Second + // New dials the backend push gRPC endpoint and prepares the REST client. The // backend lives on a trusted network segment, so the gRPC connection uses // insecure (plaintext) transport credentials (ARCHITECTURE.md §12). @@ -62,11 +70,39 @@ func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) { return &Client{ baseURL: strings.TrimRight(httpURL, "/"), http: &http.Client{Timeout: timeout, Transport: transport}, + dl: &http.Client{Timeout: exportDownloadTimeout, Transport: transport}, conn: conn, push: pushv1.NewPushClient(conn), }, nil } +// ExportDownload fetches a signed finished-game export artifact from the backend's +// public group, forwarding the caller's public Host for the image footer. It returns +// the bytes with the backend's Content-Type and Content-Disposition. rest is the +// public path suffix after /dl (e.g. "//?e=…&s=…"). +func (c *Client) ExportDownload(ctx context.Context, rest, publicHost string) ([]byte, string, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/public/dl"+rest, nil) + if err != nil { + return nil, "", "", fmt.Errorf("backendclient: new request: %w", err) + } + if publicHost != "" { + req.Header.Set("X-Public-Host", publicHost) + } + resp, err := c.dl.Do(req) + if err != nil { + return nil, "", "", fmt.Errorf("backendclient: GET export: %w", err) + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", "", fmt.Errorf("backendclient: read export: %w", err) + } + if resp.StatusCode >= http.StatusMultipleChoices { + return nil, "", "", parseAPIError(resp.StatusCode, data) + } + return data, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Disposition"), nil +} + // Close releases the gRPC connection. func (c *Client) Close() error { return c.conn.Close() } @@ -125,6 +161,34 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, return nil } +// getRaw performs one REST GET, returning the raw (un-decoded) response body and +// the value of respHeader (e.g. Cache-Control). userID, when non-empty, is +// forwarded as X-User-ID. A non-2xx response is returned as an *APIError. Unlike +// do it does not JSON-decode, so it carries a binary payload such as a dictionary +// blob. +func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([]byte, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, "", fmt.Errorf("backendclient: new request: %w", err) + } + if userID != "" { + req.Header.Set("X-User-ID", userID) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("backendclient: read response: %w", err) + } + if resp.StatusCode >= http.StatusMultipleChoices { + return nil, "", parseAPIError(resp.StatusCode, data) + } + return data, resp.Header.Get(respHeader), nil +} + // parseAPIError extracts the backend's {error:{code,message}} envelope. func parseAPIError(status int, data []byte) *APIError { var env struct { diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 98ea120..31fff16 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -36,6 +36,11 @@ type Config struct { // VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API // round-trip). Empty disables the VK auth path (auth.vk is then unregistered). VKAppSecret string + // VKID configures the VK ID web login used to link a VK identity from a browser + // (the confidential OAuth 2.1 code exchange against id.vk.com). It belongs to a + // separate VK "Web" app from VKAppSecret's Mini App, so its credentials are distinct. + // Any field empty disables the VK web-link ops (link.vk.*). + VKID VKIDConfig // BotLink configures the reverse mTLS channel to the remote Telegram bot. An // empty BotLink.Addr disables the bot channel (out-of-app push and admin relay). BotLink BotLinkConfig @@ -161,6 +166,22 @@ func DefaultAbuse() AbuseConfig { } } +// VKIDConfig holds the VK ID web-login credentials for the confidential +// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is +// its protected key; RedirectURI must exactly match the trusted redirect URL registered +// with the app and the one the frontend uses. All three are required to enable the flow. +type VKIDConfig struct { + AppID string + ClientSecret string + RedirectURI string +} + +// Enabled reports whether VK ID web login is fully configured. When false the gateway +// leaves the VK web-link ops (link.vk.*) unregistered. +func (c VKIDConfig) Enabled() bool { + return c.AppID != "" && c.ClientSecret != "" && c.RedirectURI != "" +} + // Load reads the configuration from the environment, applies defaults, and // validates the result. func Load() (Config, error) { @@ -174,6 +195,11 @@ func Load() (Config, error) { AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"), VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"), + VKID: VKIDConfig{ + AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"), + ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"), + RedirectURI: os.Getenv("GATEWAY_VK_ID_REDIRECT_URL"), + }, SessionCacheMax: defaultSessionCacheMax, RateLimit: DefaultRateLimit(), Abuse: DefaultAbuse(), diff --git a/gateway/internal/connectsrv/metrics.go b/gateway/internal/connectsrv/metrics.go index e74e4ac..9c619e7 100644 --- a/gateway/internal/connectsrv/metrics.go +++ b/gateway/internal/connectsrv/metrics.go @@ -28,6 +28,10 @@ type serverMetrics struct { rateLimited metric.Int64Counter banned metric.Int64Counter active *activeUsers + // Client-reported local move-preview adoption (see localEvalMetricsHandler). + localColdStart metric.Int64Counter + localDictLoad metric.Int64Counter + localPreview metric.Int64Counter } // newServerMetrics builds the instruments on meter (nil selects a no-op meter), @@ -54,7 +58,12 @@ func newServerMetrics(meter metric.Meter) *serverMetrics { if err != nil { b, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_abuse_banned_total") } - m := &serverMetrics{edge: h, rateLimited: c, banned: b, active: newActiveUsers()} + m := &serverMetrics{ + edge: h, rateLimited: c, banned: b, active: newActiveUsers(), + localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."), + localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."), + localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."), + } gauge, err := meter.Int64ObservableGauge("active_users", metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance).")) @@ -98,3 +107,40 @@ func (m *serverMetrics) recordRateLimited(ctx context.Context, class string) { func (m *serverMetrics) recordBan(ctx context.Context, reason string) { m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) } + +// localEvalReport is the client-reported local move-preview telemetry batch — deltas since +// the client's previous report. It backs the adoption dashboard: app cold starts vs cached +// dictionaries vs on-device previews. +type localEvalReport struct { + ColdStart int `json:"cold_start"` + DictFetched int `json:"dict_fetched"` + DictCacheHit int `json:"dict_cache_hit"` + DictMiss int `json:"dict_miss"` + PreviewLocal int `json:"preview_local"` + PreviewNetwork int `json:"preview_network"` +} + +// recordLocalEval folds one client report into the edge's local-eval counters. +func (m *serverMetrics) recordLocalEval(ctx context.Context, r localEvalReport) { + add := func(c metric.Int64Counter, n int, opts ...metric.AddOption) { + if n > 0 { + c.Add(ctx, int64(n), opts...) + } + } + add(m.localColdStart, r.ColdStart) + add(m.localDictLoad, r.DictFetched, metric.WithAttributes(attribute.String("result", "fetched"))) + add(m.localDictLoad, r.DictCacheHit, metric.WithAttributes(attribute.String("result", "cache_hit"))) + add(m.localDictLoad, r.DictMiss, metric.WithAttributes(attribute.String("result", "miss"))) + add(m.localPreview, r.PreviewLocal, metric.WithAttributes(attribute.String("path", "local"))) + add(m.localPreview, r.PreviewNetwork, metric.WithAttributes(attribute.String("path", "network"))) +} + +// counterOf builds an Int64Counter on meter, falling back to a no-op on the rare +// construction error so metrics never fail startup. +func counterOf(meter metric.Meter, name, desc string) metric.Int64Counter { + c, err := meter.Int64Counter(name, metric.WithDescription(desc)) + if err != nil { + c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter(name) + } + return c +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 1b38693..cfe93d3 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -7,9 +7,12 @@ package connectsrv import ( + "bytes" "context" "crypto/subtle" + "encoding/json" "errors" + "io" "net" "net/http" "strings" @@ -68,6 +71,7 @@ const ( type Server struct { registry *transcode.Registry sessions *session.Cache + backend *backendclient.Client limiter *ratelimit.Limiter tracker *ratelimit.Tracker banlist *ratelimit.Banlist @@ -91,7 +95,10 @@ type Server struct { type Deps struct { Registry *transcode.Registry Sessions *session.Cache - Limiter *ratelimit.Limiter + // Backend is the REST client backing the session-gated /dict blob route; a nil + // value disables that route (it 404s). + Backend *backendclient.Client + Limiter *ratelimit.Limiter // Tracker accumulates limiter rejections for the periodic report; nil // selects a private tracker (rejections are then only counted, never // reported). @@ -142,6 +149,7 @@ func NewServer(d Deps) *Server { return &Server{ registry: d.Registry, sessions: d.Sessions, + backend: d.Backend, limiter: limiter, tracker: tracker, banlist: banlist, @@ -183,6 +191,12 @@ func (s *Server) HTTPHandler() http.Handler { // does not serve the app shell at the operator path. mux.Handle("/_gm/", http.NotFoundHandler()) } + // The client-side local move preview pulls each game's pinned dictionary blob + // through this session-gated route (not public); see dictBytesHandler. + mux.Handle("/dict/", s.dictBytesHandler()) + mux.Handle("/dl/", s.exportDownloadHandler()) + // The client posts its local-move-preview adoption telemetry here (session-gated). + mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini // App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md // §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps @@ -397,6 +411,177 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler { }) } +// dictBytesHandler serves the raw dictionary blob for a (variant, version) pair to +// a signed-in client (the local move preview), proxying the backend's authed +// endpoint. It is session-gated — not public — bounds the download with the +// user-class limiter, and forwards the backend's immutable Cache-Control so the +// browser caches the blob hard. Only GET is allowed; the path is +// /dict/{variant}/{version}. +// exportDownloadHandler serves the signed finished-game export downloads (/dl/*). +// It is the gateway's only unauthenticated data route: the platforms' native +// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain anchor) +// carry no session, so the URL's HMAC — verified by the backend — is the whole +// grant. The gateway only rate-limits by IP and forwards, passing the public Host +// along for the image footer. +func (s *Server) exportDownloadHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("public:"+ip, s.publicPolicy) { + s.noteRateLimited(r.Context(), classPublic, ip, "export-dl") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + rest := strings.TrimPrefix(r.URL.Path, "/dl") + if rest == "" || rest == "/" { + http.NotFound(w, r) + return + } + if r.URL.RawQuery != "" { + rest += "?" + r.URL.RawQuery + } + data, contentType, disposition, err := s.backend.ExportDownload(r.Context(), rest, r.Host) + if err != nil { + var apiErr *backendclient.APIError + if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError { + http.NotFound(w, r) // invalid, expired or unknown link + return + } + s.log.Warn("export download failed", zap.Error(err)) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + if disposition != "" { + w.Header().Set("Content-Disposition", disposition) + } + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, max-age=0") + // ServeContent (not a bare Write): the platforms' native downloaders are picky — + // Android's system DownloadManager (the VKWebAppDownloadFile executor) hangs on a + // chunked body of unknown length and may probe with Range. ServeContent emits + // Content-Length, honours Range/If-* and answers 206s from the buffered artifact. + http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) + }) +} + +func (s *Server) dictBytesHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("user:"+ip, s.userPolicy) { + s.noteRateLimited(r.Context(), classUser, ip, "dict") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + uid, _, err := s.resolve(r.Context(), r.Header, ip) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + variant, version, ok := parseDictPath(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + data, cacheControl, err := s.backend.DictBytes(r.Context(), uid, variant, version) + if err != nil { + var apiErr *backendclient.APIError + if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError { + http.NotFound(w, r) // unknown variant or version + return + } + s.log.Warn("dict fetch failed", zap.Error(err)) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + if cacheControl != "" { + w.Header().Set("Cache-Control", cacheControl) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(data) + }) +} + +// parseDictPath splits /dict/{variant}/{version}; both segments must be present +// and non-empty, and the version must not contain a further slash. +func parseDictPath(p string) (variant, version string, ok bool) { + rest, found := strings.CutPrefix(p, "/dict/") + if !found { + return "", "", false + } + i := strings.IndexByte(rest, '/') + if i <= 0 || i == len(rest)-1 { + return "", "", false + } + variant, version = rest[:i], rest[i+1:] + if strings.Contains(version, "/") { + return "", "", false + } + return variant, version, true +} + +// localEvalMetricsHandler ingests a client's local move-preview telemetry batch into the +// edge's adoption counters (docs/ARCHITECTURE.md §11). Session-gated so only real clients +// report; the values are aggregate (no per-user attributes) and clamped against a spoofed +// inflation. Only POST; the body is a small JSON of per-metric deltas. +func (s *Server) localEvalMetricsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var rep localEvalReport + if err := json.NewDecoder(io.LimitReader(r.Body, 512)).Decode(&rep); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + clampReport(&rep) + s.metrics.recordLocalEval(r.Context(), rep) + w.WriteHeader(http.StatusNoContent) + }) +} + +// clampReport bounds each counter of a client report against a spoofed inflation — one batch +// reflects at most a few minutes of a single client's activity. +func clampReport(r *localEvalReport) { + const maxPerField = 1000 + clamp := func(n *int) { + if *n < 0 { + *n = 0 + } else if *n > maxPerField { + *n = maxPerField + } + } + clamp(&r.ColdStart) + clamp(&r.DictFetched) + clamp(&r.DictCacheHit) + clamp(&r.DictMiss) + clamp(&r.PreviewLocal) + clamp(&r.PreviewNetwork) +} + // resolve extracts and resolves the Authorization bearer token to an account id // and its guest flag, returning a Connect Unauthenticated error when it is missing // or unknown. diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 7ef473f..afc5434 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -37,6 +37,46 @@ func encodeAck(ok bool) []byte { return b.FinishedBytes() } +// encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which +// deletion step-up the account uses ("email" | "phrase"). +func encodeDeleteRequestResult(method string) []byte { + b := flatbuffers.NewBuilder(32) + m := b.CreateString(method) + fb.AccountDeleteRequestResultStart(b) + fb.AccountDeleteRequestResultAddMethod(b, m) + b.Finish(fb.AccountDeleteRequestResultEnd(b)) + return b.FinishedBytes() +} + +// encodeConfirmLinkResult builds an EmailConfirmLinkResult payload, embedding the +// minted Session for a login. All strings and the nested Session table are built +// before the result table is opened. +func encodeConfirmLinkResult(r backendclient.ConfirmLinkResp) []byte { + b := flatbuffers.NewBuilder(160) + purpose := b.CreateString(r.Purpose) + status := b.CreateString(r.Status) + var session flatbuffers.UOffsetT + if r.Session != nil { + token := b.CreateString(r.Session.Token) + uid := b.CreateString(r.Session.UserID) + name := b.CreateString(r.Session.DisplayName) + fb.SessionStart(b) + fb.SessionAddToken(b, token) + fb.SessionAddUserId(b, uid) + fb.SessionAddIsGuest(b, r.Session.IsGuest) + fb.SessionAddDisplayName(b, name) + session = fb.SessionEnd(b) + } + fb.EmailConfirmLinkResultStart(b) + fb.EmailConfirmLinkResultAddPurpose(b, purpose) + fb.EmailConfirmLinkResultAddStatus(b, status) + if r.Session != nil { + fb.EmailConfirmLinkResultAddSession(b, session) + } + b.Finish(fb.EmailConfirmLinkResultEnd(b)) + return b.FinishedBytes() +} + // encodeProfile builds a Profile payload, including the advertising-banner block // when the backend marked the viewer eligible. func encodeProfile(p backendclient.ProfileResp) []byte { @@ -47,6 +87,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte { tz := b.CreateString(p.TimeZone) awayStart := b.CreateString(p.AwayStart) awayEnd := b.CreateString(p.AwayEnd) + email := b.CreateString(p.Email) // Build the banner table (and its children) before opening Profile: FlatBuffers // forbids a nested table while another is under construction. var banner flatbuffers.UOffsetT @@ -67,6 +108,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte { fb.ProfileAddAwayEnd(b, awayEnd) fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly) fb.ProfileAddVariantPreferences(b, prefs) + fb.ProfileAddEmail(b, email) + fb.ProfileAddTelegramLinked(b, p.TelegramLinked) + fb.ProfileAddVkLinked(b, p.VkLinked) if p.Banner != nil { fb.ProfileAddBanner(b, banner) } diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index 9a136fb..def51e8 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -251,6 +251,18 @@ func encodeInvitationList(r backendclient.InvitationListResp) []byte { return b.FinishedBytes() } +// encodeExportURL builds an ExportUrl payload. +func encodeExportURL(r backendclient.ExportURLResp) []byte { + b := flatbuffers.NewBuilder(256) + path := b.CreateString(r.Path) + fn := b.CreateString(r.Filename) + fb.ExportUrlStart(b) + fb.ExportUrlAddPath(b, path) + fb.ExportUrlAddFilename(b, fn) + b.Finish(fb.ExportUrlEnd(b)) + return b.FinishedBytes() +} + // encodeGcg builds a GcgExport payload. func encodeGcg(r backendclient.GcgResp) []byte { b := flatbuffers.NewBuilder(1024) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 26c775f..c876964 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -14,42 +14,44 @@ import ( "scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/connector" "scrabble/gateway/internal/vkauth" + "scrabble/gateway/internal/vkid" fb "scrabble/pkg/fbs/scrabblefb" ) // Message types in the vertical slice. const ( - MsgAuthTelegram = "auth.telegram" - MsgAuthVK = "auth.vk" - MsgAuthGuest = "auth.guest" - MsgAuthEmailReq = "auth.email.request" - MsgAuthEmailLogin = "auth.email.login" - MsgProfileGet = "profile.get" - MsgBlockStatus = "account.block_status" - MsgGameSubmitPlay = "game.submit_play" - MsgGameState = "game.state" - MsgLobbyEnqueue = "lobby.enqueue" - MsgLobbyCancel = "lobby.cancel" - MsgLobbyPoll = "lobby.poll" - MsgChatPost = "chat.post" - MsgGamesList = "games.list" - MsgGamePass = "game.pass" - MsgGameExchange = "game.exchange" - MsgGameResign = "game.resign" - MsgGameHint = "game.hint" - MsgGameEvaluate = "game.evaluate" - MsgGameCheckWord = "game.check_word" - MsgGameComplaint = "game.complaint" - MsgGameHistory = "game.history" - MsgChatList = "chat.list" - MsgChatNudge = "chat.nudge" - MsgChatRead = "chat.read" - MsgDraftGet = "draft.get" - MsgDraftSave = "draft.save" - MsgGameHide = "game.hide" - MsgFeedbackSubmit = "feedback.submit" - MsgFeedbackGet = "feedback.get" - MsgFeedbackUnread = "feedback.unread" + MsgAuthTelegram = "auth.telegram" + MsgAuthVK = "auth.vk" + MsgAuthGuest = "auth.guest" + MsgAuthEmailReq = "auth.email.request" + MsgAuthEmailLogin = "auth.email.login" + MsgAuthEmailConfirmLink = "auth.email.confirm_link" + MsgProfileGet = "profile.get" + MsgBlockStatus = "account.block_status" + MsgGameSubmitPlay = "game.submit_play" + MsgGameState = "game.state" + MsgLobbyEnqueue = "lobby.enqueue" + MsgLobbyCancel = "lobby.cancel" + MsgLobbyPoll = "lobby.poll" + MsgChatPost = "chat.post" + MsgGamesList = "games.list" + MsgGamePass = "game.pass" + MsgGameExchange = "game.exchange" + MsgGameResign = "game.resign" + MsgGameHint = "game.hint" + MsgGameEvaluate = "game.evaluate" + MsgGameCheckWord = "game.check_word" + MsgGameComplaint = "game.complaint" + MsgGameHistory = "game.history" + MsgChatList = "chat.list" + MsgChatNudge = "chat.nudge" + MsgChatRead = "chat.read" + MsgDraftGet = "draft.get" + MsgDraftSave = "draft.save" + MsgGameHide = "game.hide" + MsgFeedbackSubmit = "feedback.submit" + MsgFeedbackGet = "feedback.get" + MsgFeedbackUnread = "feedback.unread" ) // Request is one decoded Execute call. @@ -101,6 +103,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend)} r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true} r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true} + r.ops[MsgAuthEmailConfirmLink] = Op{Handler: authEmailConfirmLinkHandler(backend)} r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true} r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} @@ -150,6 +153,15 @@ func WithVKAuth(secret string) Option { } } +// WithVKLink registers the VK web-link ops (link.vk.confirm/merge), which complete a +// browser VK ID authorization via the server-side code exchange. A nil exchanger leaves +// them unregistered, so the ops are unknown wherever VK ID web login is not configured. +func WithVKLink(ex VKIDExchanger) Option { + return func(r *Registry, backend *backendclient.Client) { + registerVKLinkOps(r, backend, ex) + } +} + // Lookup returns the operation for messageType, and whether it is registered. func (r *Registry) Lookup(messageType string) (Op, bool) { op, ok := r.ops[messageType] @@ -170,6 +182,9 @@ func DomainCode(err error) (string, bool) { if errors.Is(err, vkauth.ErrInvalid) { return "invalid_vk_params", true } + if errors.Is(err, vkid.ErrInvalid) { + return "invalid_vk_id", true + } if errors.Is(err, connector.ErrInvalidLoginWidget) { return "invalid_login_widget", true } @@ -238,7 +253,7 @@ func authGuestHandler(backend *backendclient.Client) Handler { func authEmailRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEmailRequestRequest(req.Payload, 0) - if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz())); err != nil { + if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language())); err != nil { return nil, err } return encodeAck(true), nil @@ -256,6 +271,17 @@ func authEmailLoginHandler(backend *backendclient.Client) Handler { } } +func authEmailConfirmLinkHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsEmailConfirmLinkRequest(req.Payload, 0) + res, err := backend.EmailConfirmLink(ctx, string(in.Token())) + if err != nil { + return nil, err + } + return encodeConfirmLinkResult(res), nil + } +} + func profileHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { p, err := backend.Profile(ctx, req.UserID) diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go index 546c279..1401080 100644 --- a/gateway/internal/transcode/transcode_link.go +++ b/gateway/internal/transcode/transcode_link.go @@ -4,6 +4,7 @@ import ( "context" "scrabble/gateway/internal/backendclient" + "scrabble/gateway/internal/vkid" fb "scrabble/pkg/fbs/scrabblefb" ) @@ -13,11 +14,18 @@ import ( // authenticated. The merge ops are the explicit irreversible step, gated in the UI // after a merge_required confirm. const ( - MsgLinkEmailRequest = "link.email.request" - MsgLinkEmailConfirm = "link.email.confirm" - MsgLinkEmailMerge = "link.email.merge" - MsgLinkTelegram = "link.telegram.confirm" - MsgLinkTelegramMerge = "link.telegram.merge" + MsgLinkEmailRequest = "link.email.request" + MsgLinkEmailConfirm = "link.email.confirm" + MsgLinkEmailMerge = "link.email.merge" + MsgLinkTelegram = "link.telegram.confirm" + MsgLinkTelegramMerge = "link.telegram.merge" + MsgLinkVK = "link.vk.confirm" + MsgLinkVKMerge = "link.vk.merge" + MsgLinkUnlink = "link.unlink" + MsgEmailChangeRequest = "link.email.change.request" + MsgEmailChangeConfirm = "link.email.change.confirm" + MsgAccountDeleteReq = "account.delete.request" + MsgAccountDeleteConf = "account.delete.confirm" ) // registerLinkOps adds the linking & merge operations. The telegram ops need the @@ -26,6 +34,11 @@ func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramVali r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true} r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true} r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true} + r.ops[MsgLinkUnlink] = Op{Handler: linkUnlinkHandler(backend), Auth: true} + r.ops[MsgEmailChangeRequest] = Op{Handler: changeEmailRequestHandler(backend), Auth: true, Email: true} + r.ops[MsgEmailChangeConfirm] = Op{Handler: changeEmailConfirmHandler(backend), Auth: true, Email: true} + r.ops[MsgAccountDeleteReq] = Op{Handler: deleteRequestHandler(backend), Auth: true, Email: true} + r.ops[MsgAccountDeleteConf] = Op{Handler: deleteConfirmHandler(backend), Auth: true} if tg != nil { r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true} r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true} @@ -64,6 +77,65 @@ func linkEmailMergeHandler(backend *backendclient.Client) Handler { } } +// changeEmailRequestHandler mails a confirm-code to a new address for an email change. +func changeEmailRequestHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkEmailRequest(req.Payload, 0) + if err := backend.ChangeEmailRequest(ctx, req.UserID, string(in.Email())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +// changeEmailConfirmHandler verifies the code and switches the account's email, returning +// the refreshed link result (status "changed" + the updated profile). +func changeEmailConfirmHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0) + res, err := backend.ChangeEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code())) + if err != nil { + return nil, err + } + return encodeLinkResult(res), nil + } +} + +// deleteRequestHandler starts account deletion, returning which step-up the account uses. +func deleteRequestHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.DeleteRequest(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeDeleteRequestResult(res.Method), nil + } +} + +// deleteConfirmHandler verifies the step-up proof and deletes the account. +func deleteConfirmHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsAccountDeleteConfirm(req.Payload, 0) + if err := backend.DeleteConfirm(ctx, req.UserID, string(in.Code()), string(in.Phrase())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +// linkUnlinkHandler detaches a platform identity (telegram|vk) from the caller and +// returns the refreshed link result (status "unlinked" + the updated profile). +func linkUnlinkHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkUnlinkRequest(req.Payload, 0) + res, err := backend.LinkUnlink(ctx, req.UserID, string(in.Kind())) + if err != nil { + return nil, err + } + return encodeLinkResult(res), nil + } +} + // linkTelegramHandler validates Login Widget data via the connector and then calls // the backend's link or merge endpoint with the trusted Telegram external id. func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool) Handler { @@ -85,3 +157,44 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me return encodeLinkResult(res), nil } } + +// VKIDExchanger completes a VK ID web authorization-code exchange, returning the +// launching user's trusted VK identity. It is satisfied by *vkid.Exchanger and lets the +// VK web-link ops resolve a browser VK login, which — unlike the Mini App path — has no +// offline launch signature to verify. +type VKIDExchanger interface { + Exchange(ctx context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error) +} + +// registerVKLinkOps adds the VK web-link ops when a VK ID exchanger is configured; a nil +// exchanger leaves them unregistered (VK ID web login not configured). +func registerVKLinkOps(r *Registry, backend *backendclient.Client, ex VKIDExchanger) { + if ex == nil { + return + } + r.ops[MsgLinkVK] = Op{Handler: linkVKHandler(backend, ex, false), Auth: true} + r.ops[MsgLinkVKMerge] = Op{Handler: linkVKHandler(backend, ex, true), Auth: true} +} + +// linkVKHandler completes the VK ID code exchange (server-side, under the app's +// protected key) and then calls the backend's link or merge endpoint with the trusted +// VK external id. +func linkVKHandler(backend *backendclient.Client, ex VKIDExchanger, merge bool) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkVKRequest(req.Payload, 0) + user, err := ex.Exchange(ctx, string(in.Code()), string(in.DeviceId()), string(in.CodeVerifier())) + if err != nil { + return nil, err + } + var res backendclient.LinkResultResp + if merge { + res, err = backend.LinkVKMerge(ctx, req.UserID, user.ExternalID) + } else { + res, err = backend.LinkVK(ctx, req.UserID, user.ExternalID) + } + if err != nil { + return nil, err + } + return encodeLinkResult(res), nil + } +} diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index e0125cd..672e72e 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -31,6 +31,7 @@ const ( MsgProfileUpdate = "profile.update" MsgStatsGet = "stats.get" MsgGameGCG = "game.gcg" + MsgGameExportURL = "game.export_url" ) // registerSocialOps adds the social, account and history operations to the @@ -56,6 +57,7 @@ func registerSocialOps(r *Registry, backend *backendclient.Client) { r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true} r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true} r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true} + r.ops[MsgGameExportURL] = Op{Handler: exportURLHandler(backend), Auth: true} } // --- friends --- @@ -290,6 +292,21 @@ func gcgHandler(backend *backendclient.Client) Handler { } } +func exportURLHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsExportUrlRequest(req.Payload, 0) + labels := make([]string, 0, in.ActionLabelsLength()) + for i := range in.ActionLabelsLength() { + labels = append(labels, string(in.ActionLabels(i))) + } + res, err := backend.ExportURL(ctx, req.UserID, string(in.GameId()), string(in.Kind()), string(in.DateLocale()), string(in.TimeZone()), labels) + if err != nil { + return nil, err + } + return encodeExportURL(res), nil + } +} + // decodeInviteeIDs reads the invitee id vector from a CreateInvitationRequest. func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string { n := in.InviteeIdsLength() diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go index 96206a1..f79136f 100644 --- a/gateway/internal/transcode/transcode_social_test.go +++ b/gateway/internal/transcode/transcode_social_test.go @@ -263,6 +263,54 @@ func TestGcgRoundTrip(t *testing.T) { } } +func TestExportURLRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/games/g-1/export-url" { + t.Errorf("unexpected path %q", r.URL.Path) + } + q := r.URL.Query() + if q.Get("kind") != "png" || q.Get("dl") != "ru-RU" || q.Get("tz") != "Europe/Moscow" || q.Get("t") != "пас,обмен,сдался,таймаут" { + t.Errorf("unexpected query %q", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"path":"/dl/g-1/png?e=1&s=x","filename":"game-g-1.png"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGameExportURL) + + b := flatbuffers.NewBuilder(256) + gid := b.CreateString("g-1") + kind := b.CreateString("png") + dl := b.CreateString("ru-RU") + tz := b.CreateString("Europe/Moscow") + labels := make([]flatbuffers.UOffsetT, 0, 4) + for _, s := range []string{"пас", "обмен", "сдался", "таймаут"} { + labels = append(labels, b.CreateString(s)) + } + fb.ExportUrlRequestStartActionLabelsVector(b, len(labels)) + for i := len(labels) - 1; i >= 0; i-- { + b.PrependUOffsetT(labels[i]) + } + vec := b.EndVector(len(labels)) + fb.ExportUrlRequestStart(b) + fb.ExportUrlRequestAddGameId(b, gid) + fb.ExportUrlRequestAddKind(b, kind) + fb.ExportUrlRequestAddDateLocale(b, dl) + fb.ExportUrlRequestAddActionLabels(b, vec) + fb.ExportUrlRequestAddTimeZone(b, tz) + b.Finish(fb.ExportUrlRequestEnd(b)) + + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()}) + if err != nil { + t.Fatalf("handler: %v", err) + } + res := fb.GetRootAsExportUrl(payload, 0) + if string(res.Path()) != "/dl/g-1/png?e=1&s=x" || string(res.Filename()) != "game-g-1.png" { + t.Fatalf("export url decoded wrong: path=%q filename=%q", res.Path(), res.Filename()) + } +} + func TestProfileUpdateRoundTripAway(t *testing.T) { var gotBody map[string]any backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/gateway/internal/transcode/transcode_vk_link_test.go b/gateway/internal/transcode/transcode_vk_link_test.go new file mode 100644 index 0000000..e955237 --- /dev/null +++ b/gateway/internal/transcode/transcode_vk_link_test.go @@ -0,0 +1,90 @@ +package transcode_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/gateway/internal/transcode" + "scrabble/gateway/internal/vkid" + fb "scrabble/pkg/fbs/scrabblefb" +) + +func linkVKPayload(code, deviceID, verifier string) []byte { + b := flatbuffers.NewBuilder(64) + c := b.CreateString(code) + d := b.CreateString(deviceID) + v := b.CreateString(verifier) + fb.LinkVKRequestStart(b) + fb.LinkVKRequestAddCode(b, c) + fb.LinkVKRequestAddDeviceId(b, d) + fb.LinkVKRequestAddCodeVerifier(b, v) + b.Finish(fb.LinkVKRequestEnd(b)) + return b.FinishedBytes() +} + +// fakeVKIDExchanger records the exchange inputs and returns a canned identity. +type fakeVKIDExchanger struct { + id vkid.Identity + err error + gotCode, gotDevice, gotVerifier string +} + +func (f *fakeVKIDExchanger) Exchange(_ context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error) { + f.gotCode, f.gotDevice, f.gotVerifier = code, deviceID, codeVerifier + return f.id, f.err +} + +func TestLinkVKExchangesAndForwards(t *testing.T) { + var gotExternalID string + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/link/vk" { + t.Errorf("path = %q", r.URL.Path) + } + var body struct { + ExternalID string `json:"external_id"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + gotExternalID = body.ExternalID + _, _ = w.Write([]byte(`{"status":"linked"}`)) + }) + defer cleanup() + + ex := &fakeVKIDExchanger{id: vkid.Identity{ExternalID: "777"}} + reg := transcode.NewRegistry(backend, nil, transcode.WithVKLink(ex)) + op, ok := reg.Lookup(transcode.MsgLinkVK) + if !ok { + t.Fatal("link.vk.confirm not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkVKPayload("the-code", "dev-1", "verifier-1")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + // The PKCE inputs from the wire must reach the exchanger verbatim... + if ex.gotCode != "the-code" || ex.gotDevice != "dev-1" || ex.gotVerifier != "verifier-1" { + t.Errorf("exchange got %q/%q/%q", ex.gotCode, ex.gotDevice, ex.gotVerifier) + } + // ...and the resolved vk id must be the one forwarded to the backend. + if gotExternalID != "777" { + t.Errorf("backend external_id = %q, want 777", gotExternalID) + } + if string(fb.GetRootAsLinkResult(payload, 0).Status()) != "linked" { + t.Error("expected a linked result") + } +} + +func TestLinkVKUnregisteredWithoutExchanger(t *testing.T) { + backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {}) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + if _, ok := reg.Lookup(transcode.MsgLinkVK); ok { + t.Error("link.vk.confirm must be unregistered when no VK ID exchanger is configured") + } + if _, ok := reg.Lookup(transcode.MsgLinkVKMerge); ok { + t.Error("link.vk.merge must be unregistered when no VK ID exchanger is configured") + } +} diff --git a/gateway/internal/vkid/vkid.go b/gateway/internal/vkid/vkid.go new file mode 100644 index 0000000..532ac76 --- /dev/null +++ b/gateway/internal/vkid/vkid.go @@ -0,0 +1,163 @@ +// Package vkid completes VK ID web authorization on the server. A browser linking a +// VK identity has no signed Mini App launch parameters (that offline HMAC path is +// vkauth); instead the frontend runs the VK ID raw OAuth 2.1 flow (PKCE) and hands the +// gateway the authorization code, which the gateway exchanges — confidentially, under +// the app's protected key — for the launching user's trusted VK id. Unlike the Mini App +// path this makes an outbound call to VK (there is no offline verification for the web +// flow). See docs/ARCHITECTURE.md §12. +package vkid + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + // tokenEndpoint is VK ID's OAuth 2.1 token endpoint. The frontend obtains the + // authorization code against the same host, so the confidential exchange targets it + // too. It is a fixed constant (not user input), so the outbound request carries no + // SSRF risk. + tokenEndpoint = "https://id.vk.com/oauth2/auth" + // exchangeTimeout bounds one code-for-token exchange. Linking is interactive, so an + // unreachable VK must fail fast rather than hold the request open. + exchangeTimeout = 10 * time.Second + // maxResponseBytes caps the token-response read to bound memory on an oversized body. + maxResponseBytes = 1 << 16 +) + +// ErrInvalid reports an authorization fault: the exchange was rejected or yielded no vk +// user id (a bad or expired code, a mismatched verifier or redirect). It is distinct +// from a transport failure reaching VK, which surfaces as a wrapped error. +var ErrInvalid = errors.New("vkid: vk id authorization exchange failed") + +// Identity is the user resolved from a completed VK ID exchange. ExternalID is the vk +// user id, used as the identities external_id. +type Identity struct { + ExternalID string +} + +// numericID accepts a VK user id that the token endpoint returns inconsistently as a +// JSON string or a JSON number, normalising both to their decimal string form (empty +// for a null or absent field). +type numericID string + +func (n *numericID) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), `"`) + if s == "null" { + s = "" + } + *n = numericID(s) + return nil +} + +// Exchanger completes VK ID confidential authorization-code exchanges for one app. +type Exchanger struct { + appID string + clientSecret string + redirectURI string + endpoint string + httpClient *http.Client +} + +// New constructs an Exchanger for the app credentials. redirectURI must equal the +// trusted redirect URL registered with the app and the one the frontend used, or VK +// rejects the exchange. +func New(appID, clientSecret, redirectURI string) *Exchanger { + return &Exchanger{ + appID: appID, + clientSecret: clientSecret, + redirectURI: redirectURI, + endpoint: tokenEndpoint, + httpClient: &http.Client{Timeout: exchangeTimeout}, + } +} + +// Exchange completes the authorization-code grant and returns the trusted vk user id. +// code, deviceID and codeVerifier are the PKCE inputs the frontend obtained from the VK +// ID authorization redirect; the exchange authenticates with the app's protected key. +func (e *Exchanger) Exchange(ctx context.Context, code, deviceID, codeVerifier string) (Identity, error) { + if code == "" || deviceID == "" || codeVerifier == "" { + return Identity{}, ErrInvalid + } + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "code_verifier": {codeVerifier}, + "device_id": {deviceID}, + "client_id": {e.appID}, + "client_secret": {e.clientSecret}, + "redirect_uri": {e.redirectURI}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, strings.NewReader(form.Encode())) + if err != nil { + return Identity{}, fmt.Errorf("vkid: build exchange request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := e.httpClient.Do(req) + if err != nil { + return Identity{}, fmt.Errorf("vkid: exchange request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return Identity{}, fmt.Errorf("vkid: read exchange response: %w", err) + } + if resp.StatusCode != http.StatusOK { + // VK answers 400 with {error, error_description} on a bad/expired code or a + // verifier/redirect mismatch — an authorization fault, not a transport error. + return Identity{}, ErrInvalid + } + var out struct { + UserID numericID `json:"user_id"` + IDToken string `json:"id_token"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &out); err != nil { + return Identity{}, ErrInvalid + } + if out.Error != "" { + return Identity{}, ErrInvalid + } + uid := string(out.UserID) + if uid == "" || uid == "0" { + uid = subjectFromIDToken(out.IDToken) + } + if uid == "" { + return Identity{}, ErrInvalid + } + return Identity{ExternalID: uid}, nil +} + +// subjectFromIDToken extracts the OIDC subject (the vk user id) from the payload of a +// VK ID id_token. The token arrives inside a direct TLS response from VK, so its +// signature is not re-verified here; the claim is only a fallback for a response that +// omits an explicit user_id. +func subjectFromIDToken(idToken string) string { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + var claims struct { + Sub numericID `json:"sub"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + if s := string(claims.Sub); s != "0" { + return s + } + return "" +} diff --git a/gateway/internal/vkid/vkid_test.go b/gateway/internal/vkid/vkid_test.go new file mode 100644 index 0000000..08dc29a --- /dev/null +++ b/gateway/internal/vkid/vkid_test.go @@ -0,0 +1,131 @@ +package vkid + +import ( + "context" + "encoding/base64" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// testExchanger builds an Exchanger pointed at a test server. +func testExchanger(endpoint string) *Exchanger { + return &Exchanger{ + appID: "app-1", + clientSecret: "secret-1", + redirectURI: "https://example.test/app/", + endpoint: endpoint, + httpClient: &http.Client{}, + } +} + +func TestExchangeSuccessSendsConfidentialPKCE(t *testing.T) { + var gotForm url.Values + var gotContentType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotContentType = r.Header.Get("Content-Type") + body, _ := io.ReadAll(r.Body) + gotForm, _ = url.ParseQuery(string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"user_id":"12345","access_token":"a","id_token":"h.e.s"}`) + })) + defer srv.Close() + + id, err := testExchanger(srv.URL).Exchange(context.Background(), "the-code", "dev-9", "verifier-xyz") + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if id.ExternalID != "12345" { + t.Fatalf("ExternalID = %q, want 12345", id.ExternalID) + } + if gotContentType != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q", gotContentType) + } + // The confidential exchange must carry the PKCE inputs and the app credentials. + want := map[string]string{ + "grant_type": "authorization_code", + "code": "the-code", + "code_verifier": "verifier-xyz", + "device_id": "dev-9", + "client_id": "app-1", + "client_secret": "secret-1", + "redirect_uri": "https://example.test/app/", + } + for k, v := range want { + if gotForm.Get(k) != v { + t.Errorf("form[%s] = %q, want %q", k, gotForm.Get(k), v) + } + } +} + +func TestExchangeAcceptsNumericUserID(t *testing.T) { + // VK returns user_id as a bare JSON number in some responses; it must parse too. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"user_id":1234567890,"access_token":"a"}`) + })) + defer srv.Close() + + id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v") + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if id.ExternalID != "1234567890" { + t.Fatalf("ExternalID = %q, want 1234567890", id.ExternalID) + } +} + +func TestExchangeFallsBackToIDTokenSub(t *testing.T) { + sub := "987654321" + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"` + sub + `"}`)) + idToken := "header." + payload + ".sig" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"access_token":"a","id_token":"`+idToken+`"}`) + })) + defer srv.Close() + + id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v") + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if id.ExternalID != sub { + t.Fatalf("ExternalID = %q, want %q (from id_token sub)", id.ExternalID, sub) + } +} + +func TestExchangeRejectedIsErrInvalid(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":"invalid_grant","error_description":"code expired"}`) + })) + defer srv.Close() + + _, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v") + if !errors.Is(err, ErrInvalid) { + t.Fatalf("err = %v, want ErrInvalid", err) + } +} + +func TestExchangeEmptyInputsFailFast(t *testing.T) { + // Missing PKCE inputs are rejected without any network call. + ex := testExchanger("http://127.0.0.1:0/never") + for _, args := range [][3]string{{"", "d", "v"}, {"c", "", "v"}, {"c", "d", ""}} { + if _, err := ex.Exchange(context.Background(), args[0], args[1], args[2]); !errors.Is(err, ErrInvalid) { + t.Errorf("Exchange%v err = %v, want ErrInvalid", args, err) + } + } +} + +func TestExchangeTransportErrorIsNotErrInvalid(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + srv.Close() // closed listener → connection refused + _, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v") + if err == nil { + t.Fatal("want a transport error") + } + if errors.Is(err, ErrInvalid) { + t.Fatalf("transport error must not be ErrInvalid: %v", err) + } +} diff --git a/go.work.sum b/go.work.sum index 3d54e67..d1da155 100644 --- a/go.work.sum +++ b/go.work.sum @@ -146,6 +146,8 @@ github.com/volatiletech/randomize v0.0.1 h1:eE5yajattWqTB2/eN8df4dw+8jwAzBtbdo5s github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY= github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk= github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg= +github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0= +github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= @@ -176,15 +178,18 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index a23da6a..570b98c 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -132,10 +132,12 @@ table GuestLoginRequest { // EmailRequestRequest asks the backend to send a login confirm-code to email. It // also provisions the account on first contact, so browser_tz (the detected UTC -// offset) is seeded into its time zone here, not at the later login step. +// offset) is seeded into its time zone here, not at the later login step; language +// (the client's UI language) seeds the new account's language and localises the email. table EmailRequestRequest { email:string; browser_tz:string; + language:string; } // EmailLoginRequest logs in to the account owning email (provisioned at the @@ -145,6 +147,21 @@ table EmailLoginRequest { code:string; } +// EmailConfirmLinkRequest verifies a one-tap deeplink token from a confirmation +// email. The token, not a session, is the authorization. +table EmailConfirmLinkRequest { + token:string; +} + +// EmailConfirmLinkResult is the outcome of a deeplink confirmation: purpose is +// "login" or "link"; for a login session carries the minted credential; for a link +// status is "confirmed" or "merge_required" (the app completes the merge). +table EmailConfirmLinkResult { + purpose:string; + status:string; + session:Session; +} + // Session is the minted credential returned by every auth operation. table Session { token:string; @@ -207,6 +224,12 @@ table Profile { // variant_preferences is the set of game variants the player allows themselves to be // matched into (engine.Variant labels), Erudit-first; the New Game picker is gated by it. variant_preferences:[string]; + // email is the account's confirmed email address ("" when none); telegram_linked and + // vk_linked report whether a platform identity is attached. They drive the profile's + // link / unlink / change-email controls (all added trailing — backward-compatible). + email:string; + telegram_linked:bool; + vk_linked:bool; } // BlockStatus reports the caller's current manual block. The UI fetches it after any operation @@ -478,6 +501,36 @@ table LinkTelegramRequest { data:string; } +// LinkVKRequest carries a VK ID web authorization for attaching a VK identity to the +// current account. The fields are the PKCE code-exchange inputs the frontend obtains +// from the VK ID SDK (One Tap): the authorization code, the device id issued with it, +// and the PKCE code verifier. The gateway completes the confidential code exchange +// server-side (under the app's protected key) to obtain the trusted vk user id. +table LinkVKRequest { + code:string; + device_id:string; + code_verifier:string; +} + +// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the +// caller's account; email is never unlinked (it is changed). +table LinkUnlinkRequest { + kind:string; +} + +// AccountDeleteConfirm carries the account-deletion step-up proof: a mailed code (email +// accounts) or the typed phrase (platform-only accounts). +table AccountDeleteConfirm { + code:string; + phrase:string; +} + +// AccountDeleteRequestResult reports which deletion step-up the account uses: "email" +// (a code was mailed) or "phrase" (type the confirmation phrase). +table AccountDeleteRequestResult { + method:string; +} + // LinkResult is the unified result of a confirm or merge step. status is "linked" // (bound to the caller), "merge_required" (the identity belongs to another account — // the secondary_* fields summarise it for the irreversible confirmation), or @@ -658,6 +711,27 @@ table GcgExport { content:string; } +// ExportUrlRequest asks for a signed download URL of a finished game's export artifact. +// kind is "png" or "gcg". For the PNG the client passes its presentation context — the +// UI-localized non-play move labels (pass, exchange, resign, timeout, in that order), +// the device date locale and the device IANA time zone — which ride the signed URL so +// the server render matches what the player would have seen locally. +table ExportUrlRequest { + game_id:string; + kind:string; + date_locale:string; + action_labels:[string]; + time_zone:string; +} + +// ExportUrl is the minted download link: a relative, signed, short-lived path the client +// resolves against its own origin (the SPA and the gateway share it), plus the filename +// the download will carry. +table ExportUrl { + path:string; + filename:string; +} + // --- push event payloads --- // YourTurnEvent signals that it is now the recipient's turn. The trailing fields enrich the diff --git a/pkg/fbs/scrabblefb/AccountDeleteConfirm.go b/pkg/fbs/scrabblefb/AccountDeleteConfirm.go new file mode 100644 index 0000000..e28fa49 --- /dev/null +++ b/pkg/fbs/scrabblefb/AccountDeleteConfirm.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AccountDeleteConfirm struct { + _tab flatbuffers.Table +} + +func GetRootAsAccountDeleteConfirm(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteConfirm { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AccountDeleteConfirm{} + x.Init(buf, n+offset) + return x +} + +func FinishAccountDeleteConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAccountDeleteConfirm(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteConfirm { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AccountDeleteConfirm{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAccountDeleteConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AccountDeleteConfirm) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AccountDeleteConfirm) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AccountDeleteConfirm) Code() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AccountDeleteConfirm) Phrase() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func AccountDeleteConfirmStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func AccountDeleteConfirmAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0) +} +func AccountDeleteConfirmAddPhrase(builder *flatbuffers.Builder, phrase flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(phrase), 0) +} +func AccountDeleteConfirmEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/AccountDeleteRequestResult.go b/pkg/fbs/scrabblefb/AccountDeleteRequestResult.go new file mode 100644 index 0000000..40837b4 --- /dev/null +++ b/pkg/fbs/scrabblefb/AccountDeleteRequestResult.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AccountDeleteRequestResult struct { + _tab flatbuffers.Table +} + +func GetRootAsAccountDeleteRequestResult(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteRequestResult { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AccountDeleteRequestResult{} + x.Init(buf, n+offset) + return x +} + +func FinishAccountDeleteRequestResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAccountDeleteRequestResult(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteRequestResult { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AccountDeleteRequestResult{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAccountDeleteRequestResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AccountDeleteRequestResult) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AccountDeleteRequestResult) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AccountDeleteRequestResult) Method() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func AccountDeleteRequestResultStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func AccountDeleteRequestResultAddMethod(builder *flatbuffers.Builder, method flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(method), 0) +} +func AccountDeleteRequestResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/EmailConfirmLinkRequest.go b/pkg/fbs/scrabblefb/EmailConfirmLinkRequest.go new file mode 100644 index 0000000..fbeec41 --- /dev/null +++ b/pkg/fbs/scrabblefb/EmailConfirmLinkRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type EmailConfirmLinkRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsEmailConfirmLinkRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &EmailConfirmLinkRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishEmailConfirmLinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsEmailConfirmLinkRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &EmailConfirmLinkRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedEmailConfirmLinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *EmailConfirmLinkRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *EmailConfirmLinkRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *EmailConfirmLinkRequest) Token() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func EmailConfirmLinkRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func EmailConfirmLinkRequestAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0) +} +func EmailConfirmLinkRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/EmailConfirmLinkResult.go b/pkg/fbs/scrabblefb/EmailConfirmLinkResult.go new file mode 100644 index 0000000..78ef0dd --- /dev/null +++ b/pkg/fbs/scrabblefb/EmailConfirmLinkResult.go @@ -0,0 +1,87 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type EmailConfirmLinkResult struct { + _tab flatbuffers.Table +} + +func GetRootAsEmailConfirmLinkResult(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkResult { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &EmailConfirmLinkResult{} + x.Init(buf, n+offset) + return x +} + +func FinishEmailConfirmLinkResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsEmailConfirmLinkResult(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkResult { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &EmailConfirmLinkResult{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedEmailConfirmLinkResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *EmailConfirmLinkResult) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *EmailConfirmLinkResult) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *EmailConfirmLinkResult) Purpose() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *EmailConfirmLinkResult) Status() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *EmailConfirmLinkResult) Session(obj *Session) *Session { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(Session) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func EmailConfirmLinkResultStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func EmailConfirmLinkResultAddPurpose(builder *flatbuffers.Builder, purpose flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(purpose), 0) +} +func EmailConfirmLinkResultAddStatus(builder *flatbuffers.Builder, status flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(status), 0) +} +func EmailConfirmLinkResultAddSession(builder *flatbuffers.Builder, session flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(session), 0) +} +func EmailConfirmLinkResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/EmailRequestRequest.go b/pkg/fbs/scrabblefb/EmailRequestRequest.go index 567b8ba..9f95230 100644 --- a/pkg/fbs/scrabblefb/EmailRequestRequest.go +++ b/pkg/fbs/scrabblefb/EmailRequestRequest.go @@ -57,8 +57,16 @@ func (rcv *EmailRequestRequest) BrowserTz() []byte { return nil } +func (rcv *EmailRequestRequest) Language() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func EmailRequestRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(2) + builder.StartObject(3) } func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) @@ -66,6 +74,9 @@ func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0) } +func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0) +} func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/ExportUrl.go b/pkg/fbs/scrabblefb/ExportUrl.go new file mode 100644 index 0000000..9c61e57 --- /dev/null +++ b/pkg/fbs/scrabblefb/ExportUrl.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type ExportUrl struct { + _tab flatbuffers.Table +} + +func GetRootAsExportUrl(buf []byte, offset flatbuffers.UOffsetT) *ExportUrl { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &ExportUrl{} + x.Init(buf, n+offset) + return x +} + +func FinishExportUrlBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsExportUrl(buf []byte, offset flatbuffers.UOffsetT) *ExportUrl { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &ExportUrl{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedExportUrlBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *ExportUrl) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *ExportUrl) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *ExportUrl) Path() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ExportUrl) Filename() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func ExportUrlStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func ExportUrlAddPath(builder *flatbuffers.Builder, path flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(path), 0) +} +func ExportUrlAddFilename(builder *flatbuffers.Builder, filename flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(filename), 0) +} +func ExportUrlEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/ExportUrlRequest.go b/pkg/fbs/scrabblefb/ExportUrlRequest.go new file mode 100644 index 0000000..37b8083 --- /dev/null +++ b/pkg/fbs/scrabblefb/ExportUrlRequest.go @@ -0,0 +1,116 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type ExportUrlRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsExportUrlRequest(buf []byte, offset flatbuffers.UOffsetT) *ExportUrlRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &ExportUrlRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishExportUrlRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsExportUrlRequest(buf []byte, offset flatbuffers.UOffsetT) *ExportUrlRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &ExportUrlRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedExportUrlRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *ExportUrlRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *ExportUrlRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *ExportUrlRequest) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ExportUrlRequest) Kind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ExportUrlRequest) DateLocale() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ExportUrlRequest) ActionLabels(j int) []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4)) + } + return nil +} + +func (rcv *ExportUrlRequest) ActionLabelsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *ExportUrlRequest) TimeZone() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func ExportUrlRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(5) +} +func ExportUrlRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func ExportUrlRequestAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(kind), 0) +} +func ExportUrlRequestAddDateLocale(builder *flatbuffers.Builder, dateLocale flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(dateLocale), 0) +} +func ExportUrlRequestAddActionLabels(builder *flatbuffers.Builder, actionLabels flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actionLabels), 0) +} +func ExportUrlRequestStartActionLabelsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func ExportUrlRequestAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(timeZone), 0) +} +func ExportUrlRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/LinkUnlinkRequest.go b/pkg/fbs/scrabblefb/LinkUnlinkRequest.go new file mode 100644 index 0000000..1c05dd0 --- /dev/null +++ b/pkg/fbs/scrabblefb/LinkUnlinkRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type LinkUnlinkRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsLinkUnlinkRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkUnlinkRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &LinkUnlinkRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishLinkUnlinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsLinkUnlinkRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkUnlinkRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &LinkUnlinkRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedLinkUnlinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *LinkUnlinkRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *LinkUnlinkRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *LinkUnlinkRequest) Kind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func LinkUnlinkRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func LinkUnlinkRequestAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(kind), 0) +} +func LinkUnlinkRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/LinkVKRequest.go b/pkg/fbs/scrabblefb/LinkVKRequest.go new file mode 100644 index 0000000..1514fa1 --- /dev/null +++ b/pkg/fbs/scrabblefb/LinkVKRequest.go @@ -0,0 +1,82 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type LinkVKRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsLinkVKRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkVKRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &LinkVKRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishLinkVKRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsLinkVKRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkVKRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &LinkVKRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedLinkVKRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *LinkVKRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *LinkVKRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *LinkVKRequest) Code() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *LinkVKRequest) DeviceId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *LinkVKRequest) CodeVerifier() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func LinkVKRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func LinkVKRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0) +} +func LinkVKRequestAddDeviceId(builder *flatbuffers.Builder, deviceId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(deviceId), 0) +} +func LinkVKRequestAddCodeVerifier(builder *flatbuffers.Builder, codeVerifier flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(codeVerifier), 0) +} +func LinkVKRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 209e23e..3bb715b 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -179,8 +179,40 @@ func (rcv *Profile) VariantPreferencesLength() int { return 0 } +func (rcv *Profile) Email() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(30)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Profile) TelegramLinked() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(32)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Profile) MutateTelegramLinked(n bool) bool { + return rcv._tab.MutateBoolSlot(32, n) +} + +func (rcv *Profile) VkLinked() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(34)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Profile) MutateVkLinked(n bool) bool { + return rcv._tab.MutateBoolSlot(34, n) +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(13) + builder.StartObject(16) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -224,6 +256,15 @@ func ProfileAddVariantPreferences(builder *flatbuffers.Builder, variantPreferenc func ProfileStartVariantPreferencesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func ProfileAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(email), 0) +} +func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool) { + builder.PrependBoolSlot(14, telegramLinked, false) +} +func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) { + builder.PrependBoolSlot(15, vkLinked, false) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/renderer/.gitignore b/renderer/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/renderer/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/renderer/Dockerfile b/renderer/Dockerfile new file mode 100644 index 0000000..d083897 --- /dev/null +++ b/renderer/Dockerfile @@ -0,0 +1,29 @@ +# renderer — the finished-game image-render sidecar: Node + skia-canvas running the SAME +# ui/src/lib/gameimage.ts the web client unit-tests, bundled at build time (renderer/README.md). +# Debian slim, not the repo's usual alpine/distroless: skia-canvas ships prebuilt glibc +# binaries and resolves fonts through fontconfig, and the runtime fonts are baked into the +# image — Liberation Sans (the UI font stack's Linux face) + Noto Color Emoji (the 🏆). +FROM node:22-slim AS build +WORKDIR /src/renderer +RUN corepack enable && corepack prepare pnpm@11.0.9 --activate +COPY renderer/package.json renderer/pnpm-lock.yaml renderer/pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY renderer/build.mjs ./ +COPY renderer/src ./src +COPY ui/src/lib /src/ui/src/lib +RUN pnpm run bundle + +FROM node:22-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends fonts-liberation fonts-noto-color-emoji \ + && rm -rf /var/lib/apt/lists/* +ENV NODE_ENV=production +ARG VERSION=dev +ENV APP_VERSION=${VERSION} +WORKDIR /app +COPY --from=build /src/renderer/node_modules ./node_modules +COPY --from=build /src/renderer/dist ./dist +COPY renderer/src/server.mjs renderer/src/render.mjs ./src/ +USER node +EXPOSE 8090 +CMD ["node", "src/server.mjs"] diff --git a/renderer/README.md b/renderer/README.md new file mode 100644 index 0000000..79a25e4 --- /dev/null +++ b/renderer/README.md @@ -0,0 +1,34 @@ +# renderer — the finished-game image-render sidecar + +An internal-only Node service that rasterizes the finished-game export PNG. It runs the +**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image +build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on +[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is +pixel-identical to the design the owner signed off in the browser. + +## Interface + +- `POST /render` — `{game, moves, alphabet, labels, dateLocale, hostname, scale?}` → + `image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`); + `alphabet` is the per-variant `(index, letter, value)` table tile values are drawn + from; `labels` localizes the non-play moves (pass/exchange/resign/timeout); + `hostname` + `dateLocale` feed the footer. +- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on). + +The service draws and nothing else: authentication, the participant check and the signed +public download URL all live in the backend (`backend/internal/server/export.go`); the +network path is client → gateway `/dl/*` → backend → this sidecar. + +## Development + +```sh +pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml + # (allowBuilds) or the native binary is silently never fetched +pnpm test # bundles, then node --test against testdata/request.json +node src/server.mjs # local run on :8090 (RENDERER_PORT overrides) +``` + +The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the +UI font stack's Linux face) and Noto Color Emoji (the scoresheet 🏆); skia-canvas picks +both up through fontconfig. The fixture `testdata/request.json` is a real 35-move +self-played Russian Scrabble game. diff --git a/renderer/build.mjs b/renderer/build.mjs new file mode 100644 index 0000000..23423c6 --- /dev/null +++ b/renderer/build.mjs @@ -0,0 +1,13 @@ +// Bundles the shared ui/src/lib game-image renderer (see src/entry.ts) into +// dist/gameimage.mjs for the sidecar runtime. Uses the esbuild JS API — the CLI shim is +// unreliable under pnpm's bin wrapper (it re-runs the native binary through node). +import { build } from 'esbuild'; + +await build({ + entryPoints: ['src/entry.ts'], + bundle: true, + format: 'esm', + platform: 'node', + outfile: 'dist/gameimage.mjs', + logLevel: 'info', +}); diff --git a/renderer/package.json b/renderer/package.json new file mode 100644 index 0000000..c791dfd --- /dev/null +++ b/renderer/package.json @@ -0,0 +1,23 @@ +{ + "name": "scrabble-renderer", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Internal sidecar that renders the finished-game export PNG with the same ui/src/lib/gameimage.ts the browser build tests, on skia-canvas.", + "scripts": { + "bundle": "node build.mjs", + "test": "pnpm run bundle && node --test test/*.test.mjs" + }, + "dependencies": { + "skia-canvas": "^3.0.6" + }, + "devDependencies": { + "esbuild": "^0.25.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "skia-canvas" + ] + } +} diff --git a/renderer/pnpm-lock.yaml b/renderer/pnpm-lock.yaml new file mode 100644 index 0000000..32027d3 --- /dev/null +++ b/renderer/pnpm-lock.yaml @@ -0,0 +1,366 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + skia-canvas: + specifier: ^3.0.6 + version: 3.0.8 + devDependencies: + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + parenthesis@3.1.8: + resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==} + + skia-canvas@3.0.8: + resolution: {integrity: sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==} + + string-split-by@1.0.0: + resolution: {integrity: sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + agent-base@7.1.4: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + follow-redirects@1.16.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ms@2.1.3: {} + + parenthesis@3.1.8: {} + + skia-canvas@3.0.8: + dependencies: + detect-libc: 2.1.2 + follow-redirects: 1.16.0 + https-proxy-agent: 7.0.6 + string-split-by: 1.0.0 + transitivePeerDependencies: + - debug + - supports-color + + string-split-by@1.0.0: + dependencies: + parenthesis: 3.1.8 diff --git a/renderer/pnpm-workspace.yaml b/renderer/pnpm-workspace.yaml new file mode 100644 index 0000000..9551a6c --- /dev/null +++ b/renderer/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +# pnpm 11 records build-script approval here. esbuild's postinstall materialises its CLI +# shim; skia-canvas's postinstall downloads the prebuilt native .node binary — without the +# approval it is silently skipped and the service fails at boot. +allowBuilds: + esbuild: true + skia-canvas: true diff --git a/renderer/src/entry.ts b/renderer/src/entry.ts new file mode 100644 index 0000000..2b1a954 --- /dev/null +++ b/renderer/src/entry.ts @@ -0,0 +1,6 @@ +// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib — +// the exact module the browser build unit-tests — plus the alphabet cache seeder the +// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle` +// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source). +export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage'; +export { setAlphabet } from '../../ui/src/lib/alphabet'; diff --git a/renderer/src/render.mjs b/renderer/src/render.mjs new file mode 100644 index 0000000..1823ff9 --- /dev/null +++ b/renderer/src/render.mjs @@ -0,0 +1,24 @@ +// The render core, separated from the HTTP wiring so the node test can call it directly. +import { Canvas } from 'skia-canvas'; +import { drawGameImage, setAlphabet } from '../dist/gameimage.mjs'; + +/** + * renderRequest rasterizes one export request — the JSON shape the backend sends: + * { game, moves, alphabet, labels, dateLocale, hostname, scale? } — into a PNG buffer. + * game/moves are the ui-model shapes (GameView / MoveRecord[]) the shared drawGameImage + * consumes; alphabet is the per-variant (index, letter, value) table it scores tiles with. + */ +export async function renderRequest(payload) { + const { game, moves, alphabet, labels, dateLocale, timeZone, hostname, scale } = payload; + if (!game || !Array.isArray(moves)) throw Object.assign(new Error('bad payload'), { status: 400 }); + setAlphabet(game.variant, alphabet ?? []); + const canvas = new Canvas(1, 1); + drawGameImage(canvas, game, moves, { + actionLabel: (a) => (labels && labels[a]) || a, + hostname: hostname || '', + dateLocale: dateLocale || undefined, + timeZone: timeZone || undefined, + scale: scale || 2, + }); + return canvas.toBuffer('png'); +} diff --git a/renderer/src/server.mjs b/renderer/src/server.mjs new file mode 100644 index 0000000..84ea98c --- /dev/null +++ b/renderer/src/server.mjs @@ -0,0 +1,55 @@ +// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the +// finished-game export image. It runs the same drawGameImage the browser build ships +// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from +// the image (Liberation Sans + Noto Color Emoji via fontconfig). +// +// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png +// GET /healthz → 200 +// +// The service is internal-only (docker network `internal`); authentication, participant +// checks and the signed public URL all live in the backend — this process only draws. +import { createServer } from 'node:http'; +import { renderRequest } from './render.mjs'; + +const PORT = Number(process.env.RENDERER_PORT || 8090); +// A render request is a finished game's journal — generously capped. +const MAX_BODY = 2 * 1024 * 1024; + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (c) => { + size += c.length; + if (size > MAX_BODY) { + reject(Object.assign(new Error('body too large'), { status: 413 })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +const server = createServer(async (req, res) => { + try { + if (req.method === 'GET' && req.url === '/healthz') { + res.writeHead(200, { 'content-type': 'text/plain' }).end('ok'); + return; + } + if (req.method === 'POST' && req.url === '/render') { + const png = await renderRequest(JSON.parse(await readBody(req))); + res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png); + return; + } + res.writeHead(404).end(); + } catch (err) { + const status = err.status || (err instanceof SyntaxError ? 400 : 500); + console.error(`render error: ${err.message}`); + res.writeHead(status, { 'content-type': 'text/plain' }).end('render failed'); + } +}); + +server.listen(PORT, () => console.log(`renderer listening on :${PORT}`)); diff --git a/renderer/test/render.test.mjs b/renderer/test/render.test.mjs new file mode 100644 index 0000000..50a794f --- /dev/null +++ b/renderer/test/render.test.mjs @@ -0,0 +1,37 @@ +// Smoke test for the render core: feeds the committed fixture (a real 35-move self-played +// Russian Scrabble game) through renderRequest and asserts a sane PNG comes back. Pixel +// goldens are deliberately avoided — font rasterization differs across hosts; the shared +// drawing logic itself is unit-tested in ui/ (gameimage.test.ts). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { renderRequest } from '../src/render.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixture = () => JSON.parse(readFileSync(join(here, '../testdata/request.json'), 'utf8')); + +function pngSize(buf) { + // IHDR is the first chunk: width/height are big-endian u32 at offsets 16/20. + return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }; +} + +test('renders the fixture game to a plausible PNG', async () => { + const png = await renderRequest(fixture()); + assert.ok(png.length > 50_000, `png too small: ${png.length}`); + assert.deepEqual([...png.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const { w, h } = pngSize(png); + // scale 2 over a MIN_SIDE 620 board + two seat columns: sanity bounds, not a golden. + assert.ok(w > 1600 && w < 3000, `unexpected width ${w}`); + assert.ok(h > 1000 && h < 3000, `unexpected height ${h}`); +}); + +test('rejects a payload without a game', async () => { + await assert.rejects(() => renderRequest({ moves: [] }), /bad payload/); +}); + +test('renders concurrently without cross-talk', async () => { + const [a, b] = await Promise.all([renderRequest(fixture()), renderRequest(fixture())]); + assert.equal(a.length, b.length); +}); diff --git a/renderer/testdata/request.json b/renderer/testdata/request.json new file mode 100644 index 0000000..da5776b --- /dev/null +++ b/renderer/testdata/request.json @@ -0,0 +1 @@ +{"game":{"id":"selfplay-sample","variant":"scrabble_ru","dictVersion":"","status":"finished","players":2,"toMove":0,"turnTimeoutSecs":0,"multipleWordsPerTurn":true,"moveCount":35,"endReason":"standard","lastActivityUnix":1782997629,"vsAi":false,"unreadChat":false,"unreadMessages":false,"seats":[{"seat":0,"accountId":"","displayName":"Анна","score":398,"hintsUsed":0,"isWinner":true},{"seat":1,"accountId":"","displayName":"Виктор","score":294,"hintsUsed":0,"isWinner":false}]},"moves":[{"player":0,"action":"play","dir":"H","mainRow":7,"mainCol":7,"tiles":[{"row":7,"col":7,"letter":"Т","blank":false},{"row":7,"col":8,"letter":"О","blank":false},{"row":7,"col":9,"letter":"К","blank":false},{"row":7,"col":10,"letter":"А","blank":false},{"row":7,"col":11,"letter":"Й","blank":false}],"words":["ТОКАЙ"],"count":0,"score":26,"total":26},{"player":1,"action":"play","dir":"H","mainRow":8,"mainCol":7,"tiles":[{"row":8,"col":7,"letter":"Э","blank":false},{"row":8,"col":8,"letter":"Р","blank":false}],"words":["ЭР","ТЭ","ОР"],"count":0,"score":22,"total":22},{"player":0,"action":"play","dir":"H","mainRow":9,"mainCol":5,"tiles":[{"row":9,"col":5,"letter":"Ф","blank":false},{"row":9,"col":6,"letter":"А","blank":false},{"row":9,"col":7,"letter":"К","blank":false},{"row":9,"col":8,"letter":"Т","blank":false}],"words":["ФАКТ","ТЭК","ОРТ"],"count":0,"score":48,"total":74},{"player":1,"action":"play","dir":"V","mainRow":7,"mainCol":5,"tiles":[{"row":7,"col":5,"letter":"Т","blank":false},{"row":8,"col":5,"letter":"И","blank":false},{"row":10,"col":5,"letter":"И","blank":false},{"row":11,"col":5,"letter":"Я","blank":false}],"words":["ТИФИЯ"],"count":0,"score":16,"total":38},{"player":0,"action":"play","dir":"H","mainRow":10,"mainCol":8,"tiles":[{"row":10,"col":8,"letter":"О","blank":false},{"row":10,"col":9,"letter":"Д","blank":false},{"row":10,"col":10,"letter":"Е","blank":false},{"row":10,"col":11,"letter":"О","blank":false},{"row":10,"col":12,"letter":"Н","blank":false}],"words":["ОДЕОН","ОРТО"],"count":0,"score":16,"total":90},{"player":1,"action":"play","dir":"V","mainRow":5,"mainCol":9,"tiles":[{"row":5,"col":9,"letter":"У","blank":false},{"row":6,"col":9,"letter":"Р","blank":false},{"row":8,"col":9,"letter":"А","blank":false}],"words":["УРКА","ЭРА"],"count":0,"score":20,"total":58},{"player":0,"action":"play","dir":"V","mainRow":10,"mainCol":4,"tiles":[{"row":10,"col":4,"letter":"П","blank":false},{"row":11,"col":4,"letter":"Е","blank":false},{"row":12,"col":4,"letter":"Р","blank":false},{"row":13,"col":4,"letter":"Ь","blank":false},{"row":14,"col":4,"letter":"Ё","blank":false}],"words":["ПЕРЬЁ","ПИ","ЕЯ"],"count":0,"score":30,"total":120},{"player":1,"action":"play","dir":"H","mainRow":4,"mainCol":9,"tiles":[{"row":4,"col":9,"letter":"П","blank":false},{"row":4,"col":10,"letter":"Ю","blank":false},{"row":4,"col":11,"letter":"И","blank":false}],"words":["ПЮИ","ПУРКА"],"count":0,"score":30,"total":88},{"player":0,"action":"play","dir":"H","mainRow":11,"mainCol":11,"tiles":[{"row":11,"col":11,"letter":"С","blank":false},{"row":11,"col":12,"letter":"М","blank":false},{"row":11,"col":13,"letter":"О","blank":false},{"row":11,"col":14,"letter":"Г","blank":false}],"words":["СМОГ","ОС","НМ"],"count":0,"score":27,"total":147},{"player":1,"action":"play","dir":"H","mainRow":7,"mainCol":0,"tiles":[{"row":7,"col":0,"letter":"О","blank":false},{"row":7,"col":1,"letter":"Т","blank":false},{"row":7,"col":2,"letter":"С","blank":false},{"row":7,"col":3,"letter":"В","blank":false},{"row":7,"col":4,"letter":"Е","blank":false}],"words":["ОТСВЕТ"],"count":0,"score":21,"total":109},{"player":0,"action":"play","dir":"V","mainRow":4,"mainCol":4,"tiles":[{"row":4,"col":4,"letter":"Р","blank":false},{"row":5,"col":4,"letter":"Е","blank":false},{"row":6,"col":4,"letter":"З","blank":false},{"row":8,"col":4,"letter":"Ш","blank":false}],"words":["РЕЗЕШ","ШИ"],"count":0,"score":41,"total":188},{"player":1,"action":"play","dir":"H","mainRow":5,"mainCol":8,"tiles":[{"row":5,"col":8,"letter":"Б","blank":false},{"row":5,"col":10,"letter":"Р","blank":false}],"words":["БУР","ЮР"],"count":0,"score":15,"total":124},{"player":0,"action":"play","dir":"H","mainRow":3,"mainCol":11,"tiles":[{"row":3,"col":11,"letter":"З","blank":false},{"row":3,"col":12,"letter":"М","blank":false},{"row":3,"col":13,"letter":"Е","blank":false},{"row":3,"col":14,"letter":"Я","blank":false}],"words":["ЗМЕЯ","ЗИ"],"count":0,"score":40,"total":228},{"player":1,"action":"play","dir":"V","mainRow":11,"mainCol":14,"tiles":[{"row":12,"col":14,"letter":"А","blank":false},{"row":13,"col":14,"letter":"Л","blank":false},{"row":14,"col":14,"letter":"Л","blank":false}],"words":["ГАЛЛ"],"count":0,"score":24,"total":148},{"player":0,"action":"play","dir":"H","mainRow":14,"mainCol":3,"tiles":[{"row":14,"col":3,"letter":"Ж","blank":false},{"row":14,"col":5,"letter":"Н","blank":false},{"row":14,"col":6,"letter":"К","blank":false},{"row":14,"col":7,"letter":"А","blank":false}],"words":["ЖЁНКА"],"count":0,"score":51,"total":279},{"player":1,"action":"play","dir":"H","mainRow":2,"mainCol":7,"tiles":[{"row":2,"col":7,"letter":"П","blank":false},{"row":2,"col":8,"letter":"Л","blank":false},{"row":2,"col":9,"letter":"А","blank":false},{"row":2,"col":10,"letter":"В","blank":false},{"row":2,"col":11,"letter":"У","blank":true},{"row":2,"col":12,"letter":"Н","blank":false}],"words":["ПЛАВУН","УЗИ","НМ"],"count":0,"score":30,"total":178},{"player":0,"action":"play","dir":"H","mainRow":5,"mainCol":0,"tiles":[{"row":5,"col":0,"letter":"У","blank":false},{"row":5,"col":1,"letter":"Б","blank":false},{"row":5,"col":2,"letter":"И","blank":false},{"row":5,"col":3,"letter":"В","blank":false},{"row":5,"col":5,"letter":"Ц","blank":false}],"words":["УБИВЕЦ"],"count":0,"score":29,"total":308},{"player":1,"action":"play","dir":"H","mainRow":1,"mainCol":8,"tiles":[{"row":1,"col":8,"letter":"П","blank":false},{"row":1,"col":9,"letter":"Г","blank":false},{"row":1,"col":10,"letter":"Т","blank":false}],"words":["ПГТ","ПЛ","ГА","ТВ"],"count":0,"score":28,"total":206},{"player":0,"action":"play","dir":"V","mainRow":7,"mainCol":1,"tiles":[{"row":8,"col":1,"letter":"Ы","blank":false},{"row":9,"col":1,"letter":"Ч","blank":false},{"row":10,"col":1,"letter":"О","blank":false},{"row":11,"col":1,"letter":"К","blank":false}],"words":["ТЫЧОК"],"count":0,"score":23,"total":331},{"player":1,"action":"play","dir":"V","mainRow":10,"mainCol":0,"tiles":[{"row":10,"col":0,"letter":"С","blank":false},{"row":11,"col":0,"letter":"У","blank":false},{"row":12,"col":0,"letter":"Д","blank":false},{"row":13,"col":0,"letter":"Н","blank":true},{"row":14,"col":0,"letter":"О","blank":false}],"words":["СУДНО","СО","УК"],"count":0,"score":32,"total":238},{"player":0,"action":"play","dir":"V","mainRow":0,"mainCol":14,"tiles":[{"row":0,"col":14,"letter":"С","blank":false},{"row":1,"col":14,"letter":"Е","blank":false},{"row":2,"col":14,"letter":"М","blank":false}],"words":["СЕМЯ"],"count":0,"score":21,"total":352},{"player":1,"action":"play","dir":"H","mainRow":12,"mainCol":8,"tiles":[{"row":12,"col":8,"letter":"В","blank":false},{"row":12,"col":9,"letter":"Е","blank":false},{"row":12,"col":10,"letter":"Щ","blank":false},{"row":12,"col":11,"letter":"Ь","blank":false}],"words":["ВЕЩЬ","ОСЬ"],"count":0,"score":21,"total":259},{"player":0,"action":"play","dir":"H","mainRow":13,"mainCol":9,"tiles":[{"row":13,"col":9,"letter":"Д","blank":false},{"row":13,"col":10,"letter":"И","blank":false}],"words":["ДИ","ЕД","ЩИ"],"count":0,"score":25,"total":377},{"player":1,"action":"play","dir":"V","mainRow":0,"mainCol":2,"tiles":[{"row":0,"col":2,"letter":"С","blank":false},{"row":1,"col":2,"letter":"А","blank":false},{"row":2,"col":2,"letter":"Н","blank":false},{"row":3,"col":2,"letter":"Д","blank":false},{"row":4,"col":2,"letter":"Х","blank":false}],"words":["САНДХИ"],"count":0,"score":22,"total":281},{"player":0,"action":"play","dir":"H","mainRow":0,"mainCol":0,"tiles":[{"row":0,"col":0,"letter":"Н","blank":false},{"row":0,"col":1,"letter":"У","blank":false}],"words":["НУС"],"count":0,"score":12,"total":389},{"player":1,"action":"play","dir":"H","mainRow":11,"mainCol":3,"tiles":[{"row":11,"col":3,"letter":"Л","blank":false}],"words":["ЛЕЯ"],"count":0,"score":12,"total":293},{"player":0,"action":"play","dir":"H","mainRow":6,"mainCol":3,"tiles":[{"row":6,"col":3,"letter":"О","blank":false}],"words":["ОЗ","ВОВ"],"count":0,"score":9,"total":398},{"player":1,"action":"play","dir":"V","mainRow":0,"mainCol":9,"tiles":[{"row":0,"col":9,"letter":"А","blank":false}],"words":["АГА"],"count":0,"score":5,"total":298},{"player":0,"action":"play","dir":"H","mainRow":3,"mainCol":2,"tiles":[{"row":3,"col":3,"letter":"О","blank":false}],"words":["ДО"],"count":0,"score":6,"total":404},{"player":1,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":298},{"player":0,"action":"play","dir":"V","mainRow":7,"mainCol":11,"tiles":[{"row":8,"col":11,"letter":"О","blank":false}],"words":["ЙО"],"count":0,"score":5,"total":409},{"player":1,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":298},{"player":0,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":409},{"player":1,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":298},{"player":0,"action":"pass","dir":"","mainRow":0,"mainCol":0,"tiles":[],"words":[],"count":0,"score":0,"total":409}],"alphabet":[{"index":0,"letter":"А","value":1},{"index":1,"letter":"Б","value":3},{"index":2,"letter":"В","value":1},{"index":3,"letter":"Г","value":3},{"index":4,"letter":"Д","value":2},{"index":5,"letter":"Е","value":1},{"index":6,"letter":"Ё","value":3},{"index":7,"letter":"Ж","value":5},{"index":8,"letter":"З","value":5},{"index":9,"letter":"И","value":1},{"index":10,"letter":"Й","value":4},{"index":11,"letter":"К","value":2},{"index":12,"letter":"Л","value":2},{"index":13,"letter":"М","value":2},{"index":14,"letter":"Н","value":1},{"index":15,"letter":"О","value":1},{"index":16,"letter":"П","value":2},{"index":17,"letter":"Р","value":1},{"index":18,"letter":"С","value":1},{"index":19,"letter":"Т","value":1},{"index":20,"letter":"У","value":2},{"index":21,"letter":"Ф","value":10},{"index":22,"letter":"Х","value":5},{"index":23,"letter":"Ц","value":5},{"index":24,"letter":"Ч","value":5},{"index":25,"letter":"Ш","value":8},{"index":26,"letter":"Щ","value":10},{"index":27,"letter":"Ъ","value":10},{"index":28,"letter":"Ы","value":4},{"index":29,"letter":"Ь","value":3},{"index":30,"letter":"Э","value":8},{"index":31,"letter":"Ю","value":8},{"index":32,"letter":"Я","value":3}],"labels":{"pass":"пас","exchange":"обмен","resign":"сдался","timeout":"таймаут"},"dateLocale":"ru","hostname":"erudit-game.ru"} \ No newline at end of file diff --git a/ui/e2e/export.spec.ts b/ui/e2e/export.spec.ts new file mode 100644 index 0000000..54eb524 --- /dev/null +++ b/ui/e2e/export.spec.ts @@ -0,0 +1,231 @@ +import { Buffer } from 'node:buffer'; +import { expect, test, type Page } from './fixtures'; + +// The finished-game export: one signed relative URL (game.export_url) resolved against +// the app's own origin, delivered by the most native affordance per platform — Telegram's +// showPopup chooser + downloadFile dialog (all bridge calls, activation-safe), VK's +// native viewer/download, the OS share sheet with the fetched file on a mobile browser, +// a plain anchor download on desktop. g3 ("vs Rick") is the seeded finished game. +// The mock gateway returns a shape-faithful /dl/... path; the tests intercept that +// route and serve bytes, so the download itself is exercised end to end. + +// A minimal valid 1×1 PNG, hex-decoded without node typings (the e2e tsconfig has none). +const PNG_HEX = + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489' + + '0000000a49444154789c6360000000020001e221bc330000000049454e44ae426082'; +// Buffer, not Uint8Array: route.fulfill silently hangs a FETCH interception on a +// Uint8Array body (a navigation download tolerates it) — the share test deadlocked. +const PNG_BYTES = Buffer.from(PNG_HEX, 'hex'); + +async function routeDl(page: Page): Promise { + await page.route('**/dl/**', (route) => { + const png = route.request().url().includes('/png'); + return route.fulfill({ + status: 200, + contentType: png ? 'image/png' : 'text/plain; charset=utf-8', + headers: { 'Content-Disposition': 'attachment' }, + body: png ? PNG_BYTES : '#character-encoding UTF-8\n', + }); + }); +} + +async function openFinishedHistory(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); +} + +test('the chooser downloads the PNG through the signed URL on a plain browser', async ({ page }) => { + await routeDl(page); + await openFinishedHistory(page); + await page.getByRole('button', { name: 'Export game' }).click(); + + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Image (PNG)' }).click(); + expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/); +}); + +test('the chooser downloads the GCG through the same signed-URL route', async ({ page }) => { + await routeDl(page); + await openFinishedHistory(page); + await page.getByRole('button', { name: 'Export game' }).click(); + + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'GCG file' }).click(); + expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/); +}); + +test('inside Telegram the chooser is the native popup and delivery the native downloadFile', async ({ + page, +}) => { + // A Telegram stub with showPopup AND downloadFile (Bot API 8.0): the whole chain stays + // in bridge calls — the popup picks the image, the artifact goes to the native download + // dialog, never an in-webview anchor. (Activation-safe: no gesture-gated Web APIs.) + await page.addInitScript(() => { + Object.assign(window, { + Telegram: { + WebApp: { + initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + platform: 'android', + ready() {}, + expand() {}, + showPopup(params: { buttons?: { id?: string; type?: string }[] }, cb: (id: string) => void) { + const w = window as unknown as { __popup?: unknown }; + w.__popup = params; + setTimeout(() => cb('png'), 0); + }, + downloadFile(params: { url: string; file_name: string }) { + const w = window as unknown as { __downloads?: { url: string; file_name: string }[] }; + (w.__downloads ??= []).push(params); + }, + }, + }, + }); + }); + await page.goto('/'); + await expect(page.getByText('Your turn')).toBeVisible(); // auto-authenticated lobby + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await page.getByRole('button', { name: 'Export game' }).click(); + + // The native popup carried both formats plus cancel, and no in-app modal was mounted. + const popup = await page.evaluate( + () => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup, + ); + expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['png', 'gcg', 'cancel']); + await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0); + + await expect + .poll(() => + page.evaluate(() => (window as unknown as { __downloads?: { url: string; file_name: string }[] }).__downloads), + ) + .toHaveLength(1); + const dl = await page.evaluate( + () => (window as unknown as { __downloads: { url: string; file_name: string }[] }).__downloads[0], + ); + // The relative signed path was resolved against the app's own origin — absolute for TG. + expect(dl.url).toMatch(/^http.+\/dl\/.+\/png\?/); + expect(dl.file_name).toMatch(/^game-.+\.png$/); +}); + +test('a mobile browser gets the OS share sheet with the fetched file', async ({ page }) => { + // Web Share with files present (a mobile browser): the artifact is fetched from the + // signed URL and handed to navigator.share as a File — the sheet opens directly, + // nothing lands in Downloads first. + await page.addInitScript(() => { + const shared: { name: string; type: string }[] = []; + Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }); + Object.defineProperty(navigator, 'share', { + value: async (data: { files?: File[] }) => { + for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type }); + }, + configurable: true, + }); + (window as unknown as { __shared: typeof shared }).__shared = shared; + }); + await routeDl(page); + await openFinishedHistory(page); + await page.getByRole('button', { name: 'Export game' }).click(); + await page.getByRole('button', { name: 'Image (PNG)' }).click(); + + await expect + .poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared)) + .toHaveLength(1); + const shared = await page.evaluate( + () => (window as unknown as { __shared: { name: string; type: string }[] }).__shared[0], + ); + expect(shared.name).toMatch(/^game-.+\.png$/); + expect(shared.type).toBe('image/png'); +}); + +test('Telegram on iOS keeps the app chooser and opens the OS share sheet', async ({ page }) => { + // TG iOS: WKWebView has navigator.share, and the sheet is the preferred delivery — but + // it needs the user activation a native-popup callback cannot provide, so the chooser + // stays the app modal on this one platform. + await page.addInitScript(() => { + const shared: { name: string; type: string }[] = []; + Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }); + Object.defineProperty(navigator, 'share', { + value: async (data: { files?: File[] }) => { + for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type }); + }, + configurable: true, + }); + (window as unknown as { __shared: typeof shared }).__shared = shared; + Object.assign(window, { + Telegram: { + WebApp: { + initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + platform: 'ios', + ready() {}, + expand() {}, + showPopup() { + (window as unknown as { __popupCalled?: boolean }).__popupCalled = true; + }, + downloadFile() { + (window as unknown as { __downloadCalled?: boolean }).__downloadCalled = true; + }, + }, + }, + }); + }); + await routeDl(page); + await page.goto('/'); + await expect(page.getByText('Your turn')).toBeVisible(); + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await page.getByRole('button', { name: 'Export game' }).click(); + + // The app modal opened (both formats) — the native popup was not used… + await expect(page.getByRole('button', { name: 'Image (PNG)' })).toBeVisible(); + await page.getByRole('button', { name: 'Image (PNG)' }).click(); + // …and the artifact went to the OS share sheet, not TG's download dialog. + await expect + .poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared)) + .toHaveLength(1); + const flags = await page.evaluate(() => { + const w = window as unknown as { __popupCalled?: boolean; __downloadCalled?: boolean }; + return { popup: !!w.__popupCalled, download: !!w.__downloadCalled }; + }); + expect(flags).toEqual({ popup: false, download: false }); +}); + +test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => { + await page.addInitScript(() => { + // Headless engines deny the real clipboard; a permissive stub keeps the legacy + // copy path deterministic in both browsers. + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: async () => {} }, + configurable: true, + }); + Object.assign(window, { + Telegram: { + WebApp: { + initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + ready() {}, + expand() {}, + }, + }, + }); + }); + await page.goto('/'); + await expect(page.getByText('Your turn')).toBeVisible(); + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await page.getByRole('button', { name: 'Export game' }).click(); + + // No image option without the native download; the GCG stays available (clipboard path). + await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0); + await page.getByRole('button', { name: 'GCG file' }).click(); + await expect(page.getByText('GCG copied to the clipboard.')).toBeVisible(); +}); diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index e49c028..fd2512e 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -90,16 +90,16 @@ test('new game: auto-match shows the off-by-default rule toggle from the start ( await expect(toggle).not.toBeChecked(); }); -test('single-word game shows the one-word indicator in the status bar and the history header', async ({ page }) => { +test('single-word game shows the one-word rule label in the history header', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); // g3 is a finished Russian single-word game (vs Rick); open it from the lobby. await page.getByRole('button', { name: /Rick/ }).click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('.pane')).toHaveCount(1); - // The status bar carries the small "1️⃣" indicator (single-word, nothing pending). - await expect(page.locator('.oneword')).toBeVisible(); - // Tapping the scoreboard opens the history, whose header shows the spelled-out rule label. + // The status bar no longer carries a "1️⃣" badge (dropped as noise); the rule surfaces in the + // history header instead. Tapping the scoreboard opens the history with the spelled-out label. + await expect(page.locator('.oneword')).toHaveCount(0); await page.locator('.scoreboard').click(); await expect(page.locator('.oneword-label')).toBeVisible(); }); diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index 085cbda..e6a5ab6 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -2,16 +2,29 @@ import { expect, test } from './fixtures'; // The landing page is a separate Vite entry (landing.html), served at "/" in production while // the game SPA lives at /app/ and /telegram/. In dev it is reachable at /landing.html. -test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => { +test('landing defaults to Russian, switches language via the dropdown, and toggles theme', async ({ page }) => { await page.goto('/landing.html'); - // The tagline renders (English in the default test browser). - await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); + // Russian by default regardless of the browser language (the test browser is en-US): + // crawlers render with arbitrary navigator.language, so the landing never auto-detects. + await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); + await expect(page).toHaveTitle(/Эрудит \(Скрэббл\)/); + expect(await page.evaluate(() => document.documentElement.lang)).toBe('ru'); - // The language dropdown switches the copy to Russian. + // The static SEO head is present (the page is client-rendered; this layer is what + // non-rendering crawlers and link-preview bots see). + expect(await page.locator('meta[name="description"]').getAttribute('content')).toContain('Эрудит'); + expect(await page.locator('meta[property="og:image"]').getAttribute('content')).toBe( + 'https://erudit-game.ru/og-image.png', + ); + const jsonLd = await page.locator('script[type="application/ld+json"]').textContent(); + expect(JSON.parse(jsonLd!)['@type']).toBe('WebApplication'); + + // The language dropdown switches the copy to English, and the document language follows. await page.getByRole('button', { name: 'Language' }).click(); - await page.getByRole('menuitem', { name: /Русский/ }).click(); - await expect(page.getByText(/Играй в Скрэббл/)).toBeVisible(); + await page.getByRole('menuitem', { name: /English/ }).click(); + await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.lang)).toBe('en'); // The theme toggle flips the document theme (ephemeral, light<->dark). const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); @@ -24,7 +37,7 @@ test('landing shows the pitch, switches language via the dropdown, and toggles t // adds .app-shell); the landing is a normal scrolling document and must keep scrolling. test('the landing is a normal scrolling document (the SPA document-pin does not apply)', async ({ page }) => { await page.goto('/landing.html'); - await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); + await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); const state = await page.evaluate(() => ({ shell: document.documentElement.classList.contains('app-shell'), diff --git a/ui/e2e/landscape.spec.ts b/ui/e2e/landscape.spec.ts index 366d621..709c6ed 100644 --- a/ui/e2e/landscape.spec.ts +++ b/ui/e2e/landscape.spec.ts @@ -53,3 +53,25 @@ test('landscape still places a tile and commits via the make button', async ({ p await expect(page.locator('.cell.pending')).toHaveCount(1); await expect(page.locator('.make')).toBeVisible(); }); + +// Placement auto-zoom is portrait-only (Game.svelte gates it on !landscape): landscape fits the +// whole board, so magnifying on every placed tile would only hide the position. The gate also +// requires a coarse pointer, so the block emulates touch and skips on an engine whose emulation +// does not flip `(pointer: coarse)` (desktop WebKit). The portrait counterpart lives in +// zoom.spec.ts. +test.describe('touch placement', () => { + test.use({ hasTouch: true }); + + test('landscape does not auto-zoom when a tile is placed', async ({ page }) => { + await openGame(page); + test.skip( + !(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)), + 'touch emulation does not report a coarse pointer in this engine', + ); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell][data-row="9"][data-col="6"]').click(); + await expect(page.locator('.cell.pending')).toHaveCount(1); + await page.waitForTimeout(400); // the would-be zoom transition window + await expect(page.locator('.viewport.zoomed')).toHaveCount(0); + }); +}); diff --git a/ui/e2e/maintenance.spec.ts b/ui/e2e/maintenance.spec.ts new file mode 100644 index 0000000..110b8d4 --- /dev/null +++ b/ui/e2e/maintenance.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from './fixtures'; + +// The maintenance overlay is raised in prod by the edge 503 marker (X-Scrabble-Maintenance); +// the mock transport never produces one, so — like the offline indicator — the e2e drives it +// through the window.__maint hook (gateway.ts, mock-only). It is app-global (mounted outside +// the route blocks in App.svelte), so it shows without a session. +test('maintenance overlay covers the app and lifts on recovery', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + + // A planned deploy window begins: a non-dismissable dimmed overlay covers the app. + await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on()); + const overlay = page.getByRole('alertdialog'); + await expect(overlay).toBeVisible(); + // The retry button is present (EN "Try again" / RU "Повторить" depending on locale). + await expect(overlay.getByRole('button', { name: /again|Повторить/i })).toBeVisible(); + + // The window ends — in prod the store's poll lifts it on the first successful read; the mock + // has no probe, so it clears explicitly. The overlay disappears with no page reload. + await page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off()); + await expect(page.getByRole('alertdialog')).toHaveCount(0); +}); + +test('recovery reloads the SPA to pick up the fresh client', async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on()); + await expect(page.getByRole('alertdialog')).toBeVisible(); + + // Real recovery reloads the page (the deploy may have shipped an incompatible client, and the + // running bundle is the old one). Wait for the forced navigation, then confirm the app + // re-bootstrapped: the overlay is gone (the store reset by the reload) and the login is back. + await Promise.all([ + page.waitForEvent('load'), + page.evaluate(() => (window as unknown as { __maint: { recover(): void } }).__maint.recover()), + ]); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); +}); diff --git a/ui/e2e/onboarding.spec.ts b/ui/e2e/onboarding.spec.ts new file mode 100644 index 0000000..c91b816 --- /dev/null +++ b/ui/e2e/onboarding.spec.ts @@ -0,0 +1,48 @@ +import { expect, test, type Page } from './fixtures'; + +// The first-run onboarding coachmarks. `?coach` forces the overlay on in the mock build (where it +// is otherwise off so it cannot eat the smoke's taps) and bypasses the persisted "seen" flag, so +// the series replay deterministically here. A tap anywhere on the overlay advances; after the last +// hint the overlay removes itself. + +// Tap the overlay near a corner (clear of the bubble) to advance to the next hint. +async function advance(page: Page): Promise { + await page.locator('[data-coach-overlay]').click({ position: { x: 5, y: 5 } }); +} + +// Drive both series end to end: guest login -> lobby coachmarks (3) -> open the seeded game -> +// game coachmarks (5) -> gone. Shared by the portrait and landscape projects/cases. +async function runOnboarding(page: Page): Promise { + const overlay = page.locator('[data-coach-overlay]'); + + await page.goto('/?coach=1'); + await page.getByRole('button', { name: /guest/i }).click(); + + // Lobby series: settings -> stats -> new game. + await expect(overlay).toBeVisible({ timeout: 10000 }); + await advance(page); + await advance(page); + await advance(page); + await expect(overlay).toBeHidden(); + + // The lobby is interactive again: open the seeded active game. + await page.getByRole('button', { name: /Ann/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + + // Game series: header -> pass/exchange -> hints -> shuffle -> rack. + await expect(overlay).toBeVisible({ timeout: 10000 }); + for (let i = 0; i < 5; i++) await advance(page); + await expect(overlay).toBeHidden(); +} + +test('onboarding walks the lobby then the first game (portrait)', async ({ page }) => { + await runOnboarding(page); +}); + +test.describe('landscape', () => { + test.use({ viewport: { width: 1100, height: 640 } }); + + test('onboarding anchors and advances in the wide layout', async ({ page }) => { + await runOnboarding(page); + }); +}); diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 36c27f1..ddf85d2 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -80,7 +80,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page } // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and // no comms entry at all. await page.locator('.scoreboard').click(); - await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); }); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 742044f..ac06423 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -154,14 +154,14 @@ test('GCG export appears only for a finished game', async ({ page }) => { // The finished game vs Kaya exposes export 📤 in the history header. await page.getByRole('button', { name: /Kaya/ }).click(); await page.locator('.scoreboard').click(); // open the history - await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); }); test('GCG export is hidden for an active game', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export) - await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0); }); test('finished game draws an inert footer and trims live-only controls', async ({ page }) => { @@ -172,7 +172,7 @@ test('finished game draws an inert footer and trims live-only controls', async ( await expect(page.locator('.tab').first()).toBeDisabled(); // The history header offers Export GCG, not Drop game, once the game is over. await page.locator('.scoreboard').click(); - await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0); // The comms hub offers Chat only — the Dictionary tab is hidden for a finished game. await page.getByRole('button', { name: 'Chat' }).click(); // 💬 @@ -292,35 +292,73 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa await expect(save).toBeEnabled(); }); -// Account linking is hidden in Profile.svelte while we target provider sign-in (the anonymous -// /app/ guest who upgrades by linking comes later). The flow is kept wired; re-enable these two -// specs together with the `.emailbox` section. -test.skip('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => { +// The profile's sign-in-methods matrix (email add/change + provider link/unlink). The mock +// profile is a durable account already holding an email, so the email control is the change +// flow; a new address containing "taken" stands in (in the mock) for one owned by another +// account, driving the non-disclosing refusal. +test('change email: a taken address is refused without disclosure', async ({ page }) => { await loginLobby(page); await openProfile(page); - // The linking section is shown to everyone (guests upgrade by linking). - await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible(); - // An address containing "merge" stands in (in the mock) for one already owned by - // another account, so the confirm step reveals a required merge. - await page.locator('.emailbox input[type="email"]').fill('merge@example.com'); + await expect(page.getByText('you@example.com')).toBeVisible(); + await page.getByRole('button', { name: 'Change' }).click(); + await page.getByPlaceholder('New email address').fill('taken@example.com'); await page.getByRole('button', { name: 'Send code' }).click(); - await page.locator('.emailbox .codein').fill('123456'); + await page.locator('.accounts .codein').fill('123456'); await page.getByRole('button', { name: 'OK' }).click(); - // The reveal happens only after the code, and names the other account. - await expect(page.getByText('Merge accounts?')).toBeVisible(); - await expect(page.getByText(/Ann/)).toBeVisible(); - await page.getByRole('button', { name: 'Merge' }).click(); - await expect(page.getByText('Merge accounts?')).toBeHidden(); + // The neutral message never reveals the other account. + await expect(page.getByText('Check the address or contact support.')).toBeVisible(); + await expect(page.getByText(/belongs to another account/)).toHaveCount(0); + await expect(page.getByText('you@example.com')).toBeVisible(); }); -test.skip('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => { +test('change email: a free address replaces the current one', async ({ page }) => { await loginLobby(page); await openProfile(page); + + await page.getByRole('button', { name: 'Change' }).click(); + await page.getByPlaceholder('New email address').fill('fresh@example.com'); + await page.getByRole('button', { name: 'Send code' }).click(); + await page.locator('.accounts .codein').fill('123456'); + await page.getByRole('button', { name: 'OK' }).click(); + + await expect(page.getByText('fresh@example.com')).toBeVisible(); + await expect(page.getByText('you@example.com')).toHaveCount(0); +}); + +test('link then unlink Telegram from the sign-in methods', async ({ page }) => { + await loginLobby(page); + await openProfile(page); + + // On the web the Telegram login-widget control is offered; the mock links it instantly. + await page.getByRole('button', { name: 'Link Telegram' }).click(); + const tgRow = page.locator('.acctrow').filter({ hasText: 'Telegram' }); + await expect(tgRow).toBeVisible(); + + // With email + Telegram two methods remain, so Unlink is offered; confirm it in the dialog. + await tgRow.getByRole('button', { name: 'Unlink' }).click(); + await expect(page.getByText('Unlink account?')).toBeVisible(); + await page.getByRole('dialog').getByRole('button', { name: 'Unlink' }).click(); + + await expect(page.locator('.acctrow').filter({ hasText: 'Telegram' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); }); +test('account deletion: the mailed-code step-up leads to the terminal deleted screen', async ({ page }) => { + await loginLobby(page); + await openProfile(page); + + // The mock profile holds an email, so deletion asks for the mailed code. + await page.getByRole('button', { name: 'Delete account' }).click(); + await expect(page.getByText('Delete your account?')).toBeVisible(); + await page.getByRole('dialog').locator('.codein').fill('123456'); + await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click(); + + // The app swaps to the terminal account-deleted screen. + await expect(page.getByText('Account deleted')).toBeVisible(); +}); + test('chat: one message per turn — the field shows, then a caption replaces it after sending', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts index de2ff71..5144acf 100644 --- a/ui/e2e/zoom.spec.ts +++ b/ui/e2e/zoom.spec.ts @@ -53,6 +53,27 @@ test('zoomed board clamps overscroll at the edge', async ({ page }) => { expect(ob.y).toBe('none'); }); +// Placement auto-zoom (touch): in portrait, placing a tile magnifies into it. This is the +// counterpart of landscape.spec.ts's no-auto-zoom guard — together they lock the gate to +// orientation only. Skips on an engine whose touch emulation does not flip `(pointer: coarse)`. +test.describe('touch placement', () => { + test.use({ hasTouch: true }); + + test('placing a tile auto-zooms the board in portrait', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + test.skip( + !(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)), + 'touch emulation does not report a coarse pointer in this engine', + ); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell][data-row="9"][data-col="6"]').click(); + await expect(page.locator('.viewport.zoomed')).toBeVisible(); + }); +}); + // A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the // zoom state does not change, so without an explicit recenter the board stays parked where the // player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so diff --git a/ui/index.html b/ui/index.html index 8eac93b..e6167a2 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,7 +1,11 @@ - + + + + + +
diff --git a/ui/landing.html b/ui/landing.html index b92f035..9e25e53 100644 --- a/ui/landing.html +++ b/ui/landing.html @@ -1,10 +1,51 @@ - + - Scrabble + + Эрудит (Скрэббл) — играть онлайн с друзьями или роботом + + + + + + + + + + + + + + + + + + + +
diff --git a/ui/public/apple-touch-icon.png b/ui/public/apple-touch-icon.png new file mode 100644 index 0000000..ba28ea8 Binary files /dev/null and b/ui/public/apple-touch-icon.png differ diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 0000000..ea41c87 Binary files /dev/null and b/ui/public/favicon.ico differ diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 0000000..c78acf2 --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/ui/public/og-image.png b/ui/public/og-image.png new file mode 100644 index 0000000..65bb19f Binary files /dev/null and b/ui/public/og-image.png differ diff --git a/ui/public/robots.txt b/ui/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/ui/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/ui/public/vk-logo.svg b/ui/public/vk-logo.svg new file mode 100644 index 0000000..1c61468 --- /dev/null +++ b/ui/public/vk-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 76046ef..61c0519 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -19,8 +19,10 @@ import { join } from 'node:path'; const DIST = 'dist'; -// Per-chunk gzip budgets in KB. -const BUDGET = { app: 100, shared: 30, landing: 5 }; +// Per-chunk gzip budgets in KB. The app entry was raised to 110 when the local +// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this +// covers the small in-entry game/debug wiring it needs). +const BUDGET = { app: 110, shared: 30, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/App.svelte b/ui/src/App.svelte index bba6053..ad3f36d 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -8,16 +8,20 @@ import Splash from './components/Splash.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; + import Coachmark from './components/Coachmark.svelte'; + import MaintenanceOverlay from './components/MaintenanceOverlay.svelte'; import DebugPanel from './components/DebugPanel.svelte'; import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; import NewGame from './screens/NewGame.svelte'; import SettingsHub from './screens/SettingsHub.svelte'; import Stats from './screens/Stats.svelte'; + import Confirm from './screens/Confirm.svelte'; import Game from './game/Game.svelte'; import CommsHub from './game/CommsHub.svelte'; import Feedback from './screens/Feedback.svelte'; import Blocked from './screens/Blocked.svelte'; + import AccountDeleted from './screens/AccountDeleted.svelte'; import BootError from './screens/BootError.svelte'; import TelegramLaunchError from './screens/TelegramLaunchError.svelte'; @@ -81,6 +85,8 @@ +{:else if app.accountDeleted} + {:else if app.blocked} {:else} @@ -109,6 +115,8 @@ {:else if router.route.name === 'stats'} + {:else if router.route.name === 'confirm'} + {:else} {/if} @@ -120,6 +128,8 @@ + + {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index e135e8b..3a8d587 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -1,15 +1,16 @@ + platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView + confirmation, the VK away-redirect keeps the Android WebView on the game. -->
diff --git a/ui/src/components/Coachmark.svelte b/ui/src/components/Coachmark.svelte new file mode 100644 index 0000000..1e70172 --- /dev/null +++ b/ui/src/components/Coachmark.svelte @@ -0,0 +1,330 @@ + + +{#if activeSeries && stepIndex >= 0} + + +
+
+ {t(stepsFor(activeSeries)[stepIndex].key)} + +
+
+{/if} + + diff --git a/ui/src/components/DebugPanel.svelte b/ui/src/components/DebugPanel.svelte index 0513084..6960589 100644 --- a/ui/src/components/DebugPanel.svelte +++ b/ui/src/components/DebugPanel.svelte @@ -4,18 +4,39 @@ // snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a // clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the // Telegram Android presentation quirks. Drawn from the top, just under the app header. - import { app, closeDebug } from '../lib/app.svelte'; + import { onMount } from 'svelte'; + import { app, closeDebug, resetOnboarding } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { shareText } from '../lib/share'; import { telegramChromeDiag } from '../lib/telegram'; - const report = [ - `app: ${__APP_VERSION__}`, - `locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`, - `online: ${connection.online} streamAlive: ${app.streamAlive}`, - `userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`, - telegramChromeDiag(), - ].join('\n'); + // The local move-preview snapshot — the feature flag, the bad-connection breaker and the + // dictionaries cached in IndexedDB with their sizes — loads async so a report about the + // preview is precise. Dynamically imported so the debug panel keeps the dict code lazy. + let dictInfo = $state('local eval: …'); + onMount(() => { + void (async () => { + try { + const m = await import('../lib/dict'); + const list = await m.listCachedDicts(); + const cached = list.length ? list.map((d) => `${d.key}=${(d.bytes / 1024).toFixed(0)}KB`).join(' ') : 'none'; + dictInfo = `dict breaker: ${m.dictLoadingDisabled() ? 'TRIPPED' : 'ok'}\ndicts cached: ${cached}`; + } catch { + dictInfo = 'local eval: n/a'; + } + })(); + }); + + const report = $derived( + [ + `app: ${__APP_VERSION__}`, + `locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`, + `online: ${connection.online} streamAlive: ${app.streamAlive}`, + `userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`, + telegramChromeDiag(), + dictInfo, + ].join('\n'), + ); let label = $state('Share'); async function share(e: MouseEvent): Promise { @@ -26,12 +47,32 @@ setTimeout(() => (label = 'Share'), 1500); } } + + // Clear the first-run coachmark "seen" flags so the onboarding replays on the next launch — a + // manual re-test aid. Stops the click from also closing the panel, like the Share control. + let resetLabel = $state('Reset visited'); + function resetVisited(e: MouseEvent): void { + e.stopPropagation(); + resetOnboarding(); + // Also clear the local move-preview dictionary cache (safe — it re-downloads). + // Dynamically imported so the debug panel keeps the dict code out of the app bundle. + void import('../lib/dict').then((m) => { + m.clearDictCache(); + m.clearDictInstances(); + m.bustDictHttpCache(); // also bypass the browser HTTP cache on the next load (a true cold test) + }); + resetLabel = 'Cleared — relaunch'; + setTimeout(() => (resetLabel = 'Reset visited'), 1800); + }
- +
+ + +
{report}
@@ -49,6 +90,12 @@ gap: 10px; align-items: flex-start; } + .actions { + flex: 0 0 auto; + display: flex; + gap: 10px; + flex-wrap: wrap; + } .share { flex: 0 0 auto; padding: 7px 16px; @@ -58,6 +105,16 @@ border-radius: var(--radius-sm); font-size: 0.95rem; } + /* Secondary (outline) styling so it reads as a utility next to the primary Share action. */ + .reset { + flex: 0 0 auto; + padding: 7px 16px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + border-radius: var(--radius-sm); + font-size: 0.95rem; + } .body { margin: 0; width: 100%; diff --git a/ui/src/components/DictWarmup.svelte b/ui/src/components/DictWarmup.svelte new file mode 100644 index 0000000..0806362 --- /dev/null +++ b/ui/src/components/DictWarmup.svelte @@ -0,0 +1,81 @@ + + +
+
+
+ {#key index} + {EMOJIS[index]} + {/key} +
+ {t('dict.loading')} +
+
+ + diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 7d79b29..222051d 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -48,8 +48,11 @@
- {#if app.profile?.banner && app.profile.banner.campaigns.length} + it (banner under the title, board pinned to the bottom). It is hidden while a first-run + coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the + dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears, + per this same condition, once onboarding closes. --> + {#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive} + // Non-dismissable maintenance cover. Shown while the edge is in a planned deploy window + // (maintenance.svelte.ts, raised from a caddy 503 carrying X-Scrabble-Maintenance). It has + // no close affordance — an in-session user cannot dismiss it — but the store's poll lifts + // it automatically the moment the gateway answers again; the "retry" button just forces an + // immediate re-check. Mirrors the static caddy maintenance page (deploy/caddy/maintenance.html) + // so a fresh load and an in-session user see the same thing. + import { maintenance, retryNow } from '../lib/maintenance.svelte'; + import { t } from '../lib/i18n/index.svelte'; + + +{#if maintenance.active} +
+
+ +

{t('maintenance.title')}

+

{t('maintenance.body')}

+ +
+
+{/if} + + diff --git a/ui/src/components/StaleInviteModal.svelte b/ui/src/components/StaleInviteModal.svelte index 1ababd8..b06579b 100644 --- a/ui/src/components/StaleInviteModal.svelte +++ b/ui/src/components/StaleInviteModal.svelte @@ -4,6 +4,7 @@ import { t } from '../lib/i18n/index.svelte'; import { botUsername } from '../lib/deeplink'; import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; + import { openExternalUrl } from '../lib/links'; import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs'; import Modal from './Modal.svelte'; @@ -16,8 +17,8 @@ if (username) { const url = `https://t.me/${username}`; // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall - // back to a new tab. - if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank'); + // back to a new tab (routed out of the Android VK WebView by openExternalUrl). + if (!telegramOpenLink(url)) openExternalUrl(url); } dismissStaleInvite(); } diff --git a/ui/src/components/WelcomeRedeemModal.svelte b/ui/src/components/WelcomeRedeemModal.svelte index 9cbc2d9..3bd0c29 100644 --- a/ui/src/components/WelcomeRedeemModal.svelte +++ b/ui/src/components/WelcomeRedeemModal.svelte @@ -4,6 +4,7 @@ import { t } from '../lib/i18n/index.svelte'; import { botUsername } from '../lib/deeplink'; import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; + import { openExternalUrl } from '../lib/links'; import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs'; import Modal from './Modal.svelte'; @@ -19,8 +20,8 @@ if (username) { const url = `https://t.me/${username}`; // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall - // back to a new tab. - if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank'); + // back to a new tab (routed out of the Android VK WebView by openExternalUrl). + if (!telegramOpenLink(url)) openExternalUrl(url); } dismissWelcomeRedeem(); } diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index 173afae..67a5a2a 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -5,7 +5,7 @@ import { t } from '../lib/i18n/index.svelte'; import { alphabetLetters } from '../lib/alphabet'; import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword'; - import { onExternalLinkClick } from '../lib/telegram'; + import { onExternalLinkClick } from '../lib/links'; import type { Variant } from '../lib/model'; // Word-check on its own screen: unlimited dictionary lookups, each with a diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 91d88a4..2268457 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -4,9 +4,11 @@ import TabBar from '../components/TabBar.svelte'; import TapConfirm from '../components/TapConfirm.svelte'; import Modal from '../components/Modal.svelte'; + import DictWarmup from '../components/DictWarmup.svelte'; import Board from './Board.svelte'; import Rack from './Rack.svelte'; import { gateway } from '../lib/gateway'; + import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics'; import { navigate } from '../lib/router.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; @@ -20,11 +22,13 @@ import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft } from '../lib/hints'; - import { shareOrDownloadGcg } from '../lib/share'; + import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share'; + import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; - import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram'; + import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramPlatform, telegramShowConfirm, telegramShowPopup } from '../lib/telegram'; + import { haptic } from '../lib/haptics'; import { BLANK, newPlacement, @@ -48,6 +52,15 @@ let placement = $state(newPlacement([])); let preview = $state(null); let busy = $state(false); + + // The local move-preview subsystem (dynamically imported when enabled) and the warm-up + // overlay state. dict is null until the subsystem loads, or when the feature is disabled. + let dict = $state(null); + let dictWarming = $state(false); + let warmStarted = false; + // Aborts the in-flight dictionary download (at the warm-up cap, or when leaving the game) + // so a large download does not keep starving the channel on a slow link. + let warmCtrl: AbortController | null = null; let zoomed = $state(false); let selected = $state(null); let focus = $state<{ row: number; col: number } | null>(null); @@ -242,6 +255,24 @@ void load(); void loadFriends(); void loadBlocked(); + // Load the local move-preview subsystem (kept out of the initial bundle) when enabled, + // so composing a move can be scored on-device; the network preview stays the fallback. + // Skipped in mock mode (no dictionary server; the mock uses the network preview path and + // must never see the warm-up overlay, which would intercept the e2e's taps). + if (import.meta.env.MODE !== 'mock') { + void import('../lib/dict').then((m) => { + dict = m; + }); + } + }); + + // Warm the game's dictionary for the local move preview once, when both the game and the + // dynamically-imported preview subsystem are available (see warmDict). + $effect(() => { + if (dict && view && !warmStarted) { + warmStarted = true; + void warmDict(); + } }); // cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers. @@ -478,6 +509,7 @@ draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : null; // No zoom on drag start: the player may still change their mind. Holding the tile // over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres. + // Both auto-zooms are portrait-only — landscape fits the whole board. } if (!drag) return; drag = { ...drag, x: e.clientX, y: e.clientY }; @@ -508,10 +540,10 @@ ? setTimeout(() => { // Still holding the tile over this cell: magnify into it. Only the first // (zoom-in) hold centres; once zoomed we never move the board on hover. - if (drag && isCoarse() && !zoomed) { + if (drag && isCoarse() && !landscape && !zoomed) { focus = c; zoomed = true; - telegramHaptic('light'); + haptic('light'); } }, 700) : null; @@ -567,6 +599,10 @@ clearReorder(); } onDestroy(() => { + // Leaving the game: abort a still-downloading dictionary and any in-flight preview so they + // stop holding the channel. + warmCtrl?.abort(); + evalCtrl?.abort(); window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); @@ -617,13 +653,15 @@ if (board[row]?.[col]) return; if (pendingMap.has(`${row},${col}`)) return; focus = { row, col }; - if (isCoarse() && !zoomed) zoomed = true; + // Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there + // only hides the rest of the position. + if (isCoarse() && !landscape && !zoomed) zoomed = true; if (placement.rack[index] === BLANK) { blankPrompt = { rackIndex: index, row, col }; return; } placement = place(placement, index, row, col); - telegramHaptic('select'); + haptic('select'); recompute(); scheduleDraftSave(); } @@ -631,24 +669,81 @@ if (!blankPrompt) return; placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter); blankPrompt = null; - telegramHaptic('select'); + haptic('select'); recompute(); scheduleDraftSave(); } + // warmDict loads the game's dictionary for the local move preview when the player opens the + // game. When it is already cached the preview is instant and no overlay shows; a cold + // dictionary downloads behind a non-dismissable warm-up overlay, which hides on load or after + // a 5s cap — the preview then uses the network until the download finishes in the background. + // The bad-connection breaker skips the overlay and goes straight to the network. + async function warmDict() { + const d = dict; + const v = view; + if (!d || !v) return; + if (d.hasDawg(v.game.variant, v.game.dictVersion) || d.dictLoadingDisabled()) return; + const ctrl = new AbortController(); + warmCtrl = ctrl; + let settled = false; + // A short flash-guard: a disk-cached dictionary loads in a few ms, so only show the + // overlay if the load has not settled by now — a cold (network) load always outlasts it. + const grace = setTimeout(() => { + if (!settled) dictWarming = true; + }, 120); + const loaded = await Promise.race([ + d.getDawg(v.game.variant, v.game.dictVersion, ctrl.signal), + new Promise((r) => setTimeout(() => r(null), 5000)), + ]); + settled = true; + clearTimeout(grace); + dictWarming = false; + if (!loaded) { + // Did not load within the cap: abort the download so it stops starving the channel this + // session, and count the miss toward the bad-connection breaker (3 -> stop warming). + ctrl.abort(); + d.noteDictMiss(); + } + } + let previewTimer: ReturnType | null = null; + let evalCtrl: AbortController | null = null; function recompute() { preview = null; if (previewTimer) clearTimeout(previewTimer); + // The tiles changed: cancel any in-flight network preview (evaluate is non-mutating, so + // aborting is safe) — a stale, out-of-order response cannot overwrite the newer one, and + // rapid placements do not pile up requests on a slow link. + evalCtrl?.abort(); + evalCtrl = null; // Off-turn the composition is position-only: no score preview or evaluate. if (!isMyTurn) return; const sub = toSubmit(placement); if (!sub) return; + // Instant on-device preview when the game's dictionary is warm; the network otherwise. + const d = dict; + const v = view; + if (d && v) { + const reader = d.peekDawg(v.game.variant, v.game.dictVersion); + if (reader && hasAlphabet(v.game.variant)) { + try { + preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn); + notePreviewLocal(); + return; + } catch { + /* fall through to the network preview */ + } + } + } + const ctrl = new AbortController(); + evalCtrl = ctrl; previewTimer = setTimeout(async () => { try { - preview = await gateway.evaluate(id, sub.tiles, variant); + preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal); + notePreviewNetwork(); } catch { - /* best-effort */ + /* best-effort (or aborted) */ } }, 250); } @@ -687,7 +782,7 @@ busy = true; try { applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant)); - telegramHaptic('success'); + haptic('success'); zoomed = false; } catch (e) { handleError(e); @@ -752,12 +847,13 @@ row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2), col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2), }; - // Ask the board to scroll to the hint word. A zoom-out board zooms in (and centres) on - // the next line; this nonce also recentres a board that is already zoomed in, where the - // unchanged zoom state would otherwise leave it parked where the player was looking. + // Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres) + // on the next line (portrait only); this nonce also recentres a board that is already + // zoomed in, where the unchanged zoom state would otherwise leave it parked where the + // player was looking. recenter++; } - if (isCoarse()) zoomed = true; + if (isCoarse() && !landscape) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance }; syncWallet(h.walletBalance); // The hint is the engine's own top-ranked, fully scored legal move: reuse it as the @@ -793,7 +889,7 @@ shuffling = true; setTimeout(() => (shuffling = false), 600); // A short "shake": a few quick light taps rather than one. - for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55); + for (let i = 0; i < 4; i++) setTimeout(() => haptic('light'), i * 55); scheduleDraftSave(); } function openExchange() { @@ -835,14 +931,115 @@ return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied'); } - async function exportGcg() { + // The finished-game export offers two formats — the GCG file and the server-rendered PNG + // image — behind one 📤 button, both minted as one signed relative URL and delivered by + // the best affordance each platform actually has (every branch owner-verified on-device): + // TG Android/desktop native showPopup chooser → native downloadFile dialog (whose own + // preview shares onwards). All bridge calls — activation-safe. + // TG iOS our modal chooser → the OS share sheet (WKWebView has + // navigator.share; a native-popup callback could not open it — no + // user activation — so the chooser stays the app modal here). + // VK iOS our modal (VK has no native chooser) → VKWebAppDownloadFile for + // both formats — the client lands in its native share/preview flow. + // VK Android our modal → the PNG opens in VK's native image viewer (its save + // works; the Android DownloadFile hangs), the GCG copies to the + // clipboard (the pre-URL route, always solid there). + // VK desktop iframe our modal → plain anchor downloads (an ordinary browser). + // mobile browsers our modal → the OS share sheet with the fetched file. + // desktop browsers our modal → plain anchor download. + let exportOpen = $state(false); + // TG iOS shares via the OS sheet regardless of Bot API 8.0; elsewhere in Telegram a + // legacy client without downloadFile keeps the old GCG clipboard copy and hides the + // image option (and the chooser stays our modal). + const tgShareSheet = $derived(insideTelegram() && telegramPlatform() === 'ios'); + const exportImageAvailable = $derived(!insideTelegram() || tgShareSheet || telegramCanDownloadFile()); + + async function onExportClick() { + if (insideTelegram() && !tgShareSheet && telegramCanDownloadFile() && telegramDialogsAvailable()) { + const choice = await telegramShowPopup({ + message: t('game.exportChoice'), + buttons: [ + { id: 'png', type: 'default', text: t('game.exportImageOpt') }, + { id: 'gcg', type: 'default', text: t('game.exportGcgOpt') }, + { type: 'cancel' }, + ], + }); + if (choice === 'png' || choice === 'gcg') void exportArtifact(choice); + return; + } + exportOpen = true; + } + + // Legacy-Telegram GCG delivery (no downloadFile): fetch the text and copy it to the + // clipboard, as before the unified URL route. + async function exportGcgLegacy() { try { - await shareOrDownloadGcg(await gateway.exportGcg(id)); + const gcg = await gateway.exportGcg(id); + const outcome = await shareOrDownloadGcg(gcg, true, copyGcgText); + if (outcome === 'copied') showToast(t('game.gcgCopied'), 'info'); + else if (outcome === 'failed') showToast(t('error.generic'), 'error'); } catch (e) { handleError(e); } } + // exportArtifact delivers a finished game's artifact by the unified signed-URL route. + // The device date locale, its IANA time zone and the UI-localized non-play labels ride + // the URL so the server-rendered image matches what the player would have seen locally. + const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const; + async function exportArtifact(kind: 'png' | 'gcg') { + if (insideTelegram() && !tgShareSheet && !telegramCanDownloadFile()) { + void exportGcgLegacy(); + return; + } + try { + const labels = EXPORT_ACTIONS.map((a) => t(`move.${a}` as MessageKey)); + const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : ''; + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''; + const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone); + const url = new URL(path, location.origin).href; + const mime = kind === 'png' ? 'image/png' : 'text/plain'; + if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return; + if (insideVK()) { + // Per-VK-client delivery (each branch owner-verified): the iOS client's + // VKWebAppDownloadFile lands in a native share flow and is perfect; the ANDROID + // client's download hangs indefinitely, so the PNG opens in VK's native image + // viewer (its "save" works) and the GCG goes to the clipboard; the desktop + // iframe is an ordinary browser — plain anchor downloads. + if (vkAndroidWebView()) { + if (kind === 'png' && (await vkShowImages(url))) return; + if (kind === 'gcg') { + void exportGcgLegacy(); + return; + } + } else if (!vkPlatform().startsWith('desktop') && (await vkDownloadFile(url, filename))) { + return; + } + downloadUrl(url, filename); + return; + } + // TG iOS and plain browsers: the OS share sheet with the fetched file (falls back to + // a download where files cannot be shared — the desktop browser). + const outcome = await shareUrlAsFile(url, filename, mime); + if (outcome === 'failed') showToast(t('error.generic'), 'error'); + } catch (e) { + handleError(e); + } + } + + // The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead + // ): VKWebAppCopyText inside VK — which also works in the desktop VK iframe, where + // navigator.clipboard is blocked — and navigator.clipboard elsewhere. + async function copyGcgText(text: string): Promise { + if (insideVK()) return vkCopyText(text); + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } + } + // --- move history: open by tapping the score bar, close by tapping or swiping up the board --- // The boardwrap surface drives two gestures, selected by `historyOpen`: // - open: the slid board is inert (CSS pointer-events), so the whole board reads as a @@ -1104,6 +1301,11 @@ {/if} + +{#if dictWarming} + +{/if} + {#snippet scoreboardBlock()} @@ -1111,7 +1313,7 @@ {@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)} -
+
{#if badge}{/if} {#each view.game.seats as s (s.seat)}
@@ -1157,7 +1359,7 @@ icon pinned right (the header is space-between). --> {:else} - + {/if} {:else} @@ -1241,7 +1443,7 @@ {isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} - {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if} + {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
{/if} @@ -1250,7 +1452,7 @@ {#snippet rackRow()} -
+
- 🔄{t('game.draw')} + 🔄{t('game.draw')} - 🛟{#if hintCount > 0}{hintCount}{/if} + 🛟{#if hintCount > 0}{hintCount}{/if} {t('game.hint')} {#if placement.pending.length > 0} @@ -1288,7 +1490,7 @@ {:else} {/if} {/snippet} @@ -1339,6 +1541,29 @@ {/if} +{#if exportOpen} + (exportOpen = false)}> +
+ {#if exportImageAvailable} + + {/if} + +
+
+{/if} + diff --git a/ui/src/screens/Confirm.svelte b/ui/src/screens/Confirm.svelte new file mode 100644 index 0000000..bc64a97 --- /dev/null +++ b/ui/src/screens/Confirm.svelte @@ -0,0 +1,84 @@ + + +
+
+ {#if stage === 'busy'} +

{t('confirm.busy')}

+ {:else if stage === 'error'} +

{t('confirm.expired')}

+ {:else} +
{t('app.brand')}
+

{stage === 'merge' ? t('confirm.merge') : t('confirm.linked')}

+

{t('confirm.close')}

+ {/if} +
+
+ + diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index af6b2b5..2067a1d 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -7,7 +7,7 @@ import { connection } from '../lib/connection.svelte'; import { clientChannel } from '../lib/channel'; import { attachmentError, linkify, MAX_BODY } from '../lib/feedback'; - import { onExternalLinkClick } from '../lib/telegram'; + import { onExternalLinkClick } from '../lib/links'; import type { FeedbackState } from '../lib/model'; // The feedback screen: a message (+ optional single attachment) the player sends to diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index b06ba42..3a5f3a3 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -332,13 +332,13 @@ {#snippet tabbar()} diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index f12a500..b5f1d28 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -1,10 +1,20 @@
@@ -244,12 +389,41 @@ {/if} - -