mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-14 20:17:38 +00:00
feat(web): seed the word field and keep a game's chain
Three things a player asked for after playing: The field now arrives holding the syllable the next word has to start with, so only the half that is still in question gets typed. It is written once per turn and never over an existing draft, which keeps the field uncontrolled and leaves Telex and VNI composition alone. The chain lists the newest word first. The move that decides what to play next is the last one, and it belongs where the eye lands rather than at the end of a list that has to be scrolled. A finished game can be downloaded as a plain-text transcript: the chain in playing order, who played each word, and the final score. The export keeps playing order because a chain read on its own only makes sense from the opening word forward — newest-first is a reading aid for the live board, not the shape of the game.
This commit is contained in:
1 parent
7ec53bd52c
commit
69a13eccfb
8 files changed
+291
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -19,12 +24,8 @@
|
||||
<p class="empty">{t.chainEmpty}</p>
|
||||
{:else}
|
||||
<ol bind:this={list}>
|
||||
{#each game.state.chain as entry, index}
|
||||
<li
|
||||
class:mine={entry.byMe}
|
||||
class:opening={entry.opening}
|
||||
class:latest={index === game.state.chain.length - 1}
|
||||
>
|
||||
{#each entries as entry, index}
|
||||
<li class:mine={entry.byMe} class:opening={entry.opening} class:latest={index === 0}>
|
||||
<span class="word">{entry.word}</span>
|
||||
<span class="meta">
|
||||
{#if entry.syllables > 2}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { chainToText, downloadText, historyFilename } from '$lib/history-export.js';
|
||||
import { endReasonMessages, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
@@ -13,6 +14,18 @@
|
||||
|
||||
/** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null} */
|
||||
const result = $derived(game.state.result);
|
||||
|
||||
/** Hands the finished chain to the player as a text file to keep. */
|
||||
function exportHistory() {
|
||||
const at = new Date();
|
||||
const text = chainToText({
|
||||
chain: game.state.chain,
|
||||
result,
|
||||
opponentLabel: game.state.opponentName || t.opponent,
|
||||
at
|
||||
});
|
||||
downloadText(historyFilename(at), text);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if result}
|
||||
@@ -38,6 +51,8 @@
|
||||
<p class="record">{t.newRecord}</p>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="export" onclick={exportHistory}>{t.exportHistory}</button>
|
||||
|
||||
<div class="actions">
|
||||
{#if onrematch}
|
||||
<button type="button" class="primary" onclick={onrematch}>{t.rematch}</button>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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ó',
|
||||
|
||||
|
||||
@@ -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<import('../src/lib/stores/game.svelte.js').ChainEntry>} 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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user