Files
scrabble-game/ui/src/lib/gameimage.test.ts
T
Ilia Denisov d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
feat(rules): forbid repeating a word already on the board in Erudit
Russian "Эрудит" treats a word laid on the board as belonging to the
game: it cannot be laid again. Neither the solver, the backend nor the
offline JS port knew the rule, so a player (and the robot) could replay a
word freely. Official Scrabble places no such restriction, so both
Scrabble variants keep playing unrestricted.

The rule applies in two ways. A play whose main word is already on the
board is illegal, and is neither accepted nor generated. A play whose
perpendicular cross-word is already there stands — that word is
incidental to laying the main word — but scores nothing. The set of
played words is the game's own move journal, main words and cross-words
alike, compared decoded, so a word spelled with a blank is the same word.

It lives in the game layer, not the solver: only a game knows its
history, and the solver stays stateless and standard-rules. The backend
applies it at submit, at the move preview and over generated moves
(filtering and re-ranking them, so neither the robot nor the hint can
offer a play the engine would then refuse); the client port does the same
for the offline engine and for the on-device preview of an online game.

The rule is pinned per game (games.no_repeat_words, set from the variant
at creation) rather than keyed on the variant, because a game is replayed
from its journal on every open. Applied retroactively it would make an
already-played repeat illegal — closing that game as a draw — and would
rescore a play whose cross-word repeats an earlier word, shifting a live
game's totals. Games created before the rule keep playing without it. The
flag rides the wire as a trailing field because the client's preview must
score the way the server does, and offline games pin the same answer in
their own record.
2026-07-27 18:33:56 +02:00

