UI: render move history as a per-seat column grid + swipe-down to open
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 42s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 42s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
Replace the flat chronological move list with a ruled matrix aligned under the score plaque: one column per seat, each seat's moves filling its column top to bottom. A cell is the move's word(s) and its score, "WORD (12)", centred; the player names and the running total are dropped (the plaque heads the column and shows the live total). Non-play moves keep their dim parenthesised tag; the awaited opponent's next cell shows a dim "thinking..." (never the viewer's own turn). Thin 1px rules between columns and rows match the panel's separator. Re-introduce a swipe-down-on-the-board gesture to open the history, gated to the zoom-out board scrolled to its top so it never fights the zoomed board's pan or the stage's own vertical scroll (the conflict that retired this gesture before). Grid layout extracted to lib/history.ts (unit-tested); add game.thinking to the EN/RU catalogs; e2e covers the gesture and the grid on Chromium and WebKit.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { historyGrid, type HistoryCell } from './history';
|
||||
import type { MoveRecord } from './model';
|
||||
|
||||
function play(player: number, words: string[], score: number): MoveRecord {
|
||||
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words, count: words.length, score, total: 0 };
|
||||
}
|
||||
function action(player: number, act: 'pass' | 'exchange' | 'resign' | 'timeout'): MoveRecord {
|
||||
return { player, action: act, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
|
||||
}
|
||||
function kinds(grid: HistoryCell[][]): string[][] {
|
||||
return grid.map((row) => row.map((c) => c.kind));
|
||||
}
|
||||
|
||||
describe('historyGrid', () => {
|
||||
it('lays moves into per-seat columns and marks the awaited opponent as thinking', () => {
|
||||
const moves = [play(0, ['ДОМ'], 12), action(1, 'pass'), play(0, ['ОСА', 'ОМ'], 12)];
|
||||
const grid = historyGrid(moves, 2, 1); // seat 1 is to move
|
||||
|
||||
expect(kinds(grid)).toEqual([
|
||||
['play', 'action'],
|
||||
['play', 'thinking'],
|
||||
]);
|
||||
expect(grid[0][0]).toMatchObject({ kind: 'play', words: ['ДОМ'], score: 12 });
|
||||
expect(grid[1][0]).toMatchObject({ kind: 'play', words: ['ОСА', 'ОМ'], score: 12 });
|
||||
expect(grid[0][1]).toMatchObject({ kind: 'action', action: 'pass' });
|
||||
expect(grid[1][1]).toMatchObject({ kind: 'thinking', player: 1 });
|
||||
});
|
||||
|
||||
it('renders no thinking cell when thinkingSeat is null (game over / own turn)', () => {
|
||||
const moves = [play(0, ['ДОМ'], 12), action(1, 'pass'), play(0, ['ОСА'], 6)];
|
||||
const grid = historyGrid(moves, 2, null);
|
||||
|
||||
expect(kinds(grid)).toEqual([
|
||||
['play', 'action'],
|
||||
['play', 'empty'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills ragged columns with empty cells (a seat that stopped moving)', () => {
|
||||
const moves = [
|
||||
play(0, ['ДОМ'], 12),
|
||||
play(1, ['КОТ'], 10),
|
||||
action(2, 'resign'),
|
||||
play(0, ['ОСА'], 6),
|
||||
play(1, ['ЛИС'], 8),
|
||||
];
|
||||
const grid = historyGrid(moves, 3, null);
|
||||
|
||||
expect(kinds(grid)).toEqual([
|
||||
['play', 'play', 'action'],
|
||||
['play', 'play', 'empty'],
|
||||
]);
|
||||
expect(grid[0][2]).toMatchObject({ kind: 'action', action: 'resign' });
|
||||
});
|
||||
|
||||
it('shows a lone thinking cell for the first mover on an empty journal', () => {
|
||||
expect(kinds(historyGrid([], 2, 0))).toEqual([['thinking', 'empty']]);
|
||||
});
|
||||
|
||||
it('produces an empty grid for an empty journal with no awaited mover', () => {
|
||||
expect(historyGrid([], 2, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores moves with an out-of-range seat', () => {
|
||||
const grid = historyGrid([play(5, ['X'], 1), play(0, ['ДОМ'], 12)], 2, null);
|
||||
expect(kinds(grid)).toEqual([['play', 'empty']]);
|
||||
expect(grid[0][0]).toMatchObject({ words: ['ДОМ'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Pure layout of the move journal for the in-game history panel. The panel mirrors the
|
||||
// score plaque above the board: one column per seat (a seat's moves fill its own column,
|
||||
// top to bottom) instead of a single chronological list. Keeping the arrangement here (not
|
||||
// in Game.svelte) lets it be unit-tested without a DOM; the component only renders the
|
||||
// resulting cells with i18n/CSS.
|
||||
|
||||
import type { MoveRecord } from './model';
|
||||
|
||||
/** A single cell of the history grid. The grid is row-major with one column per seat: a
|
||||
* seat's k-th move sits in row k of its column. */
|
||||
export interface HistoryCell {
|
||||
kind: 'play' | 'action' | 'thinking' | 'empty';
|
||||
/** Seat index this cell belongs to (its column). */
|
||||
player: number;
|
||||
/** Words formed, main word first — set for kind 'play'. */
|
||||
words?: string[];
|
||||
/** Points scored by the move — set for kind 'play'. */
|
||||
score?: number;
|
||||
/** The non-play action ('pass' | 'exchange' | 'resign' | 'timeout' | …) — set for kind
|
||||
* 'action'; the component localizes it. */
|
||||
action?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* historyGrid arranges the move journal into a row-major matrix with one column per seat.
|
||||
* Each seat's moves fill its column top to bottom, so a seat's k-th move lands in row k of
|
||||
* its column; with a regular turn order the rows read as rounds. When thinkingSeat is
|
||||
* non-null, that seat's next (still empty) cell becomes a 'thinking' placeholder — the move
|
||||
* being awaited. Positions with no move are 'empty', so every row is exactly seatCount wide
|
||||
* and the table rules evenly. Pass thinkingSeat=null when the game is over or when the
|
||||
* awaited mover is the viewer themselves (the viewer's own pending cell stays empty).
|
||||
*/
|
||||
export function historyGrid(
|
||||
moves: MoveRecord[],
|
||||
seatCount: number,
|
||||
thinkingSeat: number | null,
|
||||
): HistoryCell[][] {
|
||||
const cols: HistoryCell[][] = Array.from({ length: seatCount }, () => []);
|
||||
for (const m of moves) {
|
||||
if (m.player < 0 || m.player >= seatCount) continue; // defensive: drop an out-of-range seat
|
||||
cols[m.player].push(
|
||||
m.action === 'play'
|
||||
? { kind: 'play', player: m.player, words: m.words, score: m.score }
|
||||
: { kind: 'action', player: m.player, action: m.action },
|
||||
);
|
||||
}
|
||||
|
||||
const think =
|
||||
thinkingSeat !== null && thinkingSeat >= 0 && thinkingSeat < seatCount ? thinkingSeat : null;
|
||||
let rows = cols.reduce((n, c) => Math.max(n, c.length), 0);
|
||||
if (think !== null) rows = Math.max(rows, cols[think].length + 1);
|
||||
|
||||
const grid: HistoryCell[][] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row: HistoryCell[] = [];
|
||||
for (let p = 0; p < seatCount; p++) {
|
||||
if (r < cols[p].length) row.push(cols[p][r]);
|
||||
else if (think === p && r === cols[p].length) row.push({ kind: 'thinking', player: p });
|
||||
else row.push({ kind: 'empty', player: p });
|
||||
}
|
||||
grid.push(row);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
@@ -19,6 +19,11 @@ describe('i18n catalog', () => {
|
||||
expect(translate('en', 'move.exchange')).toBe('exchange');
|
||||
});
|
||||
|
||||
it('localizes the awaited-move (thinking) label', () => {
|
||||
expect(translate('ru', 'game.thinking')).toBe('думает…');
|
||||
expect(translate('en', 'game.thinking')).toBe('thinking…');
|
||||
});
|
||||
|
||||
it('maps error codes to keys with a generic fallback', () => {
|
||||
expect(errorKey('not_your_turn')).toBe('error.not_your_turn');
|
||||
expect(errorKey('totally_unknown')).toBe('error.generic');
|
||||
|
||||
@@ -87,6 +87,7 @@ export const en = {
|
||||
'game.checkWait': 'Please wait a moment.',
|
||||
'game.noHintOptions': 'No options with your letters.',
|
||||
'game.scores': 'Scores: {n}',
|
||||
'game.thinking': 'thinking…',
|
||||
|
||||
'move.pass': 'pass',
|
||||
'move.exchange': 'exchange',
|
||||
|
||||
@@ -88,6 +88,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.checkWait': 'Секунду, пожалуйста.',
|
||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||
'game.scores': 'Очков: {n}',
|
||||
'game.thinking': 'думает…',
|
||||
|
||||
'move.pass': 'пас',
|
||||
'move.exchange': 'обмен',
|
||||
|
||||
Reference in New Issue
Block a user