Files
scrabble-game/ui/src/lib/i18n/en.ts
T
Ilia Denisov 42a6308261
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
feat(hint): idle-gated wallet-free vs_ai hint on a monotonic clock (B4)
A vs_ai hint is now unlimited and wallet-free, but idle-gated as an anti-frustration
aid: it unlocks only after the player has been stuck ~30 min on a turn (timed from the
robot's last move; the human's first move, before the robot has played, is exempt).
While gated the hint button carries a small lock badge and a tap shows the remaining
minutes ('Available in N min.'); the lock lifts live at the mark.

The gate is enforced CLIENT-SIDE against a MONOTONIC clock (performance.now()), never a
wall-clock timestamp: a device clock the player controls (or an auto-sync) must not be
able to open or freeze it. The wait is therefore session-scoped -- a reload restarts it
(there is no tamper-proof way to carry idle time across a relaunch without a wall clock).
An online vs_ai game will gate the same way but from the server's clock (a follow-up).

- lib/hints.ts: HINT_GATE_MS + pure hintGateRemainingMs (monotonic) + hintLockMinutes;
  removed the wall-clock hintRemainingMs. Unit-tested red->green.
- Game.svelte: the monotonic gate (hintGateStart/monoNow, armed on the turn change), a
  vs_ai hint button (plain: no confirm, no count; lock badge + gated tap -> toast), a
  live 10s tick.
- localgame: removed the wall-clock hint gate and the now-dead robotLastMoveAtUnix field
  from source.ts/serialize.ts (the client is authoritative); hint() just serves the top
  move.
- i18n game.hintLockedIn.
- offline.spec.ts: assert the first move is un-gated and the lock arms after the robot moves.
- Docs FUNCTIONAL(+_ru) + ARCHITECTURE; bundle budget 114->115 (game-screen feature).
2026-07-06 22:03:27 +02:00

