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