a21ba23e5e737f9532c037ef3514754e096612ce
151 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
57c778f9b2 |
feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.
- Telegram: one bot; drop service_language and the supported_languages
set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
connector proto). The single bot renders chat and out-of-app push in
the recipient's preferred_language; remove the game-language push
routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
{erudit_ru}, CHECK non-empty + subset of the three variants). Gates
the New Game picker, vs-AI and the friend invitation the player
creates, enforced server-side (HTTP 400 otherwise); an invited friend
may still accept any variant. Edited on the Settings screen; variants
are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
backend, ui, UI_DESIGN, PRERELEASE).
The migration squash is deferred to a follow-up PR.
|
||
|
|
c9a5ca3ed7 |
fix(lobby): keep the spaces around the score separator
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
Svelte trims leading/trailing whitespace inside an element, so the literal
`<span class="sep"> : </span>` rendered as a bare ":" ("123:123"). Emit the
separator as a string expression `{' : '}`, which Svelte preserves, restoring
"123 : 123".
|
||
|
|
9644bd6e5e |
style(lobby): bold the score line, a touch smaller
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
Per owner follow-up: the whole lobby score line is now bold (font-weight 700, matching the over-the-board score plaques) and a hair smaller (0.8rem) to offset the heavier weight. The viewer's own number keeps its green/red standing tint. |
||
|
|
c127bc9f0e |
feat(social): per-game friend request to disguised robots + lobby/stats/tile cosmetics
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m25s
Functional: the in-game add-friend handshake aimed at an auto-match opponent who is secretly a pooled robot now records the request per (game, seat) in a new robot_friend_requests table -- never against the shared robot account -- mirroring the robot_blocks pattern. The shared account stays out of friendships, the "requested" state is pinned to the seat (not leaked across the player's other games), the robot ignores it, and a background reaper drops the row 7 days after its game finishes. The outgoing-requests list carries these per-game rows so the seat control stays disabled across reloads. No withdraw UI, per owner decision. Cosmetics: - Lobby: an in-progress game tints the viewer's own score number green when leading or tied, red when trailing (reusing --ok/--danger); scores now render in seat-number order, matching the over-the-board scoreboard. - Stats: the per-variant best move is laid out on two lines -- the variant label, then the score (right-aligned in its column) and the word tiles (left-aligned) below it. - Dark theme: the played-tile background is a touch darker so a player-placed tile reads with more contrast (light theme unchanged). Docs (ARCHITECTURE, FUNCTIONAL + _ru mirror, backend README, UI_DESIGN) updated in the same change. |
||
|
|
6e77de4c1e |
feat: sparser robot nudges, typed unread badge, lobby unread bump
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
Three owner-requested polish changes: - robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a flat uniform 9-12 h wait before every nudge; the existing sleep-window gate still skips and defers a nudge that would land in the robot's night. - ui: colour the lobby/in-game unread dot by type -- the regular danger colour when a chat message is unread, a softer amber (--warn) when only nudges are. Adds a per-viewer unread_messages flag (chat_messages.kind='message') across the backend DTO, FlatBuffers wire, gateway transcode and the UI store. - ui: float games with any unread notification to the top of the lobby's your-turn and opponent-turn sections (finished keeps its order), reusing the existing unread_chat flag. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire field is backward-compatible. |
||
|
|
d0681c5efe |
fix(ui): lower-case hint preview words to match evaluate
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 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
A hint's MoveRecord words are upper-cased on decode (for the move-history view), whereas an EvalResult keeps the backend's lower case. Seeding the move preview from the hint verbatim flipped the score caption to upper case and nudged its height. Lower-case the words in previewFromHint so the caption reads the same as the evaluate path it replaces. |
||
|
|
0dd4099d68 |
perf(ui): reuse hint result as move preview, skip redundant evaluate
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 1m9s
Taking a hint auto-placed the suggested tiles and then called recompute(), which round-trips a debounced evaluate for that exact placement. The hint is the engine's own top-ranked, fully scored legal move, so its move already carries the score, words and direction an evaluate would return — the second call was pure duplicate work and added a visible disabled->enabled flicker on the submit button over slow links. Seed the move preview directly from the hint move via previewFromHint and cancel any pending evaluate timer so a stale one cannot clobber it. A later manual edit re-arms recompute() as before, so rearranged tiles are re-evaluated normally. Client-only; no backend, wire or schema change. |
||
|
|
b156d3403c |
fix(ui): polish board→rack recall drag and its gesture interplay
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 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
- The rack now animates the opening drop gap for a board-tile recall drag too
(the reorder transition was gated on a rack-source drag only).
- While a board tile is dragged over the rack (a recall, gap shown), the ✅
confirm and its preview caption are hidden so they don't sit over the gap;
they return if the tile is dragged back out over the board.
- A recall drop lands off the boardwrap, so its pointerup never reached the
boardwrap handler and left a stale id in the active-pointer set — the next
board swipe then read as multi-touch and the shuffle / history pull went dead.
Reconcile the pointer when a drag ends or cancels. Regression e2e added.
|
||
|
|
4adb608ad1 |
feat(ui): vs-AI comms trim, overlay confirm button, recall-to-slot drag
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
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 1m8s
- vs-AI: the comms hub drops the Chat tab (Dictionary only); a finished AI game has no comms at all, so its 💬 history-header entry is hidden too. - Confirm-move ✅: redone as an absolute overlay pinned to the right edge (its right edge sits under the preview caption), so the rack keeps a fixed tile size — this reverts the tile-shrink that resized the letters at 6 tiles. - Recall: dragging a placed tile back to the rack now drops it at the slot the pointer is over (drag-to-position, a gap opens), instead of snapping to its original slot; double-tap still recalls to the origin. New pure recallToSlot. Tests: placement recallToSlot units; e2e recall-to-position; vs-AI comms e2e updated. Docs: UI_DESIGN + FUNCTIONAL (+ru). |
||
|
|
b5688d4848 |
fix(ui): right-align the confirm-move glyph under the preview caption
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 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
The ✅ box already ended at var(--pad) from the screen edge, but place-items:
center centred the glyph inside the 56px box, so it sat ~14px left of the green
preview caption (.scores, text-align:right at the same var(--pad)). Right-align
the glyph (place-items: center end) so its right edge lands under the caption's.
|
||
|
|
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. |