Files
noitu/web/src/lib/i18n/vi.js
T
tiennm99 d481a093ec feat(online): make a room a lobby with an owner and a ready-up
A room used to be a wrapper around one game: joining started it, and the
room died with it unless both players accepted a rematch inside thirty
seconds. It is now a lobby that outlives its games.

Whoever created the room owns it and the other seat is the guest. The guest
readies and the owner starts; the owner has no readiness of their own,
because starting is the same statement. A finished game returns both to the
lobby, where the next one is agreed exactly as the last was — the readiness
that started a game is spent with it.

A guest takes their readiness back before leaving, which is deliberate
friction: a player the owner is waiting on should have to say so before
walking away. The owner can free the seat of a guest who is not ready, and
not of one who is — readiness is a commitment, not an inconvenience. An
owner who leaves hands the room to whoever is left, unreadied, because they
are the one who starts now.

Something has to bound a room that outlives its games: the last player out
closes it, as does ten minutes in a lobby nobody started a game in. A
dropped connection is still not a player leaving — the seat is held for the
reconnect window in the lobby as well as mid-game, so a refresh no longer
costs somebody their room, and a resume lands in the lobby it left.

The rematch handshake is retired, and RoomCreated and RoomJoined go with it.
All three described part of what RoomState now describes in full, and three
messages for one lobby is three ways for a client to hold a view of it the
server never had. One snapshot per recipient, broadcast from the one place
that knows an input is finished, so no handler can forget to send it.
2026-09-07 16:32:14 +07:00

225 lines
8.6 KiB
JavaScript