395 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// English message catalog (authoritative). Keys are flat dotted strings; ru.ts must
// provide exactly the same keys (enforced by its type and a Vitest parity test).
// {name} placeholders are filled by t(key, params).
export const en = {
'app.title': 'Scrabble',
'app.brand': 'Erudit',
'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…',
'maintenance.title': 'Under maintenance',
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
'maintenance.retry': 'Try again',
'blocked.title': 'Account blocked',
'blocked.permanent': 'Your account is blocked.',
'blocked.temporary': 'Your account is blocked until {until}.',
'blocked.reason': 'Reason:',
'boot.errorTitle': "Couldn't load the game",
'boot.errorBody': 'Please try again in a moment.',
'launch.errorTitle': "Can't open in Telegram",
'launch.errorBody': 'Screenshot or share this with the developer.',
'launch.share': 'Share',
'launch.copied': 'Copied',
'common.back': 'Back',
'common.cancel': 'Cancel',
'common.ok': 'OK',
'common.close': 'Close',
'common.loading': 'Loading…',
'common.retry': 'Retry',
'common.you': 'You',
'common.save': 'Save',
'login.title': 'Sign in',
'login.guest': 'Play as guest',
'login.email': 'Email',
'login.emailPlaceholder': 'you@example.com',
'login.sendCode': 'Send code',
'login.codePlaceholder': '6-digit code',
'login.signIn': 'Sign in',
'confirm.busy': 'Confirming your email…',
'confirm.linked': 'Email confirmed.',
'confirm.merge': 'Almost done — finish the merge in the app.',
'confirm.close': 'You can close this window and return to the game.',
'confirm.expired': 'This link is invalid or has expired. Request a new code.',
'login.codeSent': 'We sent a code to {email}.',
'lobby.activeGames': 'Active games',
'lobby.finishedGames': 'Finished games',
'lobby.noActive': 'No active games yet.',
'lobby.noFinished': 'No finished games yet.',
'lobby.limitReached': "You've reached the simultaneous games limit.",
'lobby.new': 'Play',
'lobby.stats': 'Stats',
'lobby.profile': 'Profile',
'lobby.settings': 'Settings',
'lobby.about': 'About',
'lobby.yourTurn': 'Your turn',
'lobby.theirTurn': 'Their turn',
'lobby.hideGame': 'Remove from list',
'lobby.vs': 'vs {opponents}',
'new.title': 'New game',
'new.english': 'Scrabble',
'new.russian': 'Скрэббл',
'new.erudit': 'Erudite',
'new.find': 'Find a game',
'new.searching': 'Looking for an opponent…',
'new.rulesEnglish': '100 tiles · bingo +50',
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
'new.moveLimit': 'Move time: {n} h 00 min',
'new.searchHint':
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
// First-run coachmark hints (components/Coachmark.svelte). The leading emoji in each comment
// names the UI element the bubble's tail points at.
'onboarding.lobbySettings': 'Friends, game & profile settings, feedback', // ⚙️
'onboarding.lobbyStats': 'Stats refresh after each game with an opponent', // ✏️
'onboarding.lobbyNew': 'Practise against the AI', // 🎲
'onboarding.gameHeader': 'Move history, chat and word check', // scoreboard strip
'onboarding.gameTurn': 'Pass a turn, swap tiles', // 🔄
'onboarding.gameHints': 'Available hints', // 🛟
'onboarding.gameShuffle': 'Shuffle your tiles', // 🔀
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
'game.bag': '{n} in the bag',
'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}',
'game.yourTurn': 'Your turn',
'game.yourTurnBy': '{name}: Your turn!',
'game.opponentsTurn': "Opponent's turn",
'game.searchingForOpponent': 'Waiting for opponent…',
'game.waiting': "Waiting for {name}",
'game.makeMove': 'Make move',
'game.reset': 'Reset',
'game.draw': 'Exchange/Pass',
'game.shuffle': 'Shuffle',
'game.hint': 'Hint',
'game.hintLockedIn': 'Available in {n} min.',
'game.chat': 'Chat',
'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary',
'game.dropGame': 'Drop game',
'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Not a legal move',
'game.oneWordRule': 'One word per turn',
'game.chooseBlank': 'Choose a letter for the blank',
'game.exchangeTitle': 'Exchange or pass',
'game.exchangeConfirm': 'Exchange {n}',
'game.passNoExchange': 'Pass without exchanging',
'game.confirmResign': 'Resign this game?',
'game.hintShown': 'Best move: {word} for {n}',
'game.won': 'You won',
'game.lost': 'You lost',
'game.tied': 'Draw',
'game.abortedNote': 'This game could not be continued by the organizer and was ended in a draw.',
'game.checkWordPrompt': 'Enter a word',
'game.wordLegal': '“{word}” is valid',
'game.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree',
'game.lookup': 'Look it up',
'game.complaintSent': 'Thanks, sent for review.',
'game.check': 'Check',
'game.checkWait': 'Please wait a moment.',
'game.noHintOptions': 'No options with your letters.',
'game.thinking': 'thinking…',
'move.pass': 'pass',
'move.exchange': 'exchange',
'move.resign': 'resign',
'move.timeout': 'timeout',
'result.victory': 'Victory',
'result.defeat': 'Defeat',
'result.draw': 'Draw',
'result.place2': 'II place',
'result.place3': 'III place',
'result.place4': 'IV place',
'result.yourMove': 'Your move',
'result.oppMove': "Opponent's move",
'chat.placeholder': 'Quick message…',
'chat.send': 'Send',
'chat.nudge': 'Waiting for your move 🤭',
'chat.nudgeBy': '{name}: Waiting for your move 🤭',
'chat.nudgeAction': 'Nudge',
'chat.awaitingReply': "Let's be patient",
'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you',
'chat.sentThisTurn': 'You can write again next turn.',
'profile.title': 'Profile',
'profile.language': 'Language',
'profile.timezone': 'Time zone',
'profile.hintBalance': 'Hint balance',
'profile.guest': 'Guest account',
'profile.edit': 'Edit profile',
'profile.displayName': 'Display name',
'profile.awayWindow': 'Away window',
'profile.awayHint': 'You are not auto-resigned during these hours.',
'profile.from': 'From',
'profile.to': 'To',
'profile.blockChat': 'Disable chat',
'profile.blockFriendRequests': 'Disable friend requests',
'profile.notificationsInAppOnly': 'Notifications in the app only',
'profile.preferences': 'Preferences',
'profile.preferencesHint': 'The game variants you can be matched into. Pick at least one.',
'profile.email': 'Email',
'profile.bindEmail': 'Bind email',
'profile.emailCode': 'Confirmation code',
'profile.emailSent': 'We sent a code to {email}.',
'profile.emailBound': 'Email confirmed.',
'profile.saved': 'Profile saved.',
'profile.guestLocked': 'Sign in with email to manage your profile.',
'profile.linkAccount': 'Link an account',
'profile.linkTelegram': 'Link Telegram',
'profile.linkVK': 'Link VK',
'profile.linked': 'Account linked.',
'profile.merged': 'Accounts merged.',
'profile.mergeTitle': 'Merge accounts?',
'profile.mergeBody': 'This identity already belongs to “{name}” ({games} games, {friends} friends).',
'profile.mergeIrreversible': 'Merging combines both accounts into this one and cannot be undone.',
'profile.mergeConfirm': 'Merge',
'profile.accountsTitle': 'Sign-in methods',
'profile.changeEmail': 'Change',
'profile.newEmailPlaceholder': 'New email address',
'profile.emailChanged': 'Email updated.',
'profile.emailChangeTaken': 'Check the address or contact support.',
'profile.unlink': 'Unlink',
'profile.unlinked': 'Account unlinked.',
'profile.unlinkTitle': 'Unlink account?',
'profile.unlinkBody': 'Remove {provider} as a sign-in method? You can link it again later.',
'profile.deleteAccount': 'Delete account',
'profile.deleteTitle': 'Delete your account?',
'profile.deleteWarn': 'This removes your account and frees your sign-in methods. It cannot be undone.',
'profile.deleteEmailPrompt': 'We sent a code to your email. Enter it to delete your account.',
'profile.deletePhrasePrompt': 'Type DELETE to confirm.',
'profile.deletePhrasePlaceholder': 'DELETE',
'profile.deleteConfirm': 'Delete permanently',
'deleted.title': 'Account deleted',
'deleted.body': 'Your account has been removed. Reopening the app just creates a new account.',
'settings.title': 'Settings',
'settings.theme': 'Theme',
'settings.themeAuto': 'Auto',
'settings.themeLight': 'Light',
'settings.themeDark': 'Dark',
'settings.language': 'Interface language',
'settings.boardStyle': 'Board style',
'settings.boardLabels': 'Bonus labels',
'settings.labelsBeginner': 'Beginner',
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
'settings.offlineChecking': 'Loading dictionaries…',
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
'offline.promptTitle': 'No connection. Enable offline mode?',
'offline.promptYes': 'Enable',
'offline.promptNo': 'Keep trying',
'about.title': 'About',
'about.tab': 'Info',
'about.description': 'A multiplatform Scrabble game.',
'about.version': 'Version {v}',
'feedback.title': 'Feedback',
'feedback.placeholder': 'Describe the problem or share your suggestion…',
'feedback.attach': 'Attach file',
'feedback.removeFile': 'Remove',
'feedback.send': 'Send',
'feedback.fileRejected': 'This file cant be attached.',
'feedback.sent': 'Your message has been sent.',
'feedback.waiting': 'We are reviewing your last message.',
'feedback.banned': 'Feedback submission is unavailable.',
'feedback.replyTitle': 'Reply to your last message',
'landing.tagline': 'Play Scrabble with friends, random opponent or an AI.',
'landing.playTelegram': 'Play in Telegram',
'landing.playVK': 'Play on VK',
'landing.playWeb': 'Play in your browser',
'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK',
'landing.captionWeb': 'Web',
'install.title': 'Install the app',
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
'install.iosTitle': 'Add to Home Screen',
'install.iosStep1': 'Tap the Share button in the browser toolbar.',
'install.iosStep2': 'Choose “Add to Home Screen”.',
'install.iosStep3': 'Tap “Add”.',
'lang.en': 'English',
'lang.ru': 'Русский',
'error.not_your_turn': "It is not your turn.",
'error.nudge_own_turn': 'It is your turn — there is no one to nudge.',
'error.illegal_play': 'That is not a legal play.',
'error.hint_unavailable': 'No hints available.',
'error.no_hint_available': 'No options with your letters.',
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.nudge_too_soon': "Please don't rush your opponent so often.",
'error.chat_not_your_turn': 'You can chat only on your turn.',
'error.chat_already_sent': 'You can send only one message per turn.',
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
'error.already_queued': 'You are already in the queue.',
'error.email_taken': 'That email belongs to another account.',
'error.code_invalid': 'Invalid or expired code.',
'error.invalid_email': 'Enter a valid email address.',
'error.invalid_config': 'Invalid game settings.',
'error.not_found': 'Not found.',
'error.session_invalid': 'Your session expired. Please sign in again.',
'error.unauthenticated': 'Please sign in.',
'error.rate_limited': 'Too many requests, slow down.',
'error.unavailable': 'Connection problem. Retrying…',
'error.internal': 'Something went wrong.',
'error.generic': 'Something went wrong.',
'lobby.invitations': 'Invitations',
'lobby.friends': 'Friends',
'friends.title': 'Friends',
'friends.yours': 'Your friends',
'friends.none': 'No friends yet.',
'friends.incoming': 'Friend requests',
'friends.accept': 'Accept',
'friends.decline': 'Decline',
'friends.unfriend': 'Remove',
'friends.block': 'Block',
'friends.actions': 'Actions',
'friends.blockConfirm': 'Block this player?',
'friends.unfriendConfirm': 'Remove from friends?',
'friends.add': 'Add a friend',
'friends.addFromGame': 'Add to friends',
'friends.blockFromGame': 'Block player',
'friends.requestSent': 'Friend request sent.',
'friends.getCode': 'Show my code',
'friends.codeHint': 'Give this code to a friend within 12 hours.',
'friends.codeExpires': 'Expires at {time}',
'friends.enterCode': 'Have a code? Add a friend',
'friends.codePlaceholder': '6-digit code',
'friends.redeem': 'Add',
'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.',
'friends.shareTelegram': 'Share',
'friends.inviteText': "Let's play Scrabble!",
'friends.linkCopied': 'Link copied.',
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
'friends.staleInviteTitle': 'Link expired',
'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.',
'friends.welcomeRedeemTitle': 'Welcome!',
'friends.welcomeRedeem': 'Welcome, {name}!\n\nUse {bot} to join games the most convenient way.',
'friends.added': 'Added {name}.',
'friends.blockedList': 'Blocked players',
'friends.unblock': 'Unblock',
'friends.noneBlocked': 'No blocked players.',
'invitations.none': 'No invitations.',
'invitations.from': 'From {name}',
'invitations.with': 'With {names}',
'invitations.accept': 'Accept',
'invitations.decline': 'Decline',
'invitations.declineConfirm': 'Decline invitation?',
'invitations.cancel': 'Cancel',
'invitations.waiting': 'Waiting for replies',
'new.auto': 'Quick match',
'new.withFriends': 'Play with friends',
'new.pickFriends': 'Choose who to invite',
'new.searchFriends': 'Search friends',
'new.gameType': 'Variant',
'new.invite': 'Send invitation',
'new.moveTime': 'Move time',
'new.hintsPerPlayer': 'Hints',
'new.multipleWordsPerTurn': 'Multiple words per turn',
'new.start': 'Start game',
'new.invited': 'Invitation sent.',
'new.noFriends': 'Add friends first to invite them.',
'new.opponentAI': 'AI',
'new.opponentRandom': 'Random player',
'new.aiInactiveLimit': 'Loss after 7 days of inactivity',
'stats.title': 'Statistics',
'stats.wins': 'Wins',
'stats.losses': 'Losses',
'stats.draws': 'Draws',
'stats.played': 'Games',
'stats.moves': 'Moves',
'stats.hintShare': 'Hint share',
'stats.winRate': 'Win rate',
'stats.maxGame': 'Best game',
'stats.maxWord': 'Best move',
'stats.guestHint': 'Sign in to track your statistics.',
'game.exportGcg': 'Export game',
'game.exportChoice': 'Export the finished game as:',
'game.exportImageOpt': 'Image (PNG)',
'game.exportGcgOpt': 'GCG file',
'game.gcgActiveOnly': 'Available once the game is finished.',
'game.gcgCopied': 'GCG copied to the clipboard.',
'game.addFriendShort': 'Add friend?',
'game.blockShort': 'Block?',
'time.minutes': '{n} min',
'time.hours': '{n} h',
'error.self_relation': 'You cannot do that to yourself.',
'error.request_exists': 'A request or friendship already exists.',
'error.request_blocked': 'This player is not accepting requests.',
'error.request_not_found': 'No matching friend request.',
'error.no_shared_game': 'You can only add someone you have played with.',
'error.request_declined': 'This player declined your request.',
'error.friend_code_invalid': 'That friend code is invalid or expired.',
'error.invalid_invitation': 'Invalid invitation.',
'error.invitation_blocked': 'You cannot invite this player.',
'error.invitation_not_found': 'Invitation not found.',
'error.invitation_not_pending': 'This invitation is no longer open.',
'error.invitation_expired': 'This invitation has expired.',
'error.not_invited': 'You were not invited.',
'error.already_responded': 'You already responded.',
'error.not_inviter': 'Only the inviter can do that.',
'error.game_active': 'Available only after the game is finished.',
'error.invalid_profile': 'Some profile fields are invalid.',
'error.already_confirmed': 'This email is already confirmed.',
} as const;
export type MessageKey = keyof typeof en;