Files
scrabble-game/ui
Ilia Denisov 92ba527575
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
feat(payments): settle the direct rail through YooKassa
Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).

The two providers are not shaped alike, and that drives the change:

- Opening a purchase is now an outbound API call (`POST /v3/payments`,
  single-stage capture, redirect confirmation). The order id is both the
  `Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
  second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
  names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
  answer is acted on. Two guards ride on it — the payment's metadata must name
  the order, and its `test` flag must match the shop's, so a test-shop payment
  can never credit real chips. The sender address is checked against YooKassa's
  published ranges first, which stops a forger turning each fabricated
  notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
  silently. The existing pending-order reaper now asks the provider about each
  order that reached its expiry age carrying a payment id, and credits the ones
  really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
  surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
  anything; a failed call records nothing, so the ledger cannot claim a refund
  that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
  only if the request carries it, so every payment and refund now sends an
  itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
  variable; the settlement subject and method are constants.

Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.

No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.

Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
2026-07-28 08:51:31 +02:00
..
2026-07-20 12:53:23 +02:00
2026-07-14 09:06:03 +02:00
2026-06-03 00:55:38 +02:00

scrabble-ui

Pure-HTML5 game client — plain Svelte 5 (runes) + TypeScript + Vite, no SvelteKit. Talks to the gateway over Connect-RPC + FlatBuffers; embeddable in platform webviews and packageable to native via Capacitor.

The playable slice: sign in (guest / email), the "my games" lobby, auto-match, the board (place tiles by drag or tap, pass, exchange, resign), hint, word-check + complaint, per-game chat and nudge, the live in-app stream, i18n (en/ru), theme, and the profile. Social surfaces add friends/blocks (with one-time friend codes), friend-game invitations, profile editing + email binding, the statistics screen, the lobby notification badge, and the in-game history + GCG export (share or download, finished games only).

Scripts

pnpm install
pnpm start        # mock mode (VITE_MOCK): lobby -> game with no backend, :5173
pnpm dev          # against a running gateway (Vite proxies /scrabble.edge.v1.Gateway -> :8081)
pnpm check        # svelte-check / tsc
pnpm test:unit    # Vitest (pure logic + FlatBuffers codec)
pnpm test:e2e     # Playwright smoke against the mock
pnpm build        # static bundle into dist/ (prod app ~97 KB gzip JS; per-chunk budget: scripts/bundle-size.mjs)
pnpm codegen      # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)

GATEWAY_URL overrides the dev proxy target; VITE_GATEWAY_URL sets the runtime gateway origin for a packaged (non-proxied) build. VITE_TELEGRAM_BOT_ID enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site domain is registered with BotFather (/setdomain); VITE_TELEGRAM_LINK is the friend-invite Mini App link for the single bot (full URL https://telegram.me/<bot>/<app>). VITE_TELEGRAM_GAME_CHANNEL_NAME is the "Play in Telegram" link shown on the landing page. The native (Capacitor) build additionally reads VITE_DICT_VERSION (the bundled-dictionary version the offline path requests, matching the packaged DAWGs), VITE_PAYMENTS_DISABLED=1 (hide in-app purchases in the RuStore MVP), VITE_RUSTORE_URL (the update overlay's store target; empty until published) and VITE_APP_VERSION (git describe --tags → the X-Client-Version gate header) — all wired by .gitea/workflows/android-build.yaml.

The build has two entries: the game SPA (index.html, served at /app/ and /telegram/) and a lightweight landing page (landing.html, served at /).

How it talks to the gateway

A single Connect Execute(message_type, payload) carries every unary op; the request and response bodies are FlatBuffers tables (pkg/fbs/scrabble.fbs) in payload. The session token rides in Authorization: Bearer; a domain failure comes back in result_code. Subscribe is the live event stream; its game events carry a state delta that lib/gamedelta.ts applies to the per-game cache (lib/gamecache.ts), so a move renders without a follow-up game.state (a gap falls back to a refetch). lib/transport.ts is the real client; lib/mock/ is an in-memory fake selected by MODE === 'mock' (and tree-shaken out of production). Both speak the plain lib/model.ts types via lib/codec.ts.

No board on the wire: StateView is a summary + rack only, so the client reconstructs the 15×15 board by replaying the decoded move journal (game.history). The play loop is alphabet-agnostic: the rack and the play / exchange / word-check requests carry alphabet indices, and the client caches each variant's (index, letter, value) table — sent once behind StateRequest.include_alphabet — in lib/alphabet.ts, rendering the rack and blank chooser from it. Premium squares (lib/premiums.ts) stay a client-side geometry map ported from scrabble-solver/rules/rules.go (pinned by a Vitest parity test); tile values and the alphabet now come from the server table (their parity lives in the Go engine.AlphabetTable test). Board, tiles and effects are pure CSS + Unicode — no image/font/SVG assets.

Codegen

src/gen/ is committed; CI builds it, it is not regenerated there (the same model as the Go committed jet/fbs output). pnpm codegen runs flatc --ts on ../pkg/fbs/scrabble.fbs and buf generate (protoc-gen-es) on the edge proto. Needs flatc 23.5.26 and buf on PATH.

Theming

Design tokens are CSS custom properties (src/app.css); light/dark follows prefers-color-scheme or an explicit choice in Settings. The token system is Telegram-themeParams-ready (lib/theme.ts) — a Mini App can override the tokens at runtime; the Telegram SDK itself is wired in the Telegram stage.

Layout

src/
  lib/         model, client facade, transport (+ mock), codec, board replay,
               placement state machine, premiums (geometry), alphabet cache, stats, share,
               i18n, theme, session, router, app store
  components/  Header, Menu (+ badge), Modal, Toast, TabBar, Screen
  screens/     Login, Lobby, NewGame, Profile, Settings, About, Friends, Stats
  game/        Game, Board, Rack, Controls, MakeMove, Chat
  gen/         committed edge codegen (FlatBuffers + Connect)
e2e/           Playwright smoke + social specs (mock)