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

18 Commits

Author SHA1 Message Date
developer 621a1ac99f 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
2026-07-27 19:18:44 +00:00
Ilia Denisov 92700d0646 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
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).
2026-07-27 21:11:34 +02:00
developer f383442430 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
2026-07-27 18:52:13 +00:00
Ilia Denisov c3d36dda2d 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
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.
2026-07-27 20:45:10 +02:00
Ilia Denisov b6d88da78c 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
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.
2026-07-27 20:38:09 +02:00
developer 69b1a50644 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
2026-07-27 17:46:35 +00:00
Ilia Denisov f3c08e031e 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
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.
2026-07-27 19:37:34 +02:00
Ilia Denisov 5be8116607 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
2026-07-27 19:21:20 +02:00
Ilia Denisov e011b2ebe5 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
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.
2026-07-27 19:19:26 +02:00
Ilia Denisov 4f4768b092 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
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.
2026-07-27 19:12:37 +02:00
Ilia Denisov d7337d24ea 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
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.
2026-07-27 18:33:56 +02:00
Ilia Denisov ebd1f05da8 fix(offline): keep the local game's composition across a state reload
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.
2026-07-27 18:12:09 +02:00
developer 6ec557d33f 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
2026-07-27 15:15:00 +00:00
Ilia Denisov 9471341a0e 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
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.
2026-07-27 17:06:10 +02:00
developer 9506f89d9e 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
2026-07-27 14:03:31 +00:00
Ilia Denisov b53e23fe4d 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
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.
2026-07-27 15:59:40 +02:00
Ilia Denisov 70b88c567f feat(vk): move the tile letter to the corner, add the light cover
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.
2026-07-27 15:46:58 +02:00
Ilia Denisov 92a97c6c93 feat(vk): draft the VK community cover
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.
2026-07-27 15:21:46 +02:00