bf46b9492d
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.
Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
top pad); a top gap above the landscape rack (it sat flush under the docked history);
more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.
Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
175 lines
6.8 KiB
TypeScript
175 lines
6.8 KiB
TypeScript
// Client-side geometry of the play being composed: which cells the formed word(s) cover, and
|
|
// where to anchor the move-score badge. This is deliberately independent of the move evaluator
|
|
// (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell
|
|
// coordinates, and the board highlight plus the score badge must work even while the local
|
|
// dictionary is still warming up. Legality and the score come from the preview; the geometry is
|
|
// derived here purely from the board and the staged tiles. Both functions are pure (testable in
|
|
// lib/formed.test.ts); the board stays the source of truth for legality.
|
|
|
|
import type { Board } from './board';
|
|
import { BOARD_SIZE } from './premiums';
|
|
|
|
/** Play axis: horizontal or vertical. */
|
|
export type Dir = 'H' | 'V';
|
|
|
|
/** MainWord locates the play's main word: its start cell, axis and length in cells. */
|
|
export interface MainWord {
|
|
row: number;
|
|
col: number;
|
|
dir: Dir;
|
|
len: number;
|
|
}
|
|
|
|
/** FormedGeometry is the composed play's shape: the main word plus the set of every cell (as a
|
|
* "row,col" key) that the main word and any cross words cover — committed tiles included. */
|
|
export interface FormedGeometry {
|
|
main: MainWord;
|
|
cells: Set<string>;
|
|
}
|
|
|
|
/** A minimal cell reference; PendingTile satisfies it structurally. */
|
|
interface Cell {
|
|
row: number;
|
|
col: number;
|
|
}
|
|
|
|
const key = (r: number, c: number): string => `${r},${c}`;
|
|
|
|
/**
|
|
* filledPredicate returns a test for whether a cell holds a tile once the staged play is on the
|
|
* board: either a committed tile sits there or a pending tile is being placed there.
|
|
*/
|
|
function filledPredicate(board: Board, pending: readonly Cell[]): (r: number, c: number) => boolean {
|
|
const pend = new Set(pending.map((p) => key(p.row, p.col)));
|
|
return (r, c) =>
|
|
r >= 0 &&
|
|
r < BOARD_SIZE &&
|
|
c >= 0 &&
|
|
c < BOARD_SIZE &&
|
|
(board[r]?.[c] != null || pend.has(key(r, c)));
|
|
}
|
|
|
|
/**
|
|
* span walks the maximal contiguous filled run through cell (row, col) along dir and returns the
|
|
* run's start index and length on the moving axis (columns for H, rows for V).
|
|
*/
|
|
function span(
|
|
filled: (r: number, c: number) => boolean,
|
|
dir: Dir,
|
|
row: number,
|
|
col: number,
|
|
): { start: number; len: number } {
|
|
if (dir === 'H') {
|
|
let s = col;
|
|
while (filled(row, s - 1)) s--;
|
|
let e = col;
|
|
while (filled(row, e + 1)) e++;
|
|
return { start: s, len: e - s + 1 };
|
|
}
|
|
let s = row;
|
|
while (filled(s - 1, col)) s--;
|
|
let e = row;
|
|
while (filled(e + 1, col)) e++;
|
|
return { start: s, len: e - s + 1 };
|
|
}
|
|
|
|
/**
|
|
* formedGeometry derives the composed play's word geometry from the board and the staged tiles.
|
|
* It returns the main word (the maximal run along the play axis through the staged tiles) and the
|
|
* set of every cell the main word and each cross word (a perpendicular run of two or more cells
|
|
* through a staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words
|
|
* are omitted, matching the engine, which ignores them entirely — a perpendicular run there is
|
|
* neither validated nor scored (and need not even be a real word), so highlighting it would be
|
|
* misleading. It returns null when nothing is staged, or when the staged tiles are not on a single
|
|
* line — a shape only an illegal play produces, which the caller never asks about (it is invoked
|
|
* only for a legal preview). The play axis is fixed by the staged tiles when two or more are
|
|
* colinear; a lone tile takes whichever axis forms the longer word.
|
|
*/
|
|
export function formedGeometry(
|
|
board: Board,
|
|
pending: readonly Cell[],
|
|
multipleWords: boolean,
|
|
): FormedGeometry | null {
|
|
if (pending.length === 0) return null;
|
|
const filled = filledPredicate(board, pending);
|
|
const first = pending[0];
|
|
|
|
let dir: Dir;
|
|
if (pending.length === 1) {
|
|
const h = span(filled, 'H', first.row, first.col);
|
|
const v = span(filled, 'V', first.row, first.col);
|
|
dir = h.len >= v.len ? 'H' : 'V';
|
|
} else if (pending.every((p) => p.row === first.row)) {
|
|
dir = 'H';
|
|
} else if (pending.every((p) => p.col === first.col)) {
|
|
dir = 'V';
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
const cells = new Set<string>();
|
|
|
|
// Main word: the maximal run along the play axis through the staged tiles (contiguous via any
|
|
// committed tiles between them), collected from any one staged tile.
|
|
const ms = span(filled, dir, first.row, first.col);
|
|
let main: MainWord;
|
|
if (dir === 'H') {
|
|
main = { row: first.row, col: ms.start, dir, len: ms.len };
|
|
for (let c = ms.start; c < ms.start + ms.len; c++) cells.add(key(first.row, c));
|
|
} else {
|
|
main = { row: ms.start, col: first.col, dir, len: ms.len };
|
|
for (let r = ms.start; r < ms.start + ms.len; r++) cells.add(key(r, first.col));
|
|
}
|
|
|
|
// Cross words: the perpendicular run through each staged tile, kept only when it is a real word
|
|
// (two or more cells). Skipped under the single-word rule, where the engine ignores cross words,
|
|
// so a perpendicular run must not be highlighted as if it were a formed word.
|
|
if (multipleWords) {
|
|
const cross: Dir = dir === 'H' ? 'V' : 'H';
|
|
for (const p of pending) {
|
|
const cs = span(filled, cross, p.row, p.col);
|
|
if (cs.len < 2) continue;
|
|
if (cross === 'H') {
|
|
for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c));
|
|
} else {
|
|
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
|
|
}
|
|
}
|
|
}
|
|
|
|
return { main, cells };
|
|
}
|
|
|
|
/** Where the move-score badge is anchored: a main-word cell and the tile corner it sits on. */
|
|
export interface BadgeAnchor {
|
|
row: number;
|
|
col: number;
|
|
corner: 'tr' | 'bl';
|
|
}
|
|
|
|
/**
|
|
* badgePlacement chooses the main-word tile and corner for the move-score badge, so the badge
|
|
* points into the board and away from the nearest edge (the board clamps the final pill fully
|
|
* on-board). The rules: a word touching the right or top edge anchors at a tile's bottom-left
|
|
* corner (horizontal → its first letter, vertical → its last letter); a word touching the left or
|
|
* bottom edge — and any mid-board word — anchors at the top-right corner (horizontal → its last
|
|
* letter, vertical → its first letter). A full-width horizontal word (it touches both side edges)
|
|
* anchors at the last letter's top-right, which the board then clamps leftward.
|
|
*/
|
|
export function badgePlacement(main: MainWord): BadgeAnchor {
|
|
const last = BOARD_SIZE - 1;
|
|
const h = main.dir === 'H';
|
|
const minRow = main.row;
|
|
const maxRow = h ? main.row : main.row + main.len - 1;
|
|
const minCol = main.col;
|
|
const maxCol = h ? main.col + main.len - 1 : main.col;
|
|
|
|
if (h && main.len === BOARD_SIZE) {
|
|
return { row: main.row, col: maxCol, corner: 'tr' };
|
|
}
|
|
if (maxCol === last || minRow === 0) {
|
|
return h ? { row: main.row, col: minCol, corner: 'bl' } : { row: maxRow, col: main.col, corner: 'bl' };
|
|
}
|
|
return h ? { row: main.row, col: maxCol, corner: 'tr' } : { row: minRow, col: main.col, corner: 'tr' };
|
|
}
|