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

+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);