Compare commits
68 Commits
v1.5.0
...
422e3ccc2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 422e3ccc2a | |||
| 622d3965a7 | |||
| 2ab01ed8f7 | |||
| 1ed624eaf1 | |||
| f3e2a6822b | |||
| 65063621a9 | |||
| 4e169c368d | |||
| aec915d5c1 | |||
| 5f574a765d | |||
| db17287113 | |||
| c864147982 | |||
| 2e8fa83814 | |||
| 5f87b3fa43 | |||
| 5689f7f6a3 | |||
| f0399e1bbc | |||
| e6c5198caa | |||
| 84cc56198e | |||
| 5f9b4a7a38 | |||
| 4458f0e545 | |||
| 41f26da9a7 | |||
| cf1d773c18 | |||
| 88b6761e28 | |||
| f4dbb545e0 | |||
| 71c3411276 | |||
| f9acea1d9a | |||
| adf7c55695 | |||
| 6636d7c309 | |||
| 90f2427fa7 | |||
| d0f860a33e | |||
| d1ceccf033 | |||
| 373aa2aa6d | |||
| 4a6fe318be | |||
| 00441e3657 | |||
| 3b7c7c077e | |||
| e900d592f8 | |||
| d76f1f4026 | |||
| 0ea9764a0d | |||
| 6a5ce12fab | |||
| da17c18895 | |||
| 303348ed39 | |||
| a06dfe00fc | |||
| a88678c5c6 | |||
| 500a01cf97 | |||
| d9ede77e2f | |||
| 65c194264c | |||
| 13c22734ee | |||
| 7dabcd1317 | |||
| b03e012011 | |||
| 2495446a47 | |||
| 35705f7d1e | |||
| 4f0cc81dfb | |||
| 2ba7cc3086 | |||
| 29b6c7e4d8 | |||
| 8de9fb1ecd | |||
| 8a5a5d6c4d | |||
| ea931c6680 | |||
| d0f60ee41d | |||
| b84bd1297e | |||
| 0fb6004a8b | |||
| c1d1c1624b | |||
| 9207664fbd | |||
| a4581663f4 | |||
| 03dfc29a54 | |||
| c02262fcf7 | |||
| f8fab4a4c2 | |||
| 10264e10c8 | |||
| fc1715128e | |||
| bb18dc362b |
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: deploy-check
|
||||
description: "Use before any deploy-touching change to this repo — phrases like '/deploy-check', 'is this prod-safe', 'before we deploy', 'deploy safety review', 'проверь перед деплоем', 'это безопасно для прода'. Runs a pre-deploy checklist of this project's hard-won runtime constraints against the current diff, so the crash classes that have bitten live environments get caught before shipping instead of after."
|
||||
---
|
||||
|
||||
# Pre-deploy runtime-constraint check
|
||||
|
||||
Triggered before shipping anything that touches the deploy contour (Dockerfiles,
|
||||
`deploy/`, Caddyfile, compose, migrations, boot guards, the Telegram side-service,
|
||||
edge config). The worst frictions in this repo were never logic bugs — they were
|
||||
**environment mismatches that crashed a live env and forced a redesign**. Run this
|
||||
list against the diff first; turn crash-and-redesign into a single pass.
|
||||
|
||||
This checklist is a prompt, **not** the source of truth. The canonical detail
|
||||
lives in `deploy/README.md`, `docs/ARCHITECTURE.md`, `docs/EDGE_HTTP3.md`, and the
|
||||
agent memory files referenced below — read them when an item is in play, and add a
|
||||
new class here when a new incident teaches one.
|
||||
|
||||
## How to run it
|
||||
|
||||
1. `git diff <base>...HEAD --stat` to see what the change actually touches.
|
||||
2. For every risk class below that the diff touches, perform the **Check** and
|
||||
report PASS / FAIL with the exact file:line to fix. Skip classes the diff does
|
||||
not touch — say which you skipped and why.
|
||||
3. Remember: **deploy-job green ≠ healthy**. CI's deploy probe has historically
|
||||
passed with a dead backend (it only checked static landing+gateway). Verify the
|
||||
real feature live (`/readyz`, the actual flow) after deploy, not just the green.
|
||||
|
||||
## Risk classes
|
||||
|
||||
### 1. Container user — distroless nonroot UID 65532
|
||||
- **Bit us:** TLS keys `chmod 600` for the host owner crash-looped gateway + bot at
|
||||
boot with "permission denied" — service images run UID 65532.
|
||||
- **Check:** any new/changed mounted secret, key, or config file must be readable by
|
||||
UID 65532 (`0644`, not `0600`). Scan the diff for file modes, `chmod`, and new
|
||||
volume mounts. (memory: `distroless-nonroot-mounted-secrets`)
|
||||
|
||||
### 2. Caddy header pipeline ordering
|
||||
- **Bit us:** `header_up delete` after `set` nulled the value (the honeypot tag went
|
||||
empty); it passed CI and only showed up live.
|
||||
- **Check:** in any Caddyfile change, verify the `set` / `delete` / `header_up`
|
||||
ordering for every affected route, and test the tripwire/route on the live
|
||||
contour, not just CI.
|
||||
|
||||
### 3. Edge Alt-Svc / HTTP3
|
||||
- **Bit us:** edge advertised `Alt-Svc: h3` while UDP/443 was never exposed
|
||||
(docker tcp-only + ufw tcp-only); clients cached it 30 days and stalled on dead
|
||||
QUIC before falling back to h2 — Mini App "hangs on load".
|
||||
- **Check:** any edge/caddy change keeps `Alt-Svc: clear` (or only advertises h3 if
|
||||
UDP/443 is genuinely exposed). (memory: `tg-app-load-stall-dead-http3-altsvc`,
|
||||
`docs/EDGE_HTTP3.md`)
|
||||
|
||||
### 4. Prod caddy config recreate
|
||||
- **Bit us:** prod rolling deploy did **not** recreate caddy on a config-only change
|
||||
(pinned `caddy:2-alpine` + `admin off`), so a new Caddyfile deployed GREEN but
|
||||
stayed inert until a manual `docker restart`.
|
||||
- **Check:** a config-only edge change must `--force-recreate` caddy in
|
||||
`prod-deploy.sh` `roll()`; never trust deploy-green for edge config.
|
||||
(memory: `prod-deploy-caddy-config-recreate`)
|
||||
|
||||
### 5. DICT_VERSION / dictionary boot
|
||||
- **Bit us:** an early `DICT_VERSION` refuse-boot guard was wrong and crashed the
|
||||
live env when bumped on a seeded volume; it had to be redesigned to "marker-wins".
|
||||
- **Check:** any change touching `DICT_VERSION`, dict load, or the boot guard must
|
||||
keep marker-wins semantics and survive a seeded volume **and** an image rollback.
|
||||
`DICT_VERSION` is a required build-arg (no default), single-sourced. A new dict
|
||||
goes live via the admin console upload, not a redeploy. (memory:
|
||||
`dict-version-deploy-verify`, `contour-schema-change-wipe`)
|
||||
|
||||
### 6. Migrations — expand-contract + rollback safety
|
||||
- **Bit us / risk:** a non-backward-compatible migration breaks image rollback (DB
|
||||
ahead of rolled-back code).
|
||||
- **Check:** migrations must be **expand-contract** (backward-compatible). A schema
|
||||
change adds the maintenance window + a consistent `pg_dump` in prod-deploy. On the
|
||||
**test contour**, a schema/wire-label change needs `DROP SCHEMA backend CASCADE` +
|
||||
backend restart (new code vs old persisted DB), else the contour breaks. (memory:
|
||||
`contour-schema-change-wipe`)
|
||||
|
||||
### 7. Telegram permission model
|
||||
- **Bit us:** permissions are an **AND-intersection** — default-allow with explicit
|
||||
denies, not default-deny; inverting it broke access.
|
||||
- **Check:** any change to the Telegram permission / relay logic preserves the
|
||||
AND-intersection default-allow shape. (memory: `telegram-forum-relay-gotchas`)
|
||||
|
||||
## Output
|
||||
|
||||
A short PASS/FAIL table over the classes the diff touches, each FAIL with the exact
|
||||
file:line and the fix. If every touched class passes, say so plainly and name the
|
||||
post-deploy live check to run (not just "CI green").
|
||||
@@ -0,0 +1,175 @@
|
||||
# VK Mini App / VK Games — integration reference
|
||||
|
||||
Captured research + our implementation map, so a future session does not need to re-fetch
|
||||
the VK docs. Authoritative external source: <https://dev.vk.com/> (the `dev.vk.com` portal
|
||||
does not render via plain HTTP fetch; the facts below were cross-checked against the VKCOM
|
||||
reference repos cited at the end and verified against our own Go implementation).
|
||||
|
||||
A VK **game** is technically a **VK Mini App**: an HTML5 SPA VK loads in an **iframe inside
|
||||
vk.com** (desktop + mobile web) and in a **WebView** inside the VK mobile apps (iOS/Android).
|
||||
We serve our existing SPA under a dedicated `/vk/` path, mirroring the Telegram `/telegram/`
|
||||
entry — the single-origin, path-routed model.
|
||||
|
||||
## 1. Embedding model
|
||||
|
||||
- VK loads the app at the **Web iframe URL** configured in the app settings (HTTPS + valid
|
||||
cert required), appending the signed launch parameters as the **URL query string**.
|
||||
- An optional separate **Mobile iframe URL** is used by the VK mobile apps (we use the same).
|
||||
- No special `X-Frame-Options` / CSP `frame-ancestors` is required from us — VK frames the
|
||||
configured origin. (Our edge sets **no** framing headers today, so VK works as-is; see the
|
||||
clickjacking note in §Security.)
|
||||
- URL must match the settings exactly (scheme, host, no stray `www`/whitespace).
|
||||
|
||||
## 2. Launch parameters (URL query)
|
||||
|
||||
VK appends these to the iframe `src`. The `vk_*` set is what the signature covers.
|
||||
|
||||
| Param | Meaning |
|
||||
| --- | --- |
|
||||
| `vk_user_id` | signed-in VK user numeric id — **the identity** |
|
||||
| `vk_app_id` | our registered app id |
|
||||
| `vk_is_app_user` | 0/1 — user authorized/installed the app |
|
||||
| `vk_are_notifications_enabled` | 0/1 |
|
||||
| `vk_language` | 2-letter UI language (`ru`, `en`, …) |
|
||||
| `vk_platform` | `mobile_iphone` \| `mobile_android` \| `mobile_web` \| `desktop_web` \| … |
|
||||
| `vk_ts` | unix seconds when VK generated the params |
|
||||
| `vk_ref` | where the app was opened from (`catalog`, `feed`, …) |
|
||||
| `vk_access_token_settings` | comma-separated granted scopes (often empty) |
|
||||
| `vk_group_id`, `vk_viewer_group_role`, `vk_is_favorite`, `vk_client` | optional/contextual |
|
||||
| `sign` | **the signature** (see §3) — NOT part of the signed set |
|
||||
|
||||
Always present: `vk_user_id`, `vk_app_id`, `vk_platform`, `vk_ts`, `sign`.
|
||||
|
||||
The user's **name is NOT in the launch params** (only `vk_user_id`). Read it client-side via
|
||||
`VKWebAppGetUserInfo` (see §4) — unsigned, so treat it as a cosmetic display seed only.
|
||||
|
||||
## 3. Signature verification (`sign`) — CONFIRMED base64url, not hex
|
||||
|
||||
Algorithm (verified against our `gateway/internal/vkauth` + an independent Python reference):
|
||||
|
||||
1. Collect the query params whose key starts with `vk_` (exclude `sign`).
|
||||
2. Sort by key (alphabetical).
|
||||
3. Serialize as a URL-encoded query string `k=v&k=v…` (Go `url.Values.Encode()` matches VK's
|
||||
reference serialization for the constrained launch-param charset).
|
||||
4. `HMAC-SHA256(serialized, secret)` where `secret` = the app's **«Защищённый ключ»**
|
||||
(protected / secure key, a.k.a. client_secret) from the app settings.
|
||||
5. **base64url, no padding** (`+`→`-`, `/`→`_`, strip `=`).
|
||||
6. Constant-time compare against `sign`.
|
||||
|
||||
VK launch params have **no built-in expiry** (unlike Telegram's `auth_date`). We do NOT enforce
|
||||
freshness — the minted server session is the short-lived credential; a replay only
|
||||
re-authenticates the same `vk_user_id`.
|
||||
|
||||
Verified against the official doc <https://dev.vk.com/ru/mini-apps/development/launch-params-sign>
|
||||
(prose + PHP example: base64url = `strtr('+/','-_')` + `rtrim('=')`) and reproduced identically by
|
||||
independent Node `crypto` + Python references. **Doc-example caveat**: that page shows secret
|
||||
`wvl68m4dR1UpLrVRli` → sign `exTIBP…`, but the secret is a **placeholder** — recomputing with it does
|
||||
NOT yield the shown sign (it was made with the real, unshown key). Don't chase the mismatch; our
|
||||
`vkauth.Verify` is correct (`gateway/internal/vkauth/vkauth_test.go` carries cross-checked vectors,
|
||||
incl. the `%2C` comma case for `vk_access_token_settings`).
|
||||
|
||||
## 4. VK Bridge (client SDK)
|
||||
|
||||
`@vkontakte/vk-bridge` (npm, v3.x; bundled — `default` export `bridge`). Methods we use / may use:
|
||||
|
||||
- `VKWebAppInit` — **required**: tells VK the Mini App loaded (dismisses VK's loading cover).
|
||||
- `VKWebAppGetUserInfo` — `{ id, first_name, last_name, photo_200, … }`; no extra scope needed.
|
||||
- `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server
|
||||
verification — read `window.location.search` instead, which carries `sign`).
|
||||
- `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API).
|
||||
- `VKWebAppShare` — native share dialog (the friend-code invite uses it; `navigator.share` is absent
|
||||
in the desktop VK iframe). **Used.**
|
||||
- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is
|
||||
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.
|
||||
- `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
|
||||
it **lazily** so the pure URL helpers stay node-test-importable.
|
||||
|
||||
**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
|
||||
`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
|
||||
`vk.com/app<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
|
||||
`vk.com/app<id>?hash=<payload>` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
|
||||
and an empty `hash`). So the friend-code invite link is just `vk.com/app<id>` (`vkShareLink`, app id
|
||||
from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
|
||||
`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
|
||||
channel (e.g. an invite API) ever delivers a payload.
|
||||
|
||||
## 5. Test mode (to verify before moderation)
|
||||
|
||||
1. App already registered (we have the App ID).
|
||||
2. In the app settings (dev.vk.com / `vk.com/editapp?act=settings&app_id=<id>`):
|
||||
- Category = **Игра** (Game).
|
||||
- **Web iframe URL** = our public HTTPS `/vk/` (the test-contour origin for contour testing,
|
||||
prod `https://erudit-game.ru/vk/` later). Mobile iframe URL = same.
|
||||
- Copy the **«Защищённый ключ»** → set as `GATEWAY_VK_APP_SECRET` (Gitea `TEST_`/`PROD_` secret).
|
||||
- Add own VK id to **testers**; open in test mode.
|
||||
3. Test mode = visible only to admins/testers, no payments processed.
|
||||
|
||||
## 6. Auth / identity (our model)
|
||||
|
||||
- `vk_user_id` (from verified params) → backend identity `kind='vk'`, `external_id=vk_user_id`,
|
||||
auto-confirmed (a platform identity). First contact seeds language from `vk_language` and the
|
||||
display name from the client-supplied `VKWebAppGetUserInfo` name (placeholder if empty).
|
||||
- No VK access token / VK API call needed for the launch+login MVP.
|
||||
|
||||
## 7. Payments / monetization
|
||||
|
||||
VK Pay / «голоса» (votes) are **optional**, not required to publish a free game. Not planned.
|
||||
|
||||
## 8. ToS / moderation (pre-publish, analyzed — no blocker for a free «Эрудит»)
|
||||
|
||||
- **Trademark**: "Scrabble" is trademarked. Our public brand is **«Эрудит»** (erudit-game.ru),
|
||||
a generic Russian word-game name → fine. Ensure the VK-registered app name is «Эрудит»/word-game,
|
||||
NOT "Scrabble". The repo name is internal and irrelevant to moderation.
|
||||
- **Pre-publish requirements**: public **Privacy Policy** + **ToS** URLs (disclose collected data:
|
||||
`vk_user_id`, language; mention VK), **age rating** (likely 6+/12+), icon, description.
|
||||
- **Dictionary**: standard word lists; VK may expect offensive-word filtering — likely fine for a
|
||||
dictionary game, flag if moderation asks.
|
||||
- **In-game chat (UGC)**: we already have a moderated chat + support relay → covered.
|
||||
- Moderation reviews after submission (commonly ~24–72h); rejects on violence/hate/sexual/illegal
|
||||
content or IP infringement — none apply.
|
||||
|
||||
## 9. Platforms
|
||||
|
||||
Desktop web (iframe), mobile web (iframe), VK iOS app (WKWebView), VK Android app (WebView). Bridge
|
||||
methods behave per-platform; the app's own back chevron + app-shell document-pin cover navigation
|
||||
without VK-specific code. Theme/viewport fine-tuning is best verified live in the real VK client
|
||||
(not reproducible in Playwright — like the iOS gesture caveats).
|
||||
|
||||
## 10. Our implementation map (what to touch for VK)
|
||||
|
||||
- **Wire**: `pkg/fbs/scrabble.fbs` → `VKLoginRequest{ params, browser_tz, display_name }`
|
||||
(regen: `make -C pkg fbs` + `pnpm -C ui codegen`).
|
||||
- **Gateway**: `internal/vkauth/` (the §3 verify), `internal/transcode` op `auth.vk`
|
||||
(registered via `WithVKAuth(secret)` option; `DomainCode` → `invalid_vk_params`),
|
||||
`internal/backendclient` `VKAuth` → `POST /api/v1/internal/sessions/vk`,
|
||||
config `GATEWAY_VK_APP_SECRET`, SPA mount `/vk/` in `internal/connectsrv/server.go`.
|
||||
- **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform
|
||||
kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration
|
||||
`00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract).
|
||||
- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName` plus `vkAppId`/
|
||||
`vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing
|
||||
and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts`
|
||||
`encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`,
|
||||
`Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`.
|
||||
- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in
|
||||
`docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml`
|
||||
(`PROD_…` secret, deploy-main).
|
||||
- **Deferred**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an
|
||||
existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via
|
||||
the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.)
|
||||
|
||||
## Sources
|
||||
|
||||
- VKCOM/vk-bridge — <https://github.com/VKCOM/vk-bridge>
|
||||
- VKCOM/vk-apps-launch-params (canonical signature examples) — <https://github.com/VKCOM/vk-apps-launch-params>
|
||||
- kravetsone/vk-launch-params — <https://github.com/kravetsone/vk-launch-params>
|
||||
- SevereCloud/vksdk `vkapps.ParamsVerify` (Go reference) — <https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/vkapps>
|
||||
- VK Mini Apps API — <https://github.com/VKCOM/vk-mini-apps-api>
|
||||
@@ -31,7 +31,7 @@ on:
|
||||
# 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.
|
||||
env:
|
||||
DICT_VERSION: v1.3.0
|
||||
DICT_VERSION: v1.3.1
|
||||
|
||||
jobs:
|
||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||
@@ -207,11 +207,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 +276,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
|
||||
@@ -261,6 +316,9 @@ jobs:
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
||||
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 }}
|
||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||
@@ -277,6 +335,7 @@ 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_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
|
||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||
@@ -337,6 +396,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
|
||||
|
||||
@@ -40,6 +40,7 @@ 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_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
|
||||
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||
@@ -82,6 +83,7 @@ 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 }}
|
||||
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 }}
|
||||
@@ -136,6 +138,7 @@ jobs:
|
||||
export APP_VERSION='$TAG'
|
||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||
EOF
|
||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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(''));
|
||||
@@ -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 `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
|
||||
}
|
||||
// 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: `<path d="${d}" fill="${colour}"/>`, 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 = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
|
||||
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) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
|
||||
|
||||
// ---- 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,
|
||||
`<rect width="180" height="180" fill="${FACE}"/>` +
|
||||
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
|
||||
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 =
|
||||
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
|
||||
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(`<body style="margin:0">${markup}</body>`);
|
||||
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');
|
||||
}
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -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 |
|
||||
@@ -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 = `<!doctype html><html><head><meta charset="utf8"><style>
|
||||
body{margin:0;background:#2b2b2b;font-family:sans-serif;color:#ccc}
|
||||
.row{display:flex;gap:18px;padding:18px;align-items:flex-end;flex-wrap:wrap}
|
||||
.cell{text-align:center}
|
||||
.chk{background-image:linear-gradient(45deg,#8a8a8a 25%,transparent 25%),linear-gradient(-45deg,#8a8a8a 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#8a8a8a 75%),linear-gradient(-45deg,transparent 75%,#8a8a8a 75%);background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0;background-color:#b5b5b5}
|
||||
.s96{width:96px;height:96px}.big{width:336px;height:336px}small{font-size:11px}
|
||||
</style></head><body><div class="row" id="row"></div>
|
||||
<script src="./lottie.min.js"></script>
|
||||
<script>
|
||||
const animationData=${data};window.AD=animationData;
|
||||
const row=document.getElementById('row');window.anims=[];
|
||||
function make(cls,frame,label){
|
||||
const cell=document.createElement('div');cell.className='cell';
|
||||
const box=document.createElement('div');box.className='chk '+cls;cell.appendChild(box);
|
||||
cell.appendChild(document.createElement('br'));
|
||||
const cap=document.createElement('small');cap.textContent=label;cell.appendChild(cap);
|
||||
row.appendChild(cell);
|
||||
const a=lottie.loadAnimation({container:box,renderer:'svg',loop:false,autoplay:false,animationData:JSON.parse(JSON.stringify(animationData))});
|
||||
a.addEventListener('DOMLoaded',()=>a.goToAndStop(frame,true));
|
||||
window.anims.push(a);
|
||||
}
|
||||
[0,22,45,68].forEach(f=>make('s96',f,'f'+f));
|
||||
make('big',22,'f22 x3.5');
|
||||
window.__ready=true;
|
||||
</script></body></html>`;
|
||||
fs.writeFileSync(process.argv[3] || 'preview.html', html);
|
||||
console.log('wrote', process.argv[3] || 'preview.html');
|
||||
@@ -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));
|
||||
@@ -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');
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
File diff suppressed because one or more lines are too long
+4
-2
@@ -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**.
|
||||
|
||||
@@ -180,8 +180,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
// - <name>.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.
|
||||
// - <name>.neg.bin — negative lookups (sequences whose IndexOfB is -1), same
|
||||
// framing, to exercise the not-found path at varying depths.
|
||||
// - <name>.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 <dir>
|
||||
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)
|
||||
}
|
||||
@@ -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 <dir>
|
||||
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)
|
||||
}
|
||||
@@ -22,12 +22,13 @@ import (
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
||||
// alongside platform identities; its confirmed flag is driven by the email
|
||||
// confirm-code flow. Robot is a synthetic kind: each pooled
|
||||
// robot opponent is a durable account bound to one robot identity.
|
||||
// Identity kinds recognised by the backend. Telegram and VK are platform identities,
|
||||
// auto-confirmed on first contact. Email is modelled as an identity alongside them; its
|
||||
// confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
|
||||
// each pooled robot opponent is a durable account bound to one robot identity.
|
||||
const (
|
||||
KindTelegram = "telegram"
|
||||
KindVK = "vk"
|
||||
KindEmail = "email"
|
||||
KindRobot = "robot"
|
||||
)
|
||||
@@ -185,6 +186,28 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
|
||||
return acc, created, err
|
||||
}
|
||||
|
||||
// ProvisionVK provisions (or finds) the account bound to a VK identity, reporting
|
||||
// whether this call created it (first contact). On first contact only, it seeds the new
|
||||
// account's preferred language from the VK languageCode (vk_language, when it maps to a
|
||||
// supported language) and its display name sanitized from displayName — the name read
|
||||
// client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params —
|
||||
// falling back to a generated placeholder when it yields no letters; an already-existing
|
||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
||||
func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) {
|
||||
// Pre-check whether the identity already exists so the caller can act on first
|
||||
// contact (mirrors ProvisionTelegram); a create race only mis-reports created for
|
||||
// that one call.
|
||||
_, err := s.findByIdentity(ctx, KindVK, externalID)
|
||||
created := errors.Is(err, ErrNotFound)
|
||||
if err != nil && !created {
|
||||
return Account{}, false, err
|
||||
}
|
||||
seed := vkSeed(languageCode, displayName)
|
||||
seed.timeZone = seedZone(browserTZ)
|
||||
acc, err := s.provision(ctx, KindVK, externalID, seed)
|
||||
return acc, created, err
|
||||
}
|
||||
|
||||
// provision finds the account for (kind, externalID) or creates it with seed,
|
||||
// collapsing a concurrent-create race on the identity unique constraint into a
|
||||
// re-read of the winner's account.
|
||||
@@ -258,6 +281,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
return seed
|
||||
}
|
||||
|
||||
// vkSeed derives the create-time seed from VK launch fields: a supported preferred
|
||||
// language from languageCode (vk_language, normally a 2-letter code) and a display name
|
||||
// from displayName (sanitized to the editable format), falling back to a generated
|
||||
// placeholder in the seeded language when the name yields no usable letters. Unlike
|
||||
// telegramSeed there is no @username fallback — VK provides only the name.
|
||||
func vkSeed(languageCode, displayName string) provisionSeed {
|
||||
var seed provisionSeed
|
||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||
seed.preferredLanguage = lang
|
||||
}
|
||||
name := sanitizeDisplayName(displayName)
|
||||
if name == "" {
|
||||
name = placeholderDisplayName(seed.preferredLanguage)
|
||||
}
|
||||
seed.displayName = name
|
||||
return seed
|
||||
}
|
||||
|
||||
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
|
||||
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
||||
@@ -421,7 +462,7 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
||||
table.Identities.Kind,
|
||||
table.Identities.ExternalID,
|
||||
table.Identities.Confirmed,
|
||||
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram)
|
||||
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram || kind == KindVK)
|
||||
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
|
||||
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
|
||||
// canonical variant labels joined by "-". It is deliberately distinct from the routing
|
||||
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
|
||||
// router falls through to the lobby for it.
|
||||
const variantSeedPrefix = "v"
|
||||
|
||||
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
|
||||
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
|
||||
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
|
||||
// returns nil for any payload that is not a variant-seed link or that fails validation
|
||||
// against the known variants, so a malformed, empty or unrelated start-param simply
|
||||
// leaves the account on its default preferences rather than failing the login.
|
||||
func SeedVariantsFromStartParam(startParam string) []string {
|
||||
if !strings.HasPrefix(startParam, variantSeedPrefix) {
|
||||
return nil
|
||||
}
|
||||
body := strings.TrimPrefix(startParam, variantSeedPrefix)
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
// SetVariantPreferences overwrites only the variant-preference set of the account,
|
||||
// cleaning it to a deduplicated, canonically ordered subset of the known variants
|
||||
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
|
||||
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
|
||||
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
|
||||
// without disturbing its other profile fields.
|
||||
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
|
||||
clean, err := validateVariantPreferences(prefs)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
// clean is validated against the closed knownVariants set; bind as a text[]
|
||||
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
|
||||
// UpdateProfile.
|
||||
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
|
||||
postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||
// returns the stored row. It reports ErrInvalidProfile for a bad language,
|
||||
// timezone or display name and ErrNotFound when no account matches id.
|
||||
|
||||
@@ -75,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
||||
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKSeed covers the pure mapping from VK launch fields to the create-time account
|
||||
// seed: supported-language detection from vk_language (bare and region-tagged) and the
|
||||
// display name sanitized from the client-supplied name. Unlike Telegram there is no
|
||||
// @username fallback — VK provides only the name.
|
||||
func TestVKSeed(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, displayName string
|
||||
wantLang, wantName string
|
||||
}{
|
||||
"ru bare": {"ru", "Иван", "ru", "Иван"},
|
||||
"en region-tagged": {"en-US", "John", "en", "John"},
|
||||
"full name kept": {"ru", "Иван Петров", "ru", "Иван Петров"},
|
||||
"unknown language": {"uk", "Тарас", "", "Тарас"},
|
||||
"empty language": {"", "Neo", "", "Neo"},
|
||||
"trimmed": {" RU ", " Anna ", "ru", "Anna"},
|
||||
"emoji stripped": {"en", "🎮Kaya🎮", "en", "Kaya"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := vkSeed(tc.languageCode, tc.displayName)
|
||||
if got.preferredLanguage != tc.wantLang {
|
||||
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
|
||||
}
|
||||
if got.displayName != tc.wantName {
|
||||
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKSeedPlaceholder checks a VK name with no usable letters falls back to a
|
||||
// generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
|
||||
func TestVKSeedPlaceholder(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, displayName string
|
||||
wantRe string
|
||||
}{
|
||||
"en empty": {"en", "", `^Player-\d{5}$`},
|
||||
"ru empty": {"ru", "", `^Игрок-\d{5}$`},
|
||||
"default en": {"uk", "", `^Player-\d{5}$`},
|
||||
"name garbage": {"ru", "123!@#", `^Игрок-\d{5}$`},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := vkSeed(tc.languageCode, tc.displayName).displayName
|
||||
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
|
||||
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the
|
||||
// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned
|
||||
// to the canonical order and deduplicated, while anything that is not a variant-seed link
|
||||
// or that names an unknown variant yields nil (leaving the account on its defaults).
|
||||
func TestSeedVariantsFromStartParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
param string
|
||||
want []string
|
||||
}{
|
||||
{"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}},
|
||||
{"single variant", "vscrabble_en", []string{"scrabble_en"}},
|
||||
{"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}},
|
||||
{"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}},
|
||||
{"empty", "", nil},
|
||||
{"prefix only", "v", nil},
|
||||
{"routing game link is not a seed", "g0190abcd", nil},
|
||||
{"friend code link is not a seed", "f123456", nil},
|
||||
{"unknown variant rejected", "vscrabble_de", nil},
|
||||
{"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SeedVariantsFromStartParam(tc.param)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", VKID: "494075"}, "vk.com/id494075"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||
|
||||
@@ -142,6 +142,11 @@
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{if .VKID}}
|
||||
<section class="panel"><h2>VK</h2>
|
||||
<p>VK ID: <code>{{.VKID}}</code> · <a href="https://vk.com/id{{.VKID}}" target="_blank" rel="noopener">open profile</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>Games</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
|
||||
@@ -156,13 +156,17 @@ type UserDetailView struct {
|
||||
HintBalance int
|
||||
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
||||
// server's maxHintGrant), passed through so the policy value lives in one place.
|
||||
HintGrantMax int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
HintGrantMax int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
// TelegramID and VKID are the account's platform external ids (empty when absent).
|
||||
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
|
||||
// user id with a link to the VK profile (there is no VK messaging to drive).
|
||||
TelegramID string
|
||||
VKID string
|
||||
ConnectorEnabled bool
|
||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||
// time (min/mean/max), empty when the account has no timed move.
|
||||
|
||||
@@ -21,11 +21,13 @@ var dictFiles = map[Variant]string{
|
||||
VariantErudit: "ru_erudit.dawg",
|
||||
}
|
||||
|
||||
// entry is one resident dictionary: the loaded finder and the solver built over
|
||||
// it. The finder is retained so Close can release it.
|
||||
// entry is one resident dictionary: the loaded finder, the solver built over it
|
||||
// and the file it was loaded from. The finder is retained so Close can release
|
||||
// it; path is retained so the raw bytes can be re-read for the client download.
|
||||
type entry struct {
|
||||
finder dawg.Finder
|
||||
solver *scrabble.Solver
|
||||
path string
|
||||
}
|
||||
|
||||
// Registry holds the dictionaries resident in memory, addressed by variant and
|
||||
@@ -130,7 +132,7 @@ func (r *Registry) Load(v Variant, version, dir string) error {
|
||||
if old, ok := r.entries[v][version]; ok {
|
||||
_ = old.finder.Close()
|
||||
}
|
||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
|
||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder), path: path}
|
||||
r.latest[v] = version
|
||||
return nil
|
||||
}
|
||||
@@ -202,6 +204,30 @@ func (r *Registry) Versions(v Variant) []string {
|
||||
return versions
|
||||
}
|
||||
|
||||
// DictBytes returns the raw serialized DAWG for the (variant, version) pair,
|
||||
// re-read from the file it was loaded from — the same immutable bytes the solver
|
||||
// holds. It backs the client-side dictionary download for the local move
|
||||
// preview. It returns ErrUnknownVariant or ErrUnknownVersion when that dictionary
|
||||
// is not resident, and wraps any read error. The file is read outside the lock.
|
||||
func (r *Registry) DictBytes(v Variant, version string) ([]byte, error) {
|
||||
r.mu.RLock()
|
||||
versions, ok := r.entries[v]
|
||||
if !ok {
|
||||
r.mu.RUnlock()
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
||||
}
|
||||
e, ok := versions[version]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
||||
}
|
||||
data, err := os.ReadFile(e.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("engine: read %s/%s dictionary bytes from %s: %w", v, version, e.path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Lookup reports whether word is present in the (variant, version) dictionary,
|
||||
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
|
||||
// ErrUnknownVersion when that dictionary is not resident, and an error when word
|
||||
|
||||
@@ -54,8 +54,14 @@ type Service struct {
|
||||
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
||||
// best-effort and kept as a func so the game package never imports the social package.
|
||||
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
|
||||
metrics *gameMetrics
|
||||
log *zap.Logger
|
||||
// expireNudges, when set, marks every pending nudge in a game read once the game
|
||||
// finishes (the nudge badge is stale on a completed game). Unlike clearNudges it is
|
||||
// keyed by game alone — it clears all seats' nudges, not one mover's — and runs on
|
||||
// every completion path through commit. Best-effort; a func so the game package never
|
||||
// imports the social package.
|
||||
expireNudges func(ctx context.Context, gameID uuid.UUID) error
|
||||
metrics *gameMetrics
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewService constructs a Service. store and accounts wrap the same pool;
|
||||
@@ -107,6 +113,15 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account
|
||||
svc.clearNudges = fn
|
||||
}
|
||||
|
||||
// SetNudgeExpirer installs the hook that marks every pending nudge in a game read once the
|
||||
// game finishes, on any completion path (a closing move, a resignation, a turn-timeout or a
|
||||
// forfeit). It must be called during startup wiring; the default (nil) leaves a finished
|
||||
// game's nudges to expire only when a recipient opens the move history or chat. The social
|
||||
// package wires its ExpireNudges here. Chat messages are deliberately left unread.
|
||||
func (svc *Service) SetNudgeExpirer(fn func(ctx context.Context, gameID uuid.UUID) error) {
|
||||
svc.expireNudges = fn
|
||||
}
|
||||
|
||||
// SetFirstMoveEntropy overrides the entropy source for the first-move draw
|
||||
// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any
|
||||
// game is created; the production default is crypto/rand and is never overridden.
|
||||
@@ -699,6 +714,16 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
||||
}
|
||||
if c.finished {
|
||||
svc.cache.remove(gameID)
|
||||
// A finished game's nudges are stale, so clear them all here — every completion path
|
||||
// funnels through commit (a closing move, a resignation, a forfeit or a turn-timeout),
|
||||
// and only the move path also clears the mover's nudge on its own. Best-effort like
|
||||
// clearNudges: the finish has committed, so a cleanup failure is logged, not surfaced.
|
||||
// ExpireNudges leaves chat messages unread.
|
||||
if svc.expireNudges != nil {
|
||||
if err := svc.expireNudges(ctx, gameID); err != nil {
|
||||
svc.log.Warn("expire nudges on game finish", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
post, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -1440,13 +1465,24 @@ func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.store.VoidGame(ctx, voidCommit{
|
||||
if err := svc.store.VoidGame(ctx, voidCommit{
|
||||
gameID: pre.ID,
|
||||
endReason: g.Reason().String(),
|
||||
scores: scores,
|
||||
now: svc.clock(),
|
||||
stats: buildStats(g, statSeats),
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// A voided game is finished (as a draw) but bypasses commit, so clear its now-stale nudges
|
||||
// here too. Best-effort, like the commit path: the void has persisted, so a cleanup failure
|
||||
// is logged, not surfaced.
|
||||
if svc.expireNudges != nil {
|
||||
if err := svc.expireNudges(ctx, pre.ID); err != nil {
|
||||
svc.log.Warn("expire nudges on voided game", zap.Error(err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replayMove re-applies one journalled move to g through the decoded engine API.
|
||||
@@ -1613,6 +1649,14 @@ func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bo
|
||||
return present, nil
|
||||
}
|
||||
|
||||
// DictBytes returns the raw serialized dictionary for the (variant, version) pair
|
||||
// from the registry, backing the client-side dictionary download used by the
|
||||
// local move preview. It surfaces engine.ErrUnknownVariant /
|
||||
// engine.ErrUnknownVersion when that dictionary is not resident.
|
||||
func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) {
|
||||
return svc.registry.DictBytes(variant, version)
|
||||
}
|
||||
|
||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
||||
// allowance plus the profile wallet.
|
||||
func hintsRemaining(allowance, used, wallet int) int {
|
||||
|
||||
@@ -154,6 +154,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionVKSeedsNewAccountOnly checks VK first contact seeds the new account's
|
||||
// language, display name and time zone from the launch fields / detected offset, records
|
||||
// the vk identity as confirmed (a platform identity), and never overwrites an existing
|
||||
// account on a later launch. It also exercises the widened identities.kind CHECK — a
|
||||
// 'vk' row must insert.
|
||||
func TestProvisionVKSeedsNewAccountOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
ext := "vk-" + uuid.NewString()
|
||||
|
||||
acc, created, err := store.ProvisionVK(ctx, ext, "ru", "Иван Петров", "+03:00")
|
||||
if err != nil {
|
||||
t.Fatalf("provision vk: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Error("created = false on first contact, want true")
|
||||
}
|
||||
if acc.PreferredLanguage != "ru" {
|
||||
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
|
||||
}
|
||||
if acc.DisplayName != "Иван Петров" {
|
||||
t.Errorf("DisplayName = %q, want Иван Петров", acc.DisplayName)
|
||||
}
|
||||
if acc.TimeZone != "+03:00" {
|
||||
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
|
||||
}
|
||||
// A VK identity is a platform identity: confirmed on insert.
|
||||
if !identityConfirmed(t, account.KindVK, ext) {
|
||||
t.Error("vk identity must be confirmed")
|
||||
}
|
||||
|
||||
// A later launch with different fields returns the same account, unchanged.
|
||||
again, created, err := store.ProvisionVK(ctx, ext, "en", "Other Name", "+09:00")
|
||||
if err != nil {
|
||||
t.Fatalf("re-provision vk: %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Error("created = true on a repeat launch, want false")
|
||||
}
|
||||
if again.ID != acc.ID {
|
||||
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
|
||||
}
|
||||
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван Петров" || again.TimeZone != "+03:00" {
|
||||
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
|
||||
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
|
||||
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
|
||||
|
||||
@@ -138,6 +138,65 @@ func TestNudgeClearedByMove(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameCompletionExpiresNudgesKeepsChat checks that finishing a game by turn-timeout marks every
|
||||
// pending nudge in it read — the lobby's nudge badge is stale once the game is over — while leaving
|
||||
// real chat messages unread. It reproduces the reported bug: the timeout path commits the finish
|
||||
// directly, bypassing the move path's per-mover nudge clear, so without a completion-driven expiry
|
||||
// the awaited seat's nudge lingered as a badge on the finished game.
|
||||
func TestGameCompletionExpiresNudgesKeepsChat(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gameSvc := newGameService()
|
||||
socialSvc := newSocialService()
|
||||
gameSvc.SetNudgeExpirer(socialSvc.ExpireNudges)
|
||||
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
g, err := gameSvc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Seat 1 nudges the to-move seat 0 (the awaited player), and seat 0 posts a real chat message,
|
||||
// which seat 1 then holds unread. So before the timeout each side has exactly one unread entry:
|
||||
// seat 0 a nudge, seat 1 a message — letting HasUnread isolate each kind.
|
||||
if _, err := socialSvc.Nudge(ctx, g.ID, seats[1]); err != nil {
|
||||
t.Fatalf("nudge: %v", err)
|
||||
}
|
||||
if _, err := socialSvc.PostMessage(ctx, g.ID, seats[0], "good luck", ""); err != nil {
|
||||
t.Fatalf("post message: %v", err)
|
||||
}
|
||||
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); !unread {
|
||||
t.Fatal("seat 0 should hold the nudge unread before the timeout")
|
||||
}
|
||||
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
|
||||
t.Fatal("seat 1 should hold the message unread before the timeout")
|
||||
}
|
||||
|
||||
// Age the turn past its deadline and time seat 0 out; an empty away window keeps this
|
||||
// deterministic regardless of the wall clock. The sweep finishes the game through the direct
|
||||
// commit path, never the move path.
|
||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||
setAway(t, seats[0], "UTC", "00:00", "00:00")
|
||||
if n, err := gameSvc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
||||
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
|
||||
}
|
||||
if status, reason := gameStatus(t, gameSvc, g.ID); status != game.StatusFinished || reason != "timeout" {
|
||||
t.Fatalf("game not timed out: status %q reason %q", status, reason)
|
||||
}
|
||||
|
||||
// The nudge is stale on a finished game and must be cleared; the chat message must survive.
|
||||
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); unread {
|
||||
t.Error("the nudge should be expired once the game has finished")
|
||||
}
|
||||
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
|
||||
t.Error("a real chat message must stay unread after the game finishes")
|
||||
}
|
||||
if msg, _ := socialSvc.HasUnreadMessage(ctx, g.ID, seats[1]); !msg {
|
||||
t.Error("the chat message must remain flagged as an unread message after completion")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled
|
||||
// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the
|
||||
// chat, so the message must not linger unread (skewing the count and the read metric).
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
|
||||
// to confirm a promo deep-link start-param seeds a brand-new account's variant
|
||||
// preferences (English Scrabble alongside the default Erudit), that a new account with no
|
||||
// such payload keeps the Erudit-only default, and that an existing account is never
|
||||
// re-seeded on a later login (the new-user-only contract).
|
||||
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
DB: testDB,
|
||||
Accounts: account.NewStore(testDB),
|
||||
Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
|
||||
Notifier: &captureNotifier{},
|
||||
})
|
||||
h := srv.Handler()
|
||||
|
||||
post := func(ext, startParam string) {
|
||||
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
|
||||
if startParam != "" {
|
||||
body += `,"start_param":"` + startParam + `"`
|
||||
}
|
||||
body += `}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
store := account.NewStore(testDB)
|
||||
reload := func(ext string) []string {
|
||||
acc, err := store.AccountByIdentity(context.Background(), account.KindTelegram, ext)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup %s: %v", ext, err)
|
||||
}
|
||||
return acc.VariantPreferences
|
||||
}
|
||||
|
||||
// A brand-new account reached through a promo deep-link is seeded with English
|
||||
// Scrabble alongside the default Erudit.
|
||||
promoExt := "tg-" + uuid.NewString()
|
||||
post(promoExt, "verudit_ru-scrabble_en")
|
||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||
t.Errorf("promo new account variants = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// A brand-new account with no promo payload keeps the Erudit-only default.
|
||||
plainExt := "tg-" + uuid.NewString()
|
||||
post(plainExt, "")
|
||||
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
|
||||
t.Errorf("plain new account variants = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// A later login of the promo account, even via a different payload, must not re-seed:
|
||||
// the seed is first-contact only.
|
||||
post(promoExt, "vscrabble_ru")
|
||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Admit the 'vk' platform identity (VK Mini App users) into the identities.kind check
|
||||
-- constraint, alongside telegram/email/robot. Expand-contract: widening the allowed set
|
||||
-- is backward-compatible, so a backend image rollback stays DB-safe — the older code
|
||||
-- simply never writes a 'vk' row. The table shape is unchanged (only the CHECK), so the
|
||||
-- generated go-jet model is not regenerated.
|
||||
|
||||
-- +goose Up
|
||||
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
|
||||
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'vk'::text, 'email'::text, 'robot'::text])));
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
|
||||
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'email'::text, 'robot'::text])));
|
||||
@@ -27,6 +27,7 @@ func (s *Server) registerRoutes() {
|
||||
if s.sessions != nil && s.accounts != nil {
|
||||
in := s.internal
|
||||
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
||||
in.POST("/sessions/vk", s.handleVKAuth)
|
||||
in.POST("/sessions/guest", s.handleGuestAuth)
|
||||
in.POST("/sessions/email/request", s.handleEmailRequest)
|
||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||
@@ -89,6 +90,9 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||
u.POST("/games/:id/hide", s.handleHideGame)
|
||||
// Raw dictionary download for the client-side local move preview, keyed by
|
||||
// the game's pinned (variant, version); immutable, so cached hard.
|
||||
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
||||
}
|
||||
if s.feedback != nil {
|
||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||
|
||||
@@ -362,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
||||
view.TelegramID = tg
|
||||
}
|
||||
if vk, err := s.accounts.IdentityExternalID(ctx, id, account.KindVK); err == nil {
|
||||
view.VKID = vk
|
||||
}
|
||||
if games, err := s.games.ListForAccount(ctx, id); err == nil {
|
||||
for _, g := range games {
|
||||
view.Games = append(view.Games, gameRow(g))
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
@@ -19,13 +20,16 @@ import (
|
||||
// telegramAuthRequest carries the identity the connector extracted from a
|
||||
// validated initData payload. Username, FirstName and LanguageCode seed a
|
||||
// brand-new account's display name and language; BrowserTZ (the client's detected
|
||||
// "±HH:MM" UTC offset) seeds its time zone (first contact only).
|
||||
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
|
||||
// deep-link payload, which may seed the new account's variant preferences (first
|
||||
// contact only).
|
||||
type telegramAuthRequest struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
BrowserTZ string `json:"browser_tz"`
|
||||
StartParam string `json:"start_param"`
|
||||
}
|
||||
|
||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||
@@ -47,6 +51,47 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||
// joined the chat before registering is granted on the spot (no chat_member
|
||||
// event fires on registration).
|
||||
s.publishChatAccessChange(acc.ID)
|
||||
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
|
||||
// English Scrabble alongside the default Erudit). Best-effort: an absent or
|
||||
// malformed payload leaves the account on its defaults, and a write failure must
|
||||
// not block the session mint.
|
||||
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
|
||||
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
|
||||
s.log.Warn("telegram: seed variant preferences failed",
|
||||
zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
|
||||
// params. LanguageCode (vk_language) and DisplayName (read client-side via
|
||||
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
|
||||
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
|
||||
// offset) seeds its time zone. All seeds apply on first contact only.
|
||||
type vkAuthRequest struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BrowserTZ string `json:"browser_tz"`
|
||||
}
|
||||
|
||||
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
|
||||
// session for it, seeding a new account's language and display name from the supplied VK
|
||||
// fields (first contact only). Unlike Telegram there is no moderated-chat re-evaluation
|
||||
// or deep-link variant seed: a fresh VK account has no Telegram chat eligibility and the
|
||||
// MVP carries no launch deep link.
|
||||
func (s *Server) handleVKAuth(c *gin.Context) {
|
||||
var req vkAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
abortBadRequest(c, "external_id is required")
|
||||
return
|
||||
}
|
||||
acc, _, err := s.accounts.ProvisionVK(c.Request.Context(), req.ExternalID, req.LanguageCode, req.DisplayName, req.BrowserTZ)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// handleDictBytes streams the raw serialized dictionary for a (variant, version)
|
||||
// pair so the client can validate and score moves locally — the local move
|
||||
// preview — instead of a network round trip per tile arrangement. It is reached
|
||||
// only through the gateway's session-gated /dict route (which resolves the
|
||||
// X-User-ID this group requires), and served with a long immutable cache lifetime
|
||||
// because a published dictionary version never changes. An unknown variant or a
|
||||
// version that is not resident is a 404.
|
||||
func (s *Server) handleDictBytes(c *gin.Context) {
|
||||
variant, err := engine.ParseVariant(c.Param("variant"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "unknown variant"})
|
||||
return
|
||||
}
|
||||
data, err := s.games.DictBytes(variant, c.Param("version"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "dictionary not found"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
@@ -55,6 +55,25 @@ func (svc *Service) ClearNudges(ctx context.Context, gameID, accountID uuid.UUID
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireNudges marks every pending nudge in the game read, for when the game finishes: a nudge
|
||||
// badge is stale once the game is over, so completion clears them all for every seat. Chat
|
||||
// messages are left untouched (they stay unread). It satisfies game.NudgeExpirer and is wired
|
||||
// into the completion path; failures are the caller's to log (the game has already finished).
|
||||
// Unlike ClearNudges it records no publish-to-read latency — a completion is an expiry, not the
|
||||
// recipient reading the nudge — so the latency metric is not skewed by games that end hours later.
|
||||
func (svc *Service) ExpireNudges(ctx context.Context, gameID uuid.UUID) error {
|
||||
ctx, span := svc.tracer.Start(ctx, "social.ExpireNudges",
|
||||
trace.WithAttributes(attribute.String("game.id", gameID.String())))
|
||||
defer span.End()
|
||||
n, err := svc.store.expireNudges(ctx, gameID)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
span.SetAttributes(attribute.Int64("expired.count", n))
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnreadGames returns the set of games in which viewerID has at least one unread chat
|
||||
// entry, for seeding the lobby's per-card unread badge in a single query.
|
||||
func (svc *Service) UnreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||
@@ -140,6 +159,21 @@ RETURNING m.created_at`
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// expireNudges marks every still-unread nudge in the game read for all seats at once, used when
|
||||
// the game finishes. It returns the number of nudge entries it cleared. Only 'nudge' rows are
|
||||
// touched, so chat messages keep their unread bits; it returns no post times because a completion
|
||||
// is an expiry, not a player reading, and must not feed the publish-to-read latency metric.
|
||||
func (s *Store) expireNudges(ctx context.Context, gameID uuid.UUID) (int64, error) {
|
||||
const q = `UPDATE backend.chat_messages
|
||||
SET unread_seats = 0
|
||||
WHERE game_id = $1 AND kind = 'nudge' AND unread_seats <> 0`
|
||||
res, err := s.db.ExecContext(ctx, q, gameID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("social: expire nudges: %w", err)
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// unreadGames returns the games where viewerID has an unread entry, resolving their
|
||||
// seat per game through the game_players join.
|
||||
func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
||||
VITE_TELEGRAM_BOT_ID=
|
||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||
VITE_GATEWAY_URL=
|
||||
|
||||
# --- Grafana ----------------------------------------------------------------
|
||||
@@ -51,6 +52,13 @@ TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the suppor
|
||||
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it
|
||||
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
||||
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
||||
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
||||
TELEGRAM_MINIAPP_URL= # required
|
||||
TELEGRAM_TEST_ENV=false
|
||||
TELEGRAM_API_BASE_URL=
|
||||
|
||||
# --- VK Mini App ------------------------------------------------------------
|
||||
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||
GATEWAY_VK_APP_SECRET=
|
||||
|
||||
+3
-2
@@ -92,9 +92,10 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
||||
| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). |
|
||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||
|
||||
The five `VITE_*` are **build-args** baked into the gateway and landing images at
|
||||
The six `VITE_*` are **build-args** baked into the gateway and landing images at
|
||||
build time (both targets share one UI build stage — keep the args identical so it is
|
||||
built once), so changing them requires a rebuild (`--build`), not just a restart.
|
||||
|
||||
@@ -198,7 +199,7 @@ BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`;
|
||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
||||
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
||||
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME}`.
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK}`.
|
||||
|
||||
## Host-side setup (outside this repo)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
|
||||
# every operator surface under /_gm (the backend-rendered admin console and the
|
||||
# Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to
|
||||
# Grafana subpath); the game SPA (/app/, /telegram/, /vk/) and the Connect edge go to
|
||||
# the gateway; the catch-all — notably the public landing at / — goes to the
|
||||
# static landing container, so stray traffic never reaches the Go edge.
|
||||
# Mirrors ../galaxy-game's /_gm model.
|
||||
@@ -53,7 +53,7 @@
|
||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||
@gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/*
|
||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /metrics/* /scrabble.edge.v1.Gateway/*
|
||||
handle @gateway {
|
||||
reverse_proxy gateway:8081 {
|
||||
header_up -X-Scrabble-Honeypot
|
||||
|
||||
@@ -31,6 +31,7 @@ services:
|
||||
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
||||
TELEGRAM_PROMO_START_PARAM: ${TELEGRAM_PROMO_START_PARAM:-}
|
||||
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
|
||||
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
|
||||
TELEGRAM_TEST_ENV: "false"
|
||||
|
||||
@@ -134,6 +134,7 @@ 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_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).
|
||||
@@ -147,6 +148,10 @@ services:
|
||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||
# Telegram auth validates against the home validator (plaintext, internal).
|
||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||
# 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:-}
|
||||
# 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
|
||||
@@ -203,6 +208,7 @@ 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_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -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}}" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+81
-19
@@ -42,7 +42,12 @@ Three executables plus per-platform side-services:
|
||||
users, a weighted fair rotation — §10),
|
||||
and a client **board-style** setting (bonus-label
|
||||
mode). The visual/interaction design system is documented in
|
||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||
[`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally
|
||||
tracks Telegram's live theme switch (`themeChanged`), fits the full device safe-area
|
||||
insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
|
||||
Telegram's native **Settings** button into the in-app settings, and syncs the
|
||||
device-independent display preferences (theme, reduce-motion, board labels — **not** the
|
||||
interface language) across the user's Telegram devices via **CloudStorage**.
|
||||
- **`platform/telegram`** — the Telegram side-service (module
|
||||
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
||||
(**one bot**, one optional game channel, §3):
|
||||
@@ -144,15 +149,23 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
|
||||
- The gateway validates the originating credential **once** — Telegram `initData`
|
||||
(delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
|
||||
the HMAC secret — so it never reaches the gateway), an email-code login, or a guest
|
||||
the HMAC secret — so it never reaches the gateway), a **VK Mini App** launch (verified
|
||||
**in-process** by `gateway/internal/vkauth`: HMAC-SHA256 over the signed `vk_*` params
|
||||
under the VK app's protected key `GATEWAY_VK_APP_SECRET`, base64url — a pure offline check,
|
||||
as VK signing needs no API round-trip, so no side-service), an email-code login, or a guest
|
||||
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
|
||||
Telegram contact seeds the new account's language (from the launch `language_code`)
|
||||
and display name (§4). The validator runs on the main host and never reaches the Bot
|
||||
API, so login does not depend on Telegram or the remote bot being up (§10, §12).
|
||||
Telegram/VK contact seeds the new account's language (Telegram's `language_code` / VK's
|
||||
`vk_language`) and display name (§4; VK omits the name from the signed params, so the client
|
||||
reads it via `VKWebAppGetUserInfo` as an unsigned, cosmetic seed). The validator runs on the
|
||||
main host and never reaches the Bot API, so login does not depend on Telegram or the remote
|
||||
bot being up (§10, §12). VK launch params carry no built-in expiry (unlike Telegram's
|
||||
`auth_date`), so freshness is not enforced — the minted session is the short-lived credential,
|
||||
and a replay only re-authenticates the same `vk_user_id`.
|
||||
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
|
||||
game channel), split into a home **validator** and a remote **bot** that share the
|
||||
token. `ValidateInitData` (the validator) validates `initData` against that single
|
||||
token and returns only the Telegram user identity — there is no per-bot "service
|
||||
token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram
|
||||
user identity — there is no per-bot "service
|
||||
language" and no supported-languages set on the wire. The bot's chat messages and
|
||||
out-of-app push are
|
||||
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
|
||||
@@ -204,7 +217,7 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
> recipient's interface language (`preferred_language`), with no per-bot routing. New
|
||||
> Game variant gating moved off the login language onto a per-user profile setting
|
||||
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
|
||||
> paths; an invited friend may still accept any variant). The per-bot env vars and
|
||||
> paths; an invited friend may still accept any variant, and a Telegram **promo deep-link** seeds extra variants — e.g. English Scrabble — onto a brand-new account via the validated `start_param`). The per-bot env vars and
|
||||
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
|
||||
> `service_language`/`supported_languages` and the push `language` routing field, and
|
||||
> gained `variant_preferences` on Profile/UpdateProfile.
|
||||
@@ -339,6 +352,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
|
||||
|
||||
@@ -633,7 +657,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`**
|
||||
@@ -643,8 +671,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
|
||||
@@ -758,7 +787,9 @@ 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.
|
||||
shares the `.gcg` file via the Web Share API where available; an Android in-app WebView
|
||||
(Telegram / VK) has no Web Share and silently ignores an `<a download>`, so there it copies the GCG
|
||||
text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file.
|
||||
|
||||
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 —
|
||||
@@ -924,6 +955,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;
|
||||
@@ -972,7 +1017,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
||||
| Concern | Enforced by |
|
||||
| --- | --- |
|
||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
|
||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
|
||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||
@@ -1035,24 +1080,39 @@ 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
|
||||
**landing page** and the game **SPA**. The gateway **embeds** the SPA build
|
||||
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
|
||||
`/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
||||
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
||||
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
||||
of redirecting away); a stray hit on the gateway's `/`
|
||||
308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
|
||||
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
|
||||
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
|
||||
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
||||
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
||||
**admin console**; `/app/`, `/telegram/` and the Connect path go to the gateway; the
|
||||
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
|
||||
catch-all — notably the landing at `/` — goes to the landing container. The
|
||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||
@@ -1204,8 +1264,10 @@ migration.
|
||||
**Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct
|
||||
support channel for users who message it on Telegram. Any message other than `/start` is relayed
|
||||
into a private **forum supergroup** (`TELEGRAM_SUPPORT_CHAT_ID`): a user's first message opens a
|
||||
dedicated **forum topic** whose first message is an info card (name, @username, language, premium,
|
||||
id, profile deep-link) carrying a Block/Unblock toggle and a Clear button; every message is then
|
||||
dedicated **forum topic** whose first message is an info card (the name is a tappable profile
|
||||
mention via a `text_mention` entity — which also keeps a name beginning with `/` from being read as
|
||||
a command — plus @username, language, premium, id) carrying a Block/Unblock toggle and a Clear
|
||||
button; every message is then
|
||||
copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over).
|
||||
Any **administrator** of the support chat who writes in a user's topic has their message copied
|
||||
back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the
|
||||
|
||||
+48
-11
@@ -21,19 +21,46 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
|
||||
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
|
||||
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||
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. 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
|
||||
ephemeral guest. The gateway validates the credential once and mints a thin
|
||||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
||||
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
||||
the Telegram colours, and — on first contact — seeds the new account's interface
|
||||
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits
|
||||
the device safe-area, and — on first contact — seeds the new account's interface
|
||||
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
||||
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
||||
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
||||
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). 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.
|
||||
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
|
||||
@@ -120,7 +147,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 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.
|
||||
|
||||
@@ -141,7 +169,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 —
|
||||
@@ -238,7 +269,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
|
||||
@@ -249,12 +281,16 @@ is first created — so robot games are timed correctly before you ever open thi
|
||||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||
merge".
|
||||
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||||
entry that opens this screen, and your display preferences (theme, board-label style and
|
||||
reduce-motion — not the interface language, which follows your account) sync across your
|
||||
Telegram devices.
|
||||
|
||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||
yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at
|
||||
least one** selected. This list is exactly what **New Game** offers when you start a game
|
||||
yourself to be matched into; a **new account starts with Erudite only** — unless it was created
|
||||
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
|
||||
**at least one** selected. This list is exactly what **New Game** offers when you start a game
|
||||
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
|
||||
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
|
||||
invited friend may accept an invitation in **any** variant, and you can always open and play
|
||||
@@ -286,7 +322,8 @@ 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):
|
||||
it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file
|
||||
download, it copies the GCG to the clipboard (with a confirming toast); otherwise it downloads the file. 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
|
||||
|
||||
+53
-13
@@ -22,20 +22,50 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||
и ограничивает частоту повторов.
|
||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
||||
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||
тему и ведёт в 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-вход или как
|
||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
||||
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
||||
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и
|
||||
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
|
||||
интерфейса нового аккаунта по
|
||||
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
||||
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
||||
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
||||
Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним
|
||||
Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным
|
||||
launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса
|
||||
нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте,
|
||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||
тихого повтора «не удалось
|
||||
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним
|
||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
||||
@@ -125,7 +155,8 @@ _Вход сейчас только через провайдера, поэто
|
||||
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
||||
кто выбрал то же правило. Игры с друзьями (2–4)
|
||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
||||
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
|
||||
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
|
||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||
ответа приглашение протухает через семь дней.
|
||||
|
||||
@@ -147,7 +178,9 @@ _Вход сейчас только через провайдера, поэто
|
||||
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
||||
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
||||
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
||||
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
|
||||
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
|
||||
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||
чтобы его посмотреть. Подсказки управляются настройками
|
||||
@@ -245,7 +278,8 @@ _Вход сейчас только через провайдера, поэто
|
||||
**лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую,
|
||||
когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже
|
||||
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
|
||||
его получатель **делает ход**.
|
||||
его получатель **делает ход**, и **все nudge'и партии гаснут по её завершении** (после конца
|
||||
партии бейдж неактуален) — но непрочитанные сообщения чата остаются непрочитанными.
|
||||
|
||||
### Профиль и настройки
|
||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||
@@ -255,12 +289,17 @@ UTC; при создании аккаунта она подставляется
|
||||
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
|
||||
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
|
||||
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||
Telegram.
|
||||
|
||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
|
||||
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
||||
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
|
||||
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
|
||||
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
||||
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
|
||||
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
|
||||
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
|
||||
@@ -291,8 +330,9 @@ UTC; при создании аккаунта она подставляется
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
||||
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
|
||||
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
|
||||
иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
||||
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает;
|
||||
в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG
|
||||
в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов):
|
||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||
|
||||
+10
-1
@@ -17,10 +17,19 @@ 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.
|
||||
- **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
|
||||
|
||||
+70
-6
@@ -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
|
||||
@@ -357,7 +405,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
||||
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
|
||||
header) delivers the `.gcg` file — Web Share where available, a clipboard copy in an Android in-app
|
||||
WebView (Telegram / VK, which has neither Web Share nor a working download), else a Blob download —
|
||||
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.
|
||||
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||
@@ -376,6 +426,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
|
||||
|
||||
+4
-8
@@ -23,18 +23,14 @@ 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_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_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||
|
||||
|
||||
+11
-3
@@ -7,7 +7,7 @@ thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
||||
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||
**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
|
||||
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||
in the deployed contour the front caddy owns `/_gm` (see
|
||||
@@ -29,7 +29,7 @@ internal/push/ # live-event fan-out hub (per-user client streams)
|
||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
||||
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
||||
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
|
||||
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ + /vk/
|
||||
```
|
||||
|
||||
The FlatBuffers payloads and the backend push proto are the shared wire
|
||||
@@ -54,7 +54,14 @@ them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). Wh
|
||||
`GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR`
|
||||
is unset the bot channel (out-of-app push + admin relay) is disabled.
|
||||
|
||||
The message-type catalog: `auth.telegram`, `auth.guest`,
|
||||
`auth.vk` validates a VK Mini App launch **in-process** (`internal/vkauth`): it verifies the
|
||||
signed `vk_*` launch parameters against the VK app's protected key (`GATEWAY_VK_APP_SECRET`) —
|
||||
HMAC-SHA256 over the sorted params, base64url — a pure offline check with no side-service or VK API
|
||||
round-trip, then forwards the trusted `vk_user_id` (and the client-read display name, which VK omits
|
||||
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
||||
(`auth.vk` is 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;
|
||||
live events
|
||||
@@ -81,6 +88,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
||||
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
||||
| `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_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) |
|
||||
|
||||
@@ -190,10 +190,11 @@ 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)
|
||||
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
Backend: backend,
|
||||
Limiter: limiter,
|
||||
Tracker: tracker,
|
||||
Banlist: banlist,
|
||||
|
||||
@@ -184,10 +184,10 @@ type ChatResp struct {
|
||||
}
|
||||
|
||||
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
||||
// brand-new account's display name and language from the validated launch fields and
|
||||
// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first
|
||||
// contact only).
|
||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) {
|
||||
// brand-new account's display name and language from the validated launch fields, its
|
||||
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
||||
// validated launch deep-link startParam, its variant preferences (first contact only).
|
||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||
map[string]string{
|
||||
@@ -196,6 +196,24 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
||||
"username": username,
|
||||
"first_name": firstName,
|
||||
"browser_tz": browserTz,
|
||||
"start_param": startParam,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// VKAuth provisions/finds the VK account and mints a session, seeding a brand-new
|
||||
// account's preferred language from languageCode (the vk_language hint), its display
|
||||
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
||||
// name from the signed launch params) and its time zone from browserTz (the client's
|
||||
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
||||
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
||||
map[string]string{
|
||||
"external_id": externalID,
|
||||
"language_code": languageCode,
|
||||
"display_name": displayName,
|
||||
"browser_tz": browserTz,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -465,6 +483,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) {
|
||||
|
||||
@@ -125,6 +125,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 {
|
||||
|
||||
@@ -32,6 +32,10 @@ type Config struct {
|
||||
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
||||
// Login Widget data. Empty disables the telegram auth path.
|
||||
ValidatorAddr string
|
||||
// VKAppSecret is the VK Mini App protected ("secure") key. The gateway verifies the
|
||||
// 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
|
||||
// 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
|
||||
@@ -169,6 +173,7 @@ func Load() (Config, error) {
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
Abuse: DefaultAbuse(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ package connectsrv
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -68,6 +70,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 +94,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 +148,7 @@ func NewServer(d Deps) *Server {
|
||||
return &Server{
|
||||
registry: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
backend: d.Backend,
|
||||
limiter: limiter,
|
||||
tracker: tracker,
|
||||
banlist: banlist,
|
||||
@@ -183,14 +190,20 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// does not serve the app shell at the operator path.
|
||||
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||
}
|
||||
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram
|
||||
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below
|
||||
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and
|
||||
// each mount falls back to the app shell (index.html) for the hash router. The
|
||||
// public landing lives in its own static container behind the contour caddy,
|
||||
// so the catch-all redirects a stray root hit to the app shell — which
|
||||
// keeps a local no-caddy run usable.
|
||||
// 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())
|
||||
// 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
|
||||
// priority, and each mount falls back to the app shell (index.html) for the hash
|
||||
// router. The public landing lives in its own static container behind the contour
|
||||
// caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a
|
||||
// local no-caddy run usable.
|
||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||
mux.Handle("/vk/", webui.Handler("/vk/", "index.html"))
|
||||
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
||||
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
||||
@@ -396,6 +409,120 @@ 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}.
|
||||
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.
|
||||
|
||||
@@ -9,15 +9,18 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/connector"
|
||||
"scrabble/gateway/internal/vkauth"
|
||||
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"
|
||||
@@ -88,8 +91,9 @@ type TelegramValidator interface {
|
||||
|
||||
// NewRegistry builds the slice's message-type catalog over the backend client.
|
||||
// The Telegram auth op is registered only when a validator is supplied (the
|
||||
// connector is configured); otherwise auth.telegram is simply unknown.
|
||||
func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
|
||||
// connector is configured); otherwise auth.telegram is simply unknown. Optional ops
|
||||
// (e.g. WithVKAuth) are applied last from opts.
|
||||
func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Option) *Registry {
|
||||
r := &Registry{ops: make(map[string]Op)}
|
||||
if tg != nil {
|
||||
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
||||
@@ -125,9 +129,27 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry
|
||||
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
|
||||
registerSocialOps(r, backend)
|
||||
registerLinkOps(r, backend, tg)
|
||||
for _, opt := range opts {
|
||||
opt(r, backend)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Option configures an optional registry operation at construction. It is kept out of
|
||||
// NewRegistry's positional signature so existing call sites stay unaffected.
|
||||
type Option func(r *Registry, backend *backendclient.Client)
|
||||
|
||||
// WithVKAuth registers the VK Mini App auth op (auth.vk), which verifies launch params
|
||||
// in-process under the VK app secret. A blank secret leaves auth.vk unregistered, so
|
||||
// the op is simply unknown wherever VK is not configured.
|
||||
func WithVKAuth(secret string) Option {
|
||||
return func(r *Registry, backend *backendclient.Client) {
|
||||
if secret != "" {
|
||||
r.ops[MsgAuthVK] = Op{Handler: authVKHandler(backend, secret)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the operation for messageType, and whether it is registered.
|
||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
op, ok := r.ops[messageType]
|
||||
@@ -145,6 +167,9 @@ func DomainCode(err error) (string, bool) {
|
||||
if errors.Is(err, connector.ErrInvalidInitData) {
|
||||
return "invalid_init_data", true
|
||||
}
|
||||
if errors.Is(err, vkauth.ErrInvalid) {
|
||||
return "invalid_vk_params", true
|
||||
}
|
||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||
return "invalid_login_widget", true
|
||||
}
|
||||
@@ -154,11 +179,38 @@ func DomainCode(err error) (string, bool) {
|
||||
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
|
||||
user, err := tg.ValidateInitData(ctx, string(in.InitData()))
|
||||
initData := string(in.InitData())
|
||||
user, err := tg.ValidateInitData(ctx, initData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()))
|
||||
// start_param rides inside the signed initData validated just above, so the launch
|
||||
// deep-link payload can be trusted here without a separate wire field; the backend
|
||||
// uses it to seed a brand-new account's variant preferences.
|
||||
startParam := ""
|
||||
if q, perr := url.ParseQuery(initData); perr == nil {
|
||||
startParam = q.Get("start_param")
|
||||
}
|
||||
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeSession(sess), nil
|
||||
}
|
||||
}
|
||||
|
||||
// authVKHandler verifies a VK Mini App launch in-process (HMAC over the signed vk_*
|
||||
// params under the app secret) and provisions/finds the bound account. Unlike Telegram,
|
||||
// VK omits the user's name from the signed params, so the client-supplied display_name
|
||||
// rides the wire as a cosmetic seed for a brand-new account.
|
||||
func authVKHandler(backend *backendclient.Client, secret string) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsVKLoginRequest(req.Payload, 0)
|
||||
user, err := vkauth.Verify(string(in.Params()), secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
|
||||
t.Fatal("auth.telegram not registered")
|
||||
}
|
||||
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("init")})
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("start_param=verudit_ru-scrabble_en&query_id=abc")})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
@@ -66,6 +66,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
|
||||
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
|
||||
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
|
||||
}
|
||||
// start_param is parsed out of the validated initData and forwarded so the backend can
|
||||
// seed the new account's variant preferences from a promo deep link.
|
||||
if gotBody["start_param"] != "verudit_ru-scrabble_en" {
|
||||
t.Errorf("forwarded start_param = %q, want verudit_ru-scrabble_en", gotBody["start_param"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramAuthInvalidInitData(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package transcode_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/gateway/internal/transcode"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
const (
|
||||
vkTestSecret = "wv527nm6mvf3rgomfhd6"
|
||||
// vkGoldenSign is the base64url HMAC-SHA256 of the unsigned params below under
|
||||
// vkTestSecret, computed independently (a Python reference) — so a green test
|
||||
// exercises VK's real algorithm end to end, not a self-consistent fake.
|
||||
vkGoldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||
)
|
||||
|
||||
// vkParams is the canonical VK launch-parameter set (without the sign) that
|
||||
// vkGoldenSign covers.
|
||||
func vkParams() url.Values {
|
||||
return url.Values{
|
||||
"vk_access_token_settings": {""},
|
||||
"vk_app_id": {"6736218"},
|
||||
"vk_are_notifications_enabled": {"0"},
|
||||
"vk_is_app_user": {"1"},
|
||||
"vk_is_favorite": {"0"},
|
||||
"vk_language": {"ru"},
|
||||
"vk_platform": {"android"},
|
||||
"vk_ref": {"other"},
|
||||
"vk_ts": {"1546961916"},
|
||||
"vk_user_id": {"494075"},
|
||||
}
|
||||
}
|
||||
|
||||
func vkLoginPayload(params, browserTz, displayName string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
p := b.CreateString(params)
|
||||
tz := b.CreateString(browserTz)
|
||||
dn := b.CreateString(displayName)
|
||||
fb.VKLoginRequestStart(b)
|
||||
fb.VKLoginRequestAddParams(b, p)
|
||||
fb.VKLoginRequestAddBrowserTz(b, tz)
|
||||
fb.VKLoginRequestAddDisplayName(b, dn)
|
||||
b.Finish(fb.VKLoginRequestEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
func TestVKAuthForwardsSeedFields(t *testing.T) {
|
||||
var gotBody map[string]string
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/internal/sessions/vk" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
_, _ = w.Write([]byte(`{"token":"tok-vk","user_id":"u-vk","is_guest":false,"display_name":"Иван"}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||
op, ok := reg.Lookup(transcode.MsgAuthVK)
|
||||
if !ok {
|
||||
t.Fatal("auth.vk not registered")
|
||||
}
|
||||
|
||||
signed := vkParams()
|
||||
signed.Set("sign", vkGoldenSign)
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(signed.Encode(), "+03:00", "Иван Петров")})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
sess := fb.GetRootAsSession(payload, 0)
|
||||
if string(sess.Token()) != "tok-vk" || string(sess.UserId()) != "u-vk" {
|
||||
t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId())
|
||||
}
|
||||
// The verified vk_user_id and vk_language plus the client-supplied display name are
|
||||
// forwarded so the backend can seed a brand-new account.
|
||||
if gotBody["external_id"] != "494075" || gotBody["language_code"] != "ru" || gotBody["display_name"] != "Иван Петров" {
|
||||
t.Errorf("forwarded body = %+v, want external_id=494075 language_code=ru display_name=Иван Петров", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKAuthInvalidSign confirms a bad signature is a domain failure (invalid_vk_params)
|
||||
// and the backend is never called.
|
||||
func TestVKAuthInvalidSign(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("backend must not be called when the sign is invalid")
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||
op, _ := reg.Lookup(transcode.MsgAuthVK)
|
||||
|
||||
tampered := vkParams()
|
||||
tampered.Set("sign", "deadbeef")
|
||||
_, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(tampered.Encode(), "", "")})
|
||||
if code, ok := transcode.DomainCode(err); !ok || code != "invalid_vk_params" {
|
||||
t.Errorf("DomainCode = (%q, %v), want (invalid_vk_params, true)", code, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKAuthDisabledWithoutSecret confirms a blank VK app secret leaves auth.vk
|
||||
// unregistered.
|
||||
func TestVKAuthDisabledWithoutSecret(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
|
||||
defer cleanup()
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
if _, ok := reg.Lookup(transcode.MsgAuthVK); ok {
|
||||
t.Error("auth.vk should be unregistered without a VK app secret")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package vkauth verifies VK Mini App launch parameters in-process. VK signs the
|
||||
// launch query string with the app's protected ("secure") key; the gateway holds that
|
||||
// secret (GATEWAY_VK_APP_SECRET) and validates the `sign` itself rather than calling a
|
||||
// side-service, because the check is a pure offline HMAC with no VK API round-trip
|
||||
// (unlike the Telegram validator, which lives in a separate process to isolate the bot
|
||||
// token). See docs/ARCHITECTURE.md §12.
|
||||
package vkauth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalid is returned when launch params fail signature verification, are
|
||||
// malformed, carry no signed vk_* parameters, or lack the vk_user_id.
|
||||
var ErrInvalid = errors.New("vkauth: invalid vk launch params")
|
||||
|
||||
// Identity is the user extracted from verified VK launch params. ExternalID is the
|
||||
// vk_user_id used as the identities external_id; Language is the vk_language hint that
|
||||
// seeds a brand-new account's preferred language.
|
||||
type Identity struct {
|
||||
ExternalID string
|
||||
Language string
|
||||
}
|
||||
|
||||
// Verify checks the `sign` of a VK Mini App launch query string against secret and
|
||||
// returns the launching user's identity. Per VK's documented algorithm the signature
|
||||
// is HMAC-SHA256 over the vk_*-prefixed parameters — sorted by key and serialized as a
|
||||
// URL-encoded query string (url.Values.Encode mirrors VK's reference serialization for
|
||||
// the constrained launch-parameter charset) — under the app secret, then base64url
|
||||
// without padding; the comparison is constant-time.
|
||||
//
|
||||
// VK launch params carry no built-in expiry (unlike Telegram's auth_date), so freshness
|
||||
// is deliberately not enforced here: the gateway mints its own short-lived session, and
|
||||
// a replay only re-authenticates the same vk_user_id.
|
||||
func Verify(params, secret string) (Identity, error) {
|
||||
values, err := url.ParseQuery(params)
|
||||
if err != nil {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
sign := values.Get("sign")
|
||||
if sign == "" {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
// Only vk_*-prefixed parameters are signed; any other query parameter the client
|
||||
// appended is outside the signature and must be excluded from the check.
|
||||
signed := url.Values{}
|
||||
for k, v := range values {
|
||||
if strings.HasPrefix(k, "vk_") {
|
||||
signed[k] = v
|
||||
}
|
||||
}
|
||||
if len(signed) == 0 {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(signed.Encode()))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(sign)) != 1 {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
externalID := values.Get("vk_user_id")
|
||||
if externalID == "" {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package vkauth_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"scrabble/gateway/internal/vkauth"
|
||||
)
|
||||
|
||||
const (
|
||||
testSecret = "wv527nm6mvf3rgomfhd6"
|
||||
// goldenSign is HMAC-SHA256(sorted vk_* query, testSecret) base64url without
|
||||
// padding, computed independently from a Python reference over goldenParams — so a
|
||||
// green test proves Verify matches VK's documented algorithm, not merely itself.
|
||||
goldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||
)
|
||||
|
||||
// goldenParams is the canonical VK launch-parameter set the golden signature covers
|
||||
// (a representative real launch, including the empty vk_access_token_settings).
|
||||
func goldenParams() url.Values {
|
||||
return url.Values{
|
||||
"vk_access_token_settings": {""},
|
||||
"vk_app_id": {"6736218"},
|
||||
"vk_are_notifications_enabled": {"0"},
|
||||
"vk_is_app_user": {"1"},
|
||||
"vk_is_favorite": {"0"},
|
||||
"vk_language": {"ru"},
|
||||
"vk_platform": {"android"},
|
||||
"vk_ref": {"other"},
|
||||
"vk_ts": {"1546961916"},
|
||||
"vk_user_id": {"494075"},
|
||||
}
|
||||
}
|
||||
|
||||
func signedParams() url.Values {
|
||||
p := goldenParams()
|
||||
p.Set("sign", goldenSign)
|
||||
return p
|
||||
}
|
||||
|
||||
func TestVerifyValid(t *testing.T) {
|
||||
id, err := vkauth.Verify(signedParams().Encode(), testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: unexpected error %v", err)
|
||||
}
|
||||
if id.ExternalID != "494075" || id.Language != "ru" {
|
||||
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
|
||||
// value: vk_access_token_settings can carry a comma (e.g. "email,phone"), which VK's
|
||||
// signer and our url.Values.Encode both escape to %2C. The golden sign was computed
|
||||
// independently (Node crypto) over these params under testSecret — so a green test
|
||||
// proves Go's encoding matches VK's for that character.
|
||||
func TestVerifyCommaValue(t *testing.T) {
|
||||
p := url.Values{
|
||||
"vk_access_token_settings": {"email,phone"},
|
||||
"vk_app_id": {"6736218"},
|
||||
"vk_language": {"ru"},
|
||||
"vk_platform": {"mobile_web"},
|
||||
"vk_ts": {"1546961916"},
|
||||
"vk_user_id": {"494075"},
|
||||
"sign": {"6g9FKCAfHfT-fBUdBAcJ5QK1xUm-vKMvGZrQ63aPgnQ"},
|
||||
}
|
||||
id, err := vkauth.Verify(p.Encode(), testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: unexpected error %v", err)
|
||||
}
|
||||
if id.ExternalID != "494075" {
|
||||
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyIgnoresForeignParams asserts a non-vk_ query parameter the client may
|
||||
// append (e.g. a tracking tag) is outside the signed set and does not break the check.
|
||||
func TestVerifyIgnoresForeignParams(t *testing.T) {
|
||||
id, err := vkauth.Verify(signedParams().Encode()+"&utm_source=catalog", testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: unexpected error %v", err)
|
||||
}
|
||||
if id.ExternalID != "494075" {
|
||||
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
secret string
|
||||
}{
|
||||
{name: "tampered param", secret: testSecret, raw: func() string {
|
||||
p := signedParams()
|
||||
p.Set("vk_user_id", "1") // user id changed; the golden sign no longer matches
|
||||
return p.Encode()
|
||||
}()},
|
||||
{name: "wrong secret", secret: "not-the-secret", raw: signedParams().Encode()},
|
||||
{name: "missing sign", secret: testSecret, raw: goldenParams().Encode()},
|
||||
{name: "no vk params", secret: testSecret, raw: url.Values{"sign": {goldenSign}, "foo": {"bar"}}.Encode()},
|
||||
{name: "malformed query", secret: testSecret, raw: "%zz=bad"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := vkauth.Verify(tt.raw, tt.secret); !errors.Is(err, vkauth.ErrInvalid) {
|
||||
t.Fatalf("Verify error = %v, want ErrInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,20 @@ table TelegramLoginRequest {
|
||||
browser_tz:string;
|
||||
}
|
||||
|
||||
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
|
||||
// signed vk_* launch parameters plus the sign, which the gateway verifies in-process
|
||||
// (HMAC-SHA256 over the sorted vk_* params under the app secret, base64url) before
|
||||
// forwarding the extracted vk_user_id to the backend. display_name is the player's
|
||||
// name read client-side via VKWebAppGetUserInfo — VK omits it from the signed params,
|
||||
// so it is an untrusted, cosmetic seed for a brand-new account's display name.
|
||||
// browser_tz is the client's detected UTC offset ("±HH:MM"), seeded into a brand-new
|
||||
// account's time zone (see TelegramLoginRequest.browser_tz; first contact only).
|
||||
table VKLoginRequest {
|
||||
params:string;
|
||||
browser_tz:string;
|
||||
display_name:string;
|
||||
}
|
||||
|
||||
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
|
||||
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
|
||||
// guest account's time zone (see TelegramLoginRequest.browser_tz).
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type VKLoginRequest struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &VKLoginRequest{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &VKLoginRequest{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) Params() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) BrowserTz() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) DisplayName() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func VKLoginRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(3)
|
||||
}
|
||||
func VKLoginRequestAddParams(builder *flatbuffers.Builder, params flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(params), 0)
|
||||
}
|
||||
func VKLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
||||
}
|
||||
func VKLoginRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(displayName), 0)
|
||||
}
|
||||
func VKLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -65,8 +65,9 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
||||
`chat_member` updates, which Telegram delivers only to a chat admin.
|
||||
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
|
||||
supergroup**, the bot runs a direct support channel for users who message it. A user's first
|
||||
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (name,
|
||||
@username, language, premium, id, `tg://user` profile link) carrying a **Block/Unblock** toggle
|
||||
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
|
||||
name is a tappable profile mention via a `text_mention` entity — which also stops a name starting
|
||||
with `/` from rendering as a command — plus @username, language, premium, id) with a **Block/Unblock** toggle
|
||||
and a **Clear** button; each message is then copied into that topic (`copyMessage`, so any content
|
||||
— text, media, voice, files — carries over). Any **administrator** of the support chat who writes
|
||||
in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored
|
||||
@@ -82,7 +83,12 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
||||
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with
|
||||
`?startapp` — a `web_app` button would launch under the promo bot's identity (its token
|
||||
would sign the initData), which the main bot's validator rejects. It is fully
|
||||
self-contained: no bot-link, no gateway, no game.
|
||||
self-contained: no bot-link, no gateway, no game. Its `?startapp` payload
|
||||
(`TELEGRAM_PROMO_START_PARAM`, default `verudit_ru-scrabble_en`) is a variant-seed deep
|
||||
link the backend decodes to seed a brand-new user's variant preferences (English Scrabble
|
||||
alongside the default Erudit). The message body also renders the bot's `@username` as that
|
||||
same deep link (an HTML `text_link`), so tapping the mention — not just the button — opens
|
||||
the seeded Mini App rather than the bot profile.
|
||||
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
||||
default 25) to respect the Bot API flood limits.
|
||||
|
||||
@@ -150,6 +156,7 @@ Bot (`cmd/bot`):
|
||||
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
|
||||
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
|
||||
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
|
||||
| `TELEGRAM_PROMO_START_PARAM` | `verudit_ru-scrabble_en` | the promo button's `startapp` payload — a variant-seed deep link the backend decodes to seed a brand-new user's variant preferences; empty forwards the user's own `/start` payload |
|
||||
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
|
||||
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
|
||||
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
|
||||
|
||||
@@ -131,6 +131,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
TestEnv: cfg.TestEnv,
|
||||
BotUsername: cfg.BotUsername,
|
||||
BotLinkURL: cfg.BotLinkURL,
|
||||
StartParam: cfg.PromoStartParam,
|
||||
SendRatePerSecond: cfg.SendRatePerSecond,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,15 +3,17 @@ package bot
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
// Support relay: the bot forwards a user's direct messages into a per-user forum
|
||||
@@ -130,26 +132,20 @@ func (t *Bot) handleSupportUserMessage(ctx context.Context, m *models.Message) {
|
||||
unlock := t.supportLocks.lock(uid)
|
||||
defer unlock()
|
||||
|
||||
rec, ok := t.support.Get(uid)
|
||||
if ok && rec.Blocked {
|
||||
rec, _ := t.support.Get(uid)
|
||||
if rec.Blocked {
|
||||
return
|
||||
}
|
||||
topicID := 0
|
||||
if ok {
|
||||
topicID = rec.TopicID
|
||||
}
|
||||
if topicID == 0 {
|
||||
var err error
|
||||
if topicID, err = t.openSupportTopic(ctx, m.From); err != nil {
|
||||
t.log.Warn("support: open topic failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||
return
|
||||
}
|
||||
topicID, err := t.ensureTopic(ctx, m.From, rec)
|
||||
if err != nil {
|
||||
t.log.Warn("support: ensure topic failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
|
||||
if err != nil && isTopicMissingErr(err) {
|
||||
// The operators deleted the whole topic; reopen it and retry once.
|
||||
t.log.Info("support: topic gone, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID))
|
||||
// Backstop: the topic vanished between the liveness probe and the copy.
|
||||
t.log.Info("support: topic gone on copy, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID))
|
||||
if topicID, err = t.openSupportTopic(ctx, m.From); err == nil {
|
||||
newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
|
||||
}
|
||||
@@ -258,6 +254,31 @@ func (t *Bot) clearSupportTopic(ctx context.Context, cq *models.CallbackQuery, u
|
||||
t.answerSupportCallback(ctx, cq.ID, "Очищено")
|
||||
}
|
||||
|
||||
// ensureTopic returns a live forum-topic id for the user, opening a fresh topic (and
|
||||
// info card) when none exists or the previous one was deleted. Telegram silently
|
||||
// routes a copy aimed at a deleted topic into the chat's General topic (no error), so
|
||||
// the bot cannot rely on copyMessage failing; instead it probes the info card —
|
||||
// re-applying the card's reply markup is a no-op that errors only when the card (hence
|
||||
// the topic) is gone — and reopens on that signal. The probe also keeps the card's
|
||||
// block button in sync with the stored state.
|
||||
func (t *Bot) ensureTopic(ctx context.Context, u *models.User, rec support.User) (int, error) {
|
||||
if rec.TopicID == 0 || rec.HeaderMsgID == 0 {
|
||||
return t.openSupportTopic(ctx, u)
|
||||
}
|
||||
_, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
|
||||
ChatID: t.supportChatID,
|
||||
MessageID: rec.HeaderMsgID,
|
||||
ReplyMarkup: supportCardMarkup(u.ID, rec.Blocked),
|
||||
})
|
||||
if isCardMissingErr(err) {
|
||||
t.log.Info("support: topic gone, reopening", zap.Int64("user_id", u.ID), zap.Int("topic_id", rec.TopicID))
|
||||
return t.openSupportTopic(ctx, u)
|
||||
}
|
||||
// Any other outcome (success, or a benign "message is not modified") means the
|
||||
// topic is alive; reuse it.
|
||||
return rec.TopicID, nil
|
||||
}
|
||||
|
||||
// openSupportTopic creates a forum topic for the user and posts its info card with
|
||||
// the block/clear buttons, persisting both. It returns the new topic id. A failure to
|
||||
// persist is logged but not fatal: the in-memory mapping still lets the relay proceed.
|
||||
@@ -270,11 +291,12 @@ func (t *Bot) openSupportTopic(ctx context.Context, u *models.User) (int, error)
|
||||
return 0, fmt.Errorf("create forum topic: %w", err)
|
||||
}
|
||||
headerID := 0
|
||||
cardText, cardEntities := supportCard(u)
|
||||
header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: t.supportChatID,
|
||||
MessageThreadID: topic.MessageThreadID,
|
||||
Text: supportCardText(u),
|
||||
ParseMode: models.ParseModeHTML,
|
||||
Text: cardText,
|
||||
Entities: cardEntities,
|
||||
ReplyMarkup: supportCardMarkup(u.ID, false),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -396,16 +418,22 @@ func supportTopicName(u *models.User) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// supportCardText renders the info card describing the user. User-controlled fields
|
||||
// are HTML-escaped because the card uses HTML parse mode for the profile link.
|
||||
func supportCardText(u *models.User) string {
|
||||
name := html.EscapeString(strings.TrimSpace(u.FirstName + " " + u.LastName))
|
||||
// supportCard renders the topic info card describing the user and the message
|
||||
// entities for it. The display name is covered by a text_mention entity: it links to
|
||||
// the user's profile (the reliable way to mention a user who has no public username —
|
||||
// the bot has seen them, since they messaged it) and, because the name sits inside an
|
||||
// entity, Telegram does not auto-detect a leading "/" as a bot command or a stray
|
||||
// "@handle" inside the name as a mention. The remaining lines are plain text; a public
|
||||
// @username, when present, is left for Telegram to auto-link. Entity offsets are in
|
||||
// UTF-16 code units, as the Bot API requires; the name is at offset 0.
|
||||
func supportCard(u *models.User) (string, []models.MessageEntity) {
|
||||
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
if name == "" {
|
||||
name = "—"
|
||||
name = "user " + strconv.FormatInt(u.ID, 10)
|
||||
}
|
||||
username := "—"
|
||||
if u.Username != "" {
|
||||
username = "@" + html.EscapeString(u.Username)
|
||||
username = "@" + u.Username
|
||||
}
|
||||
lang := u.LanguageCode
|
||||
if lang == "" {
|
||||
@@ -416,12 +444,30 @@ func supportCardText(u *models.User) string {
|
||||
premium = "да"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "<b>%s</b>\n", name)
|
||||
fmt.Fprintf(&b, "Username: %s\n", username)
|
||||
fmt.Fprintf(&b, "ID: <code>%d</code>\n", u.ID)
|
||||
fmt.Fprintf(&b, "Язык: %s · Premium: %s\n", html.EscapeString(lang), premium)
|
||||
fmt.Fprintf(&b, `<a href="tg://user?id=%d">Открыть профиль</a>`, u.ID)
|
||||
return b.String()
|
||||
b.WriteString(name)
|
||||
fmt.Fprintf(&b, "\nUsername: %s", username)
|
||||
fmt.Fprintf(&b, "\nID: %d", u.ID)
|
||||
fmt.Fprintf(&b, "\nЯзык: %s · Premium: %s", lang, premium)
|
||||
entities := []models.MessageEntity{{
|
||||
Type: models.MessageEntityTypeTextMention,
|
||||
Offset: 0,
|
||||
Length: len(utf16.Encode([]rune(name))),
|
||||
User: &models.User{ID: u.ID},
|
||||
}}
|
||||
return b.String(), entities
|
||||
}
|
||||
|
||||
// isCardMissingErr reports whether err means the info-card message no longer exists
|
||||
// (the operators deleted the topic), as opposed to a benign "message is not modified"
|
||||
// no-op when the card is still there.
|
||||
func isCardMissingErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "message to edit not found") ||
|
||||
strings.Contains(s, "message can't be edited") ||
|
||||
strings.Contains(s, "message_id_invalid")
|
||||
}
|
||||
|
||||
// isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
|
||||
|
||||
@@ -38,6 +38,9 @@ type supportAPI struct {
|
||||
deleted [][]int
|
||||
editedMsgIDs []string
|
||||
answers []string
|
||||
// editErr, when set, makes editMessageReplyMarkup return that Telegram error
|
||||
// description (simulating a deleted info card / topic).
|
||||
editErr string
|
||||
}
|
||||
|
||||
func newSupportAPI(adminIDs ...int64) *supportAPI {
|
||||
@@ -77,6 +80,10 @@ func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.deleted = append(s.deleted, ids)
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
case strings.HasSuffix(path, "/editMessageReplyMarkup"):
|
||||
if s.editErr != "" {
|
||||
fmt.Fprintf(w, `{"ok":false,"error_code":400,"description":%q}`, s.editErr)
|
||||
return
|
||||
}
|
||||
s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id"))
|
||||
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
|
||||
case strings.HasSuffix(path, "/answerCallbackQuery"):
|
||||
@@ -181,6 +188,36 @@ func TestSupportSubsequentReusesTopic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupportTopicRecreatedWhenDeleted covers the case the operator hit in prod:
|
||||
// after they delete a user's whole topic, Telegram routes a copy aimed at it into
|
||||
// General with no error, so the bot must proactively detect the dead topic (the card
|
||||
// probe fails) and reopen it instead of silently losing the message to General.
|
||||
func TestSupportTopicRecreatedWhenDeleted(t *testing.T) {
|
||||
api := newSupportAPI()
|
||||
api.editErr = "message to edit not found" // the operators deleted the topic + its card
|
||||
b, st := newSupportBot(t, api)
|
||||
// Seed a topic that has since been deleted in Telegram.
|
||||
if err := st.SetTopic(7, 999, 4999, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("seed topic: %v", err)
|
||||
}
|
||||
|
||||
b.handleSupportUserMessage(context.Background(), userMsg(7, "still there?"))
|
||||
|
||||
if api.topicsOpened != 1 {
|
||||
t.Fatalf("topics opened = %d, want 1 (reopened after deletion)", api.topicsOpened)
|
||||
}
|
||||
rec, _ := st.Get(7)
|
||||
if rec.TopicID != 1000 || rec.HeaderMsgID != 5000 {
|
||||
t.Errorf("store = topic %d / header %d, want the reopened 1000 / 5000", rec.TopicID, rec.HeaderMsgID)
|
||||
}
|
||||
if len(api.copies) != 1 || api.copies[0].threadID != "1000" {
|
||||
t.Errorf("relay copies = %+v, want one into the reopened topic 1000", api.copies)
|
||||
}
|
||||
if _, ok := st.ByTopic(999); ok {
|
||||
t.Error("stale topic 999 still indexed after reopen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportBlockedUserDropped(t *testing.T) {
|
||||
api := newSupportAPI()
|
||||
b, st := newSupportBot(t, api)
|
||||
@@ -360,21 +397,53 @@ func TestParseSupportCallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportCardTextEscapes(t *testing.T) {
|
||||
u := &models.User{ID: 7, FirstName: "<b>Ann</b>", Username: "ann", LanguageCode: "ru", IsPremium: true}
|
||||
card := supportCardText(u)
|
||||
if strings.Contains(card, "<b>Ann</b>") {
|
||||
t.Error("user-supplied first name was not HTML-escaped in the card")
|
||||
}
|
||||
if !strings.Contains(card, "<b>Ann</b>") {
|
||||
t.Errorf("card = %q, want the escaped name", card)
|
||||
}
|
||||
if !strings.Contains(card, "tg://user?id=7") {
|
||||
t.Error("card missing the profile deep link")
|
||||
}
|
||||
if !strings.Contains(card, "Premium: да") {
|
||||
t.Error("card missing the premium flag")
|
||||
}
|
||||
func TestSupportCard(t *testing.T) {
|
||||
t.Run("name is a plain-text mention, not a command", func(t *testing.T) {
|
||||
u := &models.User{ID: 7, FirstName: "/start", Username: "ann", LanguageCode: "ru", IsPremium: true}
|
||||
text, ents := supportCard(u)
|
||||
// The name is rendered verbatim (no HTML, no escaping) at the start of the card.
|
||||
if !strings.HasPrefix(text, "/start\n") {
|
||||
t.Errorf("card = %q, want it to start with the raw name", text)
|
||||
}
|
||||
// A text_mention entity covers exactly the name with the user id — this both
|
||||
// suppresses the "/start" command auto-detection and links the profile.
|
||||
if len(ents) != 1 {
|
||||
t.Fatalf("entities = %d, want 1", len(ents))
|
||||
}
|
||||
e := ents[0]
|
||||
if e.Type != models.MessageEntityTypeTextMention || e.Offset != 0 || e.Length != len("/start") {
|
||||
t.Errorf("entity = %+v, want a text_mention over the name", e)
|
||||
}
|
||||
if e.User == nil || e.User.ID != 7 {
|
||||
t.Errorf("entity user = %+v, want id 7", e.User)
|
||||
}
|
||||
if strings.Contains(text, "tg://") || strings.Contains(text, "<") {
|
||||
t.Errorf("card = %q, want no tg:// link and no HTML", text)
|
||||
}
|
||||
if !strings.Contains(text, "Premium: да") || !strings.Contains(text, "@ann") {
|
||||
t.Errorf("card = %q, want premium + @username", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entity length is UTF-16, not runes", func(t *testing.T) {
|
||||
// "😀A" is 2 runes but 3 UTF-16 code units (the emoji is a surrogate pair).
|
||||
u := &models.User{ID: 9, FirstName: "😀A"}
|
||||
_, ents := supportCard(u)
|
||||
if len(ents) != 1 || ents[0].Length != 3 {
|
||||
t.Fatalf("entity = %+v, want length 3 UTF-16 units", ents)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nameless user falls back to an id label", func(t *testing.T) {
|
||||
u := &models.User{ID: 42}
|
||||
text, ents := supportCard(u)
|
||||
if !strings.HasPrefix(text, "user 42") {
|
||||
t.Errorf("card = %q, want the id fallback name", text)
|
||||
}
|
||||
if ents[0].Length != len("user 42") {
|
||||
t.Errorf("entity length = %d, want it over the fallback name", ents[0].Length)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSupportTopicName(t *testing.T) {
|
||||
|
||||
@@ -65,6 +65,12 @@ type BotConfig struct {
|
||||
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
||||
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
||||
BotLinkURL string
|
||||
// PromoStartParam is the promo button's launch payload, appended as
|
||||
// ?startapp=<PromoStartParam> (TELEGRAM_PROMO_START_PARAM, default
|
||||
// "verudit_ru-scrabble_en"). It is a variant-seed deep link the backend decodes to
|
||||
// seed a brand-new user's variant preferences; its labels must match the backend's
|
||||
// known variants. Empty falls back to forwarding the user's own /start payload.
|
||||
PromoStartParam string
|
||||
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
|
||||
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
|
||||
MiniAppURL string
|
||||
@@ -113,6 +119,10 @@ const (
|
||||
defaultValidatorGRPCAddr = ":9091"
|
||||
defaultBotReconnectDelay = 2 * time.Second
|
||||
defaultSendRatePerSecond = 25
|
||||
// defaultPromoStartParam is the promo button's default launch payload: a variant-seed
|
||||
// deep link the backend decodes to add English Scrabble to a brand-new user's variant
|
||||
// preferences alongside the default Erudit. Override with TELEGRAM_PROMO_START_PARAM.
|
||||
defaultPromoStartParam = "verudit_ru-scrabble_en"
|
||||
)
|
||||
|
||||
// LoadValidator reads the validator configuration from the environment.
|
||||
@@ -145,6 +155,7 @@ func LoadBot() (BotConfig, error) {
|
||||
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
|
||||
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
|
||||
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
||||
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
|
||||
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||
BotLink: BotLinkClientConfig{
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
)
|
||||
|
||||
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
||||
// the hash, is malformed, or is older than the freshness window.
|
||||
// the hash, is malformed, is older than the freshness window, or identifies a bot
|
||||
// user (is_bot), which is denied.
|
||||
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
||||
|
||||
// defaultMaxAge bounds how old a validated initData payload may be.
|
||||
@@ -120,6 +121,7 @@ func parseUser(userJSON string) (User, error) {
|
||||
}
|
||||
var u struct {
|
||||
ID int64 `json:"id"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
@@ -127,6 +129,12 @@ func parseUser(userJSON string) (User, error) {
|
||||
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
// Deny bot principals: the HMAC has already proved Telegram signed this payload, so is_bot==true
|
||||
// is Telegram itself attesting the launching user is a bot. A real user opening the Mini App
|
||||
// never carries it, so reject defensively rather than provision an account for a bot.
|
||||
if u.IsBot {
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
return User{
|
||||
ExternalID: strconv.FormatInt(u.ID, 10),
|
||||
Username: u.Username,
|
||||
|
||||
@@ -83,3 +83,28 @@ func TestValidateRejects(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateBotUser(t *testing.T) {
|
||||
t.Run("is_bot true is denied", func(t *testing.T) {
|
||||
initData := signInitData(testToken, map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"user": `{"id":42,"is_bot":true,"first_name":"Robo"}`,
|
||||
})
|
||||
if _, err := NewHMACValidator(testToken).Validate(initData); !errors.Is(err, ErrInvalidInitData) {
|
||||
t.Errorf("err = %v, want ErrInvalidInitData", err)
|
||||
}
|
||||
})
|
||||
t.Run("is_bot false is allowed", func(t *testing.T) {
|
||||
initData := signInitData(testToken, map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"user": `{"id":42,"is_bot":false,"first_name":"Thomas"}`,
|
||||
})
|
||||
u, err := NewHMACValidator(testToken).Validate(initData)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if u.ExternalID != "42" || u.FirstName != "Thomas" {
|
||||
t.Errorf("user = %+v, want {42 Thomas}", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
package promobot
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"html"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
@@ -33,6 +35,11 @@ type Config struct {
|
||||
// BotLinkURL is the main bot's Mini App direct link; the button appends
|
||||
// ?startapp=<payload> to it.
|
||||
BotLinkURL string
|
||||
// StartParam is the campaign payload appended as ?startapp=<StartParam> to the launch
|
||||
// button — e.g. a variant-seed deep link ("verudit_ru-scrabble_en") the backend
|
||||
// decodes to seed a brand-new user's variant preferences. Empty falls back to
|
||||
// forwarding the user's own /start payload.
|
||||
StartParam string
|
||||
// SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
|
||||
// disables the limiter. The burst equals the per-second rate.
|
||||
SendRatePerSecond int
|
||||
@@ -40,11 +47,12 @@ type Config struct {
|
||||
|
||||
// Bot is the promo bot wrapper around a Telegram Bot API client.
|
||||
type Bot struct {
|
||||
api *tgbot.Bot
|
||||
username string
|
||||
linkURL string
|
||||
log *zap.Logger
|
||||
limiter *rate.Limiter
|
||||
api *tgbot.Bot
|
||||
username string
|
||||
linkURL string
|
||||
startParam string
|
||||
log *zap.Logger
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
// New builds the promo bot, registering a /start (and default) handler that replies
|
||||
@@ -53,7 +61,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log}
|
||||
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, startParam: cfg.StartParam, log: log}
|
||||
if cfg.SendRatePerSecond > 0 {
|
||||
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
||||
}
|
||||
@@ -87,7 +95,8 @@ func (t *Bot) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// handleStart replies to any message (typically /start) with the localized promo text
|
||||
// and a button that opens the main bot's Mini App, forwarding any /start payload.
|
||||
// and a button that opens the main bot's Mini App at the configured campaign payload
|
||||
// (falling back to forwarding any /start payload the user arrived with).
|
||||
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||
if update.Message == nil {
|
||||
return
|
||||
@@ -104,11 +113,16 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
|
||||
if update.Message.From != nil {
|
||||
lang = update.Message.From.LanguageCode
|
||||
}
|
||||
text, button := promoText(lang, t.username)
|
||||
// The configured campaign payload (a variant-seed deep link) takes precedence; absent
|
||||
// one, fall back to forwarding any /start payload the user arrived with. The same
|
||||
// payload backs both the inline button and the @username link in the body.
|
||||
param := cmp.Or(t.startParam, startPayload(update.Message.Text))
|
||||
text, button := promoText(lang, t.username, t.launchURL(param))
|
||||
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
Text: text,
|
||||
ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)),
|
||||
ParseMode: models.ParseModeHTML,
|
||||
ReplyMarkup: t.launchMarkup(button, param),
|
||||
}); err != nil {
|
||||
t.log.Warn("promo: reply to start failed", zap.Error(err))
|
||||
}
|
||||
@@ -159,11 +173,15 @@ func startPayload(text string) string {
|
||||
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
|
||||
}
|
||||
|
||||
// promoText returns the localized message body and button label, naming the main bot
|
||||
// (Russian for a "ru" language code, English otherwise).
|
||||
func promoText(lang, username string) (text, button string) {
|
||||
// promoText returns the localized message body and button label. The body names the main
|
||||
// bot as a clickable @username whose link is the Mini App deep link (launchURL, the same
|
||||
// target as the button), so tapping the mention opens the seeded Mini App rather than the
|
||||
// bot profile. The body is sent with ParseMode HTML; only the link carries markup, so the
|
||||
// static sentences need no escaping. Russian for a "ru" language code, English otherwise.
|
||||
func promoText(lang, username, launchURL string) (text, button string) {
|
||||
mention := `<a href="` + html.EscapeString(launchURL) + `">@` + html.EscapeString(username) + `</a>`
|
||||
if strings.HasPrefix(strings.ToLower(lang), "ru") {
|
||||
return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
|
||||
return "Откройте " + mention + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
|
||||
}
|
||||
return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!"
|
||||
return "Open " + mention + " and choose your game variant in the profile settings.", "🤩 I want to play!"
|
||||
}
|
||||
|
||||
@@ -13,25 +13,30 @@ import (
|
||||
)
|
||||
|
||||
func TestPromoTextLocalization(t *testing.T) {
|
||||
en, enBtn := promoText("en", "ScrabbleBot")
|
||||
if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") {
|
||||
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||
// The @username is rendered as a clickable deep link (the same target as the button),
|
||||
// not a plain mention, so tapping it opens the seeded Mini App.
|
||||
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
||||
|
||||
en, enBtn := promoText("en", "ScrabbleBot", url)
|
||||
if !strings.Contains(en, wantLink) || !strings.Contains(en, "profile settings") {
|
||||
t.Errorf("en text = %q", en)
|
||||
}
|
||||
if enBtn != "🤩 I want to play!" {
|
||||
t.Errorf("en button = %q", enBtn)
|
||||
}
|
||||
|
||||
ru, ruBtn := promoText("ru-RU", "ScrabbleBot")
|
||||
if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") {
|
||||
ru, ruBtn := promoText("ru-RU", "ScrabbleBot", url)
|
||||
if !strings.Contains(ru, wantLink) || !strings.Contains(ru, "Откройте") {
|
||||
t.Errorf("ru text = %q", ru)
|
||||
}
|
||||
if ruBtn != "🤩 Хочу играть!" {
|
||||
t.Errorf("ru button = %q", ruBtn)
|
||||
}
|
||||
|
||||
// An unknown language falls back to English.
|
||||
if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") {
|
||||
t.Errorf("fallback text = %q, want English", got)
|
||||
// An unknown language falls back to English, still with the linked mention.
|
||||
if got, _ := promoText("de", "B", url); !strings.Contains(got, "Open ") || !strings.Contains(got, `">@B</a>`) {
|
||||
t.Errorf("fallback text = %q, want English with a linked mention", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +98,8 @@ func TestHandleStartReplies(t *testing.T) {
|
||||
if api.chatID != "42" {
|
||||
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||
}
|
||||
if !strings.Contains(api.text, "@ScrabbleBot") {
|
||||
t.Errorf("text = %q, want the @mention", api.text)
|
||||
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
||||
}
|
||||
if strings.Contains(api.replyMarkup, "web_app") {
|
||||
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
|
||||
|
||||
+4
-4
@@ -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();
|
||||
});
|
||||
|
||||
+20
-7
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+10
-2
@@ -1,7 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
|
||||
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
|
||||
reach this tag). -->
|
||||
<meta name="robots" content="noindex" />
|
||||
<!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a
|
||||
render-blocking <script> to telegram.org hangs the whole page on a network that blocks
|
||||
telegram.org (common where Telegram itself reaches users only over a proxy), stranding even
|
||||
@@ -13,7 +17,11 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>Scrabble</title>
|
||||
<title>Эрудит (Скрэббл)</title>
|
||||
<!-- Relative icon hrefs: the gateway serves the same build under /app/, /telegram/ and /vk/. -->
|
||||
<link rel="icon" href="favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+43
-2
@@ -1,10 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- A normal scrollable page (no board, no Telegram SDK), so allow the browser's zoom. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Scrabble</title>
|
||||
<!-- The SEO head is static and Russian-only: the page is client-rendered, so this layer is
|
||||
what non-rendering crawlers and link-preview bots (Telegram/VK use Open Graph) see. The
|
||||
absolute URLs are the production origin on purpose — the test contour then canonicalises
|
||||
to production instead of being indexed as a separate site. -->
|
||||
<title>Эрудит (Скрэббл) — играть онлайн с друзьями или роботом</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Бесплатная онлайн-игра в слова «Эрудит» (Скрэббл): играйте со случайным соперником, с друзьями или против робота — в Telegram, ВКонтакте или прямо в браузере."
|
||||
/>
|
||||
<link rel="canonical" href="https://erudit-game.ru/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Эрудит" />
|
||||
<meta property="og:title" content="Эрудит (Скрэббл) — играть онлайн" />
|
||||
<meta property="og:description" content="Игра в слова: со случайным соперником, с друзьями или против робота." />
|
||||
<meta property="og:url" content="https://erudit-game.ru/" />
|
||||
<meta property="og:image" content="https://erudit-game.ru/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:locale" content="ru_RU" />
|
||||
<meta property="og:locale:alternate" content="en_US" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<!-- Relative icon hrefs, like the in-page assets: the same build serves under any path. -->
|
||||
<link rel="icon" href="favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f3f4f6" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f1115" />
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Эрудит (Скрэббл)",
|
||||
"alternateName": "Erudit (Scrabble)",
|
||||
"url": "https://erudit-game.ru/",
|
||||
"image": "https://erudit-game.ru/og-image.png",
|
||||
"description": "Бесплатная онлайн-игра в слова «Эрудит» (Скрэббл): играйте со случайным соперником, с друзьями или против робота — в Telegram, ВКонтакте или прямо в браузере.",
|
||||
"applicationCategory": "GameApplication",
|
||||
"operatingSystem": "Any",
|
||||
"inLanguage": ["ru", "en"],
|
||||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "RUB" }
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@bufbuild/protobuf": "^2.12.0",
|
||||
"@connectrpc/connect": "^2.1.0",
|
||||
"@connectrpc/connect-web": "^2.1.0",
|
||||
"@vkontakte/vk-bridge": "^3.0.2",
|
||||
"flatbuffers": "^25.9.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+22
@@ -17,6 +17,9 @@ importers:
|
||||
'@connectrpc/connect-web':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))
|
||||
'@vkontakte/vk-bridge':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
flatbuffers:
|
||||
specifier: ^25.9.23
|
||||
version: 25.9.23
|
||||
@@ -413,6 +416,9 @@ packages:
|
||||
svelte: ^5.0.0
|
||||
vite: ^6.0.0
|
||||
|
||||
'@swc/helpers@0.5.23':
|
||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
@@ -462,6 +468,9 @@ packages:
|
||||
'@vitest/utils@3.2.6':
|
||||
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
|
||||
|
||||
'@vkontakte/vk-bridge@3.0.2':
|
||||
resolution: {integrity: sha512-MTp+nl0/jH4Sa2TXDyxNfy9VkhOhRQR1oQ4yhvthVDA4aWUa26npns7bRgfTuR3xDVGFiqW7zAwLnwcv3mL+dA==}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -689,6 +698,9 @@ packages:
|
||||
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.4.5:
|
||||
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -1022,6 +1034,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@swc/helpers@0.5.23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
@@ -1086,6 +1102,10 @@ snapshots:
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vkontakte/vk-bridge@3.0.2':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.23
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
aria-query@5.3.1: {}
|
||||
@@ -1318,6 +1338,8 @@ snapshots:
|
||||
|
||||
tinyspy@4.0.4: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.4.5: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect x="5.52" y="5.52" width="84.95" height="84.95" rx="10.15" fill="#D9B978" stroke="#B49559" stroke-width="3.05"/><path d="M46.51 25.06C46.51 25.06 46.51 25.06 46.51 25.06C42.72 25.06 39.47 25.9 36.76 27.58C34.04 29.26 32.13 31.57 31.01 34.51C31.01 34.51 31.01 34.51 31.01 34.51C31.01 34.51 24.25 32.27 24.25 32.27C26.01 27.96 28.78 24.71 32.54 22.52C36.3 20.33 40.98 19.23 46.58 19.23C46.58 19.23 46.58 19.23 46.58 19.23C54.6 19.23 60.87 21.61 65.4 26.36C69.94 31.12 72.2 37.69 72.2 46.08C72.2 46.08 72.2 46.08 72.2 46.08C72.2 51.64 71.19 56.47 69.18 60.57C67.16 64.68 64.23 67.84 60.38 70.06C56.53 72.28 51.93 73.38 46.58 73.38C46.58 73.38 46.58 73.38 46.58 73.38C35.97 73.38 28.38 68.75 23.8 59.49C23.8 59.49 23.8 59.49 23.8 59.49C23.8 59.49 29.63 56.58 29.63 56.58C31.34 60.06 33.63 62.76 36.5 64.66C39.36 66.57 42.6 67.52 46.21 67.52C46.21 67.52 46.21 67.52 46.21 67.52C51.51 67.52 55.82 65.83 59.15 62.46C62.47 59.09 64.37 54.54 64.84 48.81C64.84 48.81 64.84 48.81 64.84 48.81C64.84 48.81 40.42 48.81 40.42 48.81C40.42 48.81 40.42 43.06 40.42 43.06C40.42 43.06 64.84 43.06 64.84 43.06C64.32 37.36 62.46 32.93 59.26 29.78C56.06 26.63 51.81 25.06 46.51 25.06Z" fill="#1A1A1A" stroke="#1A1A1A" stroke-width="1.69"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg width="101" height="100" viewBox="0 0 101 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2_40)">
|
||||
<path d="M0.5 48C0.5 25.3726 0.5 14.0589 7.52944 7.02944C14.5589 0 25.8726 0 48.5 0H52.5C75.1274 0 86.4411 0 93.4706 7.02944C100.5 14.0589 100.5 25.3726 100.5 48V52C100.5 74.6274 100.5 85.9411 93.4706 92.9706C86.4411 100 75.1274 100 52.5 100H48.5C25.8726 100 14.5589 100 7.52944 92.9706C0.5 85.9411 0.5 74.6274 0.5 52V48Z" fill="#0077FF"/>
|
||||
<path d="M53.7085 72.042C30.9168 72.042 17.9169 56.417 17.3752 30.417H28.7919C29.1669 49.5003 37.5834 57.5836 44.25 59.2503V30.417H55.0004V46.8752C61.5837 46.1669 68.4995 38.667 70.8329 30.417H81.5832C79.7915 40.5837 72.2915 48.0836 66.9582 51.1669C72.2915 53.6669 80.8336 60.2086 84.0836 72.042H72.2499C69.7082 64.1253 63.3754 58.0003 55.0004 57.1669V72.042H53.7085Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2_40">
|
||||
<rect width="100" height="100" fill="white" transform="translate(0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 996 B |
@@ -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.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
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 DebugPanel from './components/DebugPanel.svelte';
|
||||
import Login from './screens/Login.svelte';
|
||||
import Lobby from './screens/Lobby.svelte';
|
||||
@@ -120,6 +121,7 @@
|
||||
<Toast />
|
||||
<StaleInviteModal />
|
||||
<WelcomeRedeemModal />
|
||||
<Coachmark />
|
||||
|
||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||
<Splash />
|
||||
|
||||
+35
-15
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { applyTheme } from './lib/theme';
|
||||
import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte';
|
||||
import { i18n, setLocale, t, type Locale } from './lib/i18n/index.svelte';
|
||||
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
|
||||
import { aboutContent } from './lib/aboutContent';
|
||||
import { telegramChannelLink } from './lib/landing';
|
||||
import { telegramChannelLink, vkAppLink } from './lib/landing';
|
||||
|
||||
// Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and
|
||||
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
|
||||
// stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system
|
||||
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app).
|
||||
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the
|
||||
// app) but defaults to Russian, never to the browser language — see landing.ts.
|
||||
|
||||
let theme = $state<'light' | 'dark'>('light');
|
||||
let langOpen = $state(false);
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
|
||||
const tgLink = $derived(telegramChannelLink());
|
||||
const vkLink = $derived(vkAppLink());
|
||||
const locales: { code: Locale; label: string }[] = [
|
||||
{ code: 'en', label: '🇬🇧 English' },
|
||||
{ code: 'ru', label: '🇷🇺 Русский' },
|
||||
@@ -30,7 +32,13 @@
|
||||
prefs = await loadPrefs();
|
||||
theme = systemTheme();
|
||||
applyTheme(theme);
|
||||
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
|
||||
setLocale(prefs.locale ?? 'ru');
|
||||
});
|
||||
|
||||
// Keep the document language honest for assistive tech and crawlers: the static shell
|
||||
// declares lang="ru" (the default), and a 🌐 switch to English must follow.
|
||||
$effect(() => {
|
||||
document.documentElement.lang = i18n.locale;
|
||||
});
|
||||
|
||||
function toggleTheme(): void {
|
||||
@@ -72,10 +80,19 @@
|
||||
<section class="hero">
|
||||
<h1>{about.title}</h1>
|
||||
<p class="tagline">{t('landing.tagline')}</p>
|
||||
{#if tgLink}
|
||||
<a class="tg" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
|
||||
<img src="telegram-logo.svg" alt="" width="64" height="64" />
|
||||
</a>
|
||||
{#if tgLink || vkLink}
|
||||
<div class="entries">
|
||||
{#if tgLink}
|
||||
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
|
||||
<img src="telegram-logo.svg" alt="" width="64" height="64" />
|
||||
</a>
|
||||
{/if}
|
||||
{#if vkLink}
|
||||
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
|
||||
<img src="vk-logo.svg" alt="" width="64" height="64" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -190,19 +207,22 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
/* The Telegram entry is just the bigger logo (no button chrome, no caption); the link
|
||||
keeps an aria-label for assistive tech. */
|
||||
.tg {
|
||||
/* The platform entries are just the bigger logos in a row (no button chrome, no captions);
|
||||
each link keeps an aria-label for assistive tech. */
|
||||
.entries {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tg img {
|
||||
.entry {
|
||||
display: inline-flex;
|
||||
}
|
||||
.entry img {
|
||||
display: block;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.tg:hover img {
|
||||
.entry:hover img {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.info {
|
||||
|
||||
+14
-3
@@ -46,9 +46,16 @@
|
||||
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
||||
content-safe-area inset, 0 elsewhere. */
|
||||
--tg-content-top: 0px;
|
||||
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
|
||||
--tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
|
||||
--tg-safe-top: 0px;
|
||||
/* Device safe-area top (the notch); inside Telegram TG's own nav controls sit between it and
|
||||
--tg-content-top, so the in-app header aligns to that band. Inside Telegram these are set from
|
||||
the SDK (lib/app.svelte.ts); elsewhere they fall back to the CSS env() safe area, so a VK Mini
|
||||
App / Capacitor / PWA webview clears the device cut-outs too (a plain browser tab reports 0). */
|
||||
--tg-safe-top: env(safe-area-inset-top, 0px);
|
||||
/* Device safe-area bottom / sides (home indicator; landscape notch) — the screen pads its bottom
|
||||
and left/right edges by these so content clears the cut-outs (e.g. the VK mobile home bar). */
|
||||
--tg-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--tg-safe-left: env(safe-area-inset-left, 0px);
|
||||
--tg-safe-right: env(safe-area-inset-right, 0px);
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||
"Noto Sans", "Liberation Sans", sans-serif;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||
@@ -164,6 +171,10 @@ html.app-shell body {
|
||||
/* No text selection anywhere by default; inputs opt back in below. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* No tap-highlight flash on tappable elements. Android in-app WebViews (Telegram, VK) draw a
|
||||
momentary selection-like box on a clickable node on tap — seen on the header title and the
|
||||
back chevron; user-select does not govern it. Inherited, so this clears it app-wide. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
input,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
} from '../lib/bannerEngine';
|
||||
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
||||
import type { BannerCampaign, BannerTimings } from '../lib/model';
|
||||
import { onExternalLinkClick } from '../lib/telegram';
|
||||
import { onExternalLinkClick } from '../lib/links';
|
||||
|
||||
let {
|
||||
campaigns,
|
||||
@@ -112,7 +112,8 @@
|
||||
</script>
|
||||
|
||||
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
|
||||
Telegram SDK (onExternalLinkClick uses closest('a')) so they skip the WebView confirmation. -->
|
||||
platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView
|
||||
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
<script lang="ts">
|
||||
// First-run onboarding coachmarks: a light dimmed overlay that points one bubble at a time at a
|
||||
// real UI element (a tab-bar button, the scoreboard, the rack), advancing on a tap anywhere and
|
||||
// removing itself for good after the last hint. Two independent series — the lobby (just
|
||||
// registered) and the first game board — selected by the current route. The geometry lives in
|
||||
// lib/coachmark (unit-tested); this component supplies the live target rectangles, drives the
|
||||
// step machine, and persists completion. Targets are found by their `data-coach` attribute, so
|
||||
// the same code anchors them in portrait and landscape, wherever the layout puts them.
|
||||
import { onDestroy } from 'svelte';
|
||||
import { app, markOnboardingDone } from '../lib/app.svelte';
|
||||
import { router } from '../lib/router.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { nextVisibleStep, placeBubble, stepsFor, type CoachSeries, type Placement } from '../lib/coachmark';
|
||||
|
||||
// In the mock build the overlay never auto-shows (so the Playwright smoke is not held up by an
|
||||
// overlay that intercepts taps — like the splash). `?coach` forces it on regardless, bypassing
|
||||
// both the mock gate and the "already seen" flag, for screenshots and the dedicated e2e.
|
||||
const isMock = import.meta.env.MODE === 'mock';
|
||||
const forced = typeof location !== 'undefined' && new URLSearchParams(location.search).has('coach');
|
||||
|
||||
// How long to wait for the first target of a series to lay out before giving up (the game board
|
||||
// mounts asynchronously once its state loads).
|
||||
const READY_BUDGET_MS = 8000;
|
||||
|
||||
let activeSeries = $state<CoachSeries | null>(null);
|
||||
let stepIndex = $state(-1);
|
||||
let placement = $state<Placement | null>(null);
|
||||
let bubbleEl = $state<HTMLElement>();
|
||||
// Series already completed in this page session. The persisted flag stops a series from
|
||||
// replaying in real use, but `?coach` deliberately bypasses that flag — so this stops a forced
|
||||
// series from immediately restarting itself the moment it finishes.
|
||||
const sessionDone = new Set<CoachSeries>();
|
||||
|
||||
// Viewport + a generic "layout changed" tick (resize, rotation, scroll, the banner reflow) that
|
||||
// re-runs the measuring effect so a bubble keeps pointing at its target.
|
||||
let vw = $state(0);
|
||||
let vh = $state(0);
|
||||
let tick = $state(0);
|
||||
let pollId = 0;
|
||||
|
||||
function seriesFor(name: string): CoachSeries | null {
|
||||
return name === 'lobby' ? 'lobby' : name === 'game' ? 'game' : null;
|
||||
}
|
||||
function seriesRoute(series: CoachSeries): string {
|
||||
return series === 'lobby' ? 'lobby' : 'game';
|
||||
}
|
||||
function targetEl(target: string): HTMLElement | null {
|
||||
return typeof document === 'undefined' ? null : document.querySelector<HTMLElement>(`[data-coach="${target}"]`);
|
||||
}
|
||||
function present(target: string): boolean {
|
||||
const el = targetEl(target);
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}
|
||||
function blockedByModal(): boolean {
|
||||
return app.welcomeRedeem || app.staleInvite || app.blocked != null || app.bootError || app.launchError != null;
|
||||
}
|
||||
function eligible(series: CoachSeries): boolean {
|
||||
if (sessionDone.has(series)) return false;
|
||||
const done = series === 'lobby' ? app.onboarding.lobbyDone : app.onboarding.gameDone;
|
||||
if (!(forced || (!isMock && !done))) return false;
|
||||
if (blockedByModal()) return false;
|
||||
// The lobby waits for the loading splash to clear; the game waits for its first target (below).
|
||||
if (series === 'lobby') return app.splashDone;
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeStart(series: CoachSeries): void {
|
||||
if (activeSeries || !eligible(series)) return;
|
||||
const i = nextVisibleStep(stepsFor(series), 0, present);
|
||||
if (i < 0) return; // targets not on screen yet — the poll keeps trying
|
||||
activeSeries = series;
|
||||
stepIndex = i;
|
||||
app.coachActive = true;
|
||||
tick++;
|
||||
}
|
||||
|
||||
function advance(): void {
|
||||
if (!activeSeries) return;
|
||||
const i = nextVisibleStep(stepsFor(activeSeries), stepIndex + 1, present);
|
||||
if (i < 0) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
stepIndex = i;
|
||||
tick++;
|
||||
}
|
||||
function finish(): void {
|
||||
if (activeSeries) {
|
||||
sessionDone.add(activeSeries);
|
||||
markOnboardingDone(activeSeries);
|
||||
}
|
||||
reset();
|
||||
}
|
||||
function reset(): void {
|
||||
activeSeries = null;
|
||||
stepIndex = -1;
|
||||
placement = null;
|
||||
app.coachActive = false;
|
||||
cancelPoll();
|
||||
}
|
||||
function cancelPoll(): void {
|
||||
if (pollId) {
|
||||
cancelAnimationFrame(pollId);
|
||||
pollId = 0;
|
||||
}
|
||||
}
|
||||
function startPoll(series: CoachSeries): void {
|
||||
cancelPoll();
|
||||
const t0 = performance.now();
|
||||
const loop = (): void => {
|
||||
pollId = 0;
|
||||
if (activeSeries || seriesFor(router.route.name) !== series) return;
|
||||
maybeStart(series);
|
||||
if (activeSeries || performance.now() - t0 > READY_BUDGET_MS) return;
|
||||
pollId = requestAnimationFrame(loop);
|
||||
};
|
||||
pollId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
// Orchestration: pick the series for the route, stop one left behind, and (re)try on every change
|
||||
// to a flag that gates eligibility (splash, modals, blocked, the seen flags). A bounded poll
|
||||
// covers the asynchronous appearance of the first target.
|
||||
$effect(() => {
|
||||
const name = router.route.name;
|
||||
// Touch the reactive eligibility inputs so this re-runs when they change.
|
||||
void app.splashDone;
|
||||
void app.welcomeRedeem;
|
||||
void app.staleInvite;
|
||||
void app.blocked;
|
||||
void app.bootError;
|
||||
void app.launchError;
|
||||
void app.onboarding.lobbyDone;
|
||||
void app.onboarding.gameDone;
|
||||
|
||||
const series = seriesFor(name);
|
||||
if (activeSeries && seriesRoute(activeSeries) !== name) reset();
|
||||
cancelPoll();
|
||||
if (!series) return;
|
||||
maybeStart(series);
|
||||
if (!activeSeries) startPoll(series);
|
||||
});
|
||||
|
||||
// While a series is active, track viewport size and any layout shift so the bubble re-anchors
|
||||
// (rotation, the hidden banner reflow, async fonts). Scroll is observed in the capture phase to
|
||||
// catch inner scrollers. Attached only when active so the always-mounted component adds no
|
||||
// global listeners while idle.
|
||||
$effect(() => {
|
||||
if (!activeSeries || typeof window === 'undefined') return;
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
const onResize = (): void => {
|
||||
vw = window.innerWidth;
|
||||
vh = window.innerHeight;
|
||||
tick++;
|
||||
};
|
||||
const onLayout = (): void => void tick++;
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('orientationchange', onResize);
|
||||
window.addEventListener('scroll', onLayout, true);
|
||||
const ro = new ResizeObserver(onLayout);
|
||||
ro.observe(document.body);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('orientationchange', onResize);
|
||||
window.removeEventListener('scroll', onLayout, true);
|
||||
ro.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
// Measure the active target and place the bubble, re-measuring each frame until the geometry holds
|
||||
// steady. The route-change slide, the hidden-banner reflow and async fonts all move the target
|
||||
// after the step first renders, so a single measurement can land the bubble at a mid-animation
|
||||
// position; settling fixes that. The bubble is laid out off-screen first so its real size is
|
||||
// known, and a vanished target is skipped.
|
||||
$effect(() => {
|
||||
void vw;
|
||||
void vh;
|
||||
void tick;
|
||||
const series = activeSeries;
|
||||
const i = stepIndex;
|
||||
if (!series || i < 0) {
|
||||
placement = null;
|
||||
return;
|
||||
}
|
||||
const target = stepsFor(series)[i].target;
|
||||
let raf = 0;
|
||||
let stableSig = '';
|
||||
let stableMs = 0;
|
||||
let totalMs = 0;
|
||||
const measure = (): void => {
|
||||
const el = targetEl(target);
|
||||
const r = el?.getBoundingClientRect();
|
||||
if (!r || r.width === 0 || r.height === 0) {
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
const bb = bubbleEl?.getBoundingClientRect();
|
||||
const size = bb && bb.width ? { width: bb.width, height: bb.height } : { width: 280, height: 80 };
|
||||
placement = placeBubble(
|
||||
{ left: r.left, top: r.top, width: r.width, height: r.height },
|
||||
{ width: window.innerWidth, height: window.innerHeight },
|
||||
size,
|
||||
);
|
||||
// Re-measure until the target rect + bubble size hold steady for a short spell (so the bubble
|
||||
// lands at the post-animation position), bounded by a hard cap as a safety net.
|
||||
const sig = `${Math.round(r.left)}:${Math.round(r.top)}:${Math.round(r.width)}:${Math.round(r.height)}:${Math.round(size.width)}`;
|
||||
if (sig === stableSig) stableMs += 16;
|
||||
else {
|
||||
stableSig = sig;
|
||||
stableMs = 0;
|
||||
}
|
||||
totalMs += 16;
|
||||
if (stableMs < 320 && totalMs < 2500) raf = requestAnimationFrame(measure);
|
||||
};
|
||||
raf = requestAnimationFrame(measure);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cancelPoll();
|
||||
if (activeSeries) app.coachActive = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if activeSeries && stepIndex >= 0}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="coach" data-coach-overlay onclick={advance}>
|
||||
<div
|
||||
class="bubble"
|
||||
class:shown={!!placement}
|
||||
bind:this={bubbleEl}
|
||||
data-side={placement?.side ?? 'bottom'}
|
||||
style="--tail:{placement?.tail ?? 0}px; {placement
|
||||
? `left:${placement.left}px; top:${placement.top}px;`
|
||||
: 'left:-9999px; top:0;'}"
|
||||
>
|
||||
<span class="text">{t(stepsFor(activeSeries)[stepIndex].key)}</span>
|
||||
<span class="tail" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.coach {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Above the lobby/game and modals (Modal.svelte z 40), below toasts (z 50) so an error toast
|
||||
still shows over it; the DebugPanel (z 10000) stays on top. */
|
||||
z-index: 46;
|
||||
/* Light dimming — the bubble (brand colour) carries the contrast. Tuned via screenshots. */
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
/* The whole layer captures taps: a tap anywhere advances, the UI underneath stays inert. */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.bubble {
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
max-width: min(72vw, 320px);
|
||||
padding: 9px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
/* Reference: the in-game "N в мешке" counter (0.85rem). */
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: break-word;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 130ms ease-out;
|
||||
}
|
||||
.bubble.shown {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Larger and a touch wider in landscape, where there is room and the portrait size looks tiny. */
|
||||
@media (orientation: landscape) {
|
||||
.bubble {
|
||||
max-width: min(46vw, 440px);
|
||||
font-size: 1.05rem;
|
||||
padding: 11px 15px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bubble {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
/* The tail: an 18px-wide triangle centred on --tail along the bubble edge that faces the target.
|
||||
`data-side` is where the bubble sits relative to the target, so the tail is on the opposite
|
||||
edge, pointing back at it. */
|
||||
.tail {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.bubble[data-side='bottom'] .tail {
|
||||
top: -9px;
|
||||
left: var(--tail);
|
||||
transform: translateX(-9px);
|
||||
border-left: 9px solid transparent;
|
||||
border-right: 9px solid transparent;
|
||||
border-bottom: 9px solid var(--accent);
|
||||
}
|
||||
.bubble[data-side='top'] .tail {
|
||||
bottom: -9px;
|
||||
left: var(--tail);
|
||||
transform: translateX(-9px);
|
||||
border-left: 9px solid transparent;
|
||||
border-right: 9px solid transparent;
|
||||
border-top: 9px solid var(--accent);
|
||||
}
|
||||
.bubble[data-side='right'] .tail {
|
||||
left: -9px;
|
||||
top: var(--tail);
|
||||
transform: translateY(-9px);
|
||||
border-top: 9px solid transparent;
|
||||
border-bottom: 9px solid transparent;
|
||||
border-right: 9px solid var(--accent);
|
||||
}
|
||||
.bubble[data-side='left'] .tail {
|
||||
right: -9px;
|
||||
top: var(--tail);
|
||||
transform: translateY(-9px);
|
||||
border-top: 9px solid transparent;
|
||||
border-bottom: 9px solid transparent;
|
||||
border-left: 9px solid var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -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<void> {
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onclick={closeDebug}>
|
||||
<button class="share" onclick={share}>{label}</button>
|
||||
<div class="actions">
|
||||
<button class="share" onclick={share}>{label}</button>
|
||||
<button class="reset" onclick={resetVisited}>{resetLabel}</button>
|
||||
</div>
|
||||
<pre class="body">{report}</pre>
|
||||
</div>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app } from '../lib/app.svelte';
|
||||
|
||||
// A cute emoji cycler shown while a game's dictionary warms for the local move
|
||||
// preview (docs/UI_DESIGN.md). One emoji per 500ms tick — 300ms static, then a
|
||||
// 200ms clockwise spin with the current emoji fading out and the next fading in —
|
||||
// looping through the sequence; the ten emojis span the 5s warm-up cap.
|
||||
const EMOJIS = ['🎲', '🤔', '📡', '📀', '💾', '⏳', '🐌', '🙈', '🇷🇺', '✌️'];
|
||||
const TICK_MS = 500;
|
||||
const SPIN_MS = 200;
|
||||
|
||||
let index = $state(0);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(() => {
|
||||
timer = setInterval(() => (index = (index + 1) % EMOJIS.length), TICK_MS);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// A simultaneous fade + clockwise spin used as both the in and out transition on the
|
||||
// keyed glyph, so the outgoing and incoming emoji cross-fade in place. Under
|
||||
// reduce-motion it degrades to a plain fade.
|
||||
function spinFade(_node: Element, { duration = SPIN_MS }: { duration?: number } = {}) {
|
||||
const spin = !app.reduceMotion;
|
||||
return {
|
||||
duration,
|
||||
css: (u: number) => (spin ? `opacity:${u};transform:rotate(${(1 - u) * 360}deg)` : `opacity:${u}`),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overlay" role="status" aria-live="polite">
|
||||
<div class="box">
|
||||
<div class="stage">
|
||||
{#key index}
|
||||
<span class="emoji" in:spinFade out:spinFade>{EMOJIS[index]}</span>
|
||||
{/key}
|
||||
</div>
|
||||
<span class="caption">{t('dict.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* Darker than the onboarding scrim — this blocks the board until the dictionary is ready. */
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
||||
.box {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.stage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* Reserve the glyph box so the caption never shifts as the emoji swaps. */
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
.emoji {
|
||||
grid-area: 1 / 1;
|
||||
font-size: 3.25rem; /* about twice a tab-bar button */
|
||||
line-height: 1;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.caption {
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
@@ -48,8 +48,11 @@
|
||||
</div>
|
||||
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
||||
same place on every screen — and in the game (grown nav) the spare height falls below
|
||||
it (banner under the title, board pinned to the bottom). -->
|
||||
{#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}
|
||||
<AdBanner
|
||||
campaigns={app.profile.banner.campaigns}
|
||||
timings={app.profile.banner.timings}
|
||||
@@ -81,7 +84,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 10px var(--pad);
|
||||
padding: 5px var(--pad);
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.05rem;
|
||||
@@ -121,6 +124,17 @@
|
||||
.back:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
/* Android in-app WebViews (Telegram, VK) flash a selection-like box on the two tappable header
|
||||
controls — the title (a 10-tap debug target) and the back chevron — on a plain tap. The global
|
||||
-webkit-user-select / -webkit-tap-highlight-color on #app is inherited, but those WebViews only
|
||||
suppress the artifact when the properties sit DIRECTLY on the tapped element. */
|
||||
h1,
|
||||
.back {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* A thin, compact "<" drawn from two borders — lighter than a glyph. */
|
||||
.chev {
|
||||
width: 11px;
|
||||
@@ -139,15 +153,15 @@
|
||||
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the
|
||||
(removed) hamburger that content shrank and the bar sat flush under Telegram's native nav
|
||||
band. So **padding-top** is the lever: it drops the title clear of the band — the notch
|
||||
plus a **10px** gap (was 6). A fixed px (not rem/em) gap so the clearance from Telegram's
|
||||
native controls stays constant if the user scales up the font (the title then grows
|
||||
downward and the bar with it). (Owner-tunable: the 10px.) */
|
||||
plus an **8px** gap (halved from 16). A fixed px (not rem/em) gap so the clearance from
|
||||
Telegram's native controls stays constant if the user scales up the font (the title then
|
||||
grows downward and the bar with it). (Owner-tunable: the 8px.) */
|
||||
min-height: var(--tg-content-top);
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: calc(var(--tg-safe-top) + 16px);
|
||||
padding-bottom: 6px;
|
||||
padding-top: calc(var(--tg-safe-top) + 8px);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
:global(html.tg-fullscreen) .spacer {
|
||||
display: none;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
scroll = true,
|
||||
growNav = false,
|
||||
column = false,
|
||||
selfInset = false,
|
||||
}: {
|
||||
title: string;
|
||||
back?: string;
|
||||
@@ -21,6 +22,10 @@
|
||||
children?: Snippet;
|
||||
scroll?: boolean;
|
||||
growNav?: boolean;
|
||||
// selfInset: the content paints the bottom home-indicator inset itself (a tab-bar-less screen
|
||||
// whose own bottom element owns the strip, e.g. the landscape game's left-panel controls), so
|
||||
// the shell does not add the detached .content padding-bottom below it.
|
||||
selfInset?: boolean;
|
||||
// column lays the content out as a flex column so a child can own the vertical fit
|
||||
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
||||
column?: boolean;
|
||||
@@ -87,7 +92,7 @@
|
||||
|
||||
<div class="screen">
|
||||
<Header {title} {back} grow={growNav} />
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column class:selfinset={selfInset}>{@render children?.()}</main>
|
||||
{#if tabbar}
|
||||
<nav class="tabbar">{@render tabbar()}</nav>
|
||||
{/if}
|
||||
@@ -101,6 +106,12 @@
|
||||
bottom input — chat, word-check — stays above an open soft keyboard without the page
|
||||
scrolling; falls back to the full height where the var is unset. */
|
||||
height: var(--vvh, 100%);
|
||||
/* Clear the landscape notch sides inside Telegram (0 elsewhere). The top inset is owned by the
|
||||
header; the home-indicator (bottom) inset is owned by the bottom bar — the .tabbar paints its
|
||||
own chrome into it, and a screen with no tab bar pads its content (.content:last-child) — so
|
||||
the strip takes the bar's colour rather than the detached content background. */
|
||||
padding-left: var(--tg-safe-left, 0px);
|
||||
padding-right: var(--tg-safe-right, 0px);
|
||||
}
|
||||
.content {
|
||||
flex: 0 1 auto;
|
||||
@@ -116,7 +127,23 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* No tab bar → the content is the bottom-most element: pad it by the device home-indicator inset
|
||||
so it clears the cut-out, the strip taking the content's own background. With a tab bar the
|
||||
.tabbar owns that inset instead (and content is not the last child, so this does not apply). */
|
||||
.content:last-child {
|
||||
padding-bottom: var(--tg-safe-bottom, 0px);
|
||||
}
|
||||
/* selfInset: the content's own bottom element paints the home-indicator strip (the landscape
|
||||
game's left-panel controls), so the shell does not add a detached padding strip below it. */
|
||||
.content.selfinset:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tabbar {
|
||||
flex: 0 0 auto;
|
||||
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
|
||||
inside Telegram (the inset is 0 elsewhere), so the safe-area strip reads as part of the bar
|
||||
instead of the content background showing through. */
|
||||
background: var(--bg-elev);
|
||||
padding-bottom: var(--tg-safe-bottom, 0px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { app, dismissStaleInvite } from '../lib/app.svelte';
|
||||
import { router } from '../lib/router.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { botUsername } from '../lib/deeplink';
|
||||
import { telegramOpenLink } from '../lib/telegram';
|
||||
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';
|
||||
|
||||
// The single bot's @username, for the deep link.
|
||||
@@ -14,14 +17,35 @@
|
||||
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();
|
||||
}
|
||||
|
||||
// Native path: when the notice fires inside the Mini App with native popups, present Telegram's
|
||||
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
|
||||
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
|
||||
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears the
|
||||
// notice; the `shown` guard fires it once per notice.
|
||||
// The notice is raised during boot; wait until the loading cover for the current route is gone —
|
||||
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
|
||||
// the native popup never appears over the splash.
|
||||
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
|
||||
let shown = false;
|
||||
$effect(() => {
|
||||
if (app.staleInvite && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
|
||||
shown = true;
|
||||
void telegramShowPopup(
|
||||
botInfoPopup(t('friends.staleInviteTitle'), t('friends.staleInvite'), username ?? '', t('common.ok')),
|
||||
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissStaleInvite()));
|
||||
} else if (!app.staleInvite) {
|
||||
shown = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if app.staleInvite}
|
||||
{#if app.staleInvite && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
|
||||
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
|
||||
import { router } from '../lib/router.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { botUsername } from '../lib/deeplink';
|
||||
import { telegramOpenLink } from '../lib/telegram';
|
||||
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';
|
||||
|
||||
// The single bot's @username, for the deep link.
|
||||
@@ -17,14 +20,35 @@
|
||||
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();
|
||||
}
|
||||
|
||||
// Native path: when the greeting fires inside the Mini App with native popups, present Telegram's
|
||||
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
|
||||
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
|
||||
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears it;
|
||||
// the `shown` guard fires it once per greeting.
|
||||
// The greeting is raised during boot; wait until the loading cover for the current route is gone —
|
||||
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
|
||||
// the native popup never appears over the splash.
|
||||
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
|
||||
let shown = false;
|
||||
$effect(() => {
|
||||
if (app.welcomeRedeem && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
|
||||
shown = true;
|
||||
void telegramShowPopup(
|
||||
botInfoPopup(t('friends.welcomeRedeemTitle'), t('friends.welcomeRedeem', { name }), username ?? '', t('common.ok')),
|
||||
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissWelcomeRedeem()));
|
||||
} else if (!app.welcomeRedeem) {
|
||||
shown = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if app.welcomeRedeem}
|
||||
{#if app.welcomeRedeem && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
|
||||
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
|
||||
|
||||
+86
-11
@@ -105,18 +105,27 @@
|
||||
prevZoom = on;
|
||||
prevRecenter = rc;
|
||||
if (landscape) {
|
||||
// Height-driven board in a wide viewport: pan so the focused cell is centred. The board
|
||||
// magnifies over the .scaler.land width/height transition, so the focus-centred scroll target
|
||||
// is only reachable once the board has grown — clamp it to the CURRENT scrollable size each
|
||||
// frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like
|
||||
// portrait's never settles here: zoom-out shrinks the board below the viewport, where it
|
||||
// stops overflowing.)
|
||||
// A wide viewport overflows VERTICALLY as soon as the square board grows past its height, but
|
||||
// HORIZONTALLY only once the board grows past the (much larger) width — so a per-axis scroll
|
||||
// moves the vertical axis early and the horizontal axis late (the browser pins scrollLeft to 0
|
||||
// until the board is wider than the viewport): the board positioning "in two steps". Drive
|
||||
// BOTH axes by one progress q — how far the board has grown past the viewport width, the point
|
||||
// at which a horizontal pan first becomes possible. Until then q is 0 and the board simply
|
||||
// zooms centred; past it both axes pan together in one diagonal motion. The full-zoom focus
|
||||
// target is finalSL/ST on zoom-in and the current position on zoom-out (where final is 0), so
|
||||
// q running 1→0 unwinds the scroll before the board shrinks back below the viewport.
|
||||
if (toggled || (recentered && on && f)) {
|
||||
const t0 = performance.now();
|
||||
let raf = requestAnimationFrame(function tick(now: number) {
|
||||
vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth));
|
||||
vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight));
|
||||
if (now - t0 < 320) raf = requestAnimationFrame(tick);
|
||||
const scaler = vp.firstElementChild as HTMLElement | null;
|
||||
const targetSL = on ? finalSL : vp.scrollLeft;
|
||||
const targetST = on ? finalST : vp.scrollTop;
|
||||
const span = Math.max(1, fullSide - clientW);
|
||||
let raf = requestAnimationFrame(function tick() {
|
||||
const side = scaler ? scaler.getBoundingClientRect().width : fullSide;
|
||||
const q = Math.max(0, Math.min(1, (side - clientW) / span));
|
||||
vp.scrollLeft = targetSL * q;
|
||||
vp.scrollTop = targetST * q;
|
||||
const settled = on ? side >= fullSide - 1 : side <= fitSide + 1;
|
||||
if (!settled) raf = requestAnimationFrame(tick);
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
@@ -204,6 +213,69 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Mouse drag-to-pan the zoomed board. Touch scrolls the overflow:auto viewport natively, but a
|
||||
// mouse cannot drag-scroll it, so on desktop / the landscape iframe the magnified board could not
|
||||
// be moved at all. Active only while zoomed, for a primary-button drag that does NOT start on a
|
||||
// pending tile (those own their pointer for relocation), and only once the pointer passes a small
|
||||
// threshold — so a plain click still places a rack tile or toggles zoom. A completed pan swallows
|
||||
// the trailing click so it does not also act on the cell under the release.
|
||||
$effect(() => {
|
||||
const vp = viewport;
|
||||
if (!vp) return;
|
||||
let armed = false;
|
||||
let panning = false;
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sl = 0;
|
||||
let st = 0;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return;
|
||||
if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return;
|
||||
armed = true;
|
||||
panning = false;
|
||||
sx = e.clientX;
|
||||
sy = e.clientY;
|
||||
sl = vp.scrollLeft;
|
||||
st = vp.scrollTop;
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!armed) return;
|
||||
const dx = e.clientX - sx;
|
||||
const dy = e.clientY - sy;
|
||||
if (!panning) {
|
||||
if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan
|
||||
panning = true;
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
}
|
||||
vp.scrollLeft = sl - dx;
|
||||
vp.scrollTop = st - dy;
|
||||
};
|
||||
const onUp = (e: PointerEvent) => {
|
||||
armed = false;
|
||||
if (!panning) return;
|
||||
panning = false;
|
||||
if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId);
|
||||
// Swallow the click the drag would otherwise produce (capture phase, before it reaches the
|
||||
// cell); self-remove on that click, and clean up on the next tick if none fired.
|
||||
const swallow = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
vp.removeEventListener('click', swallow, true);
|
||||
};
|
||||
vp.addEventListener('click', swallow, true);
|
||||
setTimeout(() => vp.removeEventListener('click', swallow, true), 0);
|
||||
};
|
||||
vp.addEventListener('pointerdown', onDown);
|
||||
vp.addEventListener('pointermove', onMove);
|
||||
vp.addEventListener('pointerup', onUp);
|
||||
vp.addEventListener('pointercancel', onUp);
|
||||
return () => {
|
||||
vp.removeEventListener('pointerdown', onDown);
|
||||
vp.removeEventListener('pointermove', onMove);
|
||||
vp.removeEventListener('pointerup', onUp);
|
||||
vp.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
});
|
||||
|
||||
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
|
||||
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
|
||||
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
|
||||
@@ -287,6 +359,9 @@
|
||||
/* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed
|
||||
board cannot be dragged past its content into empty space — it just stops at the edge. */
|
||||
overscroll-behavior: none;
|
||||
/* The zoom effect drives scrollLeft/Top itself while the board grows/shrinks; turn off the
|
||||
browser's scroll-anchoring so it does not fight those writes mid-transition. */
|
||||
overflow-anchor: none;
|
||||
}
|
||||
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
|
||||
— a magnifying-glass zoom. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user