diff --git a/README.md b/README.md index 0b44337..bb275a1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,13 @@ only client-owned state is the theme, the personal best per difficulty, and the The word field is deliberately uncontrolled. Vietnamese diacritics are composed over several keystrokes by a Telex or VNI input method, and writing the value back on every keystroke -cancels that composition and mangles the accent. +cancels that composition and mangles the accent. The one write the client does make is the +seed: when a turn arrives the field is filled with the syllable the word has to start with, +once per turn and never over text the player has already typed, so no composition can be in +progress when it happens. + +The chain is listed newest first, and a finished game can be downloaded as a plain-text +transcript — the chain in playing order, who played what, and the final score. Every Vietnamese string lives in `web/src/lib/i18n/vi.js`, including the map from `RejectReason` to a message. That is why `ServerError.code` is a UI key such as diff --git a/web/e2e/bot-game.spec.js b/web/e2e/bot-game.spec.js index 36d0660..7261981 100644 --- a/web/e2e/bot-game.spec.js +++ b/web/e2e/bot-game.spec.js @@ -35,6 +35,30 @@ test.describe('playing the bot', () => { expect(words.length).toBeGreaterThanOrEqual(3); // opening, mine, the bot's }); + test('the turn arrives with the required syllable already in the field', async ({ page }) => { + await page.goto('/play?difficulty=1'); + await waitForMyTurn(page); + + const syllable = (await board(page).syllable.textContent())?.trim(); + // A trailing space, so the player types the rest of the word and not + // the half of it that was never in question. + await expect(board(page).input).toHaveValue(`${syllable} `); + }); + + test('the chain lists the newest word first', async ({ page }) => { + await page.goto('/play?difficulty=1'); + await waitForMyTurn(page); + + const [opening] = await chainWords(page); + const mine = await playLegalMove(page, new Set([opening])); + + // The opening word was played before mine, so it sits below it — and + // stays at the bottom however many words the bot adds on top. + const words = await chainWords(page); + expect(words.indexOf(mine)).toBeLessThan(words.indexOf(opening)); + expect(words[words.length - 1]).toBe(opening); + }); + test('the score rises and the board shows both sides', async ({ page }) => { await page.goto('/play?difficulty=1'); await waitForMyTurn(page); @@ -88,10 +112,11 @@ test.describe('playing the bot', () => { await submitWord(page, UNKNOWN_WORD); await expect(board(page).rejection).toBeVisible(); - // The word reached the server, so the field clears. What must not - // happen is the turn moving on, and the player must be able to try - // again immediately. - await expect(board(page).input).toHaveValue(''); + // The word reached the server, so the typed text is gone and the + // field is back to the syllable it seeds every turn with. What must + // not happen is the turn moving on, and the player must be able to + // try again immediately. + await expect(board(page).input).toHaveValue('sinh '); await expect(board(page).turn).toHaveText('Đến lượt bạn'); await expect(board(page).input).toBeEnabled(); }); @@ -108,6 +133,29 @@ test.describe('playing the bot', () => { await expect(page.getByRole('button', { name: 'Chơi lại' })).toBeVisible(); }); + test('a finished game can be downloaded as a transcript', async ({ page }) => { + await page.goto('/play?difficulty=1'); + await waitForMyTurn(page); + + const [opening] = await chainWords(page); + await playLegalMove(page, new Set([opening])); + + page.on('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Đầu hàng' }).click(); + + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Tải chuỗi từ' }).click(); + const file = await download; + + expect(file.suggestedFilename()).toMatch(/^noi-tu-\d{4}-\d{2}-\d{2}-\d{4}\.txt$/); + const stream = await file.createReadStream(); + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + const text = Buffer.concat(chunks).toString('utf8'); + expect(text).toContain(opening); + expect(text).toContain('Bạn thua.'); + }); + test('a rematch starts exactly one new game', async ({ page }) => { // The regression this guards: the screen used to infer "start a game" // from the board being empty, so clearing it for a rematch sent a second diff --git a/web/src/lib/components/ChainHistory.svelte b/web/src/lib/components/ChainHistory.svelte index 94416b3..b94d7ab 100644 --- a/web/src/lib/components/ChainHistory.svelte +++ b/web/src/lib/components/ChainHistory.svelte @@ -5,11 +5,16 @@ /** @type {HTMLElement | undefined} */ let list = $state(); + // Newest first: the move that decides what to play next is the last one, so + // it belongs where the eye lands rather than at the end of a list the + // player has to scroll through. + const entries = $derived([...game.state.chain].reverse()); + // Keep the newest word in view as the chain grows. Reading chain.length in // the effect is what subscribes it to new moves. $effect(() => { game.state.chain.length; - list?.scrollTo({ top: list.scrollHeight, behavior: 'smooth' }); + list?.scrollTo({ top: 0, behavior: 'smooth' }); }); @@ -19,12 +24,8 @@

{t.chainEmpty}

{:else}
    - {#each game.state.chain as entry, index} -
  1. + {#each entries as entry, index} +
  2. {entry.word} {#if entry.syllables > 2} diff --git a/web/src/lib/components/GameOverPanel.svelte b/web/src/lib/components/GameOverPanel.svelte index b7dc92e..73be31d 100644 --- a/web/src/lib/components/GameOverPanel.svelte +++ b/web/src/lib/components/GameOverPanel.svelte @@ -1,4 +1,5 @@ {#if result} @@ -38,6 +51,8 @@

    {t.newRecord}

    {/if} + +
    {#if onrematch} @@ -103,6 +118,16 @@ font-weight: 700; } + .export { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 600; + } + .actions { display: flex; gap: 8px; diff --git a/web/src/lib/components/WordInput.svelte b/web/src/lib/components/WordInput.svelte index 40354e8..d3429bb 100644 --- a/web/src/lib/components/WordInput.svelte +++ b/web/src/lib/components/WordInput.svelte @@ -17,10 +17,38 @@ game.state.phase === 'playing' && game.state.myTurn && connection.status === Status.OPEN ); + // What the field was last seeded for. Plain lets, not state: they guard the + // effect below and must not re-trigger it. The rejection is part of the key + // because a refused word empties the field without ending the turn, and the + // player deserves the seed back before they retype. + let seededTurn = -1; + /** @type {unknown} */ + let seededRejection = null; + // Focus when the turn arrives, so a player on a phone can type without - // reaching for the field. Reading myTurn is what subscribes the effect. + // reaching for the field, and seed it with the syllable the word has to + // start with — that part of the answer is already decided, and typing it + // again is the one keystroke sequence every single turn shares. + // + // Reading myTurn is what subscribes the effect. $effect(() => { - if (game.state.myTurn && game.state.phase === 'playing') field?.focus(); + if (!(game.state.myTurn && game.state.phase === 'playing')) return; + const turn = game.state.turnSeq; + const syllable = game.state.currentSyllable; + const rejection = game.state.rejection; + + field?.focus(); + if (!field || (turn === seededTurn && rejection === seededRejection)) return; + seededTurn = turn; + seededRejection = rejection; + // Never over an existing draft: a rejected word is still the player's + // text, and replacing it would delete a word they were about to fix. + if (field.value || !syllable) return; + + field.value = `${syllable} `; + // Caret after the seed, so typing continues the word instead of + // landing in front of it. + field.setSelectionRange(field.value.length, field.value.length); }); /** @param {SubmitEvent} event */ diff --git a/web/src/lib/history-export.js b/web/src/lib/history-export.js new file mode 100644 index 0000000..5b37180 --- /dev/null +++ b/web/src/lib/history-export.js @@ -0,0 +1,89 @@ +import { t } from '$lib/i18n/vi.js'; + +/** + * Turns a finished game into a plain-text transcript the player can keep. + * + * The formatting lives here rather than in the panel so it can be tested + * without a DOM, and so the only thing the component owns is the click. + */ + +/** @param {number} n */ +function pad(n) { + return String(n).padStart(2, '0'); +} + +/** + * Local time, because the file is for the person who played the game and not + * for a machine in another timezone. + * + * @param {Date} at + * @returns {string} + */ +function stamp(at) { + return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`; +} + +/** + * @param {Date} at + * @returns {string} a filename safe on every platform, with no colons + */ +export function historyFilename(at = new Date()) { + const d = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`; + return `noi-tu-${d}-${pad(at.getHours())}${pad(at.getMinutes())}.txt`; +} + +/** + * The chain stays in playing order here. It is a sequence of links, and read + * on its own a transcript only makes sense from the opening word forward — + * the newest-first ordering is a reading aid for the live board, not the + * shape of the game. + * + * @param {object} args + * @param {import('$lib/stores/game.svelte.js').ChainEntry[]} args.chain + * @param {{ iWon: boolean, myScore: number, chainLength: number } | null} [args.result] + * @param {string} [args.opponentLabel] + * @param {Date} [args.at] + * @returns {string} + */ +export function chainToText({ chain, result = null, opponentLabel = t.opponent, at = new Date() }) { + const lines = [`${t.appName} — ${stamp(at)}`]; + + if (result) { + lines.push(result.iWon ? t.won : t.lost); + lines.push( + `${t.finalScore}: ${result.myScore} · ${t.chainLength}: ${result.chainLength}` + ); + } + + lines.push(''); + + chain.forEach((entry, index) => { + const number = `${index + 1}. ${entry.word}`; + if (entry.opening) { + lines.push(`${number} (${t.exportOpening})`); + return; + } + const who = entry.byMe ? t.you : opponentLabel; + const points = entry.points > 0 ? ` +${entry.points}` : ''; + lines.push(`${number} — ${who}${points}`); + }); + + return `${lines.join('\n')}\n`; +} + +/** + * Hands the text to the browser as a download. + * + * @param {string} filename + * @param {string} text + */ +export function downloadText(filename, text) { + const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + // Revoked on the next tick: some browsers read the blob after the click + // returns, and freeing it synchronously cancels the download. + setTimeout(() => URL.revokeObjectURL(url), 0); +} diff --git a/web/src/lib/i18n/vi.js b/web/src/lib/i18n/vi.js index 9497140..712f36e 100644 --- a/web/src/lib/i18n/vi.js +++ b/web/src/lib/i18n/vi.js @@ -48,6 +48,8 @@ export const t = { finalScore: 'Điểm cuối cùng', chainLength: 'Số từ trong chuỗi', newRecord: 'Kỷ lục mới!', + exportHistory: 'Tải chuỗi từ', + exportOpening: 'từ mở đầu', bestScore: 'Kỷ lục', noBestScore: 'Chưa có', diff --git a/web/tests/history-export.test.js b/web/tests/history-export.test.js new file mode 100644 index 0000000..7391238 --- /dev/null +++ b/web/tests/history-export.test.js @@ -0,0 +1,78 @@ +// The transcript is the only artefact of a game that outlives the tab, so what +// it contains is worth pinning: the chain in playing order, who played what, +// and a filename that survives every filesystem. + +import { describe, expect, it } from 'vitest'; +import { chainToText, historyFilename } from '../src/lib/history-export.js'; + +/** + * @param {Partial} entry + */ +function link(entry) { + return { word: '', typed: '', byMe: false, points: 0, syllables: 2, opening: false, ...entry }; +} + +const chain = [ + link({ word: 'học sinh', opening: true, syllables: 0 }), + link({ word: 'sinh viên', byMe: true, points: 2 }), + link({ word: 'viên chức', points: 2 }) +]; + +const at = new Date(2026, 8, 7, 15, 25); + +describe('chainToText', () => { + it('keeps the chain in playing order, opening word first', () => { + const lines = chainToText({ chain, at }).trim().split('\n'); + const words = lines.filter((line) => /^\d+\./.test(line)); + + expect(words).toHaveLength(3); + expect(words[0]).toContain('học sinh'); + expect(words[1]).toContain('sinh viên'); + expect(words[2]).toContain('viên chức'); + }); + + it('marks the opening word as played by neither side', () => { + const text = chainToText({ chain, at }); + + expect(text).toContain('1. học sinh (từ mở đầu)'); + expect(text).not.toContain('học sinh — '); + }); + + it('names who played each word and what it scored', () => { + const text = chainToText({ chain, at, opponentLabel: 'Minh' }); + + expect(text).toContain('2. sinh viên — Bạn +2'); + expect(text).toContain('3. viên chức — Minh +2'); + }); + + it('heads the file with the result when there is one', () => { + const text = chainToText({ + chain, + at, + result: { iWon: true, myScore: 4, chainLength: 3 } + }); + + expect(text).toContain('2026-09-07 15:25'); + expect(text).toContain('Bạn thắng!'); + expect(text).toContain('Điểm cuối cùng: 4'); + expect(text).toContain('Số từ trong chuỗi: 3'); + }); + + it('writes a chain with no result at all', () => { + const text = chainToText({ chain: [], at }); + + expect(text).toContain('2026-09-07 15:25'); + expect(text).not.toContain('Bạn thắng!'); + expect(text).not.toContain('Bạn thua.'); + }); +}); + +describe('historyFilename', () => { + it('stamps the local date and time without a colon', () => { + expect(historyFilename(at)).toBe('noi-tu-2026-09-07-1525.txt'); + }); + + it('pads every field so names sort chronologically', () => { + expect(historyFilename(new Date(2026, 0, 2, 3, 4))).toBe('noi-tu-2026-01-02-0304.txt'); + }); +});