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

This commit was merged in pull request #290.
This commit is contained in:
2026-07-27 20:55:08 +00:00
92 changed files with 2659 additions and 127 deletions
+12
View File
@@ -23,6 +23,13 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
- **The UI job's last step is a size gate, not a test:** `cd ui && node scripts/bundle-size.mjs`
(gzip budgets per entry, `BUDGET` in that file). `pnpm build` succeeding says nothing about it, and
the app entry usually sits within a few hundred bytes of its cap, so *any* feature touching an
always-loaded screen can fail CI green-on-everything-else. Run it, and **rebuild first** — the
script measures whatever is in `dist/`, so a stale build silently measures the wrong branch. Put
new logic behind an existing lazy import where it belongs; raising `BUDGET` is allowed but the
file's header comment records the reason for every past raise — keep that up, and ask the owner.
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
@@ -90,6 +97,11 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
DTOs. Change both or they drift.
- **The client codec UPPER-CASES history words** (`lib/codec.ts` `decodeMove`) for display, while the
engine/evaluator decodes candidate words to lower. Anything comparing the two must case-fold, or it
silently never matches — this is how the Erudit no-repeat rule shipped dead on the online path
while working offline (offline compares alphabet-index keys, so it has no case at all). A test that
hand-builds such a set instead of deriving it the way the screen does will pass vacuously.
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
then bumps it from the live event. Don't expect it on the shared broadcast.
+1 -1
View File
@@ -501,7 +501,7 @@ emerge while implementing the vertical slice, step 3.)*
toggle → **Start**.
- *With friends:* rows **Invite a friend** / **Pass-and-play** → pushed sub-flows.
- **Variant picker:** **emblem chips / segmented** (≤3 variants); **hidden entirely when a single variant
is enabled** (the common default — Erudite only).
is enabled** (a guest's default — Erudite only; a registered account starts with two).
**Behaviour (per FUNCTIONAL, kept)**
- **Quick game:** AI default (instant 🤖, no wait) / Random (auto-match 2p — drops you into the game to
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
# VK community cover
The cover art for the VK community (`1920×768`). The community avatar — the «Э» tile — is
uploaded separately; this is only the backdrop behind it.
| File | What |
|------|------|
| `cover-night-spot.png` | the dark cover — board in a warm spotlight |
| `cover-day-spot.png` | the light cover — same camera, same crop, daylight |
| `build-cover.mjs` | the generator both are rendered by |
`build-cover.mjs` is a small software renderer that draws an Эрудит board in perspective with
the opening `ГРАМОТНОСТЬ / СОПЕРНИК / ЭРУДИЦИЯ / УМ` already played, and writes the two PNGs
next to itself.
```sh
cd assets/vk/cover
node build-cover.mjs # both covers
node build-cover.mjs night-spot # one of them
FAST=1 node build-cover.mjs # 1x instead of 2x supersampling, for quick iteration
ZONES=1 node build-cover.mjs # overlay the VK keep-outs on the render
LAYOUT=cross node build-cover.mjs # the alternative word arrangement
```
It needs no install: skia-canvas comes from the render sidecar's `node_modules`, and the
letters are set in `Spectral-Bold.ttf` from the icon brand folder.
## The position
`ГРАМОТНОСТЬ` opens through the centre square (its `Т` on H8) and the other three words hook
onto a shared letter. `assertLegal` re-reads the finished grid and fails the build if the
centre is uncovered, if a declared word is missing, or if any maximal run of two or more
letters is a word nobody played — so the board on the cover is always a position the engine
would accept. All four words are in the Эрудит dictionary; tile values come from
`scrabble-solver` `rules.Erudit()`.
The default `ladder` arrangement puts both long words horizontally. The `cross` arrangement
hangs `ЭРУДИЦИЯ` down the left instead, which looks more symmetric but collides with the VK
mobile avatar (below).
## VK keep-outs
Measured off VK's own template (`../VK_group_cover_template.fig`), in cover pixels:
| Zone | Area | Why |
|------|------|-----|
| Top crop | `y < 130` | trimmed by some clients — nothing important there |
| Mobile avatar | circle, centre `(283, 818)`, r `283` | the community picture sits over the bottom-left in the VK app |
`ZONES=1` paints both so a composition can be checked against them.
## Rendering technique
The camera has pitch only — no roll, no yaw — so a board-space horizontal line always maps to
a screen-space horizontal line. That makes the perspective warp an exact per-scanline remap of
a flat texture rather than a general homography: the board surface and the tile faces are each
drawn once, flat, at high resolution, then replayed band by band (`warpPlane`). Tile thickness
is filled as real 3D quads between the two planes, so the tiles have visible edges.
Two things that are easy to get wrong here and cost a hundredfold in render time:
`ctx.filter = 'blur(...)'` applies per drawing call, so many blurred shapes must be collected
into one offscreen layer and blurred once (`blurred`); and `drawImage` re-snapshots a live
`Canvas` source on every call, so a texture sampled hundreds of times must be baked into an
`Image` first (`freeze`).
+699
View File
@@ -0,0 +1,699 @@
'use strict';
// VK community cover generator — a pseudo-3D perspective view of an Эрудит board with the
// opening words ЭРУДИЦИЯ / ГРАМОТНОСТЬ / СОПЕРНИК / УМ already played.
//
// The board is a real plane in world space: the flat board texture and the flat tile-top
// texture are drawn once at high resolution and then warped by an exact per-scanline
// perspective transform (the camera has pitch only — no roll, no yaw — so every board-space
// horizontal line stays a screen-space horizontal line and the warp is a pure row remap).
// Tile thickness is drawn as real 3D quads between the two warped planes.
//
// Usage:
// node build-cover.mjs # both covers -> cover-<id>.png
// node build-cover.mjs night-spot # one of them
//
// skia-canvas is not installed here: the render sidecar already vendors it and this is a
// one-off asset build, so we borrow that copy rather than adding a second native module.
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const DIR = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { Canvas, Image, FontLibrary } = require(join(DIR, '..', '..', '..', 'renderer', 'node_modules', 'skia-canvas'));
FontLibrary.use('EruditBrand', [join(DIR, '..', '..', 'icons', 'brand', 'Spectral-Bold.ttf')]);
// ---------------------------------------------------------------------------- game state
/** BOARD_SIZE is the Эрудит board side in cells. */
const BOARD_SIZE = 15;
/** ERUDIT_BOARD is the Эрудит premium layout (ui/src/lib/premiums.ts): T/D word, t/d letter,
* '+' the non-doubling centre. */
const ERUDIT_BOARD = [
'T..d...T...d..T',
'.D...t...t...D.',
'..D...d.d...D..',
'd..D...d...D..d',
'....D.....D....',
'.t...t...t...t.',
'..d...d.d...d..',
'T..d...+...d..T',
'..d...d.d...d..',
'.t...t...t...t.',
'....D.....D....',
'd..D...d...D..d',
'..D...d.d...D..',
'.D...t...t...D.',
'T..d...T...d..T',
];
/** VALUES maps a Cyrillic letter to its Эрудит tile value (scrabble-solver rules.Erudit). */
const VALUES = {
А: 1, Б: 3, В: 2, Г: 3, Д: 2, Е: 1, Ж: 5, З: 5, И: 1, Й: 2, К: 2, Л: 2, М: 2, Н: 1, О: 1,
П: 2, Р: 2, С: 2, Т: 2, У: 3, Ф: 10, Х: 5, Ц: 10, Ч: 5, Ш: 10, Щ: 10, Ъ: 10, Ы: 5, Ь: 5,
Э: 10, Ю: 10, Я: 3,
};
/**
* OPENINGS are the legal positions the cover can show. In both, ГРАМОТНОСТЬ opens through
* the centre (7,7 — its Т) and every other word hooks onto a shared letter, with no
* incidental cross-word anywhere.
*
* - `ladder` (default): ЭРУДИЦИЯ runs horizontally low, sharing СОПЕРНИК's И. Two long
* horizontals suit the 2.5:1 cover, and the bottom-left stays empty — which is where VK
* overlays the community avatar in its mobile app.
* - `cross`: ЭРУДИЦИЯ and СОПЕРНИК both hang vertically off ГРАМОТНОСТЬ. More symmetric,
* but its left column runs into that avatar.
*/
const OPENINGS = {
ladder: [
{ word: 'ГРАМОТНОСТЬ', row: 7, col: 2, dir: 'H' },
{ word: 'СОПЕРНИК', row: 6, col: 9, dir: 'V' },
{ word: 'ЭРУДИЦИЯ', row: 12, col: 5, dir: 'H' },
{ word: 'УМ', row: 6, col: 5, dir: 'V' },
],
cross: [
{ word: 'ГРАМОТНОСТЬ', row: 7, col: 2, dir: 'H' },
{ word: 'ЭРУДИЦИЯ', row: 6, col: 3, dir: 'V' },
{ word: 'СОПЕРНИК', row: 6, col: 9, dir: 'V' },
{ word: 'УМ', row: 6, col: 5, dir: 'V' },
],
};
/** PLAYS is the opening this run renders (LAYOUT=cross switches to the other one). */
const PLAYS = OPENINGS[process.env.LAYOUT ?? 'ladder'];
/** buildBoard lays PLAYS out on an empty grid and fails loudly on a contradiction, so a
* future edit of PLAYS cannot silently produce an illegal position. */
function buildBoard() {
const g = Array.from({ length: BOARD_SIZE }, () => new Array(BOARD_SIZE).fill(null));
for (const p of PLAYS) {
const letters = Array.from(p.word);
for (let i = 0; i < letters.length; i++) {
const r = p.row + (p.dir === 'V' ? i : 0);
const c = p.col + (p.dir === 'H' ? i : 0);
if (r >= BOARD_SIZE || c >= BOARD_SIZE) throw new Error(`${p.word} runs off the board`);
if (g[r][c] && g[r][c] !== letters[i]) {
throw new Error(`${p.word} clashes at ${r},${c}: ${g[r][c]} vs ${letters[i]}`);
}
g[r][c] = letters[i];
}
}
assertLegal(g);
return g;
}
/**
* assertLegal checks the finished grid the way the engine would: the centre must be covered,
* and every maximal run of two or more letters — in either direction — must be one of the
* words in PLAYS. That is what rules out an incidental two-letter cross-word appearing
* beside a hooked word, which reading PLAYS alone cannot tell you.
*/
function assertLegal(g) {
if (!g[7][7]) throw new Error('the opening does not cover the centre square');
const declared = new Set(PLAYS.map((p) => p.word));
const runs = [];
for (let i = 0; i < BOARD_SIZE; i++) {
for (const read of [(k) => g[i][k], (k) => g[k][i]]) {
let run = '';
for (let k = 0; k <= BOARD_SIZE; k++) {
const ch = k < BOARD_SIZE ? read(k) : null;
if (ch) run += ch;
else {
if (run.length > 1) runs.push(run);
run = '';
}
}
}
}
for (const w of runs) if (!declared.has(w)) throw new Error(`incidental word formed: ${w}`);
for (const w of declared) if (!runs.includes(w)) throw new Error(`${w} is not on the board`);
}
// ------------------------------------------------------------------------------ geometry
/** World units are board cells: the 15x15 grid is centred on the origin, +x right, +y away
* from the camera, +z up out of the board. */
const HALF_GRID = BOARD_SIZE / 2;
/** FRAME_CELLS is the wooden border around the playfield, in cells. */
const FRAME_CELLS = 0.5;
/** HALF_BOARD is the half-side of the whole slab (playfield plus frame). */
const HALF_BOARD = HALF_GRID + FRAME_CELLS;
/**
* makeCamera builds a pitch-only pinhole camera looking at the board centre from elevation
* elevDeg at distance dist; focal is in output pixels and (cx, cy) is the screen point the
* board centre lands on.
*/
function makeCamera({ elevDeg, dist, focal, cx, cy }) {
const e = (elevDeg * Math.PI) / 180;
const se = Math.sin(e);
const ce = Math.cos(e);
return {
project(x, y, z) {
const vy = y + dist * ce;
const vz = z - dist * se;
const Yc = vy * se + vz * ce;
const Zc = vy * ce - vz * se;
return [cx + (focal * x) / Zc, cy - (focal * Yc) / Zc, Zc];
},
/** boardYAt inverts a screen scanline back to the board y it came from, on plane z. */
boardYAt(sy, z) {
const k = z - dist * se;
const t = (cy - sy) / focal;
const den = t * ce - se;
if (Math.abs(den) < 1e-9) return Infinity;
return (k * (ce + t * se)) / den - dist * ce;
},
};
}
/**
* frameCamera turns the art-direction knobs into a camera: elevDeg and dist set the look,
* widthFrac says how much of the output width the board's NEAR edge — its widest point on
* screen — should span, and
* (aimY, aimXFrac, aimYFrac) put the board-space row aimY at that point of the frame, which
* is how a variant keeps the played words centred while the board itself bleeds off-canvas.
*/
function frameCamera(W, H, c) {
// Depth of a board-plane row: Zc(y, 0) = dist + y·cos(elev); the near edge is the widest.
const nearZ = c.dist - HALF_BOARD * Math.cos((c.elevDeg * Math.PI) / 180);
const focal = ((c.widthFrac * W) / (2 * HALF_BOARD)) * nearZ;
// With cy = 0 the projected y is exactly proportional to the focal length, so aiming is a
// closed form: project the target row through a unit-focal camera and scale by the focal.
const unit = makeCamera({ elevDeg: c.elevDeg, dist: c.dist, focal: 1, cx: 0, cy: 0 });
const cy = (c.aimYFrac ?? 0.5) * H - focal * unit.project(0, c.aimY ?? 0, 0)[1];
// aimX is a board column placed at aimXFrac, so a variant can slide the played words in
// the frame without dragging the whole slab off one side.
const cx = (c.aimXFrac ?? 0.5) * W - focal * unit.project(c.aimX ?? 0, c.aimY ?? 0, 0)[0];
return makeCamera({ elevDeg: c.elevDeg, dist: c.dist, focal, cx, cy });
}
/**
* warpPlane draws the flat texture src — which covers the board-space rectangle
* x ∈ [-halfW, halfW], y ∈ [-halfW, halfW] with texture row 0 at the FAR edge (y = +halfW)
* — onto ctx through cam, as the plane at height z. Output is written in bands of `step`
* pixels; the source strip height follows the local compression so the receding rows are
* filtered down rather than point-sampled.
*/
function warpPlane(ctx, src, cam, z, halfW, step, clipH) {
const yTop = cam.project(0, halfW, z)[1];
const yBot = cam.project(0, -halfW, z)[1];
const span = 2 * halfW;
const from = Math.max(-step, Math.floor(yTop));
const to = Math.min(clipH + step, Math.ceil(yBot));
for (let sy = from; sy <= to; sy += step) {
const y0 = cam.boardYAt(sy, z);
const y1 = cam.boardYAt(sy + step, z);
if (!isFinite(y0) || !isFinite(y1) || y1 > y0) continue;
const v0 = ((halfW - y0) / span) * src.height;
const v1 = ((halfW - y1) / span) * src.height;
if (v1 <= 0 || v0 >= src.height) continue;
const top = Math.max(0, v0);
const h = Math.min(src.height, v1) - top;
if (h <= 0) continue;
const xL = cam.project(-halfW, y0, z)[0];
const xR = cam.project(halfW, y0, z)[0];
ctx.drawImage(src, 0, top, src.width, h, xL, sy, xR - xL, step + 0.6);
}
}
/** quad fills the screen-space polygon of the given world-space corners. */
function quad(ctx, cam, pts, fill) {
ctx.beginPath();
pts.forEach(([x, y, z], i) => {
const [sx, sy] = cam.project(x, y, z);
if (i === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
});
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
}
// ----------------------------------------------------------------------------- utilities
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/** mix blends two #rrggbb colours, t = 0 → a, t = 1 → b. */
function mix(a, b, t) {
const p = (c) => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
const [ar, ag, ab] = p(a);
const [br, bg, bb] = p(b);
const h = (v) => Math.round(v).toString(16).padStart(2, '0');
return `#${h(ar + (br - ar) * t)}${h(ag + (bg - ag) * t)}${h(ab + (bb - ab) * t)}`;
}
/**
* freeze bakes a canvas into an immutable Image. warpPlane samples its source hundreds of
* times per plane and skia re-snapshots a live Canvas source on every drawImage — two orders
* of magnitude slower than sampling a decoded Image.
*/
function freeze(canvas) {
const img = new Image();
img.src = canvas.toBufferSync('png');
return img;
}
/**
* blurred renders draw(ctx) into an offscreen w×h canvas downscaled by `shrink`, blurs it in
* a SINGLE pass and returns that small canvas. skia applies ctx.filter per drawing call, so
* blurring N shapes one by one costs N full-canvas layers — minutes at cover resolution;
* this keeps it to one blur over a small surface.
*/
function blurred(w, h, radius, shrink, draw) {
const cv = new Canvas(Math.max(1, Math.round(w / shrink)), Math.max(1, Math.round(h / shrink)));
const ctx = cv.getContext('2d');
ctx.scale(1 / shrink, 1 / shrink);
draw(ctx);
const out = new Canvas(cv.width, cv.height);
const octx = out.getContext('2d');
octx.filter = `blur(${radius / shrink}px)`;
octx.drawImage(cv, 0, 0);
return out;
}
// ------------------------------------------------------------------------ flat textures
/** TEX_CELL is the flat-texture resolution of one board cell, in texture pixels. */
const TEX_CELL = 128;
/** TEX_PAD is the frame width in texture pixels. */
const TEX_PAD = Math.round(FRAME_CELLS * TEX_CELL);
/** TEX_SIDE is the full flat-texture side (playfield plus both frame edges). */
const TEX_SIDE = BOARD_SIZE * TEX_CELL + 2 * TEX_PAD;
/** boardTexture renders the board surface — frame, premium squares, grid, centre star and
* the tiles' contact shadows — as seen straight down; row 0 sits at the texture top, which
* warpPlane maps to the far edge. */
function boardTexture(pal, grid) {
const play = BOARD_SIZE * TEX_CELL;
const cv = new Canvas(TEX_SIDE, TEX_SIDE);
const ctx = cv.getContext('2d');
// Wooden frame: a flat fill plus a diagonal light/shade so it does not read as cardboard.
ctx.fillStyle = pal.frame;
ctx.fillRect(0, 0, TEX_SIDE, TEX_SIDE);
const fg = ctx.createLinearGradient(0, 0, TEX_SIDE, TEX_SIDE);
fg.addColorStop(0, 'rgba(255,255,255,0.12)');
fg.addColorStop(1, 'rgba(0,0,0,0.18)');
ctx.fillStyle = fg;
ctx.fillRect(0, 0, TEX_SIDE, TEX_SIDE);
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
const ch = ERUDIT_BOARD[r][c];
const prem = { T: pal.prem.TW, D: pal.prem.DW, t: pal.prem.TL, d: pal.prem.DL }[ch];
// A covered square is painted neutral, not premium: the tile face is inset and rounded,
// so a bonus colour underneath leaks out as a saturated hairline around every tile.
ctx.fillStyle = grid[r][c]
? mix(pal.cell, '#000000', 0.3)
: (prem ?? ((r + c) % 2 === 1 ? pal.cellDark : pal.cell));
ctx.fillRect(TEX_PAD + c * TEX_CELL, TEX_PAD + r * TEX_CELL, TEX_CELL, TEX_CELL);
}
}
ctx.strokeStyle = pal.grid;
ctx.lineWidth = Math.max(1, TEX_CELL * 0.018);
for (let i = 0; i <= BOARD_SIZE; i++) {
const p = TEX_PAD + i * TEX_CELL;
ctx.beginPath();
ctx.moveTo(p, TEX_PAD);
ctx.lineTo(p, TEX_PAD + play);
ctx.moveTo(TEX_PAD, p);
ctx.lineTo(TEX_PAD + play, p);
ctx.stroke();
}
if (!grid[7][7]) {
ctx.fillStyle = pal.premText;
ctx.globalAlpha = 0.5;
ctx.font = `${TEX_CELL * 0.6}px EruditBrand`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('✻', TEX_PAD + 7.5 * TEX_CELL, TEX_PAD + 7.5 * TEX_CELL);
ctx.globalAlpha = 1;
}
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = TEX_CELL * 0.05;
ctx.strokeRect(TEX_PAD, TEX_PAD, play, play);
// Contact shadows, cast down-right along the board plane (light from the upper left).
const sh = blurred(TEX_SIDE, TEX_SIDE, TEX_CELL * 0.11, 2, (sctx) => {
sctx.fillStyle = 'rgba(0,0,0,0.5)';
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
if (!grid[r][c]) continue;
roundRect(
sctx,
TEX_PAD + c * TEX_CELL + TEX_CELL * 0.09,
TEX_PAD + r * TEX_CELL + TEX_CELL * 0.16,
TEX_CELL * 0.95,
TEX_CELL * 0.95,
TEX_CELL * 0.12,
);
sctx.fill();
}
}
});
ctx.drawImage(sh, 0, 0, TEX_SIDE, TEX_SIDE);
return cv;
}
/** tileTexture renders only the tile faces (letter + value), aligned to the same board
* rectangle as boardTexture so it can be warped as the plane at tile height. */
function tileTexture(pal, grid) {
const cv = new Canvas(TEX_SIDE, TEX_SIDE);
const ctx = cv.getContext('2d');
const inset = TEX_CELL * TILE_INSET;
const w = TEX_CELL - 2 * inset;
const rad = TEX_CELL * 0.12;
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
const letter = grid[r][c];
if (!letter) continue;
const x = TEX_PAD + c * TEX_CELL + inset;
const y = TEX_PAD + r * TEX_CELL + inset;
const g = ctx.createLinearGradient(x, y, x + w, y + w);
g.addColorStop(0, mix(pal.tile, '#ffffff', 0.18));
g.addColorStop(1, mix(pal.tile, '#000000', 0.07));
ctx.fillStyle = g;
roundRect(ctx, x, y, w, w, rad);
ctx.fill();
// Chamfer: a bright top-left rim and a dark bottom-right rim inside the face.
ctx.save();
roundRect(ctx, x, y, w, w, rad);
ctx.clip();
ctx.lineWidth = w * 0.06;
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.beginPath();
ctx.moveTo(x + rad * 0.4, y + w - rad * 0.4);
ctx.lineTo(x + rad * 0.4, y + rad * 0.4);
ctx.lineTo(x + w - rad * 0.4, y + rad * 0.4);
ctx.stroke();
ctx.strokeStyle = 'rgba(0,0,0,0.22)';
ctx.beginPath();
ctx.moveTo(x + w - rad * 0.4, y + rad * 0.4);
ctx.lineTo(x + w - rad * 0.4, y + w - rad * 0.4);
ctx.lineTo(x + rad * 0.4, y + w - rad * 0.4);
ctx.stroke();
ctx.restore();
// Letter in the top-left corner, value in the bottom-right — the in-game tile layout.
// The glyph is placed by its INK box, not by the text origin, so every letter lines up
// on the same corner regardless of its side bearings and ascent.
ctx.fillStyle = pal.tileText;
ctx.font = `${TEX_CELL * 0.74}px EruditBrand`;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
const m = ctx.measureText(letter);
ctx.fillText(letter, x + w * 0.1 + m.actualBoundingBoxLeft, y + w * 0.1 + m.actualBoundingBoxAscent);
ctx.font = `600 ${TEX_CELL * 0.23}px DejaVu Sans`;
ctx.textAlign = 'right';
ctx.globalAlpha = 0.8;
ctx.fillText(String(VALUES[letter] ?? 0), x + w * 0.91, y + w * 0.93);
ctx.globalAlpha = 1;
}
}
return cv;
}
// -------------------------------------------------------------------------------- scenes
/** TILE_INSET is the gap between a tile and its cell edge, as a fraction of the cell. */
const TILE_INSET = 0.02;
/** SLAB is the board's own thickness in cells. */
const SLAB = 0.55;
/**
* drawScene composes one cover onto ctx at the given supersampled size: background, board
* slab, warped board surface, extruded tile sides, warped tile faces, then the variant's
* post effects. `step` is the warp band height in device pixels.
*/
function drawScene(ctx, W, H, v, grid, step) {
const pal = v.palette;
const cam = frameCamera(W, H, v.cam);
if (process.env.DEBUG) {
const far = cam.project(-HALF_BOARD, HALF_BOARD, 0);
const near = cam.project(-HALF_BOARD, -HALF_BOARD, 0);
const f = (n, d) => (n / d).toFixed(3);
console.log(
` ${v.id}: board x ${f(near[0], W)}..${f(W - near[0], W)} y ${f(far[1], H)}..${f(near[1], H)}`,
);
}
v.background(ctx, W, H, pal);
// Ground shadow under the slab, then the two visible slab walls.
const gs = blurred(W, H, W * 0.014, 8, (sctx) => {
sctx.beginPath();
const ring = [
[-HALF_BOARD * 1.02, HALF_BOARD, -SLAB],
[HALF_BOARD * 1.02, HALF_BOARD, -SLAB],
[HALF_BOARD * 1.09, -HALF_BOARD * 1.06, -SLAB],
[-HALF_BOARD * 1.09, -HALF_BOARD * 1.06, -SLAB],
];
ring.forEach(([x, y, z], i) => {
const [sx, sy] = cam.project(x, y, z);
if (i === 0) sctx.moveTo(sx, sy);
else sctx.lineTo(sx, sy);
});
sctx.closePath();
sctx.fillStyle = 'rgba(0,0,0,0.38)';
sctx.fill();
});
ctx.drawImage(gs, 0, 0, W, H);
quad(ctx, cam, [
[-HALF_BOARD, -HALF_BOARD, 0],
[HALF_BOARD, -HALF_BOARD, 0],
[HALF_BOARD, -HALF_BOARD, -SLAB],
[-HALF_BOARD, -HALF_BOARD, -SLAB],
], mix(pal.frame, '#000000', 0.45));
for (const s of [-1, 1]) {
quad(ctx, cam, [
[s * HALF_BOARD, -HALF_BOARD, 0],
[s * HALF_BOARD, HALF_BOARD, 0],
[s * HALF_BOARD, HALF_BOARD, -SLAB],
[s * HALF_BOARD, -HALF_BOARD, -SLAB],
], mix(pal.frame, '#000000', 0.3));
}
warpPlane(ctx, freeze(boardTexture(pal, grid)), cam, 0, HALF_BOARD, step, H);
// Tile extrusion: the near wall always, plus whichever lateral wall faces the camera.
const th = v.tileHeight ?? 0.17;
for (let r = BOARD_SIZE - 1; r >= 0; r--) {
for (let c = 0; c < BOARD_SIZE; c++) {
if (!grid[r][c]) continue;
const x0 = -HALF_GRID + c + TILE_INSET;
const x1 = -HALF_GRID + c + 1 - TILE_INSET;
const y1 = HALF_GRID - r - TILE_INSET;
const y0 = HALF_GRID - r - 1 + TILE_INSET;
// The walls run from just under the board plane to just over the tile plane: both
// planes are rasterised in scanline bands, so an exact z match leaves hairline gaps.
const zb = -0.02;
const zt = th + 0.02;
quad(ctx, cam, [[x0, y0, zb], [x1, y0, zb], [x1, y0, zt], [x0, y0, zt]], pal.sideNear);
if (x0 > 0) {
quad(ctx, cam, [[x0, y0, zb], [x0, y1, zb], [x0, y1, zt], [x0, y0, zt]], pal.sideLat);
} else if (x1 < 0) {
quad(ctx, cam, [[x1, y0, zb], [x1, y1, zb], [x1, y1, zt], [x1, y0, zt]], pal.sideLat);
}
}
}
warpPlane(ctx, freeze(tileTexture(pal, grid)), cam, th, HALF_BOARD, step, H);
v.effects?.(ctx, W, H, pal);
}
// ------------------------------------------------------------------- background helpers
function linear(ctx, W, H, stops, x0, y0, x1, y1) {
const g = ctx.createLinearGradient(x0, y0, x1, y1);
stops.forEach(([p, c]) => g.addColorStop(p, c));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function radialGlow(ctx, W, H, cx, cy, r, inner, outer) {
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, inner);
g.addColorStop(1, outer);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function vignette(ctx, W, H, strength) {
const g = ctx.createRadialGradient(W / 2, H * 0.55, H * 0.3, W / 2, H * 0.55, W * 0.7);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, `rgba(0,0,0,${strength})`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
/** farHaze fades the receding top of the frame into the background colour, which reads as
* aerial depth without the cost of a real depth-of-field blur. */
function farHaze(ctx, W, H, rgb, until, strength) {
const g = ctx.createLinearGradient(0, 0, 0, H * until);
g.addColorStop(0, `rgba(${rgb},${strength})`);
g.addColorStop(1, `rgba(${rgb},0)`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H * until);
}
/** nearFade does the same at the bottom edge, so a board that bleeds out of frame dissolves
* instead of ending on a hard crop. */
function nearFade(ctx, W, H, rgb, from, strength) {
const g = ctx.createLinearGradient(0, H * from, 0, H);
g.addColorStop(0, `rgba(${rgb},0)`);
g.addColorStop(1, `rgba(${rgb},${strength})`);
ctx.fillStyle = g;
ctx.fillRect(0, H * from, W, H * (1 - from));
}
// ------------------------------------------------------------------------------ palettes
const WARM = {
frame: '#8a5a30',
cell: '#e7ece8',
cellDark: '#d9deda',
grid: 'rgba(0,0,0,0.10)',
prem: { TW: '#e06a5b', DW: '#efa6a0', TL: '#4f8fd6', DL: '#a8cdec' },
premText: '#2a2113',
tile: '#f4e2b8',
tileText: '#2a2113',
sideNear: '#c1a068',
sideLat: '#d5bd8b',
};
const NIGHT = {
...WARM,
frame: '#3d2915',
cell: '#2a3330',
cellDark: '#232b29',
grid: 'rgba(255,255,255,0.07)',
prem: { TW: '#a8483d', DW: '#8f625e', TL: '#3a6291', DL: '#546f88' },
premText: '#f2d9a0',
tile: '#eed9ab',
tileText: '#241b0e',
sideNear: '#a3854f',
sideLat: '#bda372',
};
// ------------------------------------------------------------------------------ variants
/** WORDS_Y is the board-space y of the played cluster's middle row (rows 6..13), the point a
* variant aims the camera at when the board is allowed to bleed out of frame. */
const WORDS_Y = HALF_GRID - 10;
const VARIANTS = [
{
id: 'night-spot',
title: 'Ночь — доска в луче света, слова крупно',
palette: NIGHT,
cam: { elevDeg: 27, dist: 24, widthFrac: 1.4, aimX: 0.8, aimY: WORDS_Y, aimYFrac: 0.56 },
background(ctx, W, H) {
linear(ctx, W, H, [[0, '#0c1411'], [0.6, '#131e19'], [1, '#070c0a']], 0, 0, 0, H);
radialGlow(ctx, W, H, W * 0.5, H * 0.55, W * 0.5, 'rgba(255,214,150,0.26)', 'rgba(255,214,150,0)');
},
effects(ctx, W, H) {
farHaze(ctx, W, H, '12,20,17', 0.5, 0.9);
nearFade(ctx, W, H, '7,12,10', 0.82, 0.75);
vignette(ctx, W, H, 0.6);
},
},
{
id: 'day-spot',
title: 'День — тот же кадр, светлая пара к night-spot',
palette: WARM,
cam: { elevDeg: 27, dist: 24, widthFrac: 1.4, aimX: 0.8, aimY: WORDS_Y, aimYFrac: 0.56 },
background(ctx, W, H) {
linear(ctx, W, H, [[0, '#fbf3e7'], [0.6, '#f0e2ce'], [1, '#d8bd98']], 0, 0, 0, H);
radialGlow(ctx, W, H, W * 0.5, H * 0.5, W * 0.55, 'rgba(255,250,236,0.85)', 'rgba(255,250,236,0)');
},
effects(ctx, W, H) {
farHaze(ctx, W, H, '251,243,231', 0.5, 0.9);
nearFade(ctx, W, H, '150,113,72', 0.84, 0.35);
vignette(ctx, W, H, 0.3);
},
},
];
// -------------------------------------------------------------------------- VK guides
/**
* VK_ZONES are the community-cover keep-outs measured off VK's own Figma template
* (assets/vk/VK_group_cover_template.fig), in 1920×768 cover pixels: `topCrop` is the band
* some clients shave off the top, and `avatar` is the community picture VK overlays on the
* bottom-left in the mobile app (its centre sits below the canvas).
*/
const VK_ZONES = { topCrop: 130, avatar: { cx: 283, cy: 818, r: 283 } };
/** drawZones overlays the VK keep-outs, for checking a composition (ZONES=1). */
function drawZones(ctx, W, H) {
const s = W / OUT_W;
ctx.save();
ctx.fillStyle = 'rgba(255,0,90,0.22)';
ctx.fillRect(0, 0, W, VK_ZONES.topCrop * s);
ctx.beginPath();
ctx.arc(VK_ZONES.avatar.cx * s, VK_ZONES.avatar.cy * s, VK_ZONES.avatar.r * s, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,0,90,0.9)';
ctx.lineWidth = 2 * s;
ctx.stroke();
ctx.restore();
}
// ---------------------------------------------------------------------------------- main
/** OUT_W / OUT_H are the VK community cover dimensions; SS is the supersampling factor
* (FAST=1 drops it to 1 for quick art-direction passes — the shipped asset uses 2). */
const OUT_W = 1920;
const OUT_H = 768;
const SS = process.env.FAST ? 1 : 2;
function render(v, grid) {
const big = new Canvas(OUT_W * SS, OUT_H * SS);
const bctx = big.getContext('2d');
drawScene(bctx, OUT_W * SS, OUT_H * SS, v, grid, SS);
if (process.env.ZONES) drawZones(bctx, OUT_W * SS, OUT_H * SS);
const out = new Canvas(OUT_W, OUT_H);
const octx = out.getContext('2d');
octx.imageSmoothingEnabled = true;
octx.imageSmoothingQuality = 'high';
octx.drawImage(big, 0, 0, OUT_W, OUT_H);
return out;
}
const grid = buildBoard();
const want = process.argv.slice(2);
for (const v of VARIANTS) {
if (want.length && !want.includes(v.id)) continue;
const started = Date.now();
const file = join(DIR, `cover-${v.id}.png`);
render(v, grid).toFileSync(file);
console.log(`wrote ${file} (${Date.now() - started} ms) — ${v.title}`);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

+17 -4
View File
@@ -18,6 +18,7 @@ import (
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/lib/pq"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
@@ -57,8 +58,9 @@ type Account struct {
// VariantPreferences is the set of game variants (engine.Variant stable labels:
// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
// into. It gates the New Game picker, the matchmaker and the friend-invite the
// player creates; an invited friend may still accept any variant. A new account
// defaults to Erudit only. Never empty — enforced on update and by a DB check.
// player creates; an invited friend may still accept any variant. A newly registered
// account defaults to DefaultVariantPreferences and a guest to
// GuestVariantPreferences. Never empty — enforced on update and by a DB check.
VariantPreferences []string
// IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history.
@@ -71,6 +73,11 @@ type Account struct {
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid.
MergedInto uuid.UUID
// Deleted marks a tombstoned account (accounts.deleted_at set): its credentials are
// journalled and freed and its live surfaces anonymised, but the row survives for the
// no-cascade foreign keys. Nobody is behind it any more, so it takes part in no social
// exchange (docs/ARCHITECTURE.md §9.1).
Deleted bool
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
// zero time for an unflagged account, otherwise when the gateway-reported
// rate-limiter rejections first crossed the sustained threshold. An
@@ -557,9 +564,14 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string)
if lang == "" {
lang = "en"
}
// A guest is narrower than a registered player: Эрудит alone, where the column default
// gives a registered account both Russian-alphabet games (DefaultVariantPreferences).
// The set is written explicitly for exactly that reason, and ClearGuest widens it to the
// default when the guest later registers.
stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage).
VALUES(accountID, guestDisplayName(), true, tz, lang).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage, table.Accounts.VariantPreferences).
VALUES(accountID, guestDisplayName(), true, tz, lang,
postgres.Raw("#guest_variants::text[]", map[string]interface{}{"#guest_variants": pq.StringArray(GuestVariantPreferences)})).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
@@ -630,6 +642,7 @@ func modelToAccount(row model.Accounts) Account {
IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly,
MergedInto: mergedInto,
Deleted: row.DeletedAt != nil,
FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
+16 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
"github.com/lib/pq"
"scrabble/backend/internal/postgres/jet/backend/table"
)
@@ -282,9 +283,22 @@ func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, e
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
// to a durable account once it gains its first identity. It is a no-op
// for an already-durable account.
//
// The promotion also widens the guest's narrow variant set (GuestVariantPreferences,
// Эрудит alone) to the registered default, so a player who signs in from the New Game
// hint really does gain Russian Scrabble. A guest who changed the set themselves is left
// alone: only the untouched guest default is replaced.
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())).
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.VariantPreferences, table.Accounts.UpdatedAt).
SET(postgres.Bool(false),
postgres.Raw(
"CASE WHEN variant_preferences = #guest_variants::text[] THEN #default_variants::text[] ELSE variant_preferences END",
map[string]interface{}{
"#guest_variants": pq.StringArray(GuestVariantPreferences),
"#default_variants": pq.StringArray(DefaultVariantPreferences),
},
),
postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
+40
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"math/rand/v2"
"regexp"
"slices"
"strings"
"time"
"unicode"
@@ -76,6 +77,18 @@ var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scr
// in (Erudit, Russian Scrabble, English), independent of the client's order.
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"}
// DefaultVariantPreferences is the variant set a newly registered account starts with:
// both Russian-alphabet games. It mirrors the accounts.variant_preferences column
// default, which is what actually seeds a created row — the constant is here so the
// promotion of a guest (ClearGuest) and the tests name the same set as the schema.
var DefaultVariantPreferences = []string{"erudit_ru", "scrabble_ru"}
// GuestVariantPreferences is the variant set an ephemeral guest starts with: Эрудит
// alone. A guest is deliberately narrower than a registered player — the New Game
// screen offers the single variant and invites the guest to register for the rest —
// so ProvisionGuest writes it explicitly instead of taking the column default.
var GuestVariantPreferences = []string{"erudit_ru"}
// validateVariantPreferences cleans a profile's variant-preference set: it drops
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
// returns the preferences in canonicalVariantOrder so the stored value is
@@ -129,6 +142,33 @@ func SeedVariantsFromStartParam(startParam string) []string {
return prefs
}
// MergeVariantSeed resolves the variant-preference set to store for an account that
// arrived on a promo deep link. The seed decoded from the link is *added* to current
// (the set the account already carries, normally the column default) rather than
// replacing it, so a campaign can only ever widen a player's choice. English Scrabble
// is the one variant the language gates: it is dropped from seed when languageCode is
// Russian, because a Russian-speaking arrival is served by the two Russian-alphabet
// games and the English one would only clutter New Game. It returns nil when there is
// nothing to write — an empty or invalid seed, or a merge that leaves current as it is
// — so the caller can skip the update.
func MergeVariantSeed(current, seed []string, languageCode string) []string {
if len(seed) == 0 {
return nil
}
extra := seed
if supportedLanguage(languageCode) == "ru" {
extra = slices.DeleteFunc(slices.Clone(seed), func(v string) bool { return v == "scrabble_en" })
}
merged, err := validateVariantPreferences(append(slices.Clone(current), extra...))
if err != nil {
return nil
}
if base, err := validateVariantPreferences(current); err == nil && slices.Equal(base, merged) {
return nil
}
return merged
}
// SetVariantPreferences overwrites only the variant-preference set of the account,
// cleaning it to a deduplicated, canonically ordered subset of the known variants
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
+26
View File
@@ -180,6 +180,32 @@ func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (Deletion
return info, nil
}
// DeletedIDs returns the subset of ids whose accounts are tombstoned (deleted_at set), as a
// set. It is the batch form of Account.Deleted for a caller holding several ids at once —
// the game views resolve every seat's deleted mark in one round-trip. An empty ids slice
// queries nothing and returns an empty set; an unknown id is simply absent from the result.
func (s *Store) DeletedIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]bool, error) {
out := map[uuid.UUID]bool{}
if len(ids) == 0 {
return out, nil
}
exprs := make([]postgres.Expression, len(ids))
for i, id := range ids {
exprs[i] = postgres.UUID(id)
}
stmt := postgres.SELECT(table.Accounts.AccountID).
FROM(table.Accounts).
WHERE(table.Accounts.AccountID.IN(exprs...).AND(table.Accounts.DeletedAt.IS_NOT_NULL()))
var rows []model.Accounts
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("account: deleted ids: %w", err)
}
for _, r := range rows {
out[r.AccountID] = true
}
return out, nil
}
// RetentionReaper periodically purges expired account-deletion retention data via
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
// from main.
@@ -35,3 +35,37 @@ func TestSeedVariantsFromStartParam(t *testing.T) {
})
}
}
// TestMergeVariantSeed covers resolving what a promo-onboarded account should end up with:
// the seed only ever widens the current set, English Scrabble is withheld from a
// Russian-speaking arrival, and a merge that changes nothing reports nil so the caller
// skips the write.
func TestMergeVariantSeed(t *testing.T) {
promo := []string{"erudit_ru", "scrabble_en"} // the campaign link "verudit_ru-scrabble_en"
tests := []struct {
name string
current []string
seed []string
language string
want []string
}{
{"russian speaker keeps the default", DefaultVariantPreferences, promo, "ru", nil},
{"russian locale variant is still russian", DefaultVariantPreferences, promo, "ru-RU", nil},
{"english speaker gains english", DefaultVariantPreferences, promo, "en", []string{"erudit_ru", "scrabble_ru", "scrabble_en"}},
{"unsupported language is not russian", DefaultVariantPreferences, promo, "de", []string{"erudit_ru", "scrabble_ru", "scrabble_en"}},
{"absent language is not russian", DefaultVariantPreferences, promo, "", []string{"erudit_ru", "scrabble_ru", "scrabble_en"}},
{"no seed writes nothing", DefaultVariantPreferences, nil, "en", nil},
{"seed already covered writes nothing", DefaultVariantPreferences, []string{"scrabble_ru"}, "ru", nil},
{"the russian filter spares the account's own english", []string{"erudit_ru", "scrabble_en"}, promo, "ru", nil},
{"invalid seed writes nothing", DefaultVariantPreferences, []string{"chess"}, "en", nil},
{"a guest set is widened by the seed", GuestVariantPreferences, []string{"scrabble_ru"}, "ru", DefaultVariantPreferences},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := MergeVariantSeed(tc.current, tc.seed, tc.language)
if !slices.Equal(got, tc.want) {
t.Errorf("MergeVariantSeed(%v, %v, %q) = %v, want %v", tc.current, tc.seed, tc.language, got, tc.want)
}
})
}
}
+4
View File
@@ -110,6 +110,10 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
move, err = g.noRepeat(move, g.playedWords())
if err != nil {
return MoveRecord{}, err
}
return g.decodeMove(move), nil
}
+12 -1
View File
@@ -104,6 +104,11 @@ type Options struct {
// perpendicular cross-words are ignored. Callers always set this explicitly; the
// zero value (false) is the single-word rule.
MultipleWordsPerTurn bool
// NoRepeatWords enables the "Эрудит" rule under which a word already laid on the
// board cannot be laid again (see norepeat.go). It is pinned per game rather than
// derived from the variant, so a game started before the rule existed replays and
// scores unchanged; the zero value (false) is the unrestricted rule.
NoRepeatWords bool
}
// Game is the in-memory state of a single match and the pure rules engine over
@@ -127,6 +132,7 @@ type Game struct {
resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
noRepeatWords bool // true = a word already on the board cannot be laid again
log []MoveRecord
}
@@ -164,6 +170,7 @@ func New(reg *Registry, opts Options) (*Game, error) {
resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles,
multipleWords: opts.MultipleWordsPerTurn,
noRepeatWords: opts.NoRepeatWords,
}
for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -195,6 +202,10 @@ func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRec
if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
move, err = g.noRepeat(move, g.playedWords())
if err != nil {
return MoveRecord{}, err
}
scrabble.Apply(g.board, move)
g.removeFromHand(player, placementTiles(tiles))
@@ -300,7 +311,7 @@ func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
// GenerateMoves returns every legal play for the current player's rack, ranked
// by descending score. It is empty when the player has no legal play.
func (g *Game) GenerateMoves() []scrabble.Move {
return g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
return g.noRepeatFilter(g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts()))
}
// Hint returns the highest-scoring legal play for the current player and true,
+93
View File
@@ -0,0 +1,93 @@
package engine
import (
"fmt"
"sort"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// The no-repeat-words rule of Russian "Эрудит": a word laid on the board belongs to the game
// from then on and cannot be laid again. It is a rule of that variant only — official Scrabble
// places no such restriction — and it is pinned per game (Options.NoRepeatWords) rather than
// derived from the variant, so a game started before the rule existed keeps replaying and
// scoring exactly as it did when it was played.
//
// The rule reads the game's own move log, which is the record of every word ever formed, and
// applies in two different ways:
//
// - the play's main word must not already be there — such a play is illegal, and is neither
// accepted nor offered by the move generator;
// - a perpendicular cross-word that is already there is allowed, because it is incidental to
// laying the main word, but it scores nothing.
//
// The rule is deliberately kept out of the solver, which stays a stateless, standard-rules
// engine: only this package knows a game's history.
// playedWords returns the set of words already formed in this game, as the decoded strings the
// move log records — the main word and every cross-word of every play. Both this set and the
// words a candidate move is checked against come from the same decoder, so they compare exactly.
func (g *Game) playedWords() map[string]struct{} {
played := make(map[string]struct{})
for _, rec := range g.log {
if rec.Action != ActionPlay {
continue
}
for _, w := range rec.Words {
played[w] = struct{}{}
}
}
return played
}
// noRepeat applies the no-repeat-words rule to an already validated move, given the set of words
// already played (see playedWords). It returns ErrIllegalPlay when the move's main word is one of
// them, and otherwise the move with every already-played cross-word scored down to zero and the
// total reduced to match. A game without the rule gets its move back untouched.
func (g *Game) noRepeat(m scrabble.Move, played map[string]struct{}) (scrabble.Move, error) {
if !g.noRepeatWords {
return m, nil
}
if main := g.word(m.Main); isPlayed(played, main) {
return scrabble.Move{}, fmt.Errorf("%w: word %q is already on the board", ErrIllegalPlay, main)
}
for i, cw := range m.Cross {
if !isPlayed(played, g.word(cw)) {
continue
}
m.Score -= cw.Score
m.Cross[i].Score = 0
}
return m, nil
}
// isPlayed reports whether word is in the played set. The empty string is never played: it is
// what the decoder yields for a malformed letter index, and such a word must not silently match.
func isPlayed(played map[string]struct{}, word string) bool {
if word == "" {
return false
}
_, ok := played[word]
return ok
}
// noRepeatFilter applies the rule across a generated move list: it drops the plays whose main
// word is already on the board, rescores the rest, and re-ranks them by the adjusted score. The
// sort is stable, so plays the solver ranked equally keep its order. A game without the rule
// gets its list back untouched.
func (g *Game) noRepeatFilter(moves []scrabble.Move) []scrabble.Move {
if !g.noRepeatWords {
return moves
}
played := g.playedWords()
out := moves[:0]
for _, m := range moves {
adjusted, err := g.noRepeat(m, played)
if err != nil {
continue
}
out = append(out, adjusted)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
return out
}
+188
View File
@@ -0,0 +1,188 @@
package engine
import (
"errors"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// newEruditNoRepeat builds a two-player Erudit game with the no-repeat-words rule set either
// way, plus a helper resolving a letter to its alphabet index.
func newEruditNoRepeat(t *testing.T, noRepeat bool) (*Game, func(string) byte) {
t.Helper()
g, err := New(testReg, Options{
Variant: VariantErudit,
Version: testVersion,
Players: 2,
Seed: 1,
MultipleWordsPerTurn: true,
NoRepeatWords: noRepeat,
})
if err != nil {
t.Fatalf("new erudit game: %v", err)
}
return g, func(s string) byte {
i, err := g.rules.Alphabet.Index(s)
if err != nil {
t.Fatalf("index %q: %v", s, err)
}
return i
}
}
// playKotAtCentre commits the opening horizontal КОТ across the centre square, so that the word
// is genuinely in the game's move log rather than only on the board.
func playKotAtCentre(t *testing.T, g *Game, idx func(string) byte) (row, col int) {
t.Helper()
row, col = centre(g.rules)
g.hands[0] = []byte{idx("к"), idx("о"), idx("т")}
if _, err := g.Play(scrabble.Horizontal, placementsForWord(t, g.rules, row, col, scrabble.Horizontal, "кот")); err != nil {
t.Fatalf("opening кот: %v", err)
}
return row, col
}
// TestNoRepeatRejectsAReplayedMainWord is the rule's headline case: КОТ laid once may not be laid
// again, here as a vertical play hooking the opening word's К. Without the rule the very same play
// is legal, which pins the rejection on the rule and not on the position.
func TestNoRepeatRejectsAReplayedMainWord(t *testing.T) {
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кот"); err != nil || !ok {
t.Fatalf("precondition: кот must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
}
// The second play reuses the opening К and adds О and Т below it, forming КОТ downwards.
// Neither new tile has a horizontal neighbour, so the main word is the only word formed.
replay := func(t *testing.T, g *Game, idx func(string) byte, row, col int) error {
t.Helper()
g.hands[g.toMove] = []byte{idx("о"), idx("т")}
_, err := g.Play(scrabble.Vertical, []scrabble.Placement{
{Row: row + 1, Col: col, Letter: idx("о")},
{Row: row + 2, Col: col, Letter: idx("т")},
})
return err
}
t.Run("with the rule the replayed word is illegal", func(t *testing.T) {
g, idx := newEruditNoRepeat(t, true)
row, col := playKotAtCentre(t, g, idx)
if err := replay(t, g, idx, row, col); !errors.Is(err, ErrIllegalPlay) {
t.Fatalf("replaying кот: err = %v, want ErrIllegalPlay", err)
}
})
t.Run("without the rule the same play is accepted", func(t *testing.T) {
g, idx := newEruditNoRepeat(t, false)
row, col := playKotAtCentre(t, g, idx)
if err := replay(t, g, idx, row, col); err != nil {
t.Fatalf("replaying кот without the rule: %v", err)
}
})
}
// TestNoRepeatPreviewRejectsAReplayedMainWord pins the preview to the same answer as submitting:
// EvaluatePlay must reject what Play rejects, or the player would be shown a score for a move the
// server will refuse.
func TestNoRepeatPreviewRejectsAReplayedMainWord(t *testing.T) {
g, idx := newEruditNoRepeat(t, true)
row, col := playKotAtCentre(t, g, idx)
g.hands[g.toMove] = []byte{idx("о"), idx("т")}
_, err := g.EvaluatePlay([]TileRecord{
{Row: row + 1, Col: col, Letter: "о"},
{Row: row + 2, Col: col, Letter: "т"},
})
if !errors.Is(err, ErrIllegalPlay) {
t.Fatalf("previewing a replayed кот: err = %v, want ErrIllegalPlay", err)
}
}
// TestNoRepeatDropsTheScoreOfAReplayedCrossWord covers the other half of the rule: a play whose
// perpendicular cross-word is already on the board stays legal — the cross-word is incidental to
// laying the main word — but that cross-word scores nothing. The position places РОТ so that its
// Т completes a standing КО downwards into a second КОТ.
func TestNoRepeatDropsTheScoreOfAReplayedCrossWord(t *testing.T) {
if ok, err := testReg.Lookup(VariantErudit, testVersion, "рот"); err != nil || !ok {
t.Fatalf("precondition: рот must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
}
// The main word РОТ runs along row 12; only its last tile has vertical neighbours, so КОТ
// (К and О standing at rows 10 and 11 of column 5) is the play's single cross-word.
evaluate := func(t *testing.T, noRepeat bool) MoveRecord {
t.Helper()
g, idx := newEruditNoRepeat(t, noRepeat)
playKotAtCentre(t, g, idx)
applyScaffold(t, g, idx)
g.hands[g.toMove] = []byte{idx("р"), idx("о"), idx("т")}
rec, err := g.EvaluatePlay([]TileRecord{
{Row: 12, Col: 3, Letter: "р"},
{Row: 12, Col: 4, Letter: "о"},
{Row: 12, Col: 5, Letter: "т"},
})
if err != nil {
t.Fatalf("evaluating рот (noRepeat=%v): %v", noRepeat, err)
}
return rec
}
unrestricted, restricted := evaluate(t, false), evaluate(t, true)
// Exact totals, not merely "less": the client port scores this very position in
// ui/src/lib/dict/eval.norepeat.test.ts and is pinned to the same two numbers, so a scoring
// divergence between the two engines breaks one of the two tests.
if unrestricted.Score != 10 {
t.Errorf("score without the rule = %d, want 10 (РОТ plus the КОТ cross-word)", unrestricted.Score)
}
if restricted.Score != 5 {
t.Errorf("score with the rule = %d, want 5 (РОТ alone; the repeated cross-word earns nothing)", restricted.Score)
}
// The word is still formed and still reported — it simply earns nothing.
want := []string{"рот", "кот"}
if len(restricted.Words) != len(want) || restricted.Words[0] != want[0] || restricted.Words[1] != want[1] {
t.Errorf("words with the rule = %v, want %v", restricted.Words, want)
}
}
// TestNoRepeatFiltersGeneratedMoves keeps the robot and the hint honest: a play the rule forbids
// must never be generated, or the engine would offer a move it would then refuse.
func TestNoRepeatFiltersGeneratedMoves(t *testing.T) {
g, idx := newEruditNoRepeat(t, true)
playKotAtCentre(t, g, idx)
g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")}
moves := g.GenerateMoves()
if len(moves) == 0 {
t.Fatal("no legal replies generated; the position cannot exercise the filter")
}
for _, m := range moves {
if w := g.word(m.Main); w == "кот" {
t.Fatalf("generated a play of кот, which is already on the board")
}
}
// The list stays ranked by the adjusted score, which the hint relies on.
for i := 1; i < len(moves); i++ {
if moves[i-1].Score < moves[i].Score {
t.Fatalf("generated moves not ranked: move %d scores %d, move %d scores %d",
i-1, moves[i-1].Score, i, moves[i].Score)
}
}
}
// TestNoRepeatOffLeavesTheGameUnchanged is the compatibility guard for every game started before
// the rule existed (and for both Scrabble variants, which never take it): nothing is filtered,
// nothing is rescored.
func TestNoRepeatOffLeavesTheGameUnchanged(t *testing.T) {
g, idx := newEruditNoRepeat(t, false)
playKotAtCentre(t, g, idx)
g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")}
generated := g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
if got, want := len(g.GenerateMoves()), len(generated); got != want {
t.Errorf("generated %d moves, want the solver's %d untouched", got, want)
}
}
// applyScaffold stands КО down column 5 above row 12, so a play across row 12 completes КОТ.
func applyScaffold(t *testing.T, g *Game, idx func(string) byte) {
t.Helper()
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 10, Col: 5, Letter: idx("к")},
{Row: 11, Col: 5, Letter: idx("о")},
}})
}
+1
View File
@@ -57,6 +57,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
EndReason: g.EndReason,
Seats: seats,
LastActivityUnix: last.Unix(),
NoRepeatWords: g.NoRepeatWords,
}
}
+1
View File
@@ -60,6 +60,7 @@ func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (Repla
Seed: seed,
DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
NoRepeatWords: pre.NoRepeatWords,
})
if err != nil {
return ReplayTimelineView{}, err
+11
View File
@@ -284,6 +284,13 @@ func (svc *Service) versionResident(version string) bool {
return false
}
// noRepeatWordsFor reports whether a game of variant v is created with the no-repeat-words rule
// (see engine/norepeat.go): a word already on the board cannot be laid again. It is a rule of
// Russian "Эрудит" alone — both Scrabble variants let a word be played as often as a player can
// manage. The answer is pinned into games.no_repeat_words at creation and read back from there
// afterwards, never re-derived, so games created before the rule existed keep their own answer.
func noRepeatWordsFor(v engine.Variant) bool { return v == engine.VariantErudit }
// Create starts and persists a new game seating the given accounts in turn order
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
// the player count (24), the move clock, the hint allowance and that every seat
@@ -359,6 +366,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
Seed: seed,
DropoutTiles: params.DropoutTiles,
MultipleWordsPerTurn: params.MultipleWordsPerTurn,
NoRepeatWords: noRepeatWordsFor(params.Variant),
})
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
@@ -382,6 +390,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn,
noRepeatWords: noRepeatWordsFor(params.Variant),
vsAI: params.VsAI,
kind: params.Kind,
}
@@ -445,6 +454,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn,
noRepeatWords: noRepeatWordsFor(params.Variant),
status: StatusOpen,
openDeadline: &deadline,
kind: gamelimits.KindRandom,
@@ -1584,6 +1594,7 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
Seed: seed,
DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
NoRepeatWords: pre.NoRepeatWords,
})
if err != nil {
return nil, err
+6 -1
View File
@@ -44,6 +44,8 @@ type gameInsert struct {
dropoutTiles string
// multipleWordsPerTurn false selects the single-word rule for the game.
multipleWordsPerTurn bool
// noRepeatWords true forbids laying a word that is already on the board (games.no_repeat_words).
noRepeatWords bool
// vsAI marks an honest-AI game (games.vs_ai).
vsAI bool
// kind tags the game's origin (games.game_kind) for the active-game limits.
@@ -173,8 +175,10 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
table.Games.NoRepeatWords,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind),
ins.noRepeatWords)
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
}
@@ -1355,6 +1359,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
UpdatedAt: g.UpdatedAt,
}
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
out.NoRepeatWords = g.NoRepeatWords
out.VsAI = g.VsAi
out.Kind = gamelimits.Kind(g.GameKind)
if g.EndReason != nil {
+4
View File
@@ -144,6 +144,10 @@ type Game struct {
FinishedAt *time.Time
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
MultipleWordsPerTurn bool
// NoRepeatWords true forbids laying a word that is already on the board (the "Эрудит" rule,
// see engine/norepeat.go). It is pinned at creation from the variant rather than derived on
// read, so a game started before the rule existed keeps replaying and scoring unchanged.
NoRepeatWords bool
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
VsAI bool
+76
View File
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"regexp"
"slices"
"testing"
"time"
@@ -266,6 +267,81 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
}
}
// TestVariantPreferenceDefaults covers the variant set an account is born with and how
// registration widens it: a registered account starts on both Russian-alphabet games
// (the column default), a guest on Эрудит alone, and promoting the guest lifts it to the
// registered default — but only while the guest still carries that untouched set, and
// never for an account that is already durable.
func TestVariantPreferenceDefaults(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
equal := func(t *testing.T, what string, got, want []string) {
t.Helper()
if !slices.Equal(got, want) {
t.Errorf("%s = %v, want %v", what, got, want)
}
}
// A registered account (any identity kind) takes the column default.
durable, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Player", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
equal(t, "registered account variants", durable.VariantPreferences, account.DefaultVariantPreferences)
// A guest is deliberately narrower.
guest, err := store.ProvisionGuest(ctx, "", "ru")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
equal(t, "guest variants", guest.VariantPreferences, account.GuestVariantPreferences)
// Registering the guest promotes it to a durable account on the registered default.
if err := store.ClearGuest(ctx, guest.ID); err != nil {
t.Fatalf("clear guest: %v", err)
}
promoted, err := store.GetByID(ctx, guest.ID)
if err != nil {
t.Fatalf("reload promoted guest: %v", err)
}
if promoted.IsGuest {
t.Error("promoted guest is still flagged is_guest")
}
equal(t, "promoted guest variants", promoted.VariantPreferences, account.DefaultVariantPreferences)
// A guest who picked their own set keeps it through the promotion.
picky, err := store.ProvisionGuest(ctx, "", "en")
if err != nil {
t.Fatalf("provision picky guest: %v", err)
}
if _, err := store.SetVariantPreferences(ctx, picky.ID, []string{"scrabble_en"}); err != nil {
t.Fatalf("set picky guest variants: %v", err)
}
if err := store.ClearGuest(ctx, picky.ID); err != nil {
t.Fatalf("clear picky guest: %v", err)
}
reloaded, err := store.GetByID(ctx, picky.ID)
if err != nil {
t.Fatalf("reload picky guest: %v", err)
}
equal(t, "picky guest variants", reloaded.VariantPreferences, []string{"scrabble_en"})
// An account that is already durable is never rewritten — an established player who
// settled on Эрудит alone stays there.
if _, err := store.SetVariantPreferences(ctx, durable.ID, []string{"erudit_ru"}); err != nil {
t.Fatalf("narrow durable variants: %v", err)
}
if err := store.ClearGuest(ctx, durable.ID); err != nil {
t.Fatalf("clear guest on a durable account: %v", err)
}
established, err := store.GetByID(ctx, durable.ID)
if err != nil {
t.Fatalf("reload durable account: %v", err)
}
equal(t, "established account variants", established.VariantPreferences, []string{"erudit_ru"})
}
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
// client language falls back to the account default rather than failing the
// language CHECK.
+114
View File
@@ -5,17 +5,22 @@ package inttest
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountdelete"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/server"
"scrabble/backend/internal/social"
)
// deletedFields reads a tombstoned account's retained real name and its deleted_at.
@@ -277,6 +282,115 @@ func TestDropAllRobotGames(t *testing.T) {
}
}
// TestDeletedAccountRefusesSocialActions: once an opponent's account is deleted, neither a
// friend request nor a block may target it — the client hides both controls on a deleted
// seat, and the server refuses them for an older client. The seat's deleted mark that drives
// that hiding is resolved by Store.DeletedIDs.
func TestDeletedAccountRefusesSocialActions(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
svc := newSocialService()
deleter := accountdelete.NewDeleter(testDB)
_, seats := newGameWithSeats(t, 2) // a shared game: the befriend-an-opponent gate
viewer, gone := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, viewer, gone); err != nil {
t.Fatalf("friend request before deletion = %v, want nil", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, gone); err != nil {
t.Fatalf("delete: %v", err)
}
// Deletion dropped the pending request with the rest of the account's social rows, so
// this is a fresh request against the tombstone — exactly what an older client sends
// after the 🤝 reappears on the finished game's scoreboard.
if err := svc.SendFriendRequest(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) {
t.Errorf("friend request to a deleted account = %v, want ErrAccountDeleted", err)
}
if err := svc.Block(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) {
t.Errorf("block of a deleted account = %v, want ErrAccountDeleted", err)
}
// The live opponent stays actionable — the guard is per-account, not a blanket switch.
live := provisionAccount(t)
if err := svc.Block(ctx, viewer, live); err != nil {
t.Errorf("block of a live account = %v, want nil", err)
}
deleted, err := store.DeletedIDs(ctx, []uuid.UUID{viewer, gone})
if err != nil {
t.Fatalf("deleted ids: %v", err)
}
if !deleted[gone] {
t.Error("the deleted account must be marked deleted")
}
if deleted[viewer] {
t.Error("a live account must not be marked deleted")
}
}
// TestGameStateMarksDeletedSeat: the per-viewer game state marks the seat of a deleted
// opponent, which is what hides the in-game social controls (add-friend, block, chat, nudge)
// on the client; the viewer's own live seat stays unmarked.
func TestGameStateMarksDeletedSeat(t *testing.T) {
ctx := context.Background()
games := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := games.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Games: games,
})
viewer, gone := seats[0], seats[1]
if marks := stateSeatDeleted(t, srv, g.ID, viewer); marks[0] || marks[1] {
t.Fatalf("before deletion no seat may be marked deleted, got %v", marks)
}
if err := accountdelete.NewDeleter(testDB).AnonymizeAndTombstone(ctx, gone); err != nil {
t.Fatalf("delete: %v", err)
}
marks := stateSeatDeleted(t, srv, g.ID, viewer)
if marks[0] {
t.Error("the viewer's own live seat must not be marked deleted")
}
if !marks[1] {
t.Error("the deleted opponent's seat must be marked deleted")
}
}
// stateSeatDeleted fetches viewer's game state and returns the seats' deleted marks, indexed
// by seat.
func stateSeatDeleted(t *testing.T, srv *server.Server, gameID, viewer uuid.UUID) map[int]bool {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/games/"+gameID.String()+"/state", viewer)
if rec.Code != http.StatusOK {
t.Fatalf("game state = %d (%s), want 200", rec.Code, rec.Body.String())
}
var body struct {
Game struct {
Seats []struct {
Seat int `json:"seat"`
Deleted bool `json:"deleted"`
} `json:"seats"`
} `json:"game"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode game state: %v", err)
}
out := map[int]bool{}
for _, s := range body.Game.Seats {
out[s.Seat] = s.Deleted
}
return out
}
// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no
// deeplink in the mail); a platform-only account has no email and cannot request a code.
func TestDeleteStepUpEmail(t *testing.T) {
+3 -2
View File
@@ -135,10 +135,11 @@ func TestUpdateProfilePersists(t *testing.T) {
store := account.NewStore(testDB)
acc := provisionAccount(t)
// A fresh account defaults to Erudit only (the DB-level column default).
// A fresh registered account defaults to both Russian-alphabet games (the DB-level
// column default); the lifecycle around it is covered by TestVariantPreferenceDefaults.
if def, err := store.GetByID(ctx, acc); err != nil {
t.Fatalf("get default: %v", err)
} else if want := []string{"erudit_ru"}; !slices.Equal(def.VariantPreferences, want) {
} else if want := account.DefaultVariantPreferences; !slices.Equal(def.VariantPreferences, want) {
t.Errorf("default variant preferences = %v, want %v", def.VariantPreferences, want)
}
+86
View File
@@ -0,0 +1,86 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// createTwoSeatGame starts a plain two-human game of the given variant.
func createTwoSeatGame(t *testing.T, svc *game.Service, variant engine.Variant) game.Game {
t.Helper()
g, err := svc.Create(context.Background(), game.CreateParams{
Variant: variant,
Seats: []uuid.UUID{provisionAccount(t), provisionAccount(t)},
HintsAllowed: true,
HintsPerPlayer: 1,
MultipleWordsPerTurn: true,
})
if err != nil {
t.Fatalf("create %s game: %v", variant, err)
}
return g
}
// TestNoRepeatWordsPinnedFromTheVariant checks the rule is written into games.no_repeat_words at
// creation from the variant — on for Erudit, off for both Scrabble variants — and read back with
// the game rather than re-derived.
func TestNoRepeatWordsPinnedFromTheVariant(t *testing.T) {
svc := newGameService()
for _, tc := range []struct {
variant engine.Variant
want bool
}{
{engine.VariantErudit, true},
{engine.VariantRussianScrabble, false},
{engine.VariantEnglish, false},
} {
t.Run(tc.variant.String(), func(t *testing.T) {
created := createTwoSeatGame(t, svc, tc.variant)
if created.NoRepeatWords != tc.want {
t.Errorf("created game NoRepeatWords = %v, want %v", created.NoRepeatWords, tc.want)
}
// Re-read it: the value must come from the row, not from the create call.
reloaded, err := svc.GameByID(context.Background(), created.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if reloaded.NoRepeatWords != tc.want {
t.Errorf("reloaded game NoRepeatWords = %v, want %v", reloaded.NoRepeatWords, tc.want)
}
})
}
}
// TestNoRepeatWordsHonoursThePinNotTheVariant is the compatibility guard for every Erudit game
// that existed before the rule: those rows carry no_repeat_words false (the migration's default),
// and such a game must keep playing unrestricted. Flipping the stored flag stands in for one.
func TestNoRepeatWordsHonoursThePinNotTheVariant(t *testing.T) {
ctx := context.Background()
svc := newGameService()
g := createTwoSeatGame(t, svc, engine.VariantErudit)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.games SET no_repeat_words = false WHERE game_id = $1`, g.ID); err != nil {
t.Fatalf("clear the pin: %v", err)
}
reloaded, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if reloaded.NoRepeatWords {
t.Fatal("an Erudit game whose row has the rule off must report it off")
}
// The rule reaches the engine from the row, so the reconstructed game plays unrestricted:
// its generated moves are the solver's full list, none filtered away.
if _, err := svc.Hint(ctx, reloaded.ID, reloaded.Seats[reloaded.ToMove].AccountID); err != nil {
t.Fatalf("hint on an unrestricted Erudit game: %v", err)
}
}
+25 -16
View File
@@ -19,10 +19,11 @@ import (
)
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
// to confirm a promo deep-link start-param seeds a brand-new account's variant
// preferences (English Scrabble alongside the default Erudit), that a new account with no
// such payload keeps the Erudit-only default, and that an existing account is never
// re-seeded on a later login (the new-user-only contract).
// to confirm a promo deep-link start-param widens a brand-new account's variant
// preferences English Scrabble on top of the default pair, but only for a
// non-Russian-speaking arrival — that a new account with no such payload keeps the
// default, and that an existing account is never re-seeded on a later login (the
// new-user-only contract).
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
@@ -33,8 +34,8 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
})
h := srv.Handler()
post := func(ext, startParam string) {
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
post := func(ext, language, startParam string) {
body := `{"external_id":"` + ext + `","language_code":"` + language + `","first_name":"Promo"`
if startParam != "" {
body += `,"start_param":"` + startParam + `"`
}
@@ -56,25 +57,33 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
return acc.VariantPreferences
}
// A brand-new account reached through a promo deep-link is seeded with English
// Scrabble alongside the default Erudit.
// An English-speaking arrival on a promo deep-link gains English Scrabble on top of
// the default pair, so a native English player is not lost at the door.
promoExt := "tg-" + uuid.NewString()
post(promoExt, "verudit_ru-scrabble_en")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("promo new account variants = %v, want %v", got, want)
post(promoExt, "en", "verudit_ru-scrabble_en")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("english promo account variants = %v, want %v", got, want)
}
// A brand-new account with no promo payload keeps the Erudit-only default.
// A Russian-speaking arrival on the same link stays on the default pair: the two
// Russian-alphabet games serve them, and English would only clutter New Game.
ruExt := "tg-" + uuid.NewString()
post(ruExt, "ru", "verudit_ru-scrabble_en")
if got, want := reload(ruExt), []string{"erudit_ru", "scrabble_ru"}; !slices.Equal(got, want) {
t.Errorf("russian promo account variants = %v, want %v", got, want)
}
// A brand-new account with no promo payload keeps the default.
plainExt := "tg-" + uuid.NewString()
post(plainExt, "")
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
post(plainExt, "en", "")
if got, want := reload(plainExt), []string{"erudit_ru", "scrabble_ru"}; !slices.Equal(got, want) {
t.Errorf("plain new account variants = %v, want %v", got, want)
}
// A later login of the promo account, even via a different payload, must not re-seed:
// the seed is first-contact only.
post(promoExt, "vscrabble_ru")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
post(promoExt, "en", "vscrabble_ru")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
}
}
@@ -16,10 +16,10 @@ import (
// TestVariantPreferenceGate covers the New Game gate: a player may start a quick game
// or create a friend invitation only in a variant they have enabled in their profile
// (a fresh account defaults to Erudit only), and any other variant is refused with 400.
// The complementary case — an invited friend accepting an invitation in a variant they
// have NOT enabled — is exercised by TestGameLimitGate, where a default-Erudit human
// accepts an English invitation over HTTP.
// (a fresh account defaults to the two Russian-alphabet games), and any other variant is
// refused with 400. The complementary case — an invited friend accepting an invitation in
// a variant they have NOT enabled — is exercised by TestGameLimitGate, where a
// default-preference human accepts an English invitation over HTTP.
func TestVariantPreferenceGate(t *testing.T) {
clearOpenGames(t)
srv := server.New(":0", server.Deps{
+1
View File
@@ -42,6 +42,7 @@ func toWireGame(g GameSummary) wire.GameView {
EndReason: g.EndReason,
Seats: seats,
LastActivityUnix: g.LastActivityUnix,
NoRepeatWords: g.NoRepeatWords,
}
}
+3
View File
@@ -38,6 +38,9 @@ type GameSummary struct {
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
// it rides live events so a lobby patch keeps the per-kind count correct.
Kind int
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule, pinned
// per game); it rides live events so the client's local move preview scores like the server.
NoRepeatWords bool
}
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
@@ -34,4 +34,5 @@ type Games struct {
MultipleWordsPerTurn bool
VsAi bool
GameKind int16
NoRepeatWords bool
}
@@ -38,6 +38,7 @@ type gamesTable struct {
MultipleWordsPerTurn postgres.ColumnBool
VsAi postgres.ColumnBool
GameKind postgres.ColumnInteger
NoRepeatWords postgres.ColumnBool
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -100,9 +101,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
VsAiColumn = postgres.BoolColumn("vs_ai")
GameKindColumn = postgres.IntegerColumn("game_kind")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
NoRepeatWordsColumn = postgres.BoolColumn("no_repeat_words")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
)
return gamesTable{
@@ -130,6 +132,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
VsAi: VsAiColumn,
GameKind: GameKindColumn,
NoRepeatWords: NoRepeatWordsColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,14 @@
-- The "Эрудит" no-repeat-words rule: a word laid on the board cannot be laid again. It is pinned
-- per game rather than derived from the variant, because a game is replayed from its journal on
-- every open — a rule applied retroactively would make an already-played repeat illegal (voiding
-- the game) and would rescore a play whose cross-word repeats an earlier word. Every existing game
-- therefore keeps the unrestricted rule (DEFAULT false, no backfill) and only new erudit_ru games
-- are created with it on. Additive column — applies forward via goose with no data rewrite (no
-- contour wipe); an image rollback ignores the column.
-- +goose Up
ALTER TABLE backend.games ADD COLUMN no_repeat_words boolean NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE backend.games DROP COLUMN no_repeat_words;
@@ -0,0 +1,16 @@
-- Registered players start with both Russian-alphabet games enabled: Эрудит and Russian Scrabble.
-- Only the column default moves — existing accounts keep the set they already carry, so a player
-- who deliberately settled on one variant is not overruled. Guests are the exception and stay on
-- Эрудит alone; account.ProvisionGuest therefore writes the column explicitly rather than relying
-- on this default, and account.ClearGuest widens a promoted guest to the default set. A default
-- change rewrites no rows (no contour wipe); an image rollback ignores it — the previous code
-- reads a two-element set unchanged.
-- +goose Up
ALTER TABLE backend.accounts
ALTER COLUMN variant_preferences SET DEFAULT ARRAY['erudit_ru', 'scrabble_ru']::text[];
-- +goose Down
ALTER TABLE backend.accounts
ALTER COLUMN variant_preferences SET DEFAULT ARRAY['erudit_ru']::text[];
+8
View File
@@ -132,6 +132,10 @@ type seatDTO struct {
Score int `json:"score"`
HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"`
// Deleted marks a seat whose account has been deleted: the client hides every social
// control aimed at it (add-friend, block, chat, nudge). gameDTOFromGame leaves it false
// (it does not reach the account store); fillSeatDeleted fills it.
Deleted bool `json:"deleted"`
}
// gameDTO is the shared game summary.
@@ -144,6 +148,9 @@ type gameDTO struct {
ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule, pinned
// per game). The client needs it to score its local move preview the way the server does.
NoRepeatWords bool `json:"no_repeat_words"`
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
// are suppressed in the client.
VsAI bool `json:"vs_ai"`
@@ -292,6 +299,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
NoRepeatWords: g.NoRepeatWords,
VsAI: g.VsAI,
Kind: int(g.Kind),
MoveCount: g.MoveCount,
+2
View File
@@ -314,6 +314,8 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "request_exists"
case errors.Is(err, social.ErrRequestBlocked):
return http.StatusForbidden, "request_blocked"
case errors.Is(err, social.ErrAccountDeleted):
return http.StatusGone, "account_deleted"
case errors.Is(err, social.ErrRequestNotFound):
return http.StatusNotFound, "request_not_found"
case errors.Is(err, social.ErrNoSharedGame):
+13 -10
View File
@@ -23,10 +23,11 @@ import (
// validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language; BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
// deep-link payload, which may seed the new account's variant preferences (first
// contact only). Subtype is the client-reported device family (ios/android/web);
// Telegram's initData does not sign it, so it is recorded best-effort and the
// payments gate never relies on it.
// deep-link payload, which may widen the new account's variant preferences (first
// contact only) — LanguageCode decides whether English Scrabble is part of that.
// Subtype is the client-reported device family (ios/android/web); Telegram's initData
// does not sign it, so it is recorded best-effort and the payments gate never relies
// on it.
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
@@ -56,12 +57,14 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
// joined the chat before registering is granted on the spot (no chat_member
// event fires on registration).
s.publishChatAccessChange(acc.ID)
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
// English Scrabble alongside the default Erudit). Best-effort: an absent or
// malformed payload leaves the account on its defaults, and a write failure must
// not block the session mint.
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
// A promo deep-link may widen this brand-new account's variant preferences beyond
// the default (English Scrabble for a non-Russian-speaking arrival — see
// account.MergeVariantSeed, which gates that variant on req.LanguageCode).
// Best-effort: an absent or malformed payload leaves the account on its defaults,
// and a write failure must not block the session mint.
seed := account.SeedVariantsFromStartParam(req.StartParam)
if merged := account.MergeVariantSeed(acc.VariantPreferences, seed, req.LanguageCode); len(merged) > 0 {
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, merged); err != nil {
s.log.Warn("telegram: seed variant preferences failed",
zap.String("account", acc.ID.String()), zap.Error(err))
}
+40
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
@@ -97,6 +98,42 @@ func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]
}
}
// fillSeatDeleted marks each seat whose account has been deleted, memoising across seats and
// games within one request (the same shape as fillSeatNames) and resolving the seats still
// unknown in a single batch query. A seat with no account (an open game's empty seat) is
// skipped. It is best-effort: on a store error the seats keep the false the projection left,
// which only leaves the social controls as they were before.
func (s *Server) fillSeatDeleted(ctx context.Context, g *gameDTO, memo map[string]bool) {
var ask []uuid.UUID
for _, seat := range g.Seats {
if seat.AccountID == "" {
continue
}
if _, ok := memo[seat.AccountID]; ok {
continue
}
uid, err := uuid.Parse(seat.AccountID)
if err != nil {
memo[seat.AccountID] = false
continue
}
memo[seat.AccountID] = false // resolved below; a failed lookup stays false
ask = append(ask, uid)
}
if len(ask) > 0 {
deleted, err := s.accounts.DeletedIDs(ctx, ask)
if err != nil {
s.log.Warn("seat deleted marks failed", zap.Error(err))
}
for id := range deleted {
memo[id.String()] = true
}
}
for i := range g.Seats {
g.Seats[i].Deleted = memo[g.Seats[i].AccountID]
}
}
// moveRecordDTOFromHistory projects a journal move into the shared move DTO.
func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO {
tiles := make([]tileDTO, 0, len(m.Tiles))
@@ -435,6 +472,7 @@ func (s *Server) handleListGames(c *gin.Context) {
return
}
memo := map[string]string{}
delMemo := map[string]bool{}
// Two queries seed the lobby's per-card unread badge for every listed game: which games have
// any unread entry, and which of those have a real message (so the badge can colour
// message-bearing games apart from nudge-only ones).
@@ -447,6 +485,7 @@ func (s *Server) handleListGames(c *gin.Context) {
for _, g := range games {
dto := gameDTOFromGame(g)
s.fillSeatNames(c.Request.Context(), &dto, memo)
s.fillSeatDeleted(c.Request.Context(), &dto, delMemo)
dto.UnreadChat = unread[g.ID]
dto.UnreadMessages = unreadMsg[g.ID]
out = append(out, dto)
@@ -526,6 +565,7 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
return
}
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
s.fillSeatDeleted(c.Request.Context(), &dto.Game, map[string]bool{})
if uid, ok := userID(c); ok {
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
}
+2
View File
@@ -137,6 +137,7 @@ func (s *Server) handleGameState(c *gin.Context) {
return
}
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
s.fillSeatDeleted(c.Request.Context(), &dto.Game, map[string]bool{})
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
c.JSON(http.StatusOK, dto)
}
@@ -207,6 +208,7 @@ func (s *Server) handleEnqueue(c *gin.Context) {
dto := matchDTOFrom(res)
if dto.Game != nil {
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{})
s.fillSeatDeleted(c.Request.Context(), dto.Game, map[string]bool{})
}
c.JSON(http.StatusOK, dto)
}
+10
View File
@@ -28,6 +28,16 @@ func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) e
if blockerID == blockedID {
return ErrSelfRelation
}
// A deleted account sends nothing there is anything left to suppress, and the block would
// outlive the retention purge as a dangling row: refuse it. The client already hides the
// control on a deleted seat; this guard catches an older one.
blocked, err := svc.accounts.GetByID(ctx, blockedID)
if err != nil {
return err
}
if blocked.Deleted {
return ErrAccountDeleted
}
if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
return err
}
+5
View File
@@ -73,6 +73,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
}
return err
}
if addressee.Deleted {
// The addressee deleted their account: there is nobody to answer the request, so it is
// refused rather than left pending forever.
return ErrAccountDeleted
}
if iBlockThem {
// The requester has blocked the addressee — they are the blocker and aware of it.
return ErrRequestBlocked
+5
View File
@@ -62,6 +62,11 @@ var (
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend
// request, redeem a friend code); friends are a durable-account feature.
ErrGuestForbidden = errors.New("social: guests cannot use friends")
// ErrAccountDeleted is returned when a social action targets a deleted (tombstoned)
// account: nobody is behind it any more, so it can be neither befriended nor blocked.
// The client already hides those controls on a deleted seat; this is the server source
// of truth, and the guard an older client hits.
ErrAccountDeleted = errors.New("social: this account has been deleted")
// ErrRequestNotFound is returned when no pending friend request matches.
ErrRequestNotFound = errors.New("social: no pending friend request")
// ErrNoSharedGame is returned when a friend request targets someone the
+51 -4
View File
@@ -245,9 +245,15 @@ arrive from a platform rather than completing a mandatory registration).
than stranding a user who never opened Settings on the creation-time seed.
- **Variant preferences (New Game gating).** Which variants a player may be matched into is a
per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels
(`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New
accounts default to **Erudit only** (a DB column default); at least one variant must stay
selected. The picker is ordered **Erudit-first** everywhere. The preference gates the New
(`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. A newly
**registered** account defaults to the two Russian-alphabet games — **Erudit + Russian
Scrabble** (a DB column default); a **guest** is deliberately narrower and starts on
**Erudit only** (`account.ProvisionGuest` writes the set explicitly), with New Game inviting
the guest to sign in for the rest. Signing in promotes the set to the registered default
(`account.ClearGuest`), but only while the guest still carries the untouched guest set —
a player who chose their own set is never overruled, and **existing accounts are not
backfilled** when the default moves. At least one variant must stay selected. The picker is
ordered **Erudit-first** everywhere. The preference gates the New
Game picker on every create path the player **initiates** — auto-match, vs-AI and a friend
invitation the player **creates** — and the backend **enforces** it on those paths (a chosen
variant outside the caller's preferences is rejected with HTTP 400). An **invited** friend
@@ -316,6 +322,18 @@ arrive from a platform rather than completing a mandatory registration).
> `service_language`/`supported_languages` and the push `language` routing field, and
> gained `variant_preferences` on Profile/UpdateProfile.
> **Decision (2026-07-27) — registered accounts start on both Russian games.** The
> Erudit-only default was too narrow a first impression on VK, where Russian Scrabble is the
> familiar game: a **registered** account is now created with `erudit_ru + scrabble_ru`, so
> New Game opens with a real choice (and therefore no pre-selected variant). A **guest**
> stays on Erudit alone and is invited to register for the rest, which keeps the offline
> device-local guest — who has no profile at all — matching the server. **Existing accounts
> are not backfilled**: the set a player already carries may be a deliberate choice, and the
> two are indistinguishable in the data. The Telegram promo `start_param` correspondingly
> became **additive** rather than a replacement (it can only widen a set), with English
> Scrabble withheld from a Russian-speaking arrival — otherwise the English campaign link
> would have quietly taken Russian Scrabble away from every account it onboarded.
## 4. Accounts, identities, linking & merge
- One internal account may carry several **platform identities**
@@ -398,6 +416,16 @@ arrive from a platform rather than completing a mandatory registration).
full timeline. (A merge that would otherwise leave the survivor with two identities of one
kind — e.g. each account held a confirmed email — keeps the primary's and journals the
secondary's with `reason=merge` before dropping it.)
A tombstoned account **takes part in no further social exchange**: the per-viewer game
views mark its seats (`SeatView.deleted`, resolved by a batch `accounts.deleted_at`
lookup beside the seat display names), and the client hides every control aimed at such a
seat — add-friend, block, and the chat composer (message + nudge) once no reachable
opponent is left. The live events carry the seat standings the game domain holds and so
leave the mark unset; the client seeds it from the REST-backed views and the delta
reducers preserve it. The server is the source of truth for an older client:
`SendFriendRequest` and `Block` against a tombstone return `social.ErrAccountDeleted`
(HTTP 410, code `account_deleted`) — otherwise a request would hang pending forever,
since deletion already dropped the account's social rows.
Step-up is a mailed code (`purpose=delete`, no deeplink — a stray click must not delete;
`ConfirmByToken` refuses a delete token) for an email account, else a typed phrase.
`last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled
@@ -551,6 +579,24 @@ Key points:
it on (standard). For auto-match the rule is part of the matchmaking key, so only players
who chose the same rule are paired (the rule field rides every create/enqueue request, so
matchmaking stays one uniform path).
- **No repeated words (Erudit).** A word laid on the board cannot be laid again — the "Эрудит"
rule; official Scrabble has no such restriction, so both Scrabble variants never take it. It
applies in two ways: a play whose **main word** is already on the board is illegal, and a play
whose **cross-word** is already there stands (it is incidental to laying the main word) but that
cross-word scores nothing. The set of played words is the game's own **move journal** — the main
word and every cross-word of every play, compared decoded, so a word spelled with a blank is the
same word. The rule lives in the **game layer**, not the solver: `backend/internal/engine`
(`norepeat.go`) and its client port `ui/src/lib/dict/norepeat.ts` apply 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 solver stays stateless and
standard-rules; only a game knows its history.
It is **pinned per game** (`games.no_repeat_words`, set from the variant at creation and read
back thereafter, never re-derived) 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 (§9.1) — and would rescore a play whose cross-word repeats
an earlier word. Games created before the rule therefore keep playing without it. The flag rides
the wire (`GameView.no_repeat_words`) because the client's on-device move preview must score the
way the server does; offline games pin the same answer in their own record.
- **End of game**: the bag is empty **and** a player empties their rack, **or**
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
a missed turn. The **per-game turn timeout** is chosen at creation
@@ -855,7 +901,8 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
- **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window**, the
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
Game — §3, defaulting to Erudit only, at least one enforced) and the
Game — §3, defaulting to Erudit + Russian Scrabble for a registered account and Erudit
alone for a guest, at least one enforced) and the
block toggles — all editable through `account.UpdateProfile`, which validates them:
a display name is Unicode letters joined by single ` `/`.`/`_`
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
+27 -5
View File
@@ -174,7 +174,8 @@ resigning or by letting it lapse to the 7-day timeout — drops from your *finis
automatically, with no swipe needed; a normally finished AI game stays until you remove it, and
no other game type is auto-removed. The game types offered on **New Game** are
limited to the player's chosen **variant preferences** (see *Profile & settings*) —
Erudite-first, defaulting to Erudite only. Variants are shown by their **display name** — both Scrabble variants read
Erudite-first, defaulting to Erudite + Russian Scrabble for a registered player and to Erudite
alone for a guest. Variants are shown by their **display name** — both Scrabble variants read
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
the same name titles the in-game screen. This gates only **starting** a new game you initiate —
auto-match, a vs-AI game and a friend invitation **you create** — so a player still sees and plays
@@ -196,7 +197,19 @@ the simplified **single-word rule** — only the word laid along the player's li
real word (and it must still cross or extend letters already on the board along that line),
and any incidental perpendicular words are ignored and not scored — while on is
standard Scrabble. English games are always standard and show no such toggle. In auto-match
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (24) are
the choice joins the pairing key, so a player only meets opponents who picked the same rule.
**Erudite forbids repeating a word.** A word laid on the board belongs to the game: it cannot be laid
again, and the AI never plays one either. A play whose main word is already on the board is rejected
like any illegal move — the tiles read invalid on the board and the move cannot be sent, and because
the word itself is a perfectly good one the caption above the scores names it, reading
"WORD: already used" rather than the usual score. That verdict is reached on the device, before the
move is offered to the server, in an online game exactly as in an offline one; a play that only forms an already-played word **across** its main word stands —
that word is incidental to the move — but scores nothing. The rule is not a choice: every Erudite
game takes it, and both Scrabble variants (where the official rules place no such restriction) never
do, so the variant's one-line description on **New Game** reads "no repeated words". Games started
before the rule existed keep playing without it, so a game in progress never changes rules under its
players. Friend games (24) are
formed by inviting players from the friend list (an invitation, like a friend code,
is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the
Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the
@@ -374,7 +387,12 @@ goes **disabled** while a request is pending or was declined and **disappears**
friends; both controls **disappear and the opponent's name is struck through** once you have
blocked them. They update in place the moment the relationship changes and stay correct across
reloads. Applying a block (or friend request) takes effect immediately and is confirmed by a
live event; a transport failure rolls the control back to its prior state.
live event; a transport failure rolls the control back to its prior state. Once an opponent
**deletes their account** there is nobody left to reach, so both controls disappear for that
seat for good — in the finished game as much as in one still running — and the chat composer
goes too (message and nudge) when no reachable opponent remains; the chat log and the game
itself stay readable. Attempting either action anyway (an app version that still shows the
controls) is refused with *This player has deleted their account*.
Block globally — switch off incoming chat and/or friend requests — or block an **individual
player**. A per-user block is **one-directional and silent**: you stop receiving everything
@@ -434,8 +452,12 @@ per-device preference and is hidden on desktop, where the board already fits and
**Preferences (which variants you can be matched into).** A profile setting picks the game
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
yourself to be matched into; a **new account starts with Erudite only** — unless it was created
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
yourself to be matched into; a **newly registered account starts with Erudite + Russian
Scrabble**, and arriving through the **promo bot's deep link** also enables **English Scrabble**
when your Telegram language is not Russian. A **guest** starts with **Erudite only** and New Game
invites them to **sign in** for the rest (the same wording and link as the Stats guest stub, both
routing to Settings → Profile); signing in widens the account to the registered default.
Changing the defaults never rewrites accounts that already exist. You must keep
**at least one** selected. This list is exactly what **New Game** offers when you start a game
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
+27 -6
View File
@@ -177,7 +177,8 @@ e-mail) либо ввод фразы. Активные игры форфейтя
уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий
на экране **Новая игра**
ограничены выбранными игроком **предпочтениями вариантов** (см. «Профиль и
настройки») — сначала Эрудит, по умолчанию только Эрудит. Варианты показываются под **отображаемым именем** — оба варианта Scrabble
настройки») — сначала Эрудит, по умолчанию Эрудит и русский Scrabble у зарегистрированного игрока
и только Эрудит у гостя. Варианты показываются под **отображаемым именем** — оба варианта Scrabble
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты
инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок
@@ -202,7 +203,19 @@ e-mail) либо ввод фразы. Активные игры форфейтя
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4)
кто выбрал то же правило.
**В «Эрудите» слово нельзя повторять.** Выставленное на доску слово принадлежит партии: выставить
его ещё раз нельзя, и робот такого хода тоже не делает. Ход, чьё основное слово уже стоит на доске,
отклоняется как любой недопустимый: фишки на доске подсвечиваются как недопустимые и ход не отправить,
а поскольку само слово вполне настоящее, подпись над счётом называет его — «СЛОВО: уже использовано»
вместо обычных очков. Это решение принимается на устройстве, до того как ход предложен серверу,
одинаково в онлайн- и в оффлайн-партии; ход, который лишь образует уже выставленное слово **поперёк**
основного, засчитывается — такое слово побочно для хода, — но очков не приносит. Правило не
выбирается: его берёт каждая партия «Эрудита», и никогда — оба варианта скрэббла (в официальных
правилах такого ограничения нет), поэтому однострочное описание варианта на экране **новой игры**
содержит «слова без повтора». Партии, начатые до появления правила, продолжают играть без него,
так что правила идущей партии не меняются у игроков на ходу. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
@@ -377,7 +390,12 @@ Telegram/VK** — в отличие от офлайн-режима игры вы
**исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым**
после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после
перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым
событием; при сбое транспорта контрол возвращается в прежнее состояние.
событием; при сбое транспорта контрол возвращается в прежнее состояние. Если соперник **удалил
свою учётную запись**, обращаться больше не к кому: оба контрола на этом месте исчезают
навсегда — и в завершённой партии, и в идущей, — а когда доступных соперников не остаётся,
пропадает и поле чата (сообщение и nudge); сама переписка и партия остаются доступны для
чтения. Попытка выполнить действие всё равно (версия приложения, которая ещё показывает
контролы) отклоняется сообщением *Игрок удалил свою учётную запись*.
Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного
игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека
@@ -441,9 +459,12 @@ Telegram. Внутри VK Mini App те же настройки — и язык
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
которые ты разрешаешь себя подбирать; **новый зарегистрированный аккаунт стартует с Эрудитом и
русским Scrabble**, а вход по **диплинку промо-бота** дополнительно включает **английский
Scrabble**, если язык Telegram не русский. **Гость** стартует **только с Эрудитом**, и «Новая игра»
предлагает ему **войти** ради остальных (та же формулировка и ссылка, что и у гостевой заглушки в
статистике, обе ведут в Settings → Profile); вход расширяет аккаунт до набора зарегистрированного. Смена набора по умолчанию никогда не переписывает уже существующие аккаунты.
Нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
+10 -1
View File
@@ -202,6 +202,11 @@ e2e and the screenshots.
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
shadow) pins to the board as the board slides down, instead of tracking the table as
moves accumulate; its scrollbar gutter is reserved so the centred cells do not jitter. The
table is shown **scrolled to its newest row**: the grid fills top to bottom, oldest first, so an
untouched scroller would open on the opening moves — the least interesting end. It is pinned once
per showing (in landscape, once the moves reach the already-docked table), never on each arriving
move, so a player reading back through a long game is not yanked to the bottom by an opponent's
move. The
history is a **ruled matrix** — one column per seat, aligned under the score plaques, each
seat's moves filling its column top to bottom: no player names (the plaque heads the column)
and no running total, just the word(s) and the move score, `WORD (12)`, centred. A non-play
@@ -247,7 +252,11 @@ e2e and the screenshots.
portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
behaviour and markup stay single-sourced. The rack is a **seven-column grid** filling the tray
width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes
the fixed 7th slot while a play is staged).
the fixed 7th slot while a play is staged). The landscape tray shares the narrow left panel, so its
tiles — and with them the `cqw`-sized glyphs — come out smaller than the full-width portrait tray:
the **letter** and Erudit's **blank star** are scaled up a quarter there (a `(orientation:
landscape)` query in `Rack.svelte`) to stay comfortably legible, while the point value keeps its
size — it reads fine small, and growing it would crowd the letter on a tile that size.
- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light
green once they form a word (a shade darker on the committed board tiles the word runs through —
in a **single-word game** only the main word lights up, since the engine ignores cross words),
+4 -1
View File
@@ -153,7 +153,8 @@ type MoveRecordResp struct {
Total int `json:"total"`
}
// SeatResp is one seat's public standing.
// SeatResp is one seat's public standing. Deleted marks a seat whose account has been
// deleted, so the client can hide the social controls aimed at it.
type SeatResp struct {
Seat int `json:"seat"`
AccountID string `json:"account_id"`
@@ -161,6 +162,7 @@ type SeatResp struct {
Score int `json:"score"`
HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"`
Deleted bool `json:"deleted"`
}
// GameResp is the shared game summary.
@@ -181,6 +183,7 @@ type GameResp struct {
UnreadChat bool `json:"unread_chat"`
UnreadMessages bool `json:"unread_messages"`
Kind int `json:"kind"`
NoRepeatWords bool `json:"no_repeat_words"`
}
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
+2
View File
@@ -618,6 +618,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
HintsUsed: s.HintsUsed,
IsWinner: s.IsWinner,
DisplayName: s.DisplayName,
Deleted: s.Deleted,
}
}
return wire.GameView{
@@ -637,6 +638,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
UnreadChat: g.UnreadChat,
UnreadMessages: g.UnreadMessages,
Kind: g.Kind,
NoRepeatWords: g.NoRepeatWords,
}
}
+9 -1
View File
@@ -300,7 +300,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if r.URL.Path != "/api/v1/user/games" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"unread_messages":true,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}],"at_game_limit":true}`))
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"unread_messages":true,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7,"deleted":true}]}],"at_game_limit":true}`))
})
defer cleanup()
@@ -333,6 +333,14 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if string(seat.DisplayName()) != "Ann" {
t.Errorf("seat display name = %q, want Ann", seat.DisplayName())
}
if !seat.Deleted() {
t.Error("seat deleted = false, want true (the backend marked the seat's account deleted)")
}
var own fb.SeatView
g.Seats(&own, 0)
if own.Deleted() {
t.Error("seat deleted = true for a live account, want false")
}
if !gl.AtGameLimit() {
t.Error("at_game_limit = false, want true (the backend reported the cap)")
}
+9 -1
View File
@@ -44,7 +44,9 @@ table AlphabetEntry {
}
// SeatView is one seat's public standing in a game. display_name is resolved by the
// backend from the account store (added trailing — backward-compatible).
// backend from the account store (added trailing — backward-compatible). deleted marks a
// seat whose account has been deleted (tombstoned): the client hides every social control
// aimed at it — add-friend, block, chat and nudge (added trailing).
table SeatView {
seat:int;
account_id:string;
@@ -52,6 +54,7 @@ table SeatView {
hints_used:int;
is_winner:bool;
display_name:string;
deleted:bool;
}
// GameView is the shared (non-private) game summary.
@@ -85,6 +88,11 @@ table GameView {
// caller's per-kind limits (Profile.game_limits) to lock a capped New-Game start (added
// trailing — backward-compatible).
kind:int;
// no_repeat_words true forbids laying a word that is already on the board — the "Эрудит"
// rule. It is pinned per game at creation rather than derived from the variant, so a game
// started before the rule existed keeps playing without it; the client needs it to score its
// local move preview the same way the server does (added trailing — backward-compatible).
no_repeat_words:bool;
}
// MoveRecord is one decoded move (a committed play, or a hint preview).
+16 -1
View File
@@ -221,8 +221,20 @@ func (rcv *GameView) MutateKind(n int32) bool {
return rcv._tab.MutateInt32Slot(34, n)
}
func (rcv *GameView) NoRepeatWords() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *GameView) MutateNoRepeatWords(n bool) bool {
return rcv._tab.MutateBoolSlot(36, n)
}
func GameViewStart(builder *flatbuffers.Builder) {
builder.StartObject(16)
builder.StartObject(17)
}
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
@@ -275,6 +287,9 @@ func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool
func GameViewAddKind(builder *flatbuffers.Builder, kind int32) {
builder.PrependInt32Slot(15, kind, 0)
}
func GameViewAddNoRepeatWords(builder *flatbuffers.Builder, noRepeatWords bool) {
builder.PrependBoolSlot(16, noRepeatWords, false)
}
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+16 -1
View File
@@ -105,8 +105,20 @@ func (rcv *SeatView) DisplayName() []byte {
return nil
}
func (rcv *SeatView) Deleted() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *SeatView) MutateDeleted(n bool) bool {
return rcv._tab.MutateBoolSlot(16, n)
}
func SeatViewStart(builder *flatbuffers.Builder) {
builder.StartObject(6)
builder.StartObject(7)
}
func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) {
builder.PrependInt32Slot(0, seat, 0)
@@ -126,6 +138,9 @@ func SeatViewAddIsWinner(builder *flatbuffers.Builder, isWinner bool) {
func SeatViewAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(displayName), 0)
}
func SeatViewAddDeleted(builder *flatbuffers.Builder, deleted bool) {
builder.PrependBoolSlot(6, deleted, false)
}
func SeatViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+8
View File
@@ -26,6 +26,9 @@ type SeatView struct {
HintsUsed int
IsWinner bool
DisplayName string
// Deleted marks a seat whose account has been deleted (tombstoned): the client hides
// every social control aimed at it — add-friend, block, chat and nudge.
Deleted bool
}
// GameView is the shared, non-private game summary.
@@ -52,6 +55,9 @@ type GameView struct {
// Kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game), 1 vs_ai,
// 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start.
Kind int
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule). Pinned
// per game, so a game started before the rule existed keeps playing without it.
NoRepeatWords bool
}
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
@@ -142,6 +148,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed))
fb.SeatViewAddIsWinner(b, s.IsWinner)
fb.SeatViewAddDisplayName(b, dname)
fb.SeatViewAddDeleted(b, s.Deleted)
seatOffs[i] = fb.SeatViewEnd(b)
}
fb.GameViewStartSeatsVector(b, len(seatOffs))
@@ -173,6 +180,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
fb.GameViewAddUnreadChat(b, g.UnreadChat)
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
fb.GameViewAddKind(b, int32(g.Kind))
fb.GameViewAddNoRepeatWords(b, g.NoRepeatWords)
return fb.GameViewEnd(b)
}
+26
View File
@@ -121,6 +121,32 @@ test('a two-finger pinch does not open the history (pinch-zoom vs swipe-down)',
await expect(page.locator('.history')).toHaveCount(0);
});
/** tallHistoryRows stretches the move-table cells so the seeded four-move game overflows the
* table's scroller — the condition under which "opens at the newest move" is observable at all
* (with no overflow the assertion would hold vacuously). Injected at document start, since the
* table is pinned as soon as it is shown. */
async function tallHistoryRows(page: Page): Promise<void> {
await page.addInitScript(() => {
document.addEventListener('DOMContentLoaded', () => {
const style = document.createElement('style');
style.textContent = '.hcell { min-height: 200px !important; }';
document.head.append(style);
});
});
}
test('the history opens scrolled to the newest move', async ({ page }) => {
await tallHistoryRows(page);
await openGame(page);
await page.locator('.scoreboard').click(); // open the history
const m = await page
.locator('.hgridwrap')
.evaluate((el) => ({ top: el.scrollTop, scroll: el.scrollHeight, client: el.clientHeight }));
expect(m.scroll).toBeGreaterThan(m.client); // the table really overflows — no vacuous pass
expect(m.top).toBeGreaterThanOrEqual(m.scroll - m.client - 1); // pinned to the last row
});
test('a swipe-down on the zoomed-in board does not open the history (native scroll wins)', async ({ page }) => {
await openGame(page);
// Double-tap an empty cell to zoom in (two synchronous clicks = a double-tap).
+21
View File
@@ -26,6 +26,27 @@ test('landscape lays the game in two columns with the history docked open', asyn
await expect(page.locator('.leftpane .tabbar')).toBeVisible();
});
test('the docked history shows the newest move', async ({ page }) => {
// Stretch the cells so the seeded four-move game overflows the docked table's scroller — the
// condition under which "shows the newest move" is observable at all. Injected at document
// start: in landscape the table is docked open from the mount, and it is pinned to its last row
// as soon as the moves arrive. The portrait (drawer) counterpart lives in history.spec.ts.
await page.addInitScript(() => {
document.addEventListener('DOMContentLoaded', () => {
const style = document.createElement('style');
style.textContent = '.hcell { min-height: 200px !important; }';
document.head.append(style);
});
});
await openGame(page);
const m = await page
.locator('.hgridwrap')
.evaluate((el) => ({ top: el.scrollTop, scroll: el.scrollHeight, client: el.clientHeight }));
expect(m.scroll).toBeGreaterThan(m.client); // the table really overflows — no vacuous pass
expect(m.top).toBeGreaterThanOrEqual(m.scroll - m.client - 1); // pinned to the last row
});
test('landscape zooms the board on a double-tap and the zoomed board overflows the pane', async ({ page }) => {
await openGame(page);
// Double-tap an empty cell zooms in, same as portrait. The board is height-driven here, so the
+6 -2
View File
@@ -41,8 +41,12 @@ const DIST = 'dist';
// + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded
// transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The
// heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy
// chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 130, shared: 31, landing: 5 };
// chunks. Raised to 131 for the Erudit no-repeat-words rule: the per-game flag rides the decoded
// GameView (codec + model), and the always-loaded game screen has to name the word its preview
// rejected, since a repeated word is a real word and the board alone cannot say why the play is
// refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in
// the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 131, shared: 31, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+2 -2
View File
@@ -40,8 +40,8 @@
// nobody to message or hurry. The fields still show (turn-driven), but disabled.
vsAi?: boolean;
// blocked hides the whole composer (message field, send and nudge), leaving only the chat
// log — as in a finished game — when the viewer has blocked the opponent: there is no one
// they will message (the backend rejects it too).
// log — as in a finished game — when no opponent can be reached: the viewer has blocked them
// (the backend rejects it too), or their account has been deleted.
blocked?: boolean;
onsend: (text: string) => void;
onnudge: () => void;
+6 -5
View File
@@ -36,15 +36,16 @@
const canNudge = $derived(!!view && view.game.status === 'active' && view.game.toMove !== view.seat);
// While the auto-match game still has no opponent, chat and nudge are both disabled.
const waiting = $derived(!!view && view.game.status === 'open');
// peerBlocked is true when every seated opponent is one the viewer has blocked: the whole
// composer is then hidden (a blocked opponent cannot be messaged).
const peerBlocked = $derived.by(() => {
// peerUnreachable is true when no seated opponent can be reached — each is either one the viewer
// has blocked or a deleted account: the whole composer (message field, send and nudge) is then
// hidden, leaving only the chat log.
const peerUnreachable = $derived.by(() => {
const v = view;
if (!v) return false;
const opponents = v.game.seats.filter((s) => s.seat !== v.seat && !!s.accountId);
return (
opponents.length > 0 &&
opponents.every((s) => blockedIds.has(s.accountId) || blockedRobotSeats.has(s.seat))
opponents.every((s) => s.deleted || blockedIds.has(s.accountId) || blockedRobotSeats.has(s.seat))
);
});
const nudgeCooldownSecs = 3600;
@@ -155,7 +156,7 @@
{waiting}
{nudgeOnCooldown}
vsAi={!!view && view.game.vsAi}
blocked={peerBlocked}
blocked={peerUnreachable}
onsend={sendChat}
onnudge={nudge}
/>
+56 -10
View File
@@ -419,6 +419,11 @@
// opponent_moved and game_over self-heal via the next event's move-count gap check, but
// opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two
// fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby:
//
// All three recover from the live stream, which a local (offline) game does not use at all — its
// events come from the source itself (see onMount). Reacting to the stream there would refetch
// the state for no reason, so (A) and (C) are gated on the game being a network one; (B) needs no
// gate, as a local game is never 'open' and so never waits for an opponent.
// (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed
// while it was down. The common case is a mobile suspend that drops the stream, the opponent
@@ -426,7 +431,7 @@
let wasStreamAlive = app.streamAlive;
$effect(() => {
const alive = app.streamAlive;
if (alive && !wasStreamAlive) void load();
if (alive && !wasStreamAlive && !isLocalGameId(id)) void load();
wasStreamAlive = alive;
});
@@ -446,7 +451,7 @@
const r = app.resync;
if (r !== lastResync) {
lastResync = r;
void load();
if (!isLocalGameId(id)) void load();
}
});
@@ -828,7 +833,10 @@
const reader = d.peekDawg(v.game.variant, v.game.dictVersion);
if (reader && hasAlphabet(v.game.variant)) {
try {
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn);
// Under the no-repeat-words rule the evaluator also needs the words this game has
// already formed; both it and that set live behind the same lazy import.
const played = v.game.noRepeatWords ? d.playedWordsFromHistory(moves) : undefined;
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn, played);
notePreviewLocal();
return;
} catch {
@@ -1350,6 +1358,29 @@
}
});
// The move table's scroller (the grid between the sticky header and the pinned bag footer).
let hgridEl = $state<HTMLDivElement>();
// Whether the table has already been pinned to its newest row for the current showing.
// A plain flag (not $state): the effect below writes it, and tracking it would re-run the
// effect from its own write.
let historyPinned = false;
// The history opens showing the LATEST moves: the grid fills top to bottom (oldest first), so
// an untouched scroller would open on the opening moves — the least interesting end. It is
// pinned once per showing, not on every arriving move, so a player who has scrolled back
// through a long game is not yanked to the bottom by an opponent's move. In landscape the
// table is docked open from the start, so the moves usually arrive after it mounts — hence
// the wait for a non-empty journal rather than a plain "on open".
$effect(() => {
const el = hgridEl;
if (!historyShown || !el) {
historyPinned = false;
return;
}
if (historyPinned || moves.length === 0) return;
historyPinned = true;
el.scrollTop = el.scrollHeight;
});
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
// card while the history is open), derived from the server so it is correct across reloads
// and live-updates when a request is answered: `friends` are the caller's accepted friends;
@@ -1472,13 +1503,15 @@
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled).
function canAddFriend(s: { accountId: string; seat: number }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
function canAddFriend(s: { accountId: string; seat: number; deleted?: boolean }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, a blocked player or a
// deleted account — nor in a local (offline) game, whose seats are account-less: vs_ai, and
// hotseat's synthetic seat ids.
if (view?.game.vsAi || isLocalGameId(id)) return false;
return (
netReady &&
!!s.accountId &&
!s.deleted &&
!app.profile?.isGuest &&
s.accountId !== app.session?.userId &&
!friends.has(s.accountId) &&
@@ -1488,10 +1521,18 @@
// canBlock reports whether a seat shows the ✖️ block control: like canAddFriend, but a friend
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
// An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(s: { accountId: string; seat: number }): boolean {
// An already-blocked opponent hides it (both controls go, and the name is struck); a deleted
// account hides it too — there is nobody left to block.
function canBlock(s: { accountId: string; seat: number; deleted?: boolean }): boolean {
if (view?.game.vsAi || isLocalGameId(id)) return false;
return netReady && !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
return (
netReady &&
!!s.accountId &&
!s.deleted &&
!app.profile?.isGuest &&
s.accountId !== app.session?.userId &&
!seatBlocked(s)
);
}
</script>
@@ -1553,6 +1594,11 @@
{resultText()}
{:else if preview?.legal && !recallOverRack}
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
{:else if preview?.repeatedWord && !recallOverRack}
<!-- A real word, rejected only because this game has already used it (the Erudit
no-repeat rule): the tiles read invalid, and the caption says which word and why —
otherwise the player is left staring at a word they know is in the dictionary. -->
{preview.repeatedWord.toUpperCase()}{': '}{t('game.wordAlreadyUsed')}
{:else}
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
{/if}
@@ -1634,7 +1680,7 @@
</button>
{/if}
</div>
<div class="hgridwrap">
<div class="hgridwrap" bind:this={hgridEl}>
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
{#each row as cell (cell.player)}
+15
View File
@@ -159,4 +159,19 @@
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
font-size: 7.6cqw;
}
/* Landscape docks the rack in the narrow left panel (Game.svelte's .leftpane), so its tiles —
and with them the cqw-sized glyphs — come out markedly smaller than the full-width portrait
tray. Scale the tile face up by a quarter there so the letters stay comfortably legible; the
point value keeps its size (it reads fine small, and growing it would crowd the letter on a
tile this size). The media query matches the one that selects the landscape layout. */
@media (orientation: landscape) {
.letter {
font-size: 7.5vmin; /* cqw fallback for Chrome < 105 */
font-size: 7.5cqw;
}
.star {
font-size: 9.5vmin; /* cqw fallback for Chrome < 105 */
font-size: 9.5cqw;
}
}
</style>
+12 -2
View File
@@ -118,8 +118,13 @@ kind():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
noRepeatWords():boolean {
const offset = this.bb!.__offset(this.bb_pos, 36);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startGameView(builder:flatbuffers.Builder) {
builder.startObject(16);
builder.startObject(17);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -198,12 +203,16 @@ static addKind(builder:flatbuffers.Builder, kind:number) {
builder.addFieldInt32(15, kind, 0);
}
static addNoRepeatWords(builder:flatbuffers.Builder, noRepeatWords:boolean) {
builder.addFieldInt8(16, +noRepeatWords, +false);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number):flatbuffers.Offset {
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number, noRepeatWords:boolean):flatbuffers.Offset {
GameView.startGameView(builder);
GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset);
@@ -221,6 +230,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addUnreadChat(builder, unreadChat);
GameView.addUnreadMessages(builder, unreadMessages);
GameView.addKind(builder, kind);
GameView.addNoRepeatWords(builder, noRepeatWords);
return GameView.endGameView(builder);
}
}
+12 -2
View File
@@ -54,8 +54,13 @@ displayName(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
deleted():boolean {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startSeatView(builder:flatbuffers.Builder) {
builder.startObject(6);
builder.startObject(7);
}
static addSeat(builder:flatbuffers.Builder, seat:number) {
@@ -82,12 +87,16 @@ static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers
builder.addFieldOffset(5, displayNameOffset, 0);
}
static addDeleted(builder:flatbuffers.Builder, deleted:boolean) {
builder.addFieldInt8(6, +deleted, +false);
}
static endSeatView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createSeatView(builder:flatbuffers.Builder, seat:number, accountIdOffset:flatbuffers.Offset, score:number, hintsUsed:number, isWinner:boolean, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
static createSeatView(builder:flatbuffers.Builder, seat:number, accountIdOffset:flatbuffers.Offset, score:number, hintsUsed:number, isWinner:boolean, displayNameOffset:flatbuffers.Offset, deleted:boolean):flatbuffers.Offset {
SeatView.startSeatView(builder);
SeatView.addSeat(builder, seat);
SeatView.addAccountId(builder, accountIdOffset);
@@ -95,6 +104,7 @@ static createSeatView(builder:flatbuffers.Builder, seat:number, accountIdOffset:
SeatView.addHintsUsed(builder, hintsUsed);
SeatView.addIsWinner(builder, isWinner);
SeatView.addDisplayName(builder, displayNameOffset);
SeatView.addDeleted(builder, deleted);
return SeatView.endSeatView(builder);
}
}
+7
View File
@@ -400,6 +400,7 @@ describe('codec', () => {
fb.SeatView.addHintsUsed(b, 0);
fb.SeatView.addIsWinner(b, false);
fb.SeatView.addDisplayName(b, dn);
fb.SeatView.addDeleted(b, true);
const seat = fb.SeatView.endSeatView(b);
const seats = fb.GameView.createSeatsVector(b, [seat]);
const id = b.createString('g1');
@@ -422,6 +423,7 @@ describe('codec', () => {
fb.GameView.addVsAi(b, true);
fb.GameView.addUnreadChat(b, true);
fb.GameView.addKind(b, 2);
fb.GameView.addNoRepeatWords(b, true);
const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
@@ -434,10 +436,15 @@ describe('codec', () => {
expect(gl.games[0].id).toBe('g1');
expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13);
// The deleted mark rides the seat: it hides the social controls aimed at that seat.
expect(gl.games[0].seats[0].deleted).toBe(true);
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
expect(gl.games[0].vsAi).toBe(true);
expect(gl.games[0].unreadChat).toBe(true);
expect(gl.games[0].kind).toBe(2);
// The Erudit no-repeat rule is pinned per game and rides the view: the on-device move
// preview needs it to score the way the server does.
expect(gl.games[0].noRepeatWords).toBe(true);
expect(gl.atGameLimit).toBe(true);
});
+3
View File
@@ -291,6 +291,7 @@ function decodeSeat(v: fb.SeatView): Seat {
score: v.score(),
hintsUsed: v.hintsUsed(),
isWinner: v.isWinner(),
deleted: v.deleted(),
};
}
@@ -309,6 +310,7 @@ function decodeGameView(g: fb.GameView): GameView {
toMove: g.toMove(),
turnTimeoutSecs: g.turnTimeoutSecs(),
multipleWordsPerTurn: g.multipleWordsPerTurn(),
noRepeatWords: g.noRepeatWords(),
moveCount: g.moveCount(),
endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()),
@@ -1142,6 +1144,7 @@ function emptyGame(): GameView {
toMove: 0,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { evaluateLocal } from './eval';
import { playedWordsFromHistory } from './norepeat';
import { seedMockAlphabets } from '../mock/alphabet';
import { emptyBoard, type Board } from '../board';
import type { Dict } from './validate';
import type { PlacedTile } from '../client';
import type { MoveRecord } from '../model';
// The no-repeat-words rule as the game screen sees it: through evaluateLocal, the on-device move
// preview, which decides the rule before a play ever reaches the server. The dictionary is stubbed
// to accept every word, so what these tests pin is the rule and the letter<->index adapter around
// it, not the word list (the Go/JS agreement on plain scoring is pinned by the parity suites).
const anyWord: Dict = { indexOf: () => 0 };
// place fills a board from "row,col,letter" triples.
function place(board: Board, cells: [number, number, string][]): Board {
for (const [row, col, letter] of cells) board[row][col] = { letter, blank: false };
return board;
}
function tile(row: number, col: number, letter: string): PlacedTile {
return { row, col, letter, blank: false };
}
// playRecord is one history entry as the client holds it — note the UPPER-CASE words, which is how
// the codec decodes them for display (lib/codec decodeMove). Building the played set from records
// in this shape is what the game screen actually does, so these tests go through it rather than
// hand-writing the set; comparing the codec's case against the evaluator's would otherwise silently
// never match and the rule would never fire.
function playRecord(...words: string[]): MoveRecord {
return {
player: 0,
action: 'play',
dir: 'H',
mainRow: 0,
mainCol: 0,
tiles: [],
words: words.map((w) => w.toUpperCase()),
count: 0,
score: 0,
total: 0,
};
}
// passRecord is a non-play entry, which contributes no words.
function passRecord(): MoveRecord {
return { player: 0, action: 'pass', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
}
beforeAll(() => seedMockAlphabets());
describe('evaluateLocal under the no-repeat-words rule', () => {
// The position is a real one from the test contour, where the rule rejected the play on the
// server while the composition still read as legal on the board. БРА was laid on move 4 (row 5),
// and this play hooks the Р of ПРИНТ to spell it again downwards in column 3.
function contourBoard(): Board {
return place(emptyBoard(), [
[0, 7, 'о'], [1, 7, 'в'], [2, 7, 'е'],
[2, 5, 'д'], [2, 6, 'ж'], [2, 8, 'б'],
[3, 7, 'н'], [3, 8, 'а'], [3, 9, 'е'], [3, 10, 'м'],
[4, 10, 'а'],
[5, 3, 'п'], [5, 4, 'о'], [5, 5, 'й'], [5, 6, 'к'], [5, 7, 'а'], [5, 9, 'б'], [5, 10, 'р'], [5, 11, 'а'],
[6, 0, 'п'], [6, 3, 'у'], [6, 6, 'о'], [6, 10, 'а'],
[7, 0, 'л'], [7, 1, 'и'], [7, 2, 'с'], [7, 3, 'т'], [7, 6, 'т'], [7, 7, 'р'], [7, 8, 'а'], [7, 9, 'в'], [7, 10, 'л'], [7, 11, 'я'],
[8, 0, 'у'], [8, 2, 'м'], [8, 3, 'ы'], [8, 4, 'с'], [8, 5, 'и'], [8, 6, 'к'],
[9, 0, 'г'], [9, 4, 'у'],
[10, 4, 'ш'],
[11, 2, 'п'], [11, 3, 'р'], [11, 4, 'и'], [11, 5, 'н'], [11, 6, 'т'],
]);
}
// Б above the standing Р and А below it spell БРА downwards.
const bra: PlacedTile[] = [tile(10, 3, 'б'), tile(12, 3, 'а')];
const history: MoveRecord[] = [
playRecord('травля'), playRecord('марал'), playRecord('кот'), playRecord('наем'), playRecord('бра'),
playRecord('пойка'), playRecord('овен'), playRecord('джеб'), playRecord('путы'), playRecord('мысик'),
playRecord('суши'), playRecord('лист'), playRecord('плуг'), playRecord('принт'), passRecord(),
];
const played = playedWordsFromHistory(history);
it('scores the play when the game does not take the rule', () => {
// The control: the same play in a game without the rule is a perfectly ordinary six-pointer.
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false);
expect(res.legal).toBe(true);
expect(res.words).toEqual(['бра']);
expect(res.score).toBe(6);
expect(res.repeatedWord).toBeUndefined();
});
it('rejects the play and names the word when the game has already used it', () => {
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false, played);
expect(res.legal).toBe(false);
expect(res.score).toBe(0);
// Named, so the caption can read "БРА: уже использовано" instead of the bare turn label.
expect(res.repeatedWord).toBe('бра');
});
it('leaves the play alone when the rule is in force but the word is new', () => {
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false, playedWordsFromHistory([playRecord('кот')]));
expect(res.legal).toBe(true);
expect(res.score).toBe(6);
});
});
describe('evaluateLocal under the rule with several words per turn', () => {
// The same position the server engine scores in backend/internal/engine/norepeat_test.go
// (TestNoRepeatDropsTheScoreOfAReplayedCrossWord), pinned to the same two totals: КОТ stands
// across the centre and again down column 5, and РОТ laid across row 12 completes the second
// one as a cross-word. Both engines must agree to the point, or one of the two tests breaks.
function crossBoard(): Board {
return place(emptyBoard(), [
[7, 7, 'к'], [7, 8, 'о'], [7, 9, 'т'], // the opening КОТ, away from the play
[10, 5, 'к'], [11, 5, 'о'], // the standing КО the play completes
]);
}
const rot: PlacedTile[] = [tile(12, 3, 'р'), tile(12, 4, 'о'), tile(12, 5, 'т')];
it('keeps the play legal when only a cross-word repeats, and stops scoring that word', () => {
const free = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true);
expect(free.legal).toBe(true);
expect(free.words).toEqual(['рот', 'кот']);
expect(free.score).toBe(10); // РОТ plus the КОТ cross-word
const restricted = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, playedWordsFromHistory([playRecord('кот')]));
expect(restricted.legal).toBe(true);
expect(restricted.repeatedWord).toBeUndefined();
// The cross-word is still formed and still reported — it just earns nothing.
expect(restricted.words).toEqual(['рот', 'кот']);
expect(restricted.score).toBe(5);
});
it('rejects the play when the main word repeats, whatever its cross-words do', () => {
const res = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, playedWordsFromHistory([playRecord('рот', 'кот')]));
expect(res.legal).toBe(false);
expect(res.repeatedWord).toBe('рот');
});
});
+16 -4
View File
@@ -9,6 +9,7 @@
import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate';
import { playDirection } from './direction';
import { noRepeatScore } from './norepeat';
import type { Board as ClientBoard } from '../board';
import type { PlacedTile } from '../client';
import type { EvalResult, Variant } from '../model';
@@ -64,7 +65,7 @@ function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
// decodeWord turns a word's alphabet indices back into a lower-cased string, the
// form the network EvalResult carries (the caption renders it directly).
function decodeWord(variant: Variant, letters: number[]): string {
function decodeWord(variant: Variant, letters: readonly number[]): string {
let s = '';
for (const i of letters) s += letterForIndex(variant, i);
return s.toLowerCase();
@@ -74,8 +75,12 @@ function decodeWord(variant: Variant, letters: number[]): string {
* evaluateLocal computes the move preview for tiles placed on board in the given
* game, returning the same shape as the network `evaluate`. dict is the loaded
* dictionary reader for the game's (variant, version); multipleWords is the game's
* cross-word rule. It may throw if the variant's alphabet table is not loaded the
* caller guards with hasAlphabet and falls back to the network on any failure.
* cross-word rule. playedWords is the game's already-played words (decoded, as the
* history carries them) when the game takes the no-repeat-words rule, and absent
* otherwise with it a play repeating a word is reported illegal and an already-played
* cross-word scores nothing, exactly as the server decides. It may throw if the
* variant's alphabet table is not loaded the caller guards with hasAlphabet and falls
* back to the network on any failure.
*/
export function evaluateLocal(
dict: Dict,
@@ -83,6 +88,7 @@ export function evaluateLocal(
board: ClientBoard,
tiles: PlacedTile[],
multipleWords: boolean,
playedWords?: ReadonlySet<string>,
): EvalResult {
const vboard = buildBoard(variant, board);
const rs = buildRuleset(variant, multipleWords);
@@ -100,5 +106,11 @@ export function evaluateLocal(
}
const m = res.move;
const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters));
return { legal: true, score: m.score, words, dir: dir === Horizontal ? 'H' : 'V' };
const score = playedWords ? noRepeatScore(m, playedWords, (l) => decodeWord(variant, l)) : m.score;
if (score === null) {
// A real word, but one this game has already used: illegal, and named so the caption can say
// why rather than leaving the player staring at a word they know is in the dictionary.
return { legal: false, score: 0, words: [], dir: '', repeatedWord: words[0] };
}
return { legal: true, score, words, dir: dir === Horizontal ? 'H' : 'V' };
}
+1
View File
@@ -3,5 +3,6 @@
// of the initial app bundle — it is a progressive enhancement over the network
// preview, which remains the fallback.
export { evaluateLocal } from './eval';
export { playedWordsFromHistory } from './norepeat';
export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader';
export { clearDictCache, listCachedDicts } from './store';
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { wordKey, playedWordKeys, noRepeatScore } from './norepeat';
import type { Direction, Move, Word } from './validate';
import { Horizontal, Vertical } from './validate';
// word builds a scored Word; only its letters and score matter to the rule.
function word(letters: number[], score: number, dir: Direction = Horizontal): Word {
return { row: 0, col: 0, dir, letters, blanks: letters.map(() => false), score };
}
// move builds a scored play from a main word and its cross-words, with the total the engine
// would have computed (main + cross, no bingo).
function move(main: Word, cross: Word[] = []): Move {
return {
dir: Horizontal,
tiles: [],
main,
cross,
bonus: 0,
score: main.score + cross.reduce((sum, w) => sum + w.score, 0),
};
}
describe('no-repeat-words rule', () => {
it('keys a word by its letters, so a blank spells the same word as a real tile', () => {
// The letters are alphabet indices and the blank flag rides separately, so it never enters
// the key — matching the server, which compares the words decoded.
const withBlank: Word = { ...word([2, 14, 19], 5), blanks: [false, true, false] };
expect(wordKey(withBlank.letters)).toBe(wordKey([2, 14, 19]));
});
it('builds the played set from the journal words, ignoring empty ones', () => {
const played = playedWordKeys([[2, 14, 19], [], [3, 4]]);
expect(played.has(wordKey([2, 14, 19]))).toBe(true);
expect(played.has(wordKey([3, 4]))).toBe(true);
expect(played.size).toBe(2);
});
it('rejects a play whose main word is already on the board', () => {
const played = playedWordKeys([[2, 14, 19]]);
expect(noRepeatScore(move(word([2, 14, 19], 7)), played)).toBeNull();
});
it('accepts a play whose main word is new, at its full score', () => {
const played = playedWordKeys([[2, 14, 19]]);
expect(noRepeatScore(move(word([17, 14, 19], 6)), played)).toBe(6);
});
it('keeps a play whose cross-word repeats, but scores that cross-word at nothing', () => {
const played = playedWordKeys([[2, 14, 19]]);
const m = move(word([17, 14, 19], 6), [word([2, 14, 19], 4, Vertical), word([8, 9], 3, Vertical)]);
expect(m.score).toBe(13);
expect(noRepeatScore(m, played)).toBe(9); // the repeated cross-word's 4 points are dropped
});
it('takes a caller-supplied key, so a decoded history can be compared directly', () => {
// The online preview reads the words the server already decoded, so it keys on the decoded
// form rather than on alphabet indices.
const letters = ['', 'a', 'b', 'c'];
const decode = (l: readonly number[]) => l.map((i) => letters[i]).join('');
const played = new Set(['abc']);
expect(noRepeatScore(move(word([1, 2, 3], 7)), played, decode)).toBeNull();
expect(noRepeatScore(move(word([3, 2, 1], 7)), played, decode)).toBe(7);
});
});
+77
View File
@@ -0,0 +1,77 @@
// The no-repeat-words rule of Russian "Эрудит": a word laid on the board belongs to the game from
// then on and cannot be laid again. This is the client-side port of the server's rule
// (backend/internal/engine/norepeat.go) and must stay in step with it — it decides both what the
// offline engine accepts and what the online move preview scores.
//
// The rule applies in two different ways:
//
// - the play's main word must not already be there — such a play is illegal, and is neither
// accepted nor offered by the move generator;
// - a perpendicular cross-word that is already there is allowed, because it is incidental to
// laying the main word, but it scores nothing.
//
// It is a rule of that variant only, and it is pinned per game rather than derived from the
// variant, so a game started before the rule existed plays on without it.
import type { Move } from './validate';
import type { MoveRecord } from '../model';
/**
* wordKey identifies a word for the rule. Words are alphabet-index letters, so the key ignores
* which tile carried a letter: a word spelled with a blank is the same word as one spelled with
* real tiles, exactly as the server sees it (there the words are compared decoded).
*/
export function wordKey(letters: readonly number[]): string {
return letters.join(',');
}
/**
* playedWordKeys builds the rule's lookup set from a game's move journal pass every play's
* words (the main word and its cross-words). A game with the rule off never needs it.
*/
export function playedWordKeys(words: Iterable<readonly number[]>): Set<string> {
const played = new Set<string>();
for (const w of words) {
if (w.length > 0) played.add(wordKey(w));
}
return played;
}
/**
* playedWordsFromHistory is the rule's lookup set for a game whose history the server decoded for
* us: every word its plays have formed, main words and cross-words alike. It lives here, with the
* rest of the rule and behind the same lazy import as the evaluator, rather than in the game screen.
*
* The history is case-folded to lower, because the codec upper-cases those words for display
* (lib/codec decodeMove) while evaluateLocal decodes a candidate word to lower comparing the two
* raw would never match, and the rule would silently never fire.
*/
export function playedWordsFromHistory(moves: readonly MoveRecord[]): Set<string> {
const played = new Set<string>();
for (const m of moves) {
if (m.action === 'play') for (const w of m.words) played.add(w.toLowerCase());
}
return played;
}
/**
* noRepeatScore applies the rule to an already validated move against the set of words already
* played. It returns null when the move's main word is one of them the play is illegal and
* otherwise the move's score with every already-played cross-word dropped from the total.
*
* keyOf turns a candidate word's letters into the key the played set is built with. The offline
* engine keeps its journal in alphabet-index space and uses the default; the online preview reads
* the history the server sent, whose words are already decoded, and passes a decoding key instead.
*/
export function noRepeatScore(
m: Move,
played: ReadonlySet<string>,
keyOf: (letters: readonly number[]) => string = wordKey,
): number | null {
if (played.has(keyOf(m.main.letters))) return null;
let score = m.score;
for (const cw of m.cross) {
if (played.has(keyOf(cw.letters))) score -= cw.score;
}
return score;
}
+1
View File
@@ -16,6 +16,7 @@ function gameView(id: string): GameView {
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+42
View File
@@ -17,6 +17,7 @@ function gameView(moveCount: number, over = false): GameView {
toMove: 1,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount,
endReason: over ? 'standard' : '',
lastActivityUnix: 0,
@@ -103,6 +104,47 @@ describe('applyGameOver', () => {
});
});
describe('deleted seat marks', () => {
// The live events carry no deleted mark (only the REST-backed views resolve it), so a delta must
// not clear the mark the cache already holds — otherwise the social controls aimed at a deleted
// opponent reappear until the next cold load.
function cacheWithDeletedSeat(moveCount: number): CachedGame {
const c = cache(moveCount);
c.view.game.seats = [
{ seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'gone', displayName: '[Deleted]', score: 0, hintsUsed: 0, isWinner: false, deleted: true },
];
return c;
}
function eventSeats(): GameView['seats'] {
return [
{ seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'gone', displayName: '[Deleted]', score: 12, hintsUsed: 0, isWinner: false },
];
}
it('keeps a deleted seat marked across a move delta', () => {
const d = delta(4, 1);
d.game = { ...gameView(4), seats: eventSeats() };
const res = applyMoveDelta(cacheWithDeletedSeat(3), d);
expect(res.cache?.view.game.seats.map((s) => !!s.deleted)).toEqual([false, true]);
expect(res.cache?.view.game.seats[1].score).toBe(12); // the event's own fields still win
});
it('keeps a deleted seat marked across the final summary', () => {
const final: GameView = { ...gameView(3, true), seats: eventSeats() };
const res = applyGameOver(cacheWithDeletedSeat(3), final);
expect(res.cache?.view.game.seats.map((s) => !!s.deleted)).toEqual([false, true]);
});
it('leaves the event seats untouched when no cached seat is deleted', () => {
const final: GameView = { ...gameView(3, true), seats: eventSeats() };
const res = applyGameOver(cache(3), final);
expect(res.cache?.view.game.seats).toBe(final.seats);
});
});
function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent {
return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen };
}
+15 -2
View File
@@ -23,6 +23,19 @@ export interface DeltaResult {
refetch: boolean;
}
/**
* keepDeletedMarks carries the seats' deleted marks from the cached game over to a game view that
* arrived on the live stream. The mark says the seat's account has been deleted; only the REST-backed
* views resolve it (the events carry the seat standings the game domain holds, with no account-store
* lookup), so without this a live move or the final summary would clear it and the social controls
* aimed at a deleted seat would reappear until the next cold load.
*/
function keepDeletedMarks(prev: GameView, next: GameView): GameView {
if (!prev.seats.some((s) => s.deleted)) return next;
const wasDeleted = new Set(prev.seats.filter((s) => s.deleted).map((s) => s.seat));
return { ...next, seats: next.seats.map((s) => (wasDeleted.has(s.seat) ? { ...s, deleted: true } : s)) };
}
/**
* seedInitialState builds a fresh cache entry from a started game's initial view (match_found /
* game_started). A freshly started game has no moves, so the board replays from an empty journal.
@@ -50,7 +63,7 @@ export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): De
if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed
// The actor's own move changed their rack (a draw), which opponent_moved does not carry.
if (d.move.player === cached.view.seat) return { refetch: true };
const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen };
const view: StateView = { ...cached.view, game: keepDeletedMarks(cached.view.game, d.game), bagLen: d.bagLen };
return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false };
}
@@ -64,7 +77,7 @@ export function applyGameOver(cached: CachedGame | undefined, game: GameView | u
if (!cached) return { refetch: false };
if (!game) return { refetch: cached.view.game.status !== 'finished' };
if (cached.view.game.moveCount < game.moveCount) return { refetch: true };
const view: StateView = { ...cached.view, game };
const view: StateView = { ...cached.view, game: keepDeletedMarks(cached.view.game, game) };
return { cache: { view, moves: cached.moves }, refetch: false };
}
+1
View File
@@ -20,6 +20,7 @@ function game(seats: Seat[], over = true): GameView {
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: false,
noRepeatWords: false,
moveCount: 0,
endReason: over ? 'standard' : '',
lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC)
+1
View File
@@ -13,6 +13,7 @@ function game(kind: number, status: string, extra: Partial<GameView> = {}): Game
toMove: 0,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+5 -5
View File
@@ -24,9 +24,9 @@ function load(): Promise<LocalSource> {
* local-only create() and the robot-reply event subscription events().
*/
export const localSource = {
// Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate
// abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full
// gateway signatures but forwards only what the local source uses.
// Offline scoring is self-contained, so the local source ignores includeAlphabet and the evaluate
// abort signal; the proxy accepts the full gateway signatures but forwards only what the local
// source uses.
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
gameHistory: (id) => load().then((s) => s.gameHistory(id)),
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)),
@@ -36,8 +36,8 @@ export const localSource = {
hint: (id) => load().then((s) => s.hint(id)),
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)),
checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
draftGet: (_id) => load().then((s) => s.draftGet()),
draftSave: (_id, _json) => load().then((s) => s.draftSave()),
draftGet: (id) => load().then((s) => s.draftGet(id)),
draftSave: (id, json) => load().then((s) => s.draftSave(id, json)),
create: (opts) => load().then((s) => s.create(opts)),
list: () => load().then((s) => s.list()),
delete: (id) => load().then((s) => s.delete(id)),
+12 -2
View File
@@ -41,6 +41,10 @@ export const en = {
'common.you': 'You',
'common.guest': 'Guest',
'common.save': 'Save',
// The opening word of a guest's invitation to sign in, rendered as a link to the profile
// screen (New Game, Stats). Shared so the two screens cannot drift apart; each screen keeps
// its own tail, which carries its leading space — Svelte trims literal edge whitespace.
'common.signInLink': 'Sign in',
'login.title': 'Sign in',
'login.guest': 'Play as guest',
@@ -79,10 +83,13 @@ export const en = {
'new.searching': 'Looking for an opponent…',
'new.rulesEnglish': '100 tiles · bingo +50',
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15 · no repeated words',
'new.moveLimit': 'Move time: {n} h 00 min',
'new.searchHint':
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
// The tail of the guest's invitation to sign in (common.signInLink opens it), shown under the
// single variant a guest is offered.
'new.guestVariantHint': ' to play the Russian or English Scrabble.',
// First-run coachmark hints (components/Coachmark.svelte). The leading emoji in each comment
// names the UI element the bubble's tail points at.
@@ -99,6 +106,7 @@ export const en = {
'game.bagCount': 'In the bag: {n}',
'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}',
'game.wordAlreadyUsed': 'already used',
'game.yourTurn': 'Your turn',
'game.yourTurnBy': '{name}: Your turn!',
'game.opponentsTurn': "Opponent's turn",
@@ -414,7 +422,8 @@ export const en = {
'stats.winRate': 'Win rate',
'stats.maxGame': 'Best game',
'stats.maxWord': 'Best move',
'stats.guestHint': 'Sign in to track your statistics.',
// The tail of the guest's invitation to sign in (common.signInLink opens it).
'stats.guestHint': ' to track your statistics.',
'game.exportGcg': 'Export game',
'game.exportChoice': 'Export the finished game as:',
@@ -434,6 +443,7 @@ export const en = {
'error.request_not_found': 'No matching friend request.',
'error.no_shared_game': 'You can only add someone you have played with.',
'error.request_declined': 'This player declined your request.',
'error.account_deleted': 'This player has deleted their account.',
'error.friend_code_invalid': 'That friend code is invalid or expired.',
'error.invalid_invitation': 'Invalid invitation.',
'error.invitation_blocked': 'You cannot invite this player.',
+6 -2
View File
@@ -42,6 +42,7 @@ export const ru: Record<MessageKey, string> = {
'common.you': 'Вы',
'common.guest': 'Гость',
'common.save': 'Сохранить',
'common.signInLink': 'Войдите',
'login.title': 'Вход',
'login.guest': 'Играть как гость',
@@ -80,10 +81,11 @@ export const ru: Record<MessageKey, string> = {
'new.searching': 'Ищем соперника…',
'new.rulesEnglish': '100 фишек · бинго +50',
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15 · слова без повтора',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
'new.guestVariantHint': ', чтобы играть в русский или английский вариант Скрэббл.',
// Подсказки первого запуска (components/Coachmark.svelte).
'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️
@@ -99,6 +101,7 @@ export const ru: Record<MessageKey, string> = {
'game.bagCount': 'В мешке: {n}',
'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}',
'game.wordAlreadyUsed': 'уже использовано',
'game.yourTurn': 'Ваш ход',
'game.yourTurnBy': '{name}: Ваш ход!',
'game.opponentsTurn': 'Ход соперника',
@@ -414,7 +417,7 @@ export const ru: Record<MessageKey, string> = {
'stats.winRate': 'Доля побед',
'stats.maxGame': 'Лучшая игра',
'stats.maxWord': 'Лучший ход',
'stats.guestHint': 'Войдите, чтобы вести статистику.',
'stats.guestHint': ', чтобы вести статистику.',
'game.exportGcg': 'Экспорт партии',
'game.exportChoice': 'Экспортировать завершённую партию как:',
@@ -434,6 +437,7 @@ export const ru: Record<MessageKey, string> = {
'error.request_not_found': 'Подходящей заявки нет.',
'error.no_shared_game': 'Можно добавить только того, с кем вы играли.',
'error.request_declined': 'Игрок отклонил вашу заявку.',
'error.account_deleted': 'Игрок удалил свою учётную запись.',
'error.friend_code_invalid': 'Код недействителен или истёк.',
'error.invalid_invitation': 'Неверное приглашение.',
'error.invitation_blocked': 'Нельзя пригласить этого игрока.',
+2 -1
View File
@@ -12,7 +12,7 @@ function invitation(id: string, status: string): Invitation {
hintsAllowed: true,
hintsPerPlayer: 0,
multipleWordsPerTurn: true,
dropoutTiles: 'remove',
dropoutTiles: 'remove',
status,
gameId: '',
expiresAtUnix: 0,
@@ -33,6 +33,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
toMove,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+1
View File
@@ -29,6 +29,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
toMove,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix,
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { Dawg } from '../dict/dawg';
import { RACK_SIZE } from '../premiums';
import { LocalGame } from './engine';
import { wordKey } from '../dict/norepeat';
import { decide } from '../robot/strategy';
import type { Move } from '../dict/validate';
// The no-repeat-words rule driven through the whole offline engine: two robots self-play a game
// with the rule on, and no word may ever be laid twice. This covers both halves of the wiring at
// once — generateMoves must not offer a forbidden play, and play must not accept one — over a long
// run of real positions rather than a hand-built one. The pinned semantics live in
// dict/norepeat.test.ts; the same rule on the server is engine/norepeat_test.go.
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
function bestOpponentScore(game: LocalGame, seat: number): number {
let best = 0;
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
return best;
}
// selfPlay runs a whole game of robot turns, returning every main word laid in order. The sample
// dictionary emits a one-letter word the validator rejects, so candidates are filtered the way
// the robot's own turn does (see engine.test.ts).
function selfPlay(noRepeatWords: boolean, seed: bigint): string[] {
const game = new LocalGame({
variant: 'scrabble_en',
version: 'sample',
seed,
players: 2,
dawg,
multipleWords: true,
noRepeatWords,
});
const mains: string[] = [];
for (let turn = 0; turn < 500 && !game.isOver; turn++) {
const seat = game.currentPlayer;
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
if (dec.kind === 'play') {
mains.push(wordKey(dec.move.main.letters));
game.play(dec.move.dir, dec.move.tiles);
} else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) {
game.exchange(dec.exchange);
} else {
game.pass();
}
}
return mains;
}
describe('offline engine under the no-repeat-words rule', () => {
it('never lays the same word twice, where the same run without the rule does', () => {
// Both halves of the wiring at once: generateMoves must not offer a forbidden play and play
// must not accept one. Running the same seed with the rule off is the anti-vacuity guard —
// it proves this position sequence really does offer repeats.
const seed = 20260727n;
const withRule = selfPlay(true, seed);
const withoutRule = selfPlay(false, seed);
expect(withoutRule.length).toBeGreaterThan(0);
expect(new Set(withoutRule).size).toBeLessThan(withoutRule.length); // repeats are on offer...
expect(new Set(withRule).size).toBe(withRule.length); // ...and the rule keeps every one out
});
});
+49 -8
View File
@@ -14,6 +14,7 @@ import { Bag } from './bag';
import { RULESETS } from './ruleset';
import { generateMoves, GenRack, Both } from '../dict/generate';
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
import { noRepeatScore, playedWordKeys } from '../dict/norepeat';
import { playDirection } from '../dict/direction';
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
import { BLANK_INDEX } from '../alphabet';
@@ -154,6 +155,10 @@ export interface LocalGameOptions {
players: number;
dawg: Dawg;
multipleWords: boolean;
/** Forbids laying a word that is already on the board (the Erudit rule, see dict/norepeat).
* Pinned per game, so a game started before the rule existed replays and scores unchanged;
* absent reads as off. */
noRepeatWords?: boolean;
dropoutTiles?: DropoutTiles;
}
@@ -175,6 +180,7 @@ export class LocalGame {
private readonly rackSize: number;
private readonly dawg: Dawg;
private readonly multipleWords: boolean;
private readonly noRepeatWords: boolean;
private readonly dropoutTiles: DropoutTiles;
private readonly board: LocalBoard;
@@ -197,6 +203,7 @@ export class LocalGame {
this.seed = opts.seed;
this.dawg = opts.dawg;
this.multipleWords = opts.multipleWords;
this.noRepeatWords = opts.noRepeatWords ?? false;
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
this.values = RULESETS[opts.variant].values;
@@ -250,28 +257,60 @@ export class LocalGame {
}
/** config returns the rule settings the game was created with the bits, alongside the seed and
* journal, needed to reconstruct it (see localgame/serialize). */
get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } {
return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles };
get config(): { multipleWords: boolean; noRepeatWords: boolean; dropoutTiles: DropoutTiles } {
return { multipleWords: this.multipleWords, noRepeatWords: this.noRepeatWords, dropoutTiles: this.dropoutTiles };
}
/** history returns a copy of the move log. */
get history(): LocalMove[] {
return this.log.map((m) => ({ ...m }));
}
/** generateMoves returns every legal play for the current player, ranked by descending score. */
/** generateMoves returns every legal play for the current player, ranked by descending score.
* Under the no-repeat-words rule the plays it forbids are dropped and the rest are rescored and
* re-ranked, so neither the robot nor the hint can offer a move the engine would then refuse. */
generateMoves(): Move[] {
return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
const moves = generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
if (!this.noRepeatWords) return moves;
const played = this.playedKeys();
const out: Move[] = [];
for (const m of moves) {
const score = noRepeatScore(m, played);
if (score === null) continue;
out.push(score === m.score ? m : { ...m, score });
}
// A stable sort, so plays the generator ranked equally keep its order.
return out.sort((a, b) => b.score - a.score);
}
/** playedKeys is the no-repeat-words rule's lookup set: every word this game has already
* formed, main words and cross-words alike (see dict/norepeat). */
private playedKeys(): ReadonlySet<string> {
const words: number[][] = [];
for (const m of this.log) {
if (m.action === 'play' && m.words) words.push(...m.words);
}
return playedWordKeys(words);
}
/** evaluatePlay scores a candidate placement for the current position without committing it,
* returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index
* arrays, main first) and the inferred direction. Backs the local move preview. */
evaluatePlay(tiles: Placement[]): { legal: boolean; score: number; words: number[][]; dir: Direction } {
evaluatePlay(tiles: Placement[]): {
legal: boolean;
score: number;
words: number[][];
dir: Direction;
/** The main word when the no-repeat-words rule is what rejects the play: a real word the game
* has already used. Absent on every other outcome. */
repeated?: number[];
} {
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
const m = res.move;
return { legal: true, score: m.score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
const score = this.noRepeatWords ? noRepeatScore(m, this.playedKeys()) : m.score;
if (score === null) return { legal: false, score: 0, words: [], dir, repeated: m.main.letters };
return { legal: true, score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
}
/** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */
@@ -299,10 +338,12 @@ export class LocalGame {
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) throw new GameError('illegal_play');
const move = res.move;
const score = this.noRepeatWords ? noRepeatScore(move, this.playedKeys()) : move.score;
if (score === null) throw new GameError('illegal_play');
for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
this.removeFromHand(player, used);
this.scores[player] += move.score;
this.scores[player] += score;
this.refill(player);
this.scorelessRun = 0;
@@ -314,7 +355,7 @@ export class LocalGame {
mainRow: move.main.row,
mainCol: move.main.col,
words: [move.main.letters, ...move.cross.map((w) => w.letters)],
score: move.score,
score,
total: this.scores[player],
};
this.log.push(rec);
+15
View File
@@ -35,6 +35,10 @@ export interface LocalGameRecord {
seed: string;
players: number;
multipleWords: boolean;
/** Forbids laying a word that is already on the board (the Erudit rule). Pinned at creation from
* the variant, so a game started before the rule existed (where it is absent, read as false)
* replays and scores exactly as it was played. */
noRepeatWords?: boolean;
dropoutTiles: DropoutTiles;
seats: Seat[];
/** The dictionary-independent, alphabet-index-space move journal. */
@@ -53,6 +57,13 @@ export interface LocalGameRecord {
* read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it
* see lib/hints. */
hintUnlockAtMs: number;
/** The seated player's in-progress composition (rack order + pending board tiles) as the draft
* JSON the game screen serialises, or absent when there is none. A local game keeps it here
* there is no server to hold it so a reload of the game state restores the arrangement instead
* of dropping the tiles back into the rack. It belongs to the turn: the source clears it wherever
* the turn advances (a committed move, a resignation, a host skip), so a hotseat seat never
* inherits the previous player's arrangement. */
draft?: string;
}
/** The record fields the caller owns (the engine supplies the rest). */
@@ -64,6 +75,7 @@ export interface RecordMeta {
createdAtUnix: number;
updatedAtUnix: number;
hintUnlockAtMs: number;
draft?: string;
}
/**
@@ -79,6 +91,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
seed: game.seed.toString(),
players: game.playerCount,
multipleWords: cfg.multipleWords,
noRepeatWords: cfg.noRepeatWords || undefined,
dropoutTiles: cfg.dropoutTiles,
seats: meta.seats,
journal: game.history,
@@ -88,6 +101,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix,
hintUnlockAtMs: meta.hintUnlockAtMs,
draft: meta.draft,
};
}
@@ -104,6 +118,7 @@ export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame {
players: record.players,
dawg,
multipleWords: record.multipleWords,
noRepeatWords: record.noRepeatWords,
dropoutTiles: record.dropoutTiles,
};
const game = new LocalGame(opts);
+16
View File
@@ -97,6 +97,22 @@ describe('LocalSource', () => {
}
});
it('persists the draft, so reloading the state keeps the composition', async () => {
// The game screen refetches the state on several triggers (a stream reconnect, a resume). With
// no stored draft every one of those wiped the player's arrangement back into the rack.
const src = await newSource('local:gDraft', 42n);
expect(await src.draftGet('local:gDraft')).toBe('');
await src.draftSave('local:gDraft', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
expect(await src.draftGet('local:gDraft')).toBe('{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
});
it('drops the draft when the turn advances, so a hotseat seat never inherits it', async () => {
const src = await newSource('local:gDraft2', 42n);
await src.draftSave('local:gDraft2', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
await src.pass('local:gDraft2');
expect(await src.draftGet('local:gDraft2')).toBe('');
});
it('drives a whole local game to completion', async () => {
const src = await newSource('local:g5', 2024n);
src.events('local:g5', () => {});
+21 -7
View File
@@ -18,6 +18,7 @@ import { HINT_GATE_MS } from '../hints';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy';
import { verifyPin, type PinLock } from '../pin';
import { noRepeatWordsFor } from '../variants';
import { GatewayError, type PlacedTile } from '../client';
import type {
EvalResult,
@@ -122,6 +123,7 @@ export class LocalSource implements GameLoopSource {
players: opts.seats.length,
dawg,
multipleWords: opts.multipleWords,
noRepeatWords: noRepeatWordsFor(opts.variant),
});
const now = nowUnix();
const record = serializeGame(game, {
@@ -195,6 +197,7 @@ export class LocalSource implements GameLoopSource {
const seat = entry.game.currentPlayer;
const move = entry.game.resign();
entry.unlockedSeat = null;
entry.record.draft = undefined; // the turn is over; the abandoned composition goes with it
await this.persist(entry);
return this.moveResult(entry, move, seat);
}
@@ -251,6 +254,7 @@ export class LocalSource implements GameLoopSource {
if (action === 'skip') entry.game.pass();
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
entry.unlockedSeat = null;
entry.record.draft = undefined; // the host advanced the turn; the seat's composition is stale
await this.persist(entry);
return this.stateView(entry);
}
@@ -268,11 +272,13 @@ export class LocalSource implements GameLoopSource {
async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
const { game } = await this.load(gameId);
const r = game.evaluatePlay(encodePlacements(variant, tiles));
const decode = (w: readonly number[]) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase();
return {
legal: r.legal,
score: r.score,
words: r.words.map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()),
words: r.words.map(decode),
dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '',
repeatedWord: r.repeated ? decode(r.repeated) : undefined,
};
}
@@ -282,13 +288,18 @@ export class LocalSource implements GameLoopSource {
return { word, legal: game.dictionaryHas(idx) };
}
// Drafts (the in-progress composition) are not persisted for local games — the arrangement is
// rebuilt from the rack on reopen. The screen tolerates an empty draft.
async draftGet(): Promise<string> {
return '';
// Drafts (the in-progress composition: rack order + pending board tiles) live in the game's own
// record — a local game has no server to hold them. Persisting them keeps every reload of the
// state (the game screen refetches on several triggers) from dropping the arrangement back into
// the rack, and carries it across leaving and reopening the app.
async draftGet(gameId: string): Promise<string> {
const entry = await this.load(gameId);
return entry.record.draft ?? '';
}
async draftSave(): Promise<void> {
/* no-op offline */
async draftSave(gameId: string, json: string): Promise<void> {
const entry = await this.load(gameId);
entry.record.draft = json || undefined;
await this.persist(entry);
}
/** events subscribes to a local game's push events (the robot's reply, game over). Returns an
@@ -325,6 +336,7 @@ export class LocalSource implements GameLoopSource {
const seat = entry.game.currentPlayer;
const move = act(entry.game);
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
entry.record.draft = undefined; // the composition was committed (or swapped away) with the turn
const robotMoves = this.runRobots(entry);
await this.persist(entry);
const result = this.moveResult(entry, move, seat);
@@ -378,6 +390,7 @@ export class LocalSource implements GameLoopSource {
createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(),
hintUnlockAtMs: entry.record.hintUnlockAtMs,
draft: entry.record.draft,
});
await saveLocalGame(entry.record);
}
@@ -474,6 +487,7 @@ export class LocalSource implements GameLoopSource {
toMove: game.currentPlayer,
turnTimeoutSecs: 0,
multipleWordsPerTurn: record.multipleWords,
noRepeatWords: !!record.noRepeatWords,
moveCount: game.moveCount,
endReason: game.isOver ? game.endReason : '',
lastActivityUnix: record.updatedAtUnix,
+3
View File
@@ -46,6 +46,7 @@ import type {
Catalog,
} from '../model';
import { valueForLetter } from '../alphabet';
import { noRepeatWordsFor } from '../variants';
import { seedMockAlphabets } from './alphabet';
import {
ME,
@@ -292,6 +293,7 @@ export class MockGateway implements GatewayClient {
toMove: 0,
turnTimeoutSecs: 604800,
multipleWordsPerTurn: multipleWords,
noRepeatWords: noRepeatWordsFor(variant),
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
@@ -326,6 +328,7 @@ export class MockGateway implements GatewayClient {
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: multipleWords,
noRepeatWords: noRepeatWordsFor(variant),
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
+4 -1
View File
@@ -113,7 +113,7 @@ export function mockInvitations(): Invitation[] {
hintsAllowed: true,
hintsPerPlayer: 1,
multipleWordsPerTurn: false,
dropoutTiles: 'remove',
dropoutTiles: 'remove',
status: 'pending',
gameId: '',
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
@@ -198,6 +198,7 @@ function activeGame(): MockGame {
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: G1_MOVES.length,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
@@ -237,6 +238,7 @@ function finishedG2(): MockGame {
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 2,
endReason: 'normal',
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
@@ -277,6 +279,7 @@ function finishedG3(): MockGame {
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: false,
noRepeatWords: false,
moveCount: 1,
endReason: 'resignation',
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
+13
View File
@@ -32,6 +32,10 @@ export interface Seat {
/** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so
* the finished-game medal ranking can place it last (no medal); undefined for online seats. */
resigned?: boolean;
/** The seat's account has been deleted: every social control aimed at it is hidden (add-friend,
* block, chat, nudge) there is nobody behind it any more. Carried by the REST-backed views
* only; the live events leave it unset, so the delta reducers preserve the cached value. */
deleted?: boolean;
}
export interface GameView {
@@ -44,6 +48,10 @@ export interface GameView {
turnTimeoutSecs: number;
/** true = standard Scrabble; false = the single-word rule (Russian games). */
multipleWordsPerTurn: boolean;
/** true = a word already on the board cannot be laid again (the Erudit rule). Pinned per game at
* creation, so a game started before the rule existed reports false. The local move preview needs
* it to score the way the server does. */
noRepeatWords: boolean;
moveCount: number;
endReason: string;
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
@@ -121,6 +129,11 @@ export interface EvalResult {
words: string[];
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
dir: string;
/** The play's main word when it is a real word the game has already used, so the rule (and not
* the dictionary) is what makes the play illegal the composition reads as invalid but the
* caption says so in those words. Set only by the on-device evaluator, which decides the rule
* before the play ever reaches the server; absent on every other outcome. */
repeatedWord?: string;
}
export interface WordCheckResult {
+1
View File
@@ -28,6 +28,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+1
View File
@@ -26,6 +26,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
toMove,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+13
View File
@@ -4,6 +4,7 @@ import {
ALL_VARIANTS,
DEFAULT_VARIANTS,
availableVariants,
showRegisterHint,
supportsMultipleWordsToggle,
multipleWordsForRequest,
usesStarBlank,
@@ -38,6 +39,18 @@ describe('availableVariants', () => {
});
});
describe('showRegisterHint', () => {
it('invites only an online guest holding a single variant', () => {
expect(showRegisterHint(true, 1, false)).toBe(true);
});
it('stays hidden for a registered player, offline, or when several variants are offered', () => {
expect(showRegisterHint(false, 1, false)).toBe(false);
expect(showRegisterHint(true, 1, true)).toBe(false);
expect(showRegisterHint(true, 2, false)).toBe(false);
});
});
describe('supportsMultipleWordsToggle', () => {
it('is true for Russian variants only', () => {
expect(supportsMultipleWordsToggle('scrabble_ru')).toBe(true);
+24 -6
View File
@@ -28,6 +28,15 @@ export function variantNameKey(v: Variant): MessageKey {
return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english';
}
// noRepeatWordsFor reports whether a new game of variant v takes the no-repeat-words rule (see
// lib/dict/norepeat): a word already on the board cannot be laid again. It is a rule of Erudit
// alone — both Scrabble variants let a word be played as often as a player can manage. It mirrors
// the server's noRepeatWordsFor and is only ever consulted when a game is created; an existing
// game carries its own pinned answer (GameView.noRepeatWords / LocalGameRecord.noRepeatWords).
export function noRepeatWordsFor(v: Variant): boolean {
return v === 'erudit_ru';
}
// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game
// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
export const VARIANT_RULES: Record<Variant, MessageKey> = {
@@ -64,23 +73,32 @@ export function usesStarBlank(v: Variant): boolean {
// DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh
// or offline native client whose profile has not been synced yet (the device-local guest boots
// with no profile at all). It mirrors the backend's new-account default (the account service
// seeds Erudit only), so the local guest and a later synced account agree on what is enabled.
// Every dictionary is still bundled; the other variants are simply off until the player turns
// them on in Settings.
// with no profile at all). Such a client is a guest, so it mirrors the backend's *guest* set
// (account.GuestVariantPreferences, Erudit alone) rather than the wider set a registered account
// is created with. Every dictionary is still bundled; the other variants are simply off until
// the player registers and turns them on in Settings.
export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru'];
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set they
// enabled in Settings). An empty or absent set falls back to DEFAULT_VARIANTS rather than every
// variant: a real profile always carries at least one preference, and a profileless client (a
// fresh offline native launch) must match the server's Erudit-only default instead of exposing
// the English game before the player opts in.
// fresh offline native launch) must match the server's guest default instead of exposing
// the other games before the player opts in.
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
const prefs = preferences ?? [];
const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
return ALL_VARIANTS.filter((v) => effective.includes(v.id));
}
// showRegisterHint reports whether New Game invites the player to sign in in order to unlock the
// other games, given whether they are a guest (isGuest), how many variants they are offered
// (offered) and whether the app is in offline mode (offline). It is the guest's case alone: a
// guest is created with Erudit only, while signing in widens the account to both Russian-alphabet
// games. Offline it is suppressed — signing in needs the network, so the link would be dead.
export function showRegisterHint(isGuest: boolean, offered: number, offline: boolean): boolean {
return isGuest && !offline && offered === 1;
}
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
// applies to a variant. Only Russian games choose the rule; English is always standard, so
// its toggle is not shown.
+27
View File
@@ -22,6 +22,7 @@
import type { AccountRef, GameView, Variant } from '../lib/model';
import {
availableVariants,
showRegisterHint,
VARIANT_FLAG,
VARIANT_RULES,
supportsMultipleWordsToggle,
@@ -395,6 +396,14 @@
</button>
{/each}
</div>
<!-- A guest is offered Erudit alone; signing in widens the account to both Russian games
(and Settings then offers English), so point them at the profile screen — the same
wording and link the Stats guest stub uses. -->
{#if showRegisterHint(guest, variants.length, offlineMode.active)}
<p class="guesthint">
<button class="hintlink" onclick={() => navigate('/profile')}>{t('common.signInLink')}</button>{t('new.guestVariantHint')}
</p>
{/if}
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
<label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span>
@@ -771,6 +780,24 @@
font-size: 0.85rem;
line-height: 1.4;
}
/* The guest's register invitation: a muted line opening with an inline link-styled button
that routes to the profile screen (mirrors Profile's .linklike). */
.guesthint {
margin: 0;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.4;
}
.hintlink {
background: none;
border: none;
padding: 0;
color: var(--accent);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
/* The offline-blocker reason (a missing dictionary, or the online segment needing a connection). */
.dictnote {
margin: 0;
+16 -1
View File
@@ -4,6 +4,7 @@
import WordTiles from '../components/WordTiles.svelte';
import { app, handleError } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
import { gamesPlayed, hintSharePercent, winRate } from '../lib/stats';
import { ALL_VARIANTS, variantNameKey } from '../lib/variants';
@@ -56,7 +57,11 @@
<Screen title={t('stats.title')} back="/">
<div class="page">
{#if app.profile?.isGuest}
<p class="muted">{t('stats.guestHint')}</p>
<!-- The opening word links to the profile screen, where a guest signs in (the same wording
and link New Game shows a guest under its lone variant). -->
<p class="muted">
<button class="hintlink" onclick={() => navigate('/profile')}>{t('common.signInLink')}</button>{t('stats.guestHint')}
</p>
{:else if stats}
<div class="grid">
{#each cards as c (c.key)}
@@ -89,6 +94,16 @@
.muted {
color: var(--text-muted);
}
/* The inline link-styled button opening the guest stub (mirrors New Game's .hintlink). */
.hintlink {
background: none;
border: none;
padding: 0;
color: var(--accent);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);