Compare commits
27 Commits
v1.7.0
...
41f26da9a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f26da9a7 | |||
| cf1d773c18 | |||
| 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 |
@@ -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
|
# 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.
|
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||||
env:
|
env:
|
||||||
DICT_VERSION: v1.3.0
|
DICT_VERSION: v1.3.1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
@@ -261,6 +261,9 @@ jobs:
|
|||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
||||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_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 }}
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ jobs:
|
|||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
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_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
@@ -136,6 +137,7 @@ jobs:
|
|||||||
export APP_VERSION='$TAG'
|
export APP_VERSION='$TAG'
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||||
EOF
|
EOF
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
|
|||||||
@@ -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,
|
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
|
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`
|
`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 reader's bit when they open the move history or chat, a wired `NudgeClearer`
|
||||||
clears a nudge when its recipient moves — both record the publish-to-read latency.
|
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
|
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
|
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**.
|
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 := social.NewService(social.NewStore(db), accounts, games)
|
||||||
socialSvc.SetNotifier(hub)
|
socialSvc.SetNotifier(hub)
|
||||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
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.SetNudgeClearer(socialSvc.ClearNudges)
|
||||||
|
games.SetNudgeExpirer(socialSvc.ExpireNudges)
|
||||||
// Reap per-game disguised-robot friend requests once their game is long finished
|
// 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).
|
// (the robot ignores them; the row only pins the in-game "request sent" state).
|
||||||
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
|
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import (
|
|||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
// Identity kinds recognised by the backend. Telegram and VK are platform identities,
|
||||||
// alongside platform identities; its confirmed flag is driven by the email
|
// auto-confirmed on first contact. Email is modelled as an identity alongside them; its
|
||||||
// confirm-code flow. Robot is a synthetic kind: each pooled
|
// confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
|
||||||
// robot opponent is a durable account bound to one robot identity.
|
// each pooled robot opponent is a durable account bound to one robot identity.
|
||||||
const (
|
const (
|
||||||
KindTelegram = "telegram"
|
KindTelegram = "telegram"
|
||||||
|
KindVK = "vk"
|
||||||
KindEmail = "email"
|
KindEmail = "email"
|
||||||
KindRobot = "robot"
|
KindRobot = "robot"
|
||||||
)
|
)
|
||||||
@@ -185,6 +186,28 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
|
|||||||
return acc, created, err
|
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,
|
// provision finds the account for (kind, externalID) or creates it with seed,
|
||||||
// collapsing a concurrent-create race on the identity unique constraint into a
|
// collapsing a concurrent-create race on the identity unique constraint into a
|
||||||
// re-read of the winner's account.
|
// re-read of the winner's account.
|
||||||
@@ -258,6 +281,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
|||||||
return seed
|
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.
|
// 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) {
|
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
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.Kind,
|
||||||
table.Identities.ExternalID,
|
table.Identities.ExternalID,
|
||||||
table.Identities.Confirmed,
|
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 {
|
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
|||||||
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
|||||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
{"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"},
|
{"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", 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", 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", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||||
|
|||||||
@@ -142,6 +142,11 @@
|
|||||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{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>
|
<section class="panel"><h2>Games</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
<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
|
HintBalance int
|
||||||
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
// 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.
|
// server's maxHintGrant), passed through so the policy value lives in one place.
|
||||||
HintGrantMax int
|
HintGrantMax int
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
HasStats bool
|
HasStats bool
|
||||||
Stats StatsRow
|
Stats StatsRow
|
||||||
Identities []IdentityRow
|
Identities []IdentityRow
|
||||||
Games []GameRow
|
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
|
TelegramID string
|
||||||
|
VKID string
|
||||||
ConnectorEnabled bool
|
ConnectorEnabled bool
|
||||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
// 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.
|
// time (min/mean/max), empty when the account has no timed move.
|
||||||
|
|||||||
@@ -54,8 +54,14 @@ type Service struct {
|
|||||||
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
// 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.
|
// 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
|
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
|
||||||
metrics *gameMetrics
|
// expireNudges, when set, marks every pending nudge in a game read once the game
|
||||||
log *zap.Logger
|
// 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;
|
// 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
|
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
|
// 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
|
// (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.
|
// 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 {
|
if c.finished {
|
||||||
svc.cache.remove(gameID)
|
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)
|
post, err := svc.store.GetGame(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1440,13 +1465,24 @@ func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return svc.store.VoidGame(ctx, voidCommit{
|
if err := svc.store.VoidGame(ctx, voidCommit{
|
||||||
gameID: pre.ID,
|
gameID: pre.ID,
|
||||||
endReason: g.Reason().String(),
|
endReason: g.Reason().String(),
|
||||||
scores: scores,
|
scores: scores,
|
||||||
now: svc.clock(),
|
now: svc.clock(),
|
||||||
stats: buildStats(g, statSeats),
|
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.
|
// replayMove re-applies one journalled move to g through the decoded engine API.
|
||||||
|
|||||||
@@ -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
|
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
|
||||||
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
|
// 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
|
// 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
|
// 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
|
// 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).
|
// chat, so the message must not linger unread (skewing the count and the read metric).
|
||||||
|
|||||||
@@ -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 {
|
if s.sessions != nil && s.accounts != nil {
|
||||||
in := s.internal
|
in := s.internal
|
||||||
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
||||||
|
in.POST("/sessions/vk", s.handleVKAuth)
|
||||||
in.POST("/sessions/guest", s.handleGuestAuth)
|
in.POST("/sessions/guest", s.handleGuestAuth)
|
||||||
in.POST("/sessions/email/request", s.handleEmailRequest)
|
in.POST("/sessions/email/request", s.handleEmailRequest)
|
||||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||||
|
|||||||
@@ -362,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
||||||
view.TelegramID = tg
|
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 {
|
if games, err := s.games.ListForAccount(ctx, id); err == nil {
|
||||||
for _, g := range games {
|
for _, g := range games {
|
||||||
view.Games = append(view.Games, gameRow(g))
|
view.Games = append(view.Games, gameRow(g))
|
||||||
|
|||||||
@@ -65,6 +65,37 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
|||||||
s.mintSession(c, acc)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
||||||
type pushTargetRequest struct {
|
type pushTargetRequest struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ func (svc *Service) ClearNudges(ctx context.Context, gameID, accountID uuid.UUID
|
|||||||
return nil
|
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
|
// 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.
|
// 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) {
|
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()
|
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
|
// unreadGames returns the games where viewerID has an unread entry, resolving their
|
||||||
// seat per game through the game_players join.
|
// seat per game through the game_players join.
|
||||||
func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||||
|
|||||||
@@ -55,3 +55,9 @@ TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a vari
|
|||||||
TELEGRAM_MINIAPP_URL= # required
|
TELEGRAM_MINIAPP_URL= # required
|
||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
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=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
|
# 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
|
# 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
|
# the gateway; the catch-all — notably the public landing at / — goes to the
|
||||||
# static landing container, so stray traffic never reaches the Go edge.
|
# static landing container, so stray traffic never reaches the Go edge.
|
||||||
# Mirrors ../galaxy-game's /_gm model.
|
# Mirrors ../galaxy-game's /_gm model.
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
# 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
|
# 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).
|
# 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/* /scrabble.edge.v1.Gateway/*
|
||||||
handle @gateway {
|
handle @gateway {
|
||||||
reverse_proxy gateway:8081 {
|
reverse_proxy gateway:8081 {
|
||||||
header_up -X-Scrabble-Honeypot
|
header_up -X-Scrabble-Honeypot
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ services:
|
|||||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||||
# Telegram auth validates against the home validator (plaintext, internal).
|
# Telegram auth validates against the home validator (plaintext, internal).
|
||||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
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
|
# 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
|
# 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
|
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
||||||
|
|||||||
+24
-11
@@ -149,11 +149,18 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
|
|
||||||
- The gateway validates the originating credential **once** — Telegram `initData`
|
- The gateway validates the originating credential **once** — Telegram `initData`
|
||||||
(delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
|
(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
|
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`)
|
Telegram/VK contact seeds the new account's language (Telegram's `language_code` / VK's
|
||||||
and display name (§4). The validator runs on the main host and never reaches the Bot
|
`vk_language`) and display name (§4; VK omits the name from the signed params, so the client
|
||||||
API, so login does not depend on Telegram or the remote bot being up (§10, §12).
|
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
|
- **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
|
game channel), split into a home **validator** and a remote **bot** that share the
|
||||||
token. `ValidateInitData` (the validator) validates `initData` against that single
|
token. `ValidateInitData` (the validator) validates `initData` against that single
|
||||||
@@ -639,7 +646,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**
|
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
|
(`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
|
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
|
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_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`**
|
unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`**
|
||||||
@@ -649,8 +660,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
|
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
|
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
|
**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
|
sections (the finished section keeps its activity order). On each player-driven clear the
|
||||||
latency is recorded; the read time itself is not retained.
|
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
|
- **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email
|
||||||
(confirm-code binding, see §4), **timezone**, the daily **away window**, the
|
(confirm-code binding, see §4), **timezone**, the daily **away window**, the
|
||||||
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
|
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
|
||||||
@@ -1046,10 +1058,11 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
|
|||||||
Single public origin, path-routed. The Vite build has two entries: a lightweight
|
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
|
**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
|
(`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
|
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
||||||
of redirecting away); a stray hit on the gateway's `/`
|
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
|
||||||
308-redirects to `/app/`. The **landing** ships in its own static container: the
|
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,
|
`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
|
`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. Hash-named `/assets/*` are served
|
||||||
@@ -1058,7 +1071,7 @@ static file serving and never reaches the Go edge. Hash-named `/assets/*` are se
|
|||||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
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
|
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
|
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
|
catch-all — notably the landing at `/` — goes to the landing container. The
|
||||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||||
|
|||||||
+25
-3
@@ -22,9 +22,23 @@ 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.
|
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
|
A public **landing page** at the site root introduces the game, switches language and
|
||||||
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
||||||
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||||
|
|
||||||
|
### 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
|
### Identity & sessions
|
||||||
A player arrives from a platform (Telegram first), via email login, or as an
|
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
|
ephemeral guest. The gateway validates the credential once and mints a thin
|
||||||
@@ -35,6 +49,12 @@ the device safe-area, and — on first contact — seeds the new account's inter
|
|||||||
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
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
|
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.
|
**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
|
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
|
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
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
@@ -121,7 +141,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
|
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
|
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,
|
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
|
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||||
expires after seven days.
|
expires after seven days.
|
||||||
|
|
||||||
@@ -239,7 +260,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
|
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
|
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
|
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
|
### Profile & settings
|
||||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||||
|
|||||||
+27
-4
@@ -23,9 +23,24 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
||||||
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||||
|
|
||||||
|
### Первый запуск: онбординг
|
||||||
|
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
||||||
|
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
|
||||||
|
«хвостиком» указывает на реальный элемент управления, а **тап в любом месте** переходит к
|
||||||
|
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
|
||||||
|
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
|
||||||
|
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
|
||||||
|
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
|
||||||
|
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
|
||||||
|
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
|
||||||
|
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
|
||||||
|
Обе серии одинаково работают в portrait и landscape (облачка переустанавливаются туда, куда
|
||||||
|
переезжают элементы), тексты подсказок локализованы (en/ru). Рекламный баннер скрывается, пока
|
||||||
|
оверлей показан, и возвращается по закрытию.
|
||||||
|
|
||||||
### Личность и сессии
|
### Личность и сессии
|
||||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||||
@@ -37,7 +52,13 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
||||||
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
||||||
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
||||||
Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним
|
Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным
|
||||||
|
launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса
|
||||||
|
нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте,
|
||||||
|
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||||
|
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||||
|
тихого повтора «не удалось
|
||||||
|
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
||||||
@@ -127,7 +148,8 @@ _Вход сейчас только через провайдера, поэто
|
|||||||
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
||||||
кто выбрал то же правило. Игры с друзьями (2–4)
|
кто выбрал то же правило. Игры с друзьями (2–4)
|
||||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
|
||||||
|
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
|
||||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||||
ответа приглашение протухает через семь дней.
|
ответа приглашение протухает через семь дней.
|
||||||
|
|
||||||
@@ -247,7 +269,8 @@ _Вход сейчас только через провайдера, поэто
|
|||||||
**лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую,
|
**лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую,
|
||||||
когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже
|
когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже
|
||||||
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
|
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
|
||||||
его получатель **делает ход**.
|
его получатель **делает ход**, и **все nudge'и партии гаснут по её завершении** (после конца
|
||||||
|
партии бейдж неактуален) — но непрочитанные сообщения чата остаются непрочитанными.
|
||||||
|
|
||||||
### Профиль и настройки
|
### Профиль и настройки
|
||||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||||
|
|||||||
+48
-3
@@ -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`
|
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.
|
(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
|
## Navigation
|
||||||
|
|
||||||
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
- **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
|
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
|
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
|
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 transitions** (`App.svelte`): navigation slides directionally — a
|
||||||
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
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
|
from the left (back). Transitions are local (so they do not play on first load) and
|
||||||
@@ -113,6 +142,18 @@ 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
|
by a background suspend reconnects silently on return — the connection banner is
|
||||||
suppressed while hidden and for a short grace after resume (visibilitychange +
|
suppressed while hidden and for a short grace after resume (visibilitychange +
|
||||||
pageshow/pagehide + Telegram `activated`/`deactivated`).
|
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.
|
||||||
|
|
||||||
## Tiles & board
|
## Tiles & board
|
||||||
|
|
||||||
@@ -137,7 +178,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
|
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
|
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
|
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
|
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
|
||||||
hint's placement, not
|
hint's placement, not
|
||||||
the top-left.
|
the top-left.
|
||||||
@@ -243,7 +287,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`
|
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
|
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
|
`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
|
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
|
live outside the components, so navigating between screens (which remounts the view) **continues the
|
||||||
|
|||||||
+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
|
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
|
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
|
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
|
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;
|
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
|
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/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||||
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
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/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
|
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`
|
`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.
|
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`,
|
`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;
|
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
||||||
live events
|
live events
|
||||||
@@ -81,6 +88,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
|||||||
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
||||||
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
| `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_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_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_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) |
|
| `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,7 +190,7 @@ 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)")
|
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{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
|
|||||||
@@ -201,6 +201,23 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
|||||||
return out, err
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
||||||
// external_id (empty when they have no Telegram identity), preferred language, and
|
// external_id (empty when they have no Telegram identity), preferred language, and
|
||||||
// whether they confined notifications to the in-app stream.
|
// whether they confined notifications to the in-app stream.
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ type Config struct {
|
|||||||
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
||||||
// Login Widget data. Empty disables the telegram auth path.
|
// Login Widget data. Empty disables the telegram auth path.
|
||||||
ValidatorAddr string
|
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
|
// 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).
|
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
||||||
BotLink BotLinkConfig
|
BotLink BotLinkConfig
|
||||||
@@ -169,6 +173,7 @@ func Load() (Config, error) {
|
|||||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
|
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||||
SessionCacheMax: defaultSessionCacheMax,
|
SessionCacheMax: defaultSessionCacheMax,
|
||||||
RateLimit: DefaultRateLimit(),
|
RateLimit: DefaultRateLimit(),
|
||||||
Abuse: DefaultAbuse(),
|
Abuse: DefaultAbuse(),
|
||||||
|
|||||||
@@ -183,14 +183,15 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
// does not serve the app shell at the operator path.
|
// does not serve the app shell at the operator path.
|
||||||
mux.Handle("/_gm/", http.NotFoundHandler())
|
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||||
}
|
}
|
||||||
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram
|
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||||
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below
|
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
||||||
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and
|
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
||||||
// each mount falls back to the app shell (index.html) for the hash router. The
|
// priority, and each mount falls back to the app shell (index.html) for the hash
|
||||||
// public landing lives in its own static container behind the contour caddy,
|
// router. The public landing lives in its own static container behind the contour
|
||||||
// so the catch-all redirects a stray root hit to the app shell — which
|
// caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a
|
||||||
// keeps a local no-caddy run usable.
|
// local no-caddy run usable.
|
||||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
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("/app/", webui.Handler("/app/", "index.html"))
|
||||||
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
||||||
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import (
|
|||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
"scrabble/gateway/internal/connector"
|
"scrabble/gateway/internal/connector"
|
||||||
|
"scrabble/gateway/internal/vkauth"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message types in the vertical slice.
|
// Message types in the vertical slice.
|
||||||
const (
|
const (
|
||||||
MsgAuthTelegram = "auth.telegram"
|
MsgAuthTelegram = "auth.telegram"
|
||||||
|
MsgAuthVK = "auth.vk"
|
||||||
MsgAuthGuest = "auth.guest"
|
MsgAuthGuest = "auth.guest"
|
||||||
MsgAuthEmailReq = "auth.email.request"
|
MsgAuthEmailReq = "auth.email.request"
|
||||||
MsgAuthEmailLogin = "auth.email.login"
|
MsgAuthEmailLogin = "auth.email.login"
|
||||||
@@ -89,8 +91,9 @@ type TelegramValidator interface {
|
|||||||
|
|
||||||
// NewRegistry builds the slice's message-type catalog over the backend client.
|
// 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
|
// The Telegram auth op is registered only when a validator is supplied (the
|
||||||
// connector is configured); otherwise auth.telegram is simply unknown.
|
// connector is configured); otherwise auth.telegram is simply unknown. Optional ops
|
||||||
func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
|
// (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)}
|
r := &Registry{ops: make(map[string]Op)}
|
||||||
if tg != nil {
|
if tg != nil {
|
||||||
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
||||||
@@ -126,9 +129,27 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry
|
|||||||
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
|
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
|
||||||
registerSocialOps(r, backend)
|
registerSocialOps(r, backend)
|
||||||
registerLinkOps(r, backend, tg)
|
registerLinkOps(r, backend, tg)
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(r, backend)
|
||||||
|
}
|
||||||
return r
|
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.
|
// Lookup returns the operation for messageType, and whether it is registered.
|
||||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||||
op, ok := r.ops[messageType]
|
op, ok := r.ops[messageType]
|
||||||
@@ -146,6 +167,9 @@ func DomainCode(err error) (string, bool) {
|
|||||||
if errors.Is(err, connector.ErrInvalidInitData) {
|
if errors.Is(err, connector.ErrInvalidInitData) {
|
||||||
return "invalid_init_data", true
|
return "invalid_init_data", true
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, vkauth.ErrInvalid) {
|
||||||
|
return "invalid_vk_params", true
|
||||||
|
}
|
||||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||||
return "invalid_login_widget", true
|
return "invalid_login_widget", true
|
||||||
}
|
}
|
||||||
@@ -175,6 +199,25 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
return encodeSession(sess), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func authGuestHandler(backend *backendclient.Client) Handler {
|
func authGuestHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
// The guest bootstrap historically carried no payload; the detected zone is
|
// The guest bootstrap historically carried no payload; the detected zone is
|
||||||
|
|||||||
@@ -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;
|
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
|
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
|
||||||
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
|
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
|
||||||
// guest account's time zone (see TelegramLoginRequest.browser_tz).
|
// 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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@bufbuild/protobuf": "^2.12.0",
|
"@bufbuild/protobuf": "^2.12.0",
|
||||||
"@connectrpc/connect": "^2.1.0",
|
"@connectrpc/connect": "^2.1.0",
|
||||||
"@connectrpc/connect-web": "^2.1.0",
|
"@connectrpc/connect-web": "^2.1.0",
|
||||||
|
"@vkontakte/vk-bridge": "^3.0.2",
|
||||||
"flatbuffers": "^25.9.23"
|
"flatbuffers": "^25.9.23"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+22
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@connectrpc/connect-web':
|
'@connectrpc/connect-web':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.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:
|
flatbuffers:
|
||||||
specifier: ^25.9.23
|
specifier: ^25.9.23
|
||||||
version: 25.9.23
|
version: 25.9.23
|
||||||
@@ -413,6 +416,9 @@ packages:
|
|||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
vite: ^6.0.0
|
vite: ^6.0.0
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.23':
|
||||||
|
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
@@ -462,6 +468,9 @@ packages:
|
|||||||
'@vitest/utils@3.2.6':
|
'@vitest/utils@3.2.6':
|
||||||
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
|
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:
|
acorn@8.16.0:
|
||||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@@ -689,6 +698,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
typescript@5.4.5:
|
typescript@5.4.5:
|
||||||
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
|
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -1022,6 +1034,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.23':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
@@ -1086,6 +1102,10 @@ snapshots:
|
|||||||
loupe: 3.2.1
|
loupe: 3.2.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vkontakte/vk-bridge@3.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@swc/helpers': 0.5.23
|
||||||
|
|
||||||
acorn@8.16.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
aria-query@5.3.1: {}
|
aria-query@5.3.1: {}
|
||||||
@@ -1318,6 +1338,8 @@ snapshots:
|
|||||||
|
|
||||||
tinyspy@4.0.4: {}
|
tinyspy@4.0.4: {}
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
typescript@5.4.5: {}
|
typescript@5.4.5: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Splash from './components/Splash.svelte';
|
import Splash from './components/Splash.svelte';
|
||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
|
import Coachmark from './components/Coachmark.svelte';
|
||||||
import DebugPanel from './components/DebugPanel.svelte';
|
import DebugPanel from './components/DebugPanel.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -120,6 +121,7 @@
|
|||||||
<Toast />
|
<Toast />
|
||||||
<StaleInviteModal />
|
<StaleInviteModal />
|
||||||
<WelcomeRedeemModal />
|
<WelcomeRedeemModal />
|
||||||
|
<Coachmark />
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||||
<Splash />
|
<Splash />
|
||||||
|
|||||||
+14
-8
@@ -46,14 +46,16 @@
|
|||||||
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
||||||
content-safe-area inset, 0 elsewhere. */
|
content-safe-area inset, 0 elsewhere. */
|
||||||
--tg-content-top: 0px;
|
--tg-content-top: 0px;
|
||||||
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
|
/* 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, 0 elsewhere. */
|
--tg-content-top, so the in-app header aligns to that band. Inside Telegram these are set from
|
||||||
--tg-safe-top: 0px;
|
the SDK (lib/app.svelte.ts); elsewhere they fall back to the CSS env() safe area, so a VK Mini
|
||||||
/* Telegram device safe-area bottom / sides (home indicator; landscape notch), 0 elsewhere —
|
App / Capacitor / PWA webview clears the device cut-outs too (a plain browser tab reports 0). */
|
||||||
the screen pads its bottom and left/right edges by these so content clears the cut-outs. */
|
--tg-safe-top: env(safe-area-inset-top, 0px);
|
||||||
--tg-safe-bottom: 0px;
|
/* Device safe-area bottom / sides (home indicator; landscape notch) — the screen pads its bottom
|
||||||
--tg-safe-left: 0px;
|
and left/right edges by these so content clears the cut-outs (e.g. the VK mobile home bar). */
|
||||||
--tg-safe-right: 0px;
|
--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,
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||||
"Noto Sans", "Liberation Sans", sans-serif;
|
"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);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||||
@@ -169,6 +171,10 @@ html.app-shell body {
|
|||||||
/* No text selection anywhere by default; inputs opt back in below. */
|
/* No text selection anywhere by default; inputs opt back in below. */
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-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,
|
input,
|
||||||
|
|||||||
@@ -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,7 +4,7 @@
|
|||||||
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
|
// 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
|
// 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.
|
// Telegram Android presentation quirks. Drawn from the top, just under the app header.
|
||||||
import { app, closeDebug } from '../lib/app.svelte';
|
import { app, closeDebug, resetOnboarding } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { shareText } from '../lib/share';
|
import { shareText } from '../lib/share';
|
||||||
import { telegramChromeDiag } from '../lib/telegram';
|
import { telegramChromeDiag } from '../lib/telegram';
|
||||||
@@ -26,12 +26,25 @@
|
|||||||
setTimeout(() => (label = 'Share'), 1500);
|
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();
|
||||||
|
resetLabel = 'Cleared — relaunch';
|
||||||
|
setTimeout(() => (resetLabel = 'Reset visited'), 1800);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="overlay" onclick={closeDebug}>
|
<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>
|
<pre class="body">{report}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,6 +62,12 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
.actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.share {
|
.share {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 7px 16px;
|
padding: 7px 16px;
|
||||||
@@ -58,6 +77,16 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.95rem;
|
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 {
|
.body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -48,8 +48,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
<!-- 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
|
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). -->
|
it (banner under the title, board pinned to the bottom). It is hidden while a first-run
|
||||||
{#if app.profile?.banner && app.profile.banner.campaigns.length}
|
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
|
<AdBanner
|
||||||
campaigns={app.profile.banner.campaigns}
|
campaigns={app.profile.banner.campaigns}
|
||||||
timings={app.profile.banner.timings}
|
timings={app.profile.banner.timings}
|
||||||
@@ -121,6 +124,17 @@
|
|||||||
.back:hover {
|
.back:hover {
|
||||||
background: var(--surface-2);
|
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. */
|
/* A thin, compact "<" drawn from two borders — lighter than a glyph. */
|
||||||
.chev {
|
.chev {
|
||||||
width: 11px;
|
width: 11px;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
scroll = true,
|
scroll = true,
|
||||||
growNav = false,
|
growNav = false,
|
||||||
column = false,
|
column = false,
|
||||||
|
selfInset = false,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
back?: string;
|
back?: string;
|
||||||
@@ -21,6 +22,10 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
scroll?: boolean;
|
scroll?: boolean;
|
||||||
growNav?: 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
|
// 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).
|
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
||||||
column?: boolean;
|
column?: boolean;
|
||||||
@@ -87,7 +92,7 @@
|
|||||||
|
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
<Header {title} {back} grow={growNav} />
|
<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}
|
{#if tabbar}
|
||||||
<nav class="tabbar">{@render tabbar()}</nav>
|
<nav class="tabbar">{@render tabbar()}</nav>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -128,6 +133,11 @@
|
|||||||
.content:last-child {
|
.content:last-child {
|
||||||
padding-bottom: var(--tg-safe-bottom, 0px);
|
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 {
|
.tabbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
|
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
|
||||||
|
|||||||
+86
-11
@@ -105,18 +105,27 @@
|
|||||||
prevZoom = on;
|
prevZoom = on;
|
||||||
prevRecenter = rc;
|
prevRecenter = rc;
|
||||||
if (landscape) {
|
if (landscape) {
|
||||||
// Height-driven board in a wide viewport: pan so the focused cell is centred. The board
|
// A wide viewport overflows VERTICALLY as soon as the square board grows past its height, but
|
||||||
// magnifies over the .scaler.land width/height transition, so the focus-centred scroll target
|
// HORIZONTALLY only once the board grows past the (much larger) width — so a per-axis scroll
|
||||||
// is only reachable once the board has grown — clamp it to the CURRENT scrollable size each
|
// moves the vertical axis early and the horizontal axis late (the browser pins scrollLeft to 0
|
||||||
// frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like
|
// until the board is wider than the viewport): the board positioning "in two steps". Drive
|
||||||
// portrait's never settles here: zoom-out shrinks the board below the viewport, where it
|
// BOTH axes by one progress q — how far the board has grown past the viewport width, the point
|
||||||
// stops overflowing.)
|
// 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)) {
|
if (toggled || (recentered && on && f)) {
|
||||||
const t0 = performance.now();
|
const scaler = vp.firstElementChild as HTMLElement | null;
|
||||||
let raf = requestAnimationFrame(function tick(now: number) {
|
const targetSL = on ? finalSL : vp.scrollLeft;
|
||||||
vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth));
|
const targetST = on ? finalST : vp.scrollTop;
|
||||||
vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight));
|
const span = Math.max(1, fullSide - clientW);
|
||||||
if (now - t0 < 320) raf = requestAnimationFrame(tick);
|
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);
|
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
|
// 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
|
// 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
|
// 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
|
/* 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. */
|
board cannot be dragged past its content into empty space — it just stops at the edge. */
|
||||||
overscroll-behavior: none;
|
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
|
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
|
||||||
— a magnifying-glass zoom. */
|
— a magnifying-glass zoom. */
|
||||||
|
|||||||
+32
-13
@@ -24,7 +24,8 @@
|
|||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
import { patchLobbyGame } from '../lib/lobbycache';
|
import { patchLobbyGame } from '../lib/lobbycache';
|
||||||
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
||||||
import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram';
|
import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
|
||||||
|
import { haptic } from '../lib/haptics';
|
||||||
import {
|
import {
|
||||||
BLANK,
|
BLANK,
|
||||||
newPlacement,
|
newPlacement,
|
||||||
@@ -511,7 +512,7 @@
|
|||||||
if (drag && isCoarse() && !zoomed) {
|
if (drag && isCoarse() && !zoomed) {
|
||||||
focus = c;
|
focus = c;
|
||||||
zoomed = true;
|
zoomed = true;
|
||||||
telegramHaptic('light');
|
haptic('light');
|
||||||
}
|
}
|
||||||
}, 700)
|
}, 700)
|
||||||
: null;
|
: null;
|
||||||
@@ -623,7 +624,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
placement = place(placement, index, row, col);
|
placement = place(placement, index, row, col);
|
||||||
telegramHaptic('select');
|
haptic('select');
|
||||||
recompute();
|
recompute();
|
||||||
scheduleDraftSave();
|
scheduleDraftSave();
|
||||||
}
|
}
|
||||||
@@ -631,7 +632,7 @@
|
|||||||
if (!blankPrompt) return;
|
if (!blankPrompt) return;
|
||||||
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
||||||
blankPrompt = null;
|
blankPrompt = null;
|
||||||
telegramHaptic('select');
|
haptic('select');
|
||||||
recompute();
|
recompute();
|
||||||
scheduleDraftSave();
|
scheduleDraftSave();
|
||||||
}
|
}
|
||||||
@@ -687,7 +688,7 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
|
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
|
||||||
telegramHaptic('success');
|
haptic('success');
|
||||||
zoomed = false;
|
zoomed = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -793,7 +794,7 @@
|
|||||||
shuffling = true;
|
shuffling = true;
|
||||||
setTimeout(() => (shuffling = false), 600);
|
setTimeout(() => (shuffling = false), 600);
|
||||||
// A short "shake": a few quick light taps rather than one.
|
// A short "shake": a few quick light taps rather than one.
|
||||||
for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55);
|
for (let i = 0; i < 4; i++) setTimeout(() => haptic('light'), i * 55);
|
||||||
scheduleDraftSave();
|
scheduleDraftSave();
|
||||||
}
|
}
|
||||||
function openExchange() {
|
function openExchange() {
|
||||||
@@ -1074,6 +1075,7 @@
|
|||||||
column
|
column
|
||||||
scroll={false}
|
scroll={false}
|
||||||
tabbar={landscape ? undefined : bottomBar}
|
tabbar={landscape ? undefined : bottomBar}
|
||||||
|
selfInset={landscape}
|
||||||
>
|
>
|
||||||
{#if view}
|
{#if view}
|
||||||
{#if landscape}
|
{#if landscape}
|
||||||
@@ -1110,7 +1112,7 @@
|
|||||||
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
|
<div class="scoreboard" class:flat={landscape} data-coach="game-header" onclick={landscape ? undefined : toggleHistory}>
|
||||||
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||||
{#each view.game.seats as s (s.seat)}
|
{#each view.game.seats as s (s.seat)}
|
||||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||||
@@ -1249,7 +1251,7 @@
|
|||||||
{#snippet rackRow()}
|
{#snippet rackRow()}
|
||||||
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
||||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||||
<div class="rack-row" class:inert={gameOver}>
|
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
||||||
<div class="rack-wrap">
|
<div class="rack-wrap">
|
||||||
<Rack
|
<Rack
|
||||||
slots={rackSlots}
|
slots={rackSlots}
|
||||||
@@ -1270,7 +1272,7 @@
|
|||||||
|
|
||||||
{#snippet controlButtons()}
|
{#snippet controlButtons()}
|
||||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
||||||
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||||
</button>
|
</button>
|
||||||
<TapConfirm
|
<TapConfirm
|
||||||
triggerClass="tab"
|
triggerClass="tab"
|
||||||
@@ -1278,7 +1280,7 @@
|
|||||||
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
||||||
onconfirm={doHint}
|
onconfirm={doHint}
|
||||||
>
|
>
|
||||||
<span class="sq">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
||||||
<span class="lbl">{t('game.hint')}</span>
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
</TapConfirm>
|
</TapConfirm>
|
||||||
{#if placement.pending.length > 0}
|
{#if placement.pending.length > 0}
|
||||||
@@ -1287,7 +1289,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
|
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
|
||||||
<span class="sq">🔀</span>
|
<span class="sq" data-coach="game-shuffle">🔀</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -1544,6 +1546,14 @@
|
|||||||
.make:disabled {
|
.make:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
|
||||||
|
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
|
||||||
|
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
|
||||||
|
so it sits inside the freed slot with its right edge level with the rack's right edge — the
|
||||||
|
mirror of the first tile's left edge. */
|
||||||
|
.game-land .make {
|
||||||
|
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
|
||||||
|
}
|
||||||
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
||||||
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
||||||
.hhead {
|
.hhead {
|
||||||
@@ -1730,8 +1740,10 @@
|
|||||||
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
|
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
/* The left-column children carry their own horizontal --pad (matching portrait), so the
|
/* The left-column children carry their own horizontal --pad (matching portrait), so the
|
||||||
grid only pads its right edge; the board centres in the right pane. */
|
grid only pads its right edge; the board centres in the right pane. The bottom is flush so the
|
||||||
padding: 4px var(--pad) 6px 0;
|
controls bar and the board reach the screen edge — the controls bar owns the home-indicator
|
||||||
|
inset (see the .leftpane .tabbar rule), the board runs under the thin indicator. */
|
||||||
|
padding: 4px var(--pad) 0 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.leftpane {
|
.leftpane {
|
||||||
@@ -1739,6 +1751,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
/* The home-indicator inset on the controls side: the left panel's controls bar paints its own
|
||||||
|
chrome (--bg-elev) into it so the buttons clear the cut-out (the shell's detached content strip
|
||||||
|
is suppressed here via Screen selfInset); the board pane runs to the edge under the thin
|
||||||
|
indicator. */
|
||||||
|
.game-land .leftpane :global(.tabbar) {
|
||||||
|
padding-bottom: calc(8px + var(--tg-safe-bottom, 0px));
|
||||||
|
}
|
||||||
.rightpane {
|
.rightpane {
|
||||||
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
|
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
|
||||||
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
|
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
|
||||||
|
|||||||
+23
-3
@@ -112,12 +112,32 @@
|
|||||||
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
|
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
|
||||||
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
|
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
|
||||||
viewport-width measure that would overflow seven tiles in a column. */
|
viewport-width measure that would overflow seven tiles in a column. */
|
||||||
|
.rack.landscape {
|
||||||
|
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
|
||||||
|
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
|
||||||
|
resolves unreliably, so the rack — which has a definite width — is the query container. A
|
||||||
|
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
|
||||||
|
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
.rack.landscape .tile {
|
.rack.landscape .tile {
|
||||||
flex: 1 1 0;
|
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
|
||||||
width: auto;
|
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
|
||||||
min-width: 0;
|
matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
|
||||||
|
tiles leave. */
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: calc((100% - 6 * 5px) / 7);
|
||||||
max-width: 46px;
|
max-width: 46px;
|
||||||
}
|
}
|
||||||
|
.rack.landscape .letter {
|
||||||
|
font-size: 6cqw;
|
||||||
|
}
|
||||||
|
.rack.landscape .val {
|
||||||
|
font-size: 3.1cqw;
|
||||||
|
}
|
||||||
|
.rack.landscape .star {
|
||||||
|
font-size: 7.6cqw;
|
||||||
|
}
|
||||||
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
||||||
tile width plus the rack gap), so the drop position is visible. */
|
tile width plus the rack gap), so the drop position is visible. */
|
||||||
.rack.reordering .tile {
|
.rack.reordering .tile {
|
||||||
|
|||||||
@@ -71,5 +71,6 @@ export { TargetRequest } from './scrabblefb/target-request.js';
|
|||||||
export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js';
|
export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js';
|
||||||
export { TileRecord } from './scrabblefb/tile-record.js';
|
export { TileRecord } from './scrabblefb/tile-record.js';
|
||||||
export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
|
export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
|
||||||
|
export { VKLoginRequest } from './scrabblefb/vklogin-request.js';
|
||||||
export { WordCheckResult } from './scrabblefb/word-check-result.js';
|
export { WordCheckResult } from './scrabblefb/word-check-result.js';
|
||||||
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
|
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class VKLoginRequest {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):VKLoginRequest {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsVKLoginRequest(bb:flatbuffers.ByteBuffer, obj?:VKLoginRequest):VKLoginRequest {
|
||||||
|
return (obj || new VKLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsVKLoginRequest(bb:flatbuffers.ByteBuffer, obj?:VKLoginRequest):VKLoginRequest {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new VKLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
params():string|null
|
||||||
|
params(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
params(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
browserTz():string|null
|
||||||
|
browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
browserTz(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName():string|null
|
||||||
|
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
displayName(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startVKLoginRequest(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addParams(builder:flatbuffers.Builder, paramsOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, paramsOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, browserTzOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, displayNameOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endVKLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createVKLoginRequest(builder:flatbuffers.Builder, paramsOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
VKLoginRequest.startVKLoginRequest(builder);
|
||||||
|
VKLoginRequest.addParams(builder, paramsOffset);
|
||||||
|
VKLoginRequest.addBrowserTz(builder, browserTzOffset);
|
||||||
|
VKLoginRequest.addDisplayName(builder, displayNameOffset);
|
||||||
|
return VKLoginRequest.endVKLoginRequest(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
-12
@@ -23,7 +23,6 @@ import {
|
|||||||
telegramSafeAreaInset,
|
telegramSafeAreaInset,
|
||||||
telegramDisableVerticalSwipes,
|
telegramDisableVerticalSwipes,
|
||||||
telegramShowSettingsButton,
|
telegramShowSettingsButton,
|
||||||
telegramHaptic,
|
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
type TelegramLaunch,
|
type TelegramLaunch,
|
||||||
telegramOnEvent,
|
telegramOnEvent,
|
||||||
@@ -32,9 +31,22 @@ import {
|
|||||||
telegramCloudGet,
|
telegramCloudGet,
|
||||||
telegramCloudSet,
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
|
import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk';
|
||||||
|
import { haptic } from './haptics';
|
||||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
import {
|
||||||
|
clearOnboarding,
|
||||||
|
clearSession,
|
||||||
|
loadOnboarding,
|
||||||
|
loadPrefs,
|
||||||
|
loadSession,
|
||||||
|
type OnboardingState,
|
||||||
|
saveOnboarding,
|
||||||
|
saveSession,
|
||||||
|
savePrefs,
|
||||||
|
} from './session';
|
||||||
|
import type { CoachSeries } from './coachmark';
|
||||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||||
import { isConnectionCode } from './retry';
|
import { isConnectionCode } from './retry';
|
||||||
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
||||||
@@ -85,6 +97,12 @@ export const app = $state<{
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
boardLabels: BoardLabelMode;
|
boardLabels: BoardLabelMode;
|
||||||
|
/** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
|
||||||
|
onboarding: OnboardingState;
|
||||||
|
/** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
|
||||||
|
* banner (components/PromoBanner) hides so it does not run above the dimmed onboarding layer,
|
||||||
|
* and reappears (per its own show logic) once onboarding closes. */
|
||||||
|
coachActive: boolean;
|
||||||
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
||||||
notifications: number;
|
notifications: number;
|
||||||
/** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
|
/** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
|
||||||
@@ -127,6 +145,8 @@ export const app = $state<{
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
boardLabels: 'beginner',
|
boardLabels: 'beginner',
|
||||||
|
onboarding: { lobbyDone: false, gameDone: false },
|
||||||
|
coachActive: false,
|
||||||
notifications: 0,
|
notifications: 0,
|
||||||
chatUnread: {},
|
chatUnread: {},
|
||||||
messageUnread: {},
|
messageUnread: {},
|
||||||
@@ -261,7 +281,7 @@ export function handleError(err: unknown): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isConnectionCode(code) || !connection.online) return;
|
if (isConnectionCode(code) || !connection.online) return;
|
||||||
telegramHaptic('error');
|
haptic('error');
|
||||||
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +628,7 @@ export async function bootstrap(): Promise<void> {
|
|||||||
app.theme = prefs.theme ?? 'auto';
|
app.theme = prefs.theme ?? 'auto';
|
||||||
app.reduceMotion = prefs.reduceMotion ?? false;
|
app.reduceMotion = prefs.reduceMotion ?? false;
|
||||||
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
||||||
|
app.onboarding = await loadOnboarding();
|
||||||
applyTheme(app.theme);
|
applyTheme(app.theme);
|
||||||
applyReduceMotion(app.reduceMotion);
|
applyReduceMotion(app.reduceMotion);
|
||||||
if (prefs.locale) {
|
if (prefs.locale) {
|
||||||
@@ -664,6 +685,34 @@ export async function bootstrap(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VK Mini App launch: signal readiness to the VK client (which dismisses its loading cover), then
|
||||||
|
// authenticate from the signed launch parameters in the URL — the display name comes from
|
||||||
|
// VKWebAppGetUserInfo, since VK omits it from the signed params. The /vk/ entry opened outside VK
|
||||||
|
// (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow.
|
||||||
|
if (onVKPath() && insideVK()) {
|
||||||
|
// Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
|
||||||
|
// VK mobile webview's prefers-color-scheme does not track it).
|
||||||
|
void vkOnScheme((scheme) => {
|
||||||
|
if (app.theme === 'auto') applyTheme(scheme);
|
||||||
|
});
|
||||||
|
// Clear the device safe area from VK's insets — the VK mobile webview does not surface them via
|
||||||
|
// CSS env() (notably the Android home bar). max() keeps whichever of env() / the VK value is real.
|
||||||
|
void vkOnInsets((i) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--tg-safe-top', `max(env(safe-area-inset-top, 0px), ${i.top}px)`);
|
||||||
|
root.style.setProperty('--tg-safe-bottom', `max(env(safe-area-inset-bottom, 0px), ${i.bottom}px)`);
|
||||||
|
root.style.setProperty('--tg-safe-left', `max(env(safe-area-inset-left, 0px), ${i.left}px)`);
|
||||||
|
root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
|
||||||
|
});
|
||||||
|
await vkInit();
|
||||||
|
// The app owns navigation (its own back chevron), so silence VK's horizontal swipe-back to keep
|
||||||
|
// it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes.
|
||||||
|
void vkDisableSwipeBack();
|
||||||
|
await bootVK();
|
||||||
|
app.ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const saved = await loadSession();
|
const saved = await loadSession();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
await adoptSession(saved);
|
await adoptSession(saved);
|
||||||
@@ -674,10 +723,10 @@ export async function bootstrap(): Promise<void> {
|
|||||||
app.ready = true;
|
app.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall
|
// Inside a Mini App the only identity is the platform session (Telegram or VK), so a failed launch
|
||||||
// back to the web login screen. A transient backend outage (a deploy rolling over) is retried a
|
// must never fall back to the web login screen. A transient backend outage (a deploy rolling over)
|
||||||
// few times in silence; only then does the boot-error screen surface, from which Retry re-runs the
|
// is retried a few times in silence; only then does the boot-error screen surface, from which Retry
|
||||||
// same path (retryTelegramBoot).
|
// re-runs the same path (retryMiniAppBoot).
|
||||||
const TELEGRAM_BOOT_RETRIES = 2;
|
const TELEGRAM_BOOT_RETRIES = 2;
|
||||||
const TELEGRAM_BOOT_RETRY_MS = 1200;
|
const TELEGRAM_BOOT_RETRY_MS = 1200;
|
||||||
|
|
||||||
@@ -714,14 +763,54 @@ async function bootTelegram(launch: TelegramLaunch): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It
|
* bootVK authenticates a VK Mini App launch from the signed launch parameters in the URL, seeding a
|
||||||
* clears the error and shows the loading state again, then runs the same retrying boot; on success
|
* brand-new account's display name from VKWebAppGetUserInfo. Like bootTelegram it retries a few
|
||||||
* the app renders normally, otherwise the boot-error screen returns.
|
* times on a transient failure before raising the boot-error screen, and a blocked account is
|
||||||
|
* terminal. This MVP carries no VK deep-link routing.
|
||||||
*/
|
*/
|
||||||
export async function retryTelegramBoot(): Promise<void> {
|
async function bootVK(): Promise<void> {
|
||||||
|
const params = vkLaunchParams();
|
||||||
|
const displayName = await vkUserName();
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await adoptSession(await gateway.authVK(params, displayName));
|
||||||
|
// VK forwards only the signed vk_* params to the iframe — a custom payload on the app link
|
||||||
|
// (whether after '#', eaten by the vk.com SPA, or as a '?' query, dropped) never reaches it,
|
||||||
|
// confirmed on the contour. So there is no friend-code auto-redeem on VK in test mode: the
|
||||||
|
// launch lands on the lobby. vkStartParam stays as the reader for a future (post-moderation)
|
||||||
|
// deep-link channel, and routes the lobby for an empty payload today.
|
||||||
|
if (!app.blocked) await routeStartParam(vkStartParam());
|
||||||
|
app.bootError = false;
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof GatewayError && err.code === 'account_blocked') {
|
||||||
|
await enterBlocked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt >= TELEGRAM_BOOT_RETRIES) {
|
||||||
|
app.bootError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await delay(TELEGRAM_BOOT_RETRY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retryMiniAppBoot re-attempts a Mini App launch from the boot-error screen's Retry button — the VK
|
||||||
|
* boot on the /vk/ entry, the Telegram boot otherwise. It clears the error and shows the loading
|
||||||
|
* state again, then runs the same retrying boot; on success the app renders normally, otherwise the
|
||||||
|
* boot-error screen returns.
|
||||||
|
*/
|
||||||
|
export async function retryMiniAppBoot(): Promise<void> {
|
||||||
app.bootError = false;
|
app.bootError = false;
|
||||||
app.ready = false;
|
app.ready = false;
|
||||||
await bootTelegram(telegramLaunch());
|
if (onVKPath()) {
|
||||||
|
await vkInit();
|
||||||
|
await bootVK();
|
||||||
|
} else {
|
||||||
|
await bootTelegram(telegramLaunch());
|
||||||
|
}
|
||||||
app.ready = true;
|
app.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,6 +927,26 @@ export async function logout(): Promise<void> {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markOnboardingDone records that a first-run coachmark series has been completed (its last hint
|
||||||
|
* was shown) and persists the flag, so the series never replays for this client. A series
|
||||||
|
* interrupted before its last hint is left unmarked and replays from the start next launch.
|
||||||
|
*/
|
||||||
|
export function markOnboardingDone(series: CoachSeries): void {
|
||||||
|
if (series === 'lobby') app.onboarding.lobbyDone = true;
|
||||||
|
else app.onboarding.gameDone = true;
|
||||||
|
void saveOnboarding({ ...app.onboarding });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resetOnboarding clears both coachmark completion flags (the hidden DebugPanel control), so the
|
||||||
|
* onboarding replays on the next launch — a manual re-test aid.
|
||||||
|
*/
|
||||||
|
export function resetOnboarding(): void {
|
||||||
|
app.onboarding = { lobbyDone: false, gameDone: false };
|
||||||
|
void clearOnboarding();
|
||||||
|
}
|
||||||
|
|
||||||
function persistPrefs(): void {
|
function persistPrefs(): void {
|
||||||
void savePrefs({
|
void savePrefs({
|
||||||
theme: app.theme,
|
theme: app.theme,
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export type Unsubscribe = () => void;
|
|||||||
export interface GatewayClient {
|
export interface GatewayClient {
|
||||||
// --- auth (unauthenticated) ---
|
// --- auth (unauthenticated) ---
|
||||||
authTelegram(initData: string): Promise<Session>;
|
authTelegram(initData: string): Promise<Session>;
|
||||||
|
/** Authenticate a VK Mini App launch: params is the signed vk_* launch query string (the gateway
|
||||||
|
* verifies its sign); displayName is the client-read VKWebAppGetUserInfo name (an unsigned,
|
||||||
|
* cosmetic seed for a brand-new account). */
|
||||||
|
authVK(params: string, displayName: string): Promise<Session>;
|
||||||
authGuest(locale?: string): Promise<Session>;
|
authGuest(locale?: string): Promise<Session>;
|
||||||
authEmailRequest(email: string): Promise<void>;
|
authEmailRequest(email: string): Promise<void>;
|
||||||
authEmailLogin(email: string, code: string): Promise<Session>;
|
authEmailLogin(email: string, code: string): Promise<Session>;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
GAME_STEPS,
|
||||||
|
LOBBY_STEPS,
|
||||||
|
nextVisibleStep,
|
||||||
|
placeBubble,
|
||||||
|
stepsFor,
|
||||||
|
type Rect,
|
||||||
|
type Viewport,
|
||||||
|
} from './coachmark';
|
||||||
|
|
||||||
|
describe('stepsFor', () => {
|
||||||
|
it('returns the owner-defined order for each series', () => {
|
||||||
|
expect(stepsFor('lobby')).toBe(LOBBY_STEPS);
|
||||||
|
expect(stepsFor('game')).toBe(GAME_STEPS);
|
||||||
|
expect(LOBBY_STEPS.map((s) => s.target)).toEqual(['lobby-settings', 'lobby-stats', 'lobby-new']);
|
||||||
|
expect(GAME_STEPS.map((s) => s.target)).toEqual(['game-header', 'game-turn', 'game-hints', 'game-shuffle', 'game-rack']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextVisibleStep', () => {
|
||||||
|
const present = () => true;
|
||||||
|
it('returns the start index when its target is present', () => {
|
||||||
|
expect(nextVisibleStep(LOBBY_STEPS, 0, present)).toBe(0);
|
||||||
|
expect(nextVisibleStep(LOBBY_STEPS, 1, present)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips steps whose target is absent', () => {
|
||||||
|
const isPresent = (t: string) => t !== 'game-hints' && t !== 'game-shuffle';
|
||||||
|
// From the hints step (index 2), both hints and shuffle are absent, so it lands on the rack.
|
||||||
|
expect(nextVisibleStep(GAME_STEPS, 2, isPresent)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when no further step is visible', () => {
|
||||||
|
expect(nextVisibleStep(GAME_STEPS, 5, present)).toBe(-1);
|
||||||
|
expect(nextVisibleStep(GAME_STEPS, 0, () => false)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placeBubble', () => {
|
||||||
|
const portrait: Viewport = { width: 390, height: 844 };
|
||||||
|
const bubble = { width: 280, height: 80 };
|
||||||
|
|
||||||
|
it('places the bubble below a target at the top (scoreboard strip)', () => {
|
||||||
|
const target: Rect = { left: 0, top: 50, width: 390, height: 40 };
|
||||||
|
const p = placeBubble(target, portrait, bubble);
|
||||||
|
expect(p.side).toBe('bottom');
|
||||||
|
expect(p.top).toBe(100); // 50 + 40 + gap(10)
|
||||||
|
expect(p.left).toBe(55); // centred under the target, room to spare
|
||||||
|
expect(p.tail).toBeGreaterThan(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the bubble above a bottom tab-bar target and clamps it on screen', () => {
|
||||||
|
const target: Rect = { left: 260, top: 790, width: 120, height: 54 };
|
||||||
|
const p = placeBubble(target, portrait, bubble);
|
||||||
|
expect(p.side).toBe('top');
|
||||||
|
expect(p.top).toBe(700); // 790 - gap(10) - height(80)
|
||||||
|
// Centring would overflow the right edge, so the bubble is clamped...
|
||||||
|
expect(p.left).toBe(portrait.width - bubble.width - 8);
|
||||||
|
// ...yet the tail still aims at the target centre.
|
||||||
|
const tailViewportX = p.left + p.tail;
|
||||||
|
expect(Math.abs(tailViewportX - (target.left + target.width / 2))).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the bubble to the side of a tall left-panel target (landscape)', () => {
|
||||||
|
const landscape: Viewport = { width: 1100, height: 640 };
|
||||||
|
const wide = { width: 300, height: 120 };
|
||||||
|
const target: Rect = { left: 30, top: 20, width: 60, height: 600 };
|
||||||
|
const p = placeBubble(target, landscape, wide);
|
||||||
|
expect(p.side).toBe('right');
|
||||||
|
expect(p.left).toBe(100); // 30 + 60 + gap(10)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the bubble and the tail at a corner target', () => {
|
||||||
|
const target: Rect = { left: 0, top: 0, width: 30, height: 30 };
|
||||||
|
const p = placeBubble(target, portrait, { width: 200, height: 60 });
|
||||||
|
expect(p.side).toBe('bottom');
|
||||||
|
expect(p.left).toBe(8); // left margin
|
||||||
|
expect(p.tail).toBe(8); // tail clamped to its minimum off the corner
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a side and keeps the bubble within the viewport when nothing fits', () => {
|
||||||
|
const target: Rect = { left: 0, top: 0, width: 390, height: 844 };
|
||||||
|
const p = placeBubble(target, portrait, { width: 200, height: 80 });
|
||||||
|
expect(p.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(p.left).toBeLessThanOrEqual(portrait.width - 200 - 8);
|
||||||
|
expect(p.top).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(p.top).toBeLessThanOrEqual(portrait.height - 80 - 8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Pure logic for the first-run onboarding coachmarks (components/Coachmark.svelte). Kept free of
|
||||||
|
// Svelte and the DOM so it is unit-testable in the node test env: the component supplies the live
|
||||||
|
// target rectangles and renders the bubble from the geometry computed here.
|
||||||
|
|
||||||
|
import type { MessageKey } from './i18n/en';
|
||||||
|
|
||||||
|
/** The two independent onboarding series: the lobby (just registered) and the first game board. */
|
||||||
|
export type CoachSeries = 'lobby' | 'game';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single coachmark step: the `data-coach` attribute of the element the bubble points at and the
|
||||||
|
* i18n key of the hint text. Steps whose target is absent at show time are skipped.
|
||||||
|
*/
|
||||||
|
export interface CoachStep {
|
||||||
|
target: string;
|
||||||
|
key: MessageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lobby series, in the owner-defined order: settings → stats → new game. */
|
||||||
|
export const LOBBY_STEPS: readonly CoachStep[] = [
|
||||||
|
{ target: 'lobby-settings', key: 'onboarding.lobbySettings' },
|
||||||
|
{ target: 'lobby-stats', key: 'onboarding.lobbyStats' },
|
||||||
|
{ target: 'lobby-new', key: 'onboarding.lobbyNew' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The game series, in the owner-defined order: header → pass/exchange → hints → shuffle → rack. */
|
||||||
|
export const GAME_STEPS: readonly CoachStep[] = [
|
||||||
|
{ target: 'game-header', key: 'onboarding.gameHeader' },
|
||||||
|
{ target: 'game-turn', key: 'onboarding.gameTurn' },
|
||||||
|
{ target: 'game-hints', key: 'onboarding.gameHints' },
|
||||||
|
{ target: 'game-shuffle', key: 'onboarding.gameShuffle' },
|
||||||
|
{ target: 'game-rack', key: 'onboarding.gameRack' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Returns the ordered step list for the given series. */
|
||||||
|
export function stepsFor(series: CoachSeries): readonly CoachStep[] {
|
||||||
|
return series === 'lobby' ? LOBBY_STEPS : GAME_STEPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the index of the first step at or after `from` whose target is present, or -1 when no
|
||||||
|
* further step is visible. The component advances through the steps with this, skipping any whose
|
||||||
|
* element is not on screen (e.g. a control hidden in the current game state).
|
||||||
|
*/
|
||||||
|
export function nextVisibleStep(steps: readonly CoachStep[], from: number, isPresent: (target: string) => boolean): number {
|
||||||
|
for (let i = Math.max(0, from); i < steps.length; i++) {
|
||||||
|
if (isPresent(steps[i].target)) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal rectangle (a subset of DOMRect) — the target's position in the viewport. */
|
||||||
|
export interface Rect {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The viewport size the bubble must stay inside. */
|
||||||
|
export interface Viewport {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The measured bubble box, used to keep it on screen and to aim the tail. */
|
||||||
|
export interface BubbleSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which side of the target the bubble sits on; the tail is drawn on the bubble edge facing it. */
|
||||||
|
export type BubbleSide = 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The computed bubble placement. `left`/`top` are the bubble's viewport position; `tail` is the
|
||||||
|
* offset of the tail along the facing edge — an X from the bubble's left for top/bottom sides, a Y
|
||||||
|
* from the bubble's top for left/right sides — so the tail keeps pointing at the target centre even
|
||||||
|
* when the bubble is clamped to the viewport edge.
|
||||||
|
*/
|
||||||
|
export interface Placement {
|
||||||
|
side: BubbleSide;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
tail: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tuning knobs for {@link placeBubble}; all in CSS pixels. */
|
||||||
|
export interface PlaceOpts {
|
||||||
|
/** Gap between the target and the bubble. */
|
||||||
|
gap?: number;
|
||||||
|
/** Minimum distance the bubble keeps from every viewport edge. */
|
||||||
|
margin?: number;
|
||||||
|
/** Half-width of the tail triangle (it cannot sit closer than this to a bubble corner). */
|
||||||
|
tail?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v: number, lo: number, hi: number): number {
|
||||||
|
// When the bubble is larger than the slot (lo > hi) keep the low edge so it stays anchored.
|
||||||
|
return hi < lo ? lo : Math.max(lo, Math.min(hi, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes where to draw the bubble for a target. It prefers the vertical axis (below a target,
|
||||||
|
* else above), then the horizontal axis (right, else left) — picking the first side that fits the
|
||||||
|
* bubble with the gap and edge margin, and otherwise the side with the most room. The bubble is
|
||||||
|
* clamped inside the viewport and the tail is aimed at the target centre (clamped to the bubble
|
||||||
|
* edge), so a target near a corner still gets a correctly pointing tail.
|
||||||
|
*/
|
||||||
|
export function placeBubble(target: Rect, vp: Viewport, bubble: BubbleSize, opts?: PlaceOpts): Placement {
|
||||||
|
const gap = opts?.gap ?? 10;
|
||||||
|
const margin = opts?.margin ?? 8;
|
||||||
|
const tail = opts?.tail ?? 8;
|
||||||
|
|
||||||
|
const tcx = target.left + target.width / 2;
|
||||||
|
const tcy = target.top + target.height / 2;
|
||||||
|
|
||||||
|
const space: Record<BubbleSide, number> = {
|
||||||
|
top: target.top,
|
||||||
|
bottom: vp.height - (target.top + target.height),
|
||||||
|
left: target.left,
|
||||||
|
right: vp.width - (target.left + target.width),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fits = (side: BubbleSide): boolean => {
|
||||||
|
if (side === 'top' || side === 'bottom') return space[side] >= bubble.height + gap + margin;
|
||||||
|
return space[side] >= bubble.width + gap + margin;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bubble below a top target, above a bottom one, then to the side — the layout our targets use.
|
||||||
|
const order: BubbleSide[] = ['bottom', 'top', 'right', 'left'];
|
||||||
|
let side = order.find(fits);
|
||||||
|
if (!side) {
|
||||||
|
side = (Object.keys(space) as BubbleSide[]).reduce((a, b) => (space[a] >= space[b] ? a : b));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (side === 'top' || side === 'bottom') {
|
||||||
|
const left = clamp(tcx - bubble.width / 2, margin, vp.width - bubble.width - margin);
|
||||||
|
const rawTop = side === 'top' ? target.top - gap - bubble.height : target.top + target.height + gap;
|
||||||
|
const top = clamp(rawTop, margin, vp.height - bubble.height - margin);
|
||||||
|
const tailX = clamp(tcx - left, tail, bubble.width - tail);
|
||||||
|
return { side, left, top, tail: tailX };
|
||||||
|
}
|
||||||
|
const top = clamp(tcy - bubble.height / 2, margin, vp.height - bubble.height - margin);
|
||||||
|
const rawLeft = side === 'left' ? target.left - gap - bubble.width : target.left + target.width + gap;
|
||||||
|
const left = clamp(rawLeft, margin, vp.width - bubble.width - margin);
|
||||||
|
const tailY = clamp(tcy - top, tail, bubble.height - tail);
|
||||||
|
return { side, left, top, tail: tailY };
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
encodeTarget,
|
encodeTarget,
|
||||||
encodeTelegramLogin,
|
encodeTelegramLogin,
|
||||||
encodeUpdateProfile,
|
encodeUpdateProfile,
|
||||||
|
encodeVKLogin,
|
||||||
} from './codec';
|
} from './codec';
|
||||||
|
|
||||||
describe('codec', () => {
|
describe('codec', () => {
|
||||||
@@ -94,6 +95,13 @@ describe('codec', () => {
|
|||||||
);
|
);
|
||||||
expect(email.email()).toBe('a@example.com');
|
expect(email.email()).toBe('a@example.com');
|
||||||
expect(email.browserTz()).toBe('+00:00');
|
expect(email.browserTz()).toBe('+00:00');
|
||||||
|
|
||||||
|
const vk = fb.VKLoginRequest.getRootAsVKLoginRequest(
|
||||||
|
new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')),
|
||||||
|
);
|
||||||
|
expect(vk.params()).toBe('vk_user_id=494075&vk_ts=1&sign=abc');
|
||||||
|
expect(vk.browserTz()).toBe('+03:00');
|
||||||
|
expect(vk.displayName()).toBe('Иван Петров');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips a feedback submit and decodes state + unread', () => {
|
it('round-trips a feedback submit and decodes state + unread', () => {
|
||||||
|
|||||||
@@ -189,6 +189,18 @@ export function encodeTelegramLogin(initData: string, browserTz: string): Uint8A
|
|||||||
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encodeVKLogin(params: string, browserTz: string, displayName: string): Uint8Array {
|
||||||
|
const b = new Builder(512);
|
||||||
|
const p = b.createString(params);
|
||||||
|
const tz = b.createString(browserTz);
|
||||||
|
const dn = b.createString(displayName);
|
||||||
|
fb.VKLoginRequest.startVKLoginRequest(b);
|
||||||
|
fb.VKLoginRequest.addParams(b, p);
|
||||||
|
fb.VKLoginRequest.addBrowserTz(b, tz);
|
||||||
|
fb.VKLoginRequest.addDisplayName(b, dn);
|
||||||
|
return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
|
||||||
|
}
|
||||||
|
|
||||||
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
|
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const l = b.createString(locale);
|
const l = b.createString(locale);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink';
|
import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink, vkShareLink } from './deeplink';
|
||||||
|
|
||||||
describe('parseStartParam', () => {
|
describe('parseStartParam', () => {
|
||||||
it('classifies game / invitation / friend code', () => {
|
it('classifies game / invitation / friend code', () => {
|
||||||
@@ -37,6 +37,20 @@ describe('shareLink', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('vkShareLink', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('returns null outside a VK launch (no vk_app_id)', () => {
|
||||||
|
vi.stubGlobal('location', { search: '' });
|
||||||
|
expect(vkShareLink()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the plain vk.com/app link (VK drops a custom query payload, so the code is not carried)', () => {
|
||||||
|
vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' });
|
||||||
|
expect(vkShareLink()).toBe('https://vk.com/app6736218');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('botUsername', () => {
|
describe('botUsername', () => {
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
// f<6-digit code> redeem that friend code
|
// f<6-digit code> redeem that friend code
|
||||||
// An empty or unrecognised parameter opens the lobby.
|
// An empty or unrecognised parameter opens the lobby.
|
||||||
|
|
||||||
|
import { vkAppId } from './vk';
|
||||||
|
|
||||||
export type DeepLink =
|
export type DeepLink =
|
||||||
| { kind: 'lobby' }
|
| { kind: 'lobby' }
|
||||||
| { kind: 'game'; id: string }
|
| { kind: 'game'; id: string }
|
||||||
@@ -74,3 +76,16 @@ export function shareLink(param: string): string | null {
|
|||||||
const sep = base.includes('?') ? '&' : '?';
|
const sep = base.includes('?') ? '&' : '?';
|
||||||
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
|
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkShareLink returns the VK Mini App link (https://vk.com/app<id>) to share on VK, or null when the
|
||||||
|
* VK app id is unknown (outside a VK launch). VK forwards no custom payload through the app link to
|
||||||
|
* the iframe — the '#' form is eaten by the vk.com SPA and a '?' query is dropped (confirmed on the
|
||||||
|
* contour: only the signed vk_* params arrive) — so the link cannot carry the friend code; the
|
||||||
|
* recipient enters the copied code by hand.
|
||||||
|
*/
|
||||||
|
export function vkShareLink(): string | null {
|
||||||
|
const id = vkAppId();
|
||||||
|
if (!id) return null;
|
||||||
|
return `https://vk.com/app${id}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Cross-platform haptic feedback. The app fires one Haptic vocabulary from its interaction code
|
||||||
|
// (tile placement, submit, errors); this routes it to whichever host Mini App is in play —
|
||||||
|
// Telegram's HapticFeedback or VK Bridge's taptic events — and stays silent in a plain browser.
|
||||||
|
|
||||||
|
import { type Haptic, telegramHaptic } from './telegram';
|
||||||
|
import { insideVK, vkHaptic } from './vk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* haptic fires the host Mini App's haptic feedback for kind: Telegram's HapticFeedback inside
|
||||||
|
* Telegram and VK Bridge's taptic inside VK; a no-op in an ordinary browser. Both backends
|
||||||
|
* self-guard off their platform, so this is always safe to call. The VK send is fire-and-forget.
|
||||||
|
*/
|
||||||
|
export function haptic(kind: Haptic): void {
|
||||||
|
telegramHaptic(kind);
|
||||||
|
if (insideVK()) void vkHaptic(kind);
|
||||||
|
}
|
||||||
@@ -65,6 +65,17 @@ export const en = {
|
|||||||
'new.searchHint':
|
'new.searchHint':
|
||||||
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
|
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
|
||||||
|
|
||||||
|
// First-run coachmark hints (components/Coachmark.svelte). The leading emoji in each comment
|
||||||
|
// names the UI element the bubble's tail points at.
|
||||||
|
'onboarding.lobbySettings': 'Friends, game & profile settings, feedback', // ⚙️
|
||||||
|
'onboarding.lobbyStats': 'Stats refresh after each game with an opponent', // ✏️
|
||||||
|
'onboarding.lobbyNew': 'Practise against the AI', // 🎲
|
||||||
|
'onboarding.gameHeader': 'Move history, chat and word check', // scoreboard strip
|
||||||
|
'onboarding.gameTurn': 'Pass a turn, swap tiles', // 🔄
|
||||||
|
'onboarding.gameHints': 'Available hints', // 🛟
|
||||||
|
'onboarding.gameShuffle': 'Shuffle your tiles', // 🔀
|
||||||
|
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
|
||||||
|
|
||||||
'game.bag': '{n} in the bag',
|
'game.bag': '{n} in the bag',
|
||||||
'game.bagEmpty': 'Bag is empty',
|
'game.bagEmpty': 'Bag is empty',
|
||||||
'game.hints': 'Hints {n}',
|
'game.hints': 'Hints {n}',
|
||||||
|
|||||||
+11
-1
@@ -66,6 +66,16 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.searchHint':
|
'new.searchHint':
|
||||||
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
||||||
|
|
||||||
|
// Подсказки первого запуска (components/Coachmark.svelte).
|
||||||
|
'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️
|
||||||
|
'onboarding.lobbyStats': 'Статистика обновляется после каждой игры с соперником', // ✏️
|
||||||
|
'onboarding.lobbyNew': 'Потренируйтесь с ИИ', // 🎲
|
||||||
|
'onboarding.gameHeader': 'История ходов, чат и проверка слова', // шапка над доской
|
||||||
|
'onboarding.gameTurn': 'Пропуск хода, обмен фишек', // 🔄
|
||||||
|
'onboarding.gameHints': 'Доступные подсказки', // 🛟
|
||||||
|
'onboarding.gameShuffle': 'Встряхните фишки', // 🔀
|
||||||
|
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
|
||||||
|
|
||||||
'game.bag': '{n} в мешке',
|
'game.bag': '{n} в мешке',
|
||||||
'game.bagEmpty': 'Мешок пуст',
|
'game.bagEmpty': 'Мешок пуст',
|
||||||
'game.hints': 'Подсказки {n}',
|
'game.hints': 'Подсказки {n}',
|
||||||
@@ -301,7 +311,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.exportGcg': 'Экспорт GCG',
|
'game.exportGcg': 'Экспорт GCG',
|
||||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||||
'game.addFriendShort': 'В друзья?',
|
'game.addFriendShort': 'В друзья?',
|
||||||
'game.blockShort': 'Блокируем?',
|
'game.blockShort': 'В бан?',
|
||||||
|
|
||||||
'time.minutes': '{n} мин',
|
'time.minutes': '{n} мин',
|
||||||
'time.hours': '{n} ч',
|
'time.hours': '{n} ч',
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ export class MockGateway implements GatewayClient {
|
|||||||
if (initData.includes('bootfail')) throw new GatewayError('unavailable');
|
if (initData.includes('bootfail')) throw new GatewayError('unavailable');
|
||||||
return { ...SESSION, isGuest: false };
|
return { ...SESSION, isGuest: false };
|
||||||
}
|
}
|
||||||
|
async authVK(): Promise<Session> {
|
||||||
|
return { ...SESSION, isGuest: false };
|
||||||
|
}
|
||||||
async authGuest(): Promise<Session> {
|
async authGuest(): Promise<Session> {
|
||||||
return { ...SESSION };
|
return { ...SESSION };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,3 +133,30 @@ export async function loadPrefs(): Promise<Partial<Prefs>> {
|
|||||||
export function savePrefs(p: Prefs): Promise<void> {
|
export function savePrefs(p: Prefs): Promise<void> {
|
||||||
return kvSet(PREFS_KEY, p);
|
return kvSet(PREFS_KEY, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ONBOARDING_KEY = 'onboarding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether each first-run coachmark series has been completed. A series is marked done only after
|
||||||
|
* its last hint, so a player interrupted mid-series sees it again from the start next launch.
|
||||||
|
*/
|
||||||
|
export interface OnboardingState {
|
||||||
|
lobbyDone: boolean;
|
||||||
|
gameDone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads the persisted onboarding completion flags, defaulting to not-yet-seen. */
|
||||||
|
export async function loadOnboarding(): Promise<OnboardingState> {
|
||||||
|
const s = await kvGet<Partial<OnboardingState>>(ONBOARDING_KEY);
|
||||||
|
return { lobbyDone: s?.lobbyDone ?? false, gameDone: s?.gameDone ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists the onboarding completion flags. */
|
||||||
|
export function saveOnboarding(s: OnboardingState): Promise<void> {
|
||||||
|
return kvSet(ONBOARDING_KEY, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the onboarding flags so the coachmarks replay on the next launch (DebugPanel reset). */
|
||||||
|
export function clearOnboarding(): Promise<void> {
|
||||||
|
return kvDel(ONBOARDING_KEY);
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async authTelegram(initData) {
|
async authTelegram(initData) {
|
||||||
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
|
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
|
||||||
},
|
},
|
||||||
|
async authVK(params, displayName) {
|
||||||
|
return codec.decodeSession(await exec('auth.vk', codec.encodeVKLogin(params, browserOffset(), displayName)));
|
||||||
|
},
|
||||||
async authGuest(locale) {
|
async authGuest(locale) {
|
||||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam } from './vk';
|
||||||
|
|
||||||
|
describe('vk launch detection', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('is not inside VK and not on the VK path without a location (node / SSR)', () => {
|
||||||
|
expect(onVKPath()).toBe(false);
|
||||||
|
expect(insideVK()).toBe(false);
|
||||||
|
expect(vkLaunchParams()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects the dedicated /vk/ entry path', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '' });
|
||||||
|
expect(onVKPath()).toBe(true);
|
||||||
|
vi.stubGlobal('location', { pathname: '/app/', search: '' });
|
||||||
|
expect(onVKPath()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the signed launch query only when a sign is present', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&vk_ts=2&sign=abc' });
|
||||||
|
expect(vkLaunchParams()).toBe('vk_user_id=1&vk_ts=2&sign=abc');
|
||||||
|
expect(insideVK()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a URL carrying no sign as not a VK launch', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?utm_source=catalog' });
|
||||||
|
expect(vkLaunchParams()).toBe('');
|
||||||
|
expect(insideVK()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vk launch params', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('reads the VK app id from the launch query', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_app_id=6736218&sign=x' });
|
||||||
|
expect(vkAppId()).toBe('6736218');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the direct-link deep-link payload from the hash query param', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x&hash=f123456' });
|
||||||
|
expect(vkStartParam()).toBe('f123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty app id / start param when absent', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x' });
|
||||||
|
expect(vkAppId()).toBe('');
|
||||||
|
expect(vkStartParam()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// VK Mini App SDK access via @vkontakte/vk-bridge. The bridge is imported lazily inside the
|
||||||
|
// functions that need it — not at module top level — because the SDK reads browser globals on
|
||||||
|
// import: the lazy import keeps the pure URL helpers below importable in the node test environment,
|
||||||
|
// and code-splits the bridge into a chunk loaded only on the /vk/ entry. This wraps the subset the
|
||||||
|
// app uses: launch detection, the signed launch parameters (for auth.vk) and the user's display name
|
||||||
|
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
|
||||||
|
// call outside VK.
|
||||||
|
|
||||||
|
import type { Haptic } from './telegram';
|
||||||
|
|
||||||
|
async function bridge() {
|
||||||
|
return (await import('@vkontakte/vk-bridge')).default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onVKPath reports whether the app is served under the dedicated VK entry path (/vk/).
|
||||||
|
*/
|
||||||
|
export function onVKPath(): boolean {
|
||||||
|
if (typeof location === 'undefined') return false;
|
||||||
|
return location.pathname.startsWith('/vk/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkLaunchParams returns the raw signed VK launch query string (the vk_* parameters plus sign) from
|
||||||
|
* the page URL — the exact form the gateway verifies — or '' when the URL carries no signed launch
|
||||||
|
* (an ordinary browser tab, or the /vk/ path opened directly).
|
||||||
|
*/
|
||||||
|
export function vkLaunchParams(): string {
|
||||||
|
if (typeof location === 'undefined') return '';
|
||||||
|
const query = location.search.replace(/^\?/, '');
|
||||||
|
return new URLSearchParams(query).has('sign') ? query : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* insideVK reports whether the app launched as a VK Mini App — the URL carries signed launch
|
||||||
|
* parameters (an ordinary browser tab has none).
|
||||||
|
*/
|
||||||
|
export function insideVK(): boolean {
|
||||||
|
return vkLaunchParams() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkInit signals to the VK client that the Mini App has loaded (VKWebAppInit), dismissing VK's own
|
||||||
|
* loading cover. Best-effort: it resolves even if the bridge is unavailable (outside VK), so the
|
||||||
|
* caller can await it unconditionally.
|
||||||
|
*/
|
||||||
|
export async function vkInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await (await bridge()).send('VKWebAppInit', {});
|
||||||
|
} catch {
|
||||||
|
// Outside VK there is no client to receive it; the launch continues regardless.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it
|
||||||
|
* from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back
|
||||||
|
* to a generated placeholder. The value is a cosmetic seed only — being unsigned, the gateway never
|
||||||
|
* trusts it for identity.
|
||||||
|
*/
|
||||||
|
export async function vkUserName(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const u = await (await bridge()).send('VKWebAppGetUserInfo', {});
|
||||||
|
return [u.first_name, u.last_name].filter(Boolean).join(' ').trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkAppId returns the launching VK app id (vk_app_id) from the signed launch parameters in the URL —
|
||||||
|
* so the app can build its own vk.com/app<id> links without a VK API call — or '' outside a VK launch.
|
||||||
|
*/
|
||||||
|
export function vkAppId(): string {
|
||||||
|
if (typeof location === 'undefined') return '';
|
||||||
|
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_app_id') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkStartParam returns the VK direct-link deep-link payload. VK passes everything after the '#' in a
|
||||||
|
* vk.com/app<id>#<payload> link to the app as the `hash` query parameter (it is also in location.hash,
|
||||||
|
* but that collides with the app's hash router, so the query parameter is the safe source). Empty when
|
||||||
|
* the launch carried no deep link.
|
||||||
|
*/
|
||||||
|
export function vkStartParam(): string {
|
||||||
|
if (typeof location === 'undefined') return '';
|
||||||
|
return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for
|
||||||
|
* navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was
|
||||||
|
* handled, false on any failure or outside VK.
|
||||||
|
*/
|
||||||
|
export async function vkShare(link: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await (await bridge()).send('VKWebAppShare', { link });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
|
||||||
|
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
|
||||||
|
*/
|
||||||
|
export async function vkCopyText(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await (await bridge()).send('VKWebAppCopyText', { text });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkOnScheme subscribes to VK's appearance (VKWebAppUpdateConfig) and calls handler with the mapped
|
||||||
|
* 'light' | 'dark' scheme on launch and whenever the user switches the VK client theme — so the app's
|
||||||
|
* "auto" theme can follow the VK client instead of the (often wrong) webview prefers-color-scheme.
|
||||||
|
* A no-op outside VK.
|
||||||
|
*/
|
||||||
|
export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): Promise<void> {
|
||||||
|
try {
|
||||||
|
const b = await bridge();
|
||||||
|
b.subscribe((e) => {
|
||||||
|
const detail = (e as { detail?: { type?: string; data?: { scheme?: string; appearance?: string } } }).detail;
|
||||||
|
if (detail?.type !== 'VKWebAppUpdateConfig') return;
|
||||||
|
const scheme = detail.data?.scheme ?? detail.data?.appearance ?? '';
|
||||||
|
handler(/dark|space_gray/i.test(scheme) ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Outside VK / bridge unavailable: leave the app on its own theme.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VKInsets is the device safe-area the VK client reports (px). */
|
||||||
|
export interface VKInsets {
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkOnInsets subscribes to VK's safe-area insets and calls handler with them on launch and on change.
|
||||||
|
* VK reports them via VKWebAppUpdateConfig (iOS) and the dedicated VKWebAppUpdateInsets event; the VK
|
||||||
|
* mobile webview does not expose them through CSS env() the way iOS Safari does, so the app reads them
|
||||||
|
* from the bridge to clear the home bar (notably on Android). A no-op outside VK.
|
||||||
|
*/
|
||||||
|
export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise<void> {
|
||||||
|
try {
|
||||||
|
const b = await bridge();
|
||||||
|
b.subscribe((e) => {
|
||||||
|
const d = (e as { detail?: { type?: string; data?: { insets?: Partial<VKInsets> } } }).detail;
|
||||||
|
if (d?.type !== 'VKWebAppUpdateConfig' && d?.type !== 'VKWebAppUpdateInsets') return;
|
||||||
|
const i = d.data?.insets;
|
||||||
|
if (!i) return;
|
||||||
|
handler({ top: i.top ?? 0, bottom: i.bottom ?? 0, left: i.left ?? 0, right: i.right ?? 0 });
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Outside VK / bridge unavailable: the CSS env() safe-area fallback applies.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkHaptic fires a VK Bridge haptic mirroring the Telegram set: a selection tick
|
||||||
|
* (VKWebAppTapticSelectionChanged), a success/warning/error notification
|
||||||
|
* (VKWebAppTapticNotificationOccurred) or a light/medium/heavy impact (VKWebAppTapticImpactOccurred).
|
||||||
|
* Best-effort and a no-op outside VK or on a client without taptic support (the send rejects and is
|
||||||
|
* swallowed), so callers fire it unconditionally.
|
||||||
|
*/
|
||||||
|
export async function vkHaptic(kind: Haptic): Promise<void> {
|
||||||
|
try {
|
||||||
|
const b = await bridge();
|
||||||
|
if (kind === 'select') {
|
||||||
|
await b.send('VKWebAppTapticSelectionChanged', {});
|
||||||
|
} else if (kind === 'success' || kind === 'warning' || kind === 'error') {
|
||||||
|
await b.send('VKWebAppTapticNotificationOccurred', { type: kind });
|
||||||
|
} else {
|
||||||
|
await b.send('VKWebAppTapticImpactOccurred', { style: kind });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Outside VK / no taptic support: silent, like the Telegram haptic off-platform.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkDisableSwipeBack turns off VK's horizontal swipe-back gesture (VKWebAppSetSwipeSettings with
|
||||||
|
* history:false) so it does not fight the app's own edge-swipe-back and on-board tile drag — the
|
||||||
|
* app owns navigation through its back chevron, as on Telegram (telegramDisableVerticalSwipes).
|
||||||
|
* Best-effort; a no-op outside VK or on a client without the method.
|
||||||
|
*/
|
||||||
|
export async function vkDisableSwipeBack(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await (await bridge()).send('VKWebAppSetSwipeSettings', { history: false });
|
||||||
|
} catch {
|
||||||
|
// Outside VK / unsupported: VK's default gesture handling stays as-is.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The Mini App launch failed to authenticate after its silent retries (e.g. the backend was
|
// The Mini App launch failed to authenticate after its silent retries (e.g. the backend was
|
||||||
// briefly down during a deploy). Inside Telegram there is no web login to fall back to, so this
|
// briefly down during a deploy). Inside a Mini App (Telegram or VK) there is no web login to fall
|
||||||
// terminal screen offers a manual Retry that re-runs the launch (app.svelte retryTelegramBoot).
|
// back to, so this terminal screen offers a manual Retry that re-runs the launch (app.svelte
|
||||||
import { retryTelegramBoot } from '../lib/app.svelte';
|
// retryMiniAppBoot).
|
||||||
|
import { retryMiniAppBoot } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
let retrying = $state(false);
|
let retrying = $state(false);
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
if (retrying) return;
|
if (retrying) return;
|
||||||
retrying = true;
|
retrying = true;
|
||||||
try {
|
try {
|
||||||
await retryTelegramBoot();
|
await retryMiniAppBoot();
|
||||||
} finally {
|
} finally {
|
||||||
retrying = false;
|
retrying = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
import { friendCodeParam, shareLink, vkShareLink } from '../lib/deeplink';
|
||||||
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
|
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
|
||||||
|
import { insideVK, vkCopyText, vkShare } from '../lib/vk';
|
||||||
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
|
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
|
||||||
|
|
||||||
let friends = $state<AccountRef[]>([]);
|
let friends = $state<AccountRef[]>([]);
|
||||||
@@ -140,6 +141,11 @@
|
|||||||
|
|
||||||
async function copyCode() {
|
async function copyCode() {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
|
// Inside the VK iframe navigator.clipboard is blocked, so copy via VK Bridge there.
|
||||||
|
if (insideVK()) {
|
||||||
|
if (await vkCopyText(code.code)) showToast(t('friends.codeCopied'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code.code);
|
await navigator.clipboard.writeText(code.code);
|
||||||
showToast(t('friends.codeCopied'));
|
showToast(t('friends.codeCopied'));
|
||||||
@@ -153,6 +159,13 @@
|
|||||||
// copies the link to the clipboard.
|
// copies the link to the clipboard.
|
||||||
async function shareInvite(url: string) {
|
async function shareInvite(url: string) {
|
||||||
const text = t('friends.inviteText');
|
const text = t('friends.inviteText');
|
||||||
|
// Inside VK navigator.share is absent (desktop iframe) and the clipboard is blocked: share via VK
|
||||||
|
// Bridge, falling back to a VK-Bridge clipboard copy if the share is dismissed or unavailable.
|
||||||
|
if (insideVK()) {
|
||||||
|
if (await vkShare(url)) return;
|
||||||
|
if (await vkCopyText(url)) showToast(t('friends.linkCopied'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shareTelegramLink(url, text)) return;
|
if (shareTelegramLink(url, text)) return;
|
||||||
if (typeof navigator !== 'undefined' && navigator.share) {
|
if (typeof navigator !== 'undefined' && navigator.share) {
|
||||||
try {
|
try {
|
||||||
@@ -188,7 +201,7 @@
|
|||||||
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
|
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
|
||||||
</div>
|
</div>
|
||||||
{#if code}
|
{#if code}
|
||||||
{@const tg = shareLink(friendCodeParam(code.code))}
|
{@const shareUrl = insideVK() ? vkShareLink() : shareLink(friendCodeParam(code.code))}
|
||||||
<div class="code" data-testid="friend-code">
|
<div class="code" data-testid="friend-code">
|
||||||
<div class="coderow">
|
<div class="coderow">
|
||||||
<button class="codeval" onclick={copyCode}>{code.code}</button>
|
<button class="codeval" onclick={copyCode}>{code.code}</button>
|
||||||
@@ -197,8 +210,8 @@
|
|||||||
<span class="codehint">
|
<span class="codehint">
|
||||||
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
|
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
|
||||||
</span>
|
</span>
|
||||||
{#if tg}
|
{#if shareUrl}
|
||||||
<button type="button" class="link" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
|
<button type="button" class="link" onclick={() => shareInvite(shareUrl)}>{t('friends.shareTelegram')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -332,13 +332,13 @@
|
|||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||||||
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" onclick={() => navigate('/stats')}>
|
<button class="tab" onclick={() => navigate('/stats')}>
|
||||||
<span class="sq">✏️</span><span class="lbl">{t('lobby.stats')}</span>
|
<span class="sq" data-coach="lobby-stats">✏️</span><span class="lbl">{t('lobby.stats')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" onclick={() => navigate('/settings')}>
|
<button class="tab" onclick={() => navigate('/settings')}>
|
||||||
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
|
<span class="sq" data-coach="lobby-settings">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
|
||||||
<span class="lbl">{t('lobby.settings')}</span>
|
<span class="lbl">{t('lobby.settings')}</span>
|
||||||
</button>
|
</button>
|
||||||
</TabBar>
|
</TabBar>
|
||||||
|
|||||||
Reference in New Issue
Block a user