UI: move history as a per-seat column grid + swipe-down to open #43
+15
-8
@@ -79,9 +79,12 @@ Login uses `Screen`.
|
|||||||
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||||||
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
||||||
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
||||||
the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll);
|
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
|
||||||
history opens on a **tap of the score bar** and closes on a tap or an **upward swipe** of
|
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
|
||||||
the then-inert board (below). A **hint** auto-zooms centred on the hint's placement, not
|
that once retired this gesture) — and it is suppressed on the zoomed board, where the
|
||||||
|
one-finger drag pans. History also opens on a **tap of the score bar** and closes on a tap or
|
||||||
|
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
|
||||||
|
hint's placement, not
|
||||||
the top-left.
|
the top-left.
|
||||||
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
||||||
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
||||||
@@ -94,8 +97,12 @@ Login uses `Screen`.
|
|||||||
while the others read **sunk in** (an inset shadow). A tap on the plaque toggles
|
while the others read **sunk in** (an inset shadow). A tap on the plaque toggles
|
||||||
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
|
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
|
||||||
shadow) pins to the board as the board slides down, instead of tracking the table as
|
shadow) pins to the board as the board slides down, instead of tracking the table as
|
||||||
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
|
moves accumulate; its scrollbar gutter is reserved so the centred cells do not jitter. The
|
||||||
jitter. A move's row lists every word it formed (the main word first). While the history
|
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
|
||||||
|
move keeps its dim parenthesised tag `(pass)`; the seat whose move is awaited shows a dim
|
||||||
|
`thinking…` in its next cell (never the viewer's own). While the history
|
||||||
is open the **slid board is inert** and the stage cannot scroll, so the whole board reads
|
is open the **slid board is inert** and the stage cannot scroll, so the whole board reads
|
||||||
as a **tap-or-swipe-up-to-close** surface — closing genuinely clears the open state (it no
|
as a **tap-or-swipe-up-to-close** surface — closing genuinely clears the open state (it no
|
||||||
longer merely scrolls the board out of view, which used to leave a stale-open state that
|
longer merely scrolls the board out of view, which used to leave a stale-open state that
|
||||||
@@ -189,9 +196,9 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
|
|||||||
- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or
|
- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or
|
||||||
the icon copies it. Flex text inputs carry `min-width:0` so they shrink instead of
|
the icon copies it. Flex text inputs carry `min-width:0` so they shrink instead of
|
||||||
overflowing in Safari.
|
overflowing in Safari.
|
||||||
- **History / GCG**: the in-game slide-down history gains the running total per move;
|
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
|
||||||
*Export GCG* (the 📤 in the history header) shares or downloads the `.gcg` file and
|
(the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history
|
||||||
appears only once the game is finished.
|
header) shares or downloads the `.gcg` file and appears only once the game is finished.
|
||||||
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||||
offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; and
|
offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; and
|
||||||
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { expect, test, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The redesigned move history: a ruled matrix with one column per seat (no player names, no
|
||||||
|
// running total), opened by a swipe-down on the zoom-out board. The gesture is touch-only and
|
||||||
|
// the desktop projects have no touch input, so the swipe is dispatched as PointerEvents with
|
||||||
|
// pointerType:'touch'; a phone viewport so the board fills the width.
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
async function openGame(page: Page): Promise<void> {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game G1 (your turn)
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('.pane')).toHaveCount(1); // let the screen-slide settle
|
||||||
|
}
|
||||||
|
|
||||||
|
/** touchSwipeDown dispatches a single-finger vertical touch drag on the board wrapper. */
|
||||||
|
async function touchSwipeDown(page: Page, fromY: number, toY: number): Promise<void> {
|
||||||
|
const box = (await page.locator('.boardwrap').boundingBox())!;
|
||||||
|
const x = box.x + box.width / 2;
|
||||||
|
await page.locator('.boardwrap').evaluate(
|
||||||
|
(el, { x, fromY, toY }) => {
|
||||||
|
const fire = (type: string, y: number) =>
|
||||||
|
el.dispatchEvent(
|
||||||
|
new PointerEvent(type, { pointerType: 'touch', clientX: x, clientY: y, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
fire('pointerdown', fromY);
|
||||||
|
for (let i = 1; i <= 8; i++) fire('pointermove', fromY + ((toY - fromY) * i) / 8);
|
||||||
|
fire('pointerup', toY);
|
||||||
|
},
|
||||||
|
{ x, fromY, toY },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a swipe-down on the zoom-out board opens the history', async ({ page }) => {
|
||||||
|
await openGame(page);
|
||||||
|
await page.locator('.stage').evaluate((el) => (el.scrollTop = 0)); // the pull only opens at the top
|
||||||
|
const box = (await page.locator('.boardwrap').boundingBox())!;
|
||||||
|
|
||||||
|
await touchSwipeDown(page, box.y + 20, box.y + 180);
|
||||||
|
|
||||||
|
await expect(page.locator('.history')).toBeVisible();
|
||||||
|
await expect(page.locator('.boardwrap.slid')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the history is a per-seat grid with move scores but no names or running total', async ({ page }) => {
|
||||||
|
await openGame(page);
|
||||||
|
await page.locator('.scoreboard').click(); // open the history (gesture is covered separately)
|
||||||
|
|
||||||
|
const grid = page.locator('.hgrid');
|
||||||
|
await expect(grid).toBeVisible();
|
||||||
|
// G1: You [HELLO 16, RAT 3] vs Ann [WORLD 9, AND 4] — a 2×2 matrix, no empty/thinking cells.
|
||||||
|
await expect(page.locator('.hcell')).toHaveCount(4);
|
||||||
|
await expect(grid).toContainText('HELLO');
|
||||||
|
await expect(grid).toContainText('(3)'); // RAT's per-move score
|
||||||
|
// The running total (RAT's was 19) and the seat names no longer appear in the table.
|
||||||
|
await expect(grid).not.toContainText('19');
|
||||||
|
await expect(grid).not.toContainText('Ann');
|
||||||
|
});
|
||||||
|
|
||||||
|
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).
|
||||||
|
await page
|
||||||
|
.locator('[data-cell]:not(.filled)')
|
||||||
|
.nth(20)
|
||||||
|
.evaluate((el: HTMLElement) => {
|
||||||
|
el.click();
|
||||||
|
el.click();
|
||||||
|
});
|
||||||
|
await expect(page.locator('.viewport.zoomed')).toBeVisible();
|
||||||
|
const box = (await page.locator('.boardwrap').boundingBox())!;
|
||||||
|
|
||||||
|
await touchSwipeDown(page, box.y + 20, box.y + 180);
|
||||||
|
|
||||||
|
await expect(page.locator('.history')).toHaveCount(0);
|
||||||
|
});
|
||||||
+92
-50
@@ -14,6 +14,7 @@
|
|||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||||
import { lastMoveCells, replay } from '../lib/board';
|
import { lastMoveCells, replay } from '../lib/board';
|
||||||
|
import { historyGrid } from '../lib/history';
|
||||||
import { centre, premiumGrid } from '../lib/premiums';
|
import { centre, premiumGrid } from '../lib/premiums';
|
||||||
import { variantNameKey } from '../lib/variants';
|
import { variantNameKey } from '../lib/variants';
|
||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
@@ -101,6 +102,12 @@
|
|||||||
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
||||||
const gameOver = $derived(!!view && view.game.status !== 'active');
|
const gameOver = $derived(!!view && view.game.status !== 'active');
|
||||||
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
||||||
|
// The seat whose move the history grid awaits with a "thinking…" placeholder: the player to
|
||||||
|
// move while the game is active, but never the viewer themselves (their own pending cell
|
||||||
|
// stays empty) and never on a finished game.
|
||||||
|
const thinkingSeat = $derived(
|
||||||
|
!view || gameOver || view.game.toMove === view.seat ? null : view.game.toMove,
|
||||||
|
);
|
||||||
|
|
||||||
// moveActionLabel localizes a non-play move's history label (pass/exchange/resign/timeout);
|
// moveActionLabel localizes a non-play move's history label (pass/exchange/resign/timeout);
|
||||||
// an unknown action (forward-compat for the MoveAction string union) falls back to its raw
|
// an unknown action (forward-compat for the MoveAction string union) falls back to its raw
|
||||||
@@ -637,29 +644,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
|
// --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
|
||||||
// While the history is open the board is inert (CSS pointer-events), so the whole slid board
|
// The boardwrap surface drives two gestures, selected by `historyOpen`:
|
||||||
// reads as a "tap or swipe up to close" surface and the stage cannot scroll instead of close.
|
// - open: the slid board is inert (CSS pointer-events), so the whole board reads as a
|
||||||
// The tap closes on click; the swipe closes as soon as enough upward travel is seen, so it
|
// "tap or swipe up to close" surface and the stage cannot scroll instead of close. The
|
||||||
|
// tap closes on click; the swipe closes as soon as enough upward travel is seen, so it
|
||||||
// never depends on where a fast swipe's pointerup lands (which differs across engines).
|
// never depends on where a fast swipe's pointerup lands (which differs across engines).
|
||||||
// Closing genuinely clears `historyOpen` (rather than only scrolling the slid board out of
|
// - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled
|
||||||
|
// to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag
|
||||||
|
// is the stage's own vertical scroll (the conflict that once retired this open gesture).
|
||||||
|
// Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of
|
||||||
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
||||||
let histSwipeY: number | null = null;
|
let stageEl = $state<HTMLDivElement>();
|
||||||
|
let boardSwipe: { x: number; y: number; mode: 'open' | 'close' } | null = null;
|
||||||
function toggleHistory() {
|
function toggleHistory() {
|
||||||
historyOpen = !historyOpen;
|
historyOpen = !historyOpen;
|
||||||
}
|
}
|
||||||
function closeHistoryByGesture() {
|
function closeHistoryByGesture() {
|
||||||
if (!historyOpen) return;
|
if (!historyOpen) return;
|
||||||
historyOpen = false;
|
historyOpen = false;
|
||||||
histSwipeY = null;
|
boardSwipe = null;
|
||||||
// Swallow the click some browsers synthesise from a board tap, so it does not place a tile.
|
// Swallow the click some browsers synthesise from a board tap, so it does not place a tile.
|
||||||
swallowClick = true;
|
swallowClick = true;
|
||||||
setTimeout(() => (swallowClick = false), 120);
|
setTimeout(() => (swallowClick = false), 120);
|
||||||
}
|
}
|
||||||
|
function openHistoryByGesture() {
|
||||||
|
if (historyOpen) return;
|
||||||
|
historyOpen = true;
|
||||||
|
boardSwipe = null;
|
||||||
|
// Swallow the click the opening pull may synthesise, so it neither places a tile nor
|
||||||
|
// immediately re-closes the panel via the boardwrap's tap-to-close.
|
||||||
|
swallowClick = true;
|
||||||
|
setTimeout(() => (swallowClick = false), 120);
|
||||||
|
}
|
||||||
function onBoardWrapDown(e: PointerEvent) {
|
function onBoardWrapDown(e: PointerEvent) {
|
||||||
histSwipeY = historyOpen ? e.clientY : null;
|
if (historyOpen) {
|
||||||
|
boardSwipe = { x: e.clientX, y: e.clientY, mode: 'close' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Arm the open pull only where a downward drag is free to mean "reveal the history":
|
||||||
|
// the zoom-out board scrolled to its top, a touch pointer, no tile drag under way.
|
||||||
|
const onTile = !!(e.target as Element | null)?.closest?.('.cell.pending');
|
||||||
|
const atTop = (stageEl?.scrollTop ?? 0) <= 2;
|
||||||
|
boardSwipe =
|
||||||
|
!zoomed && atTop && !drag && !onTile && e.pointerType !== 'mouse'
|
||||||
|
? { x: e.clientX, y: e.clientY, mode: 'open' }
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
function onBoardWrapMove(e: PointerEvent) {
|
function onBoardWrapMove(e: PointerEvent) {
|
||||||
if (histSwipeY !== null && histSwipeY - e.clientY > 32) closeHistoryByGesture();
|
if (!boardSwipe) return;
|
||||||
|
const dx = e.clientX - boardSwipe.x;
|
||||||
|
const dy = e.clientY - boardSwipe.y;
|
||||||
|
if (boardSwipe.mode === 'close') {
|
||||||
|
if (-dy > 32) closeHistoryByGesture(); // enough upward travel
|
||||||
|
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
|
||||||
|
openHistoryByGesture(); // a clear, vertical-dominant downward pull
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// A closed history clears every per-seat add-friend confirmation.
|
// A closed history clears every per-seat add-friend confirmation.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -733,7 +772,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stage" class:histopen={historyOpen}>
|
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
||||||
{#if historyOpen}
|
{#if historyOpen}
|
||||||
<div class="history">
|
<div class="history">
|
||||||
<div class="hhead">
|
<div class="hhead">
|
||||||
@@ -746,16 +785,23 @@
|
|||||||
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
|
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ol>
|
<div class="hgridwrap">
|
||||||
{#each moves as m, i (i)}
|
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
|
||||||
<li>
|
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
|
||||||
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
{#each row as cell (cell.player)}
|
||||||
<span class="ha" class:sys={m.action !== 'play'}>{m.action === 'play' ? m.words.join(', ') : `(${moveActionLabel(m.action)})`}</span>
|
<div class="hcell">
|
||||||
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
|
{#if cell.kind === 'play'}
|
||||||
</li>
|
<span>{cell.words?.join(', ')} <span class="hsc">({cell.score})</span></span>
|
||||||
|
{:else if cell.kind === 'action'}
|
||||||
|
<span class="hsys">({moveActionLabel(cell.action ?? '')})</span>
|
||||||
|
{:else if cell.kind === 'thinking'}
|
||||||
|
<span class="hsys">{t('game.thinking')}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if moves.length === 0}<li class="hempty">—</li>{/if}
|
{/each}
|
||||||
</ol>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -766,8 +812,10 @@
|
|||||||
class:slid={historyOpen}
|
class:slid={historyOpen}
|
||||||
onpointerdown={onBoardWrapDown}
|
onpointerdown={onBoardWrapDown}
|
||||||
onpointermove={onBoardWrapMove}
|
onpointermove={onBoardWrapMove}
|
||||||
onpointerup={() => (histSwipeY = null)}
|
onpointerup={() => (boardSwipe = null)}
|
||||||
onclick={closeHistoryByGesture}
|
onclick={() => {
|
||||||
|
if (!swallowClick) closeHistoryByGesture();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Board
|
<Board
|
||||||
{board}
|
{board}
|
||||||
@@ -976,44 +1024,38 @@
|
|||||||
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.history ol {
|
/* The history is a ruled matrix: one column per seat (aligned under the score plaque), each
|
||||||
margin: 0;
|
seat's moves filling its column top to bottom. The thin grid lines are the 1px gap showing
|
||||||
padding: 8px 14px;
|
the border colour through the cells' surface fill; there is no outer frame (cells sit flush
|
||||||
list-style: decimal;
|
to the grid edge — the .hhead border above and the .history border below close the table).
|
||||||
display: flex;
|
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
|
||||||
flex-direction: column;
|
plaques. */
|
||||||
gap: 4px;
|
.hgridwrap {
|
||||||
|
padding: 8px var(--pad);
|
||||||
}
|
}
|
||||||
.history li {
|
.hgrid {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
gap: 1px;
|
||||||
gap: 10px;
|
background: var(--border);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.hp {
|
.hcell {
|
||||||
color: var(--text-muted);
|
display: grid;
|
||||||
}
|
place-items: center;
|
||||||
.ha {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
min-height: 1.7em;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.ha.sys {
|
/* Secondary annotations within a cell — the parenthesised move score, a non-play action
|
||||||
/* A non-scoring move (pass/exchange/resign/timeout): dimmer and parenthesised so it
|
label, the awaited "thinking…" — read muted, like the old running-total/system labels. */
|
||||||
reads as a system action rather than a scored word. */
|
.hsc,
|
||||||
|
.hsys {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.hs {
|
.hsc {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.ht {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
.hempty {
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
.boardwrap {
|
.boardwrap {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|||||||
@@ -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');
|
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', () => {
|
it('maps error codes to keys with a generic fallback', () => {
|
||||||
expect(errorKey('not_your_turn')).toBe('error.not_your_turn');
|
expect(errorKey('not_your_turn')).toBe('error.not_your_turn');
|
||||||
expect(errorKey('totally_unknown')).toBe('error.generic');
|
expect(errorKey('totally_unknown')).toBe('error.generic');
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export const en = {
|
|||||||
'game.checkWait': 'Please wait a moment.',
|
'game.checkWait': 'Please wait a moment.',
|
||||||
'game.noHintOptions': 'No options with your letters.',
|
'game.noHintOptions': 'No options with your letters.',
|
||||||
'game.scores': 'Scores: {n}',
|
'game.scores': 'Scores: {n}',
|
||||||
|
'game.thinking': 'thinking…',
|
||||||
|
|
||||||
'move.pass': 'pass',
|
'move.pass': 'pass',
|
||||||
'move.exchange': 'exchange',
|
'move.exchange': 'exchange',
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.checkWait': 'Секунду, пожалуйста.',
|
'game.checkWait': 'Секунду, пожалуйста.',
|
||||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||||
'game.scores': 'Очков: {n}',
|
'game.scores': 'Очков: {n}',
|
||||||
|
'game.thinking': 'думает…',
|
||||||
|
|
||||||
'move.pass': 'пас',
|
'move.pass': 'пас',
|
||||||
'move.exchange': 'обмен',
|
'move.exchange': 'обмен',
|
||||||
|
|||||||
Reference in New Issue
Block a user