Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.
- backend: a directional blockExists guard across chat/nudge/friends/invitations
(store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
the matchmaker excludes a block-related pair (both directions) from auto-match;
user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
(red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
The statistics screen gains real depth, plus a hint-count bug fix found along the way.
- Best move per variant: the screen shows the actual best-move word (drawn as game
tiles; a wildcard shows its letter but no value), broken down by game variant, empty
variants omitted. New account_best_move table, written at game finish.
- Moves & hint share: two new lifetime tiles — the player's play count and the share of
plays that used a hint — from summed account_stats counters (moves, hints_used).
Honest-AI games are excluded, like the rest of the stats.
- Hint-count fix: the in-game hint badge no longer goes stale across games. The global
wallet now rides the wire apart from the per-game allowance (wallet_balance on
StateView/HintResult/StatsView), so the client reads the live wallet rather than a
per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet),
its true per-game total.
- Account merge: sums the new moves/hints_used counters and merges the per-variant best
moves (higher score kept), which it previously dropped.
- Admin: the user card shows Moves and Hints used.
- UI polish: tab/label wording, game-over text, and e2e selectors hardened against label
changes.
All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.
Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).
UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
Admin campaign editor polish:
- move the link-formatting help aside to the top of the Messages section,
beside the intro note (~40% width), so it no longer drops below and stretches
the form fields.
- make the English/Russian message fields 3-row, vertically resizable textareas
(was single-line inputs) so long text wraps instead of scrolling off to the
right. The strip is white-space:nowrap, so a stray newline collapses to a space
on display.
On the campaign detail page, beside the "Add message" form, a static aside
explains the message markdown: plain text is escaped, `[text](url)` becomes a
link, and only http(s)/root-relative targets are linkified (others show as
plain text). New .form-help (flex row) + .help (muted aside) console styles;
wraps below the form on a narrow viewport.
Turn the gated-off mock banner into a real advertising subsystem (backend +
admin half; the UI rotation lands in PR2).
- internal/ads: campaigns (percent weight + validity window; a perpetual,
undeletable default that fills the remainder up to 100%), 1..N bilingual
messages (en+ru), global display timings; ActiveSet computes the
window-filtered, default-remainder, GCD-reduced, language-resolved rotation
feed. Smooth-weighted-round-robin math is unit-tested.
- migration 00006 (+ jetgen): ad_campaigns / ad_messages / ad_settings, seeded
default campaign + house message + default timings.
- eligibility = !paid_account && hint_balance==0 && !no_banner role (new role;
guests qualify). The resolved feed rides the profile.get response (no new RPC,
works for guests, nothing distinct to filter); language by service_language.
- live update: a notify `banner` sub-kind (re-poll signal) published when an
operator grants hints or grants/revokes no_banner, so the client shows/hides
in place.
- admin console /_gm/banners (+ /_gm/banner-settings): campaign + message CRUD
with reorder, default protection, clamped timings.
- wire: fbs BannerInfo/BannerCampaign on Profile; gateway transcode forwards it.
- docs: ARCHITECTURE §10, FUNCTIONAL (+ _ru), backend README, PRERELEASE tracker
(incl. the deferred app.load aggregator note).
Follow-ups on the honest-AI game, same PR:
- GCG export labels the robot seat "AI" instead of its pool name (ExportGCG
overrides via accounts.IsRobot); the in-app 🤖 is unchanged.
- vs_ai games emit no your_turn (the robot replies instantly, so it would be
redundant); opponent_moved still advances the UI.
- Admin console shows the AI flag: a 🤖 column in /games and an "AI game" line
on the game card (GameRow/GameDetailView gain VsAI).
- games_started_total / games_abandoned_total gain a vs_ai attribute; the
Grafana Game-domain dashboard splits started/abandoned into human and AI
panels.
Tests: metrics unit (vs_ai split); integration (no your_turn, GCG "AI").
In the console feedback detail, show the sender's interface language (account
preferred_language) always, and — for a message that arrived through an external
connector (currently Telegram) — the bot they last used (en/ru, from the
account's service_language).
- style the About feedback button and the form's Send/attach/remove buttons with
the accent/border tokens used by the New Game CTA, so they follow light/dark
theme (the previous .btn/.ghost classes were not defined globally); the attach
button is a neutral 📎 icon button with an aria-label
- show a round badge on the About 'Feedback' button when a reply is waiting
- admin: one 'ban from feedback' checkbox shared by Delete and Delete-all (via
button formaction); hide Mark read when already read and Archive when archived
- e2e: match the About button by substring (its name gains the badge)
Add an "Add hints" form on the admin user card that additively tops up a
player's hint wallet (1-100 per grant). The grant is raise-only by
construction (an additive UPDATE never lowers the balance) and stays correct
under a concurrent in-game spend; a per-grant cap bounds a fat-finger, since
the console can never reduce a wallet.
The in-game hint policy is unchanged and already correct: a game offers the
per-seat allowance plus the wallet, spending the allowance first and the
wallet only after (covered by TestHintPolicy).
Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint.
Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
Replace the dictionary hot-reload with an online update flow in the GM console
(/_gm/dictionary): the operator uploads scrabble-dawg-vX.Y.Z.tar.gz, previews the
per-variant words added/removed against the active dictionary, and confirms to
install + activate it. Versions are immutable; in-progress games keep their pinned
version while new games use the new one.
- engine: DiffWords (enumerate both DAWGs, decode only the differences), OpenFinder,
Registry.Finder, DictFiles; OpenWithVersions skips the .staging area.
- dictadmin: hardened release-archive validation + extraction (path-traversal,
symlink, oversize, entry-count rejection) and staging -> install (atomic rename).
- game: active dictionary version persisted in the dictionary_state singleton
(single source of truth, restored on boot), concurrency-safe accessor.
- storage: BACKEND_DICT_DIR is a named volume seeded from the image (nonroot-owned),
so uploaded versions persist across redeploys; the build's DICT_VERSION labels the
seed and equals the resident tag (BACKEND_DICT_VERSION).
- docs: ARCHITECTURE §5, FUNCTIONAL (+ru), backend README, TESTING, PRERELEASE.
Tests: engine + dictadmin unit; integration upload->preview->install->activate->
restart->pin->immutability->CSRF.
The games-list status filter offered only active/finished; add 'open' (auto-match games awaiting an opponent) to the subnav and accept it in normalizeGameStatus. Render test covers the new filter link.
- accounts.flagged_high_rate_at baked into the R1 baseline (no prod data; the
contour schema is wiped after merge); jet regenerated — the regen also picks
up the previously missing game_drafts/game_hidden models.
- account.Store: FlagHighRate (set-once), ClearHighRateFlag, the flag in
GetByID/ListUsers and a ListFlaggedHighRate review queue.
- New internal/ratewatch: ingests the gateway rejection reports, keeps a
bounded in-memory episode window for the console and applies the
conservative auto-flag (1000 rejected / 10 min, BACKEND_HIGHRATE_FLAG_*).
- POST /api/v1/internal/ratelimit/report (network-trusted, like
sessions/resolve).
- Admin console: Throttled page (episodes + flagged accounts), a high-rate
badge in the user list, the marker + operator clear action on the user card.
- Tests: ratewatch unit suite, report-route handler test, renderer cases,
integration coverage for the store round-trip and the console flow.
Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod
data; verified schema-identical to the chain via a pg_dump diff + the green
integration suite) and rename the game-variant labels
english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the
backend, the FlatBuffers wire values and the UI.
dawg filenames and the Go enum identifiers are unchanged; the i18n display keys
are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from
CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
A right-aligned 'Export CSV ↓' link in the filter row downloads /_gm/messages.csv
with the active filters (game / sender / name / ext masks), exporting every matching
message (capped at 100k) regardless of the page window — columns time, source,
sender_id, sender, ip, message, game_id.
A new /_gm/messages console page lists posted chat messages (nudges
excluded) newest-first — time, source (guest/robot/oldest identity kind),
sender (linked to the user card), IP, body, game (linked to the game card)
— searchable by sender name / external-id glob masks and pinnable to one
game (?game=) or sender (?user=), linked from the game and user cards.
The list query lives in social (raw SQL, kind='message', source via a SQL
CASE), reusing the now-exported account.LikePattern. Server-rendered
adminconsole MessagesView + messages.gohtml, 50/page via the shared pager.
Tests: adminconsole render case; backend integration AdminListMessages
(real Postgres) — nudge exclusion, game/sender pins, glob masks, source.
Docs: ARCHITECTURE section 8 chat moderation, PLAN round-6.
The admin game detail now shows, per robot seat, the game's deterministic play-to-win
decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move
ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the
~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports,
account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants
(never early, never in the sleep window), PlayToWin export, and an admin render integration
test asserting the intent + ETA + target appear.
- account.ListUsers/CountUsers with a UserFilter: people vs robots (by a robot identity),
case-insensitive '*'/'?' glob masks on display_name and any identity's external_id
- admin users list shows the real kind (robot/guest/registered), defaults to people,
with a People/Robots toggle + a filter form; pager preserves the filter
- integration test for the filter; SQL verified against the live contour DB
- #4 bag label: '{n} in the bag' / 'Bag is empty' (was 'Bag {n}')
- #6 allow a single trailing dot in display names (backend + UI regex + tests)
- #1 double-tap zooms toward the tapped cell, not the top-left
- #8 shuffle fires a short multi-pulse haptic
- #11 highlighted/flashing tiles darken their bottom edge too (shadow joins the flash)
- #13 toast slides up from the bottom and fades out
- #7 hide the logout button (kept wired behind `hidden`)
- #16 admin game seats: left-align numeric columns, clarify the 'Hints used' header
- Grafana: disable Live (GF_LIVE_MAX_CONNECTIONS=0) so its WebSocket no longer trips caddy Basic-Auth and re-prompts; admin console gains a Grafana nav link
- deploy: force-recreate config-only services so reseeded Grafana dashboards / Caddyfile are actually picked up (the move-duration panel was invisible because the bind-mount went stale)
- rate-limit: raise per-user budget 120/40 -> 300/80; UI skips reloading on the echo of the player's own move (fewer requests, no double-load)
- iOS/Telegram reconnect: suppress the connection banner while backgrounded and for a short grace after resume; reconnect silently; wire visibilitychange + pageshow/pagehide + Telegram activated/deactivated (Bot API 8.0)
- hint button disabled when 0 hints remain; nudge button shows a disabled state on your own turn
- players plaque: invert so the active seat pops (accent chip, raised) and others recede
- make-move UX: a direct ✅ commit button (no hold/popover); the Shuffle tab becomes ↩️ Reset while tiles are pending
Service-agnostic refinement of the owner's idea: the sign-in service returns a
set of supported game languages with the user identity, and the lobby gates the
New Game variant choice by it (en -> English; ru -> Russian + Эрудит).
- Connector hosts two bots in one container (one per service language, each its
own token + game channel; the same telegram_id spans both). ValidateInitData
tries each token and returns the validating bot's service_language +
supported_languages. Per-language config (TELEGRAM_BOT_TOKEN_EN/_RU, channels).
- supported_languages rides the Session (fbs, session-scoped, not persisted); the
UI offers only the matching variants on New Game — gating only the START of a
new game (auto-match + friend invite), not accept/open/play; backend does not
enforce.
- service_language persisted (accounts.service_language, migration 00010, written
every login, last-login-wins) and routes the user-facing Notify push back
through the right bot (push-target coalesces with preferred_language).
- Admin SendToUser/SendToGameChannel gain an operator-chosen language selector in
the console (unrelated to ValidateInitData).
- Non-Telegram logins carry the gateway default set
(GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, all variants).
Wire (committed regen): ValidateInitDataResponse +service_language
+supported_languages; Session +supported_languages; SendToUser/SendToGameChannel
+language. Docs (ARCHITECTURE/FUNCTIONAL/_ru/READMEs) + PLAN updated; stage marked done.