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. 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`, - **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. 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 - **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). `${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 - **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. both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST - **Seat display name is built in two places** — `game.Service` live events **and** the server REST
DTOs. Change both or they drift. 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, - **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. 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**. toggle → **Start**.
- *With friends:* rows **Invite a friend** / **Pass-and-play** → pushed sub-flows. - *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 - **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)** **Behaviour (per FUNCTIONAL, kept)**
- **Quick game:** AI default (instant 🤖, no wait) / Random (auto-match 2p — drops you into the game to - **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/go-jet/jet/v2/qrm"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/lib/pq"
"scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table" "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: // VariantPreferences is the set of game variants (engine.Variant stable labels:
// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched // "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 // 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 // player creates; an invited friend may still accept any variant. A newly registered
// defaults to Erudit only. Never empty — enforced on update and by a DB check. // account defaults to DefaultVariantPreferences and a guest to
// GuestVariantPreferences. Never empty — enforced on update and by a DB check.
VariantPreferences []string VariantPreferences []string
// IsGuest marks an ephemeral guest account: a durable row with no identity, // IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history. // 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 // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid. // foreign keys of a shared finished game stay valid.
MergedInto uuid.UUID 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 // FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
// zero time for an unflagged account, otherwise when the gateway-reported // zero time for an unflagged account, otherwise when the gateway-reported
// rate-limiter rejections first crossed the sustained threshold. An // 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 == "" { if lang == "" {
lang = "en" 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. stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage). 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). VALUES(accountID, guestDisplayName(), true, tz, lang,
postgres.Raw("#guest_variants::text[]", map[string]interface{}{"#guest_variants": pq.StringArray(GuestVariantPreferences)})).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts var row model.Accounts
@@ -630,6 +642,7 @@ func modelToAccount(row model.Accounts) Account {
IsGuest: row.IsGuest, IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly, NotificationsInAppOnly: row.NotificationsInAppOnly,
MergedInto: mergedInto, MergedInto: mergedInto,
Deleted: row.DeletedAt != nil,
FlaggedHighRateAt: flaggedHighRateAt, FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt, UpdatedAt: row.UpdatedAt,
+16 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq"
"scrabble/backend/internal/postgres/jet/backend/table" "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 // 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 // to a durable account once it gains its first identity. It is a no-op
// for an already-durable account. // 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 { func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt). upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.VariantPreferences, table.Accounts.UpdatedAt).
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())). 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( WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(accountID)). table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))), AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
+40
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"regexp" "regexp"
"slices"
"strings" "strings"
"time" "time"
"unicode" "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. // in (Erudit, Russian Scrabble, English), independent of the client's order.
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"} 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 // validateVariantPreferences cleans a profile's variant-preference set: it drops
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and // duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
// returns the preferences in canonicalVariantOrder so the stored value is // returns the preferences in canonicalVariantOrder so the stored value is
@@ -129,6 +142,33 @@ func SeedVariantsFromStartParam(startParam string) []string {
return prefs 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, // SetVariantPreferences overwrites only the variant-preference set of the account,
// cleaning it to a deduplicated, canonically ordered subset of the known variants // 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 // (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 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 // RetentionReaper periodically purges expired account-deletion retention data via
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once // Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
// from main. // 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 { if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) 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 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 // perpendicular cross-words are ignored. Callers always set this explicitly; the
// zero value (false) is the single-word rule. // zero value (false) is the single-word rule.
MultipleWordsPerTurn bool 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 // 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 resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
multipleWords bool // false = single-word rule (perpendicular cross-words ignored) 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 log []MoveRecord
} }
@@ -164,6 +170,7 @@ func New(reg *Registry, opts Options) (*Game, error) {
resigned: make([]bool, opts.Players), resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles, dropoutTiles: opts.DropoutTiles,
multipleWords: opts.MultipleWordsPerTurn, multipleWords: opts.MultipleWordsPerTurn,
noRepeatWords: opts.NoRepeatWords,
} }
for i := range g.hands { for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize) 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 { if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) 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) scrabble.Apply(g.board, move)
g.removeFromHand(player, placementTiles(tiles)) 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 // 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. // by descending score. It is empty when the player has no legal play.
func (g *Game) GenerateMoves() []scrabble.Move { 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, // 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, EndReason: g.EndReason,
Seats: seats, Seats: seats,
LastActivityUnix: last.Unix(), 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, Seed: seed,
DropoutTiles: pre.DropoutTiles, DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn, MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
NoRepeatWords: pre.NoRepeatWords,
}) })
if err != nil { if err != nil {
return ReplayTimelineView{}, err return ReplayTimelineView{}, err
+11
View File
@@ -284,6 +284,13 @@ func (svc *Service) versionResident(version string) bool {
return false 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 // 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 // (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 // 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, Seed: seed,
DropoutTiles: params.DropoutTiles, DropoutTiles: params.DropoutTiles,
MultipleWordsPerTurn: params.MultipleWordsPerTurn, MultipleWordsPerTurn: params.MultipleWordsPerTurn,
NoRepeatWords: noRepeatWordsFor(params.Variant),
}) })
if err != nil { if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) { 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, hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(), dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
noRepeatWords: noRepeatWordsFor(params.Variant),
vsAI: params.VsAI, vsAI: params.VsAI,
kind: params.Kind, kind: params.Kind,
} }
@@ -445,6 +454,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
hintsPerPlayer: params.HintsPerPlayer, hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(), dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
noRepeatWords: noRepeatWordsFor(params.Variant),
status: StatusOpen, status: StatusOpen,
openDeadline: &deadline, openDeadline: &deadline,
kind: gamelimits.KindRandom, kind: gamelimits.KindRandom,
@@ -1584,6 +1594,7 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
Seed: seed, Seed: seed,
DropoutTiles: pre.DropoutTiles, DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn, MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
NoRepeatWords: pre.NoRepeatWords,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
+6 -1
View File
@@ -44,6 +44,8 @@ type gameInsert struct {
dropoutTiles string dropoutTiles string
// multipleWordsPerTurn false selects the single-word rule for the game. // multipleWordsPerTurn false selects the single-word rule for the game.
multipleWordsPerTurn bool 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 marks an honest-AI game (games.vs_ai).
vsAI bool vsAI bool
// kind tags the game's origin (games.game_kind) for the active-game limits. // 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.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt, table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind, 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, ).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 { if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err) return fmt.Errorf("insert game: %w", err)
} }
@@ -1355,6 +1359,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
UpdatedAt: g.UpdatedAt, UpdatedAt: g.UpdatedAt,
} }
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
out.NoRepeatWords = g.NoRepeatWords
out.VsAI = g.VsAi out.VsAI = g.VsAi
out.Kind = gamelimits.Kind(g.GameKind) out.Kind = gamelimits.Kind(g.GameKind)
if g.EndReason != nil { if g.EndReason != nil {
+4
View File
@@ -144,6 +144,10 @@ type Game struct {
FinishedAt *time.Time FinishedAt *time.Time
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule. // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
MultipleWordsPerTurn bool 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 // 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. // player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
VsAI bool VsAI bool
+76
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"errors" "errors"
"regexp" "regexp"
"slices"
"testing" "testing"
"time" "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 // TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
// client language falls back to the account default rather than failing the // client language falls back to the account default rather than failing the
// language CHECK. // language CHECK.
+114
View File
@@ -5,17 +5,22 @@ package inttest
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"net/http"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/accountdelete" "scrabble/backend/internal/accountdelete"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "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. // 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 // 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. // deeplink in the mail); a platform-only account has no email and cannot request a code.
func TestDeleteStepUpEmail(t *testing.T) { func TestDeleteStepUpEmail(t *testing.T) {
+3 -2
View File
@@ -135,10 +135,11 @@ func TestUpdateProfilePersists(t *testing.T) {
store := account.NewStore(testDB) store := account.NewStore(testDB)
acc := provisionAccount(t) 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 { if def, err := store.GetByID(ctx, acc); err != nil {
t.Fatalf("get default: %v", err) 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) 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 // TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
// to confirm a promo deep-link start-param seeds a brand-new account's variant // to confirm a promo deep-link start-param widens a brand-new account's variant
// preferences (English Scrabble alongside the default Erudit), that a new account with no // preferences English Scrabble on top of the default pair, but only for a
// such payload keeps the Erudit-only default, and that an existing account is never // non-Russian-speaking arrival — that a new account with no such payload keeps the
// re-seeded on a later login (the new-user-only contract). // default, and that an existing account is never re-seeded on a later login (the
// new-user-only contract).
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) { func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
srv := server.New(":0", server.Deps{ srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t), Logger: zaptest.NewLogger(t),
@@ -33,8 +34,8 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
}) })
h := srv.Handler() h := srv.Handler()
post := func(ext, startParam string) { post := func(ext, language, startParam string) {
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"` body := `{"external_id":"` + ext + `","language_code":"` + language + `","first_name":"Promo"`
if startParam != "" { if startParam != "" {
body += `,"start_param":"` + startParam + `"` body += `,"start_param":"` + startParam + `"`
} }
@@ -56,25 +57,33 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
return acc.VariantPreferences return acc.VariantPreferences
} }
// A brand-new account reached through a promo deep-link is seeded with English // An English-speaking arrival on a promo deep-link gains English Scrabble on top of
// Scrabble alongside the default Erudit. // the default pair, so a native English player is not lost at the door.
promoExt := "tg-" + uuid.NewString() promoExt := "tg-" + uuid.NewString()
post(promoExt, "verudit_ru-scrabble_en") post(promoExt, "en", "verudit_ru-scrabble_en")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) { if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("promo new account variants = %v, want %v", 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() plainExt := "tg-" + uuid.NewString()
post(plainExt, "") post(plainExt, "en", "")
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) { 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) 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: // A later login of the promo account, even via a different payload, must not re-seed:
// the seed is first-contact only. // the seed is first-contact only.
post(promoExt, "vscrabble_ru") post(promoExt, "en", "vscrabble_ru")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) { 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) 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 // 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 // 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. // (a fresh account defaults to the two Russian-alphabet games), and any other variant is
// The complementary case — an invited friend accepting an invitation in a variant they // refused with 400. The complementary case — an invited friend accepting an invitation in
// have NOT enabled — is exercised by TestGameLimitGate, where a default-Erudit human // a variant they have NOT enabled — is exercised by TestGameLimitGate, where a
// accepts an English invitation over HTTP. // default-preference human accepts an English invitation over HTTP.
func TestVariantPreferenceGate(t *testing.T) { func TestVariantPreferenceGate(t *testing.T) {
clearOpenGames(t) clearOpenGames(t)
srv := server.New(":0", server.Deps{ srv := server.New(":0", server.Deps{
+1
View File
@@ -42,6 +42,7 @@ func toWireGame(g GameSummary) wire.GameView {
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
LastActivityUnix: g.LastActivityUnix, 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); // 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. // it rides live events so a lobby patch keeps the per-kind count correct.
Kind int 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 // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
@@ -34,4 +34,5 @@ type Games struct {
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
VsAi bool VsAi bool
GameKind int16 GameKind int16
NoRepeatWords bool
} }
@@ -38,6 +38,7 @@ type gamesTable struct {
MultipleWordsPerTurn postgres.ColumnBool MultipleWordsPerTurn postgres.ColumnBool
VsAi postgres.ColumnBool VsAi postgres.ColumnBool
GameKind postgres.ColumnInteger GameKind postgres.ColumnInteger
NoRepeatWords postgres.ColumnBool
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -100,9 +101,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn") MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
VsAiColumn = postgres.BoolColumn("vs_ai") VsAiColumn = postgres.BoolColumn("vs_ai")
GameKindColumn = postgres.IntegerColumn("game_kind") 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} NoRepeatWordsColumn = postgres.BoolColumn("no_repeat_words")
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn} 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}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, 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, NoRepeatWordsColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
) )
return gamesTable{ return gamesTable{
@@ -130,6 +132,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
MultipleWordsPerTurn: MultipleWordsPerTurnColumn, MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
VsAi: VsAiColumn, VsAi: VsAiColumn,
GameKind: GameKindColumn, GameKind: GameKindColumn,
NoRepeatWords: NoRepeatWordsColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, 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"` Score int `json:"score"`
HintsUsed int `json:"hints_used"` HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"` 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. // gameDTO is the shared game summary.
@@ -144,6 +148,9 @@ type gameDTO struct {
ToMove int `json:"to_move"` ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` 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 // VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
// are suppressed in the client. // are suppressed in the client.
VsAI bool `json:"vs_ai"` VsAI bool `json:"vs_ai"`
@@ -292,6 +299,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
NoRepeatWords: g.NoRepeatWords,
VsAI: g.VsAI, VsAI: g.VsAI,
Kind: int(g.Kind), Kind: int(g.Kind),
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
+2
View File
@@ -314,6 +314,8 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "request_exists" return http.StatusConflict, "request_exists"
case errors.Is(err, social.ErrRequestBlocked): case errors.Is(err, social.ErrRequestBlocked):
return http.StatusForbidden, "request_blocked" return http.StatusForbidden, "request_blocked"
case errors.Is(err, social.ErrAccountDeleted):
return http.StatusGone, "account_deleted"
case errors.Is(err, social.ErrRequestNotFound): case errors.Is(err, social.ErrRequestNotFound):
return http.StatusNotFound, "request_not_found" return http.StatusNotFound, "request_not_found"
case errors.Is(err, social.ErrNoSharedGame): case errors.Is(err, social.ErrNoSharedGame):
+13 -10
View File
@@ -23,10 +23,11 @@ import (
// validated initData payload. Username, FirstName and LanguageCode seed a // validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language; BrowserTZ (the client's detected // 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 // "±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 // deep-link payload, which may widen the new account's variant preferences (first
// contact only). Subtype is the client-reported device family (ios/android/web); // contact only) — LanguageCode decides whether English Scrabble is part of that.
// Telegram's initData does not sign it, so it is recorded best-effort and the // Subtype is the client-reported device family (ios/android/web); Telegram's initData
// payments gate never relies on it. // does not sign it, so it is recorded best-effort and the payments gate never relies
// on it.
type telegramAuthRequest struct { type telegramAuthRequest struct {
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
Username string `json:"username"` 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 // joined the chat before registering is granted on the spot (no chat_member
// event fires on registration). // event fires on registration).
s.publishChatAccessChange(acc.ID) s.publishChatAccessChange(acc.ID)
// A promo deep-link may seed this brand-new account's variant preferences (e.g. // A promo deep-link may widen this brand-new account's variant preferences beyond
// English Scrabble alongside the default Erudit). Best-effort: an absent or // the default (English Scrabble for a non-Russian-speaking arrival — see
// malformed payload leaves the account on its defaults, and a write failure must // account.MergeVariantSeed, which gates that variant on req.LanguageCode).
// not block the session mint. // Best-effort: an absent or malformed payload leaves the account on its defaults,
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 { // and a write failure must not block the session mint.
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil { 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", s.log.Warn("telegram: seed variant preferences failed",
zap.String("account", acc.ID.String()), zap.Error(err)) zap.String("account", acc.ID.String()), zap.Error(err))
} }
+40
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "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. // moveRecordDTOFromHistory projects a journal move into the shared move DTO.
func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO { func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO {
tiles := make([]tileDTO, 0, len(m.Tiles)) tiles := make([]tileDTO, 0, len(m.Tiles))
@@ -435,6 +472,7 @@ func (s *Server) handleListGames(c *gin.Context) {
return return
} }
memo := map[string]string{} 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 // 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 // any unread entry, and which of those have a real message (so the badge can colour
// message-bearing games apart from nudge-only ones). // message-bearing games apart from nudge-only ones).
@@ -447,6 +485,7 @@ func (s *Server) handleListGames(c *gin.Context) {
for _, g := range games { for _, g := range games {
dto := gameDTOFromGame(g) dto := gameDTOFromGame(g)
s.fillSeatNames(c.Request.Context(), &dto, memo) s.fillSeatNames(c.Request.Context(), &dto, memo)
s.fillSeatDeleted(c.Request.Context(), &dto, delMemo)
dto.UnreadChat = unread[g.ID] dto.UnreadChat = unread[g.ID]
dto.UnreadMessages = unreadMsg[g.ID] dto.UnreadMessages = unreadMsg[g.ID]
out = append(out, dto) out = append(out, dto)
@@ -526,6 +565,7 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
return return
} }
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) 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 { if uid, ok := userID(c); ok {
s.setUnreadChat(c.Request.Context(), &dto.Game, uid) s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
} }
+2
View File
@@ -137,6 +137,7 @@ func (s *Server) handleGameState(c *gin.Context) {
return return
} }
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) 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) s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
c.JSON(http.StatusOK, dto) c.JSON(http.StatusOK, dto)
} }
@@ -207,6 +208,7 @@ func (s *Server) handleEnqueue(c *gin.Context) {
dto := matchDTOFrom(res) dto := matchDTOFrom(res)
if dto.Game != nil { if dto.Game != nil {
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{}) 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) 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 { if blockerID == blockedID {
return ErrSelfRelation 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 { if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
return err return err
} }
+5
View File
@@ -73,6 +73,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
} }
return err 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 { if iBlockThem {
// The requester has blocked the addressee — they are the blocker and aware of it. // The requester has blocked the addressee — they are the blocker and aware of it.
return ErrRequestBlocked return ErrRequestBlocked
+5
View File
@@ -62,6 +62,11 @@ var (
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend // 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. // request, redeem a friend code); friends are a durable-account feature.
ErrGuestForbidden = errors.New("social: guests cannot use friends") 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 is returned when no pending friend request matches.
ErrRequestNotFound = errors.New("social: no pending friend request") ErrRequestNotFound = errors.New("social: no pending friend request")
// ErrNoSharedGame is returned when a friend request targets someone the // 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. 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 - **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 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 (`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. A newly
accounts default to **Erudit only** (a DB column default); at least one variant must stay **registered** account defaults to the two Russian-alphabet games — **Erudit + Russian
selected. The picker is ordered **Erudit-first** everywhere. The preference gates the New 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 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 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 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 > `service_language`/`supported_languages` and the push `language` routing field, and
> gained `variant_preferences` on Profile/UpdateProfile. > 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 ## 4. Accounts, identities, linking & merge
- One internal account may carry several **platform identities** - 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 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 kind — e.g. each account held a confirmed email — keeps the primary's and journals the
secondary's with `reason=merge` before dropping it.) 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; 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. `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 `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 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 who chose the same rule are paired (the rule field rides every create/enqueue request, so
matchmaking stays one uniform path). 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** - **End of game**: the bag is empty **and** a player empties their rack, **or**
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or **6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
a missed turn. The **per-game turn timeout** is chosen at creation 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 - **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window**, the (confirm-code binding, see §4), **timezone**, the daily **away window**, the
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New **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: block toggles — all editable through `account.UpdateProfile`, which validates them:
a display name is Unicode letters joined by single ` `/`.`/`_` a display name is Unicode letters joined by single ` `/`.`/`_`
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a 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 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 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*) — 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 "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 — 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 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), 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 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 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, 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 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 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 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 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 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 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 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 **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 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 yourself to be matched into; a **newly registered account starts with Erudite + Russian
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep 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 **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 (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 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»/«Эрудит» (по языку интерфейса), читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты
инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок
@@ -202,7 +203,19 @@ e-mail) либо ввод фразы. Активные игры форфейтя
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются; этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4) кто выбрал то же правило.
**В «Эрудите» слово нельзя повторять.** Выставленное на доску слово принадлежит партии: выставить
его ещё раз нельзя, и робот такого хода тоже не делает. Ход, чьё основное слово уже стоит на доске,
отклоняется как любой недопустимый: фишки на доске подсвечиваются как недопустимые и ход не отправить,
а поскольку само слово вполне настоящее, подпись над счётом называет его — «СЛОВО: уже использовано»
вместо обычных очков. Это решение принимается на устройстве, до того как ход предложен серверу,
одинаково в онлайн- и в оффлайн-партии; ход, который лишь образует уже выставленное слово **поперёк**
основного, засчитывается — такое слово побочно для хода, — но очков не приносит. Правило не
выбирается: его берёт каждая партия «Эрудита», и никогда — оба варианта скрэббла (в официальных
правилах такого ограничения нет), поэтому однострочное описание варианта на экране **новой игры**
содержит «слова без повтора». Партии, начатые до появления правила, продолжают играть без него,
так что правила идущей партии не меняются у игроков на ходу. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга, формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App можно отправить 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**, — русским 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 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 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 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 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) 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 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 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 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 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 - **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 — 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), 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"` 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 { type SeatResp struct {
Seat int `json:"seat"` Seat int `json:"seat"`
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
@@ -161,6 +162,7 @@ type SeatResp struct {
Score int `json:"score"` Score int `json:"score"`
HintsUsed int `json:"hints_used"` HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"` IsWinner bool `json:"is_winner"`
Deleted bool `json:"deleted"`
} }
// GameResp is the shared game summary. // GameResp is the shared game summary.
@@ -181,6 +183,7 @@ type GameResp struct {
UnreadChat bool `json:"unread_chat"` UnreadChat bool `json:"unread_chat"`
UnreadMessages bool `json:"unread_messages"` UnreadMessages bool `json:"unread_messages"`
Kind int `json:"kind"` 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 // 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, HintsUsed: s.HintsUsed,
IsWinner: s.IsWinner, IsWinner: s.IsWinner,
DisplayName: s.DisplayName, DisplayName: s.DisplayName,
Deleted: s.Deleted,
} }
} }
return wire.GameView{ return wire.GameView{
@@ -637,6 +638,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
UnreadChat: g.UnreadChat, UnreadChat: g.UnreadChat,
UnreadMessages: g.UnreadMessages, UnreadMessages: g.UnreadMessages,
Kind: g.Kind, 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" { if r.URL.Path != "/api/v1/user/games" {
t.Errorf("unexpected path %q", r.URL.Path) 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() defer cleanup()
@@ -333,6 +333,14 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if string(seat.DisplayName()) != "Ann" { if string(seat.DisplayName()) != "Ann" {
t.Errorf("seat display name = %q, want Ann", seat.DisplayName()) 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() { if !gl.AtGameLimit() {
t.Error("at_game_limit = false, want true (the backend reported the cap)") 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 // 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 { table SeatView {
seat:int; seat:int;
account_id:string; account_id:string;
@@ -52,6 +54,7 @@ table SeatView {
hints_used:int; hints_used:int;
is_winner:bool; is_winner:bool;
display_name:string; display_name:string;
deleted:bool;
} }
// GameView is the shared (non-private) game summary. // 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 // caller's per-kind limits (Profile.game_limits) to lock a capped New-Game start (added
// trailing — backward-compatible). // trailing — backward-compatible).
kind:int; 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). // 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) 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) { func GameViewStart(builder *flatbuffers.Builder) {
builder.StartObject(16) builder.StartObject(17)
} }
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) 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) { func GameViewAddKind(builder *flatbuffers.Builder, kind int32) {
builder.PrependInt32Slot(15, kind, 0) builder.PrependInt32Slot(15, kind, 0)
} }
func GameViewAddNoRepeatWords(builder *flatbuffers.Builder, noRepeatWords bool) {
builder.PrependBoolSlot(16, noRepeatWords, false)
}
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+16 -1
View File
@@ -105,8 +105,20 @@ func (rcv *SeatView) DisplayName() []byte {
return nil 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) { func SeatViewStart(builder *flatbuffers.Builder) {
builder.StartObject(6) builder.StartObject(7)
} }
func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) { func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) {
builder.PrependInt32Slot(0, seat, 0) builder.PrependInt32Slot(0, seat, 0)
@@ -126,6 +138,9 @@ func SeatViewAddIsWinner(builder *flatbuffers.Builder, isWinner bool) {
func SeatViewAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { func SeatViewAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(displayName), 0) 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 { func SeatViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+8
View File
@@ -26,6 +26,9 @@ type SeatView struct {
HintsUsed int HintsUsed int
IsWinner bool IsWinner bool
DisplayName string 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. // 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, // 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. // 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start.
Kind int 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 // 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.SeatViewAddHintsUsed(b, int32(s.HintsUsed))
fb.SeatViewAddIsWinner(b, s.IsWinner) fb.SeatViewAddIsWinner(b, s.IsWinner)
fb.SeatViewAddDisplayName(b, dname) fb.SeatViewAddDisplayName(b, dname)
fb.SeatViewAddDeleted(b, s.Deleted)
seatOffs[i] = fb.SeatViewEnd(b) seatOffs[i] = fb.SeatViewEnd(b)
} }
fb.GameViewStartSeatsVector(b, len(seatOffs)) fb.GameViewStartSeatsVector(b, len(seatOffs))
@@ -173,6 +180,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
fb.GameViewAddUnreadChat(b, g.UnreadChat) fb.GameViewAddUnreadChat(b, g.UnreadChat)
fb.GameViewAddUnreadMessages(b, g.UnreadMessages) fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
fb.GameViewAddKind(b, int32(g.Kind)) fb.GameViewAddKind(b, int32(g.Kind))
fb.GameViewAddNoRepeatWords(b, g.NoRepeatWords)
return fb.GameViewEnd(b) 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); 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 }) => { test('a swipe-down on the zoomed-in board does not open the history (native scroll wins)', async ({ page }) => {
await openGame(page); await openGame(page);
// Double-tap an empty cell to zoom in (two synchronous clicks = a double-tap). // 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(); 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 }) => { test('landscape zooms the board on a double-tap and the zoomed board overflows the pane', async ({ page }) => {
await openGame(page); await openGame(page);
// Double-tap an empty cell zooms in, same as portrait. The board is height-driven here, so the // 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 // + 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 // 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 // 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. // chunks. Raised to 131 for the Erudit no-repeat-words rule: the per-game flag rides the decoded
const BUDGET = { app: 130, shared: 31, landing: 5 }; // 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 // 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. // 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. // nobody to message or hurry. The fields still show (turn-driven), but disabled.
vsAi?: boolean; vsAi?: boolean;
// blocked hides the whole composer (message field, send and nudge), leaving only the chat // 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 // log — as in a finished game — when no opponent can be reached: the viewer has blocked them
// they will message (the backend rejects it too). // (the backend rejects it too), or their account has been deleted.
blocked?: boolean; blocked?: boolean;
onsend: (text: string) => void; onsend: (text: string) => void;
onnudge: () => void; onnudge: () => void;
+6 -5
View File
@@ -36,15 +36,16 @@
const canNudge = $derived(!!view && view.game.status === 'active' && view.game.toMove !== view.seat); 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. // While the auto-match game still has no opponent, chat and nudge are both disabled.
const waiting = $derived(!!view && view.game.status === 'open'); const waiting = $derived(!!view && view.game.status === 'open');
// peerBlocked is true when every seated opponent is one the viewer has blocked: the whole // peerUnreachable is true when no seated opponent can be reached — each is either one the viewer
// composer is then hidden (a blocked opponent cannot be messaged). // has blocked or a deleted account: the whole composer (message field, send and nudge) is then
const peerBlocked = $derived.by(() => { // hidden, leaving only the chat log.
const peerUnreachable = $derived.by(() => {
const v = view; const v = view;
if (!v) return false; if (!v) return false;
const opponents = v.game.seats.filter((s) => s.seat !== v.seat && !!s.accountId); const opponents = v.game.seats.filter((s) => s.seat !== v.seat && !!s.accountId);
return ( return (
opponents.length > 0 && 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; const nudgeCooldownSecs = 3600;
@@ -155,7 +156,7 @@
{waiting} {waiting}
{nudgeOnCooldown} {nudgeOnCooldown}
vsAi={!!view && view.game.vsAi} vsAi={!!view && view.game.vsAi}
blocked={peerBlocked} blocked={peerUnreachable}
onsend={sendChat} onsend={sendChat}
onnudge={nudge} 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_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 // 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: // 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 // (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 // 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; let wasStreamAlive = app.streamAlive;
$effect(() => { $effect(() => {
const alive = app.streamAlive; const alive = app.streamAlive;
if (alive && !wasStreamAlive) void load(); if (alive && !wasStreamAlive && !isLocalGameId(id)) void load();
wasStreamAlive = alive; wasStreamAlive = alive;
}); });
@@ -446,7 +451,7 @@
const r = app.resync; const r = app.resync;
if (r !== lastResync) { if (r !== lastResync) {
lastResync = r; lastResync = r;
void load(); if (!isLocalGameId(id)) void load();
} }
}); });
@@ -828,7 +833,10 @@
const reader = d.peekDawg(v.game.variant, v.game.dictVersion); const reader = d.peekDawg(v.game.variant, v.game.dictVersion);
if (reader && hasAlphabet(v.game.variant)) { if (reader && hasAlphabet(v.game.variant)) {
try { 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(); notePreviewLocal();
return; return;
} catch { } 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 // 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 // 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; // 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 // 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 // (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled). // opponent still shows it, but disabled).
function canAddFriend(s: { accountId: string; seat: number }): boolean { function canAddFriend(s: { accountId: string; seat: number; deleted?: boolean }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in // Never offer add-friend against an AI opponent, an existing friend, a blocked player or a
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids. // 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; if (view?.game.vsAi || isLocalGameId(id)) return false;
return ( return (
netReady && netReady &&
!!s.accountId && !!s.accountId &&
!s.deleted &&
!app.profile?.isGuest && !app.profile?.isGuest &&
s.accountId !== app.session?.userId && s.accountId !== app.session?.userId &&
!friends.has(s.accountId) && !friends.has(s.accountId) &&
@@ -1488,10 +1521,18 @@
// canBlock reports whether a seat shows the ✖️ block control: like canAddFriend, but a friend // 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. // 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). // An already-blocked opponent hides it (both controls go, and the name is struck); a deleted
function canBlock(s: { accountId: string; seat: number }): boolean { // 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; 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> </script>
@@ -1553,6 +1594,11 @@
{resultText()} {resultText()}
{:else if preview?.legal && !recallOverRack} {:else if preview?.legal && !recallOverRack}
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score} {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} {:else}
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()} {view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
{/if} {/if}
@@ -1634,7 +1680,7 @@
</button> </button>
{/if} {/if}
</div> </div>
<div class="hgridwrap"> <div class="hgridwrap" bind:this={hgridEl}>
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)"> <div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row} {#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
{#each row as cell (cell.player)} {#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.6vmin; /* cqw fallback for Chrome < 105 */
font-size: 7.6cqw; 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> </style>
+12 -2
View File
@@ -118,8 +118,13 @@ kind():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; 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) { static startGameView(builder:flatbuffers.Builder) {
builder.startObject(16); builder.startObject(17);
} }
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -198,12 +203,16 @@ static addKind(builder:flatbuffers.Builder, kind:number) {
builder.addFieldInt32(15, kind, 0); builder.addFieldInt32(15, kind, 0);
} }
static addNoRepeatWords(builder:flatbuffers.Builder, noRepeatWords:boolean) {
builder.addFieldInt8(16, +noRepeatWords, +false);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startGameView(builder);
GameView.addId(builder, idOffset); GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset); GameView.addVariant(builder, variantOffset);
@@ -221,6 +230,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addUnreadChat(builder, unreadChat); GameView.addUnreadChat(builder, unreadChat);
GameView.addUnreadMessages(builder, unreadMessages); GameView.addUnreadMessages(builder, unreadMessages);
GameView.addKind(builder, kind); GameView.addKind(builder, kind);
GameView.addNoRepeatWords(builder, noRepeatWords);
return GameView.endGameView(builder); 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; 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) { static startSeatView(builder:flatbuffers.Builder) {
builder.startObject(6); builder.startObject(7);
} }
static addSeat(builder:flatbuffers.Builder, seat:number) { static addSeat(builder:flatbuffers.Builder, seat:number) {
@@ -82,12 +87,16 @@ static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers
builder.addFieldOffset(5, displayNameOffset, 0); builder.addFieldOffset(5, displayNameOffset, 0);
} }
static addDeleted(builder:flatbuffers.Builder, deleted:boolean) {
builder.addFieldInt8(6, +deleted, +false);
}
static endSeatView(builder:flatbuffers.Builder):flatbuffers.Offset { static endSeatView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startSeatView(builder);
SeatView.addSeat(builder, seat); SeatView.addSeat(builder, seat);
SeatView.addAccountId(builder, accountIdOffset); SeatView.addAccountId(builder, accountIdOffset);
@@ -95,6 +104,7 @@ static createSeatView(builder:flatbuffers.Builder, seat:number, accountIdOffset:
SeatView.addHintsUsed(builder, hintsUsed); SeatView.addHintsUsed(builder, hintsUsed);
SeatView.addIsWinner(builder, isWinner); SeatView.addIsWinner(builder, isWinner);
SeatView.addDisplayName(builder, displayNameOffset); SeatView.addDisplayName(builder, displayNameOffset);
SeatView.addDeleted(builder, deleted);
return SeatView.endSeatView(builder); return SeatView.endSeatView(builder);
} }
} }
+7
View File
@@ -400,6 +400,7 @@ describe('codec', () => {
fb.SeatView.addHintsUsed(b, 0); fb.SeatView.addHintsUsed(b, 0);
fb.SeatView.addIsWinner(b, false); fb.SeatView.addIsWinner(b, false);
fb.SeatView.addDisplayName(b, dn); fb.SeatView.addDisplayName(b, dn);
fb.SeatView.addDeleted(b, true);
const seat = fb.SeatView.endSeatView(b); const seat = fb.SeatView.endSeatView(b);
const seats = fb.GameView.createSeatsVector(b, [seat]); const seats = fb.GameView.createSeatsVector(b, [seat]);
const id = b.createString('g1'); const id = b.createString('g1');
@@ -422,6 +423,7 @@ describe('codec', () => {
fb.GameView.addVsAi(b, true); fb.GameView.addVsAi(b, true);
fb.GameView.addUnreadChat(b, true); fb.GameView.addUnreadChat(b, true);
fb.GameView.addKind(b, 2); fb.GameView.addKind(b, 2);
fb.GameView.addNoRepeatWords(b, true);
const game = fb.GameView.endGameView(b); const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]); const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b); fb.GameList.startGameList(b);
@@ -434,10 +436,15 @@ describe('codec', () => {
expect(gl.games[0].id).toBe('g1'); expect(gl.games[0].id).toBe('g1');
expect(gl.games[0].seats[0].displayName).toBe('Ann'); expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13); 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].lastActivityUnix).toBe(1717000000);
expect(gl.games[0].vsAi).toBe(true); expect(gl.games[0].vsAi).toBe(true);
expect(gl.games[0].unreadChat).toBe(true); expect(gl.games[0].unreadChat).toBe(true);
expect(gl.games[0].kind).toBe(2); 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); expect(gl.atGameLimit).toBe(true);
}); });
+3
View File
@@ -291,6 +291,7 @@ function decodeSeat(v: fb.SeatView): Seat {
score: v.score(), score: v.score(),
hintsUsed: v.hintsUsed(), hintsUsed: v.hintsUsed(),
isWinner: v.isWinner(), isWinner: v.isWinner(),
deleted: v.deleted(),
}; };
} }
@@ -309,6 +310,7 @@ function decodeGameView(g: fb.GameView): GameView {
toMove: g.toMove(), toMove: g.toMove(),
turnTimeoutSecs: g.turnTimeoutSecs(), turnTimeoutSecs: g.turnTimeoutSecs(),
multipleWordsPerTurn: g.multipleWordsPerTurn(), multipleWordsPerTurn: g.multipleWordsPerTurn(),
noRepeatWords: g.noRepeatWords(),
moveCount: g.moveCount(), moveCount: g.moveCount(),
endReason: s(g.endReason()), endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()), lastActivityUnix: Number(g.lastActivityUnix()),
@@ -1142,6 +1144,7 @@ function emptyGame(): GameView {
toMove: 0, toMove: 0,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, 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 { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate';
import { playDirection } from './direction'; import { playDirection } from './direction';
import { noRepeatScore } from './norepeat';
import type { Board as ClientBoard } from '../board'; import type { Board as ClientBoard } from '../board';
import type { PlacedTile } from '../client'; import type { PlacedTile } from '../client';
import type { EvalResult, Variant } from '../model'; 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 // decodeWord turns a word's alphabet indices back into a lower-cased string, the
// form the network EvalResult carries (the caption renders it directly). // 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 = ''; let s = '';
for (const i of letters) s += letterForIndex(variant, i); for (const i of letters) s += letterForIndex(variant, i);
return s.toLowerCase(); 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 * 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 * 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 * 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 * cross-word rule. playedWords is the game's already-played words (decoded, as the
* caller guards with hasAlphabet and falls back to the network on any failure. * 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( export function evaluateLocal(
dict: Dict, dict: Dict,
@@ -83,6 +88,7 @@ export function evaluateLocal(
board: ClientBoard, board: ClientBoard,
tiles: PlacedTile[], tiles: PlacedTile[],
multipleWords: boolean, multipleWords: boolean,
playedWords?: ReadonlySet<string>,
): EvalResult { ): EvalResult {
const vboard = buildBoard(variant, board); const vboard = buildBoard(variant, board);
const rs = buildRuleset(variant, multipleWords); const rs = buildRuleset(variant, multipleWords);
@@ -100,5 +106,11 @@ export function evaluateLocal(
} }
const m = res.move; const m = res.move;
const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters)); 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 // of the initial app bundle — it is a progressive enhancement over the network
// preview, which remains the fallback. // preview, which remains the fallback.
export { evaluateLocal } from './eval'; export { evaluateLocal } from './eval';
export { playedWordsFromHistory } from './norepeat';
export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader'; export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader';
export { clearDictCache, listCachedDicts } from './store'; 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, toMove: 0,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+42
View File
@@ -17,6 +17,7 @@ function gameView(moveCount: number, over = false): GameView {
toMove: 1, toMove: 1,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount, moveCount,
endReason: over ? 'standard' : '', endReason: over ? 'standard' : '',
lastActivityUnix: 0, 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 { function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent {
return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen }; 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; 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 / * 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. * 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 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. // 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 }; 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 }; 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 (!cached) return { refetch: false };
if (!game) return { refetch: cached.view.game.status !== 'finished' }; if (!game) return { refetch: cached.view.game.status !== 'finished' };
if (cached.view.game.moveCount < game.moveCount) return { refetch: true }; 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 }; return { cache: { view, moves: cached.moves }, refetch: false };
} }
+1
View File
@@ -20,6 +20,7 @@ function game(seats: Seat[], over = true): GameView {
toMove: 0, toMove: 0,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: false, multipleWordsPerTurn: false,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: over ? 'standard' : '', endReason: over ? 'standard' : '',
lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC) 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, toMove: 0,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+5 -5
View File
@@ -24,9 +24,9 @@ function load(): Promise<LocalSource> {
* local-only create() and the robot-reply event subscription events(). * local-only create() and the robot-reply event subscription events().
*/ */
export const localSource = { export const localSource = {
// Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate // Offline scoring is self-contained, so the local source ignores includeAlphabet and the evaluate
// abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full // abort signal; the proxy accepts the full gateway signatures but forwards only what the local
// gateway signatures but forwards only what the local source uses. // source uses.
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)), gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
gameHistory: (id) => load().then((s) => s.gameHistory(id)), gameHistory: (id) => load().then((s) => s.gameHistory(id)),
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)), 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)), hint: (id) => load().then((s) => s.hint(id)),
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)), 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)), checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
draftGet: (_id) => load().then((s) => s.draftGet()), draftGet: (id) => load().then((s) => s.draftGet(id)),
draftSave: (_id, _json) => load().then((s) => s.draftSave()), draftSave: (id, json) => load().then((s) => s.draftSave(id, json)),
create: (opts) => load().then((s) => s.create(opts)), create: (opts) => load().then((s) => s.create(opts)),
list: () => load().then((s) => s.list()), list: () => load().then((s) => s.list()),
delete: (id) => load().then((s) => s.delete(id)), delete: (id) => load().then((s) => s.delete(id)),
+12 -2
View File
@@ -41,6 +41,10 @@ export const en = {
'common.you': 'You', 'common.you': 'You',
'common.guest': 'Guest', 'common.guest': 'Guest',
'common.save': 'Save', '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.title': 'Sign in',
'login.guest': 'Play as guest', 'login.guest': 'Play as guest',
@@ -79,10 +83,13 @@ export const en = {
'new.searching': 'Looking for an opponent…', 'new.searching': 'Looking for an opponent…',
'new.rulesEnglish': '100 tiles · bingo +50', 'new.rulesEnglish': '100 tiles · bingo +50',
'new.rulesRussian': '104 tiles · ё is a letter · 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.moveLimit': 'Move time: {n} h 00 min',
'new.searchHint': '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.', '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 // First-run coachmark hints (components/Coachmark.svelte). The leading emoji in each comment
// names the UI element the bubble's tail points at. // names the UI element the bubble's tail points at.
@@ -99,6 +106,7 @@ export const en = {
'game.bagCount': 'In the bag: {n}', 'game.bagCount': 'In the bag: {n}',
'game.bagEmpty': 'Bag is empty', 'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}', 'game.hints': 'Hints {n}',
'game.wordAlreadyUsed': 'already used',
'game.yourTurn': 'Your turn', 'game.yourTurn': 'Your turn',
'game.yourTurnBy': '{name}: Your turn!', 'game.yourTurnBy': '{name}: Your turn!',
'game.opponentsTurn': "Opponent's turn", 'game.opponentsTurn': "Opponent's turn",
@@ -414,7 +422,8 @@ export const en = {
'stats.winRate': 'Win rate', 'stats.winRate': 'Win rate',
'stats.maxGame': 'Best game', 'stats.maxGame': 'Best game',
'stats.maxWord': 'Best move', '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.exportGcg': 'Export game',
'game.exportChoice': 'Export the finished game as:', 'game.exportChoice': 'Export the finished game as:',
@@ -434,6 +443,7 @@ export const en = {
'error.request_not_found': 'No matching friend request.', 'error.request_not_found': 'No matching friend request.',
'error.no_shared_game': 'You can only add someone you have played with.', 'error.no_shared_game': 'You can only add someone you have played with.',
'error.request_declined': 'This player declined your request.', '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.friend_code_invalid': 'That friend code is invalid or expired.',
'error.invalid_invitation': 'Invalid invitation.', 'error.invalid_invitation': 'Invalid invitation.',
'error.invitation_blocked': 'You cannot invite this player.', '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.you': 'Вы',
'common.guest': 'Гость', 'common.guest': 'Гость',
'common.save': 'Сохранить', 'common.save': 'Сохранить',
'common.signInLink': 'Войдите',
'login.title': 'Вход', 'login.title': 'Вход',
'login.guest': 'Играть как гость', 'login.guest': 'Играть как гость',
@@ -80,10 +81,11 @@ export const ru: Record<MessageKey, string> = {
'new.searching': 'Ищем соперника…', 'new.searching': 'Ищем соперника…',
'new.rulesEnglish': '100 фишек · бинго +50', 'new.rulesEnglish': '100 фишек · бинго +50',
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50', 'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15', 'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15 · слова без повтора',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.', 'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint': 'new.searchHint':
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.', 'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
'new.guestVariantHint': ', чтобы играть в русский или английский вариант Скрэббл.',
// Подсказки первого запуска (components/Coachmark.svelte). // Подсказки первого запуска (components/Coachmark.svelte).
'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️ 'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️
@@ -99,6 +101,7 @@ export const ru: Record<MessageKey, string> = {
'game.bagCount': 'В мешке: {n}', 'game.bagCount': 'В мешке: {n}',
'game.bagEmpty': 'Мешок пуст', 'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}', 'game.hints': 'Подсказки {n}',
'game.wordAlreadyUsed': 'уже использовано',
'game.yourTurn': 'Ваш ход', 'game.yourTurn': 'Ваш ход',
'game.yourTurnBy': '{name}: Ваш ход!', 'game.yourTurnBy': '{name}: Ваш ход!',
'game.opponentsTurn': 'Ход соперника', 'game.opponentsTurn': 'Ход соперника',
@@ -414,7 +417,7 @@ export const ru: Record<MessageKey, string> = {
'stats.winRate': 'Доля побед', 'stats.winRate': 'Доля побед',
'stats.maxGame': 'Лучшая игра', 'stats.maxGame': 'Лучшая игра',
'stats.maxWord': 'Лучший ход', 'stats.maxWord': 'Лучший ход',
'stats.guestHint': 'Войдите, чтобы вести статистику.', 'stats.guestHint': ', чтобы вести статистику.',
'game.exportGcg': 'Экспорт партии', 'game.exportGcg': 'Экспорт партии',
'game.exportChoice': 'Экспортировать завершённую партию как:', 'game.exportChoice': 'Экспортировать завершённую партию как:',
@@ -434,6 +437,7 @@ export const ru: Record<MessageKey, string> = {
'error.request_not_found': 'Подходящей заявки нет.', 'error.request_not_found': 'Подходящей заявки нет.',
'error.no_shared_game': 'Можно добавить только того, с кем вы играли.', 'error.no_shared_game': 'Можно добавить только того, с кем вы играли.',
'error.request_declined': 'Игрок отклонил вашу заявку.', 'error.request_declined': 'Игрок отклонил вашу заявку.',
'error.account_deleted': 'Игрок удалил свою учётную запись.',
'error.friend_code_invalid': 'Код недействителен или истёк.', 'error.friend_code_invalid': 'Код недействителен или истёк.',
'error.invalid_invitation': 'Неверное приглашение.', 'error.invalid_invitation': 'Неверное приглашение.',
'error.invitation_blocked': 'Нельзя пригласить этого игрока.', 'error.invitation_blocked': 'Нельзя пригласить этого игрока.',
+1
View File
@@ -33,6 +33,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
toMove, toMove,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+1
View File
@@ -29,6 +29,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
toMove, toMove,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix, 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 { RULESETS } from './ruleset';
import { generateMoves, GenRack, Both } from '../dict/generate'; import { generateMoves, GenRack, Both } from '../dict/generate';
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate'; 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 { playDirection } from '../dict/direction';
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums'; import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
import { BLANK_INDEX } from '../alphabet'; import { BLANK_INDEX } from '../alphabet';
@@ -154,6 +155,10 @@ export interface LocalGameOptions {
players: number; players: number;
dawg: Dawg; dawg: Dawg;
multipleWords: boolean; 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; dropoutTiles?: DropoutTiles;
} }
@@ -175,6 +180,7 @@ export class LocalGame {
private readonly rackSize: number; private readonly rackSize: number;
private readonly dawg: Dawg; private readonly dawg: Dawg;
private readonly multipleWords: boolean; private readonly multipleWords: boolean;
private readonly noRepeatWords: boolean;
private readonly dropoutTiles: DropoutTiles; private readonly dropoutTiles: DropoutTiles;
private readonly board: LocalBoard; private readonly board: LocalBoard;
@@ -197,6 +203,7 @@ export class LocalGame {
this.seed = opts.seed; this.seed = opts.seed;
this.dawg = opts.dawg; this.dawg = opts.dawg;
this.multipleWords = opts.multipleWords; this.multipleWords = opts.multipleWords;
this.noRepeatWords = opts.noRepeatWords ?? false;
this.dropoutTiles = opts.dropoutTiles ?? 'remove'; this.dropoutTiles = opts.dropoutTiles ?? 'remove';
this.vrs = buildRuleset(opts.variant, opts.multipleWords); this.vrs = buildRuleset(opts.variant, opts.multipleWords);
this.values = RULESETS[opts.variant].values; 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 /** config returns the rule settings the game was created with the bits, alongside the seed and
* journal, needed to reconstruct it (see localgame/serialize). */ * journal, needed to reconstruct it (see localgame/serialize). */
get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } { get config(): { multipleWords: boolean; noRepeatWords: boolean; dropoutTiles: DropoutTiles } {
return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles }; return { multipleWords: this.multipleWords, noRepeatWords: this.noRepeatWords, dropoutTiles: this.dropoutTiles };
} }
/** history returns a copy of the move log. */ /** history returns a copy of the move log. */
get history(): LocalMove[] { get history(): LocalMove[] {
return this.log.map((m) => ({ ...m })); 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[] { 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, /** 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 * 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. */ * 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 dir = playDirection(this.board, this.vrs, this.dawg, tiles);
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles); const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir }; if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
const m = res.move; 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. */ /** 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); const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) throw new GameError('illegal_play'); if (!res.legal || !res.move) throw new GameError('illegal_play');
const move = res.move; 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); for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
this.removeFromHand(player, used); this.removeFromHand(player, used);
this.scores[player] += move.score; this.scores[player] += score;
this.refill(player); this.refill(player);
this.scorelessRun = 0; this.scorelessRun = 0;
@@ -314,7 +355,7 @@ export class LocalGame {
mainRow: move.main.row, mainRow: move.main.row,
mainCol: move.main.col, mainCol: move.main.col,
words: [move.main.letters, ...move.cross.map((w) => w.letters)], words: [move.main.letters, ...move.cross.map((w) => w.letters)],
score: move.score, score,
total: this.scores[player], total: this.scores[player],
}; };
this.log.push(rec); this.log.push(rec);
+15
View File
@@ -35,6 +35,10 @@ export interface LocalGameRecord {
seed: string; seed: string;
players: number; players: number;
multipleWords: boolean; 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; dropoutTiles: DropoutTiles;
seats: Seat[]; seats: Seat[];
/** The dictionary-independent, alphabet-index-space move journal. */ /** 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 * read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it
* see lib/hints. */ * see lib/hints. */
hintUnlockAtMs: number; 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). */ /** The record fields the caller owns (the engine supplies the rest). */
@@ -64,6 +75,7 @@ export interface RecordMeta {
createdAtUnix: number; createdAtUnix: number;
updatedAtUnix: number; updatedAtUnix: number;
hintUnlockAtMs: number; hintUnlockAtMs: number;
draft?: string;
} }
/** /**
@@ -79,6 +91,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
seed: game.seed.toString(), seed: game.seed.toString(),
players: game.playerCount, players: game.playerCount,
multipleWords: cfg.multipleWords, multipleWords: cfg.multipleWords,
noRepeatWords: cfg.noRepeatWords || undefined,
dropoutTiles: cfg.dropoutTiles, dropoutTiles: cfg.dropoutTiles,
seats: meta.seats, seats: meta.seats,
journal: game.history, journal: game.history,
@@ -88,6 +101,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
createdAtUnix: meta.createdAtUnix, createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix, updatedAtUnix: meta.updatedAtUnix,
hintUnlockAtMs: meta.hintUnlockAtMs, hintUnlockAtMs: meta.hintUnlockAtMs,
draft: meta.draft,
}; };
} }
@@ -104,6 +118,7 @@ export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame {
players: record.players, players: record.players,
dawg, dawg,
multipleWords: record.multipleWords, multipleWords: record.multipleWords,
noRepeatWords: record.noRepeatWords,
dropoutTiles: record.dropoutTiles, dropoutTiles: record.dropoutTiles,
}; };
const game = new LocalGame(opts); 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 () => { it('drives a whole local game to completion', async () => {
const src = await newSource('local:g5', 2024n); const src = await newSource('local:g5', 2024n);
src.events('local:g5', () => {}); 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 { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy'; import { decide } from '../robot/strategy';
import { verifyPin, type PinLock } from '../pin'; import { verifyPin, type PinLock } from '../pin';
import { noRepeatWordsFor } from '../variants';
import { GatewayError, type PlacedTile } from '../client'; import { GatewayError, type PlacedTile } from '../client';
import type { import type {
EvalResult, EvalResult,
@@ -122,6 +123,7 @@ export class LocalSource implements GameLoopSource {
players: opts.seats.length, players: opts.seats.length,
dawg, dawg,
multipleWords: opts.multipleWords, multipleWords: opts.multipleWords,
noRepeatWords: noRepeatWordsFor(opts.variant),
}); });
const now = nowUnix(); const now = nowUnix();
const record = serializeGame(game, { const record = serializeGame(game, {
@@ -195,6 +197,7 @@ export class LocalSource implements GameLoopSource {
const seat = entry.game.currentPlayer; const seat = entry.game.currentPlayer;
const move = entry.game.resign(); const move = entry.game.resign();
entry.unlockedSeat = null; entry.unlockedSeat = null;
entry.record.draft = undefined; // the turn is over; the abandoned composition goes with it
await this.persist(entry); await this.persist(entry);
return this.moveResult(entry, move, seat); return this.moveResult(entry, move, seat);
} }
@@ -251,6 +254,7 @@ export class LocalSource implements GameLoopSource {
if (action === 'skip') entry.game.pass(); if (action === 'skip') entry.game.pass();
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer); else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
entry.unlockedSeat = null; entry.unlockedSeat = null;
entry.record.draft = undefined; // the host advanced the turn; the seat's composition is stale
await this.persist(entry); await this.persist(entry);
return this.stateView(entry); return this.stateView(entry);
} }
@@ -268,11 +272,13 @@ export class LocalSource implements GameLoopSource {
async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> { async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
const { game } = await this.load(gameId); const { game } = await this.load(gameId);
const r = game.evaluatePlay(encodePlacements(variant, tiles)); const r = game.evaluatePlay(encodePlacements(variant, tiles));
const decode = (w: readonly number[]) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase();
return { return {
legal: r.legal, legal: r.legal,
score: r.score, 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') : '', 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) }; return { word, legal: game.dictionaryHas(idx) };
} }
// Drafts (the in-progress composition) are not persisted for local games — the arrangement is // Drafts (the in-progress composition: rack order + pending board tiles) live in the game's own
// rebuilt from the rack on reopen. The screen tolerates an empty draft. // record — a local game has no server to hold them. Persisting them keeps every reload of the
async draftGet(): Promise<string> { // state (the game screen refetches on several triggers) from dropping the arrangement back into
return ''; // 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> { async draftSave(gameId: string, json: string): Promise<void> {
/* no-op offline */ 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 /** 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 seat = entry.game.currentPlayer;
const move = act(entry.game); const move = act(entry.game);
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai) 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); const robotMoves = this.runRobots(entry);
await this.persist(entry); await this.persist(entry);
const result = this.moveResult(entry, move, seat); const result = this.moveResult(entry, move, seat);
@@ -378,6 +390,7 @@ export class LocalSource implements GameLoopSource {
createdAtUnix: entry.record.createdAtUnix, createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(), updatedAtUnix: nowUnix(),
hintUnlockAtMs: entry.record.hintUnlockAtMs, hintUnlockAtMs: entry.record.hintUnlockAtMs,
draft: entry.record.draft,
}); });
await saveLocalGame(entry.record); await saveLocalGame(entry.record);
} }
@@ -474,6 +487,7 @@ export class LocalSource implements GameLoopSource {
toMove: game.currentPlayer, toMove: game.currentPlayer,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: record.multipleWords, multipleWordsPerTurn: record.multipleWords,
noRepeatWords: !!record.noRepeatWords,
moveCount: game.moveCount, moveCount: game.moveCount,
endReason: game.isOver ? game.endReason : '', endReason: game.isOver ? game.endReason : '',
lastActivityUnix: record.updatedAtUnix, lastActivityUnix: record.updatedAtUnix,
+3
View File
@@ -46,6 +46,7 @@ import type {
Catalog, Catalog,
} from '../model'; } from '../model';
import { valueForLetter } from '../alphabet'; import { valueForLetter } from '../alphabet';
import { noRepeatWordsFor } from '../variants';
import { seedMockAlphabets } from './alphabet'; import { seedMockAlphabets } from './alphabet';
import { import {
ME, ME,
@@ -292,6 +293,7 @@ export class MockGateway implements GatewayClient {
toMove: 0, toMove: 0,
turnTimeoutSecs: 604800, turnTimeoutSecs: 604800,
multipleWordsPerTurn: multipleWords, multipleWordsPerTurn: multipleWords,
noRepeatWords: noRepeatWordsFor(variant),
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000), lastActivityUnix: Math.floor(Date.now() / 1000),
@@ -326,6 +328,7 @@ export class MockGateway implements GatewayClient {
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: multipleWords, multipleWordsPerTurn: multipleWords,
noRepeatWords: noRepeatWordsFor(variant),
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000), lastActivityUnix: Math.floor(Date.now() / 1000),
+3
View File
@@ -198,6 +198,7 @@ function activeGame(): MockGame {
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: G1_MOVES.length, moveCount: G1_MOVES.length,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200, lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
@@ -237,6 +238,7 @@ function finishedG2(): MockGame {
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 2, moveCount: 2,
endReason: 'normal', endReason: 'normal',
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400, lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
@@ -277,6 +279,7 @@ function finishedG3(): MockGame {
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: false, multipleWordsPerTurn: false,
noRepeatWords: false,
moveCount: 1, moveCount: 1,
endReason: 'resignation', endReason: 'resignation',
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800, 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 /** 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. */ * the finished-game medal ranking can place it last (no medal); undefined for online seats. */
resigned?: boolean; 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 { export interface GameView {
@@ -44,6 +48,10 @@ export interface GameView {
turnTimeoutSecs: number; turnTimeoutSecs: number;
/** true = standard Scrabble; false = the single-word rule (Russian games). */ /** true = standard Scrabble; false = the single-word rule (Russian games). */
multipleWordsPerTurn: boolean; 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; moveCount: number;
endReason: string; endReason: string;
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ /** 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[]; words: string[];
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */ /** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
dir: string; 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 { export interface WordCheckResult {
+1
View File
@@ -28,6 +28,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
toMove: 0, toMove: 0,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+1
View File
@@ -26,6 +26,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
toMove, toMove,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+13
View File
@@ -4,6 +4,7 @@ import {
ALL_VARIANTS, ALL_VARIANTS,
DEFAULT_VARIANTS, DEFAULT_VARIANTS,
availableVariants, availableVariants,
showRegisterHint,
supportsMultipleWordsToggle, supportsMultipleWordsToggle,
multipleWordsForRequest, multipleWordsForRequest,
usesStarBlank, 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', () => { describe('supportsMultipleWordsToggle', () => {
it('is true for Russian variants only', () => { it('is true for Russian variants only', () => {
expect(supportsMultipleWordsToggle('scrabble_ru')).toBe(true); 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'; 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 // 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. // buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
export const VARIANT_RULES: Record<Variant, MessageKey> = { 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 // 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 // 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 // with no profile at all). Such a client is a guest, so it mirrors the backend's *guest* set
// seeds Erudit only), so the local guest and a later synced account agree on what is enabled. // (account.GuestVariantPreferences, Erudit alone) rather than the wider set a registered account
// Every dictionary is still bundled; the other variants are simply off until the player turns // is created with. Every dictionary is still bundled; the other variants are simply off until
// them on in Settings. // the player registers and turns them on in Settings.
export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru']; export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru'];
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set they // 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 // 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 // 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 // fresh offline native launch) must match the server's guest default instead of exposing
// the English game before the player opts in. // the other games before the player opts in.
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] { export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
const prefs = preferences ?? []; const prefs = preferences ?? [];
const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs; const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
return ALL_VARIANTS.filter((v) => effective.includes(v.id)); 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 // 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 // applies to a variant. Only Russian games choose the rule; English is always standard, so
// its toggle is not shown. // its toggle is not shown.
+27
View File
@@ -22,6 +22,7 @@
import type { AccountRef, GameView, Variant } from '../lib/model'; import type { AccountRef, GameView, Variant } from '../lib/model';
import { import {
availableVariants, availableVariants,
showRegisterHint,
VARIANT_FLAG, VARIANT_FLAG,
VARIANT_RULES, VARIANT_RULES,
supportsMultipleWordsToggle, supportsMultipleWordsToggle,
@@ -395,6 +396,14 @@
</button> </button>
{/each} {/each}
</div> </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))} {#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
<label class="toggle"> <label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span> <span>{t('new.multipleWordsPerTurn')}</span>
@@ -771,6 +780,24 @@
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.4; 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). */ /* The offline-blocker reason (a missing dictionary, or the online segment needing a connection). */
.dictnote { .dictnote {
margin: 0; margin: 0;
+16 -1
View File
@@ -4,6 +4,7 @@
import WordTiles from '../components/WordTiles.svelte'; import WordTiles from '../components/WordTiles.svelte';
import { app, handleError } from '../lib/app.svelte'; import { app, handleError } from '../lib/app.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
import { gamesPlayed, hintSharePercent, winRate } from '../lib/stats'; import { gamesPlayed, hintSharePercent, winRate } from '../lib/stats';
import { ALL_VARIANTS, variantNameKey } from '../lib/variants'; import { ALL_VARIANTS, variantNameKey } from '../lib/variants';
@@ -56,7 +57,11 @@
<Screen title={t('stats.title')} back="/"> <Screen title={t('stats.title')} back="/">
<div class="page"> <div class="page">
{#if app.profile?.isGuest} {#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} {:else if stats}
<div class="grid"> <div class="grid">
{#each cards as c (c.key)} {#each cards as c (c.key)}
@@ -89,6 +94,16 @@
.muted { .muted {
color: var(--text-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 { .grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);