Commit Graph

489 Commits

Author SHA1 Message Date
Ilia Denisov c3b3cafcdd feat(observability): add the Scrabble — Messages Grafana dashboard
CI / changes (pull_request) Successful in 1s
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 59s
Visualises the chat read-receipt metrics added with the read-receipt feature
(the dashboard was deferred there per the owner): posted rate by kind, the
unread backlog (chat_unread_messages gauge), and the publish-to-read latency
(chat_read_duration — average by kind plus overall p50/p95). Mirrors the
game-domain dashboard's structure; the file provider auto-discovers it.
2026-06-17 12:00:58 +02:00
developer b71f006ddf Merge pull request 'feat(chat): unread read-receipts — lobby/game dot, history-open ack, admin + metrics' (#77) from feature/chat-read-receipts into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
2026-06-17 09:52:23 +00:00
Ilia Denisov 20f2a5a011 feat(chat): a message to a disguised robot opponent is born read
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 1m21s
A pooled robot substituted into an ordinary (non-AI) game never opens the
chat, so a text message to it would linger unread forever — skewing the unread
count and the publish-to-read metric. Clear its recipient bit at PostMessage
time (robotRecipients via account.IsRobot), so the message is born read. The
human sender never had their own message unread, so this is invisible to them;
a nudge to a robot already self-clears when the robot answers by moving.
2026-06-17 11:46:59 +02:00
Ilia Denisov 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
2026-06-17 11:14:25 +02:00
Ilia Denisov 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.
2026-06-17 11:12:38 +02:00
developer d53ff18a67 Merge pull request 'feat(lobby): cap simultaneous quick games at 10' (#76) from feature/quick-game-limit into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 50s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
2026-06-16 21:00:41 +00:00
Ilia Denisov 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
2026-06-16 22:51:18 +02:00
developer 05d83ced86 Merge pull request 'feat(nudge): name the sender; blink lobby cards; re-animate toasts' (#75) from feature/nudge-name-lobby-blink into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
2026-06-16 19:33:57 +00:00
Ilia Denisov 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.
2026-06-16 21:06:31 +02:00
Ilia Denisov 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.
2026-06-16 19:09:24 +02:00
developer e9dfa4ccb8 Merge pull request 'fix(robot): rare dot between handle words, prefer underscore' (#74) from feature/robot-nick-separator into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
2026-06-16 14:20:37 +00:00
Ilia Denisov 9c30bdf8e7 fix(robot): make a dot between two handle words rare, prefer underscore
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
A handle joining two meaningful words with a dot ("Тихий.Воин", "Hidden.Hunter")
reads as machine-generated — people use "_" there, a dot only rarely. assembleHandle
now picks "_" about nine times in ten and "." only about one in ten for the
separator-joined form; the camelCase and bare-noun forms are unchanged.
2026-06-16 16:10:39 +02:00
developer 51d7c4e005 Merge pull request 'feat(robot): per-game opponent names from a wide corpus + frozen seat-name snapshot' (#73) from feature/robot-name-variety into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
2026-06-16 13:58:35 +00:00
Ilia Denisov 14b5f61af9 feat(robot): gender-agree Cyrillic handle adjectives
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
Cyrillic adjective+noun handles paired a masculine adjective with any noun, so a
feminine noun read ungrammatically ("Вольный Комета"). Carry masculine + feminine
adjective forms (cyrAdjective) and tag each noun's gender (cyrNoun); cyrillicNick
now renders the adjective in the agreeing form ("Рыжая Комета", "Дикий Волк"), and a
few feminine nouns (Комета/Звезда/Молния/Пантера/Рысь/Буря/Сова/Акула) are added for variety.
2026-06-16 15:51:00 +02:00
Ilia Denisov 6d66545062 fix(robot): show the per-game name in REST game views, not the account name
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 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The seat display-name snapshot only reached the live-event path (seatNames);
the REST DTO layer still resolved seat names from the account store
(fillSeatNames), so the game screen and lobby list showed the robot's seeded
account name ("Женя") while the your_turn toast showed its per-game name
("Звёздный_Барс2"). Carry the snapshot into gameDTOFromGame and have
fillSeatNames fall back to the account only for a seat with no snapshot (a
pre-snapshot legacy row). Friends and invitations keep account names (the
persistent identity, not a per-game disguise).
2026-06-16 15:22:27 +02:00
Ilia Denisov 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.
2026-06-16 12:28:04 +02:00
developer ac1c89c0ee Merge pull request 'docs(ui): correct the banner section to the shipped rotation behaviour' (#72) from docs/banner-ui-accuracy into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m8s
2026-06-16 05:57:14 +00:00
Ilia Denisov 52f2caae51 docs(ui): correct the banner section to the shipped rotation behaviour
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
The banner UX evolved across the PR2 polish iterations past what UI_DESIGN
described. Bring the section in line with the shipped behaviour:

- a lone message pulses (fades out and back in) instead of sitting frozen;
- a long message's scroll rewind fades (out → rewind hidden → fade the same
  message back in) instead of every-message-only fades;
- the scroll position carries across a navigation (resumeScroll), rather than
  restarting at the left;
- the strip reserves a constant line height (invisible spacer + absolute
  message layer) so it does not jump while the message is gone during the gap.

ARCHITECTURE §10 already deferred these client-fade details to UI_DESIGN, so
this is the only doc that drifted. Docs-only, no code change.
2026-06-16 07:54:13 +02:00
developer 27871f2a1d Merge pull request 'feat(ads): client banner rotation, fade UX & live toggle (PR2)' (#71) from feature/ad-network-ui into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Successful in 50s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m2s
2026-06-16 05:45:01 +00:00
Ilia Denisov 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.
2026-06-16 07:18:50 +02:00
Ilia Denisov 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.
2026-06-16 06:39:12 +02:00
Ilia Denisov 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).
2026-06-16 06:26:12 +02:00
Ilia Denisov 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.
2026-06-16 06:09:35 +02:00
Ilia Denisov 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.
2026-06-16 05:45:38 +02:00
Ilia Denisov 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.
2026-06-16 00:35:11 +02:00
Ilia Denisov 9e72e2c799 feat(ads): banner message editor — top help aside + multi-line fields
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Admin campaign editor polish:
- move the link-formatting help aside to the top of the Messages section,
  beside the intro note (~40% width), so it no longer drops below and stretches
  the form fields.
- make the English/Russian message fields 3-row, vertically resizable textareas
  (was single-line inputs) so long text wraps instead of scrolling off to the
  right. The strip is white-space:nowrap, so a stray newline collapses to a space
  on display.
2026-06-15 23:53:42 +02:00
Ilia Denisov 53c6e34c13 feat(ads): add a link-formatting help aside to the banner message editor
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 1m7s
On the campaign detail page, beside the "Add message" form, a static aside
explains the message markdown: plain text is escaped, `[text](url)` becomes a
link, and only http(s)/root-relative targets are linkified (others show as
plain text). New .form-help (flex row) + .help (muted aside) console styles;
wraps below the form on a narrow viewport.
2026-06-15 23:35:52 +02:00
Ilia Denisov 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.
2026-06-15 23:25:27 +02:00
developer a5f066224c Merge pull request 'feat(ads): server-driven ad-banner backend, wire & admin console (PR1)' (#70) from feature/ad-network-backend into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 48s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
2026-06-15 21:09:29 +00:00
Ilia Denisov 00a33c227b fix(ads): verify a message belongs to its campaign before edit/delete
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 1m5s
DeleteMessage/EditMessage acted on the message id without checking it belonged
to the campaign id in the URL. A mismatched pair could edit an unrelated
campaign's message or — worse — delete the default campaign's last message via
another campaign's URL, bypassing the "default keeps >=1 message" guard. Both
now load the campaign and return ErrNotFound unless it owns the message
(MoveMessage already did). Operator-only, but a real business-logic bypass.
Regression test: TestBannerMessageOwnership.
2026-06-15 23:05:41 +02:00
Ilia Denisov 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).
2026-06-15 23:00:19 +02:00
developer f59c8dcd43 Merge pull request 'feat(robot): occasional off-strategy deviation, strict in the endgame' (#69) from feature/ai-strategy-deviation into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
2026-06-15 19:49:28 +00:00
Ilia Denisov 3bceafbc12 feat(robot): occasional off-strategy deviation, strict in the endgame
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
The robot followed its per-game playToWin/lose intent on every move, which made
the outcome too predictable. It now flips that intent for a single move on ~20%
of opening/midgame turns (a winning robot eases off, a losing one surges ahead),
so the chosen strategy may not pan out — which favours the human. The chance
tapers linearly to 0 over the last 14 tiles in the bag and is 0 once the bag is
empty, so the endgame follows the chosen strategy strictly.

The decision is deterministic from the seed (mix(seed,"deviate",moveCount)) and
applies to both robot paths via the shared selectMove; the per-game play-to-win
intent the admin card shows is unchanged. Adds deviateProb/deviates helpers and
unit tests (taper bounds + monotonicity, never-in-endgame, determinism, ~20%
distribution); bakes the behaviour into ARCHITECTURE §7, FUNCTIONAL (+_ru),
backend/README, PRERELEASE and PLAN Stage 5.
2026-06-15 21:37:23 +02:00
developer 9e6899bb7d Merge pull request 'feat: honest AI opponent in quick game' (#68) from feature/ai-opponent into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Successful in 48s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
2026-06-15 19:05:23 +00:00
Ilia Denisov d2a9441287 feat: AI-game refinements (GCG, your_turn, admin, metrics)
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 1m7s
Follow-ups on the honest-AI game, same PR:
- GCG export labels the robot seat "AI" instead of its pool name (ExportGCG
  overrides via accounts.IsRobot); the in-app 🤖 is unchanged.
- vs_ai games emit no your_turn (the robot replies instantly, so it would be
  redundant); opponent_moved still advances the UI.
- Admin console shows the AI flag: a 🤖 column in /games and an "AI game" line
  on the game card (GameRow/GameDetailView gain VsAI).
- games_started_total / games_abandoned_total gain a vs_ai attribute; the
  Grafana Game-domain dashboard splits started/abandoned into human and AI
  panels.

Tests: metrics unit (vs_ai split); integration (no your_turn, GCG "AI").
2026-06-15 20:49:49 +02:00
Ilia Denisov 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.
2026-06-15 20:14:24 +02:00
developer 91d5c341ef Merge pull request 'feat(ui): external dictionary lookup link on the word-check tool' (#67) from feat/dict-lookup-link into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 47s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
2026-06-15 16:32:23 +00:00
Ilia Denisov 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.
2026-06-15 18:29:53 +02:00
Ilia Denisov 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'.
2026-06-15 18:14:19 +02:00
developer 4d6df4bd8b Merge pull request 'feat(ui): настоящий share friend-code + ссылка по текущему боту (service_language)' (#66) from feat/friend-invite-share into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Successful in 47s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s
2026-06-15 15:42:19 +00:00
developer 4b454db219 Merge pull request 'fix(ui): язык интерфейса по системе (не по боту) + pinch-zoom не триггерит back' (#65) from fix/locale-and-pinch-swipe into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 48s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m7s
2026-06-15 15:42:03 +00:00
Ilia Denisov 8073971fca docs: bake Telegram invite & launch refinements into ARCHITECTURE/UI_DESIGN
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 57s
- UI_DESIGN: mobile immersive fullscreen on launch (desktop keeps full-size); the
  close confirmation is now mobile-only; friend-code share-via-Telegram deep-link
  (per-bot, by service language), the friendly self-redeem note, and the
  outdated-link lobby notice.
- ARCHITECTURE: the service language rides the Session wire so the client builds
  the per-bot invite link; the friend code is shared as a startapp deep-link with
  graceful spent/expired handling.
2026-06-15 17:39:35 +02:00
Ilia Denisov 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.
2026-06-15 17:34:07 +02:00
Ilia Denisov 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).
2026-06-15 17:22:09 +02:00
Ilia Denisov 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.
2026-06-15 16:09:10 +02:00
Ilia Denisov 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.
2026-06-15 15:49:36 +02:00
Ilia Denisov 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.
2026-06-15 15:49:26 +02:00
Ilia Denisov 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.
2026-06-15 13:52:52 +02:00
Ilia Denisov 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.
2026-06-15 13:52:52 +02:00
developer 711fe6e594 Merge pull request 'feat(feedback): обратная связь от пользователей с разбором в админке и ролями' (#64) from feature/feedback into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 46s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
2026-06-15 11:30:09 +00:00