384bd143d0d50bcdefd5aa2f06fe437eb3a0e069
15 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.
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
c305363ccd |
feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place. Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent". Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated. |
||
|
|
74455c7b12 |
feat: "multiple words per turn" rule for Russian games
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 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Add a per-game rule chosen on New Game for Russian variants (default off = the
single-word rule; on = standard Scrabble). Off, only the main word along the play
direction is validated and scored; perpendicular cross-words are ignored,
including in robot move generation. The rule rides every create and enqueue
request and joins the matchmaking key, so games and auto-match stay one uniform
path; "Russian-only" is a UI affordance (English always sends standard and shows
no toggle).
- Engine: consume scrabble-solver v1.1.0's PlayOptions{IgnoreCrossWords}, threaded
through engine.Options.MultipleWordsPerTurn -> playOpts() into validate, score
and generate.
- Backend: thread the flag through game CreateParams/Game + store (games column),
lobby InvitationSettings + invitation row, and the matchmaker queue key (variant
+ rule); persisted, so a rebuilt-from-journal game keeps it. Baseline migration
gains multiple_words_per_turn (DB not versioned); jet regenerated.
- Edge: multiple_words_per_turn added to the EnqueueRequest / CreateInvitationRequest
FlatBuffers tables (Go + TS regenerated) and threaded through the gateway.
- UI: a "Multiple words per turn" toggle on New Game, shown for Russian variants
only (auto-match and friend invite), default off; English silently sends standard.
- Tests: backend engine/matchmaker; UI unit (gating) + Playwright e2e (solver
corner-case + GCG fixtures ship in v1.1.0). Docs + PRERELEASE tracker updated.
|
||
|
|
92f48a3b12 |
Backend infers play direction; UI previews words and gates submit on legality
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
A single tile that only extended a word perpendicular to the client-declared direction was rejected: the UI always sent dir=H for one-tile plays (the dirOverride/Controls toggle was orphaned in the Stage 7 game rework), so placing "А" above "БАК" to form "АБАК" failed the solver's main-word-length check even though the word is in the dictionary. Make the backend infer a play's orientation from the placed tiles and the board (internal/engine.resolveDirection): two or more tiles by the line they share, a lone tile by the axis it abuts (longer word wins, horizontal on a tie). Direction becomes an output, not an input: drop dir from the SubmitPlay/Eval wire requests and add it to EvalResult. Journal replay keeps trusting the stored "H"/"V" (SubmitPlayDir) so a rebuilt game matches the one committed. UI: stop computing/sending direction; the preview now shows the words a move forms with its total score (game.previewWords); the make-move control is disabled until the play is confirmed legal; the "your turn" label hides while tiles are pending. Delete the orphaned Controls.svelte. Regenerate the FlatBuffers bindings (Go + TS) and update the gateway transcode and the loadtest edge client to the new contract. Bake the decision into ARCHITECTURE.md (§5/§9.1), FUNCTIONAL.md (+ _ru) and the backend README. |
||
|
|
8881214213 |
R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green. |
||
|
|
4999478ded |
Stage 17 #5: hide finished games from your own lobby list
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m16s
A player can remove a finished game from their own 'my games' list. The action is per-account, finished-only and irreversible (the game stays for the other players; there is no un-hide). - backend: migration 00012 game_hidden(account_id, game_id); store HideGame + hiddenGameIDs + ListGamesForAccount filtering; service HideGame (seat + finished checks, reusing ErrNotAPlayer / ErrGameActive); POST /api/v1/user/games/:id/hide. - gateway: game.hide edge op (reuses GameActionRequest -> Ack) + backendclient.HideGame. - ui: finished rows reveal a delete via swipe-left (touch) or a kebab tap (desktop), active rows get an inert chevron for icon alignment; optimistic removal + lobby-cache sync; mock + transport + client wiring; lobby.hideGame label (en/ru). - tests: integration (active->ErrGameActive, outsider->ErrNotAPlayer, per-account, idempotent), gateway transcode round-trip, mock e2e (kebab -> delete); hardened a pre-existing chat-screen .back transition flake surfaced by the new test's timing. - docs: ARCHITECTURE persistence list, FUNCTIONAL (+ _ru) lobby story, PLAN tracker. |
||
|
|
10412fee8e |
Stage 17 round 5 — backend/correctness bug fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Failing after 12s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat. - Quick-match cancel was a UI no-op (only stopped polling): add the full path (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no later robot-substituted game). NewGame dequeues on cancel and on abandon. - Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win. The winner now takes rank 1 and the viewer is placed from rank 2 — matching the game-detail screen. - Friend request to a robot: robots no longer block requests; the request stays pending and expires (friendRequestTTL), mirroring a human who ignores it. - Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a correct message; the chat nudge button disables during the hourly cooldown; the nudge note reads 'Waiting for your move!' (button keeps the Nudge action label). Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot inttest, result.ts 0-0 resignation, nudge_too_soon mapping. |
||
|
|
90eaf4964b |
Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s
Live play now exchanges per-variant alphabet indices instead of concrete letters (rack out; submit-play, evaluate, exchange, word-check in). The client caches each variant's (index, letter, value) table behind StateRequest.include_alphabet and renders the rack and blank chooser from it, dropping the hardcoded value/alphabet tables. History, the durable journal and GCG stay decoded concrete characters (ARCHITECTURE §9.1, unchanged). - pkg/fbs: new AlphabetEntry + PlayTile; StateView.rack -> [ubyte] + alphabet; StateRequest.include_alphabet; SubmitPlay/Eval tiles -> [PlayTile]; Exchange tiles + CheckWord word -> [ubyte] (committed Go + TS regenerated). - engine: AlphabetTable + a cached per-variant codec (LetterForIndex/EncodeRack/ DecodeTiles/DecodeWord) + BlankIndex sentinel; Go parity test. - backend server edge maps index<->letter (new thin game.Service.GameVariant); game.Service domain methods, engine.Game and the robot keep one letter-based play path. The gateway forwards indices verbatim (no alphabet table). - ui: lib/alphabet.ts in-memory cache; codec encodes/decodes indices; premiums.ts is geometry-only; the mock seeds a fixture table; the UI normalises display to upper case (codec + cache), leaving placement/board/checkword unchanged. Parity moved to the Go engine.AlphabetTable test; premiums.ts loses its value tables. Discharges TODO-4. |
||
|
|
65689b903f |
Stage 7 (wip): wire remaining ops (backend REST, FBS, gateway transcode) + real UI transport
backend: REST handlers for pass/exchange/resign/hint/evaluate/check_word/complaint/history/chat-list/nudge + new game.ListForAccount (my games) + seat display_name resolution pkg/fbs: GameActionRequest/ExchangeRequest/EvalRequest/EvalResult/CheckWordRequest/WordCheckResult/ComplaintRequest/HintResult/History/GameList/ChatList + SeatView.display_name; committed Go regenerated (flatc 23.5.26) gateway: 11 new transcode ops + backendclient methods + FB encoders ui: edge TS codegen (flatc --ts + protoc-gen-es, committed), FlatBuffers<->model codec, real connect-web transport (binary, bearer auth, Subscribe). prod bundle ~69KB gzip JS |
||
|
|
408da3f201 |
Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
New public ingress and the first network edge. Framework + a vertical slice of operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7. Contracts (new module scrabble/pkg): - push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen). Backend: - REST handlers on the /api/v1 groups: internal session endpoints (telegram/guest/email login -> mint, resolve, revoke) and the user slice (profile, submit_play, state, lobby enqueue/poll, chat). - internal/notify in-process Publisher hub + internal/pushgrpc gRPC server (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found; emission in game.commit, social, matchmaker. - migration 00005 accounts.is_guest; guests are durable rows excluded from stats; ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode). Gateway (new module scrabble/gateway): - Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON transcode registry, Telegram initData HMAC validator (seam), session cache, token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push gRPC client, admin Basic-Auth reverse proxy. go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/** path filters; unit build/vet/test span all three modules. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests + guest/email-login integration tests. |