feat(web): show why a word scored what it did, and let a dead end be claimed

The chain shows each word's score breakdown as small chips beside the total.
A "Bí từ" button next to the input claims a dead end on the player's own
turn, armed the same way resign is; a false claim is answered inline near
the input rather than in the general error banner. A rejection for a word
not in the dictionary offers the one real word a diacritic typo differs
from, filling and focusing the field on click, and a button to report the
word as real, acknowledged with a confirmation once the server has heard it.
This commit is contained in:
tiennm99 committed 2026-09-21 01:55:04 +07:00
1 parent 16fd37978a
commit 0003a4510b
12 files changed
+546 -20

No files matched your search

+19 -1
View File
@@ -1,5 +1,5 @@
<script>
import { fill, t } from '$lib/i18n/vi.js';
import { fill, pointKindLabels, t } from '$lib/i18n/vi.js';
import { scrollBehavior } from '$lib/motion.js';
import { game } from '$lib/stores/game.svelte.js';
@@ -106,6 +106,16 @@
{fill(t.correctedFrom, { typed: entry.typed, word: entry.word })}
</span>
{/if}
{#if entry.parts.length}
<!-- Why the word scored what it did, not only that it did: the
client has no wordlist to re-derive this from, so the terms
travel with the total. -->
<p class="parts">
{entry.parts
.map((/** @type {any} */ p) => `+${p.value} ${pointKindLabels[p.kind] ?? ''}`)
.join(' · ')}
</p>
{/if}
{#if open}
<!-- Plain text from the server, rendered as text: the builder
stripped the wiki markup and nothing here re-interprets it. -->
@@ -302,4 +312,12 @@
color: var(--text-muted);
font-size: var(--text-3);
}
.parts {
margin: 0;
padding: 0 12px 8px;
color: var(--text-muted);
font-size: var(--text-2);
font-variant-numeric: tabular-nums;
}
</style>
+104 -2
View File
@@ -22,6 +22,8 @@
* modeLabel?: string,
* onsubmit: (word: string) => boolean,
* onresign: () => void,
* onclaimdeadend: () => void,
* onreportword: (word: string) => void,
* gameOver: import('svelte').Snippet,
* banner?: import('svelte').Snippet,
* chatUnread?: number,
@@ -32,19 +34,25 @@
modeLabel = '',
onsubmit,
onresign,
onclaimdeadend,
onreportword,
gameOver,
banner,
chatUnread = 0,
onchatopen
} = $props();
/** How long an armed resign button waits before it goes back to being safe. */
/** How long an armed resign or claim button waits before it goes back to being safe. */
const ARM_MS = 4000;
let arming = $state(false);
/** @type {ReturnType<typeof setTimeout>} */
let armTimer;
let claimArming = $state(false);
/** @type {ReturnType<typeof setTimeout>} */
let claimArmTimer;
// Whose turn it is, said by name. With four people at the table "the
// opponent is thinking" stops naming anybody.
const turnLabel = $derived.by(() => {
@@ -69,6 +77,16 @@
arming = false;
});
// A claim is the same offer resign is: made only on the player's own turn,
// on the same reasoning canResign already states.
const canClaimDeadEnd = $derived(game.state.myTurn && !offline);
$effect(() => {
if (canClaimDeadEnd) return;
clearTimeout(claimArmTimer);
claimArming = false;
});
/**
* Two presses, in place of a native confirm().
*
@@ -90,7 +108,21 @@
armTimer = setTimeout(() => (arming = false), ARM_MS);
}
/** Same two-press shape as armOrResign, and for the same reason. */
function armOrClaim() {
if (claimArming) {
clearTimeout(claimArmTimer);
claimArming = false;
onclaimdeadend();
return;
}
claimArming = true;
clearTimeout(claimArmTimer);
claimArmTimer = setTimeout(() => (claimArming = false), ARM_MS);
}
$effect(() => () => clearTimeout(armTimer));
$effect(() => () => clearTimeout(claimArmTimer));
</script>
<section class="board" data-phase={game.state.phase}>
@@ -191,7 +223,33 @@
{#if game.iAmOut}
<p class="spectating">{t.spectating}</p>
{:else}
<WordInput {onsubmit} />
<WordInput {onsubmit} {onreportword} />
{#if game.state.myTurn}
<!-- Next to the input, not down by resign: a dead end is read off
the current syllable, which only means something on this
player's own turn — unlike resign, there is no "not yet" state
worth showing for it off turn. -->
<button
type="button"
class="claim-dead-end"
class:arming={claimArming}
disabled={!canClaimDeadEnd}
onclick={armOrClaim}
>
{claimArming ? t.claimDeadEndSure : t.claimDeadEnd}
</button>
{/if}
{#if game.state.claimError}
<p class="claim-error" role="alert">
{game.state.claimError}
<button
type="button"
class="icon-button"
onclick={() => game.clearClaimError()}
aria-label={t.dismiss}>×</button
>
</p>
{/if}
{/if}
{/if}
@@ -390,4 +448,48 @@
background: var(--danger-soft);
font-weight: 600;
}
/* Beside the input rather than down with resign: a dead-end claim is about
the syllable on screen right now, so it reads as part of answering it
rather than as a way out of the game. Secondary weight either way — it
is not the way to play a turn, just a shortcut past an empty one. */
.claim-dead-end {
align-self: flex-start;
min-height: 32px;
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font-size: var(--text-3);
transition: background-color 150ms ease-out;
}
.claim-dead-end:hover:enabled {
background: var(--surface-alt);
}
.claim-dead-end:disabled {
border-color: var(--border);
color: var(--text-muted);
}
.claim-dead-end.arming {
border-color: var(--accent);
background: var(--accent-soft);
font-weight: 600;
}
.claim-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin: 0;
padding: 10px var(--space-3);
border-radius: var(--radius-sm);
background: var(--danger-soft);
color: var(--danger);
font-size: var(--text-5);
}
</style>
+68 -3
View File
@@ -1,10 +1,16 @@
<script>
import { t } from '$lib/i18n/vi.js';
import { fill, t } from '$lib/i18n/vi.js';
import { RejectReason } from '$lib/proto/noitu/v1/game_pb.js';
import { game } from '$lib/stores/game.svelte.js';
import { Status, connection } from '$lib/ws/connection.svelte.js';
/** @type {{ onsubmit: (word: string) => boolean }} */
let { onsubmit } = $props();
/**
* @type {{
* onsubmit: (word: string) => boolean,
* onreportword: (word: string) => void
* }}
*/
let { onsubmit, onreportword } = $props();
/** @type {HTMLInputElement | undefined} */
let field = $state();
@@ -145,6 +151,25 @@
// every turn to get the keyboard back.
field.focus();
}
/**
* Fills the field with the server's suggestion and focuses it. A direct
* write, unlike everywhere else in this field: it happens only from this
* click, a gesture the player just made rather than one mid-keystroke, so
* no IME composition can be in progress for it to cancel.
*/
function useSuggestion() {
const suggestion = game.state.rejection?.suggestion;
if (!suggestion || !field) return;
field.value = suggestion;
field.focus();
field.setSelectionRange(field.value.length, field.value.length);
}
function report() {
const word = game.state.rejection?.word;
if (word) onreportword(word);
}
</script>
<form class="input-row" onsubmit={handleSubmit}>
@@ -194,6 +219,22 @@
clock, with nothing to compare against. -->
<p class="rejection" id="word-rejection" role="alert">
<strong>{game.state.rejection.word}</strong> — {game.state.rejection.message}
{#if game.state.rejection.suggestion}
<!-- Corrects typing, not vocabulary: the server only ever offers this
for a word that differs from a real one by diacritics alone. -->
<button type="button" class="suggestion" onclick={useSuggestion}>
{fill(t.suggestionPrompt, { word: game.state.rejection.suggestion })}
</button>
{/if}
{#if game.state.rejection.reason === RejectReason.NOT_IN_DICTIONARY}
<button type="button" class="report" onclick={report}>{t.reportWord}</button>
{/if}
</p>
{/if}
{#if game.state.reportConfirmation}
<p class="report-confirmation" role="status">
{game.state.reportConfirmation}
</p>
{/if}
@@ -244,6 +285,10 @@
}
.rejection {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px var(--space-2);
margin: var(--space-2) 0 0;
padding: 10px var(--space-3);
border-radius: var(--radius-sm);
@@ -255,4 +300,24 @@
.rejection strong {
font-weight: 600;
}
.suggestion,
.report {
padding: 2px var(--space-2);
border: 1px solid currentColor;
border-radius: var(--radius-pill);
background: none;
color: inherit;
font-size: var(--text-3);
font-weight: 600;
}
.report-confirmation {
margin: var(--space-2) 0 0;
padding: 10px var(--space-3);
border-radius: var(--radius-sm);
background: var(--accent-soft);
color: var(--text);
font-size: var(--text-5);
}
</style>
+35 -4
View File
@@ -1,4 +1,4 @@
import { RejectReason, GameEndReason, Difficulty } from '$lib/proto/noitu/v1/game_pb.js';
import { RejectReason, GameEndReason, Difficulty, PointKind } from '$lib/proto/noitu/v1/game_pb.js';
/**
* Every user-facing string in the app. Nothing outside this file writes
@@ -37,6 +37,17 @@ export const t = {
resignSure: 'Chắc chắn đầu hàng?',
retry: 'Thử lại',
// The dead-end claim: "bí từ" is the same phrase the rules page uses for
// the rule itself, so the button reads as naming the situation rather than
// as a new piece of vocabulary.
claimDeadEnd: 'Bí từ',
claimDeadEndSure: 'Chắc chắn bí từ?',
// A player disputing a rejection, and the server's acknowledgement of it.
reportWord: 'Báo từ này là từ thật',
wordReported: 'Đã ghi nhận “{word}”. Cảm ơn bạn!',
suggestionPrompt: 'Ý bạn là “{word}”?',
yourTurn: 'Đến lượt bạn',
opponentTurn: 'Đối thủ đang suy nghĩ…',
playerTurn: 'Đến lượt {name}…',
@@ -173,7 +184,7 @@ export const t = {
rulesDeadEndTitle: 'Bí từ',
rulesDeadEndBody:
'Nối một từ khiến người kế tiếp không còn tiếng nào để nối không phải là thắng ngay lập tức: người bị bí vẫn được chơi lượt của mình và vẫn thua vào đồng hồ như bình thường, không phải thua ngay khi bí. Nếu họ không nối được, màn hình kết quả sẽ cho xem vài từ tiếng đó còn nối được — hoặc cho biết tiếng đó đã hết từ để nối.',
'Nối một từ khiến người kế tiếp không còn tiếng nào để nối không phải là thắng ngay lập tức: người bị bí vẫn được chơi lượt của mình và vẫn thua vào đồng hồ như bình thường, không phải thua ngay khi bí. Nếu họ không nối được, màn hình kết quả sẽ cho xem vài từ tiếng đó còn nối được — hoặc cho biết tiếng đó đã hết từ để nối. Thay vì chờ hết giờ, người bị bí có thể bấm nút “Bí từ” cạnh ô nhập để máy chủ xác nhận ngay — nếu đúng là hết từ thì thua lượt ngay lập tức, còn nếu vẫn còn từ nối được thì bị từ chối và đồng hồ vẫn chạy tiếp như cũ.',
rulesEliminationTitle: 'Bị loại và người thắng cuộc',
rulesEliminationBody:
@@ -189,6 +200,8 @@ export const t = {
rulesScoringRarity:
'Cộng tối đa {bonus} điểm nếu tiếng đó hiếm từ để nối: được cộng đủ khi từ điển chỉ có đúng một từ bắt đầu bằng tiếng đó, giảm {penalty} điểm mỗi khi số từ có thể nối tăng gấp đôi.',
rulesScoringCap: 'Dù cộng đủ mọi phần, một từ không bao giờ được quá {cap} điểm.',
rulesScoringBreakdown:
'Mỗi từ nối được hiển thị kèm phần tính điểm chi tiết ngay bên cạnh, để thấy rõ điểm đến từ đâu chứ không chỉ một con số.',
rulesRoomTitle: 'Mã phòng và sẵn sàng',
rulesRoomBody:
@@ -228,6 +241,19 @@ export const rejectMessages = {
[RejectReason.GAME_OVER]: 'Ván đấu đã kết thúc.'
};
/**
* Short labels for a word's score breakdown, one per PointKind. Chip-sized on
* purpose: they sit beside the total in the chain, not in a sentence.
* @type {Record<number, string>}
*/
export const pointKindLabels = {
[PointKind.BASE]: 'nền',
[PointKind.CHAIN]: 'chuỗi',
[PointKind.SYLLABLES]: 'dài',
[PointKind.SPEED]: 'nhanh',
[PointKind.RARITY]: 'hiếm'
};
/**
* How a finished game ended, phrased from the losing or winning side.
* @type {Record<number, string>}
@@ -260,12 +286,15 @@ export const errorMessages = {
cannot_kick_self: 'Bạn không thể tự mời mình ra khỏi phòng.',
must_unready_first: 'Hãy bỏ sẵn sàng trước khi rời phòng.',
need_more_players: 'Cần ít nhất hai người chơi mới bắt đầu được.',
not_a_dead_end: 'Vẫn còn từ nối được. Hãy thử lại.',
not_everyone_ready: 'Vẫn còn người chưa sẵn sàng.',
not_in_a_game: 'Bạn không ở trong ván đấu nào.',
not_in_a_room: 'Bạn không ở trong phòng nào.',
not_the_owner: 'Chỉ chủ phòng làm được việc này.',
not_your_seat: 'Bạn không phải người chơi trong ván này.',
not_your_turn: 'Chỉ đầu hàng được trong lượt của bạn.',
// Shared by resigning and claiming a dead end: both are things only the
// player to act may spend.
not_your_turn: 'Chưa đến lượt bạn.',
no_one_to_kick: 'Chưa có ai trong phòng để mời ra.',
player_offline: 'Vẫn còn người đang mất kết nối.',
owner_needs_no_ready: 'Chủ phòng không cần bấm sẵn sàng.',
@@ -281,7 +310,9 @@ export const errorMessages = {
too_fast: 'Bạn thao tác quá nhanh. Chậm lại một chút nhé.',
too_many_attempts: 'Bạn thử vào phòng quá nhiều lần. Hãy đợi một lát.',
too_many_rooms: 'Bạn tạo phòng quá nhanh. Hãy đợi một lát.',
unknown_difficulty: 'Độ khó không hợp lệ.'
unknown_difficulty: 'Độ khó không hợp lệ.',
word_report_limit: 'Bạn đã báo quá nhiều từ trong phiên này.',
word_report_refused: 'Từ này phải có ít nhất 2 tiếng mới báo được.'
};
export const errorFallback = 'Đã có lỗi xảy ra. Hãy thử lại.';
+69 -6
View File
@@ -1,4 +1,4 @@
import { rejectMessage, errorMessage } from '$lib/i18n/vi.js';
import { rejectMessage, errorMessage, fill, t } from '$lib/i18n/vi.js';
/**
* How many messages the panel holds. The same window the server keeps, so the
@@ -21,9 +21,14 @@ export const CHAT_WINDOW = 20;
* @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
* @property {PointPart[]} parts - how points was arrived at, one entry per
* non-zero term, summing to points; empty for the opening word
* @typedef {object} Sense
* @property {string} pos - Vietnamese part-of-speech label, empty when unknown
* @property {string} gloss - the definition, plain text
* @typedef {object} PointPart
* @property {number} kind - a PointKind enum value
* @property {number} value
* @typedef {object} PlayerSlot
* @property {string} playerId
* @property {string} name
@@ -143,8 +148,28 @@ function initialState() {
*/
chatCount: 0,
/** @type {{ word: string, message: string } | null} */
/**
* `reason` is a RejectReason enum value, kept alongside the rendered
* message so the UI can decide whether reporting the word applies
* (only for NOT_IN_DICTIONARY) without re-deriving it from the text.
* `suggestion` is the one real word the input differs from by
* diacritics alone, empty when none applies.
* @type {{ word: string, message: string, reason: number, suggestion: string } | null}
*/
rejection: null,
/**
* A dead-end claim the server refused because a move still existed.
* Shown inline near the input rather than in the top banner: it is
* specific to the move just attempted, not a room-wide condition.
* @type {string | null}
*/
claimError: null,
/**
* The confirmation text for the last word this session reported, once
* the server has acknowledged it.
* @type {string | null}
*/
reportConfirmation: null,
/**
* The finished game, from this player's side. The table it came with
* is `standings`; this is the part about them.
@@ -165,6 +190,15 @@ function toSenses(senses) {
return (senses ?? []).map((/** @type {any} */ s) => ({ pos: s.pos, gloss: s.gloss }));
}
/**
* Reads a move's score breakdown off the wire.
* @param {any[] | undefined} parts
* @returns {PointPart[]}
*/
function toParts(parts) {
return (parts ?? []).map((/** @type {any} */ p) => ({ kind: p.kind, value: p.value }));
}
/**
* Reads one PlayerScore off the wire.
* @param {any} p
@@ -282,7 +316,8 @@ export function createGameStore() {
points: 0,
syllables: 0,
opening: true,
meanings: toSenses(value.openingMeanings)
meanings: toSenses(value.openingMeanings),
parts: []
}
];
// The opening word is the newest word there is.
@@ -314,7 +349,8 @@ export function createGameStore() {
points: played.points,
syllables: played.syllables,
opening: false,
meanings: toSenses(played.meanings)
meanings: toSenses(played.meanings),
parts: toParts(played.parts)
});
state.expanded = state.expanded.filter((/** @type {string} */ w) => w !== previous);
if (!state.expanded.includes(played.word)) state.expanded.push(played.word);
@@ -332,15 +368,29 @@ export function createGameStore() {
// was just refused, and wiping the reason off their screen is
// one player's exit costing another the only explanation they
// had.
if (played) state.rejection = null;
if (played) {
state.rejection = null;
state.reportConfirmation = null;
}
// A false dead-end claim is about the position this update
// just moved past, however the turn moved.
state.claimError = null;
break;
}
case 'moveRejected':
state.rejection = {
word: value.word,
message: rejectMessage(value.reason, state.currentSyllable)
message: rejectMessage(value.reason, state.currentSyllable),
reason: value.reason,
suggestion: value.suggestion ?? ''
};
// A new rejection has nothing reported against it yet.
state.reportConfirmation = null;
break;
case 'wordReported':
state.reportConfirmation = fill(t.wordReported, { word: value.word });
break;
case 'playerEliminated':
@@ -415,6 +465,13 @@ export function createGameStore() {
// the model has to stop describing one. Set after, because
// leaving clears everything including the message.
if (value.code === 'kicked' || value.code === 'room_idle_closed') leave();
// A false dead-end claim is answered next to the input, not in
// the top banner: it is about the move just attempted, not a
// room-wide condition every screen has to show.
if (value.code === 'not_a_dead_end') {
state.claimError = errorMessage(value.code);
break;
}
state.error = errorMessage(value.code);
break;
@@ -518,6 +575,12 @@ export function createGameStore() {
clearError() {
state.error = null;
},
clearClaimError() {
state.claimError = null;
},
clearReportConfirmation() {
state.reportConfirmation = null;
},
/**
* Whether a word's meaning panel is open.
* @param {string} word
+24
View File
@@ -1,5 +1,6 @@
import { create } from '@bufbuild/protobuf';
import {
ClaimDeadEndSchema,
ClientMessageSchema,
CreateRoomSchema,
HelloSchema,
@@ -7,6 +8,7 @@ import {
KickPlayerSchema,
LeaveRoomSchema,
PingSchema,
ReportWordSchema,
ResignSchema,
SendChatSchema,
SetReadySchema,
@@ -130,6 +132,28 @@ export function resign() {
});
}
/**
* Claims the syllable in play has no answer left, in place of waiting out the
* clock. The server checks: a true claim ends the turn exactly as a timeout
* would, a false one costs nothing but the answer it gives away.
*/
export function claimDeadEnd() {
return create(ClientMessageSchema, {
payload: { case: 'claimDeadEnd', value: create(ClaimDeadEndSchema, {}) }
});
}
/**
* Disputes a rejection: this word is real. Acknowledged with `WordReported`;
* nothing about the game in progress changes.
* @param {string} word
*/
export function reportWord(word) {
return create(ClientMessageSchema, {
payload: { case: 'reportWord', value: create(ReportWordSchema, { word }) }
});
}
/** @param {number} clientTimeMs */
export function ping(clientTimeMs) {
return create(ClientMessageSchema, {
+15
View File
@@ -15,10 +15,12 @@
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
import {
claimDeadEnd,
createRoom,
joinRoom,
kickPlayer,
leaveRoom,
reportWord,
resign,
sendChat,
setReady,
@@ -324,6 +326,17 @@
// Armed by the board with a second press, for the same reason as kick.
send(resign());
}
function claim() {
// Armed by the board the same way giving up is: a second press, so a
// stray tap cannot spend it.
send(claimDeadEnd());
}
/** @param {string} word */
function report(word) {
send(reportWord(word));
}
</script>
<svelte:head>
@@ -351,6 +364,8 @@
modeLabel={game.state.roomCode}
onsubmit={play}
onresign={giveUp}
onclaimdeadend={claim}
onreportword={report}
chatUnread={wide ? 0 : chatUnread}
onchatopen={wide ? undefined : openChat}
>
+17 -2
View File
@@ -9,7 +9,7 @@
import { createBotSession } from '$lib/stores/bot-session.svelte.js';
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
import { resign, startBotGame, submitWord } from '$lib/ws/messages.js';
import { claimDeadEnd, reportWord, resign, startBotGame, submitWord } from '$lib/ws/messages.js';
import { Status, connect, connection, disconnect, send } from '$lib/ws/connection.svelte.js';
/**
@@ -93,6 +93,15 @@
// dialog could cost the turn it was meant to protect.
send(resign());
}
function claim() {
send(claimDeadEnd());
}
/** @param {string} word */
function report(word) {
send(reportWord(word));
}
</script>
<svelte:head>
@@ -101,7 +110,13 @@
<h1 class="sr-only">{t.titlePlay}</h1>
<GameBoard modeLabel={difficultyLabels[difficulty]} onsubmit={play} onresign={giveUp}>
<GameBoard
modeLabel={difficultyLabels[difficulty]}
onsubmit={play}
onresign={giveUp}
onclaimdeadend={claim}
onreportword={report}
>
{#snippet gameOver()}
<!-- A bot always plays again, so there is nothing to negotiate: the
button starts the next game rather than offering one. -->
+1
View File
@@ -79,6 +79,7 @@
</li>
</ul>
<p>{fill(t.rulesScoringCap, { cap: SCORING.maxPointsPerWord })}</p>
<p>{t.rulesScoringBreakdown}</p>
</section>
<section id="room">
+136 -1
View File
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
import { create } from '@bufbuild/protobuf';
import {
GameEndReason,
PointKind,
RejectReason,
ServerMessageSchema
} from '../src/lib/proto/noitu/v1/game_pb.js';
@@ -297,7 +298,9 @@ describe('moveRejected', () => {
expect(store.state.rejection).toEqual({
word: 'học sinh',
message: 'Từ này đã được dùng rồi.'
message: 'Từ này đã được dùng rồi.',
reason: RejectReason.ALREADY_USED,
suggestion: ''
});
});
@@ -308,6 +311,28 @@ describe('moveRejected', () => {
expect(store.state.rejection?.message).toBe('Từ phải bắt đầu bằng tiếng “sinh”.');
});
it('carries a near-miss suggestion for a diacritic typo', () => {
const store = createGameStore();
store.apply(started());
store.apply(
msg('moveRejected', {
reason: RejectReason.NOT_IN_DICTIONARY,
word: 'sinh vien',
suggestion: 'sinh viên'
})
);
expect(store.state.rejection?.suggestion).toBe('sinh viên');
});
it('reports no suggestion when the server sent none', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('moveRejected', { reason: RejectReason.NOT_IN_DICTIONARY, word: 'xyz' }));
expect(store.state.rejection?.suggestion).toBe('');
});
});
describe('gameOver', () => {
@@ -753,3 +778,113 @@ describe('the lobby', () => {
expect(store.state.error).not.toBeNull();
});
});
describe('score breakdown', () => {
it('carries the parts a played word scored, opening word excluded', () => {
const store = createGameStore();
store.apply(started());
expect(store.state.chain[0].parts).toEqual([]);
store.apply(
msg('turnUpdate', {
played: {
word: 'sinh viên',
byMe: true,
points: 25,
syllables: 2,
typed: 'sinh viên',
playerId: 'p1',
parts: [
{ kind: PointKind.BASE, value: 10 },
{ kind: PointKind.SPEED, value: 15 }
]
},
currentSyllable: 'viên',
myTurn: false,
turnSeq: 2,
chainLength: 2,
players: table(25, 0),
turnPlayerId: 'p2'
})
);
expect(store.state.chain[1].parts).toEqual([
{ kind: PointKind.BASE, value: 10 },
{ kind: PointKind.SPEED, value: 15 }
]);
});
});
describe('word report', () => {
it('shows the confirmation once the server acknowledges a report', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('wordReported', { word: 'bình tâm' }));
expect(store.state.reportConfirmation).toBe('Đã ghi nhận “bình tâm”. Cảm ơn bạn!');
});
it('clears a stale confirmation when a new rejection arrives', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('wordReported', { word: 'bình tâm' }));
store.apply(msg('moveRejected', { reason: RejectReason.NOT_IN_DICTIONARY, word: 'xyz' }));
expect(store.state.reportConfirmation).toBeNull();
});
it('clears the confirmation once an accepted move answers the rejection it was about', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('wordReported', { word: 'bình tâm' }));
store.apply(
msg('turnUpdate', {
played: { word: 'sinh viên', byMe: true, points: 1, syllables: 2, typed: '' },
currentSyllable: 'viên',
myTurn: false,
turnSeq: 2,
chainLength: 2
})
);
expect(store.state.reportConfirmation).toBeNull();
});
});
describe('dead-end claim', () => {
it('answers a false claim inline rather than in the general error banner', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('error', { code: 'not_a_dead_end', message: '' }));
expect(store.state.claimError).not.toBeNull();
expect(store.state.error).toBeNull();
});
it('clears the claim error once the position moves on', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('error', { code: 'not_a_dead_end', message: '' }));
store.apply(
msg('turnUpdate', {
currentSyllable: 'sinh',
myTurn: true,
turnSeq: 2,
chainLength: 1,
players: table(),
turnPlayerId: 'p1'
})
);
expect(store.state.claimError).toBeNull();
});
it('is dismissed the same way a general error is', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('error', { code: 'not_a_dead_end', message: '' }));
store.clearClaimError();
expect(store.state.claimError).toBeNull();
});
});
+39
View File
@@ -132,4 +132,43 @@ describe('generated wire types', () => {
const msg = decode('client_create_room');
expect(msg.payload.case).toBe('createRoom');
});
// A word's score breakdown must sum to the same total the player sees next
// to it — the invariant the client relies on to draw the chips at all.
it('carries a score breakdown that sums to the total', () => {
const turn = decode('server_turn_update');
const parts = turn.payload.value.played.parts;
expect(parts.length).toBeGreaterThan(0);
const sum = parts.reduce((total, /** @type {any} */ p) => total + p.value, 0);
expect(sum).toBe(turn.payload.value.played.points);
});
it('carries a near-miss suggestion for a diacritic typo', () => {
const rejected = decode('server_move_rejected_near_miss');
expect(rejected.payload.case).toBe('moveRejected');
expect(rejected.payload.value.reason).toBe(RejectReason.NOT_IN_DICTIONARY);
expect(rejected.payload.value.suggestion).toBe('bình yên');
});
it('carries no suggestion when a rejection has none', () => {
const rejected = decode('server_move_rejected');
expect(rejected.payload.value.suggestion).toBe('');
});
it('echoes a reported word', () => {
const reported = decode('server_word_reported');
expect(reported.payload.case).toBe('wordReported');
expect(reported.payload.value.word).toBe('bình tâm');
});
it('decodes an empty client claim', () => {
const claim = decode('client_claim_dead_end');
expect(claim.payload.case).toBe('claimDeadEnd');
});
it('carries the word a client reports', () => {
const report = decode('client_report_word');
expect(report.payload.case).toBe('reportWord');
expect(report.payload.value.word).toBe('bình tâm');
});
});
+19 -1
View File
@@ -6,10 +6,12 @@ import { describe, expect, it } from 'vitest';
import {
Difficulty,
GameEndReason,
PointKind,
RejectReason,
RejectReasonSchema,
GameEndReasonSchema,
DifficultySchema
DifficultySchema,
PointKindSchema
} from '../src/lib/proto/noitu/v1/game_pb.js';
import {
difficultyLabels,
@@ -17,6 +19,7 @@ import {
errorFallback,
errorMessage,
fill,
pointKindLabels,
rejectMessage,
rejectMessages
} from '../src/lib/i18n/vi.js';
@@ -80,6 +83,21 @@ describe('end reason messages', () => {
});
});
describe('point kind labels', () => {
it('names every scoring term except the unspecified one', () => {
const real = valuesOf(PointKindSchema).filter((v) => v !== PointKind.UNSPECIFIED);
expect(real.length).toBeGreaterThan(0);
for (const value of real) {
expect(pointKindLabels[value], `PointKind ${value} has no label`).toBeTypeOf('string');
expect(pointKindLabels[value].length).toBeGreaterThan(0);
}
});
it('carries no label for the unspecified kind, which never reaches a player', () => {
expect(pointKindLabels[PointKind.UNSPECIFIED]).toBeUndefined();
});
});
describe('difficulty labels', () => {
it('names every playable difficulty', () => {
const playable = valuesOf(DifficultySchema).filter((v) => v !== Difficulty.UNSPECIFIED);