From e652f7764da505e67a2e7444745c87711e34566d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 8 Sep 2026 22:48:26 +0700 Subject: [PATCH] feat(web): show each chain word's meaning, newest open, click to toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open set is client-only state; every word is a button; `Chưa có nghĩa` for a word without one. --- web/e2e/bot-game.spec.js | 33 +++++++- web/e2e/helpers.js | 10 +++ web/e2e/pvp-game.spec.js | 21 +++++ web/src/lib/components/ChainHistory.svelte | 78 ++++++++++++++++-- web/src/lib/i18n/vi.js | 3 + web/src/lib/stores/game.svelte.js | 61 +++++++++++++- web/tests/game-store.test.js | 96 ++++++++++++++++++++++ 7 files changed, 292 insertions(+), 10 deletions(-) diff --git a/web/e2e/bot-game.spec.js b/web/e2e/bot-game.spec.js index de0afdd..ab2efd1 100644 --- a/web/e2e/bot-game.spec.js +++ b/web/e2e/bot-game.spec.js @@ -1,9 +1,10 @@ import { expect, test } from '@playwright/test'; -import { ONE_SYLLABLE_WORD, UNKNOWN_WORD } from './fixture-dictionary.js'; +import { ONE_SYLLABLE_WORD, UNKNOWN_WORD, renderedSense } from './fixture-dictionary.js'; import { board, chainWords, chooseDifficulty, + openMeanings, playLegalMove, setNickname, submitWord, @@ -59,6 +60,36 @@ test.describe('playing the bot', () => { expect(words[words.length - 1]).toBe(opening); }); + test('the newest word shows its meaning, and a click toggles any word', async ({ page }) => { + await page.goto('/play?difficulty=1'); + await waitForMyTurn(page); + + // The opening word is the newest word there is, so it is open, and the + // panel shows the fixture's sense for it as `(pos) gloss`. Which + // opening is drawn varies, so the expectation is read from the list. + const [opening] = await chainWords(page); + expect(await openMeanings(page)).toEqual([opening]); + const sense = renderedSense(opening); + expect(sense, `${opening} has no fixture meaning`).toBeDefined(); + await expect(page.locator('.meanings li').first()).toHaveText(sense ?? ''); + await expect(page.locator('.meanings li').first()).toHaveText(/^\([^)]+\) /); + + await playLegalMove(page, new Set([opening])); + await waitForMyTurn(page); + + // The bot's reply is the newest word now; mine and the opening closed + // as each was overtaken. + const [newest] = await chainWords(page); + expect(await openMeanings(page)).toEqual([newest]); + + // Clicking reopens the opening word without closing the newest, and + // clicking the newest closes it. + await page.getByRole('button', { name: `Xem nghĩa của ${opening}` }).click(); + expect((await openMeanings(page)).sort()).toEqual([newest, opening].sort()); + await page.getByRole('button', { name: `Ẩn nghĩa của ${newest}` }).click(); + expect(await openMeanings(page)).toEqual([opening]); + }); + test('the score rises and the board shows both sides', async ({ page }) => { await page.goto('/play?difficulty=1'); await waitForMyTurn(page); diff --git a/web/e2e/helpers.js b/web/e2e/helpers.js index 7beeae8..7ad0ff5 100644 --- a/web/e2e/helpers.js +++ b/web/e2e/helpers.js @@ -183,3 +183,13 @@ export async function setNickname(page, name) { export async function chainWords(page) { return page.locator('ol li .word').allTextContents(); } + +/** + * Reads the words whose meaning panel is open. + * + * @param {import('@playwright/test').Page} page + * @returns {Promise} + */ +export async function openMeanings(page) { + return page.locator('ol li:has(> .meanings) > .word').allTextContents(); +} diff --git a/web/e2e/pvp-game.spec.js b/web/e2e/pvp-game.spec.js index f54005d..2821045 100644 --- a/web/e2e/pvp-game.spec.js +++ b/web/e2e/pvp-game.spec.js @@ -4,6 +4,7 @@ import { board, chainWords, chat, + openMeanings, playLegalMove, readyAndStart, say, @@ -556,3 +557,23 @@ test.describe('playing a stranger', () => { await close(); }); }); + +test.describe('word meanings', () => { + test('both players see the newest word open', async ({ browser }) => { + const { lead, second, close } = await playingPair(browser); + + const [opening] = await chainWords(lead); + expect(await openMeanings(lead)).toEqual([opening]); + expect(await openMeanings(second)).toEqual([opening]); + + const played = await playLegalMove(lead, new Set([opening])); + await expect(second.locator('ol li .word').first()).toHaveText(played); + + // The same word is open on both sides: the rule lives in the client + // store and both clients received the same move. + expect(await openMeanings(lead)).toEqual([played]); + expect(await openMeanings(second)).toEqual([played]); + + await close(); + }); +}); diff --git a/web/src/lib/components/ChainHistory.svelte b/web/src/lib/components/ChainHistory.svelte index 2a1ce53..aebb24e 100644 --- a/web/src/lib/components/ChainHistory.svelte +++ b/web/src/lib/components/ChainHistory.svelte @@ -23,10 +23,24 @@ {#if game.state.chain.length === 0}

