ab1ad998aa28ca29405e1862ff2248d6311c205a
141 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ab1ad998aa |
feat(ui): batch of UI polish tweaks
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
- admin dashboard "Users" count excludes robots (CountUsers, humans only) - lobby finished-game delete reveal: background matches header/tab-bar (--bg-elev) - mobile: swipe up on the zoom-out board (no staged tiles) shuffles the rack - lobby: status icons -25%, game-row top/bottom padding halved - toast: info tier drifts up ~a tab-bar height while fading over 2s and dismisses on tap; error tier unchanged - game: confirm-move button stays pinned at the right edge; rack tiles shrink to fit so a full rack never overflows onto it - telegram: a successful invite-link redeem shows a welcome window pointing at the bot - settings: remove the grid-lines toggle; the board is always a gapless checkerboard - settings/comms hubs: the selected tab highlight wraps icon + label as a pill Docs: UI_DESIGN (toast, board surface, selected tab), PLAN; e2e updated for the removed grid-lines toggle. |
||
|
|
64be0572b3 |
fix(social): robot blocks
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 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Blocking an auto-match opponent who is secretly a pooled robot is recorded instead in a separate `robot_blocks` table. Now blocking behaves the same in that game (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is recorded only against that game — the disguise holds, the shared robot is never globally blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out of opponents). - the shared robot account is never put in `blocks` - the matchmaker keeps it free and it is not blocked under its other per-game names - the blocked list and the in-game card still show it by joining that table; an unblock deletes the row |
||
|
|
81b9e1529e |
feat(social): asymmetric per-user block, in-game block control, admin lists
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
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.
|
||
|
|
8793bd34f2 |
feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
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. |
||
|
|
fb0ddab0f1 |
feat(ui): hide GCG export in honest-AI games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
A vs_ai game is throwaway practice, so its finished history header no longer
offers the 📤 GCG export (an empty slot keeps the comms icon pinned right).
Docs note the AI exclusion; UI_DESIGN also records that confirming a resign
reveals the full board (closes the history drawer, zooms out).
|
||
|
|
e850ecd83b |
fix(ui): don't strand the Mini App on a cancelled GCG share
On iOS WKWebView (the Telegram Mini App), cancelling the Web Share sheet fell through to the Blob <a download> fallback. iOS ignores the download attribute, so clicking the anchor navigated the webview to the blob: URL — replacing the SPA with the raw GCG file, with no way back (force-quit only). The share path no longer falls back to a download: Web Share is available on that platform, so a cancelled or failed share is a no-op and the user can retry. The Blob download stays the desktop-only path (no Web Share). |
||
|
|
2c24d54047 |
feat(ui): reveal the full board on resign (close history, zoom out)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
After the player confirms a resign, close the move-history drawer (portrait; the landscape dock is unaffected) and zoom the board out if it was magnified, so the resigned game shows its full final board. |
||
|
|
7cd4474945 |
fix(ui): pin the in-game ad-banner height on a short viewport
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
The ad banner lives inside the grown game header (`.nav.grow`, portrait game only). That rule carried `flex: 1 1 auto`, so on a short viewport the flex algorithm shrank the header — clipping the banner (`.ad` is `overflow:hidden`) — *and* the board's scroll area at the same time. The banner and the board ended up splitting the vertical squeeze. Drop the shrink (`flex: 1 0 auto`): the header still grows into spare height (banner under the title, board pinned to the bottom), but on a short viewport it holds its natural height and the board's own scroll (`.stage`) absorbs the whole squeeze. The banner now keeps a constant height. Portrait-only: in landscape and on every other screen `.nav` is already `flex: 0 0 auto`, so the banner never shrank there. Verified in the mock UI (portrait, live banner): at 440px tall the banner held 30px (was clipped to 14px) while the board scrolled; the tall-viewport layout is unchanged. Full UI suite green locally (check, 272 unit, build, bundle-size, 140 e2e). |
||
|
|
a7f3df9346 |
fix(chat): don't restore unread on a failed read-ack (would loop the in-game effect; REST re-seed self-heals)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
|
||
|
|
aaac816dc2 |
feat(chat): unread read-receipts with lobby/game dot and history-open ack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
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.
|
||
|
|
63ab85a5e5 |
feat(lobby): cap simultaneous quick games at 10
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Limit a player to 10 active quick games (auto-match + AI); friend games created by invitation are not counted. At the cap the backend refuses both new-game entry points — quick enqueue and invitation creation — with 409 game_limit_reached, while accepting an incoming invitation stays allowed, so friend games are capped from the other end. The lobby disables "New Game" and shows a low-emphasis notice, driven by a new at_game_limit flag on games.list (no per-event payload: a turn change does not move the count, and the lobby already re-fetches games.list on entry and every game event). - game.MaxActiveQuickGames + Store/Service.CountActiveQuickGames (active/open seats, no game_invitations row; hidden games still count -> dedicated count) - Server.ensureUnderGameLimit gating handleEnqueue + handleCreateInvitation; game.ErrGameLimitReached -> 409 game_limit_reached - FB GameList.at_game_limit (regenerated Go + TS) through the gateway transcode and UI codec; gameListDTO + lobbycache snapshot + Lobby.svelte + i18n - tests: integration count rule + HTTP gate + accept bypass; server error map; gateway transcode round-trip; UI codec + lobbycache unit; e2e gamelimit - docs: PRERELEASE (GL), FUNCTIONAL(+ru), ARCHITECTURE 8, UI_DESIGN, backend README |
||
|
|
2a034ff9be |
fix(ui): don't refetch the lobby on heartbeats
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
The lobby refetched /user/games on every stream event, including the 10s keep-alive heartbeat, turning it into a 10s poll. That poll's REST view of a just-committed opponent move could flip a card (and now blink it) seconds before the matching your_turn event — and its toast — arrived over the slower live stream, so the new lobby-card blink appeared to lead the toast by 5-7s (variable with stream delivery; in sync when prompt). Gate the refetch to real events (kind !== 'heartbeat'): the card, its blink and the toast now ride the same event (opponent_moved + your_turn are published together), and the constant 10s full-lobby poll per client is gone. |
||
|
|
12d128f1cc |
feat(nudge): name the sender; blink lobby cards; re-animate toasts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The "waiting for your move" popup was the nudge (chat.nudge), shown without a
sender name. Resolve the nudger's per-game seat name server-side and carry it on
a new NudgeEvent.sender_name field, so:
- the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy);
- the out-of-app Telegram push names the sender too (render nudgeBy);
falling back to the plain phrase when the name is absent (RU mirrored). The
your_turn toast already named the opponent and is unchanged.
Lobby: when a card transitions into "your turn" or "finished" while the lobby is
open, its status emoji blinks twice (two 1s fades); the opponent's-turn change
stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer)
so overlapping events animate in isolation; suppressed under reduce-motion.
Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the
previous one and replays its entrance, uniformly on every screen.
Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName
(integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru,
UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.
|
||
|
|
183e08ec80 |
feat(robot): per-game display names from a wide name corpus
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Decouple the displayed opponent name from the small pool of durable robot accounts: the disguised auto-match robot now gets a freshly composed name each game, stamped on a new game_players.display_name seat snapshot. The snapshot also captures humans' names, freezing what an opponent sees for the life of a game (a later rename no longer rewrites past games); readers fall back to the account's current name for pre-migration rows. Names come from a wide composed corpus (internal/robot/namevariety.go): Western locales (EN/DE/ES/IT/FR/PT), native Japanese/Chinese names, a gender-agreed Russian pool, and human-style handles. Routing keeps Pick's spirit -- a Russian game draws Cyrillic + <=20% Latin and never a CJK script; an English game the full corpus -- via robot.PickNamed. Loosen account.ValidateDisplayName (and the UI mirror) to admit a trailing run of up to five digits, so "Player2007"-style handles are valid for humans too and the disguised robot stays indistinguishable. Migration 00007 adds game_players.display_name (additive, NOT NULL DEFAULT ''); jet regenerated. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru, PLAN, README) updated. |
||
|
|
dd45af20ef |
fix(ads): fade the scroll rewind + keep the banner strip height constant
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
Two polish fixes (owner feedback): - Scroll loop: a long message that scrolled to its right edge rewound with a hard jump (no fade). It now runs the same fade as a message change at each rewind: fade out at the edge, reset the scroll while hidden, fade the same message back in, then scroll again. - Strip height: during the fade gap the message layer is removed, which let the strip collapse by ~1-2px. An always-present invisible spacer now reserves one line of height and the message is overlaid absolutely, so the strip height is constant whether or not the message is showing. Verified live: opacity sampling shows a full fade-out → gap → fade-in at each scroll rewind (~every 6s), and the .ad height stays a single constant value (30.31px) across the whole cycle including the gap. Loop-fade unit-tested. |
||
|
|
115c92b39a |
fix(ads): pulse a lone banner message through the fade cycle
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
A single campaign message (e.g. the default campaign's one message) faded in once on load and then sat frozen — the rotator only ran the fade/advance cycle when more than one message existed, so with one message there were no further fades. Drop the `total > 1` guards: every message now runs the full hold → fade-out → gap → fade-in cycle, so a lone message pulses (the same message fades back in) and a lone long message fades at each scroll-loop boundary. Multi-message rotation is unchanged. Verified by opacity sampling (single message pulses 1→0→gap→0→1 without navigation); the single-message test now asserts the pulse. |
||
|
|
9f83962bf7 |
feat(ads): carry the banner scroll position across navigation
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Per the owner's idea: instead of moving the banner out of the per-screen header (which would change its position), remember the banner's "life stage" and resume it on the next screen. The engine already keeps the message + rotation timing; this adds the scroll offset: - bannerEngine tracks the in-flight scroll (target, duration, start). On attach, if a scroll is still running, it computes the current offset and calls the new host's resumeScroll(fromTx, toPx, remaining) — the view jumps to the carried offset and continues to the end over the remaining time, instead of restarting at the left. - A finished scroll is left at its end; the rotator's own loop then takes over. Verified: spot-checked in the browser (a long message at offset -785 resumes at -788 on the next screen, not 0) and unit-tested (attach mid-scroll calls resumeScroll with a partial offset and the remaining duration). |
||
|
|
dc582e9f73 |
fix(ads): restore reliable banner fades + keep banner on profile update
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Two regressions from the previous banner pass: - Fade (#2): the manual-opacity fade could paint opacity 0 and 1 in one frame and skip the transition — most visible for a single (default) campaign message, whose only fade is the first show. Revert the fade to Svelte transition:fade (which forces the from-state, so even the first/only message fades), keeping it on its own {#if} layer independent of the scroll. A freshly-mounted view onto a running cycle still renders the live message instantly (inFade duration 0 once), so navigation does not replay the fade. Verified by opacity sampling: advances fade, navigation stays at opacity 1. - Profile update (#3): the banner block was attached only to GET /profile, so a profile.update (e.g. a language switch) returned a profile without it and the banner vanished until reload. A shared profileResponse() now attaches the banner to GET, PUT and the link/merge profile responses. Regression test added (TestBannerSurvivesProfileUpdate). Still open: the scroll position is not preserved across navigation (the view remounts); discussed separately. |
||
|
|
5fb0daa746 |
fix(ads): banner truly continuous across navigation + re-measure on resize
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
The previous engine kept the scheduler running but the view re-`show()`-ed the
current message on every (re)mount, replaying the fade on each navigation — which
looked like the cycle restarting (especially for a single message). Now:
- A mounted AdBanner reads the engine's live message (bannerCurrent) and renders
it immediately, with no fade; attach no longer re-shows. Only a real advance
fades. Verified: opacity stays 1.0 across a navigation, message preserved.
- The fade is manual opacity on a .fadewrap layer (not transition:fade), kept
independent of the scroll (inner track transform), so a long message still
fades at both ends and a {#key} remount cannot force an intro fade.
- A viewport size change (portrait↔landscape) re-measures the current message
(remeasureBanner on resize/orientationchange, debounced) so the scroll
re-evaluates for the new width — the owner accepts the restart on resize.
Rotator gains restart(); engine gains bannerCurrent()/remeasureBanner().
Engine continuity + remeasure unit-tested.
|
||
|
|
3b20abe0bd |
feat(ads): banner under the header, continuous across navigation, robust fades
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
Banner UX refinements (owner feedback):
- Position: render the banner inside Header (under the title) instead of in
Screen, so it sits in the same place on every screen. In the game the grown
nav's spare height now falls below the banner (banner under title, board
pinned to the bottom) — it no longer jumps to the game area.
- Continuity: move the rotation into a persistent module engine
(lib/bannerEngine) — the scheduler + timer live outside the components, so a
navigation (which remounts the view) continues the cycle instead of restarting
it. Each AdBanner only attaches as the DOM host and resyncs to the live message.
- Fades: a long, scrolling message now fades at both ends. The fade is a
{#if} transition:fade layer, independent of the scroll (the inner track's
transform), so the two no longer interfere.
Verified live (mock + Playwright): same position in lobby and game; the cycle
continues across lobby↔game; opacity sampling shows fade-out + fade-in for the
long message. Engine continuity unit-tested.
|
||
|
|
cb4a31a860 |
feat(ads): client banner rotation, fade UX & live toggle (PR2)
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 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
Consume the server-driven banner block (PR1) in the UI and retire the gate. - banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each appears its weight share per cycle, evenly interleaved) with round-robin over a campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap -> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll. - model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded from the fbs block. - Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from app.profile.banner (campaigns + timings + reduceMotion). - AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when the campaigns/timings change (a `banner` notify re-fetch swaps them in place). - app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner shows/hides in place. - tests: scheduler distribution + round-robin, the fade sequence, single-message, reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated. |
||
|
|
0946a3f66c |
feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
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). |
||
|
|
aa765a0c06 |
feat: honest AI opponent in quick game
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
New Game's quick game gains an explicit opponent selector — 🤖 AI (default) or 👤 Random player. AI starts a game seated with a pooled robot that joins and moves at once: no per-move timeout (a 7-day inactivity loss reusing the turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown as 🤖 everywhere. The random path (disguised robot) is unchanged. Driven by one game flag (games.vs_ai), set only on AI-started games so the disguised path is never revealed; Matchmaker.StartVsAI seats the robot directly (no open pool); the robot replies event-driven via the game service's after-commit/after-create hook. Wire: vs_ai on EnqueueRequest + GameView. |
||
|
|
bd0482c376 |
feat(ui): route all in-app external links through Telegram openLink
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
Extend the openLink routing from the dictionary lookup to every external
link shown inside the Mini App, so none triggers the WebView's 'open this
link?' confirmation. A shared onExternalLinkClick handler resolves the anchor
via closest() (so it also works delegated on {@html} content), backed by a
pure routeExternalLinkInTelegram decision: only inside Telegram, only an
external http(s) target=_blank link, excluding same-origin/in-app and t.me
links (t.me keeps openTelegramLink). Applied to the word-check lookup, the
About rules link, the Feedback operator-reply links, and the feature-gated
announcement banner.
Outside Telegram every anchor keeps its native target=_blank.
|
||
|
|
8e0d7f9e17 |
feat(ui): external dictionary lookup link on the word-check tool
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
When a checked word is found, show a 'look it up' text link beside the complaint button that opens an external reference dictionary in a new tab: gramota.ru for the Russian variants, scrabblewordfinder.org for English (word lower-cased and percent-encoded). The link hides for a word that is not found. Inside Telegram it routes through the Mini App SDK's openLink, so Telegram opens it directly instead of the WebView's 'open this link?' confirmation; in a browser the anchor's own target=_blank handles it. Relabel the complaint button to 'Возражаю' (ru); English stays 'Disagree'. |
||
|
|
4d6df4bd8b | Merge pull request 'feat(ui): настоящий share friend-code + ссылка по текущему боту (service_language)' (#66) from feat/friend-invite-share into development | ||
|
|
00129414e5 |
feat(ui): force Mini App fullscreen on mobile, not via the share link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
Replace the shared-link &mode=fullscreen (which would also force fullscreen on desktop) with an imperative requestFullscreen() on launch, gated to mobile clients (ios/android/android_x) — mirroring how Telegram's own Mini Apps go immersive on phones while desktop keeps the bot's full-size window. It triggers the existing fullscreenChanged -> safe-area resync; a no-op on clients predating Bot API 8.0. |
||
|
|
853730823b |
feat(ui): refine Telegram invite & close UX
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
- Shared invite links open the Mini App fullscreen (mode=fullscreen), so a shared link matches the bot's own fullscreen launch. - A used or expired invite deep-link now lands the visitor in the lobby with a gentle notice pointing at the right bot (@<username>, by service language), instead of a red "code invalid/expired" error on the Friends screen. - The in-game close confirmation is armed only on Telegram mobile clients; on desktop (tdesktop/macOS/web) it is skipped, where the "changes may not be saved" dialog is just noise (drafts auto-save). |
||
|
|
01d2d1f368 |
fix(ui): per-bot invite caption + friendly self-redeem note
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
- The share caption is now in the bot's language: 'Давай играть в Эрудит!' (ru bot), "Let's play Scrabble!" (en bot) — picked by the session service language, not the interface language. - Redeeming your own invite (deep link or manual) no longer shows the scary 'can't do that to yourself' error; it shows a friendly neutral note instead. |
||
|
|
03eb8044ff |
feat(ui): real friend-invite share with a per-bot link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
The friend-code 'share' was an <a> that just opened the bot. Make it a real share: Telegram's native share-to-chat picker inside the Mini App (openTelegramLink + t.me/share/url), the system share sheet (navigator.share) on the web, else copy the link. The shared deep link points at the same bot the player is in — it picks VITE_TELEGRAM_LINK_EN/_RU by the session's service language, falling back to the single VITE_TELEGRAM_LINK. Adds the per-bot build args across Dockerfile / compose / ci.yaml / .env / docs; PLAN TODO-5 updated. |
||
|
|
6679260d0a |
feat(session): carry the bot service_language on the Session wire
Thread the Telegram bot's service language (en/ru) from the session mint response through the gateway into the FlatBuffers Session, so the UI knows which bot the player signed in through. handleTelegramAuth refreshes the account's service language onto the response before minting (it was set after the fetched copy). Empty for a non-Telegram login. |
||
|
|
800a692766 |
fix(ui): a board pinch-zoom no longer triggers swipe-back
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
The edge swipe-back armed on the first finger and fired on release even when a second finger had joined (a pinch-zoom — the board is not .zoomed yet at the first touch, so the hit-test could not skip it). Track the live pointer count in the capture phase and cancel the swipe the moment a second pointer joins; the back navigation fires only for a lone finger. Synthetic-PointerEvent e2e covers both. |
||
|
|
30a7c24140 |
fix(ui): interface language follows the device, not the Telegram bot
On login the UI no longer overrides the interface language from the account's preferred_language. The live interface follows the device — the explicit local choice (saved, locked) or the system-language guess — so opening the mini-app via the ru-bot on an English system keeps the interface English (it was forced to Russian by the account seed). preferred_language is still written from Settings and used for out-of-app push routing; it just no longer dictates the UI on launch. |
||
|
|
277954c47f |
feat(feedback): render links in the operator reply (open in a new tab)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Linkify the operator reply on the feedback screen: http/https/ftp/mailto/tel URLs become anchors (target=_blank rel=noopener), everything else stays escaped text. A small whitelisted regex (no dependency) — the reply is operator-authored, so explicit schemes suffice; dangerous schemes (javascript:/data:) are never linked, and \b avoids matching a scheme inside a word (hotel:, email:). |
||
|
|
1ae43080ec |
fix(feedback): show the operator reply only on the player's latest message
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
A reply was bound to 'the latest message that has a reply', so after the player read a reply and sent a new (unanswered) message, the old reply kept showing as 'Ответ на ваше последнее сообщение'. Bind it to the single most-recent message instead: sending any new message immediately drops the previous reply (the new message has no reply yet), well before the one-week window. Client clears the reply optimistically on submit; the mock mirrors it; inttest covers the case. |
||
|
|
5287794a72 |
fix(feedback): theme buttons, badge the Info button, simplify admin actions
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
- 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)
|
||
|
|
419ea11b14 |
feat(feedback): in-app user feedback with admin review and account roles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
User-facing Feedback screen (Settings -> Info, registered accounts only): a message (<=1024 runes) plus one optional attachment, an anti-spam gate (one unreviewed message at a time), and the operator's inline reply with a Settings/Info badge. Server-rendered admin console section (/_gm/feedback): unread/read/archived queue with per-user search, detail with read/reply/ archive/delete/delete-all, safe attachment serving (nosniff, images inline via <img>, others download-only). Introduces account_roles, the first per-account role table; feedback_banned blocks only feedback submission, granted/revoked from /users and the delete-with-block action. - migration 00004_feedback (feedback_messages + account_roles) + jetgen - backend internal/feedback (store+service), internal/account/roles.go - wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest, is_guest via session resolve) -> guest_forbidden before any backend call - reply push reuses NotificationEvent with a new admin_reply sub-kind - UI: /feedback route + screen, attachment picker, badge, channel detection, i18n - tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e - docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs |
||
|
|
ac62d29ef7 |
feat(ui): name the previous player in the your-turn toast
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
The in-app "your turn" toast (shown when it becomes your turn in another game) now reads "<Opponent>: Your turn!", naming the player who moved just before this one — the previous seat in turn order, so it reads the same in games with any number of players. Falls back to the bare "Your turn" label when no name is present (an older peer, or a gap event without one). YourTurnEvent already carries opponent_name end to end: the backend sets it (game.displayName of the last mover) and the gateway forwards the payload opaquely, so this is a client-only change — decode the field, thread it through PushEvent, and pick the localized string. |
||
|
|
d1ba666495 |
feat(admin): manual account blocking (suspensions)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
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). |
||
|
|
5f53ec81b9 |
fix(chat): no chat field or nudge on a finished game
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
A finished game is read-only: gate the nudge behind canNudge (active game, opponent's turn) so it no longer shows on a finished (or otherwise non-active) game — where the backend rejects it anyway. The message field is already hidden off your own turn. Extend the finished-game e2e to assert neither Send nor Nudge is offered. |
||
|
|
02681ae9e0 |
feat(chat): limit in-game chat to one message per turn
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Enforce one chat message per turn on both ends. The backend rejects a second message in the same turn (ErrChatAlreadySentThisTurn -> 409 chat_already_sent), keyed on the move-driven turn start (turn_started_at). The UI derives "already wrote this turn" from the message list against GameView.lastActivityUnix (no counter, survives reopening, resets on turn change), hides the field behind a short caption once the limit is reached, and now reloads the game state on turn/game-state events so the toggle and the limit follow the live game. Enter is gated on !busy to avoid a double-send in the in-flight window. Backend: new game.TurnStartedAt; social GameReader gains it; PostMessage enforces the limit reusing lastMessageAt. UI: new lib/chatlimit.ts pure logic + unit tests; Chat/ChatScreen wiring; chat.sentThisTurn and error.chat_already_sent i18n (en/ru); extended chat e2e. Docs: FUNCTIONAL (+ru), ARCHITECTURE, UI_DESIGN. |
||
|
|
02ef31c464 |
feat(ui): landscape iteration — reorder left panel, enable board zoom
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
Left panel order is now score / history / status / rack / controls (the history fills the middle and scrolls). Board zoom is re-enabled in landscape with the same gestures as portrait, but height-driven: the viewport is the full right pane, the board fits by height as a centred square when zoomed out, and on zoom-in it magnifies past the pane and pans within it (focus-centred scroll that rides the magnify transition), occupying the full width up to the left panel. Board.svelte gains a landscape prop; portrait stays width-driven and unchanged. landscape.spec.ts now asserts zoom works (the zoomed board overflows the pane). |
||
|
|
9ec72c8377 |
feat(ui): landscape two-column game layout, board fitted by height
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
When the viewport is wider than tall (matchMedia orientation: landscape) the game screen switches from the portrait stack to a two-column layout: the board fills the right column as the largest square that fits the height (no zoom, shrinking by width when cramped — lowest priority), while the left panel stacks the rack (+ make), the status line, the score plaques, the always-open docked history and the controls. Board zoom, the history slide-drawer gestures and the growing nav bar are gated off in landscape; the portrait layout is unchanged and both render from the same snippets so behaviour stays single-sourced. The mock e2e now defaults to a portrait viewport (the mobile-first app the gesture/zoom/history specs are written for); landscape.spec.ts covers the wide layout in its own viewport. |
||
|
|
c9021fc070 |
feat(ui): preload ongoing games and cache the draft for an instant, jump-free game open
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 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
Opening a game from the lobby for the first time this session showed a brief loading flash, and every open showed a two-step rack->board jump: the saved draft (pending composition) was fetched separately and applied only after the board had already painted the full rack. Both stem from the full state and the draft not being available synchronously at first paint. Cache the draft alongside view+history (CachedGame.draft), make applyDraft take the already-fetched JSON so it runs synchronously, and fetch the draft in the same Promise.all as state+history. setCachedGame preserves the cached draft when the delta path omits it and clears it on a committed move (mirroring the server). A new preload module warms the per-game cache (state, history, draft) for the lobby's ongoing games with bounded concurrency, so opening any of them is instant. Tests: gamecache (preserve/clear/setCachedDraft) and preload (warm/skip) units; existing draft-restore e2e still green. |
||
|
|
222eaf730f |
feat(game): void unreplayable games as a draw instead of erroring
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
A committed move that becomes illegal under a tightened rule (the single-word connectivity fix) makes engine replay fail, which left such games unopenable — an empty screen and an 'illegal play' error. Now the first open closes the game gracefully as a draw (engine.EndAborted -> end_reason 'aborted', no winner), preserves the journal, and surfaces an impersonal organizer note at the end of the move history and in the GCG export. - engine: EndAborted + Abort() (draw, no rack adjustment; winner -1). - service: replay aborts on ErrIllegalPlay; liveGame persists the void once (lazy, on open); GameState re-reads for the settled view. - store: VoidGame finishes the game and stamps a draw without a journal row. - migration 00002: allow end_reason 'aborted'. - ui: organizer note under the history grid; i18n en/ru. - docs: ARCHITECTURE 6/9.1, FUNCTIONAL(+ru), PRERELEASE MW3. |
||
|
|
abe1038333 |
test(ui): run e2e against the minified vite build, not the dev server
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
The e2e booted the unminified Vite dev server, so production-minification bugs were invisible to it — exactly how a minifier dropping a bare `void recenter` reactive read slipped through. Build the app in mock mode and serve the minified artifact via `vite preview` instead, so the smoke exercises the same bundle the contour ships. Build to dist-e2e/ (gitignored) so it never clobbers the dist/ the bundle-size gate measures, and with `--base /` so the SPA-fallback also boots a subpath like /telegram/ (the production relative base needs the gateway's path mapping, absent under a plain preview). All 118 specs pass against the build on both engines, including the hint-recenter spec — confirming the hardened dependency survives minification. |
||
|
|
553152e195 |
refactor(ui): harden the board's recenter dependency and gate it on a real bump
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
The recenter effect declared its dependency with a bare `void recenter`, which a production minifier could drop as a side-effect-free statement (the e2e runs only the unminified dev server, so it would not catch that). Read the nonce into a used variable and gate the pan on it actually changing (`recentered`), so the reactive dependency is robust to minification and the board pans only on an explicit hint recenter — never on an incidental re-run such as a viewport resize. |
||
|
|
1cc6c0d56e |
fix(ui): stop the game-screen freeze when an opponent joins (reactive self-loop)
The in-game live-event $effect read `view`/`moves`/`placement` (via cacheSnapshot and direct reads) AND wrote `view` in its branches, so those reads became the effect's own dependencies: writing `view = …` re-ran the effect, and with app.lastEvent unchanged it re-entered the same branch and wrote `view` again — a tight self-invalidating loop that pinned the main thread, freezing the board and rack. opponent_moved escaped it only because applyMoveDelta is idempotent on the move count (no cache → no write on the second pass); opponent_joined and game_over have no such guard, so an opponent joining hung the whole screen. Tracking `placement` similarly re-fired the handler on every tile the player placed after an opponent's move (a spurious reload). Fix: the effect must depend only on app.lastEvent and process each event once. Wrap the branch body in `untrack`, scoping its view/moves/placement reads out of the effect's dependency set; the writes inside no longer re-trigger it. e2e: after the opponent joins, placing a rack tile must render a pending tile — verified RED (frozen, 0 pending tiles, both engines) before the fix, GREEN after. |
||
|
|
f3914af793 |
fix(ui): recentre the board on a hint taken while already zoomed in
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
The board only scrolled to the hint's word when the hint also toggled zoom (the zoom-out case): the recenter effect tracked `zoomed` and read `focus` untracked, so a hint taken while ALREADY zoomed in — no zoom change — never recentred and the board stayed parked where the player was looking. Add a `recenter` nonce the hint bumps; the board's scroll effect tracks it and, when it fires without a zoom toggle, pans straight to `focus` (the board width is stable, so the width-driven zoom tween has nothing to ride). Placing a 2nd+ tile or hovering a dragged tile still set `focus` without the nonce, so the board never jumps on those — the original no-jump intent is preserved. e2e: zoom into the corner, then hint (the mock plays at the centre) — the board pans toward the centre. Verified RED without the fix (both engines), GREEN with. |
||
|
|
56dbf86472 |
feat(lobby): keep lobby/game caches fresh from any screen + invitation delta channel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Builds on the cross-screen cache work: the global stream handler now keeps both caches current no matter which screen is mounted, and invitations become a live delta channel so the lobby's invitations list is fresh from any screen too. Client (boundary already started): - advanceCached now also folds opponent_joined into a not-currently-viewed game's cache via a new pure reducer applyOpponentJoined (extracted and reused by the mounted game board), so opening an open game that filled while you were elsewhere is flash-free. - patchLobbyInvitation upserts a still-pending invitation and removes a terminal one (started/declined/cancelled/expired); the global notify handler calls it on the invitation / invitation_update sub-kinds. Invitations delta channel (no wire/gateway/connector change — the notification already carries the full invitation with id/status/game_id end to end): - notify: a new in-app-only NotifyInvitationUpdate sub + NotificationInvitationUpdate constructor (shares encoding with NotificationInvitation). The Telegram connector renders no message for it, so a decline/cancel never becomes an out-of-app push. - lobby: emit the changed invitation to every participant on respond (accept/decline), on the final accept's game start, and on cancel — so each participant's lobby patches its list in place. The authoritative list holds only pending invitations, so the client's pending-vs-terminal rule matches it exactly. Tests: applyOpponentJoined + patchLobbyInvitation unit tests (TDD), the NotificationInvitationUpdate encoding unit test, and integration assertions that decline/cancel/accept publish invitation_update to every participant. Full local suite green (backend unit+integration, UI check/unit/build/bundle/e2e). Docs: ARCHITECTURE §10 (notify catalog + in-app-only note) and UI_DESIGN updated. |