feat(web): apply the whole-game UX review's copy proposals

Rewrites the strings the review flagged as unclear or misleading: the
not_your_turn and not_a_dead_end messages now describe the race or the
position they actually answer, need_more_players drops a server constant
that had leaked into prose, the per-game score and the room's series score
get separate labels instead of sharing "Tỉ số", the opponent-turn fallback
reads neutral rather than assuming one opponent, a chat author who left
reads as a name rather than a sentence, too_fast drops its odd-one-out
"nhé", and the meta description stops promising 1v1 for a 2-4 person game.
This commit is contained in:
tiennm99 committed 2026-09-21 16:58:50 +07:00
1 parent 18170aad15
commit d94c2a105c
4 files changed
+86 -29

No files matched your search

+4 -4
View File
@@ -322,8 +322,8 @@ test.describe('playing a stranger', () => {
// The series score of the room, which the finished game has just moved:
// the guest took it, so the owner's lobby shows 1 against their seat.
await expect(host.getByTestId('wins-p2')).toContainText('Tỉ số 1');
await expect(host.getByTestId('wins-p1')).toContainText('Tỉ số 0');
await expect(host.getByTestId('wins-p2')).toContainText('Ván thắng 1');
await expect(host.getByTestId('wins-p1')).toContainText('Ván thắng 0');
await readyAndStart(host, guest);
@@ -335,7 +335,7 @@ test.describe('playing a stranger', () => {
// And the tally is carried into it, where the board shows it beside
// each player's score for this game.
await expect(host.getByTestId('series-p2')).toContainText('Tỉ số 1');
await expect(host.getByTestId('series-p2')).toContainText('Thắng 1');
await close();
});
@@ -559,7 +559,7 @@ test.describe('playing a stranger', () => {
// which on a phone is under a four-seat list and off the screen — so
// "Bắt đầu" being refused looked like "Bắt đầu" being broken.
// containText, not haveText: the box carries its own dismiss button.
await expect(host.getByTestId('lobby-error')).toContainText('Bạn thao tác quá nhanh');
await expect(host.getByTestId('lobby-error')).toContainText('Thao tác quá nhanh');
await close();
});
+4 -1
View File
@@ -14,7 +14,10 @@
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="description" content="Trò chơi nối từ tiếng Việt: chơi với máy hoặc đấu 1v1 trực tuyến." />
<meta
name="description"
content="Trò chơi nối từ tiếng Việt: chơi với máy hoặc đấu trực tuyến 2–4 người."
/>
<title>Nối Từ</title>
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<!--
+4 -4
View File
@@ -1,5 +1,5 @@
<script>
import { t } from '$lib/i18n/vi.js';
import { fill, t } from '$lib/i18n/vi.js';
import { game } from '$lib/stores/game.svelte.js';
/**
@@ -38,7 +38,7 @@
<span class="score">{player.score}</span>
{#if series}
<span class="series" data-testid={`series-${player.playerId}`}>
{t.winsLabel} {game.winsOf(player.playerId)}
{fill(t.winsCompact, { n: game.winsOf(player.playerId) })}
</span>
{/if}
{#if player.rank === 1}
@@ -100,7 +100,7 @@
max-width: 100%;
overflow: hidden;
color: var(--text-muted);
font-size: var(--text-3);
font-size: var(--text-2);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -115,7 +115,7 @@
}
.score {
font-size: var(--text-7);
font-size: var(--text-4);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
+74 -20
View File
@@ -19,10 +19,22 @@ export const t = {
nicknameLabel: 'Tên của bạn',
nicknamePlaceholder: 'Nhập tên hiển thị',
nicknameHint:
'Tối đa 20 ký tự. Máy chủ có thể rút gọn tên của bạn. Để trống sẽ được gọi là “Người chơi”.',
// The server truncating a name is an edge case it already shows by
// displaying the sanitized result — three sentences on a 360px screen was
// explaining it twice.
nicknameHint: 'Tối đa 20 ký tự. Để trống sẽ là “Người chơi”.',
nicknameNeeded: 'Nhập tên của bạn trước khi vào phòng.',
// The chain rule itself, read once on the landing screen rather than first
// learned from a rejection under a running clock. The turn length is a
// server constant not known here, so "có đồng hồ" rather than a number.
howToPlay:
'Tiếng đầu của từ bạn phải là tiếng cuối của từ trước — {example}. Mỗi lượt có đồng hồ.',
howToPlayExample: 'ngôn ngữ → ngữ pháp → pháp luật',
// The opening word's own row in the chain: a newcomer sees one grey word
// with nobody credited for it and no reason given why it starts there.
openingCaption: 'Từ mở đầu — nối tiếp bằng tiếng cuối',
playBot: 'Chơi với máy',
playOnline: 'Đấu trực tuyến',
difficultyLabel: 'Độ khó',
@@ -49,7 +61,10 @@ export const t = {
suggestionPrompt: 'Ý bạn là “{word}”?',
yourTurn: 'Đến lượt bạn',
opponentTurn: 'Đối thủ đang suy nghĩ…',
// The fallback for an online turn whose name has not arrived yet — a bot
// game always has one, so this reads as "some other player" rather than
// naming an opponent that may not be singular.
opponentTurn: 'Đang chờ người chơi khác…',
playerTurn: 'Đến lượt {name}…',
currentSyllable: 'Nối tiếp tiếng',
wordInputPlaceholder: 'Nhập từ của bạn',
@@ -90,24 +105,33 @@ export const t = {
exportHistory: 'Tải chuỗi từ',
exportOpening: 'từ mở đầu',
bestScore: 'Kỷ lục',
noBestScore: 'Chưa có',
// The chain row's score-breakdown chips, named for a screen reader since
// the chips themselves carry no heading of their own.
pointsBreakdown: 'Cách tính điểm',
theme: 'Giao diện',
themeLight: 'Sáng',
themeDark: 'Tối',
onlineTitle: 'Đấu trực tuyến',
onlineIntro: 'Tạo phòng rồi gửi mã cho bạn bè, hoặc nhập mã bạn được mời.',
onlineIntro: 'Chơi ngay với người lạ, tạo phòng cho bạn bè, hoặc nhập mã bạn được mời.',
createRoom: 'Tạo phòng',
joinRoom: 'Vào phòng',
// Sub-labels under the two ways into an online game, since "Chơi ngay" and
// "Tạo phòng" alone do not say what each actually starts.
quickMatchHint: 'Ghép với người đang chờ, ván đầu tự bắt đầu',
createRoomHint: 'Nhận mã sáu ký tự để gửi cho bạn bè',
// Quick-match: paired with the next stranger who also asked, no code
// exchanged.
quickMatch: 'Chơi ngay',
quickMatchWaiting: 'Đang tìm đối thủ… {n} giây',
// The searching state announced once; the running seconds are shown beside
// it but kept out of the live region, or a screen reader would hear "n
// giây" every second.
quickMatchWaiting: 'Đang tìm đối thủ…',
quickMatchCancel: 'Hủy tìm trận',
quickMatchNudge: 'Chưa có ai chờ…',
quickMatchNudgeLink: 'Chơi với máy trong lúc đợi?',
quickMatchNudge: 'Chưa có ai chờ.',
quickMatchNudgeLink: 'Chơi với máy trong lúc đợi (sẽ rời hàng chờ)',
roomCodeLabel: 'Mã phòng',
roomCodePlaceholder: 'VD: K7M2QP',
roomCodeHint: 'Sáu ký tự. Không phân biệt hoa thường.',
@@ -128,11 +152,17 @@ export const t = {
chatPlaceholder: 'Nhắn tin…',
chatEmpty: 'Chưa có tin nhắn nào.',
chatUnread: '{n} tin mới',
chatAuthorLeft: 'Đã rời phòng',
// Parenthesized rather than a second clause: "Đã rời phòng: hello" reads
// as a sentence about the message, not a name standing in for one.
chatAuthorLeft: '(đã rời phòng)',
// The series score: how many games each player has won since the room
// opened, which is a room fact rather than a game one.
winsLabel: 'Tỉ số',
// opened, which is a room fact rather than a game one — distinct from the
// per-game score sitting right beside it, which used to share this label.
winsLabel: 'Ván thắng',
// The same figure, compact, on the board itself: "Ván thắng 1" beside a
// per-game score reading "12" was two numbers both called scores.
winsCompact: 'Thắng {n}',
lobbyTitle: 'Phòng chờ',
owner: 'Chủ phòng',
@@ -158,8 +188,10 @@ export const t = {
playerDisconnected: '{name} mất kết nối…',
playerDisconnectedIn: '{name} mất kết nối… ({n}s)',
// Said once, in the board's own spectating box, alongside whatever the
// position that knocked this player out still had left in it — a second
// banner used to say the same thing 60px above it.
youAreOut: 'Bạn đã bị loại. Ván đấu vẫn đang tiếp tục.',
spectating: 'Bạn đang xem ván đấu.',
playerOut: '{name} đã bị loại.',
// Left rather than knocked out: the seat is gone from the room, not just
// out of the game, so the others are not waiting for anybody.
@@ -236,6 +268,18 @@ export const difficultyLabels = {
/** The three difficulties offered on the home screen, in ladder order. */
export const difficultyOrder = [Difficulty.EASY, Difficulty.MEDIUM, Difficulty.HARD];
/**
* What each rung's bot actually does, shown in place of the record line until
* there is a record to show instead — server/internal/bot/strategy_*.go is
* the source of truth this describes.
* @type {Record<number, string>}
*/
export const difficultyHints = {
[Difficulty.EASY]: 'Máy chọn từ ngẫu nhiên',
[Difficulty.MEDIUM]: 'Máy chọn từ tốt nhất trước mắt',
[Difficulty.HARD]: 'Máy tính trước vài nước, hay dồn bí từ'
};
/**
* Why a word was refused. `{syllable}` is filled from the syllable the server
* is currently asking for — the reason alone does not say which one it was.
@@ -247,7 +291,11 @@ export const rejectMessages = {
[RejectReason.WRONG_LINK]: 'Từ phải bắt đầu bằng tiếng “{syllable}”.',
[RejectReason.NOT_IN_DICTIONARY]: 'Không tìm thấy từ này trong từ điển.',
[RejectReason.ALREADY_USED]: 'Từ này đã được dùng rồi.',
[RejectReason.NOT_YOUR_TURN]: 'Chưa đến lượt bạn.',
// Both callers submit only on the player's own turn, so this reason only
// ever answers a race where the turn moved on between the tap and the
// server's reply — "chưa đến lượt" alone reads as "not yet" about a turn
// that has in fact already passed.
[RejectReason.NOT_YOUR_TURN]: 'Chưa đến lượt bạn — từ này chưa được gửi.',
[RejectReason.TIMEOUT]: 'Hết giờ!',
[RejectReason.GAME_OVER]: 'Ván đấu đã kết thúc.'
};
@@ -260,7 +308,9 @@ export const rejectMessages = {
export const pointKindLabels = {
[PointKind.BASE]: 'nền',
[PointKind.CHAIN]: 'chuỗi',
[PointKind.SYLLABLES]: 'dài',
// "dài" alone is not a phrase; "+5 dài" read as a stray adjective rather
// than a bonus for a long word.
[PointKind.SYLLABLES]: 'từ dài',
[PointKind.SPEED]: 'nhanh',
[PointKind.RARITY]: 'hiếm'
};
@@ -298,16 +348,20 @@ export const errorMessages = {
kicked: 'Bạn đã bị mời ra khỏi phòng.',
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.',
// "Hai" was the server's own minimum leaking into prose; a room can need
// more than that, and ownerNeedsMore already says the real number.
need_more_players: 'Chưa đủ người để bắt đầu.',
not_a_dead_end: 'Vẫn còn từ nối được với tiếng này. Nghĩ thêm chút nữa!',
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.',
// 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.',
// Shared by resigning and claiming a dead end: both are gated on the
// player's own turn client-side, so this can only answer a race where the
// turn moved on between the tap and the server's reply — never a plain
// "wait your turn". Shown beside the button, not in the top banner.
not_your_turn: 'Lượt vừa chuyển đi, thao tác này không còn hiệu lực.',
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.',
@@ -320,7 +374,7 @@ export const errorMessages = {
server_full: 'Máy chủ đang quá tải. Hãy thử lại sau ít phút.',
server_restarting: 'Máy chủ đang khởi động lại. Hãy thử lại sau giây lát.',
session_not_resumable: 'Không khôi phục được ván đấu trước.',
too_fast: 'Bạn thao tác quá nhanh. Chậm lại một chút nhé.',
too_fast: 'Thao tác quá nhanh. Đợi một chút rồi thử lại.',
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ệ.',