149 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { buildScoresheet, layoutImage, formatFinishedDate, MIN_SIDE } from './gameimage';
import type { GameView, MoveRecord, Seat } from './model';
function seat(n: number, name: string, score: number, winner = false): Seat {
return { seat: n, accountId: `a${n}`, displayName: name, score, hintsUsed: 0, isWinner: winner };
}
function game(seats: Seat[], over = true): GameView {
return {
id: 'g1',
variant: 'scrabble_ru',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status: over ? 'finished' : 'active',
players: seats.length,
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: false,
noRepeatWords: false,
moveCount: 0,
endReason: over ? 'standard' : '',
lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC)
seats,
};
}
function play(player: number, words: string[], score: number, total: number, dir: 'H' | 'V' = 'H'): MoveRecord {
return { player, action: 'play', dir, mainRow: 7, mainCol: 7, tiles: [], words, count: words.length, score, total };
}
// A non-play move carries the seat's current running total on the wire (engine game.go
// sets Total: g.scores[player] on pass/exchange/resign) — the fixture mirrors that.
function action(player: number, act: 'pass' | 'exchange' | 'resign', total = 0): MoveRecord {
return { player, action: act, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total };
}
const label = (a: string) => `<${a}>`;
describe('buildScoresheet', () => {
it('builds per-seat header, coordinated play rows and localized actions', () => {
const g = game([seat(0, 'Аня', 118, true), seat(1, 'Боб', 92)]);
const moves = [
play(0, ['ГРОТ'], 24, 24),
action(1, 'exchange'),
play(0, ['МЫС'], 18, 42, 'V'),
play(1, ['СОН'], 12, 12),
];
const s = buildScoresheet(g, moves, label, { hostname: 'erudit-game.ru', dateLocale: 'ru' });
expect(s.header).toEqual([
{ name: 'Аня', score: 118, winner: true },
{ name: 'Боб', score: 92, winner: false },
]);
expect(s.rows).toHaveLength(2);
expect(s.rows[0][0]).toMatchObject({ kind: 'play', coord: '8H', words: ['ГРОТ'], score: 24 });
expect(s.rows[0][1]).toMatchObject({ kind: 'action', label: '<exchange>' });
expect(s.rows[1][0]).toMatchObject({ kind: 'play', coord: 'H8' }); // vertical → letter first
expect(s.rows[1][1]).toMatchObject({ kind: 'play', coord: '8H', words: ['СОН'] });
expect(s.footer.startsWith('erudit-game.ru · ')).toBe(true);
expect(s.footer).toContain('2026');
});
it('derives the endgame rack settlement from final scores vs last running totals', () => {
// Seat 0 went out (+6 from the opponent rack), seat 1 lost its rack value (6).
const g = game([seat(0, 'A', 48, true), seat(1, 'B', 24)]);
const moves = [play(0, ['X'], 42, 42), play(1, ['Y'], 30, 30)];
const s = buildScoresheet(g, moves, label, { hostname: 'h' });
expect(s.adjustments).toEqual([6, -6]);
expect(s.hasAdjustments).toBe(true);
});
it('reports no settlement when the final scores match the journal (resignation)', () => {
const g = game([seat(0, 'A', 42, true), seat(1, 'B', 30)]);
const moves = [play(0, ['X'], 42, 42), play(1, ['Y'], 30, 30), action(1, 'resign', 30)];
const s = buildScoresheet(g, moves, label, { hostname: 'h' });
expect(s.adjustments).toEqual([0, 0]);
expect(s.hasAdjustments).toBe(false);
});
it('keeps one column per seat for a four-player game', () => {
const g = game([seat(0, 'A', 10), seat(1, 'B', 20, true), seat(2, 'C', 5), seat(3, 'D', 0)]);
const moves = [play(0, ['X'], 10, 10), play(1, ['Y'], 20, 20), play(2, ['Z'], 5, 5)];
const s = buildScoresheet(g, moves, label, { hostname: 'h' });
expect(s.header).toHaveLength(4);
expect(s.rows[0]).toHaveLength(4);
expect(s.rows[0][3]).toEqual({ kind: 'empty' });
});
});
describe('layoutImage', () => {
const short = (seats = 2) =>
buildScoresheet(
game(Array.from({ length: seats }, (_, i) => seat(i, `P${i}`, 0))),
[play(0, ['ГРОТ'], 24, 24)],
label,
{ hostname: 'h' },
);
it('clamps the board to MIN_SIDE for a short game', () => {
const l = layoutImage(short());
expect(l.board.side).toBe(MIN_SIDE);
expect(l.board.cell).toBeCloseTo(MIN_SIDE / 15);
});
it('stretches the board (and the image) to a long scoresheet, keeping the typography fixed', () => {
const moves: MoveRecord[] = [];
for (let i = 0; i < 80; i++) moves.push(play(i % 2, ['СЛОВО'], 10, 10 * i));
const s = buildScoresheet(game([seat(0, 'A', 300, true), seat(1, 'B', 290)]), moves, label, { hostname: 'h' });
const l = layoutImage(s);
expect(l.board.side).toBeGreaterThan(MIN_SIDE);
// The column content exactly fills the board height (that is the stretch contract).
const content =
l.headNameH + l.headScoreH + l.headRuleH + l.rowHeights.reduce((a, b) => a + b, 0) + l.adjRowH;
expect(l.board.side).toBe(Math.ceil(content));
expect(l.h).toBe(l.margin + l.axis + l.board.side + l.footerH + l.margin);
});
it('gives a row the extra sub-line only when a play formed several words', () => {
const s = buildScoresheet(
game([seat(0, 'A', 0), seat(1, 'B', 0)]),
[play(0, ['ОСА', 'ОМ'], 12, 12), play(1, ['ДОМ'], 8, 8)],
label,
{ hostname: 'h' },
);
const l = layoutImage(s);
expect(l.rowHeights[0]).toBe(l.rowH + l.subRowH);
expect(l.rowHeights).toHaveLength(1);
});
it('widens the sheet per seat', () => {
const two = layoutImage(short(2));
const four = layoutImage(short(4));
expect(four.hist.w).toBeGreaterThan(two.hist.w);
expect(four.w - four.hist.w).toBe(two.w - two.hist.w); // board block unchanged
});
});
describe('formatFinishedDate', () => {
it('formats with the given locale (regional convention, long month)', () => {
const s = formatFinishedDate(1_782_997_629, 'ru');
expect(s).toContain('2026');
expect(s).toMatch(/июн|июл/); // TZ-dependent day, but always mid-summer
});
});