Compare commits

..

28 Commits

Author SHA1 Message Date
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer ba57687430 Merge pull request 'fix(grafana): real byte thresholds for the Database size stat' (#120) from feature/grafana-db-size-thresholds into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m20s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 19:41:42 +00:00
developer 6cb88b28c4 Merge pull request 'fix(edge): suppress dead HTTP/3 advert with Alt-Svc: clear' (#119) from feature/edge-suppress-http3-altsvc into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 55s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-06-22 19:41:16 +00:00
Ilia Denisov 46d569720c fix(grafana): give "Database size" stat real byte thresholds
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The "Database size" stat had no thresholds, so Grafana applied its stat
default (green base, red at >=80). The query is pg_database_size_bytes, so a
healthy ~9 MiB database (9.4M >> 80) rendered permanently RED on the
Scrabble - Resources dashboard (test + prod), reading as a false alert; the
neighbouring percentunit cache-hit stat stayed green only because its 0..1
values fall under 80.

Add absolute byte thresholds sized to the 40 GiB prod disk (4.6 GiB used,
observability bounded -- Tempo <=1 GiB, Prometheus 7d -- so the DB is the
only unbounded grower): green up to 8 GiB, yellow at 8 GiB (~20% of disk),
red at 16 GiB (~40%), an early warning with ample runway before the disk
tightens, not a panic line. Cosmetic panel coloring only; there are no
Grafana alert rules provisioned.
2026-06-22 21:35:48 +02:00
Ilia Denisov 9253b1bdca fix(edge): suppress dead HTTP/3 advert with Alt-Svc: clear
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Caddy enables HTTP/3 by default on any TLS listener and emits
Alt-Svc: h3=":443"; ma=2592000, but UDP/443 is never reachable: the prod
compose maps only "443:443" (TCP) and ufw opens 443/tcp (test contour: the
host caddy publishes only :443/tcp). A client that cached the 30-day advert
tries QUIC first on later opens, gets no response, and waits for the QUIC
attempt to time out before falling back to h2 -- which surfaced as the
Telegram Mini App intermittently hanging on load (a barely-noticeable pause
up to a blank window). The h2/TCP serving path itself is healthy (~10ms TTFB).

Emit Alt-Svc: clear site-wide at the contour caddy so clients actively drop
any cached alternative and stay on h2/h1. This caddy terminates TLS in prod
(the fix target); in the test contour it serves plain :80 and the host caddy
re-stamps its own Alt-Svc, so the live test fix lives in the host caddy. Add
docs/EDGE_HTTP3.md (symptom, diagnosis method, verify, and option B -- serving
h3 for real -- if it recurs) and link it from ARCHITECTURE.md.
2026-06-22 21:20:31 +02:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer 9d1ca213d6 Merge pull request 'fix(i18n): banner/push follow the interface language even without a Settings change' (#118) from feature/banner-language-followup into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 18:17:03 +00:00
developer 1f78bb274b Merge pull request 'feat(telegram): localized /start welcome with channel & chat follow links' (#117) from feature/bot-welcome-localized into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Has been skipped
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / gate (pull_request) Successful in 0s
CI / deploy (push) Successful in 1m21s
CI / deploy (pull_request) Has been skipped
2026-06-22 18:16:52 +00:00
Ilia Denisov 81b716569f fix(i18n): reconcile preferred_language to the interface locale on every adopt
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
A user who never changed the language in Settings kept their account at the
creation-time preferred_language seed (e.g. en from the Telegram launch language_code)
even after switching the device to another language: the UI followed the device (ru) but
the ad banner and out-of-app push — both resolved server-side from preferred_language —
stayed en. The on-adopt reconcile was gated on an explicit local choice (localeLocked),
so a system-guess locale was never pushed through.

Reconcile preferred_language to the active interface locale (app.locale) on every session
adopt and link, regardless of how the locale was chosen; persistLanguageToServer already
self-gates (a no-op for guests and when already equal), so there is no steady-state write.
The banner and push are the only server-rendered language surfaces and both read
preferred_language, so this keeps the whole interface consistent — not just the banner.
Drop the now-dead localeLocked flag (the reconcile guards were its only readers; the saved
prefs.locale still restores the UI choice per device).

Trade-off: preferred_language now follows the most-recently-opened device, so an explicit
choice on one device can be overwritten by a system guess on another (the "explicit" mark
is local, per-device); making it globally sticky would need a DB flag.

Docs: ARCHITECTURE §4 + the profile field.
2026-06-22 20:09:41 +02:00
Ilia Denisov aa330b726e feat(telegram): localized /start welcome with channel & chat follow links
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The main bot answered /start with a single English line ("Tap to open Scrabble.").
Localize it: Russian or English by the sender's reported Telegram language
(Message.from.language_code, which the Bot API carries on the message itself — there is
no separate user-update event — English fallback), with the longer welcome copy and a
localized launch button ("Открыть «Эрудит»" / "Open “Erudite”").

The welcome links the game channel and the discussion chat by their public @username,
resolved once at startup from the configured TELEGRAM_GAME_CHANNEL_ID / TELEGRAM_CHAT_ID
via getChat and cached. A handle that is unset, private, or unreadable degrades to a
generic noun ("the channel" / "our chat") rather than a dangling "@", so the paragraph
always reads cleanly (the bot's info screen still lists the real links). Adds
GameChannelID to bot.Config (wired from the existing config) for the channel handle.

Tests: startText localization + handle embedding + per-slot generic fallback; handleStart
language selection; resolveWelcomeHandles. README updated.
2026-06-22 19:39:00 +02:00
developer d5369a0188 Merge pull request 'feat(account): seed the time zone from the client's detected offset at creation' (#116) from feature/account-seed-timezone into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 17:07:53 +00:00
developer 170a6ae9ef Merge pull request 'feat(feedback): capture app version + browser zone; Filed time in three zones' (#115) from feature/feedback-version into development
CI / changes (push) Successful in 1s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 55s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (push) Successful in 0s
CI / gate (pull_request) Successful in 0s
CI / deploy (push) Successful in 1m21s
CI / deploy (pull_request) Has been skipped
2026-06-22 17:07:21 +00:00
Ilia Denisov ef2c2d1eb9 feat(account): seed the time zone from the client's detected offset at creation
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
A new account's time_zone defaulted to 'UTC' until the player saved a profile, so the
robot's sleep window and the turn-timeout away-window sweeper — both anchored to the
account zone via account.ResolveZone — ran on UTC for every fresh player, skewing
robot-game timing until a manual Settings save. Seed the zone at creation instead, from
the client's detected "±HH:MM" offset.

- Carry browser_tz on the three account-creating auth requests (TelegramLoginRequest,
  GuestLoginRequest, EmailRequestRequest — the email account is provisioned at the
  code-request step, not at login) through the fbs envelope (+ Go/TS codegen), the
  gateway transcode + backend client, and the backend auth handlers into
  ProvisionTelegram / ProvisionGuest / ProvisionEmail.
- create() now writes time_zone explicitly: the validated detected offset, or 'UTC'
  (equal to the column default) when absent or malformed — deterministic, never guessed.
  The column is already NOT NULL DEFAULT 'UTC', so no migration is needed and existing
  accounts keep 'UTC'. An existing account is never overwritten on re-login.
- A detected zero offset is stored as "+00:00" (the zone is known and equals UTC),
  distinct from the "UTC" default that means "unknown" — which the feedback console's
  three-zone Filed display already reflects.
- Guard the guest handler against an empty payload (the bootstrap historically carried
  none) so it degrades to no-seed rather than panicking in GetRootAs*.
- Tests: zone seeding across Telegram/guest/email plus the "+00:00"/malformed/empty
  cases and the not-overwrite rule; codec round-trip for the three auth encoders.
  ARCHITECTURE + FUNCTIONAL(+ru) updated.
2026-06-22 18:43:24 +02:00
Ilia Denisov 004aca4e97 feat(feedback): capture the browser UTC offset; Filed time in three zones
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
The account time zone defaults to UTC until a player saves a profile, so a
report's Filed time could only render in UTC even for a player clearly in
another zone. Capture the client's detected "±HH:MM" offset (browser_tz) with
each submission and show the Filed time in three zones in the operator console
— UTC, the browser offset detected at submit, and the sender's saved profile
zone — each shown "N/A" when not known, so the operator can tell what is
certainly known from what is merely defaulted.

- Thread browser_tz through the fbs envelope (+ Go/TS codegen), the gateway
  transcode + backend client, and the backend feedback service/store; add the
  column via migration 00004 (additive, image-rollback-safe).
- Fix fmtTimeIn to resolve "±HH:MM" offsets via account.ResolveZone;
  time.LoadLocation alone silently fell back to UTC for offset zones, which is
  the second reason a "+02:00" sender showed only UTC.
- Update ARCHITECTURE/FUNCTIONAL(+ru) docs and the feedback integration test.
2026-06-22 18:05:39 +02:00
Ilia Denisov b78ce42922 feat(feedback): capture the app version; show version + local Filed time
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Each feedback submission now carries the client app version (__APP_VERSION__),
snapshotted like the interface language: FlatBuffers FeedbackSubmitRequest gains
a version field → gateway transcode → backend, persisted in a new nullable
feedback_messages.app_version column (migration 00003, additive so an image
rollback stays DB-safe). The operator console detail shows the app version and
renders the Filed time in UTC plus the sender's time zone (fmtTimeIn).

Touches: fbs schema + regenerated Go/TS codegen, codec + transport (the client
attaches its build), gateway transcode + backendclient, feedback store/service,
admin view + template, docs (ARCHITECTURE §15, FUNCTIONAL + _ru). Verified:
feedback integration tests (migration + version round-trip), codec round-trip,
check/unit/build green.
2026-06-22 17:02:05 +02:00
developer 08c2c5f660 Merge pull request 'fix(i18n): banner/push language follows the device's saved choice' (#113) from feature/banner-language-sync into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / deploy (push) Successful in 1m19s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 14:26:38 +00:00
developer 06cc4c1edc Merge pull request 'feat(ads): seed the house banner with the curated tip set' (#112) from feature/ad-tips-default-banner into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m20s
2026-06-22 14:26:29 +00:00
Ilia Denisov 90f0424de2 fix(i18n): reconcile preferred_language with the device's saved language
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The UI language follows the device (the local choice / system guess) and is
deliberately not overridden from the account, but the advertising banner and
out-of-app push routing are resolved server-side from preferred_language. A
saved device choice the account had not recorded — picked while a guest, or
differing from the Telegram system-language seed — left the banner (and pushes)
in the wrong language until a Settings change rewrote preferred_language.

On profile load (adoptSession and the in-place link path) push the saved local
choice to the account when it differs (new pure helper languageNeedsServerSync;
no-op for guests and when already equal), so the banner and pushes match the
visible UI from the first open. Unit-tested.
2026-06-22 16:16:33 +02:00
Ilia Denisov c36543e54e feat(ads): seed the house banner with the curated tip set
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Migration 00002 replaces the default (house) campaign's single seed tip with
the 47 curated, language-agnostic Scrabble tips — one bilingual ad_messages row
each (body_en + body_ru), which the client round-robins per ARCHITECTURE §ads.

Data-only: the ad_messages schema is unchanged, so a backend image rollback
stays DB-safe. Down restores the original single seed tip. Verified up/down
against a throwaway Postgres (47 rows; apostrophes escaped).
2026-06-22 15:44:49 +02:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer be1627936f Merge pull request 'chore(deploy): pin dictionary seed to v1.3.0' (#110) from feature/pin-dict-v130 into development
CI / changes (push) Successful in 2s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 13:06:26 +00:00
Ilia Denisov 1ba52dd0b4 refactor(deploy): make DICT_VERSION a required build arg (single-sourced)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
Drop the literal version default from the build files (backend Dockerfile both
stages, loadtest Dockerfile, the compose build-arg) so the release tag is not
duplicated as a stale-prone default a newcomer can't tell from the real source.

DICT_VERSION is now required: compose uses ${DICT_VERSION:?…} and the Dockerfiles
have no ARG default, so a missing value fails loudly instead of baking a stale tag.
The tag lives only in its genuine sources — ci.yaml env (CI tests), the Gitea
TEST_/PROD_DICT_VERSION variables (deploy seed) and deploy/.env.example (local).
Adds a "Bumping the dictionary version" section to deploy/README and fixes the bare
docker-build examples (CLAUDE.md, README.md, loadtest/README) to pass --build-arg.
2026-06-22 15:03:07 +02:00
Ilia Denisov bb0e3e17e5 chore(deploy): pin dictionary seed to v1.3.0
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Bump DICT_VERSION v1.2.1 -> v1.3.0 across the seed surface: .env.example, the
compose build-arg default, both backend Dockerfile stages, the loadtest
Dockerfile, the CI dawg-download version, and the deploy/backend docs.

v1.3.0 drops the abbreviation class from the Russian word list (scrabble-dictionary
#6). This only seeds a FRESH volume; a live contour/prod volume is unaffected (the
.seed_version marker wins — seed-drift guard) and moves to v1.3.0 through the admin
console (ARCHITECTURE §5). Per-contour deploy still overrides via TEST_/PROD_DICT_VERSION.
2026-06-22 14:45:52 +02:00
developer 1ef2bde395 Merge pull request 'feat(ui): Erudit blank tiles carry the star (✻) mark' (#109) from feature/erudit-blank-star into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-06-22 11:06:22 +00:00
Ilia Denisov 62f66735a3 fix(ui): nudge the rack blank star up a pixel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
By eye the centred star still read a hair low; subtract 1px from its top
offset (top: calc(0.5% - 1px)).
2026-06-22 12:55:06 +02:00
Ilia Denisov 81680a1d5e fix(ui): raise the rack blank star to centre on the letters
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Top-aligning it still read a touch low; move the empty-blank star up
(top 8% -> 0.5%) so its ink centres against the rack letters' block,
matching the board tile's centred mark. Size unchanged.
2026-06-22 12:22:48 +02:00
Ilia Denisov a393561d79 fix(ui): align the Erudit blank star with neighbouring tiles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Rack: the empty-blank star is now top-anchored level with the letters and a
touch larger (was centred, sitting low). Board and Stats best-move tiles: the
placed-blank star's ink is centred on the value digits' line (was slightly
high). CSS-only nudges; pixel offsets measured against the rendered glyphs.
2026-06-22 12:11:35 +02:00
Ilia Denisov e3e4cedc77 feat(ui): Erudit blank tiles carry the star (✻) mark
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The Erudit variant's blank is the "звёздочка", so render it with a star.
An empty rack blank (and its drag ghost) shows ✻ centred; a placed blank
keeps its designated letter and carries ✻ where the (absent) point value
sits — on the board and in the Stats best-move tiles. The Scrabble variants
are unchanged. Gated by usesStarBlank() in lib/variants.ts.
2026-06-22 11:52:00 +02:00
69 changed files with 1168 additions and 176 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ on:
# unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
env:
DICT_VERSION: v1.2.1
DICT_VERSION: v1.3.0
jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when
+1 -1
View File
@@ -144,7 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
pnpm start # UI mock mode: lobby -> game, no backend
docker build -f backend/Dockerfile -t scrabble-backend . # images; gateway embeds the SPA
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
docker compose -f deploy/docker-compose.yml config # validate the full contour
+1 -1
View File
@@ -89,7 +89,7 @@ observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a fron
services build from multi-stage distroless `*/Dockerfile`.
```sh
docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
```
+6 -4
View File
@@ -7,12 +7,14 @@
# (GOPRIVATE), so the build stage needs git and network.
#
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
# in the Docker context:
# docker build -f backend/Dockerfile -t scrabble-backend .
# in the Docker context. DICT_VERSION has no default — the caller supplies the
# scrabble-dictionary release tag (compose/CI pass it; see deploy/README.md
# "Bumping the dictionary version"):
# docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend .
# --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.2.1
ARG DICT_VERSION
RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \
@@ -42,7 +44,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
# Re-declare the build arg in this stage so it labels the seed dictionary. One
# DICT_VERSION drives both the artifact the dawg stage downloads and the version
# label the binary pins, so the resident version equals the release tag.
ARG DICT_VERSION=v1.2.1
ARG DICT_VERSION
COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
+1 -1
View File
@@ -228,7 +228,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.3.0/scrabble-dawg-v1.3.0.tar.gz | tar xz -C /tmp/dawg
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=/tmp/dawg \
GOPRIVATE='gitea.iliadenisov.ru/*' \
+50 -14
View File
@@ -119,6 +119,16 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return s.provision(ctx, kind, externalID, provisionSeed{})
}
// ProvisionEmail returns the account owning the email identity externalID, creating
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
// and leaves an existing account untouched, so a returning user's saved zone is never
// overwritten. The email account is created here (the code-request step), not at the
// later login, so this is where its zone is seeded.
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
}
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
// member: a KindRobot identity carrying displayName, with chat blocked but friend
// requests NOT blocked — a request to a robot is accepted as pending and, since the
@@ -160,7 +170,7 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
// is never overwritten. The created flag lets the auth handler re-evaluate moderated-
// chat write access on first registration — the path of a user who joined the chat
// before registering, whom no chat_member event covers.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, bool, error) {
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName, browserTZ string) (Account, bool, error) {
// Pre-check whether the identity already exists so the caller can act on first
// contact. A race with a concurrent create only over- or under-reports created for
// that one call, which the idempotent chat-access re-evaluation tolerates.
@@ -169,7 +179,9 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
if err != nil && !created {
return Account{}, false, err
}
acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
seed := telegramSeed(languageCode, username, firstName)
seed.timeZone = seedZone(browserTZ)
acc, err := s.provision(ctx, KindTelegram, externalID, seed)
return acc, created, err
}
@@ -197,12 +209,24 @@ func (s *Store) provision(ctx context.Context, kind, externalID string, seed pro
}
// provisionSeed carries the optional create-time profile seed for a brand-new
// account (Telegram first contact). Empty fields fall back to the accounts table
// defaults, so an unknown language keeps the 'en' default and an empty name keeps
// the ” default.
// account (first contact). Empty fields fall back to the accounts table defaults,
// so an unknown language keeps the 'en' default, an empty name keeps the ” default
// and an empty time zone keeps the 'UTC' default.
type provisionSeed struct {
preferredLanguage string
displayName string
timeZone string
}
// seedZone returns browserTZ when it is a well-formed zone to persist at account
// creation (a "±HH:MM" offset or a loadable IANA name), else "" so the new account
// falls back to the accounts table's 'UTC' default. The client reports the device's
// detected offset deterministically; a bad value is dropped rather than guessed at.
func seedZone(browserTZ string) string {
if validZone(browserTZ) {
return browserTZ
}
return ""
}
// telegramSeed derives the create-time seed from Telegram launch fields: a
@@ -368,16 +392,22 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
var created Account
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
// Seed the new row's display name and language (Telegram first contact); an
// empty seed reproduces the table defaults ('' and 'en') the other callers
// relied on, so their behaviour is unchanged.
// Seed the new row's display name, language and time zone (first contact); an
// empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers
// relied on, so their behaviour is unchanged. time_zone is written explicitly (the
// detected offset, or 'UTC' equal to the column default) so a seeded zone lands at
// creation while an unseeded one stays UTC.
lang := seed.preferredLanguage
if lang == "" {
lang = "en"
}
tz := seed.timeZone
if tz == "" {
tz = "UTC"
}
insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
VALUES(accountID, seed.displayName, lang).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
VALUES(accountID, seed.displayName, lang, tz).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
@@ -416,15 +446,21 @@ const guestDisplayName = "Guest"
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
// no identity, flagged is_guest, so it can hold a session and a game seat (both
// foreign-key the accounts table) while being excluded from statistics, friends
// and history. Guests are not reused — each bootstrap mints a new account.
func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
// back to the 'UTC' default when empty or malformed.
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
accountID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err)
}
tz := seedZone(browserTZ)
if tz == "" {
tz = "UTC"
}
stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest).
VALUES(accountID, guestDisplayName, true).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName, true, tz).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
+5 -3
View File
@@ -131,13 +131,15 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
// the unauthenticated email-login entry point and, unlike RequestCode,
// does not refuse an already-confirmed email — that is the ordinary returning-user
// login. The code is mailed to the address, so only its real owner can complete
// the login. It returns the target account id for the subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) {
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
// seeds the new account's time zone. It returns the target account id for the
// subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.UUID{}, err
}
acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr)
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
if err != nil {
return uuid.UUID{}, err
}
@@ -7,8 +7,9 @@
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>Channel</b> {{.Channel}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</li>
<li><b>App version</b> {{if .Version}}<code>{{.Version}}</code>{{else}}<span class="note">unknown</span>{{end}}</li>
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Filed</b> {{.CreatedAt}}</li>
<li><b>Filed</b> {{.CreatedAt}} UTC &middot; browser {{if .CreatedAtBrowser}}{{.CreatedAtBrowser}} ({{.BrowserTZ}}){{else}}<span class="note">N/A</span>{{end}} &middot; user {{if .CreatedAtUser}}{{.CreatedAtUser}} ({{.UserTZ}}){{else}}<span class="note">N/A</span>{{end}}</li>
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
</ul>
+13 -1
View File
@@ -554,5 +554,17 @@ type FeedbackDetailView struct {
ReplyBody string
RepliedAt string
CreatedAt string
Banned bool
// Version is the client app build the report was sent from (empty for rows that predate it).
Version string
// The Filed time is shown in three zones so the operator can tell what is certainly known from
// what is merely defaulted. CreatedAt is the authoritative UTC time. CreatedAtBrowser is that
// instant in the client's UTC offset detected at submit (BrowserTZ its "±HH:MM" label), empty
// when the client reported none (an older build). CreatedAtUser is that instant in the sender's
// saved profile zone (UserTZ its label), empty when the account has no zone beyond the UTC
// default — the template then shows "N/A" so the missing datum is explicit.
CreatedAtBrowser string
BrowserTZ string
CreatedAtUser string
UserTZ string
Banned bool
}
+5 -4
View File
@@ -72,7 +72,7 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
// validates the body (non-empty, within the rune limit) and the optional
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
// client IP (validated); channel is the submitting platform.
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, version, browserTZ, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return err
@@ -112,9 +112,10 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
attachmentName = "" // a name without bytes carries no attachment
}
ch := normalizeChannel(channel)
// Snapshot the sender's interface language at submit time (acc is already loaded
// for the guest check) so the operator later sees the state as it was.
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
// Snapshot the sender's interface language, the client app version and the client's
// detected UTC offset at submit time (acc is already loaded for the guest check) so the
// operator later sees the state as it was.
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, version, browserTZ, parseIP(senderIP))
return err
}
+18 -10
View File
@@ -34,10 +34,10 @@ func NewStore(db *sql.DB) *Store {
// Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. lang (the sender's interface language) is a snapshot
// taken now, so the operator later sees the state at submit time. created_at defaults
// to now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) {
// channel are stored as given. lang (interface language), version (client app build) and
// browserTZ (the client's detected "±HH:MM" UTC offset) are snapshots taken now, so the operator
// later sees the state at submit time. created_at defaults to now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, version, browserTZ string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7()
if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
@@ -48,9 +48,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil {
(message_id, account_id, body, attachment, attachment_name, channel, lang, app_version, browser_tz, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), nullStr(browserTZ), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
}
return id, nil
@@ -228,7 +228,15 @@ type AdminMessage struct {
Body string
Channel string
// Lang is the sender's interface language, snapshotted at submit time.
Lang string
Lang string
// Version is the client app build the report was sent from, snapshotted at submit time.
Version string
// BrowserTZ is the client's detected "±HH:MM" UTC offset at submit time, snapshotted so the
// filed time can be shown in the sender's browser-local zone even before they save a profile.
BrowserTZ string
// TimeZone is the sender account's stored zone ("±HH:MM" offset, IANA name, or ""), for
// rendering CreatedAt in the sender's own configured time alongside UTC.
TimeZone string
SenderIP string
HasAttachment bool
AttachmentName string
@@ -343,7 +351,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
var m AdminMessage
var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
COALESCE(m.lang, ''),
COALESCE(m.lang, ''), COALESCE(m.app_version, ''), COALESCE(m.browser_tz, ''), a.time_zone,
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
@@ -352,7 +360,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
&m.Lang,
&m.Lang, &m.Version, &m.BrowserTZ, &m.TimeZone,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
+59 -11
View File
@@ -110,15 +110,15 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
}
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
// seeds the new account's language and display name from the launch fields,
// defaults the in-app-only flag on, and never overwrites an existing account on a
// later login (language seeding).
// seeds the new account's language, display name and time zone from the launch
// fields / detected offset, defaults the in-app-only flag on, and never overwrites
// an existing account on a later login (language and zone seeding).
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван", "+03:00")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -131,12 +131,15 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
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)
}
if !acc.NotificationsInAppOnly {
t.Error("NotificationsInAppOnly should default to true")
}
// A later login with different fields returns the same account, unchanged.
again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other", "+09:00")
if err != nil {
t.Fatalf("re-provision telegram: %v", err)
}
@@ -146,8 +149,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
}
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" {
t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName)
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" || again.TimeZone != "+03:00" {
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
}
}
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
// missing or malformed offset falls back to the "UTC" column default rather than
// being guessed at.
func TestProvisionSeedsTimeZone(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
// A detected zero offset is written as "+00:00" — we record that the zone was
// detected (and equals UTC), distinct from the "UTC" default meaning "unknown".
utcDetected, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Zero", "+00:00")
if err != nil {
t.Fatalf("provision telegram +00:00: %v", err)
}
if utcDetected.TimeZone != "+00:00" {
t.Errorf("TimeZone = %q, want the seeded +00:00", utcDetected.TimeZone)
}
// A malformed offset is dropped: the account keeps the UTC default.
bad, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Bad", "not-a-zone")
if err != nil {
t.Fatalf("provision telegram bad tz: %v", err)
}
if bad.TimeZone != "UTC" {
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
}
// A guest is seeded its detected offset; an empty one keeps the UTC default.
guest, err := store.ProvisionGuest(ctx, "-05:30")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
if guest.TimeZone != "-05:30" {
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
}
plainGuest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision plain guest: %v", err)
}
if plainGuest.TimeZone != "UTC" {
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
}
}
@@ -156,7 +204,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
// language CHECK.
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
ctx := context.Background()
acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -172,7 +220,7 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
func TestHighRateFlagRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -228,7 +276,7 @@ func TestIdentityExternalID(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -253,7 +301,7 @@ func TestIdentityExternalID(t *testing.T) {
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
+1 -1
View File
@@ -222,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player", "")
if err != nil {
t.Fatalf("provision: %v", err)
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestChatAccessResolver(t *testing.T) {
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
ext := "tg-" + uuid.NewString()
acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter")
acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter", "")
if err != nil {
t.Fatalf("provision: %v", err)
}
+5 -2
View File
@@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
svc := account.NewEmailService(account.NewStore(testDB), mailer)
email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email)
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00")
if err != nil {
t.Fatalf("request login code: %v", err)
}
@@ -225,12 +225,15 @@ func TestEmailLoginFlow(t *testing.T) {
if acc.IsGuest {
t.Error("an email account must be durable, not a guest")
}
if acc.TimeZone != "+02:00" {
t.Errorf("TimeZone = %q, want the +02:00 seeded at the request step", acc.TimeZone)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("the email identity must be confirmed after login")
}
// A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil {
t.Fatalf("second request: %v", err)
}
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
+13 -13
View File
@@ -38,7 +38,7 @@ func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.U
func TestFeedbackGuestRejected(t *testing.T) {
svc := newFeedbackService()
guest := provisionGuest(t)
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "v1", "+05:00", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
}
}
@@ -48,11 +48,11 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
svc := newFeedbackService()
acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil {
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "v1.2.0", "+03:00", "9.9.9.9"); err != nil {
t.Fatalf("submit: %v", err)
}
// Anti-spam gate: a second message is refused while the first is unreviewed.
if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) {
if err := svc.Submit(ctx, acc, "again", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrPendingReview) {
t.Fatalf("second submit err = %v, want ErrPendingReview", err)
}
if st, err := svc.State(ctx, acc); err != nil {
@@ -69,7 +69,7 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
if m.Body != "please fix the board" { // trimmed
t.Fatalf("body = %q, want trimmed", m.Body)
}
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" {
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" || m.Version != "v1.2.0" || m.BrowserTZ != "+03:00" {
t.Fatalf("admin message = %+v", m)
}
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" {
@@ -116,7 +116,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
acc := provisionAccount(t)
// msg1, replied → the player can send again and currently sees the reply.
if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "first", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit msg1: %v", err)
}
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil {
@@ -130,7 +130,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
// Sending a new message immediately drops the previous reply (it now belongs to an
// older message), even though it is well within the one-week window.
if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "second", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit msg2: %v", err)
}
st, err := svc.State(ctx, acc)
@@ -154,7 +154,7 @@ func TestFeedbackSnapshotsLanguage(t *testing.T) {
t.Fatalf("set language: %v", err)
}
// A message snapshots the sender's interface language at submit time.
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", "", "", ""); err != nil {
t.Fatalf("submit: %v", err)
}
id := latestFeedbackID(t, svc, acc)
@@ -184,7 +184,7 @@ func TestFeedbackBanRole(t *testing.T) {
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("grant role: %v", err)
}
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) {
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrBanned) {
t.Fatalf("banned submit err = %v, want ErrBanned", err)
}
if st, err := svc.State(ctx, acc); err != nil {
@@ -196,7 +196,7 @@ func TestFeedbackBanRole(t *testing.T) {
if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("revoke role: %v", err)
}
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit after unban: %v", err)
}
}
@@ -219,7 +219,7 @@ func TestFeedbackValidation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
acc := provisionAccount(t) // fresh account so the pending gate never fires first
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) {
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", "", "", ""); !errors.Is(err, tt.want) {
t.Fatalf("submit err = %v, want %v", err, tt.want)
}
})
@@ -231,7 +231,7 @@ func TestFeedbackAdminLifecycle(t *testing.T) {
svc := newFeedbackService()
acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit: %v", err)
}
id := latestFeedbackID(t, svc, acc)
@@ -276,7 +276,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
svc := newFeedbackService()
acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "one", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit: %v", err)
}
if err := svc.DeleteAllByAccount(ctx, acc); err != nil {
@@ -286,7 +286,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err)
}
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit after delete-all: %v", err)
}
}
+1 -1
View File
@@ -120,7 +120,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
// provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
@@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) {
Accounts: accounts,
})
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked", "")
if err != nil {
t.Fatalf("provision: %v", err)
}
+2 -2
View File
@@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) {
st := account.NewStore(testDB)
uniq := uuid.NewString()
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman", "")
if err != nil {
t.Fatalf("provision human: %v", err)
}
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil {
t.Fatalf("provision robot: %v", err)
}
guest, err := st.ProvisionGuest(ctx)
guest, err := st.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
@@ -0,0 +1,64 @@
-- Replace the default (house) ad campaign's single seed tip with the curated,
-- language-agnostic Scrabble tip set (one bilingual row per tip; the client picks the
-- column for the viewer's language). Data-only — the ad_messages schema is unchanged, so
-- a backend image rollback stays DB-safe. The default campaign is the fixed house id seeded
-- in 00001; ON DELETE CASCADE is irrelevant here (we only touch its messages).
-- +goose Up
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru) VALUES
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 0, 'Keep a balanced rack — a slight edge of consonants over vowels.', 'Держи на руках баланс — с лёгким перевесом согласных над гласными.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 1, 'Your "leave" (the tiles you keep) sets up your next turn — value it.', '«Остаток» (что оставляешь на руках) готовит следующий ход — цени его.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 2, 'Shed duplicate tiles — repeats clog your options.', 'Сбрасывай дубли фишек — повторы забивают возможности.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 3, 'A slightly consonant-heavy rack builds full-rack plays more easily.', 'Лёгкий перевес согласных проще складывается в выкладку всех фишек.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 4, 'Play several tiles per turn to keep your rack cycling.', 'Выкладывай по нескольку фишек за ход, чтобы рука обновлялась.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 5, 'Don''t hoard hard-to-place duplicates or a lone high-value tile.', 'Не копи труднопристраиваемые дубли или одинокую дорогую фишку.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 6, 'Using all your rack tiles in one move scores a large bonus — chase it.', 'Выкладка всех фишек с рук за ход даёт крупный бонус — стремись к ней.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 7, 'Learn common prefixes and suffixes — they extend words to use every tile.', 'Учи частые приставки и суффиксы — они растягивают слово на все фишки.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 8, '"Fish": play few tiles to keep a near-complete rack when you''re ahead.', '«Рыбачь»: сыграй мало фишек, сохранив почти всю руку, когда ведёшь.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 9, 'Don''t hoard high-value tiles — play them in good time, not at the very end.', 'Не копи дорогие фишки — играй их вовремя, а не под самый конец.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 10, 'Don''t hold a high-value tile waiting for a rare partner — usually a loss.', 'Не держи дорогую фишку ради редкого партнёра — обычно это проигрыш.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 11, 'Land your priciest tile on a premium square for a big single score.', 'Сажай самую дорогую фишку на бонусную клетку ради крупных очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 12, 'High-value tiles shine in parallel plays through short words.', 'Дорогие фишки сильны в параллельных выкладках через короткие слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 13, 'Stuck with an unplayable high-value tile late? Exchange it.', 'Завис с неиграбельной дорогой фишкой под конец? Обменяй её.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 14, 'The blanks are the most valuable tiles in the bag — guard them.', 'Пустышки — самые ценные фишки в мешке; береги их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 15, 'Save a blank for a full-rack play or a key premium square.', 'Береги пустышку для выкладки всех фишек или важной бонусной клетки.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 16, 'Don''t spend a blank cheaply — hold it for a much bigger gain.', 'Не трать пустышку по мелочи — придержи ради куда большей выгоды.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 17, 'Put high-value tiles on letter-bonus or word-bonus squares.', 'Клади дорогие фишки на бонус буквы или слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 18, 'Stack bonuses — a letter bonus under a word bonus multiplies both.', 'Совмещай бонусы — бонус буквы под бонусом слова умножает оба.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 19, 'Parallel plays can earn nearly half your points — look for them.', 'Параллельные выкладки могут давать почти половину очков — ищи их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 20, 'A hook adds one tile to an existing word to make a new one.', '«Крючок» — одна фишка к готовому слову, образующая новое.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 21, 'Hooks work at the front or the back of a word.', 'Крючки работают спереди и сзади слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 22, 'Short words are the keys to tight parallel plays — memorize them.', 'Короткие слова — ключ к плотным параллелям; выучи их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 23, 'Your opening word crosses the centre — keep it compact, don''t open up.', 'Первое слово идёт через центр — держи компактным, не раскрывайся.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 24, 'It''s not only your score — limit your opponent''s options too.', 'Это не только твои очки — ограничивай и возможности соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 25, 'Denying a big reply often beats squeezing a few more points yourself.', 'Закрыть крупный ответ часто важнее, чем добрать пару своих очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 26, 'When ahead, keep the board tight and closed; avoid open lanes.', 'Ведёшь — держи доску плотной и закрытой, не открывай линии.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 27, 'When behind, open the board up to create high-scoring chances.', 'Отстаёшь — раскрывай доску ради шансов на крупный ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 28, 'Don''t leave a word-bonus square open right beside your word.', 'Не оставляй клетку бонуса слова открытой рядом со своим словом.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 29, 'Block a hot square even with a weak word to deny a big play.', 'Закрывай опасную клетку даже слабым словом, чтобы срубить крупный ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 30, 'Know words that take no hooks — use them to seal off lines.', 'Знай слова, не берущие крючков — ими запирай линии.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 31, 'Track the tiles played to judge what is still left in the bag.', 'Считай сыгранные фишки — так поймёшь, что осталось в мешке.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 32, 'Exchange when your rack is unbalanced or can only score low.', 'Меняй фишки, когда рука несбалансированна или тянет мало.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 33, 'A good exchange beats a bad play — a clean rack is worth a turn.', 'Хороший обмен лучше плохого хода — чистая рука стоит хода.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 34, 'Swap away a surplus of vowels or consonants to rebalance.', 'Сбрасывай в обмен избыток гласных или согласных, чтобы выровняться.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 35, 'Rare high-value tiles are gone once seen — note them as they appear.', 'Редкие дорогие фишки исчезают, едва мелькнув — отмечай их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 36, 'Once the bag is empty, deduce your opponent''s remaining tiles.', 'Когда мешок пуст, вычисли оставшиеся фишки соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 37, 'Shed high-value tiles before the bag empties — don''t get stuck with them.', 'Сбрось дорогие фишки до опустения мешка — не зависай с ними.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 38, 'Unplayed tiles count against you at the end — try to go out first.', 'Несыгранные фишки минусуют очки в конце — старайся выйти первым.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 39, 'Going out first adds your opponent''s leftover tiles to your score.', 'Кто вышел первым, добирает очки за оставшиеся фишки соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 40, 'Sometimes leaving one tile in the bag buys you an extra turn.', 'Иногда оставить одну фишку в мешке — это лишний ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 41, 'In the endgame, block the exact squares your opponent needs.', 'В эндшпиле блокируй именно те клетки, что нужны сопернику.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 42, 'Shuffle your rack to spot new patterns.', 'Перемешивай фишки на руках — так замечаешь новые сочетания.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 43, 'Separate prefix, suffix and middle tiles to anagram faster.', 'Разнеси приставку, суффикс и середину — анаграммы решаются быстрее.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 44, 'Value board position and future turns over raw points this turn.', 'Цени позицию и будущие ходы выше сиюминутных очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 45, 'Early game build position; midgame maximize score; endgame defend.', 'В начале — позиция, в середине — очки, в конце — защита.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 46, 'Learn the short-word lists first — they pay off in every game.', 'Сначала учи списки коротких слов — окупаются в каждой партии.');
-- +goose Down
-- Restore the original single house tip seeded by the baseline.
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru)
VALUES ('00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0,
'Tip: a play using all 7 tiles earns a +50 bonus.',
'Совет: ход всеми 7 фишками приносит бонус +50 очков.');
@@ -0,0 +1,10 @@
-- Capture the client app version (the build a report was sent from) with each feedback
-- message, so the operator console can show which version a player was on. Nullable, so the
-- rows that predate this keep working — additive and backward-compatible, so a backend image
-- rollback stays DB-safe (older code simply ignores the column).
-- +goose Up
ALTER TABLE backend.feedback_messages ADD COLUMN app_version text;
-- +goose Down
ALTER TABLE backend.feedback_messages DROP COLUMN app_version;
@@ -0,0 +1,11 @@
-- Capture the client's detected UTC offset ("±HH:MM") with each feedback message, so the
-- operator console can show the filed time in the sender's browser-local zone even before that
-- player has ever saved a profile (the account zone defaults to UTC until then). Nullable, so the
-- rows that predate this keep working — additive and backward-compatible, so a backend image
-- rollback stays DB-safe (older code simply ignores the column).
-- +goose Up
ALTER TABLE backend.feedback_messages ADD COLUMN browser_tz text;
-- +goose Down
ALTER TABLE backend.feedback_messages DROP COLUMN browser_tz;
@@ -1198,6 +1198,17 @@ func fmtTime(t time.Time) string {
return t.UTC().Format("2006-01-02 15:04")
}
// fmtTimeIn formats a timestamp in the given zone — a "±HH:MM" offset or an IANA name, resolved
// by account.ResolveZone (falling back to UTC when empty or unknown) — or "" when zero. Used to
// show a time in a user's local zone beside UTC; the offset form is what the profile editor and
// the feedback browser-tz snapshot store, so it must not go through time.LoadLocation alone.
func fmtTimeIn(t time.Time, tz string) string {
if t.IsZero() {
return ""
}
return t.In(account.ResolveZone(tz)).Format("2006-01-02 15:04")
}
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
func fmtTimePtr(t *time.Time) string {
if t == nil {
@@ -77,6 +77,17 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
s.consoleError(c, err)
return
}
// Filed time in three zones so the operator can tell what is certainly known from what is
// merely defaulted: always UTC; the client's offset detected at submit (when the build
// reported one); and the sender's saved profile zone (when set beyond the UTC default). An
// empty rendered time makes the template show "N/A" for that line.
browserCreated, userCreated := "", ""
if m.BrowserTZ != "" {
browserCreated = fmtTimeIn(m.CreatedAt, m.BrowserTZ)
}
if m.TimeZone != "" && m.TimeZone != "UTC" {
userCreated = fmtTimeIn(m.CreatedAt, m.TimeZone)
}
view := adminconsole.FeedbackDetailView{
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang,
@@ -84,6 +95,9 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
Version: m.Version,
CreatedAtBrowser: browserCreated, BrowserTZ: m.BrowserTZ,
CreatedAtUser: userCreated, UserTZ: m.TimeZone,
}
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
view.Banned = banned
+24 -7
View File
@@ -18,12 +18,14 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language (first contact only).
// brand-new account's display name and language; BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds its time zone (first contact only).
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
BrowserTZ string `json:"browser_tz"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
@@ -35,7 +37,7 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
abortBadRequest(c, "external_id is required")
return
}
acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName, req.BrowserTZ)
if err != nil {
s.abortErr(c, err)
return
@@ -97,9 +99,21 @@ func (s *Server) handlePushTarget(c *gin.Context) {
})
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
// guestAuthRequest carries the guest bootstrap's optional time-zone seed: BrowserTZ
// (the client's detected "±HH:MM" UTC offset) is written to the new guest account's
// time zone, so robot timing is anchored to the player's zone from the first game.
type guestAuthRequest struct {
BrowserTZ string `json:"browser_tz"`
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
// seeding its time zone from the optional detected browser offset.
func (s *Server) handleGuestAuth(c *gin.Context) {
acc, err := s.accounts.ProvisionGuest(c.Request.Context())
// The body is optional: an absent or malformed one simply yields no time-zone seed
// (the account keeps the UTC default), so a bind error must not fail the bootstrap.
var req guestAuthRequest
_ = c.ShouldBindJSON(&req)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ)
if err != nil {
s.abortErr(c, err)
return
@@ -107,9 +121,12 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
s.mintSession(c, acc)
}
// emailRequest is an email-login code request.
// emailRequest is an email-login code request. BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds the time zone of an account provisioned here on first
// contact (the email account is created at the request step, not at login).
type emailRequest struct {
Email string `json:"email"`
Email string `json:"email"`
BrowserTZ string `json:"browser_tz"`
}
// handleEmailRequest issues a login confirm-code to the email. It always reports
@@ -121,7 +138,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
abortBadRequest(c, "email is required")
return
}
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil {
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ); err != nil {
s.abortErr(c, err)
return
}
+7 -1
View File
@@ -16,6 +16,12 @@ type feedbackSubmitRequest struct {
Attachment string `json:"attachment"`
AttachmentName string `json:"attachment_name"`
Channel string `json:"channel"`
// Version is the client's app version (pkg/version / the SPA build), snapshotted so the
// operator sees which build a report came from.
Version string `json:"version"`
// BrowserTZ is the client's detected UTC offset ("±HH:MM") at submit, so the operator can
// see the filed time in the sender's local zone even before they save a profile.
BrowserTZ string `json:"browser_tz"`
}
// feedbackReplyDTO is the operator's reply shown back to the player.
@@ -61,7 +67,7 @@ func (s *Server) handleFeedbackSubmit(c *gin.Context) {
}
attachment = data
}
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, clientIP(c)); err != nil {
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, req.Version, req.BrowserTZ, clientIP(c)); err != nil {
s.abortErr(c, err)
return
}
+1 -1
View File
@@ -16,7 +16,7 @@ POSTGRES_PASSWORD=change-me # required
# the active version lives in the DB. On a live volume a changed value is ignored (the
# recorded .seed_version marker wins — the seed-drift guard); change a running
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
DICT_VERSION=v1.2.1
DICT_VERSION=v1.3.0
# --- Logging ----------------------------------------------------------------
LOG_LEVEL=info
+23 -1
View File
@@ -80,7 +80,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
@@ -117,6 +117,28 @@ collector's / gateway's internal IP is fine (connected route), but its `AWG_CONF
which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is
intentionally **unset** — caddy owns `/_gm` in the contour.
## Bumping the dictionary version
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
a build-time input with **no default** in the images, so it is set in exactly two places to
move the whole stack — change both to a new release:
1. **CI tests**`.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
download that dawg).
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
`DICT_VERSION`).
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
default — a missing value fails loudly instead of baking a stale tag.
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
## Production rollout
Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only,
+12
View File
@@ -21,6 +21,18 @@
}
{$CADDY_SITE_ADDRESS::80} {
# HTTP/3 is advertised by default whenever this caddy terminates TLS (prod:
# CADDY_SITE_ADDRESS is the domain). But UDP/443 is never reachable — the prod
# compose maps only "443:443" (TCP) and ufw opens 443/tcp — so a client that cached
# the `Alt-Svc: h3` advert (sticky for ma=2592000s) stalls on the dead QUIC path
# before falling back to h2, which surfaced as the Telegram Mini App intermittently
# hanging on load. `Alt-Svc: clear` actively drops any cached alternative and pins
# clients to h2/h1; it is applied site-wide so every route is covered. In the test
# contour this caddy serves plain :80 (no h3 to advertise) and the host caddy
# re-stamps its own Alt-Svc, so the live test fix lives in the host caddy — here it
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
header Alt-Svc clear
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
@gm path /_gm /_gm/*
handle @gm {
+5 -3
View File
@@ -68,9 +68,11 @@ services:
context: ..
dockerfile: backend/Dockerfile
args:
# Seed dictionary for a FRESH volume; the per-contour value comes from the
# deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below.
DICT_VERSION: ${DICT_VERSION:-v1.2.1}
# Seed dictionary for a FRESH volume; required (no default) so the release tag is
# set in exactly one place per context — the deploy env (Gitea TEST_/PROD_DICT_VERSION)
# or .env for local builds. See the volume note below + deploy/README.md "Bumping the
# dictionary version".
DICT_VERSION: ${DICT_VERSION:?set DICT_VERSION — the scrabble-dictionary release tag, e.g. in deploy/.env}
# Build version stamped into the binary (git tag; see pkg/version).
VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
+15 -1
View File
@@ -36,7 +36,21 @@
"type": "stat",
"title": "Database size",
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
"fieldConfig": {
"defaults": {
"unit": "bytes",
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 8589934592 },
{ "color": "red", "value": 17179869184 }
]
}
},
"overrides": []
},
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(pg_database_size_bytes{datname=\"scrabble\"})" }]
},
+26 -9
View File
@@ -158,7 +158,12 @@ arrive from a platform rather than completing a mandatory registration).
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
any bot-scoped language, and the friend-invite **share link** (and its caption) point at
that one bot. First Telegram contact seeds the new account's `preferred_language` from the
launch `language_code` (§4); the interface language is otherwise edited in Settings.
launch `language_code` (§4), but the **interface language follows the device** — the system
guess, or an explicit Settings choice saved locally — and the bot never dictates the UI.
`preferred_language` is then **reconciled to the active interface locale on every session
adopt** (not only on a Settings change; a no-op for guests and when already equal), so the
server-rendered language surfaces — this push and the ad banner — always match the UI rather
than stranding a user who never opened Settings on the creation-time seed.
- **Variant preferences (New Game gating).** Which variants a player may be matched into is a
per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels
(`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New
@@ -640,7 +645,7 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
**floats games with any unread entry to the top** of the your-turn and opponent-turn
sections (the finished section keeps its activity order). On each clear the publish-to-read
latency is recorded; the read time itself is not retained.
- **Profile**: `preferred_language` (en/ru, edited in Settings), 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
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
Game — §3, defaulting to Erudit only, at least one enforced) and the
@@ -649,7 +654,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone`
for the sweeper and the robot's sleep (a fixed offset trades DST for a simple
picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform
picker), and is **seeded at account creation** from the client's detected offset — sent
on the Telegram / guest / email first-contact request — so the robot's sleep and the
away-window sweeper are anchored to the player's real zone from the first game rather
than the `UTC` default (an undetected or malformed offset keeps the default); the away
window is at most **12 h** (midnight-wrap aware). Linked platform
accounts and merge are covered in §4.
## 9. Persistence
@@ -1089,7 +1098,10 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
the **main host** runs the full stack (`docker-compose.yml` + `docker-compose.prod.yml`),
the **bot host** runs only the bot (`docker-compose.bot.yml`, no VPN — native Bot API
egress, telemetry off). There is no host caddy, so the contour caddy terminates TLS —
`CADDY_SITE_ADDRESS` is the domain and caddy does its own ACME. The gateway **publishes**
`CADDY_SITE_ADDRESS` is the domain and caddy does its own ACME. Caddy advertises HTTP/3 by default, but UDP/443 is not exposed (the
compose maps only TCP and ufw opens 443/tcp), so the edge emits `Alt-Svc: clear` to keep
clients on h2/h1 rather than stall on a dead QUIC path — see [`EDGE_HTTP3.md`](EDGE_HTTP3.md).
The gateway **publishes**
the bot-link `:9443`; the remote bot dials it over mTLS (certs from `PROD_BOTLINK_*`,
ServerName `gateway`, so TLS validation is independent of the public dial address), holds
no inbound port, and login is unaffected if that host or the link is down.
@@ -1149,9 +1161,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts
only). A message (≤1024 runes) plus an optional single attachment is stored in
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat) and the submitting
**channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain
is `internal/feedback` (store + service), modelled on the admin chat-moderation surface.
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat), the submitting
**channel** (telegram/ios/android/web, client-reported and validated), the **client app version**
(`__APP_VERSION__`, the build a report was sent from), the client's **detected UTC offset** at
submit (`browser_tz`, `±HH:MM`) and a snapshot of the sender's interface language are recorded. The domain is `internal/feedback` (store + service), modelled on the admin
chat-moderation surface.
**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the
gate is server-side. Because the operator must act before the next message, this is itself the
@@ -1159,8 +1173,11 @@ rate limit — there is no separate per-user feedback limiter.
**Operator review** happens in the server-rendered console (`/_gm/feedback`): an
unread / read / archived queue with per-user search (the `/users` glob masks), a detail card
(user content rendered as auto-escaped `html/template` text), and the read / reply / archive /
delete / delete-all actions — each marks the message read; merely opening the detail does not.
(user content rendered as auto-escaped `html/template` text; it shows the channel, interface
language and app version, and the filed time in three zones — UTC, the browser offset detected at
submit, and the sender's saved profile zone, each `N/A` when not known), and the read /
reply / archive / delete / delete-all actions — each marks the message read; merely opening the
detail does not.
The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options:
nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is
inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
+111
View File
@@ -0,0 +1,111 @@
# Edge HTTP/3 (`Alt-Svc`) policy
## TL;DR
The edge **advertises HTTP/3 but does not actually serve it** (UDP/443 is not exposed),
so we suppress the advert with `Alt-Svc: clear`. Advertising QUIC on `:443/udp` while
that port is unreachable makes clients — notably the Telegram Mini App webview — stall
on a dead QUIC connection before falling back to h2, which shows up as the app "hanging
on load".
## Symptom
Opening the Mini App intermittently hangs on load: from a barely-noticeable pause to
several seconds, sometimes a blank window that never finishes downloading `index.html`.
Intermittent, worse after the first successful visit, reproduced on both the test
contour and prod.
## Root cause
Caddy enables HTTP/3 by default on any TLS listener and emits
`Alt-Svc: h3=":443"; ma=2592000` — telling every client "reach me over QUIC/UDP 443"
and to cache that for 30 days. But UDP/443 is **never reachable end to end**:
- **Test contour**: the host caddy publishes only `:443/tcp` (`docker port caddy` shows
no `udp`); QUIC packets from the internet are dropped.
- **Prod**: `deploy/docker-compose.prod.yml` maps `"443:443"` (Docker = **TCP only**)
and `deploy/ansible/roles/main/tasks/main.yml` opens 443 `proto: tcp`. UDP/443 is
dropped at both the publish and the firewall.
Caddy *does* bind `udp/443` inside the container and h3 works container-to-container
(verified `http=3 code=200`), so the listener is healthy — it is simply not exposed.
A client that cached the advert tries QUIC first on later opens, gets no response, and
waits for the QUIC attempt to time out before falling back to TCP/h2. That wait is the
stall. The very first visit (no cached `Alt-Svc`) uses h2 and is fast.
The h2/TCP serving path itself is healthy: 30 fresh-TLS requests through the full path
(host caddy -> contour caddy -> gateway) measured TTFB ~9.5 ms, total ~9.8 ms, no tail;
`index.html` is ~1 KB.
## Fix in place (option A — suppress the advert)
Emit `Alt-Svc: clear`, which actively drops any cached alternative (better than merely
deleting the header, which leaves the sticky 30-day cache in place):
- **Prod / repo**: `deploy/caddy/Caddyfile` — a site-level `header Alt-Svc clear` (this
caddy terminates TLS in prod).
- **Test contour**: the host caddy terminates TLS, so the fix lives there (homelab
config, outside this repo): `header Alt-Svc clear` on the `scrabble.*` site. The
in-compose caddy serves plain `:80` in test and never advertises h3, so the repo
directive is a harmless no-op there (the host caddy re-stamps the header).
`header Alt-Svc clear` overrides Caddy's auto-advert (verified) and is site-scoped.
### Verify
The runner/prod host shell cannot reach the Docker bridge IPs directly, so probe from a
container on the relevant network, using `--resolve` to hit the TLS-terminating caddy by
its bridge IP (this also bypasses the public-IP NAT hairpin):
```sh
# <edge-ip> = the TLS-terminating caddy's IP on its network (docker inspect ... )
docker run --rm --network edge curlimages/curl:latest -sS -D - -o /dev/null \
--resolve <host>:443:<edge-ip> https://<host>/telegram/ | grep -iE '^HTTP|^alt-svc'
# expect: HTTP/2 200, and NO `alt-svc: h3=...` (the header is absent or `alt-svc: clear`)
```
## If it recurs — alternatives to try
So we do not re-derive the diagnosis from scratch:
1. **Re-confirm the advert is actually suppressed** with the verify command above. A
redeploy or a Caddy upgrade could regress it, or a client may still hold a cached
`h3` entry that has not yet been replaced by a `clear` (it needs one successful h2
response to receive the `clear`).
2. **Option B — serve HTTP/3 for real** instead of suppressing it. Worth it only if we
actually want QUIC (the benefit is marginal for a ~1 KB shell plus hash-immutable
cached assets, and it adds UDP/QUIC attack surface):
- Publish UDP: add `"443:443/udp"` next to the TCP map in
`deploy/docker-compose.prod.yml` (and publish udp/443 on the test host caddy too).
- Open the firewall: add a `443 proto: udp` rule in
`deploy/ansible/roles/main/tasks/main.yml`.
- Drop the `header Alt-Svc clear` so Caddy advertises h3 again.
- Verify with an h3 client from inside the network:
`docker run --rm --network edge ymuski/curl-http3 curl --http3-only ...` should
return `http=3 code=200`.
3. **Look past the edge** if the advert is suppressed and stalls persist. The h2 path is
fast server-side, so a remaining stall is most likely the client network / RTT / the
provider, not our stack. Re-run the timing loop (below) to confirm the server is
still <~10 ms TTFB before chasing the client side.
## How this was diagnosed (method, to repeat)
- The runner/prod host shell cannot reach the Docker bridge subnets, so all probing runs
from a throwaway container on the target network (`docker run --network <net>
curlimages/curl`), using `--resolve <host>:443:<edge-ip>` to bypass the public-IP NAT
hairpin and exercise the real TLS path.
- Compare a fresh-connection timing loop (worst case, full TLS each time) against a
keepalive batch to separate handshake cost from serving cost:
```sh
docker run --rm --network edge curlimages/curl:latest sh -c '
for i in $(seq 1 30); do
curl -sS -o /dev/null --resolve <host>:443:<edge-ip> \
-w "http=%{http_version} code=%{http_code} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
https://<host>/telegram/
done'
```
- `docker port <caddy>` shows whether `udp/443` is actually published; the response
`Alt-Svc` header shows what the edge advertises. The two disagreeing is the bug.
+5 -2
View File
@@ -241,7 +241,8 @@ also clears the moment its recipient **takes their move**.
Edit the display name (letters joined by a single space / "." / "_" separator, with an
optional trailing "." or a trailing run of up to five digits, up to 32 characters and at most
5 special characters — the "." / "_" punctuation, spaces and digits aside), the timezone
(chosen as a UTC offset), the
(chosen as a UTC offset, and pre-filled from your device's detected offset when the account
is first created — so robot games are timed correctly before you ever open this form), the
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
block toggles. The profile form is edited inline (no separate edit mode). Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
@@ -346,7 +347,9 @@ over-grant cannot be reversed there.
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
**unread / read / archived** with per-user search, each shown with its sender, source, channel
(with the bot language — en/ru — for a Telegram message), the sender's interface
language, IP and any attachment. The operator can mark a message read, **reply** to the player (delivered
language, the **app version** it was sent from, IP, the filed time (in three zones — UTC, the
browser zone detected at submit, and the sender's saved zone, each shown `N/A` when not known) and
any attachment. The operator can mark a message read, **reply** to the player (delivered
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
+5 -3
View File
@@ -248,8 +248,9 @@ _Вход сейчас только через провайдера, поэто
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не
более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
UTC; при создании аккаунта она подставляется из определённого смещения устройства — чтобы
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
@@ -356,7 +357,8 @@ high-rate флага. С карточки пользователя операт
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
**версией приложения**, с которой отправлено, IP, временем подачи (в трёх зонах — UTC, зоне браузера
на момент отправки и сохранённой зоне отправителя, каждая — «N/A», если неизвестна) и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
+4 -1
View File
@@ -109,7 +109,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
## Tiles & board
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
value), the point value bottom-right; blanks show no value.
value), the point value bottom-right; blanks show no value. In **Erudit** the blank is the
"звёздочка" (star) chip: an unplaced blank shows the star (`✻`, U+273B) centred on the rack
tile, and a placed blank carries it in the value corner; the Scrabble variants leave the
blank unmarked (`usesStarBlank` in `lib/variants.ts`).
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
the board's width** inside a fixed-size viewport (a real layout change → native scroll
that works consistently across browsers; no `transform`, which broke scrolling
+15 -8
View File
@@ -184,8 +184,10 @@ type ChatResp struct {
}
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
// brand-new account's display name and language from the validated launch fields.
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) {
// brand-new account's display name and language from the validated launch fields and
// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first
// contact only).
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
map[string]string{
@@ -193,6 +195,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
"language_code": languageCode,
"username": username,
"first_name": firstName,
"browser_tz": browserTz,
}, &out)
return out, err
}
@@ -243,17 +246,21 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
return out, err
}
// GuestAuth provisions a guest account and mints a session.
func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) {
// GuestAuth provisions a guest account and mints a session, seeding its time zone
// from browserTz (the client's detected "±HH:MM" UTC offset).
func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", struct{}{}, &out)
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
map[string]string{"browser_tz": browserTz}, &out)
return out, err
}
// EmailRequest asks the backend to mail a login code.
func (c *Client) EmailRequest(ctx context.Context, email string) error {
// EmailRequest asks the backend to mail a login code, provisioning the account on
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new
// account's time zone, since the email account is created here, not at login.
func (c *Client) EmailRequest(ctx context.Context, email, browserTz string) error {
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
map[string]string{"email": email}, nil)
map[string]string{"email": email, "browser_tz": browserTz}, nil)
}
// EmailLogin verifies a login code and mints a session.
@@ -27,12 +27,14 @@ type FeedbackUnreadResp struct {
// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded
// into the JSON body for the internal hop; clientIP rides X-Forwarded-For.
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, clientIP string) error {
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, version, browserTz, clientIP string) error {
payload := map[string]string{
"body": body,
"attachment": "",
"attachment_name": attachmentName,
"channel": channel,
"version": version,
"browser_tz": browserTz,
}
if len(attachment) > 0 {
payload["attachment"] = base64.StdEncoding.EncodeToString(attachment)
+12 -5
View File
@@ -158,7 +158,7 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
if err != nil {
return nil, err
}
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName)
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()))
if err != nil {
return nil, err
}
@@ -167,8 +167,15 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
}
func authGuestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, _ Request) ([]byte, error) {
sess, err := backend.GuestAuth(ctx)
return func(ctx context.Context, req Request) ([]byte, error) {
// The guest bootstrap historically carried no payload; the detected zone is
// optional, so an absent or empty one simply yields no time-zone seed (rather
// than panicking in GetRootAs* on a zero-length buffer).
var browserTz string
if len(req.Payload) > 0 {
browserTz = string(fb.GetRootAsGuestLoginRequest(req.Payload, 0).BrowserTz())
}
sess, err := backend.GuestAuth(ctx, browserTz)
if err != nil {
return nil, err
}
@@ -179,7 +186,7 @@ func authGuestHandler(backend *backendclient.Client) Handler {
func authEmailRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
if err := backend.EmailRequest(ctx, string(in.Email())); err != nil {
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz())); err != nil {
return nil, err
}
return encodeAck(true), nil
@@ -499,7 +506,7 @@ func hideGameHandler(backend *backendclient.Client) Handler {
func feedbackSubmitHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0)
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), req.ClientIP); err != nil {
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), string(in.Version()), string(in.BrowserTz()), req.ClientIP); err != nil {
return nil, err
}
return encodeAck(true), nil
+2 -1
View File
@@ -12,7 +12,8 @@
# --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.2.1
# Required, no default: the build caller supplies the scrabble-dictionary release tag.
ARG DICT_VERSION
RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \
+2 -2
View File
@@ -35,8 +35,8 @@ The harness reaches Postgres and the gateway directly, so run it as a one-shot
container on the contour's docker network (this bypasses the host→gateway hairpin):
```sh
# from the repo root
docker build -f loadtest/Dockerfile -t scrabble-loadtest .
# from the repo root (DICT_VERSION has no default — pass the scrabble-dictionary release tag)
docker build --build-arg DICT_VERSION=v1.3.0 -f loadtest/Dockerfile -t scrabble-loadtest .
docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
+16 -5
View File
@@ -99,24 +99,33 @@ table MoveRecord {
// --- auth (unauthenticated) ---
// TelegramLoginRequest carries the platform launch data; the gateway validates
// its HMAC before forwarding the extracted identity to the backend.
// its HMAC before forwarding the extracted identity to the backend. browser_tz is
// the client's detected UTC offset ("±HH:MM"), seeded into a brand-new account's
// time zone so the robot's sleep window and the turn-timeout away window are
// anchored to the player's real zone from first contact (first contact only).
table TelegramLoginRequest {
init_data:string;
browser_tz:string;
}
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
// preferred-language hint.
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
// guest account's time zone (see TelegramLoginRequest.browser_tz).
table GuestLoginRequest {
locale:string;
browser_tz:string;
}
// EmailRequestRequest asks the backend to send a login confirm-code to email.
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
// also provisions the account on first contact, so browser_tz (the detected UTC
// offset) is seeded into its time zone here, not at the later login step.
table EmailRequestRequest {
email:string;
browser_tz:string;
}
// EmailLoginRequest logs in (or provisions) the account owning email, verifying
// the confirm-code.
// EmailLoginRequest logs in to the account owning email (provisioned at the
// request step), verifying the confirm-code.
table EmailLoginRequest {
email:string;
code:string;
@@ -383,6 +392,8 @@ table FeedbackSubmitRequest {
attachment:[ubyte];
attachment_name:string;
channel:string;
version:string;
browser_tz:string;
}
// FeedbackReply is the operator's answer shown back to the player.
+12 -1
View File
@@ -49,12 +49,23 @@ func (rcv *EmailRequestRequest) Email() []byte {
return nil
}
func (rcv *EmailRequestRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
builder.StartObject(2)
}
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
}
func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+23 -1
View File
@@ -99,8 +99,24 @@ func (rcv *FeedbackSubmitRequest) Channel() []byte {
return nil
}
func (rcv *FeedbackSubmitRequest) Version() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FeedbackSubmitRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(4)
builder.StartObject(6)
}
func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0)
@@ -117,6 +133,12 @@ func FeedbackSubmitRequestAddAttachmentName(builder *flatbuffers.Builder, attach
func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0)
}
func FeedbackSubmitRequestAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(version), 0)
}
func FeedbackSubmitRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(browserTz), 0)
}
func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+12 -1
View File
@@ -49,12 +49,23 @@ func (rcv *GuestLoginRequest) Locale() []byte {
return nil
}
func (rcv *GuestLoginRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func GuestLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
builder.StartObject(2)
}
func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0)
}
func GuestLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+12 -1
View File
@@ -49,12 +49,23 @@ func (rcv *TelegramLoginRequest) InitData() []byte {
return nil
}
func (rcv *TelegramLoginRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func TelegramLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
builder.StartObject(2)
}
func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0)
}
func TelegramLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+11 -4
View File
@@ -38,10 +38,17 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
operator-chosen for broadcasts) with a Mini App launch button and sends it. It replies
with an `Ack` per command (`delivered` mirrors the former connector semantics —
false when the kind is not rendered out-of-app or the user never started the bot).
- **Bot chat.** `/start <payload>` (and the chat menu button) reply with a Mini App
launch button; a deep-link payload routes the launch to a game / invitation / friend
code. This is **self-contained** the bot never calls back into the game, so `/start`
onboarding works even when the game is down.
- **Bot chat.** `/start <payload>` (and the chat menu button) reply with a localized
welcome and a Mini App launch button; a deep-link payload routes the launch to a game /
invitation / friend code. The welcome is **Russian or English** by the sender's reported
Telegram language (`Message.from.language_code`, which the Bot API carries on the message
itself — no separate user-update event — English fallback) and links the game channel and
discussion chat by their public `@username`, **resolved once at startup** from
`TELEGRAM_GAME_CHANNEL_ID` / `TELEGRAM_CHAT_ID` via `getChat` (a chat that is unset,
private, or unreadable degrades that link to a generic noun — "the channel" / "our
chat" — rather than a dangling "@"). This is otherwise **self-contained**
— the bot never calls back into the game, so `/start` onboarding works even when the game
is down.
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
group, the bot gates who may write there. The group **allows sending by default** (a
human setting) and the bot only **restricts** — Telegram intersects the chat default with
+1
View File
@@ -72,6 +72,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
MiniAppURL: cfg.MiniAppURL,
SendRatePerSecond: cfg.SendRatePerSecond,
ChatID: cfg.ChatID,
GameChannelID: cfg.GameChannelID,
}, logger)
if err != nil {
return err
+57 -4
View File
@@ -33,8 +33,12 @@ type Config struct {
SendRatePerSecond int
// ChatID is the moderated discussion chat the bot gates write access in; 0
// disables chat gating (and the chat_member long-poll subscription). Gating needs
// the bot to be an administrator there with the restrict-members right.
// the bot to be an administrator there with the restrict-members right. Its public
// @username is also resolved at startup for the /start welcome's discussion link.
ChatID int64
// GameChannelID is the game channel whose public @username the /start welcome links
// to (resolved from this id via getChat at startup); 0 omits that follow link.
GameChannelID int64
}
// EligibilityResolver answers whether the Telegram user identified by externalID
@@ -54,6 +58,13 @@ type Bot struct {
limiter *rate.Limiter
// chatID is the moderated discussion chat (0 disables gating).
chatID int64
// channelID is the game channel (0 omits its welcome follow link).
channelID int64
// channelUsername and chatUsername are the public @usernames (without the leading
// @) of the game channel and the discussion chat, resolved once at startup
// (resolveWelcomeHandles) for the /start welcome's follow links; "" when unresolved.
channelUsername string
chatUsername string
// botID is the bot's own Telegram user id (resolved at startup); it skips the
// chat_member updates the bot's own restrict actions generate — the grant loop guard.
botID int64
@@ -69,7 +80,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil {
log = zap.NewNop()
}
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID}
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID, channelID: cfg.GameChannelID}
if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
}
@@ -123,9 +134,43 @@ func (t *Bot) Run(ctx context.Context) {
if t.chatID != 0 {
t.logChatAdminStatus(ctx)
}
t.resolveWelcomeHandles(ctx)
t.api.Start(ctx)
}
// resolveWelcomeHandles resolves, once at startup, the public @usernames of the game
// channel and the discussion chat from their configured ids (getChat), caching them for
// the /start welcome's follow links. It runs before the update loop, so the handles are
// set before any /start is handled; a chat that is unset, private (no public username)
// or unreadable simply leaves its handle empty and the welcome omits that follow link.
func (t *Bot) resolveWelcomeHandles(ctx context.Context) {
t.channelUsername = t.resolveUsername(ctx, t.channelID, "game channel")
t.chatUsername = t.resolveUsername(ctx, t.chatID, "discussion chat")
}
// resolveUsername returns the public @username (without the leading @) of the chat with
// the given id, or "" when id is 0, the chat has no public username, or getChat fails —
// logging the reason, since a missing handle silently drops a welcome follow link.
func (t *Bot) resolveUsername(ctx context.Context, id int64, label string) string {
if id == 0 {
return ""
}
chat, err := t.api.GetChat(ctx, &tgbot.GetChatParams{ChatID: id})
if err != nil {
t.log.Warn("welcome: getChat failed; follow link omitted",
zap.String("chat", label), zap.Int64("id", id), zap.Error(err))
return ""
}
if chat.Username == "" {
t.log.Warn("welcome: chat has no public @username; follow link omitted",
zap.String("chat", label), zap.Int64("id", id))
return ""
}
t.log.Info("welcome: resolved follow link",
zap.String("chat", label), zap.String("username", chat.Username))
return chat.Username
}
// logChatAdminStatus checks, at startup, whether the bot can actually gate the
// moderated chat — it must be an administrator there with the restrict-members
// ("Ban users") right, or Telegram delivers no chat_member updates and restricts
@@ -198,11 +243,19 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
if update.Message.Chat.Type != models.ChatTypePrivate {
return
}
// The sender's Telegram language rides on the message itself (Message.from.language_code
// in the Bot API — there is no separate user-update event); fall back to English when it
// is absent.
lang := ""
if update.Message.From != nil {
lang = update.Message.From.LanguageCode
}
text, button := startText(lang, t.channelUsername, t.chatUsername)
startParam := startPayload(update.Message.Text)
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID,
Text: "Tap to open Scrabble.",
ReplyMarkup: t.launchMarkup("Open Scrabble", startParam),
Text: text,
ReplyMarkup: t.launchMarkup(button, startParam),
}); err != nil {
t.log.Warn("reply to start failed", zap.Error(err))
}
+54 -1
View File
@@ -29,6 +29,10 @@ func (f *fakeBotAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.text = r.FormValue("text")
f.replyMarkup = r.FormValue("reply_markup")
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
case strings.HasSuffix(r.URL.Path, "/getChat"):
// Echo the requested id into the username so a resolver test can tell the
// channel lookup from the chat lookup.
io.WriteString(w, `{"ok":true,"result":{"id":-100,"type":"channel","username":"u`+r.FormValue("chat_id")+`"}}`)
default:
io.WriteString(w, `{"ok":true,"result":true}`)
}
@@ -105,7 +109,7 @@ func TestTestEnvironmentRoutesGetMe(t *testing.T) {
}
func TestHandleStartRepliesPrivateOnly(t *testing.T) {
t.Run("private replies", func(t *testing.T) {
t.Run("private replies in english by default", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{
@@ -114,6 +118,35 @@ func TestHandleStartRepliesPrivateOnly(t *testing.T) {
if api.chatID != "42" || !strings.Contains(api.replyMarkup, "web_app") {
t.Errorf("private /start: chat=%q markup=%q, want a web_app reply", api.chatID, api.replyMarkup)
}
// No reported language -> English welcome + English button.
if !strings.Contains(api.text, "Hi!") {
t.Errorf("text = %q, want the English welcome", api.text)
}
if !strings.Contains(api.replyMarkup, "Open") {
t.Errorf("reply_markup = %q, want the English button", api.replyMarkup)
}
})
t.Run("uses the sender's reported language", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/start",
From: &models.User{ID: 7, LanguageCode: "ru"},
}})
if !strings.Contains(api.text, "Привет!") {
t.Errorf("text = %q, want the Russian welcome for a ru sender", api.text)
}
})
t.Run("embeds resolved follow handles", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.channelUsername, b.chatUsername = "erudit", "erudite_chat"
b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/start",
}})
if !strings.Contains(api.text, "@erudit") || !strings.Contains(api.text, "@erudite_chat") {
t.Errorf("text = %q, want the follow handles", api.text)
}
})
t.Run("group ignored", func(t *testing.T) {
api := &fakeBotAPI{}
@@ -127,6 +160,26 @@ func TestHandleStartRepliesPrivateOnly(t *testing.T) {
})
}
func TestResolveWelcomeHandles(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.channelID, b.chatID = 111, 222
b.resolveWelcomeHandles(context.Background())
// The fake echoes the requested id into the username, so each lookup is independent.
if b.channelUsername != "u111" {
t.Errorf("channelUsername = %q, want u111", b.channelUsername)
}
if b.chatUsername != "u222" {
t.Errorf("chatUsername = %q, want u222", b.chatUsername)
}
// An unset id resolves to no handle (and makes no getChat call).
b.channelID = 0
b.resolveWelcomeHandles(context.Background())
if b.channelUsername != "" {
t.Errorf("channelUsername = %q, want empty for id 0", b.channelUsername)
}
}
func TestStartPayload(t *testing.T) {
cases := map[string]string{
"/start g123": "g123",
+60
View File
@@ -0,0 +1,60 @@
package bot
import "strings"
// startText returns the localized /start welcome body and the launch-button label.
// Russian is used when lang (the IETF language tag the Telegram client reports on the
// message's sender) starts with "ru", English otherwise and when it is absent — so a
// user with no reported language still gets a sensible message. channel and chat are
// the resolved public @usernames (without the leading @) of the game channel and the
// discussion chat; when either is empty its follow link degrades to a generic noun
// (e.g. "the channel" / "our chat") rather than rendering a dangling "@", since the
// bot's own info screen still lists the real links.
func startText(lang, channel, chat string) (text, button string) {
if strings.HasPrefix(strings.ToLower(lang), "ru") {
return ruWelcome(channel, chat), "Открыть «Эрудит»"
}
return enWelcome(channel, chat), "Open “Erudite”"
}
// ruWelcome builds the Russian welcome. A known handle is named as "@<username>"; an
// unresolved one degrades to a plain noun.
func ruWelcome(channel, chat string) string {
ch := "канал"
if channel != "" {
ch = "@" + channel
}
ct := "чате"
if chat != "" {
ct = "@" + chat
}
return strings.Join([]string{
"Привет! 👋",
"Здесь можно сражаться в «Эрудит» со случайными игроками или в компании друзей.",
"Подписывайтесь на " + ch + ", чтобы быть в курсе последних игровых событий и вовремя " +
"получать важные уведомления. Игроки могут обсуждать игру и просто общаться в нашем " +
ct + "! 💬",
"Ни слова больше.\nПервая партия сама себя не сыграет 😊",
}, "\n\n")
}
// enWelcome builds the English welcome (the fallback for any non-Russian or missing
// language). A known handle is named as "@<username>"; an unresolved one degrades to a
// plain noun.
func enWelcome(channel, chat string) string {
ch := "the channel"
if channel != "" {
ch = "@" + channel
}
ct := "group chat"
if chat != "" {
ct = "@" + chat
}
return strings.Join([]string{
"Hi! 👋",
"Play Scrabble against random players — or with a group of friends.",
"Follow " + ch + " to stay up to date with the latest game events and receive important " +
"notifications in time. Players can discuss the game and simply chat in our " + ct + "! 💬",
"Okay, no more talking.\nFirst game won't play itself 😊",
}, "\n\n")
}
@@ -0,0 +1,73 @@
package bot
import (
"strings"
"testing"
)
func TestStartTextLocalizesByLanguage(t *testing.T) {
cases := []struct {
name string
lang string
wantButton string
wantSubstr string // a phrase unique to the chosen language body
}{
{"russian", "ru", "Открыть «Эрудит»", "Привет!"},
{"russian region tag", "ru-RU", "Открыть «Эрудит»", "Первая партия"},
{"english", "en", "Open “Erudite”", "Hi!"},
{"other language falls back to english", "de", "Open “Erudite”", "Hi!"},
{"absent language falls back to english", "", "Open “Erudite”", "no more talking"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
text, button := startText(tc.lang, "erudit", "erudite_chat")
if button != tc.wantButton {
t.Errorf("button = %q, want %q", button, tc.wantButton)
}
if !strings.Contains(text, tc.wantSubstr) {
t.Errorf("text %q does not contain %q", text, tc.wantSubstr)
}
})
}
}
func TestStartTextEmbedsFollowHandles(t *testing.T) {
for _, lang := range []string{"ru", "en"} {
text, _ := startText(lang, "erudit", "erudite_chat")
if !strings.Contains(text, "@erudit") || !strings.Contains(text, "@erudite_chat") {
t.Errorf("lang %q: follow paragraph missing the handles: %q", lang, text)
}
}
}
func TestStartTextFallsBackToGenericWhenHandleMissing(t *testing.T) {
// An unresolved handle degrades to a generic noun rather than a dangling "@" — and
// only that slot degrades; a resolved sibling still shows its "@username".
t.Run("both missing leaves no @", func(t *testing.T) {
for _, lang := range []string{"ru", "en"} {
text, _ := startText(lang, "", "")
if strings.Contains(text, "@") {
t.Errorf("lang %q: text shows a dangling @: %q", lang, text)
}
}
// The generic nouns are present in each language.
ru, _ := startText("ru", "", "")
if !strings.Contains(ru, "на канал") || !strings.Contains(ru, "в нашем чате") {
t.Errorf("russian generic fallback missing: %q", ru)
}
en, _ := startText("en", "", "")
if !strings.Contains(en, "Follow the channel") || !strings.Contains(en, "in our group chat") {
t.Errorf("english generic fallback missing: %q", en)
}
})
t.Run("only the missing slot degrades", func(t *testing.T) {
// Channel resolved, chat missing: the channel keeps its @handle, the chat is generic.
en, _ := startText("en", "erudit", "")
if !strings.Contains(en, "@erudit") || strings.Contains(en, "@erudite") {
t.Errorf("channel handle not shown / chat handle leaked: %q", en)
}
if !strings.Contains(en, "in our group chat") {
t.Errorf("chat slot did not degrade to a generic noun: %q", en)
}
})
}
+18 -6
View File
@@ -1,12 +1,14 @@
<script lang="ts">
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
// shows its letter but no value, exactly as on the board. Letters are upper-cased for
// display. The tile values ride on each tile, so this renders without the variant's
// alphabet table (which the statistics screen has not cached).
import type { BestMoveTile } from '../lib/model';
// shows its letter but no value, exactly as on the board; in Erudit it also carries the
// blank's star (✻) in the value corner. Letters are upper-cased for display. The tile
// values ride on each tile, so this needs only the variant id (for the star) — not the
// variant's alphabet table, which the statistics screen has not cached.
import type { BestMoveTile, Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
let { word }: { word: BestMoveTile[] } = $props();
let { word, variant }: { word: BestMoveTile[]; variant: Variant } = $props();
const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
</script>
@@ -15,7 +17,11 @@
{#each word as tile, i (i)}
<span class="tile" class:blank={tile.blank} aria-hidden="true">
<span class="letter">{tile.letter.toUpperCase()}</span>
{#if !tile.blank}<span class="val">{tile.value}</span>{/if}
{#if !tile.blank}
<span class="val">{tile.value}</span>
{:else if usesStarBlank(variant)}
<span class="val blankmark">{BLANK_STAR}</span>
{/if}
</span>
{/each}
</span>
@@ -50,4 +56,10 @@
font-size: 7px;
font-weight: 600;
}
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink kept on the value digits' line (mirrors the board tile). */
.blankmark {
font-size: 8px;
bottom: 0;
}
</style>
+12 -1
View File
@@ -4,6 +4,7 @@
import type { Premium } from '../lib/premiums';
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog';
@@ -254,7 +255,11 @@
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
{#if !blank}
<span class="val">{valueForLetter(variant, letter)}</span>
{:else if usesStarBlank(variant)}
<span class="val blankmark">{BLANK_STAR}</span>
{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if bl?.kind === 'single'}
@@ -410,6 +415,12 @@
font-size: 2.4cqw;
font-weight: 600;
}
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink centred on the same line as a neighbouring tile's value digit. */
.blankmark {
font-size: 2.8cqw;
bottom: 0;
}
.star {
position: absolute;
inset: 0;
+2 -2
View File
@@ -17,7 +17,7 @@
import { badgeKind } from '../lib/unread';
import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints';
import { shareOrDownloadGcg } from '../lib/share';
@@ -1293,7 +1293,7 @@
{#if drag}
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? '' : drag.letter}</span>
<span>{drag.blank ? (usesStarBlank(variant) ? BLANK_STAR : '') : drag.letter}</span>
</div>
{/if}
+18 -2
View File
@@ -3,6 +3,7 @@
import { BLANK } from '../lib/placement';
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
let {
slots,
@@ -66,8 +67,12 @@
animate:hop={shuffling}
onpointerdown={(e) => ondown(e, slot.index)}
>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if}
{#if slot.letter === BLANK}
{#if usesStarBlank(variant)}<span class="star">{BLANK_STAR}</span>{/if}
{:else}
<span class="letter">{slot.letter}</span>
<span class="val">{valueForLetter(variant, slot.letter)}</span>
{/if}
</button>
{/each}
</div>
@@ -133,4 +138,15 @@
font-size: 0.7rem;
font-weight: 600;
}
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
tile face; the top offset centres its ink against the neighbouring letters' block, nudged
up a pixel to sit right by eye (it is slightly larger than them so it reads). */
.star {
position: absolute;
top: calc(0.5% - 1px);
left: 0;
right: 0;
text-align: center;
font-size: 1.7rem;
}
</style>
@@ -27,22 +27,34 @@ email(optionalEncoding?:any):string|Uint8Array|null {
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;
}
static startEmailRequestRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
builder.startObject(2);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, emailOffset, 0);
}
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, browserTzOffset, 0);
}
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset {
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailRequestRequest.startEmailRequestRequest(builder);
EmailRequestRequest.addEmail(builder, emailOffset);
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
return EmailRequestRequest.endEmailRequestRequest(builder);
}
}
@@ -56,8 +56,22 @@ channel(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
version():string|null
version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
version(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 12);
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, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startFeedbackSubmitRequest(builder:flatbuffers.Builder) {
builder.startObject(4);
builder.startObject(6);
}
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
@@ -88,17 +102,27 @@ static addChannel(builder:flatbuffers.Builder, channelOffset:flatbuffers.Offset)
builder.addFieldOffset(3, channelOffset, 0);
}
static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) {
builder.addFieldOffset(4, versionOffset, 0);
}
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, browserTzOffset, 0);
}
static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset):flatbuffers.Offset {
static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
FeedbackSubmitRequest.startFeedbackSubmitRequest(builder);
FeedbackSubmitRequest.addBody(builder, bodyOffset);
FeedbackSubmitRequest.addAttachment(builder, attachmentOffset);
FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset);
FeedbackSubmitRequest.addChannel(builder, channelOffset);
FeedbackSubmitRequest.addVersion(builder, versionOffset);
FeedbackSubmitRequest.addBrowserTz(builder, browserTzOffset);
return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder);
}
}
@@ -27,22 +27,34 @@ locale(optionalEncoding?:any):string|Uint8Array|null {
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;
}
static startGuestLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
builder.startObject(2);
}
static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, localeOffset, 0);
}
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, browserTzOffset, 0);
}
static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset):flatbuffers.Offset {
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
GuestLoginRequest.startGuestLoginRequest(builder);
GuestLoginRequest.addLocale(builder, localeOffset);
GuestLoginRequest.addBrowserTz(builder, browserTzOffset);
return GuestLoginRequest.endGuestLoginRequest(builder);
}
}
@@ -27,22 +27,34 @@ initData(optionalEncoding?:any):string|Uint8Array|null {
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;
}
static startTelegramLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
builder.startObject(2);
}
static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, initDataOffset, 0);
}
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, browserTzOffset, 0);
}
static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset):flatbuffers.Offset {
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
TelegramLoginRequest.startTelegramLoginRequest(builder);
TelegramLoginRequest.addInitData(builder, initDataOffset);
TelegramLoginRequest.addBrowserTz(builder, browserTzOffset);
return TelegramLoginRequest.endTelegramLoginRequest(builder);
}
}
+20 -10
View File
@@ -8,6 +8,7 @@ import { gateway } from './gateway';
import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { languageNeedsServerSync } from './language';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import {
insideTelegram,
@@ -67,7 +68,6 @@ export const app = $state<{
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
localeLocked: boolean;
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
notifications: number;
/** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
@@ -108,7 +108,6 @@ export const app = $state<{
locale: 'en',
reduceMotion: false,
boardLabels: 'beginner',
localeLocked: false,
notifications: 0,
chatUnread: {},
messageUnread: {},
@@ -450,11 +449,20 @@ async function adoptSession(s: Session): Promise<void> {
await saveSession(s);
try {
app.profile = await gateway.profileGet();
// The live interface language follows the device — the explicit local choice (locked, saved
// in prefs) or the system guess made at bootstrap — and is no longer overridden from the
// account here. preferred_language stays the user's saved choice (written from Settings,
// and used for out-of-app push routing), but the Telegram bot a user signs in through must
// not dictate the UI: a ru-bot launch on an English system stays English.
// The live interface language follows the device — the explicit local choice (saved in
// prefs) or the system guess made at bootstrap — and is no longer overridden from the
// account here: the Telegram bot a user signs in through must not dictate the UI, so a
// ru-bot launch on an English system stays English.
//
// The banner and out-of-app push are resolved server-side from preferred_language, so it
// must track whatever language the UI actually shows — the explicit choice AND the system
// guess. Reconcile it to the active locale on every adopt, not only after an explicit
// Settings choice: a user who never opened Settings would otherwise be stuck on the
// creation-time seed — e.g. an English banner under a Russian UI. This keeps every
// server-rendered, language-dependent surface (banner, out-of-app push) aligned with the
// interface, not just one. persistLanguageToServer self-gates (a no-op for guests and when
// already equal), so there is no write in the steady state.
void persistLanguageToServer(app.locale);
} catch (err) {
handleError(err);
}
@@ -475,6 +483,10 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
return;
}
app.profile = await gateway.profileGet();
// A guest who linked in place now has a durable account: push the active interface language
// so the banner + push routing follow it (see adoptSession — reconciled regardless of an
// explicit Settings choice).
void persistLanguageToServer(app.locale);
}
/**
@@ -527,7 +539,6 @@ export async function bootstrap(): Promise<void> {
applyReduceMotion(app.reduceMotion);
if (prefs.locale) {
app.locale = prefs.locale;
app.localeLocked = true;
setLocale(prefs.locale);
} else {
const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en');
@@ -743,7 +754,6 @@ export function setTheme(theme: ThemePref): void {
export function setLocalePref(locale: Locale): void {
app.locale = locale;
app.localeLocked = true;
setLocale(locale);
persistPrefs();
void persistLanguageToServer(locale);
@@ -756,7 +766,7 @@ export function setLocalePref(locale: Locale): void {
*/
async function persistLanguageToServer(locale: Locale): Promise<void> {
const p = app.profile;
if (!p || p.isGuest || p.preferredLanguage === locale) return;
if (!p || !languageNeedsServerSync(p, locale)) return;
try {
app.profile = await gateway.profileUpdate({
displayName: p.displayName,
+29 -2
View File
@@ -19,13 +19,16 @@ import {
decodeStateView,
decodeStats,
encodeCheckWord,
encodeEmailRequest,
encodeFeedbackSubmit,
encodeDraftSave,
encodeEnqueue,
encodeExchange,
encodeGuestLogin,
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
encodeTelegramLogin,
encodeUpdateProfile,
} from './codec';
@@ -73,21 +76,45 @@ describe('codec', () => {
});
});
it('carries the detected browser zone on every account-creating auth request', () => {
const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest(
new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00')),
);
expect(tg.initData()).toBe('init-data-blob');
expect(tg.browserTz()).toBe('+03:00');
const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
new ByteBuffer(encodeGuestLogin('ru', '-05:30')),
);
expect(guest.locale()).toBe('ru');
expect(guest.browserTz()).toBe('-05:30');
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00')),
);
expect(email.email()).toBe('a@example.com');
expect(email.browserTz()).toBe('+00:00');
});
it('round-trips a feedback submit and decodes state + unread', () => {
const att = new Uint8Array([1, 2, 3, 4]);
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')),
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios', 'v1.2.3', '+03:00')),
);
expect(req.body()).toBe('please fix');
expect(req.attachmentName()).toBe('shot.png');
expect(req.channel()).toBe('ios');
expect(req.version()).toBe('v1.2.3');
expect(req.browserTz()).toBe('+03:00');
expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);
// No attachment: the vector is empty.
const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')),
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web', 'dev', '+00:00')),
);
expect(req2.body()).toBe('hi');
expect(req2.version()).toBe('dev');
expect(req2.browserTz()).toBe('+00:00');
expect(req2.attachmentLength()).toBe(0);
// State carrying a reply.
+15 -3
View File
@@ -179,27 +179,33 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array {
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
}
export function encodeTelegramLogin(initData: string): Uint8Array {
export function encodeTelegramLogin(initData: string, browserTz: string): Uint8Array {
const b = new Builder(512);
const d = b.createString(initData);
const tz = b.createString(browserTz);
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
fb.TelegramLoginRequest.addInitData(b, d);
fb.TelegramLoginRequest.addBrowserTz(b, tz);
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
}
export function encodeGuestLogin(locale: string): Uint8Array {
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
const b = new Builder(64);
const l = b.createString(locale);
const tz = b.createString(browserTz);
fb.GuestLoginRequest.startGuestLoginRequest(b);
fb.GuestLoginRequest.addLocale(b, l);
fb.GuestLoginRequest.addBrowserTz(b, tz);
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}
export function encodeEmailRequest(email: string): Uint8Array {
export function encodeEmailRequest(email: string, browserTz: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const tz = b.createString(browserTz);
fb.EmailRequestRequest.startEmailRequestRequest(b);
fb.EmailRequestRequest.addEmail(b, e);
fb.EmailRequestRequest.addBrowserTz(b, tz);
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}
@@ -489,17 +495,23 @@ export function encodeFeedbackSubmit(
attachment: Uint8Array | null,
attachmentName: string,
channel: string,
version: string,
browserTz: string,
): Uint8Array {
const b = new Builder(256);
const bodyOff = b.createString(body);
const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
const nameOff = b.createString(attachmentName);
const chOff = b.createString(channel);
const verOff = b.createString(version);
const tzOff = b.createString(browserTz);
fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
fb.FeedbackSubmitRequest.addBody(b, bodyOff);
if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
fb.FeedbackSubmitRequest.addChannel(b, chOff);
fb.FeedbackSubmitRequest.addVersion(b, verOff);
fb.FeedbackSubmitRequest.addBrowserTz(b, tzOff);
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { languageNeedsServerSync } from './language';
import type { Profile } from './model';
// The reconciler only reads isGuest + preferredLanguage; a partial cast keeps the fixture small.
const profile = (over: Partial<Profile>): Profile => ({ isGuest: false, preferredLanguage: 'en', ...over }) as Profile;
describe('languageNeedsServerSync', () => {
it('is false without a profile', () => {
expect(languageNeedsServerSync(null, 'ru')).toBe(false);
expect(languageNeedsServerSync(undefined, 'ru')).toBe(false);
});
it('is false for a guest — guests keep only the client preference', () => {
expect(languageNeedsServerSync(profile({ isGuest: true, preferredLanguage: 'en' }), 'ru')).toBe(false);
});
it('is false when the account already matches the locale', () => {
expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'ru')).toBe(false);
});
it('is true for a real account whose stored language differs (banner + push follow it)', () => {
expect(languageNeedsServerSync(profile({ preferredLanguage: 'en' }), 'ru')).toBe(true);
expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'en')).toBe(true);
});
});
+22
View File
@@ -0,0 +1,22 @@
// Interface-language reconciliation. Kept out of app.svelte.ts (a runes module that the
// node-env Vitest layer cannot import) so the decision is unit-testable.
import type { Locale } from './i18n/catalog';
import type { Profile } from './model';
/**
* languageNeedsServerSync reports whether the durable account's `preferred_language` should be
* rewritten to the chosen interface `locale`. It is true only for a real (non-guest) account
* whose stored language differs from the locale; guests keep only the client-side preference,
* and an already-matching account is a no-op.
*
* The UI language follows the device (the local choice / system guess), but the advertising
* banner and out-of-app push routing are resolved server-side from `preferred_language`. A saved
* device choice the account has not yet recorded picked while a guest, or differing from the
* Telegram system-language seed would otherwise leave the banner and pushes in the wrong
* language until the next Settings change. Both the Settings control and the on-load reconciler
* gate their write on this.
*/
export function languageNeedsServerSync(profile: Profile | null | undefined, locale: Locale): boolean {
return !!profile && !profile.isGuest && profile.preferredLanguage !== locale;
}
+8 -4
View File
@@ -10,6 +10,7 @@ import { createConnectTransport } from '@connectrpc/connect-web';
import { Gateway } from '../gen/edge/v1/edge_pb';
import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec';
import { browserOffset } from './profileValidation';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
@@ -62,13 +63,13 @@ export function createTransport(baseUrl: string): GatewayClient {
},
async authTelegram(initData) {
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData)));
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
},
async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '')));
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
},
async authEmailRequest(email) {
await exec('auth.email.request', codec.encodeEmailRequest(email));
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset()));
},
async authEmailLogin(email, code) {
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
@@ -147,7 +148,10 @@ export function createTransport(baseUrl: string): GatewayClient {
await exec('chat.read', codec.encodeGameAction(id));
},
async feedbackSubmit(body, attachment, attachmentName, channel) {
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel));
// The app build (Vite define) and the device's detected UTC offset ride with the report
// so the operator sees which version it came from and the local time it was filed; the
// caller need not pass them.
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel, __APP_VERSION__, browserOffset()));
},
async feedbackGet() {
return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));
+15
View File
@@ -5,6 +5,8 @@ import {
availableVariants,
supportsMultipleWordsToggle,
multipleWordsForRequest,
usesStarBlank,
BLANK_STAR,
} from './variants';
describe('ALL_VARIANTS', () => {
@@ -53,3 +55,16 @@ describe('multipleWordsForRequest', () => {
expect(multipleWordsForRequest('scrabble_en', true)).toBe(true);
});
});
describe('usesStarBlank', () => {
it('marks the blank with a star for Erudit only', () => {
expect(usesStarBlank('erudit_ru')).toBe(true);
expect(usesStarBlank('scrabble_ru')).toBe(false);
expect(usesStarBlank('scrabble_en')).toBe(false);
});
it('BLANK_STAR is the heavy teardrop-spoked asterisk (U+273B)', () => {
expect(BLANK_STAR).toBe('✻');
expect(BLANK_STAR.codePointAt(0)).toBe(0x273b);
});
});
+14
View File
@@ -48,6 +48,20 @@ export const VARIANT_FLAG: Record<Variant, string> = {
// ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
// BLANK_STAR is the glyph drawn on an Эрудит blank tile: the variant's blank is the
// "звёздочка" (star) chip, so it carries a star rather than a bare face. U+273B HEAVY
// TEARDROP-SPOKED ASTERISK.
export const BLANK_STAR = '✻';
// usesStarBlank reports whether a variant marks its blank tiles with BLANK_STAR. Only
// Эрудит does: an empty rack blank shows the star centred, and a placed blank carries it
// in the value corner (the corner is free — a blank has no point value). The Scrabble
// variants leave the blank unmarked (an empty rack face; a placed blank shown by its
// designated letter alone).
export function usesStarBlank(v: Variant): boolean {
return v === 'erudit_ru';
}
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
// — a safety fallback; a real profile always carries at least one preference.
+1 -1
View File
@@ -73,7 +73,7 @@
{#each bestMoves as bm (bm.variant)}
<span class="variant">{t(variantNameKey(bm.variant))}</span>
<span class="score">{bm.score}</span>
<span class="wordcell"><WordTiles word={bm.word} /></span>
<span class="wordcell"><WordTiles word={bm.word} variant={bm.variant} /></span>
{/each}
</div>
</div>