mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-19 04:16:17 +00:00
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:
1 parent
16fd37978a
commit
0003a4510b
12 files changed
+546
-20
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user