feat(web): show each chain word's meaning, newest open, click to toggle

open set is client-only state; every word is a button; `Chưa có nghĩa` for a word without one.
This commit is contained in:
tiennm99 committed 2026-09-08 22:48:26 +07:00
1 parent 9e74f6f959
commit e652f7764d
7 files changed
+292 -10

No files matched your search

+32 -1
View File
@@ -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);
+10
View File
@@ -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<string[]>}
*/
export async function openMeanings(page) {
return page.locator('ol li:has(> .meanings) > .word').allTextContents();
}
+21
View File
@@ -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();
});
});
+71 -7
View File
@@ -23,10 +23,24 @@
{#if game.state.chain.length === 0}
<p class="empty">{t.chainEmpty}</p>
{:else}
<ol bind:this={list}>
<ol class="rows" bind:this={list}>
{#each entries as entry, index}
<!-- The panel id comes from the row's place in the chain, not the
word, so two rows can never share one. -->
{@const open = game.isExpanded(entry.word)}
{@const panelId = `meaning-${entries.length - 1 - index}`}
<li class:mine={entry.byMe} class:opening={entry.opening} class:latest={index === 0}>
<span class="word">{entry.word}</span>
<!-- Every word is a button, with or without a definition, so the
chain behaves the same for all of them. Never focused from
here: the word input keeps focus while a player types. -->
<button
class="word"
type="button"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
aria-label={fill(open ? t.meaningHide : t.meaningShow, { word: entry.word })}
onclick={() => game.toggleMeaning(entry.word)}>{entry.word}</button
>
<!-- Who played it, not merely whether it was mine: a chain
four people built is unreadable without the names. -->
{#if !entry.opening && !entry.byMe && game.nameOf(entry.playerId)}
@@ -47,6 +61,19 @@
{fill(t.correctedFrom, { typed: entry.typed, word: entry.word })}
</span>
{/if}
{#if open}
<!-- Plain text from the server, rendered as text: the builder
stripped the wiki markup and nothing here re-interprets it. -->
{#if entry.meanings.length}
<ol class="meanings" id={panelId}>
{#each entry.meanings as sense}
<li>{sense.pos ? `(${sense.pos}) ` : ''}{sense.gloss}</li>
{/each}
</ol>
{:else}
<p class="meanings none" id={panelId}>{t.meaningNone}</p>
{/if}
{/if}
</li>
{/each}
</ol>
@@ -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 <ol> of <li> 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 {
+3
View File
@@ -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',
+59 -2
View File
@@ -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
+96
View File
@@ -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();