{t.chainEmpty}

{:else} -
    +
      {#each entries as entry, index} + + {@const open = game.isExpanded(entry.word)} + {@const panelId = `meaning-${entries.length - 1 - index}`}
    1. - {entry.word} + + {#if !entry.opening && !entry.byMe && game.nameOf(entry.playerId)} @@ -47,6 +61,19 @@ {fill(t.correctedFrom, { typed: entry.typed, word: entry.word })} {/if} + {#if open} + + {#if entry.meanings.length} +
        + {#each entry.meanings as sense} +
      1. {sense.pos ? `(${sense.pos}) ` : ''}{sense.gloss}
      2. + {/each} +
      + {:else} +

      {t.meaningNone}

      + {/if} + {/if}
    2. {/each}
    @@ -74,7 +101,9 @@ color: var(--text-muted); } - ol { + /* Row rules are scoped to the outer list: the meanings list nested in a + row is an
      of
    1. too and must not inherit the card styling. */ + .rows { display: flex; flex-direction: column; gap: 6px; @@ -84,7 +113,7 @@ list-style: none; } - li { + .rows > li { display: flex; flex-wrap: wrap; align-items: baseline; @@ -95,23 +124,58 @@ background: var(--surface); } - li.mine { + .rows > li.mine { border-color: var(--accent); background: var(--accent-soft); } - li.opening { + .rows > li.opening { border-style: dashed; background: var(--surface-alt); } - li.latest { + .rows > li.latest { box-shadow: var(--shadow); } .word { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; font-size: 1.05rem; font-weight: 600; + text-align: left; + cursor: pointer; + } + + .word:hover { + text-decoration: underline; + } + + .word:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 2px; + } + + .meanings { + flex-basis: 100%; + margin: 2px 0 0; + padding-left: 1.4em; + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.4; + } + + ol.meanings { + list-style: decimal; + } + + .meanings.none { + padding-left: 0; + font-style: italic; } .meta { diff --git a/web/src/lib/i18n/vi.js b/web/src/lib/i18n/vi.js index c30b670..5766a96 100644 --- a/web/src/lib/i18n/vi.js +++ b/web/src/lib/i18n/vi.js @@ -31,6 +31,9 @@ export const t = { submit: 'Gửi', chainTitle: 'Chuỗi từ', chainEmpty: 'Chưa có từ nào.', + meaningShow: 'Xem nghĩa của {word}', + meaningHide: 'Ẩn nghĩa của {word}', + meaningNone: 'Chưa có nghĩa', you: 'Bạn', opponent: 'Đối thủ', someone: 'Người chơi', diff --git a/web/src/lib/stores/game.svelte.js b/web/src/lib/stores/game.svelte.js index 5cb1ece..0e35e35 100644 --- a/web/src/lib/stores/game.svelte.js +++ b/web/src/lib/stores/game.svelte.js @@ -20,6 +20,12 @@ export const CHAT_WINDOW = 20; * @property {number} points * @property {number} syllables * @property {boolean} opening - the seed word, played by neither side + * @property {Sense[]} meanings - what the word means, at most five; empty when + * the dictionary has none + * + * @typedef {object} Sense + * @property {string} pos - Vietnamese part-of-speech label, empty when unknown + * @property {string} gloss - the definition, plain text * * @typedef {object} PlayerSlot * @property {string} playerId @@ -56,6 +62,16 @@ function initialState() { phase: 'idle', /** @type {ChainEntry[]} */ chain: [], + /** + * Words in the chain whose meaning is open. Client-only state, like the + * theme: the newest word opens on arrival and closes the one before it, + * and a click toggles any word, so any number may be open at once. A + * list with set semantics rather than a Set, because $state proxies + * arrays and not Sets. + * + * @type {string[]} + */ + expanded: [], currentSyllable: '', myTurn: false, deadlineMs: 0, @@ -153,6 +169,16 @@ function initialState() { }; } +/** + * Reads a word's senses off the wire. + * + * @param {any[] | undefined} senses + * @returns {Sense[]} + */ +function toSenses(senses) { + return (senses ?? []).map((/** @type {any} */ s) => ({ pos: s.pos, gloss: s.gloss })); +} + /** * Reads one PlayerScore off the wire. * @@ -267,9 +293,12 @@ export function createGameStore() { playerId: '', points: 0, syllables: 0, - opening: true + opening: true, + meanings: toSenses(value.openingMeanings) } ]; + // The opening word is the newest word there is. + state.expanded = [value.openingWord]; state.currentSyllable = value.currentSyllable; state.myTurn = value.myTurn; state.deadlineMs = Number(value.deadlineUnixMs); @@ -286,6 +315,9 @@ export function createGameStore() { // on: the syllable and the chain survive the player who could // not answer them, so there is nothing to append. if (played) { + // The newest word takes over the open panel from the one + // before it. Words the player opened by hand stay open. + const previous = state.chain[state.chain.length - 1]?.word; state.chain.push({ word: played.word, typed: played.typed, @@ -293,8 +325,11 @@ export function createGameStore() { playerId: played.playerId, points: played.points, syllables: played.syllables, - opening: false + opening: false, + meanings: toSenses(played.meanings) }); + state.expanded = state.expanded.filter((/** @type {string} */ w) => w !== previous); + if (!state.expanded.includes(played.word)) state.expanded.push(played.word); } state.currentSyllable = value.currentSyllable; state.myTurn = value.myTurn; @@ -495,6 +530,28 @@ export function createGameStore() { clearError() { state.error = null; }, + /** + * Whether a word's meaning panel is open. + * + * @param {string} word + */ + isExpanded(word) { + return state.expanded.includes(word); + }, + /** + * Opens a closed word's meaning or closes an open one. Every word in the + * chain toggles, with or without a definition, so the chain behaves + * the same for all of them. + * + * @param {string} word + */ + toggleMeaning(word) { + if (state.expanded.includes(word)) { + state.expanded = state.expanded.filter((/** @type {string} */ w) => w !== word); + } else { + state.expanded.push(word); + } + }, /** * Forgets the conversation without forgetting the room. The screen * calls this when it is entered and left: chat survives reset() so a diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js index 1ab9244..c58a28d 100644 --- a/web/tests/game-store.test.js +++ b/web/tests/game-store.test.js @@ -172,6 +172,102 @@ describe('turnUpdate', () => { }); }); +describe('meanings', () => { + /** A move by p1 with optional senses, seq counting from 2. */ + function move(word, seq, meanings = []) { + return msg('turnUpdate', { + played: { word, byMe: true, points: 2, syllables: 2, typed: word, playerId: 'p1', meanings }, + currentSyllable: word.split(' ').at(-1), + myTurn: false, + deadlineUnixMs: 1_700_000_040_000n, + turnSeq: seq, + chainLength: seq, + players: table(), + turnPlayerId: 'p2' + }); + } + + it('carries the senses off the wire, label and gloss apart', () => { + const store = createGameStore(); + store.apply( + started({ openingMeanings: [{ pos: 'danh từ', gloss: 'Người học ở trường phổ thông.' }] }) + ); + store.apply(move('sinh viên', 2, [{ pos: '', gloss: 'Người học đại học.' }])); + + expect(store.state.chain[0].meanings).toEqual([ + { pos: 'danh từ', gloss: 'Người học ở trường phổ thông.' } + ]); + expect(store.state.chain[1].meanings).toEqual([{ pos: '', gloss: 'Người học đại học.' }]); + }); + + it('opens the opening word when the game starts', () => { + const store = createGameStore(); + store.apply(started()); + expect(store.state.expanded).toEqual(['học sinh']); + expect(store.isExpanded('học sinh')).toBe(true); + }); + + it('opens the new word and closes the previous newest', () => { + const store = createGameStore(); + store.apply(started()); + store.apply(move('sinh viên', 2)); + expect(store.state.expanded).toEqual(['sinh viên']); + store.apply(move('viên chức', 3)); + expect(store.state.expanded).toEqual(['viên chức']); + }); + + it('opens a word without a definition the same way', () => { + const store = createGameStore(); + store.apply(started()); + store.apply(move('sinh viên', 2, [])); + expect(store.state.chain[1].meanings).toEqual([]); + expect(store.isExpanded('sinh viên')).toBe(true); + }); + + it('leaves the open set alone when an elimination moves the turn', () => { + const store = createGameStore(); + store.apply(started()); + store.apply( + msg('turnUpdate', { + currentSyllable: 'sinh', + myTurn: true, + deadlineUnixMs: 1_700_000_040_000n, + turnSeq: 2, + chainLength: 1, + players: table(), + turnPlayerId: 'p1' + }) + ); + expect(store.state.expanded).toEqual(['học sinh']); + }); + + it('toggles any word, and two can be open together', () => { + const store = createGameStore(); + store.apply(started()); + store.apply(move('sinh viên', 2)); + + store.toggleMeaning('học sinh'); + expect(store.state.expanded.sort()).toEqual(['học sinh', 'sinh viên']); + store.toggleMeaning('sinh viên'); + expect(store.state.expanded).toEqual(['học sinh']); + store.toggleMeaning('học sinh'); + expect(store.state.expanded).toEqual([]); + }); + + it('is cleared by reset, and a resumed session ends with one word open', () => { + const store = createGameStore(); + store.apply(started()); + store.toggleMeaning('sinh viên'); + store.reset(); + expect(store.state.expanded).toEqual([]); + + // A resume replays the opening and then the last move. + store.apply(started()); + store.apply(move('sinh viên', 2)); + expect(store.state.expanded).toEqual(['sinh viên']); + }); +}); + describe('moveRejected', () => { it('renders the specific Vietnamese reason', () => { const store = createGameStore();