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}