import { RejectReason, GameEndReason, Difficulty } from '$lib/proto/noitu/v1/game_pb.js';
/**
* Every user-facing string in the app. Nothing outside this file writes
* Vietnamese prose, which is what lets the server send UI keys such as
* "room_not_found" instead of sentences.
*/
export const t = {
appName: 'Nối Từ',
tagline: 'Trò chơi nối từ tiếng Việ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.',
playBot: 'Chơi với máy',
playOnline: 'Đấu trực tuyến',
difficultyLabel: 'Độ khó',
back: 'Quay lại',
dismiss: 'Bỏ qua',
home: 'Về trang chủ',
rematch: 'Chơi lại',
resign: 'Đầu hàng',
resignConfirm: 'Bạn chắc chắn muốn đầu hàng?',
yourTurn: 'Đến lượt bạn',
opponentTurn: 'Đối thủ đang suy nghĩ…',
currentSyllable: 'Nối tiếp tiếng',
wordInputPlaceholder: 'Nhập từ của bạn',
submit: 'Gửi',
chainTitle: 'Chuỗi từ',
chainEmpty: 'Chưa có từ nào.',
you: 'Bạn',
opponent: 'Đối thủ',
scoreLabel: 'Điểm',
syllableUnit: 'tiếng',
correctedFrom: 'Bạn gõ “{typed}”, từ đúng là “{word}”.',
secondsLeft: '{n} giây',
waiting: 'Đang chờ…',
connecting: 'Đang kết nối…',
connected: 'Đã kết nối',
reconnecting: 'Mất kết nối, đang thử lại…',
noConnection: 'Không có kết nối',
won: 'Bạn thắng!',
lost: 'Bạn thua.',
finalScore: 'Điểm cuối cùng',
chainLength: 'Số từ trong chuỗi',
newRecord: 'Kỷ lục mới!',
suggestionsTitle: 'Bạn có thể nối',
noSuggestions: 'Không còn từ nào bắt đầu bằng tiếng “{syllable}”. Ai gặp thế này cũng chịu thôi!',
exportHistory: 'Tải chuỗi từ',
exportOpening: 'từ mở đầu',
bestScore: 'Kỷ lục',
noBestScore: 'Chưa có',
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.',
createRoom: 'Tạo phòng',
joinRoom: 'Vào phòng',
roomCodeLabel: 'Mã phòng',
roomCodePlaceholder: 'VD: K7M2QP',
roomCodeHint: 'Sáu ký tự. Không phân biệt hoa thường.',
roomCodeInvalid: 'Mã phòng gồm sáu ký tự.',
yourRoomCode: 'Mã phòng của bạn',
copyCode: 'Sao chép mã',
copyLink: 'Sao chép liên kết mời',
shareLink: 'Chia sẻ',
copied: 'Đã sao chép',
waitingForOpponent: 'Đang chờ đối thủ vào phòng…',
leaveRoom: 'Rời phòng',
shareInvite: 'Vào chơi nối từ với tôi',
lobbyTitle: 'Phòng chờ',
owner: 'Chủ phòng',
guest: 'Khách',
ready: 'Sẵn sàng',
unready: 'Chưa sẵn sàng',
isReady: 'Đã sẵn sàng',
notReady: 'Chưa sẵn sàng',
startGame: 'Bắt đầu',
kickPlayer: 'Mời ra khỏi phòng',
kickConfirm: 'Mời người chơi này ra khỏi phòng?',
emptySeat: 'Còn trống',
offline: 'Mất kết nối',
ownerStartsHint: 'Bạn là chủ phòng. Bắt đầu khi đối thủ đã sẵn sàng.',
guestReadyHint: 'Bấm sẵn sàng rồi chờ chủ phòng bắt đầu.',
waitingForStart: 'Đang chờ chủ phòng bắt đầu…',
unreadyToLeave: 'Bỏ sẵn sàng trước khi rời phòng.',
opponentDisconnected: 'Đối thủ mất kết nối…',
opponentDisconnectedIn: 'Đối thủ mất kết nối… ({n}s)',
opponentGone: 'Đối thủ đã rời trận.',
attributionIntro: 'Từ điển dựa trên',
attributionSource: 'minhqnd/dictionary',
attributionLicense: 'giấy phép CC BY-SA 4.0',
attributionMiddle: 'phát hành theo'
};
/**
* Bot difficulty labels, keyed by the proto enum.
*
* @type {Record<number, string>}
*/
export const difficultyLabels = {
[Difficulty.EASY]: 'Dễ',
[Difficulty.MEDIUM]: 'Trung bình',
[Difficulty.HARD]: 'Khó'
};
/** The three difficulties offered on the home screen, in ladder order. */
export const difficultyOrder = [Difficulty.EASY, Difficulty.MEDIUM, Difficulty.HARD];
/**
* 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.
*
* @type {Record<number, string>}
*/
export const rejectMessages = {
[RejectReason.UNSPECIFIED]: 'Từ không hợp lệ.',
[RejectReason.TOO_FEW_SYLLABLES]: 'Từ phải có ít nhất 2 tiếng.',
[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.',
[RejectReason.TIMEOUT]: 'Hết giờ!',
[RejectReason.GAME_OVER]: 'Ván đấu đã kết thúc.'
};
/**
* How a finished game ended, phrased from the losing or winning side.
*
* @type {Record<number, string>}
*/
export const endReasonMessages = {
[GameEndReason.UNSPECIFIED]: '',
[GameEndReason.TIMEOUT]: 'Hết thời gian suy nghĩ.',
[GameEndReason.NO_LEGAL_MOVE]: 'Không còn từ nào nối được.',
[GameEndReason.OPPONENT_LEFT]: 'Đối thủ đã rời trận.',
[GameEndReason.RESIGNED]: 'Có người đầu hàng.'
};
/**
* ServerError.code is a UI key, so this is where those keys become sentences.
* An unknown code falls back to `errorFallback` rather than showing the raw
* key: a key leaking into the UI is a bug, not a message.
*
* @type {Record<string, string>}
*/
export const errorMessages = {
already_greeted: 'Phiên chơi đã được mở rồi.',
bad_frame: 'Máy chủ không đọc được dữ liệu gửi lên.',
busy: 'Bạn đang ở trong một ván đấu khác.',
cannot_join_own_room: 'Bạn không thể vào phòng của chính mình.',
game_already_over: 'Ván đấu đã kết thúc.',
game_in_progress: 'Ván đấu đang diễn ra.',
game_not_started: 'Ván đấu chưa bắt đầu.',
game_start_failed: 'Không thể bắt đầu ván đấu. Hãy thử lại.',
handshake_required: 'Phiên chơi chưa sẵn sàng. Hãy tải lại trang.',
kicked: 'Bạn đã bị mời ra khỏi phòng.',
must_unready_first: 'Hãy bỏ sẵn sàng trước khi rời phòng.',
need_two_players: 'Cần hai người chơi mới bắt đầu được.',
not_everyone_ready: 'Đối thủ 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.',
no_one_to_kick: 'Chưa có ai trong phòng để mời ra.',
opponent_offline: 'Đối thủ đang mất kết nối.',
owner_needs_no_ready: 'Chủ phòng không cần bấm sẵn sàng.',
player_is_ready: 'Không thể mời một người đã sẵn sàng ra khỏi phòng.',
protocol_version_mismatch: 'Phiên bản đã cũ. Hãy tải lại trang.',
room_full: 'Phòng đã đủ người.',
room_idle_closed: 'Phòng đã đóng vì không có ván nào được bắt đầu.',
room_not_found: 'Không tìm thấy phòng với mã này.',
room_start_failed: 'Không thể tạo phòng. Hãy thử lại.',
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_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ệ.'
};
export const errorFallback = 'Đã có lỗi xảy ra. Hãy thử lại.';
/**
* Fills `{name}` placeholders. Keeping interpolation here means a message can
* gain a placeholder without every call site learning about it.
*
* @param {string} template
* @param {Record<string, string | number>} [values]
* @returns {string}
*/
export function fill(template, values = {}) {
return template.replace(/\{(\w+)\}/g, (whole, key) =>
key in values ? String(values[key]) : whole
);
}
/**
* @param {number} reason - a RejectReason enum value
* @param {string} syllable - the syllable the server is currently asking for
* @returns {string}
*/
export function rejectMessage(reason, syllable) {
const template = rejectMessages[reason] ?? rejectMessages[RejectReason.UNSPECIFIED];
return fill(template, { syllable });
}
/**
* @param {string} code - ServerError.code
* @returns {string}
*/
export function errorMessage(code) {
return errorMessages[code] ?? errorFallback;
}