release: promote development → master (v1.25.0) #290

Merged
developer merged 18 commits from development into master 2026-07-27 20:55:08 +00:00
Owner

Promote development to master for the v1.25.0 production release. Every change below
is already merged into development and green there (run #1773); this PR is the release gate.

What ships

  • Erudit no-repeat-words rule — a word already laid on the board cannot be laid again.
    The rule is pinned per game at creation (games.no_repeat_words), not derived from the
    variant, because a game is replayed from its journal on every open: applying it retroactively
    would void an already-played repeat and rescore earlier plays. Existing games keep the
    unrestricted rule; only new erudit_ru games get it. Exposed on the wire so the client scores
    its local move preview exactly like the server.
  • A registered account now defaults to both Russian-alphabet variants — Erudit + Russian
    Scrabble. Only the column default moves, so a player who deliberately settled on one variant
    is not overruled; guests stay on Erudit alone.
  • Social controls are hidden on a deleted opponent's seat — add-friend, block, chat and
    nudge, driven by a new per-seat deleted flag.
  • Offline — a local game keeps its composition across a state reload.
  • UI — the move history opens at the newest move; the landscape rack scales its glyphs;
    the guest stubs use the "sign in" wording and Stats links it.
  • Assets — the VK community cover (day + night).

Schema and wire

Two migrations, both expand-contract and rollback-safe:

  • 00017_no_repeat_words.sql — additive boolean NOT NULL DEFAULT false column on
    backend.games, no backfill; an image rollback ignores it.
  • 00018_default_variant_preferences.sql — moves only the accounts.variant_preferences
    column default; rewrites no rows, and the previous code reads the two-element set unchanged.

FlatBuffers fields are added trailing (SeatView.deleted, GameView.no_repeat_words) —
backward-compatible for older clients.

Because the release carries migrations, the production rollout opens a maintenance window and
takes a consistent pg_dump before the roll, per deploy/README.md.

Release

Tagged v1.25.0 on master after this merge; production is then rolled by the manual
prod-deploy dispatch (confirm=deploy).

Promote `development` to `master` for the **v1.25.0** production release. Every change below is already merged into `development` and green there (run #1773); this PR is the release gate. ## What ships - **Erudit no-repeat-words rule** — a word already laid on the board cannot be laid again. The rule is **pinned per game** at creation (`games.no_repeat_words`), not derived from the variant, because a game is replayed from its journal on every open: applying it retroactively would void an already-played repeat and rescore earlier plays. Existing games keep the unrestricted rule; only new `erudit_ru` games get it. Exposed on the wire so the client scores its local move preview exactly like the server. - **A registered account now defaults to both Russian-alphabet variants** — Erudit + Russian Scrabble. Only the column default moves, so a player who deliberately settled on one variant is not overruled; guests stay on Erudit alone. - **Social controls are hidden on a deleted opponent's seat** — add-friend, block, chat and nudge, driven by a new per-seat `deleted` flag. - **Offline** — a local game keeps its composition across a state reload. - **UI** — the move history opens at the newest move; the landscape rack scales its glyphs; the guest stubs use the "sign in" wording and Stats links it. - **Assets** — the VK community cover (day + night). ## Schema and wire Two migrations, both expand-contract and rollback-safe: - `00017_no_repeat_words.sql` — additive `boolean NOT NULL DEFAULT false` column on `backend.games`, no backfill; an image rollback ignores it. - `00018_default_variant_preferences.sql` — moves only the `accounts.variant_preferences` column default; rewrites no rows, and the previous code reads the two-element set unchanged. FlatBuffers fields are **added trailing** (`SeatView.deleted`, `GameView.no_repeat_words`) — backward-compatible for older clients. Because the release carries migrations, the production rollout opens a maintenance window and takes a consistent `pg_dump` before the roll, per `deploy/README.md`. ## Release Tagged `v1.25.0` on `master` after this merge; production is then rolled by the manual `prod-deploy` dispatch (`confirm=deploy`).
developer added 18 commits 2026-07-27 20:50:57 +00:00
Adds VK's own cover template (1920x768) and a self-contained generator that
renders an Эрудит board in perspective with ГРАМОТНОСТЬ / СОПЕРНИК /
ЭРУДИЦИЯ / УМ already played, in six art-direction variants for review.

The position is checked, not asserted: assertLegal re-reads the finished grid
and fails the build unless the centre is covered, every declared word is on the
board, and no maximal run of two or more letters is a word nobody played. All
four words are in the Эрудит dictionary and tile values come from
scrabble-solver rules.Erudit().

The camera has pitch only, so the perspective warp reduces to an exact
per-scanline remap of a flat texture; tile thickness is filled as 3D quads
between the board plane and the tile plane. skia-canvas is borrowed from the
render sidecar rather than installed a second time for a one-off asset build.

Composition respects the two VK keep-outs measured off the template: the top
130 px some clients crop, and the mobile avatar circle over the bottom-left —
which is why the default word arrangement keeps that corner empty.
Sets the letter in the tile's top-left corner with the value staying bottom
right — the in-game tile layout — instead of centring it. The glyph is placed
by its ink box rather than the text origin, so every letter lands on the same
corner whatever its side bearings and ascent.

Adds day-spot: the same crop and camera as night-spot on a light ground, so the
chosen composition exists as a light/dark pair.
chore(vk): keep the two chosen covers, drop the rest
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
b53e23fe4d
The dark and light covers move up beside the generator as the shipped assets;
the six exploratory variants and the contact sheet go.

Trims the generator to match: VARIANTS is now only the two covers, and with the
retired variants gone so are the knobs nothing reaches any more — the wordmark
caption, the branded palette, the whole-board height fit and the non-brand tile
face. Left behind, they would read as supported options that no output exercises.

`node build-cover.mjs` now reproduces exactly the two committed PNGs.
Merge pull request 'feat(vk): VK community cover' (#285) from feature/vk-group-cover into development
CI / changes (push) Successful in 3s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / conformance (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Has been skipped
9506f89d9e
feat(social): hide social controls on a deleted opponent's seat
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
9471341a0e
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.

The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
Merge pull request 'feat(social): hide social controls on a deleted opponent's seat' (#286) from feature/deleted-seat-social into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m46s
6ec557d33f
A local (offline) game persisted no draft: draftGet returned an empty
string and draftSave was a no-op, on the assumption that the arrangement
is only ever rebuilt from the rack when the game is reopened. The game
screen, however, refetches the state on several triggers, and every one
of them applied that empty draft over the live composition — dropping the
player's pending tiles back into the rack and resetting the rack order.

Offline the most visible trigger fires on its own: with no network the
live-stream watchdog declares the stream dead after 25 s, the reconnect
4 s later flips app.streamAlive back to true, and the game screen's
reconnect effect refetches. Composing a move offline was therefore reset
roughly every half minute.

Store the draft in the game's own record instead, so a reload restores it
and it survives leaving and reopening the app, and clear it wherever the
turn advances (a committed move, a resignation, a host skip) so a hotseat
seat never inherits the previous player's arrangement. Gate the two
stream-driven refetches on the game being a network one: a local game
takes its events from the source, so reacting to the stream only cost it
a pointless reload.
feat(rules): forbid repeating a word already on the board in Erudit
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
d7337d24ea
Russian "Эрудит" treats a word laid on the board as belonging to the
game: it cannot be laid again. Neither the solver, the backend nor the
offline JS port knew the rule, so a player (and the robot) could replay a
word freely. Official Scrabble places no such restriction, so both
Scrabble variants keep playing unrestricted.

The rule applies in two ways. A play whose main word is already on the
board is illegal, and is neither accepted nor generated. A play whose
perpendicular cross-word is already there stands — that word is
incidental to laying the main word — but scores nothing. The set of
played words is the game's own move journal, main words and cross-words
alike, compared decoded, so a word spelled with a blank is the same word.

It lives in the game layer, not the solver: only a game knows its
history, and the solver stays stateless and standard-rules. The backend
applies it at submit, at the move preview and over generated moves
(filtering and re-ranking them, so neither the robot nor the hint can
offer a play the engine would then refuse); the client port does the same
for the offline engine and for the on-device preview of an online game.

The rule is pinned per game (games.no_repeat_words, set from the variant
at creation) rather than keyed on the variant, because a game is replayed
from its journal on every open. Applied retroactively it would make an
already-played repeat illegal — closing that game as a draw — and would
rescore a play whose cross-word repeats an earlier word, shifting a live
game's totals. Games created before the rule keep playing without it. The
flag rides the wire as a trailing field because the client's preview must
score the way the server does, and offline games pin the same answer in
their own record.
fix(rules): name a repeated word in the move preview instead of scoring it
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
4f4768b092
Composing an already-played word read as a perfectly legal move: the tiles
drew as legal, the caption showed "БРА = 6" and the  was enabled, and
only the server refused the play with the bare "illegal move" toast.

The on-device evaluator already applied the rule, but it reported the
refusal as a plain illegal play, so nothing distinguished a repeated word
from a misspelling and the caption fell back to the turn label. It now
names the word it rejected, and the game screen reads
"БРА: уже использовано" above the scores while the tiles stay marked
invalid — the composition is wrong, but for a reason the player cannot
read off the board, since the word itself is in the dictionary. The
verdict is reached on the device before the play is ever offered to the
server, in an online game exactly as in an offline one; the offline
source reports the same reason through the same field.

Only the main word is ever refused: a cross-word the game has already
used still stands (it is incidental to laying the main word) and merely
scores nothing, so the rule cannot falsely block a play in a game with
several words per turn.

The scoring of such a play is now pinned to the point on both sides: the
engine test and the client test evaluate the same position — РОТ laid
across a standing КО, completing an already-played КОТ — and assert the
same totals (10 unrestricted, 5 with the rule), so a divergence between
the two engines breaks one of them. The client test also replays the
exact test-contour position this was reported from.
build(ui): move the rule's played-word set into the lazy dict chunk
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m27s
e011b2ebe5
Building the set of words a game has already used belonged with the rest
of the rule, behind the same lazy import as the evaluator that consumes
it, rather than in the always-loaded game screen.

What is left in the app entry — the per-game flag on the decoded GameView
and the caption that names a rejected word — costs more than the budget
had free, so raise it by the usual kilobyte with the reason recorded
alongside the earlier raises.
docs(agent): note the UI job's bundle-size gate as a local pre-push step
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
5be8116607
fix(rules): case-fold the played words, so the rule fires online too
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
f3c08e031e
The no-repeat-words rule never fired in an online game. The move preview
builds its set of already-used words from the history the client holds,
and the codec upper-cases those words for display, while the evaluator
decodes the candidate word to lower — so "БРА" was never found to equal
"бра" and every repeat scored as an ordinary play, right up to the server
refusing it.

Offline was unaffected: that engine compares alphabet-index keys taken
from its own journal, which carry no case at all.

The tests missed it because they hand-built the played set in the form
the server sends rather than deriving it the way the game screen does.
They now go through playedWordsFromHistory with history records shaped as
the codec produces them — upper-cased words and all — which is the path
that was broken; three of the five fail without this fix.
Merge pull request 'fix(offline): keep the local composition; feat(rules): no repeated words in Erudit' (#287) from feature/no-repeat-words into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 24s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m48s
69b1a50644
feat(variants): default a registered account to Erudit + Russian Scrabble
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
b6d88da78c
New Game opened on a single variant for everyone, which reads poorly on VK
where Russian Scrabble is the familiar game. A registered account now starts
with both Russian-alphabet games, so the picker offers a real choice with no
pre-selection.

A guest stays on Erudit alone and is invited to register for the rest: the
device-local guest has no profile at all, so the client-side fallback has to
keep matching the server. Registering promotes the set, but only while the
guest still carries the untouched guest default. Existing accounts are not
backfilled — the set they carry may be a deliberate choice.

The Telegram promo start-param becomes additive rather than a replacement,
with English Scrabble withheld from a Russian-speaking arrival; otherwise the
English campaign link would have taken Russian Scrabble away from every
account it onboarded.
fix(ui): use the guest stubs' "sign in" wording, and link it in Stats
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
c3d36dda2d
The New Game hint said "Register" where the Stats and Profile guest stubs say
"Sign in". Move the shared word into common.signInLink so the two screens
cannot drift, and turn the Stats stub's opening word into the same link to the
profile screen.
Merge pull request 'feat(variants): default a registered account to Erudit + Russian Scrabble' (#288) from feature/default-variants-scrabble-ru into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m58s
f383442430
feat(ui): open the move history at the newest move; scale the landscape rack glyphs
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 1m18s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m6s
92700d0646
The move table fills top to bottom, oldest first, so it used to open on the
opening moves — the least interesting end of a long game. Pin it to its last
row when it is shown (the portrait drawer and the landscape dock alike), once
per showing rather than on every arriving move, so a player reading back
through the journal is not yanked to the bottom by an opponent's move.

The landscape tray shares the narrow left panel, so its tiles — and with them
the cqw-sized glyphs — come out markedly smaller than the full-width portrait
tray. Scale the letter and Erudit's blank star up a quarter there; the point
value keeps its size (it reads fine small, and growing it would crowd the
letter on a tile that size).
Merge pull request 'feat(ui): open the move history at the newest move; scale the landscape rack glyphs' (#289) from feature/history-autoscroll-landscape-rack into development
CI / changes (push) Successful in 3s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m16s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m5s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
621a1ac99f
owner approved these changes 2026-07-27 20:53:12 +00:00
developer merged commit f802db511c into master 2026-07-27 20:55:08 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#290