From d92008f96bac8e643193a13f774e5ea7d07273d0 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Mon, 19 Jan 2026 13:46:08 +0900 Subject: [PATCH 01/45] mls (v4.14) (#2953) ## Description: mls for v29 Version identifier within MLS: 4.14 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- resources/lang/bg.json | 38 ++- resources/lang/ja.json | 38 ++- resources/lang/nl.json | 42 ++- resources/lang/ru.json | 291 +++++++++++++++------ resources/lang/tr.json | 521 ++++++++++++++++++++++++++++++-------- resources/lang/uk.json | 293 +++++++++++++++------ resources/lang/zh-CN.json | 239 +++++++++++++---- 7 files changed, 1128 insertions(+), 334 deletions(-) diff --git a/resources/lang/bg.json b/resources/lang/bg.json index ce630b90f..562270927 100644 --- a/resources/lang/bg.json +++ b/resources/lang/bg.json @@ -26,12 +26,15 @@ "title": "OpenFront (АЛФА)", "join_discord": "Discord", "login_discord": "Влез с Discord", + "sign_in": "Вход", + "discord_avatar_alt": "Discord профилна снимка", + "user_avatar_alt": "Профилната снимка на {username}", "checking_login": "Проверяване на входа...", "logged_in": "Влезли сте!", "log_out": "Излез от профила си", "create": "Създай частна игра", "join": "Присъедини се към частна игра", - "solo": "Самостоятелна игра", + "solo": "Самостоятелно", "instructions": "Инструкции", "game_info": "Информация за играта", "wiki": "Wiki", @@ -42,7 +45,7 @@ "play": "Играй", "news": "Новини", "store": "Магазин", - "options": "Опции", + "settings": "Настройки", "keys": "Клавиши", "stats": "Статистики", "account": "Акаунт", @@ -179,13 +182,8 @@ "title": "Акаунт", "connected_as": "Вписан като", "stats_overview": "Преглед на статистики", - "save_progress_title": "Запази си напредъка", - "save_progress_desc": "Свържи си акаунта, за да запазиш статистиките, ранка и козметиките си в безопасност.", "link_discord": "Свържи Discord акаунт", - "link_via_email_placeholder": "Свържи чрез имейл", - "link_button": "Свържи", "log_out": "Изход от профила", - "welcome_back": "Добре дошъл отново!", "sign_in_desc": "Впиши се, за да запазиш статистиките и напредъка си", "or": "ИЛИ", "email_placeholder": "Въведи имейл адреса си", @@ -237,7 +235,7 @@ "pirate": "Пират", "conquered": "Завладяно", "loading_game_info": "Зареждат се статистиките на играта", - "no_winner": "Играта е свършила без победител" + "no_winner": "Играта завърши без победител (или победа на нация)" }, "map": { "map": "Карта", @@ -429,7 +427,7 @@ "factory": "Фабрика" }, "user_setting": { - "title": "Потребителски настройки", + "title": "Настройки", "tab_basic": "Базови настройки", "tab_keybinds": "Бързи клавиши", "dark_mode_label": "Тъмен режим", @@ -487,6 +485,11 @@ "build_hydrogen_bomb_desc": "Пускане на водородна бомба под курсора Ви.", "build_mirv": "Пускане на МИРВ", "build_mirv_desc": "Пускане на МИРВ под курсора Ви.", + "menu_shortcuts": "Преки пътища за меню", + "build_menu_modifier": "Модификатор на менюто за изграждане", + "build_menu_modifier_desc": "Задръж този клавиш, докато кликаш, за да отвориш менюто за изграждане.", + "emoji_menu_modifier": "Модификатор на менюто с емоджита", + "emoji_menu_modifier_desc": "Задръж този клавиш, докато кликаш, за да отвориш менюто за емоджита.", "attack_ratio_controls": "Контроли за съотношение на атака", "attack_ratio_up": "Увеличаване на съотношение на атака", "attack_ratio_up_desc": "Увеличаване на съотношение на атака с 10%", @@ -497,6 +500,8 @@ "boat_attack_desc": "Изпраща атака с лодка към плочката под курсора ви.", "ground_attack": "Земна атака", "ground_attack_desc": "Изпраща земна атака към плочката под курсора ви.", + "swap_direction": "Размени посоката на ракетата", + "swap_direction_desc": "Превключване на посоката на изстрелване на ракетата (нагоре/надолу).", "zoom_controls": "Контроли за позиция на камерата", "zoom_out": "Отдалечаване на камерата", "zoom_out_desc": "Отдалечаване на камерата от картата", @@ -711,7 +716,20 @@ "wants_to_renew_alliance": "{name} иска да поднови съюза си с теб", "ignore": "Игнориране", "unit_voluntarily_deleted": "Елементът бе изтрит доброволно", - "betrayal_debuff_ends": "Остават {time} секунди до края на предателското отслабване" + "betrayal_debuff_ends": "Остават {time} секунди до края на предателското отслабване", + "attack_cancelled_retreat": "Атаката бе отменена, {troops} войници бяха убити по време на отстъплението", + "received_gold_from_captured_ship": "Получи {gold} злато от лодка на {name}, превзета от теб", + "received_gold_from_trade": "Получи {gold} злато от търговия с {name}", + "missile_intercepted": "Противоракетната установка прихвана {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} МИРВ бе прихванат} other {{count} МИРВ-а бяха прихванати}}", + "sent_troops_to_player": "Изпрати {troops} войници на {name}", + "received_troops_from_player": "Получи {troops} войници от {name}", + "sent_gold_to_player": "Изпрати {gold} злато на {name}", + "received_gold_from_player": "Получи {gold} злато от {name}", + "unit_captured_by_enemy": "Твоят/а {unit} бе превзет от {name}", + "captured_enemy_unit": "Превзе {unit} от {name}", + "unit_destroyed": "Твоят/а {unit} бе унищожен", + "no_boats_available": "Няма свободни кораби, максимум {max}" }, "unit_info_modal": { "structure_info": "Информация за постройката", diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 381fd0390..89751baae 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -26,12 +26,15 @@ "title": "OpenFront (ALPHA)", "join_discord": "Discord", "login_discord": "Discordでログイン", + "sign_in": "サインイン", + "discord_avatar_alt": "Discordのプロフィールアバター", + "user_avatar_alt": "{username}のアバター", "checking_login": "ログイン中...", "logged_in": "ログイン中!", "log_out": "ログアウト", "create": "ロビーを作成", "join": "ロビーに参加", - "solo": "1人のロビー", + "solo": "ソロ", "instructions": "説明書", "game_info": "ゲームの情報", "wiki": "ウィキ", @@ -42,7 +45,7 @@ "play": "プレイ", "news": "お知らせ", "store": "ストア", - "options": "設定", + "settings": "設定", "keys": "キー設定", "stats": "統計", "account": "アカウント", @@ -179,13 +182,8 @@ "title": "アカウント", "connected_as": "接続されたアカウント", "stats_overview": "統計の概要", - "save_progress_title": "進捗状況を保存する", - "save_progress_desc": "アカウントをリンクして、統計、ランク、コスメティックを安全に保ちます。", "link_discord": "Discordアカウントを連携する", - "link_via_email_placeholder": "メールで連携する", - "link_button": "連携", "log_out": "ログアウト", - "welcome_back": "おかえりなさい", "sign_in_desc": "統計と進捗状況を保存するにはサインインしてください", "or": "または", "email_placeholder": "メールアドレスを入力してください", @@ -237,7 +235,7 @@ "pirate": "海賊", "conquered": "征服された", "loading_game_info": "ゲームの統計を読み込んでいます", - "no_winner": "この試合の勝者はいなかった" + "no_winner": "このゲームは勝者なしで終了しました(または国家が勝利しました)" }, "map": { "map": "地図", @@ -429,7 +427,7 @@ "factory": "工場" }, "user_setting": { - "title": "ユーザー設定", + "title": "設定", "tab_basic": "基本設定", "tab_keybinds": "キーの割り当て", "dark_mode_label": "ダークモード", @@ -487,6 +485,11 @@ "build_hydrogen_bomb_desc": "選択した位置に水素爆弾を発射します。", "build_mirv": "MIRVを発射", "build_mirv_desc": "選択した位置にMIRVを発射します。", + "menu_shortcuts": "メニューのショートカット", + "build_menu_modifier": "ビルドメニューを表示", + "build_menu_modifier_desc": "ビルドメニューを開きます。", + "emoji_menu_modifier": "絵文字メニューを表示", + "emoji_menu_modifier_desc": "絵文字メニューを開きます。", "attack_ratio_controls": "攻撃比率の調整", "attack_ratio_up": "出撃兵力の割合を上げる", "attack_ratio_up_desc": "出撃兵力を10%増加させる", @@ -497,6 +500,8 @@ "boat_attack_desc": "カーソルの位置に合わせた土地にボート攻撃を送ります。", "ground_attack": "ボート攻撃", "ground_attack_desc": "カーソルの位置に合わせた土地にボート攻撃を送ります。", + "swap_direction": "核の撃つ向きを逆転", + "swap_direction_desc": "核の発射方向を切り替える(上方向/下方向)。", "zoom_controls": "ズーム操作", "zoom_out": "ズームアウト", "zoom_out_desc": "マップを縮小します", @@ -711,7 +716,20 @@ "wants_to_renew_alliance": "{name} が同盟の更新を提案しています", "ignore": "無視", "unit_voluntarily_deleted": "ユニットは自己破壊しました", - "betrayal_debuff_ends": "裏切りのデバフ終了まであと {time} 秒" + "betrayal_debuff_ends": "裏切りのデバフ終了まであと {time} 秒", + "attack_cancelled_retreat": "攻撃はキャンセルされました、撤退中に{troops} 人の兵士が死亡しました", + "received_gold_from_captured_ship": "{name} から捕獲した船から資金 {gold} を獲得しました", + "received_gold_from_trade": "{name} との貿易で資金 {gold}を獲得しました", + "missile_intercepted": "ミサイルが{unit}を迎撃しました", + "mirv_warheads_intercepted": "{count, plural, other {{count}発の MIRV 弾頭を迎撃}}", + "sent_troops_to_player": "{troops} の兵士を {name} に送信しました", + "received_troops_from_player": "{name}から{troops}の軍隊を受け取りました", + "sent_gold_to_player": "{gold} の資金を {name}に贈りました", + "received_gold_from_player": "{gold} から {name} の資金を受け取りました", + "unit_captured_by_enemy": "あなたの {unit} は {name}に鹵獲されました", + "captured_enemy_unit": "{unit}を{name}から奪い取りました", + "unit_destroyed": "あなたの{unit}は破壊されました", + "no_boats_available": "ボートをこれ以上出せません、最大は{max}隻までです" }, "unit_info_modal": { "structure_info": "建造物情報", diff --git a/resources/lang/nl.json b/resources/lang/nl.json index d103626c0..f8784c045 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -26,12 +26,15 @@ "title": "OpenFront (ALFA)", "join_discord": "Discord", "login_discord": "Login met Discord", + "sign_in": "Aanmelden", + "discord_avatar_alt": "Avatar Discord profiel", + "user_avatar_alt": "Avatar van {username}", "checking_login": "Inlog controleren...", "logged_in": "Ingelogd!", "log_out": "Uitloggen", "create": "Lobby aanmaken", "join": "Lobby toetreden", - "solo": "Solo-lobby", + "solo": "Solo", "instructions": "Instructies", "game_info": "Spelinformatie", "wiki": "Wiki", @@ -42,7 +45,7 @@ "play": "Spelen", "news": "Nieuws", "store": "Winkel", - "options": "Opties", + "settings": "Instellingen", "keys": "Sneltoetsen", "stats": "Statistieken", "account": "Account", @@ -179,14 +182,9 @@ "title": "Account", "connected_as": "Gekoppeld als", "stats_overview": "Overzicht van statistieken", - "save_progress_title": "Sla je voortgang op", - "save_progress_desc": "Koppel je account om je statistieken, rang en cosmetica veilig te houden.", "link_discord": "Discord-account koppelen", - "link_via_email_placeholder": "Koppel via e-mail", - "link_button": "Koppelen", "log_out": "Uitloggen", - "welcome_back": "Welkom terug", - "sign_in_desc": "Log in om je statistieken en voortgang op te slaan", + "sign_in_desc": "Meld je aan om statistieken en voortgang op te slaan", "or": "OF", "email_placeholder": "Voer je e-mailadres in", "get_magic_link": "Krijg Magische Link", @@ -237,7 +235,7 @@ "pirate": "Kapen", "conquered": "Veroverd", "loading_game_info": "Spelstatistieken worden geladen", - "no_winner": "Dit spel eindigde zonder winnaar" + "no_winner": "Dit spel eindigde zonder winnaar (of een Natie won)" }, "map": { "map": "Kaart", @@ -429,7 +427,7 @@ "factory": "Fabriek" }, "user_setting": { - "title": "Gebruikersinstellingen", + "title": "Instellingen", "tab_basic": "Basisinstellingen", "tab_keybinds": "Sneltoetsen", "dark_mode_label": "Donkere Modus", @@ -487,6 +485,11 @@ "build_hydrogen_bomb_desc": "Bouw een Waterstofbom onder je cursor.", "build_mirv": "Bouw MIRV", "build_mirv_desc": "Bouw een MIRV onder je cursor.", + "menu_shortcuts": "Menu sneltoetsen", + "build_menu_modifier": "Bouwmenu", + "build_menu_modifier_desc": "Houdt deze toets ingedrukt terwijl je klikt, om het bouwmenu te openen.", + "emoji_menu_modifier": "Emoji-menu", + "emoji_menu_modifier_desc": "Houdt deze toets ingedrukt terwijl je klikt, om het emoji-menu te openen.", "attack_ratio_controls": "Aanvalsverhouding-bediening", "attack_ratio_up": "Verhoog Aanvalsverhouding", "attack_ratio_up_desc": "Verhoog aanvalsverhouding met 10%", @@ -497,6 +500,8 @@ "boat_attack_desc": "Stuur een bootaanval naar de plek onder je cursor.", "ground_attack": "Grondaanval", "ground_attack_desc": "Stuur een grondaanval naar de plek onder je cursor.", + "swap_direction": "Omdraaien boogrichting atoom- / waterstofbom", + "swap_direction_desc": "Draai boogrichting raket om (opwaarts/neerwaarts).", "zoom_controls": "Zoombediening", "zoom_out": "Uitzoomen", "zoom_out_desc": "Kaart uitzoomen", @@ -711,7 +716,20 @@ "wants_to_renew_alliance": "{name} wil jullie alliantie vernieuwen", "ignore": "Negeren", "unit_voluntarily_deleted": "Eenheid vrijwillig verwijderd", - "betrayal_debuff_ends": "Nog {time} seconden tot de verraad-verzwakking afloopt" + "betrayal_debuff_ends": "Nog {time} seconden tot de verraad-verzwakking afloopt", + "attack_cancelled_retreat": "Aanval geannuleerd, {troops} soldaten gedood tijdens terugtrekken", + "received_gold_from_captured_ship": "{gold} Goud ontvangen van veroverd schip van {name}", + "received_gold_from_trade": "{gold} Goud ontvangen van handel met {name}", + "missile_intercepted": "Raket onderschepte {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} MIRV-kernkop onderschept} other {{count} MIRV-kernkoppen onderschept}}", + "sent_troops_to_player": "{troops} Troepen naar {name} gestuurd", + "received_troops_from_player": "{troops} Troepen ontvangen van {name}", + "sent_gold_to_player": "{gold} Goud verstuurd aan {name}", + "received_gold_from_player": "{gold} Goud ontvangen van {name}", + "unit_captured_by_enemy": "Jouw {unit} werd veroverd door {name}", + "captured_enemy_unit": "{unit} veroverd van {name}", + "unit_destroyed": "Jouw {unit} werd vernietigd", + "no_boats_available": "Geen boten beschikbaar, max. {max}" }, "unit_info_modal": { "structure_info": "Gebouw Info", @@ -866,7 +884,7 @@ "ship_type": "Scheepstype", "weapon": "Wapen", "built": "Gebouwd", - "destroyed": "Verwoest", + "destroyed": "Vernietigd", "captured": "Veroverd", "lost": "Verloren", "hits": "Treffers", diff --git a/resources/lang/ru.json b/resources/lang/ru.json index ce7d1cfe6..89e9daf93 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -7,6 +7,7 @@ }, "common": { "close": "Закрыть", + "back": "Назад", "available": "Доступно", "preset_max": "Максимум", "summary_send": "Перевод", @@ -17,26 +18,42 @@ "cap_tooltip": "Оставшаяся ёмкость получателя", "target_dead": "Цель устранена", "target_dead_note": "Невозможно отправить ресурсы устранённому игроку.", - "none": "Ничего" + "none": "Ничего", + "copied": "Скопировано!", + "click_to_copy": "Нажмите, чтобы скопировать" }, "main": { "title": "OpenFront (АЛЬФА)", "join_discord": "Discord", "login_discord": "Войти через Discord", + "sign_in": "Войти", + "discord_avatar_alt": "Аватар профиля Discord", + "user_avatar_alt": "Аватар {username}", "checking_login": "Проверка авторизации...", "logged_in": "Вход выполнен!", "log_out": "Выйти", - "create_lobby": "Создать лобби", - "join_lobby": "Присоединиться к лобби", - "single_player": "Одиночная игра", + "create": "Создать лобби", + "join": "Присоединиться к лобби", + "solo": "Соло", "instructions": "Инструкции", + "game_info": "Информация об игре", "wiki": "Вики", "privacy_policy": "Политика конфиденциальности", "terms_of_service": "Пользовательское соглашение", - "reddit": "Reddit" + "copyright": "© OpenFront™ и участники", + "reddit": "Reddit", + "play": "Играть", + "news": "Новости", + "store": "Магазин", + "settings": "Настройки", + "keys": "Клавиши", + "stats": "Статистика", + "account": "Аккаунт", + "help": "Помощь", + "menu": "Меню", + "pick_pattern": "Выберите узор!" }, "news": { - "see_all_releases": "Посмотреть все выпуски", "github_link": "на GitHub", "title": "Список изменений" }, @@ -66,7 +83,7 @@ "ui_events": "Панель событий", "ui_events_desc": "Панель событий отображает последние события, запросы и сообщения быстрого чата. Некоторые примеры:", "ui_events_alliance": "Союз — Запросы на заключение союзов можно принимать или отклонять. Союзники могут обмениваться ресурсами и войсками, но не могут атаковать друг друга. Нажатие на «Осмотреть» перемещает вид на игрока, который отправил запрос.", - "ui_events_attack": "Атаки — Отображение входящих и исходящих атак. Нажмите на сообщение, чтобы центровать камеру на атаку, ракету или лодку (транспортный корабль). Вы можете отозвать войска, нажав на красную кнопку «X». Это будет стоить жизней 25% войск, которые атакуют. Если вы отозвёте лодку, она вернётся в исходное местоположение и совершит атаку, если территория была захвачена. Ракеты нельзя отозвать после запуска.", + "ui_events_attack": "Атаки — Отображение входящих и исходящих атак. Нажмите на сообщение, чтобы центровать камеру на атаку, ракету или лодку (транспортный корабль). Вы можете отозвать войска, нажав на красную кнопку «X». Это будет стоить жизней 25% войск, которые атакуют. Если вы отозвёте судо, оно вернётся в исходное местоположение и совершит атаку, если территория была захвачена. Ракеты нельзя отозвать после запуска.", "ui_events_quickchat": "Быстрый чат — Здесь вы можете увидеть отправленные и полученные сообщения. Отправьте сообщение игроку, нажав на значок быстрого чата в его меню информации.", "ui_options": "Настройки", "ui_options_desc": "Среди них можно найти следующие элементы:", @@ -76,13 +93,15 @@ "option_pause": "Приостановить/Продолжить игру — Доступно только в режиме одиночной игры.", "option_timer": "Таймер — Время, прошедшее с начала игры.", "option_exit": "Кнопка выхода.", - "option_settings": "Настройки — Открыть меню настроек. В нём вы можете включить/выключить альтернативное представление, эмодзи, тёмный режим, ниндзя (режим скрытых/случайных имён) и взаимодействие левой кнопкой мыши.", + "option_settings": "Настройки — Открыть меню настроек. В нём вы можете переключить альтернативное представление, эмодзи, тёмный режим, ниндзя (режим скрытых/случайных имён) и взаимодействие левой кнопкой мыши.", "radial_title": "Круговое меню", "radial_desc": "Щелчок правой кнопкой мыши (или нажатие на мобильном устройстве) открывает круговое меню. Щёлкните правой кнопкой мыши за его пределами, чтобы закрыть его. С этого меню вы можете:", "radial_build": "Открыть меню строительства.", "radial_attack": "Открыть меню атаки.", "radial_info": "Открыть меню информации.", - "radial_boat": "Отправить лодку (транспортный корабль) для атаки указанного места. Доступно только при наличии доступа к воде.", + "radial_boat": "Отправить судно (транспортный корабль) для атаки указанного места. Доступно только при наличии доступа к воде.", + "radial_donate_troops": "Пожертвовать войска, равные соотношению вашего ползунка атаки тому союзнику, на котором вы открыли круговое меню.", + "radial_donate_gold": "Открывает меню ползунка пожертвования золота для быстрой отправки золота союзникам.", "radial_close": "Закрыть меню.", "info_title": "Меню информации", "info_enemy_desc": "Содержит такую информацию о выбранном игроке, как его имя, количество золота, войск, состояние торговли с вами, запущенные на вас ракеты и метку предателя. Прекращённая торговля значит, что вы не будете получать от игрока золото и он не будет отправлять вам золото через торговые корабли. Вручную (если игрок нажал «Прекратить торговлю», что длится до тех пор, пока вы оба не нажмёте «Начать торговлю») или автоматически (если вы предали ваш союз, что длится до тех пор, пока вы не станете союзниками снова или через 5 минут). В поле «Предатель» будет указана метка «Да» в течение 30 секунд после того, как игрок предал и напал на игрока, который был в союзе с ними. Значки ниже обозначают следующие взаимодействия:", @@ -110,12 +129,12 @@ "build_port": "Порт", "build_port_desc": "Может быть построен только вблизи воды. Позволяет строить военные корабли. Автоматически посылает торговые суда между портами вашей и других стран (за исключением случаев, когда торговля прекращена), выдавая золото обеим сторонам. Торговля прекращается автоматически если вы атакуете или атакуют вас. Возобновляется через 5 минут или если вы становитесь союзниками. Вы можете вручную управлять торговлей с помощью кнопок «Прекратить торговлю» и «Начать торговлю».", "build_warship": "Военный корабль", - "build_warship_desc": "Патрулирует территорию, захватывая вражеские торговые корабли и разрушая вражеские лодки (транспортные корабли) и военные корабли. Появляется из ближайшего порта и патрулирует область, выбранную нажатием кнопкой мыши при создании. Вы можете управлять военными кораблями при помощью кнопки атаки (см. действие «Атака» в разделе «Горячие клавиши»): сначала нажмите на корабль, а затем — на новую область, к которой вы хотите переместиться.", + "build_warship_desc": "Патрулирует территорию, захватывая вражеские торговые корабли и разрушая вражеские суда (транспортные корабли) и военные корабли. Появляется из ближайшего порта и патрулирует область, выбранную нажатием кнопкой мыши при создании. Вы можете управлять военными кораблями при помощью кнопки атаки (см. действие «Атака» в разделе «Горячие клавиши»): сначала нажмите на корабль, а затем — на новую область, к которой вы хотите переместиться.", "build_silo": "Ракетная шахта", "build_silo_desc": "Позволяет запускать ракеты.", - "build_sam": "Пусковая установка ЗРК", - "build_sam_desc": "Позволяет перехватывать вражеские ракеты в радиусе 100 пикселей. Имеет шанс 100% на попадание в атомную бомбу, 80% — в водородную бомбу и 50% — в отдельные боеголовки РГЧ ИН. Перезарядка ЗРК составляет 7,5 секунды.", - "build_atom": "Атомная бомба", + "build_sam": "ПУ ЗРК", + "build_sam_desc": "Может перехватывать вражеские ракеты в радиусе 100 пикселей. ЗРК имеет период перезарядки в 7,5 секунд.", + "build_atom": "Ядерная бомба", "build_atom_desc": "Небольшая взрывная бомба, которая разрушает территорию, сооружения, корабли и лодки. Запускается из ближайшей ракетной шахты и наносит удар по области, выбранной нажатием кнопкой мыши.", "build_hydrogen": "Водородная бомба", "build_hydrogen_desc": "Большая взрывная бомба. Запускается из ближайшей ракетной шахты и наносит удар по области, выбранной нажатием кнопкой мыши.", @@ -129,12 +148,15 @@ "icon_embargo": "Перечёркнутый знак доллара — Эмбарго. Этот игрок перестал торговать с вами; автоматически или вручную.", "icon_request": "Конверт — Запрос на союз. Этот игрок отправил вам запрос на заключение союза.", "info_enemy_panel": "Панель информации о враге", - "exit_confirmation": "Вы уверены, что хотите выйти из игры?" + "exit_confirmation": "Вы уверены, что хотите выйти из игры?", + "bomb_direction": "Траектория полёта ядерной/водородной бомбы" }, "single_modal": { - "title": "Одиночная игра", + "title": "Соло", "random_spawn": "Случайное появление", "allow_alliances": "Разрешить союзы", + "toggle_achievements": "Переключение достижений", + "sign_in_for_achievements": "Войдите, чтобы получать достижения", "options_title": "Настройки", "bots": "Боты: ", "bots_disabled": "Отключены", @@ -145,6 +167,8 @@ "infinite_troops": "Неограниченные войска", "compact_map": "Компактная карта", "max_timer": "Продолжительность игры (минуты)", + "max_timer_placeholder": "Минуты", + "max_timer_invalid": "Пожалуйста, введите допустимое максимальное значение таймера (1–120 минут)", "disable_nukes": "Отключить бомбы", "enables_title": "Разрешения", "start": "Начать игру" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Аккаунт", - "logged_in_as": "Вы вошли как {email}", + "connected_as": "Вы вошли как", + "stats_overview": "Обзор статистики", + "link_discord": "Привязать учётную запись Discord", + "log_out": "Выйти", + "sign_in_desc": "Войдите, чтобы сохранить статистику и прогресс", + "or": "ИЛИ", + "email_placeholder": "Введите свою почту", + "get_magic_link": "Получить волшебную ссылку", + "linked_account": "Вы вошли как {account_name}", "fetching_account": "Получение информации об аккаунте...", - "logged_in_with_discord": "Вы вошли через Discord", - "recovery_email_sent": "Письмо для восстановления отправлено на {email}" + "recovery_email_sent": "Письмо для восстановления отправлено на {email}", + "not_found": "Не найдено", + "clear_session": "Очистить сессию", + "failed_to_send_recovery_email": "Не удалось отправить письмо для восстановления", + "enter_email_address": "Пожалуйста, введите адрес электронной почты" }, "stats_modal": { "title": "Статистика", @@ -167,11 +202,40 @@ "loading": "Загрузка...", "error": "Ошибка загрузки статистики кланов", "no_stats": "Статистика кланов недоступна", + "no_data_yet": "Пока нет данных", "clan": "Клан", "games": "Игры", "win_score": "Счёт побед", + "win_score_tooltip": "Взвешенные победы на основе участия клана и сложности матча", "loss_score": "Счёт поражений", - "win_loss_ratio": "Победы/Поражения" + "loss_score_tooltip": "Взвешенные поражения на основе участия клана и сложности матча", + "win_loss_ratio": "Победы/Поражения", + "ratio": "Соотношение", + "rank": "Ранг", + "try_again": "Попробуйте ещё раз" + }, + "game_info_modal": { + "title": "Информация об игре", + "players": "Игроки", + "atoms": "Ядерные бомбы", + "hydros": "Водородные бомбы", + "mirv": "РГЧ ИН", + "bombs": "Бомбы", + "total_gold": "Всего", + "all_gold": "Всё золото", + "trade": "Торговля", + "conquest_gold": "Захваченное золото игроков", + "stolen_gold": "Украдено с помощью военных кораблей", + "num_of_conquests": "Количество покорённых игроков", + "duration": "Продолжительность", + "survival_time": "Время выживания", + "war": "Война", + "economy": "Экономика", + "conquests": "Завоевания", + "pirate": "Пиратство", + "conquered": "Завоёвано", + "loading_game_info": "Загрузка игровой статистики", + "no_winner": "Эта игра закончилась без победителя (или выиграла нация)" }, "map": { "map": "Карта", @@ -186,6 +250,7 @@ "asia": "Азия", "mars": "Марс", "southamerica": "Южная Америка", + "britanniaclassic": "Британия (классическая)", "britannia": "Британия", "gatewaytotheatlantic": "Гибралтарский пролив", "australia": "Австралия", @@ -206,22 +271,36 @@ "yenisei": "Енисей", "pluto": "Плутон", "montreal": "Монреаль", + "newyorkcity": "Нью-Йорк", "achiran": "Акиран", "baikalnukewars": "Байкал (ядерные войны)", "fourislands": "Четыре острова", "gulfofstlawrence": "Залив Св. Лоуренса", - "lisbon": "Лиссабон" + "lisbon": "Лиссабон", + "svalmel": "Свалмель", + "manicouagan": "Маникуаган", + "lemnos": "Лемнос", + "sierpinski": "Серпинский", + "twolakes": "Два озера", + "straitofhormuz": "Ормузский пролив", + "surrounded": "Окружение", + "didier": "Дидье", + "didierfrance": "Дидье (Франция)", + "amazonriver": "Река Амазонка" }, "map_categories": { "continental": "Континентальные", "regional": "Региональные", - "fantasy": "Прочие" + "fantasy": "Прочие", + "special": "Особые", + "arcade": "Аркадные" }, "map_component": { - "loading": "Загрузка..." + "loading": "Загрузка...", + "error": "Ошибка" }, "private_lobby": { - "title": "Присоединиться к приватному лобби", + "title": "Присоединение к приватному лобби", "enter_id": "Введите ID лобби", "player": "Игрок", "players": "Игрока(-ов)", @@ -229,42 +308,55 @@ "checking": "Проверка лобби...", "not_found": "Лобби не найдено. Пожалуйста, проверьте правильность ID и попробуйте ещё раз.", "error": "Произошла ошибка. Пожалуйста, попробуйте ещё раз или обратитесь в службу поддержки.", - "joined_waiting": "Вы успешно присоединились! Ожидание начала игры...", - "version_mismatch": "Эта игра была создана в другой версии. Невозможно присоединиться." + "joined_waiting": "Лобби подключено! Ждём, пока хост начнёт игру...", + "version_mismatch": "Эта игра была создана в другой версии. Невозможно присоединиться.", + "disabled_units": "Отключённые сооружения" }, "public_lobby": { "join": "Присоединиться к следующей игре", "waiting": "игрока(-ов) в ожидании", - "teams_Duos": "по 2 (дуо)", - "teams_Trios": "по 3 (трио)", - "teams_Quads": "по 4 (квады)", + "teams_Duos": "{team_count} команды по 2 (дуо)", + "teams_Trios": "{team_count} команды по 3 (трио)", + "teams_Quads": "{team_count} команды по 4 (квады)", + "waiting_for_players": "Ожидание игроков", + "starting_game": "Запуск игры…", "teams_hvn": "Люди против наций", + "teams_hvn_detailed": "{num} людей против {num} наций", "teams": "Команд: {num}", - "players_per_team": "по {num}" + "players_per_team": "по {num}", + "started": "Начато" }, "matchmaking_modal": { - "title": "Подбор игроков", + "title": "Рейтинговый подбор 1v1 (АЛЬФА)", "connecting": "Подключение к серверу подбора игроков...", "searching": "Поиск игры...", - "waiting_for_game": "Ожидание начала игры..." + "waiting_for_game": "Ожидание начала игры...", + "elo": "Ваш ELO: {elo}" }, "username": { "enter_username": "Введите своё имя игрока", "not_string": "Имя игрока должно быть строкой.", "too_short": "Имя игрока должно содержать не менее {min} символов.", "too_long": "Имя игрока не должно превышать {max} символов.", - "invalid_chars": "Имя игрока может содержать только латинские буквы, цифры, пробелы, подчёркивания и [квадратные скобки]." + "invalid_chars": "Имя игрока может содержать только латинские буквы, цифры, пробелы и подчёркивания.", + "tag": "ТЕГ", + "tag_too_short": "Тег клана должен состоять из 2–5 буквенно-цифровых символов.", + "tag_invalid_chars": "Тег клана может содержать только латинские буквы и цифры." }, "host_modal": { - "title": "Приватное лобби", + "title": "Создание приватного лобби", + "label": "Приватный", "mode": "Режим", "team_count": "Количество команд", + "team_type": "Тип команды", "options_title": "Настройки", "bots": "Боты: ", "bots_disabled": "Отключены", + "player_immunity_duration": "Продолжительность иммунитета в PVP (минуты)", "nations": "Нации: ", "disable_nations": "Отключить нации", "max_timer": "Продолжительность игры (минуты)", + "mins_placeholder": "Минуты", "instant_build": "Мгновенная стройка", "infinite_gold": "Неограниченное золото", "donate_gold": "Пожертвование золота", @@ -283,7 +375,11 @@ "assigned_teams": "Распределённые команды", "empty_teams": "Пустые команды", "empty_team": "Пусто", - "remove_player": "Удалить {username}" + "remove_player": "Удалить {username}", + "teams_Duos": "Дуо (команды по 2)", + "teams_Trios": "Трио (команды по 3)", + "teams_Quads": "Квады (команды по 4)", + "teams_Humans Vs Nations": "Люди против наций" }, "team_colors": { "red": "Красный", @@ -301,18 +397,22 @@ "code_license": "Код лицензирован согласно AGPL-3.0 (без гарантий)" }, "difficulty": { - "difficulty": "Сложность", - "Easy": "Расслабленная", - "Medium": "Уравновешенная", - "Hard": "Напряжённая", - "Impossible": "Невозможная" + "difficulty": "Сложность наций", + "easy": "Легко", + "medium": "Средне", + "hard": "Сложно", + "impossible": "Невозможно" }, "game_mode": { "ffa": "Каждый против каждого (FFA)", "teams": "Команды" }, + "public_game_modifier": { + "random_spawn": "Случайное появления", + "compact_map": "Компактная карта" + }, "select_lang": { - "title": "Выберите язык" + "title": "Выбор языка" }, "unit_type": { "city": "Город", @@ -320,44 +420,47 @@ "port": "Порт", "warship": "Военный корабль", "missile_silo": "Ракетная шахта", - "sam_launcher": "Пусковая установка ЗРК", - "atom_bomb": "Атомная бомба", + "sam_launcher": "ПУ ЗРК", + "atom_bomb": "Ядерная бомба", "hydrogen_bomb": "Водородная бомба", "mirv": "РГЧ ИН", "factory": "Фабрика" }, "user_setting": { - "title": "Пользовательские настройки", + "title": "Настройки", "tab_basic": "Основные настройки", "tab_keybinds": "Привязки клавиш", "dark_mode_label": "Тёмный режим", "dark_mode_desc": "Переключение внешнего вида сайта между светлой и тёмной темой", "emojis_label": "Эмодзи", - "emojis_desc": "Включение/выключение видимости эмодзи в игре", + "emojis_desc": "Переключить видимость эмодзи в игре", "alert_frame_label": "Рамка тревоги", - "alert_frame_desc": "Включить/выключить рамку тревоги. Когда включено, она будет отображаться, когда вас предают или атакуют по суше.", + "alert_frame_desc": "Переключить рамку тревоги. При включении рамка будет отображаться, когда вас предают или атакуют по суше.", "special_effects_label": "Спецэффекты", - "special_effects_desc": "Включить/выключить спецэффекты. Отключите для улучшения производительности", + "special_effects_desc": "Переключить спецэффекты. Отключите для улучшения производительности", "structure_sprites_label": "Спрайты структур", - "structure_sprites_desc": "Включение/выключение спрайтов структур", + "structure_sprites_desc": "Переключить спрайты структур", + "cursor_cost_label_label": "Цена постройки под указателем", + "cursor_cost_label_desc": "Показывать цену постройки под указателем", "anonymous_names_label": "Скрытые имена", "anonymous_names_desc": "Скрыть настоящие имена игроков и заменить их случайными.", "lobby_id_visibility_label": "Скрытые ID лобби", "lobby_id_visibility_desc": "Скрыть ID при создании приватного лобби", + "toggle_visibility": "Переключение видимости", "left_click_label": "Открытие меню левой кнопкой мыши", "left_click_desc": "ВКЛЮЧЕНО: щелчок левой кнопкой мыши открывает меню, атака совершается кнопкой с мечом. ВЫКЛЮЧЕНО: нажатие левой кнопкой мыши совершает атаку напрямую.", "left_click_menu": "Меню на левую кнопку мыши", "attack_ratio_label": "⚔️ Соотношение атаки", "attack_ratio_desc": "Какой процент ваших войск отправлять в бой (1–100%)", - "troop_ratio_desc": "Настройте соотношение между войсками (для боя) и рабочими (для добычи золота) (1–100%)", "territory_patterns_label": "🏳️ Скины территории", "territory_patterns_desc": "Выберите, показывать ли скины территорий в игре", "performance_overlay_label": "Оверлей производительности", - "performance_overlay_desc": "Включить/выключить оверлей производительности. Если включено, будет отображаться оверлей производительности. Нажмите Shift+D во время игры для включения/выключения.", + "performance_overlay_desc": "Переключить оверлей производительности. При включении будет показан оверлей производительности. Нажмите Shift+D во время игры для переключения.", "easter_writing_speed_label": "Множитель скорости печати", "easter_writing_speed_desc": "Настройте скорость, с которой вы делаете вид, что программируете (x1–x100)", "easter_bug_count_label": "Количество багов", "easter_bug_count_desc": "Количество багов, которое вы считаете приемлемым (0–1000, эмоционально)", + "press_a_key": "Нажмите клавишу", "view_options": "Настройки просмотра", "toggle_view": "Переключить представление", "toggle_view_desc": "Альтернативное представление (рельеф/страны)", @@ -374,24 +477,31 @@ "build_warship_desc": "Разместить военный корабль под указателем.", "build_missile_silo": "Разместить ракетную шахту", "build_missile_silo_desc": "Разместить ракетную шахту под указателем.", - "build_sam_launcher": "Разместить установку ЗРК", - "build_sam_launcher_desc": "Разместить установку ЗРК под указателем.", + "build_sam_launcher": "Разместить ПУ ЗРК", + "build_sam_launcher_desc": "Разместить ПУ ЗРК под указателем.", "build_atom_bomb": "Разместить ядерную бомбу", "build_atom_bomb_desc": "Разместить ядерную бомбу под указателем.", "build_hydrogen_bomb": "Разместить водородную бомбу", "build_hydrogen_bomb_desc": "Разместить водородную бомбу под указателем.", "build_mirv": "Разместить РГЧ ИН", "build_mirv_desc": "Разместить РГЧ ИН под указателем.", + "menu_shortcuts": "Горячие клавиши меню", + "build_menu_modifier": "Модификатор меню строительства", + "build_menu_modifier_desc": "Удерживайте эту клавишу при нажатии, чтобы открыть меню строительства.", + "emoji_menu_modifier": "Модификатор меню эмодзи", + "emoji_menu_modifier_desc": "Удерживайте эту клавишу при нажатии, чтобы открыть меню эмодзи.", "attack_ratio_controls": "Управление соотношением атаки", "attack_ratio_up": "Увеличить соотношение атаки", "attack_ratio_up_desc": "Увеличить соотношение атаки на 10%", "attack_ratio_down": "Уменьшить соотношение атаки", "attack_ratio_down_desc": "Уменьшить соотношение атаки на 10%", "attack_keybinds": "Привязки клавиш атаки", - "boat_attack": "Атака лодкой", + "boat_attack": "Атака судом", "boat_attack_desc": "Отправить атаку лодкой на ячейку под указателем.", "ground_attack": "Наземная атака", "ground_attack_desc": "Отправить наземную атаку на ячейку под указателем.", + "swap_direction": "Поменять направление ракеты", + "swap_direction_desc": "Переключить направление ракеты (вверх/вниз).", "zoom_controls": "Масштабирование", "zoom_out": "Отдалить", "zoom_out_desc": "Отдалить карту", @@ -412,11 +522,12 @@ "unbind": "Освободить", "on": "Включено", "off": "Выключено", - "toggle_terrain": "Включение/выключение рельефа", + "toggle_terrain": "Переключить рельеф", "exit_game_label": "Выйти из игры", "exit_game_info": "Вернуться в главное меню", "background_music_volume": "Громкость фоновой музыки", - "sound_effects_volume": "Громкость звуковых эффектов" + "sound_effects_volume": "Громкость звуковых эффектов", + "keybind_conflict_error": "Клавиша {key} уже привязана к другому действию." }, "chat": { "title": "Быстрый чат", @@ -512,7 +623,7 @@ "hydrogen_bomb": "Большой взрыв", "mirv": "Огромный взрыв, нацеленный только на выбранного игрока", "missile_silo": "Используется для запуска ракет", - "sam_launcher": "Защищает от атомных ракет", + "sam_launcher": "Защищает от ядерных ударов", "warship": "Захватывает торговые суда, уничтожает суда и лодки", "port": "Отправляет торговые корабли для генерации золота", "defense_post": "Укрепляет защиту ближайших границ", @@ -529,6 +640,7 @@ "other_team": "Команда «{team}» победила!", "you_won": "Вы победили!", "other_won": "Игрок {player} победил!", + "nation_won": "Нация {nation} победила!", "exit": "Выйти из игры", "keep": "Продолжить игру", "spectate": "Наблюдать", @@ -537,7 +649,7 @@ "ofm_winter_description": "Присоединяйтесь к турниру и состязайтесь с лучшими игроками", "join_tournament": "Присоединиться к турниру", "join_discord": "Присоединяйтесь к нашему сообществу в Discord!", - "discord_description": "Связывайтесь с другими игроками, получайте новости и делитесь стратегиями", + "discord_description": "Связывайтесь с игроками, открывайте новые возможности и выигрывайте призы!", "join_server": "Присоединиться к серверу", "youtube_tutorial": "Нужна помощь?" }, @@ -549,7 +661,7 @@ "team": "Команда", "owned": "Территории", "gold": "Золото", - "troops": "Войска", + "maxtroops": "Максимум войск", "launchers": "Установки", "sams": "ЗРК", "warships": "Военные корабли", @@ -565,6 +677,7 @@ "team": "Команда", "alliance_timeout": "Конец союза через", "troops": "Войска", + "maxtroops": "Максимум войск", "a_troops": "Войска атаки", "gold": "Золото", "ports": "Порты", @@ -575,12 +688,14 @@ "warships": "Военные корабли", "health": "Здоровье", "attitude": "Отношение", - "levels": "Уровни" + "levels": "Уровни", + "wilderness_title": "Пустошь", + "irradiated_wilderness_title": "Радиоактивная пустошь" }, "events_display": { "retreating": "отступает", "retaliate": "Напасть в ответ", - "boat": "Лодка", + "boat": "Судно", "alliance_request_status": "{name} {status} ваш запрос", "alliance_accepted": "принял", "alliance_rejected": "отклонил", @@ -600,8 +715,21 @@ "alliance_renewed": "Ваш союз с {name} был продлён", "wants_to_renew_alliance": "{name} хочет продлить ваш союз", "ignore": "Игнорировать", - "unit_voluntarily_deleted": "Объект добровольно удалён", - "betrayal_debuff_ends": "Осталось {time} сек до окончания наказания предателя" + "unit_voluntarily_deleted": "Сооружение добровольно удалено", + "betrayal_debuff_ends": "Осталось {time} сек до окончания наказания предателя", + "attack_cancelled_retreat": "Атака отменена, {troops} солдат погибло во время отступления", + "received_gold_from_captured_ship": "Получено {gold} золота с корабля, захваченного у {name}", + "received_gold_from_trade": "Получено {gold} золота от торговли с {name}", + "missile_intercepted": "{unit} перехватывает ракету", + "mirv_warheads_intercepted": "{count, plural, one {Перехвачено {count} боеголовку РГЧ ИН} few {Перехвачено {count} боеголовки РГЧ ИН} many {Перехвачено {count} боеголовок РГЧ ИН} other {Перехвачено {count} боеголовок РГЧ ИН}}", + "sent_troops_to_player": "Отправлено {troops} войск к {name}", + "received_troops_from_player": "Получено {troops} войск от {name}", + "sent_gold_to_player": "Отправлено {gold} золота для {name}", + "received_gold_from_player": "Получено {gold} золота от {name}", + "unit_captured_by_enemy": "{name} захватывает ваше сооружение «{unit}»", + "captured_enemy_unit": "Захвачено сооружение «{unit}» у {name}", + "unit_destroyed": "Ваше сооружение «{unit}» было уничтожено", + "no_boats_available": "Нет доступных судов, максимум — {max}" }, "unit_info_modal": { "structure_info": "Информация о структуре", @@ -653,7 +781,10 @@ "send_alliance": "Отправить предложение союза", "send_troops": "Отправить войска", "send_gold": "Отправить золото", - "emotes": "Эмодзи" + "emotes": "Эмодзи", + "arc_up": "Верхняя дуга", + "arc_down": "Нижняя дуга", + "flip_rocket_trajectory": "Отразить траекторию ракеты" }, "send_troops_modal": { "title_with_name": "Отправить войска игроку {name}", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "Выберите стартовое местоположение", - "random_spawn": "Случайное появление включено. Выбираем стартовое местоположение за вас..." + "random_spawn": "Случайное появление включено. Выбираем стартовое местоположение за вас...", + "singleplayer_game_paused": "Игра приостановлена", + "multiplayer_game_paused": "Игра приостановлена владельцем лобби" }, "territory_patterns": { "title": "Скины", "colors": "Цвета", "purchase": "Купить", "show_only_owned": "Мои скины", + "all_owned": "Все узоры куплены! Возвращайтесь позже за новыми товарами.", + "not_logged_in": "Вы не авторизованы", "blocked": { "login": "Вы должны войти, чтобы получить доступ к этому скину.", "purchase": "Купите этот скин, чтобы разблокировать его." }, "pattern": { "default": "По умолчанию" - } + }, + "select_skin": "Выберете узор", + "selected": "выбрано" }, "flag_input": { "title": "Выберите флаг", @@ -732,8 +869,8 @@ "contact_admin": "Если вы считаете, что видите это сообщение по ошибке, пожалуйста, свяжитесь с администратором сайта." }, "radial_menu": { - "delete_unit_title": "Удалить объект", - "delete_unit_description": "Нажмите, чтобы удалить ближайший объект" + "delete_unit_title": "Удалить сооружение", + "delete_unit_description": "Нажмите, чтобы удалить ближайшее сооружение" }, "discord_user_header": { "avatar_alt": "Аватар" @@ -743,7 +880,7 @@ "ship_arrivals": "Прибытия кораблей", "nuke_stats": "Статистика бомбардирования", "player_metrics": "Статистика игрока", - "building": "Строительство", + "building": "Сооружение", "ship_type": "Тип корабля", "weapon": "Оружие", "built": "Построено", @@ -762,19 +899,19 @@ "gold": "Золото", "workers": "Рабочие", "war": "Войны", - "trade": "Обмен", + "trade": "Торговля", "steal": "Украдено", "unit": { "city": "Город", "port": "Порт", "defp": "Укрепление", - "saml": "Пусковая установка ЗРК", + "saml": "ПУ ЗРК", "silo": "Ракетная шахта", "wshp": "Военный корабль", "fact": "Фабрика", "trade": "Торговый корабль", "trans": "Транспортный корабль", - "abomb": "Атомная бомба", + "abomb": "Ядерная бомба", "hbomb": "Водородная бомба", "mirv": "РГЧ ИН", "mirvw": "Боеголовка РГЧ ИН" @@ -786,8 +923,9 @@ "mode": "Режим", "mode_ffa": "Каждый против каждого", "mode_team": "Команда", - "view": "Осмотреть", + "replay": "Повтор", "details": "Подробности", + "ranking": "Рейтинг", "started": "Начато", "map": "Карта", "difficulty": "Сложность", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Публичный", "private": "Приватный", - "singleplayer": "Одиночная игра", + "singleplayer": "Соло", "mode": "Режим", "stats_wins": "Победы", "stats_losses": "Поражения", "stats_wlr": "Соотношение побед:поражений", "stats_games_played": "Игр сыграно", "mode_ffa": "Все против всех", - "mode_team": "Команда" + "mode_team": "Команда", + "no_stats": "Нет данных для этой выборки." + }, + "matchmaking_button": { + "play_ranked": "Рейтинговый подбор 1v1", + "description": "(АЛЬФА)", + "login_required": "Войдите, чтобы играть в рейтинговом режиме!", + "must_login": "Вы должны войти в систему, чтобы играть в рейтинговом режиме." } } diff --git a/resources/lang/tr.json b/resources/lang/tr.json index 0afa36091..940e12098 100644 --- a/resources/lang/tr.json +++ b/resources/lang/tr.json @@ -6,27 +6,54 @@ "lang_code": "tr" }, "common": { - "close": "Kapat" + "close": "Kapat", + "back": "Geri", + "available": "Mevcut", + "preset_max": "Maksimum", + "summary_send": "Gönder", + "summary_keep": "Sakla", + "cancel": "İptal Et", + "send": "Gönder", + "cap_label": "Limit", + "cap_tooltip": "Alıcının kalan kapasitesi", + "target_dead": "Hedef saf dışı kaldı", + "target_dead_note": "Saf dışı kalmış bir oyuncuya kaynak gönderemezsin.", + "none": "Hiçbiri", + "copied": "Kopyalandı!", + "click_to_copy": "Kopyalamak için tıkla" }, "main": { "title": "OpenFront (ALFA)", - "join_discord": "Discord'a katılın!", + "join_discord": "Discord", "login_discord": "Discord'la giriş yap", + "sign_in": "Oturum Aç", + "discord_avatar_alt": "Discord profil avatarı", + "user_avatar_alt": "{username}'in avatarı", "checking_login": "Giriş kontrol ediliyor...", "logged_in": "Giriş yapıldı!", "log_out": "Çıkış yap", - "create_lobby": "Lobi Oluştur", - "join_lobby": "Lobiye Katıl", - "single_player": "Tek Oyunculu", + "create": "Lobi Oluştur", + "join": "Lobiye Katıl", + "solo": "Tekli", "instructions": "Rehber", - "how_to_play": "Nasıl Oynanır", - "advertise": "Reklam Ver", + "game_info": "Oyun bilgisi", "wiki": "Wiki", "privacy_policy": "Gizlilik Politikası", - "terms_of_service": "Hizmet Şartları" + "terms_of_service": "Hizmet Şartları", + "copyright": "© OpenFront™ ve Katkıda Bulunanlar", + "reddit": "Reddit", + "play": "Oyna", + "news": "Haberler", + "store": "Mağaza", + "settings": "Seçenekler", + "keys": "Tuşlar", + "stats": "İstatistikler", + "account": "Hesap", + "help": "Yardım", + "menu": "Menü", + "pick_pattern": "Desen seç!" }, "news": { - "see_all_releases": "Tüm sürümleri gör", "github_link": "GitHub'da", "title": "Sürüm Notları" }, @@ -57,7 +84,7 @@ "ui_events_desc": "Olay paneli en son olayları, istekleri ve Hızlı Sohbet mesajlarını görüntüler. Bazı örnekler şunlardır:", "ui_events_alliance": "İttifak - İttifak istekleri kabul edilebilir veya reddedilebilir. Müttefikler kaynakları ve askerleri paylaşabilir, ancak birbirlerine saldıramazlar. Odaklan'a tıklamak görünümü isteği gönderen oyuncuya taşır.", "ui_events_attack": "Saldırılar - Gelen saldırılar ve giden saldırılarınız gösterilir. Saldırı, nükleer veya Tekne (nakliye gemisi) üzerine görünümü ortalamak için mesaja tıklayın. Kırmızı X düğmesine tıklayarak askerleri geri çekebilirsiniz. Bu, saldıran askerlerinizin %25'inin hayatına mal olur. Bir Tekne saldırısını geri alırsanız, tekne başlangıç noktasına döner ve o zamandan beri toprak ele geçirildiyse orada saldırır. Nükleerler fırlatıldıktan sonra geri alınamaz.", - "ui_events_quickchat": "Hızlı Sohbet - Gönderilen ve alınan sohbet mesajlarını burada görebilirsiniz. Bilgi menüsündeki Hızlı Sohbet simgesine tıklayarak bir oyuncuya mesaj gönderin.", + "ui_events_quickchat": "Hızlı Sohbet - Burada gönderilen ve alınan sohbet mesajlarını görebilirsin. Oyuncunun Bilgi menüsündeki Hızlı Sohbet simgesine tıklayarak oyuncuya mesaj gönderebilirsin.", "ui_options": "Seçenekler", "ui_options_desc": "İçerisinde aşağıdaki öğeler bulunabilir:", "ui_playeroverlay": "Oyuncu bilgi katmanı", @@ -73,6 +100,8 @@ "radial_attack": "Saldırı menüsünü aç.", "radial_info": "Bilgi menüsünü aç.", "radial_boat": "Seçilen konuma saldırması için bir Tekne (nakliye gemisi) gönder. Sadece suya erişiminiz varsa kullanılabilir.", + "radial_donate_troops": "Saldırı oranı kaydırma çubuğundaki yüzdeye eşdeğer sayıda askerleri, radyal menüyü açtığınız müttefikinize bağışlayın.", + "radial_donate_gold": "Müttefiklerinize hızlıca altın yollayabilmeniz için altın bağışı kaydırma menüsünü açar.", "radial_close": "Menüyü kapat.", "info_title": "Bilgi menüsü", "info_enemy_desc": "Seçilen oyuncunun adı, altını, askerleri, sizinle ticareti durdurmuş olup olmadığı, size gönderdiği nükleerler ve oyuncunun hain olup olmadığı gibi bilgileri içerir. Ticareti durdurmuş olmak, onlardan altın almayacağınız ve onlara ticaret gemileri aracılığıyla altın göndermeyeceğiniz anlamına gelir. Manuel olarak (oyuncu \"Ticareti durdur\"a tıklarsa, her ikiniz de \"Ticareti başlat\"a tıklayana kadar sürer) veya otomatik olarak (ittifakınıza ihanet ederseniz, tekrar müttefik olana kadar veya 5 dakika sonra kadar sürer). Hain, oyuncunun kendisiyle ittifak halinde olan bir oyuncuya ihanet edip saldırdığında 30 saniye boyunca Evet gösterir. Aşağıdaki simgeler şu etkileşimleri temsil eder:", @@ -104,7 +133,7 @@ "build_silo": "Füze Silosu", "build_silo_desc": "Füze fırlatmaya izin verir.", "build_sam": "SAM Fırlatıcı", - "build_sam_desc": "100 piksel menzili içindeki düşman füzelerini engelleyebilir. Atom Bombası için %100, Hidrojen Bombası için %80 ve bireysel MIRV Savaş Başlıkları için %50 isabet şansı ile. SAM'ın 7.5 saniye bekleme süresi vardır.", + "build_sam_desc": "100 piksele kadar menzildeki düşman füzelerini önler. SAM 7,5 saniye bekleme süresine sahiptir.", "build_atom": "Atom Bombası", "build_atom_desc": "Bölgeyi, binaları, gemileri ve tekneleri yok eden küçük patlayıcı bomba. En yakın Füze Silosundan doğar ve ilk inşa etmek için tıkladığınız alana düşer.", "build_hydrogen": "Hidrojen Bombası", @@ -119,20 +148,27 @@ "icon_embargo": "Üstü çizili Dolar - Ambargo. Bu oyuncu sizinle ticareti otomatik veya manuel olarak durdurmuş.", "icon_request": "Zarf - İttifak isteği. Bu oyuncu size ittifak isteği göndermiş.", "info_enemy_panel": "Düşman bilgi paneli", - "exit_confirmation": "Oyundan çıkmak istediğine emin misin?" + "exit_confirmation": "Oyundan çıkmak istediğine emin misin?", + "bomb_direction": "Atom / Hidrojen bombası yay yönü" }, "single_modal": { - "title": "Tek Oyunculu", + "title": "Tekli", + "random_spawn": "Rastgele doğma", "allow_alliances": "İttifaklara izin ver", + "toggle_achievements": "Başarımları aç/kapat", + "sign_in_for_achievements": "Başarımlar için oturum aç", "options_title": "Seçenekler", "bots": "Botlar", "bots_disabled": "Devre Dışı", + "nations": "Ülkeler: ", "disable_nations": "Ulusları Devre Dışı Bırak", "instant_build": "Anında İnşa", "infinite_gold": "Sınırsız Altın", - "donate_gold": "Altın bağışla", "infinite_troops": "Sınırsız Asker", - "donate_troops": "Asker bağışla", + "compact_map": "Sıkıştırılmış Harita", + "max_timer": "Oyun süresi (dakika)", + "max_timer_placeholder": "Dakika", + "max_timer_invalid": "Lütfen geçerli bir maksimum zamanlayıcı değeri girin (1-120 dakika)", "disable_nukes": "Nükleerleri Devre Dışı Bırak", "enables_title": "Ayarları Etkinleştir", "start": "Oyunu Başlat" @@ -144,9 +180,62 @@ }, "account_modal": { "title": "Hesap", - "logged_in_as": "{email} olarak oturum açıldı", - "logged_in_with_discord": "Discord'la giriş yapıldı", - "recovery_email_sent": "Kurtarma e-postası {email}'a gönderildi" + "connected_as": "Şu olarak bağlandı", + "stats_overview": "İstatistiklere Genel Bakış", + "link_discord": "Discord Hesabı Bağla", + "log_out": "Çıkış Yap", + "sign_in_desc": "İstatistiklerini ve ilerlemeni kaydetmek için oturum aç", + "or": "YA DA", + "email_placeholder": "E-posta adresini gir", + "get_magic_link": "Sihirli Linkini Al", + "linked_account": "{account_name} olarak giriş yapıldı", + "fetching_account": "Hesap bilgisi alınıyor...", + "recovery_email_sent": "Kurtarma e-postası {email}'a gönderildi", + "not_found": "Bulunamadı", + "clear_session": "Oturumu Temizle", + "failed_to_send_recovery_email": "Kurtarma e-postası gönderimi başarısız", + "enter_email_address": "Lütfen bir e-posta adresi giriniz" + }, + "stats_modal": { + "title": "İstatistikler", + "clan_stats": "Klan İstatistikleri", + "loading": "Yükleniyor...", + "error": "Klan istatistikleri yüklenirken hata", + "no_stats": "Klan istatistikleri mevcut değil", + "no_data_yet": "Henüz Veri Yok", + "clan": "Klan", + "games": "Oyunlar", + "win_score": "Zafer Skoru", + "win_score_tooltip": "Klan katılımı ve maç zorluğuna göre ağırlıklı kazançlar", + "loss_score": "Yenilgi Skoru", + "loss_score_tooltip": "Klan katılımı ve maç zorluğuna göre ağırlıklı kayıplar", + "win_loss_ratio": "Zafer/Yenilgi", + "ratio": "Oran", + "rank": "Sıra", + "try_again": "Tekrar Dene" + }, + "game_info_modal": { + "title": "Oyun bilgisi", + "players": "Oyuncular", + "atoms": "Atomlar", + "hydros": "Hidrojenler", + "mirv": "MIRV", + "bombs": "Bombalar", + "total_gold": "Toplam", + "all_gold": "Tüm altın", + "trade": "Ticaret", + "conquest_gold": "Fethedilen oyuncu altını", + "stolen_gold": "Savaş gemileriyle çalınan", + "num_of_conquests": "Fethedilen oyuncu sayısı", + "duration": "Süre", + "survival_time": "Hayatta kalma süresi", + "war": "Savaş", + "economy": "Ekonomi", + "conquests": "Fetihler", + "pirate": "Korsan", + "conquered": "Fethedildi", + "loading_game_info": "Oyun verileri yükleniyor", + "no_winner": "Bu oyun kazanan olmadan bitti (ya da bir Ülke kazanmadan)" }, "map": { "map": "Harita", @@ -161,6 +250,7 @@ "asia": "Asya", "mars": "Mars", "southamerica": "Güney Amerika", + "britanniaclassic": "Britanya (Klasik)", "britannia": "Britanya", "gatewaytotheatlantic": "Atlantik'e Açılan Kapı", "australia": "Avustralya", @@ -177,16 +267,37 @@ "halkidiki": "Halkidiki", "straitofgibraltar": "Cebelitarık Boğazı", "italia": "İtalya", + "japan": "Japonya", "yenisei": "Yenisey", - "pluto": "Plüto" + "pluto": "Plüto", + "montreal": "Montreal", + "newyorkcity": "New York Şehri", + "achiran": "Achiran", + "baikalnukewars": "Baykal (Nükleer Savaşlar)", + "fourislands": "Dört Adalar", + "gulfofstlawrence": "St. Lawrence Körfezi", + "lisbon": "Lizbon", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "sierpinski": "Sierpinski", + "twolakes": "İki Nehir", + "straitofhormuz": "Hürmüz Boğazı", + "surrounded": "Etrafı Çevrili", + "didier": "Didier", + "didierfrance": "Didier (Fransa)", + "amazonriver": "Amazon Nehri" }, "map_categories": { "continental": "Kıtasal", "regional": "Bölgesel", - "fantasy": "Diğer" + "fantasy": "Diğer", + "special": "Özel", + "arcade": "Arcade" }, "map_component": { - "loading": "Yükleniyor..." + "loading": "Yükleniyor...", + "error": "Hata" }, "private_lobby": { "title": "Özel Lobiye Katıl", @@ -196,43 +307,79 @@ "join_lobby": "Lobiye katıl", "checking": "Lobi kontrol ediliyor...", "not_found": "Lobi bulunamadı. Lütfen ID'yi kontrol edip tekrar deneyin.", - "error": "Bir hata oluştu. Lütfen tekrar deneyin.", - "joined_waiting": "Başarıyla katıldınız! Oyunun başlaması bekleniyor..." + "error": "Bir hata oluştu. Lütfen tekrar deneyin ya da destek ile iletişime geçin.", + "joined_waiting": "Lobiye katıldınız! Oda sahibinin başlatması bekleniyor...", + "version_mismatch": "Bu lobi oyunun başka bir sürümü ile oluşturuldu. Katılamazsın.", + "disabled_units": "Devre Dışı Bırakılmış Birimler" }, "public_lobby": { "join": "Sıradaki Oyuna Katıl", "waiting": "oyuncu bekliyor", - "teams_Duos": "İkili (2'li takımlar)", - "teams_Trios": "Üçlü (3'lü takımlar)", - "teams_Quads": "Dörtlü (4'lü takımlar)", - "teams": "{num} takımlar" + "teams_Duos": "{team_count} adet 2 kişilik takımlar (İkili)", + "teams_Trios": "{team_count} adet 3 kişilik takımlar (Üçlü)", + "teams_Quads": "{team_count} adet 4 kişilik takımlar (Dörtlü)", + "waiting_for_players": "Oyuncular bekleniyor", + "starting_game": "Oyun başlatılıyor…", + "teams_hvn": "İnsanlar vs Ülkeler", + "teams_hvn_detailed": "{num} İnsan vs {num} Ülke", + "teams": "{num} takımlar", + "players_per_team": "{num}", + "started": "Başladı" + }, + "matchmaking_modal": { + "title": "1v1 Aşamalı Eşleştirme (ALFA)", + "connecting": "Maç bulma sunucusuna bağlanılıyor...", + "searching": "Oyun aranıyor...", + "waiting_for_game": "Oyunun başlaması bekleniyor...", + "elo": "Senin ELO'n: {elo}" }, "username": { "enter_username": "Kullanıcı adınızı girin", "not_string": "Kullanıcı adı bir metin olmalıdır.", "too_short": "Kullanıcı adı en az {min} karakter uzunluğunda olmalıdır.", "too_long": "Kullanıcı adı {max} karakteri geçmemelidir.", - "invalid_chars": "Kullanıcı adı yalnızca harf, rakam, boşluk, alt çizgi ve [köşeli parantez] içerebilir." + "invalid_chars": "Kullanıcı adı sadece harfler, sayılar, boşluklar ve alt çizgi içerebilir.", + "tag": "ETİKET", + "tag_too_short": "Klan etiketi 2-5 harf arasında olmalı.", + "tag_invalid_chars": "Klan etiketi sadece harf ve sayı içerebilir." }, "host_modal": { - "title": "Özel Lobi", + "title": "Özel Lobi Oluştur", + "label": "Özel", "mode": "Mod", "team_count": "Takım Sayısı", + "team_type": "Takım Türü", "options_title": "Seçenekler", "bots": "Botları:", "bots_disabled": "Devre Dışı", + "player_immunity_duration": "PVP bağışıklık süresi (dakika)", + "nations": "Ülkeler: ", "disable_nations": "Ulusları Devre Dışı Bırak", + "max_timer": "Oyun süresi (dakika)", + "mins_placeholder": "Dakika", "instant_build": "Anında İnşa", "infinite_gold": "Sınırsız Altın", "donate_gold": "Altın bağışla", "infinite_troops": "Sınırsız Asker", "donate_troops": "Asker bağışla", + "compact_map": "Sıkıştırılmış Harita", "enables_title": "Ayarları Etkinleştir", "player": "Oyuncu", "players": "Oyuncular", + "nation_players": "Ülkeler", + "nation_player": "Millet", "waiting": "Oyuncular bekleniyor...", + "random_spawn": "Rastgele doğma", "start": "Oyunu Başlat", - "host_badge": "Host" + "host_badge": "Host", + "assigned_teams": "Atanmış Takımlar", + "empty_teams": "Boş Takımlar", + "empty_team": "Boş", + "remove_player": "{username}'i Kaldır", + "teams_Duos": "İkili (2 kişilik takımlar)", + "teams_Trios": "Üçlü (3 kişilik takımlar)", + "teams_Quads": "Dörtlü (4 kişilik takımlar)", + "teams_Humans Vs Nations": "İnsanlar vs Ülkeler" }, "team_colors": { "red": "Kırmızı", @@ -246,19 +393,24 @@ }, "game_starting_modal": { "title": "Oyun Başlıyor...", - "desc": "Oyun başlamak üzere hazırlanıyor. Lütfen bekleyin." + "credits": "Atıflar", + "code_license": "Kod AGPL-3.0 altında lisanslanmıştır (garanti yok)" }, "difficulty": { - "difficulty": "Zorluk", - "Easy": "Rahat", - "Medium": "Dengeli", - "Hard": "Yoğun", - "Impossible": "İmkansız" + "difficulty": "Ülke Zorluğu", + "easy": "Kolay", + "medium": "Orta", + "hard": "Zor", + "impossible": "İmkansız" }, "game_mode": { "ffa": "Herkes Tek", "teams": "Takımlar" }, + "public_game_modifier": { + "random_spawn": "Rastgele Doğma", + "compact_map": "Sıkıştırılmış Harita" + }, "select_lang": { "title": "Dil seç" }, @@ -275,52 +427,69 @@ "factory": "Fabrika" }, "user_setting": { - "title": "Kullanıcı Ayarları", + "title": "Seçenekler", "tab_basic": "Temel Ayarlar", "tab_keybinds": "Kısayollar", "dark_mode_label": "Karanlık Tema", "dark_mode_desc": "Sitenin görünümünü açık ve koyu tema arasında değiştir", - "dark_mode_enabled": "Karanlık tema etkinleştirildi", - "light_mode_enabled": "Açık tema etkinleştirildi", "emojis_label": "Emojiler", - "emojis_visible": "Emojiler görünüyor", - "emojis_hidden": "Emojiler gizleniyor", "emojis_desc": "Emojilerin oyunda gösterilip gösterilmeyeceğini değiştir", "alert_frame_label": "Uyarı Çerçevesi", - "alert_frame_desc": "Uyarı çerçevesini açın/kapatın. Etkinleştirildiğinde, ihanete uğradığınızda çerçeve gözükür.", + "alert_frame_desc": "Uyarı çerçevesini etkinleştir. Etkinleştirildiğinde, ihanete uğradığınızda veya kara saldırısına uğradığınızda çerçeve gözükür.", "special_effects_label": "Özel efektler", "special_effects_desc": "Özel efektleri aç/kapat. Performansı artırmak için devre dışı bırakın", - "special_effects_enabled": "Özel efektler açık", - "special_effects_disabled": "Özel efektler kapalı", "structure_sprites_label": "Yapı Simgeleri", "structure_sprites_desc": "Yapı simgelerini aç/kapat", - "structure_sprites_enabled": "Yapı Simgeleri etkinleştirildi", - "structure_sprites_disabled": "Yapı Simgeleri devre dışı bırakıldı", + "cursor_cost_label_label": "İmleç Yapım Maliyeti", + "cursor_cost_label_desc": "Oluşturma imleci simgesinin altında bir maliyet hapı göster", "anonymous_names_label": "Adları Gizle", "anonymous_names_desc": "Gerçek oyuncu isimlerini ekranında rastgele isimlerle gizle.", - "anonymous_names_enabled": "Adları gizleme açık", "lobby_id_visibility_label": "Gizli Lobi Kimlikleri", "lobby_id_visibility_desc": "Özel lobi oluştururken lobi kimliğini gizle", - "real_names_shown": "Gerçek adlar görünüyor", + "toggle_visibility": "Görünürlüğü Aç/Kapat", "left_click_label": "Menüyü Açmak için Sol Tık", "left_click_desc": "AÇIK olduğunda, sol tıklama menüyü açar ve kılıç düğmesi saldırır. KAPALI olduğunda, sol tıklama doğrudan saldırır.", "left_click_menu": "Sol Tık Menüsü", - "left_click_opens_menu": "Sol tık menüyü açar", - "right_click_opens_menu": "Sağ tık menüyü açar", "attack_ratio_label": "⚔️ Saldırı Oranı", "attack_ratio_desc": "Bir saldırıda birliklerinin yüzde kaçını göndereceksin (%1-100)", - "troop_ratio_desc": "Askerler (savaş için) ve işçiler (altın üretimi için) arasındaki dengeyi ayarlayın (%1–100)", - "territory_patterns_label": "🏳️ Bölge Desenleri", - "territory_patterns_desc": "Oyunda bölge desenlerinin gösterilip gösterilmeyeceğini seç", + "territory_patterns_label": "🏳️Bölge Desenleri", + "territory_patterns_desc": "Bölge desenlerinin oyunda gösterip gösterilmeyeceğini seç", "performance_overlay_label": "Performans Katmanı", "performance_overlay_desc": "Performans katmanını açın veya kapatın. Etkinleştirildiğinde, performans katmanı görüntülenir. Oyun sırasında Shift-D tuşlarına basarak açıp kapatabilirsiniz.", "easter_writing_speed_label": "Yazma Hızı Çarpanı", "easter_writing_speed_desc": "Kod yazıyormuş gibi yapma hızınızı ayarlayın (x1–x100)", "easter_bug_count_label": "Hata Sayısı", "easter_bug_count_desc": "Kabul edebileceğiniz hata sayısı (0–1000, duygusal olarak)", + "press_a_key": "Bir tuşa bas", "view_options": "Görüntü Seçenekleri", "toggle_view": "Görüntüyü Değiştir", "toggle_view_desc": "Alternatif görünüm (arazi/ülkeler)", + "build_controls": "İnşaat Kontrolleri", + "build_city": "Şehir İnşa Et", + "build_city_desc": "İmlecinin altına bir Şehir inşa et.", + "build_factory": "Fabrika İnşa Et", + "build_factory_desc": "İmlecinin altına bir Fabrika inşa et.", + "build_defense_post": "Bir Savunma Karakolu İnşa Et", + "build_defense_post_desc": "İmlecinin altına bir Savunma Karakolu kur.", + "build_port": "Liman İnşa Et", + "build_port_desc": "İmlecinin altına bir Liman inşa et.", + "build_warship": "Savaş Gemisi İnşa Et", + "build_warship_desc": "İmlecinin altına bir Savaş Gemisi inşa et.", + "build_missile_silo": "Füze Silosu İnşa Et", + "build_missile_silo_desc": "İmlecinin altına bir Füze silosu inşa et.", + "build_sam_launcher": "SAM Fırlatıcı İnşa Et", + "build_sam_launcher_desc": "İmlecinin altına bir SAM Fırlatıcı inşa et.", + "build_atom_bomb": "Atom Bombası İnşa Et", + "build_atom_bomb_desc": "İmlecinin altına bir Atom Bombası inşa et.", + "build_hydrogen_bomb": "Hidrojen Bombası İnşa Et", + "build_hydrogen_bomb_desc": "İmlecinin altına bir Hidrojen Bombası inşa et.", + "build_mirv": "MIRV İnşa Et", + "build_mirv_desc": "İmlecinin altına MIRV inşa et.", + "menu_shortcuts": "Menü Kısayolları", + "build_menu_modifier": "Yapı Menüsü Değiştiricisi", + "build_menu_modifier_desc": "Yapı menüsünü açmak için tıklarken bu tuşa basılı tut.", + "emoji_menu_modifier": "Emoji Menüsü Değiştiricisi", + "emoji_menu_modifier_desc": "Emoji menüsünü açmak için tıklarken bu tuşa basılı tut.", "attack_ratio_controls": "Saldırı Oranı Kontrolleri", "attack_ratio_up": "Saldırı Oranını Artır", "attack_ratio_up_desc": "Saldırı oranını %10 artır", @@ -331,6 +500,8 @@ "boat_attack_desc": "İmlecinizin altındaki kareye tekne saldırısı gönder.", "ground_attack": "Kara Saldırısı", "ground_attack_desc": "İmlecinin altındaki kareye kara saldırısı gönderir.", + "swap_direction": "Roket Yönünü Değiştir", + "swap_direction_desc": "Roket fırlatma yönünü aç/kapat (yukarı/aşağı).", "zoom_controls": "Yakınlaştırma Kontrolleri", "zoom_out": "Uzaklaştır", "zoom_out_desc": "Haritayı uzaklaştır", @@ -352,10 +523,11 @@ "on": "Açık", "off": "Kapalı", "toggle_terrain": "Araziyi Göster", - "terrain_enabled": "Arazi görünümü etkinleştirildi", - "terrain_disabled": "Arazi görünümü kapatıldı", "exit_game_label": "Oyundan Çık", - "exit_game_info": "Ana menüye dön" + "exit_game_info": "Ana menüye dön", + "background_music_volume": "Arkaplan Müziği Sesi", + "sound_effects_volume": "Ses Efektleri Sesi", + "keybind_conflict_error": "{key} tuşu zaten başka bir eyleme atanmış." }, "chat": { "title": "Hızlı Sohbet", @@ -462,15 +634,24 @@ }, "win_modal": { "support_openfront": "OpenFront'u Destekle!", - "territory_pattern": "OpenFront'u desteklemek için bölge deseni satın al!", + "territory_pattern": "Bir toprak kaplaması alarak reklamlardan kurtul!", "died": "Öldün", "your_team": "Takımınız kazandı!", "other_team": "{team} takımı kazandı!", "you_won": "Kazandın!", "other_won": "{player} kazandı!", + "nation_won": "{nation} ülkesi kazandı!", "exit": "Oyundan Çık", "keep": "Oynamaya Devam Et", - "wishlist": "Steam'de İstek Listesine Ekle!" + "spectate": "İzle", + "wishlist": "Steam'de İstek Listesine Ekle!", + "ofm_winter": "OpenFront Masters Kış Turnuvası!", + "ofm_winter_description": "Rekabetçi turnuvaya katıl ve en iyi oyunculara karşı mücadele et", + "join_tournament": "Turnuvaya Katıl", + "join_discord": "Discord Topluluğumuza Katıl!", + "discord_description": "Oyuncularla bağlantı kurun, yeni özellikleri keşfedin ve ödüller kazanın!", + "join_server": "Sunucuya Katıl", + "youtube_tutorial": "Yardım lazım mı?" }, "leaderboard": { "title": "Lider Tablosu", @@ -480,7 +661,7 @@ "team": "Takım", "owned": "Sahip Olunan", "gold": "Altın", - "troops": "Askerler", + "maxtroops": "Maks birlikler", "launchers": "Fırlatıcılar", "sams": "SAM'ler", "warships": "Savaş Gemileri", @@ -494,7 +675,9 @@ "nation": "Ulus", "player": "Oyuncu", "team": "Takım", - "d_troops": "Savunma birliği", + "alliance_timeout": "İttifak şu sürede bitiyor", + "troops": "Birlikler", + "maxtroops": "Maks birlikler", "a_troops": "Saldırı birliği", "gold": "Altın", "ports": "Limanlar", @@ -505,10 +688,13 @@ "warships": "Savaş gemileri", "health": "Sağlık", "attitude": "Tutum", - "levels": "Seviyeler" + "levels": "Seviyeler", + "wilderness_title": "Vahşi Doğa", + "irradiated_wilderness_title": "Radyoaktif Vahşi Doğa" }, "events_display": { "retreating": "geri çekiliyor", + "retaliate": "Karşılık Ver", "boat": "Tekne", "alliance_request_status": "{name} {status} ittifak isteğiniz", "alliance_accepted": "kabul edildi", @@ -527,8 +713,23 @@ "accept_alliance": "Kabul et", "reject_alliance": "Reddet", "alliance_renewed": "{name} ile ittifakın yenilendi", + "wants_to_renew_alliance": "{name} ittifakı yenilemek istiyor", "ignore": "Yoksay", - "unit_voluntarily_deleted": "Birim gönüllü olarak silindi" + "unit_voluntarily_deleted": "Birim gönüllü olarak silindi", + "betrayal_debuff_ends": "İhanet zayıflatmasının bitmesine {time} saniye kaldı", + "attack_cancelled_retreat": "Saldırı iptal edildi, {troops} asker geri çekilme sırasında öldürüldü", + "received_gold_from_captured_ship": "{name}'den ele geçirilen gemiden {gold} altın alındı", + "received_gold_from_trade": "{name} ile yapılan ticaretten {gold} altın alındı", + "missile_intercepted": "{unit} füzesi önlendi", + "mirv_warheads_intercepted": "{count, plural,one {{count} MIRV başlığı önlendi} other {{count} MIRV başlığı önlendi}}", + "sent_troops_to_player": "{name}'e {troops} birlik gönderildi", + "received_troops_from_player": "{name}'den {troops} birlik alındı", + "sent_gold_to_player": "{name}'e {gold} altın gönderildi", + "received_gold_from_player": "{name}'den {gold} altın alındı", + "unit_captured_by_enemy": "{unit} {name} tarafından ele geçirildi", + "captured_enemy_unit": "{name}'den {unit} ele geçirildi", + "unit_destroyed": "{unit} yok edildi", + "no_boats_available": "Bot mevcut değil, maks {max}" }, "unit_info_modal": { "structure_info": "Bina Bilgisi", @@ -539,6 +740,11 @@ "upgrade": "Yükselt", "level": "Seviye" }, + "player_type": { + "player": "Oyuncu", + "nation": "Ulus", + "bot": "Bot" + }, "relation": { "hostile": "Düşman", "distrustful": "Güvensiz", @@ -554,23 +760,50 @@ "player_panel": { "gold": "Altın", "troops": "Birlik", - "betrayals": "İhanet sayısı", + "betrayals": "İhanetler", "traitor": "Hain", + "trading": "Ticaret", + "active": "Aktif", + "stopped": "Durduruldu", "alliance_time_remaining": "İttifak Şu Süre Sonra Bitecek", "embargo": "Sizinle ticareti durdurdu", "nuke": "Onlar tarafından size gönderilen nükleerler", - "start_trade": "Ticareti başlat", - "stop_trade": "Ticareti durdur", - "yes": "Evet", - "no": "Hayır", - "none": "Hiçbiri", + "start_trade": "Ticarete Başla", + "stop_trade": "Ticareti Durdur", + "stop_trade_all": "Herkesle Ticareti Durdur", + "start_trade_all": "Herkesle Ticareti Başlat", "alliances": "İttifaklar", - "flag": "Bayrak" + "flag": "Bayrak", + "chat": "Sohbet", + "target": "Hedef", + "break_alliance": "İttifakı Boz", + "alliance": "İttifak", + "send_alliance": "İttifaklık İsteği Gönder", + "send_troops": "Birlik Gönder", + "send_gold": "Altın Gönder", + "emotes": "Emojiler", + "arc_up": "Yukarı yay", + "arc_down": "Aşağı yay", + "flip_rocket_trajectory": "Roket yörüngesini çevir" + }, + "send_troops_modal": { + "title_with_name": "{name}'e Birlik Gönder", + "available_tooltip": "Şu anda mevcut olan birliklerin", + "min_keep": "Minimum Elinde Tutma", + "slider_tooltip": "%{{percent}} •{{amount}}", + "aria_slider": "Birlik çubuğu", + "capacity_note": "Alıcı ancak {{amount}} kadar alabilir." + }, + "send_gold_modal": { + "title_with_name": "{name}'e Altın Gönder", + "available_tooltip": "Mevcut altının", + "aria_slider": "Miktar çubuğu", + "slider_tooltip": "%{{percent}} • {{amount}}" }, "replay_panel": { "replay_speed": "Tekrar oynatma hızı", "game_speed": "Oyun hızı", - "fastest_game_speed": "maks" + "fastest_game_speed": "Maks" }, "error_modal": { "crashed": "Oyun çöktü!", @@ -579,53 +812,47 @@ "copy_clipboard": "Panoya kopyala", "copied": "Kopyalandı!", "failed_copy": "Kopyalama başarısız", + "spawn_failed": { + "title": "Doğma başarısız", + "description": "Otomatik doğma seçimi başarısız. Bu oyunu oynayamazsın." + }, "desync_notice": "Diğer oyuncularla senkronizasyonunuz bozuldu. Gördükleriniz diğer oyunculardan farklı olabilir." }, + "performance_overlay": { + "reset": "Sıfırla", + "copy_json_title": "Mevcut performans metriklerini JSON olarak kopyala", + "copy_clipboard": "JSON'ı kopyala", + "copied": "Kopyalandı!", + "failed_copy": "Kopyalama başarısız", + "fps": "FPS:", + "avg_60s": "Ortalama (60s):", + "frame": "Kare:", + "tick_exec": "Tik Yürütme:", + "tick_delay": "Tik Gecikmesi:", + "layers_header": "Katmanlar (ort / maks, toplam vakte göre sıralanmış):" + }, "heads_up_message": { - "choose_spawn": "Başlangıç konumu seçin" + "choose_spawn": "Başlangıç konumu seçin", + "random_spawn": "Rastgele doğma aktif. Başlangıç noktası senin için seçiliyor...", + "singleplayer_game_paused": "Oyun durduruldu", + "multiplayer_game_paused": "Oyun Lobi Sahibi tarafından durduruldu" }, "territory_patterns": { - "title": "Bölge Deseni Seç", + "title": "Kaplamalar", + "colors": "Renkler", "purchase": "Satın al", + "show_only_owned": "Kaplamalarım", + "all_owned": "Bütün kaplamalara sahipsin! Yeni eşyalar için sonra tekrar kontrol et.", + "not_logged_in": "Giriş yapılmadı", "blocked": { - "login": "Bu desene erişmek için oturum açmanız gerekir.", - "purchase": "Bu deseni satın alarak kilidini açın." + "login": "Bu kaplamaya erişmek için giriş yapmış olman lazım.", + "purchase": "Bu kaplamayı açmak için satın al." }, "pattern": { - "default": "Varsayılan", - "custom": "Özel", - "stripes_v": "Dikey", - "stripes_h": "Yatay", - "horizontal_stripes": "Yatay (Alt)", - "vertical_bars": "Dikey (Alt)", - "checkerboard": "Dama tahtası", - "choco": "Çiko", - "diagonal": "Çapraz", - "cross": "Çarpı", - "mini_cross": "Mini Çarpı", - "sword": "Kılıç", - "sparse_dots": "Seyrek Noktalar", - "evan": "Evan", - "diagonal_stripe": "Çapraz Çizgi", - "mountain_ridge": "Dağ Sırtı", - "scattered_dots": "Dağınık Noktalar", - "circuit_board": "Devre Kartı", - "shells": "Kabuklar", - "-w-": ".w.", - "white_rabbit": "Beyaz Tavşan", - "goat": "Keçi", - "cats": "Kediler", - "cursor": "İmleç", - "hand": "El", - "radiation": "Radyasyon", - "openfront_qr": "OpenFront.io QR Kodu", - "openfront": "OpenFront", - "t_rex": "T-Rex", - "embelem": "Amblem", - "contributor": "Katkıda Bulunan", - "grogu_head": "Grogu Başı", - "grogu": "Grogu" - } + "default": "Varsayılan" + }, + "select_skin": "Kaplama Seç", + "selected": "seçildi" }, "flag_input": { "title": "Bayrak Seç", @@ -644,5 +871,83 @@ "radial_menu": { "delete_unit_title": "Birimi Sil", "delete_unit_description": "En yakın birimi silmek için tıklayın" + }, + "discord_user_header": { + "avatar_alt": "Profil Resmi" + }, + "player_stats_table": { + "building_stats": "Yapı İstatistikleri", + "ship_arrivals": "Gelen Gemiler", + "nuke_stats": "Nükleer İstatistikleri", + "player_metrics": "Oyuncu İstatistikleri", + "building": "Yapı", + "ship_type": "Gemi Türü", + "weapon": "Silah", + "built": "İnşa Edildi", + "destroyed": "Yok Edildi", + "captured": "Ele Geçirildi", + "lost": "Kaybedildi", + "hits": "Vuruşlar", + "launched": "Fırlatıldı", + "landed": "İniş Yaptı", + "sent": "Gönderildi", + "arrived": "Ulaştı", + "attack": "Saldır", + "received": "Alındı", + "cancelled": "İptal Edildi", + "count": "Miktar", + "gold": "Altın", + "workers": "İşçiler", + "war": "Savaş", + "trade": "Ticaret", + "steal": "Çal", + "unit": { + "city": "Şehir", + "port": "Liman", + "defp": "Savunma Karakolu", + "saml": "SAM Fırlatıcı", + "silo": "Füze Silosu", + "wshp": "Savaş Gemisi", + "fact": "Fabrika", + "trade": "Ticaret Gemisi", + "trans": "Nakliye Gemisi", + "abomb": "Atom Bombası", + "hbomb": "Hidrojen Bombası", + "mirv": "MIRV", + "mirvw": "MIRV Savaş Başlığı" + } + }, + "game_list": { + "recent_games": "Son Oyunlar", + "game_id": "Oyun ID'si", + "mode": "Mod", + "mode_ffa": "Herkes Tek", + "mode_team": "Takım", + "replay": "Tekrar", + "details": "Detaylar", + "ranking": "Sıralama", + "started": "Başladı", + "map": "Harita", + "difficulty": "Zorluk", + "type": "Tür" + }, + "player_stats_tree": { + "public": "Herkese Açık", + "private": "Özel", + "singleplayer": "Tekli", + "mode": "Mod", + "stats_wins": "Galibiyetler", + "stats_losses": "Yenilgiler", + "stats_wlr": "Kazanma:Kaybetme Oranı", + "stats_games_played": "Oynanan Oyunlar", + "mode_ffa": "Herkes Tek", + "mode_team": "Takım", + "no_stats": "Bu seçenek için veri kaydedilmedi." + }, + "matchmaking_button": { + "play_ranked": "1v1 Aşamalı Eşleştirme", + "description": "(ALFA)", + "login_required": "Aşamalı oynamak için giriş yap!", + "must_login": "Aşamalı eşleştirme oynamak için giriş yapmanız gerek." } } diff --git a/resources/lang/uk.json b/resources/lang/uk.json index 2c11971d3..80e797bac 100644 --- a/resources/lang/uk.json +++ b/resources/lang/uk.json @@ -7,6 +7,7 @@ }, "common": { "close": "Закрити", + "back": "Назад", "available": "Доступно", "preset_max": "Максимум", "summary_send": "Переказ", @@ -17,32 +18,48 @@ "cap_tooltip": "Залишкова місткість отримувача", "target_dead": "Ціль знищено", "target_dead_note": "Неможливо надіслати ресурси полеглому гравцю.", - "none": "Немає" + "none": "Немає", + "copied": "Скопійовано!", + "click_to_copy": "Натисніть, щоб скопіювати" }, "main": { "title": "OpenFront (АЛЬФА)", "join_discord": "Discord", "login_discord": "Увійти з Discord", + "sign_in": "Увійти", + "discord_avatar_alt": "Аватар профілю Discord", + "user_avatar_alt": "Аватар {username}", "checking_login": "Перевірка входу...", - "logged_in": "Вхід виконано!", + "logged_in": "Вхід здійснено!", "log_out": "Вийти", - "create_lobby": "Створити лобі", - "join_lobby": "Приєднатися до лобі", - "single_player": "Гра наодинці", + "create": "Створити лобі", + "join": "Приєднатися до лобі", + "solo": "Соло", "instructions": "Інструкції", + "game_info": "Інформація про гру", "wiki": "Вікі", "privacy_policy": "Політика конфіденційності", "terms_of_service": "Умови користування", - "reddit": "Reddit" + "copyright": "© OpenFront™ і співавтори", + "reddit": "Reddit", + "play": "Грати", + "news": "Новини", + "store": "Крамниця", + "settings": "Налаштування", + "keys": "Клавіші", + "stats": "Статистика", + "account": "Акаунт", + "help": "Допомога", + "menu": "Меню", + "pick_pattern": "Оберіть візерунок!" }, "news": { - "see_all_releases": "Переглянути всі випуски", "github_link": "на GitHub", "title": "Список змін" }, "help_modal": { "hotkeys": "Гарячі клавіші", - "table_key": "Клавіш", + "table_key": "Клавіша", "table_action": "Дія", "action_alt_view": "Альтернативний вигляд (рельєф/країни)", "action_attack_altclick": "Атака (коли лівий клац призначено на відкриття меню)", @@ -60,7 +77,7 @@ "ui_leaderboard_desc": "Показує найкращих гравців гри та їхні імена, % підконтрольних територій, кількість золота та військ. За допомогою кнопки «Показати все» ви можете переглянути всіх гравців у грі. Якщо ви не бажаєте бачити таблицю лідерів, натисніть «Приховати».", "ui_control": "Панель керування", "ui_control_desc": "Панель керування містить наступні елементи:", - "ui_pop": "Населення — Кількість ваших підрозділів, максимальне населення та темп його приросту.", + "ui_pop": "Населення — Кількість ваших підрозділів, ліміт населення та темп його приросту.", "ui_gold": "Золото — Обсяг вашого золота та швидкість, з якою ви отримуєте його.", "ui_attack_ratio": "Коефіцієнт атаки — Кількість військ, що беруть участь в атаці. Ви можете налаштувати коефіцієнт атаки за допомогою повзунка. Якщо наступальних військ більше ніж оборонних, то буде зменшено втрати під час атаки, а якщо менше — буде збільшено шкоду, що буде завдано вашим наступальним військам. Ефективність не збільшується після коефіцієнту 2:1.", "ui_events": "Панель подій", @@ -76,13 +93,15 @@ "option_pause": "Призупинити/Продовжити гру — Доступно лише в режимі гри наодинці.", "option_timer": "Таймер — Час, що минув із початку гри.", "option_exit": "Кнопка виходу.", - "option_settings": "Налаштування — Відкрити меню налаштувань. У ньому можна увімкнути/вимкнути режим альтернативного вигляду, емоджі, темний режим, нінджя (режим прихованих/випадкових імен) та виконання дії при клацанні лівою кнопкою миші.", + "option_settings": "Налаштування — Відкрити меню налаштувань. У ньому можна перемкнути режим альтернативного вигляду, емоджі, темний режим, нінджя (режим прихованих/випадкових імен) та виконання дії при клацанні лівою кнопкою миші.", "radial_title": "Кругове меню", "radial_desc": "Правий клац (або дотик на мобільних пристроях) відкриває кругове меню. Клацніть правою кнопкою миші поза ним, щоб закрити його. У меню ви можете:", "radial_build": "Відкрити меню будівництва.", "radial_attack": "Відкрити меню атаки.", "radial_info": "Відкрити меню інформації.", "radial_boat": "Відправити човен (транспортний корабель) атакувати вибране розташування. Доступно лише якщо ви маєте доступ до води.", + "radial_donate_troops": "Пожертвувати кількість військ, що дорівнює коефіцієнту повзунку атаки тому союзнику, на якому ви відкрили кругове меню.", + "radial_donate_gold": "Відкриває меню повзунка пожертвування золота для швидкого надсилання золота союзникам.", "radial_close": "Закрити меню.", "info_title": "Меню інформації", "info_enemy_desc": "Містить таку інформацію про вибраного гравця, як його імʼя, кількість золота, військ, стан торгувілі з вами, кількість запущених на вас ракет і мітку зрадника. Припинення торгівля означає, що ви не отримуватиме золото від гравця, а він не надсилатиме вам золото торговельними кораблями. Свідомо (якщо гравець натиснув «Припинити торгівлю», що триває, поки ви обидва не натиснете «Розпочати торгівлю») або автоматично (якщо ви зрадили союз, що триває, поки ви знову не станете союзниками або через 5 хвилин). Поле «Зрадник» показує стан «Так» протягом 30 секунд після того, як гравець зрадив й атакував гравця, який перебував у союзні з ним. Значки нижче позначають такі взаємодії:", @@ -102,7 +121,7 @@ "build_icon": "Значок", "build_desc": "Опис", "build_city": "Місто", - "build_city_desc": "Збільшує вашу максимальну кількість населення. Корисно, коли ви не можете розширити територію або населення сягає ліміту.", + "build_city_desc": "Збільшує ваш ліміт населення. Корисно, коли ви не можете розширити територію або населення незабаром досягне ліміту.", "build_factory": "Фабрика", "build_factory_desc": "Автоматично прокладає залізничні колії до найближчих міст, портів та інших фабрик. Також може обʼєднуватися з дружніми сусідніми країнами. Поїзди зʼявляються регулярно і дають сталу кількість золота за кожну будівлю, яку проїжджають на шляху, із бонусом за відвідування будівель сусідніх країн.", "build_defense": "Пункт оборони", @@ -114,7 +133,7 @@ "build_silo": "Ракетна шахта", "build_silo_desc": "Дає можливість запускати ракети.", "build_sam": "ПУ ЗРК", - "build_sam_desc": "Дозволяє перехоплювати ворожі ракети в радіусі 100 пікселів. Має 100% шанс на збиття атомної бомби, 80% — водневої бомби та 50% — окремих боєголовок РГЧ ІН. ЗРК має період перезаряджання в 7,5 секунд.", + "build_sam_desc": "Може перехоплювати ворожі ракети в радіусі 100 пікселів. ЗРК має період перезаряджання в 7,5 секунд.", "build_atom": "Атомна бомба", "build_atom_desc": "Невелика вибухова бомба, яка руйнує територію, будівлі, кораблі та човни. Запускається з найближчої ракетної шахти та вражає область, вибрану клацанням кнопкою миші.", "build_hydrogen": "Воднева бомба", @@ -129,12 +148,15 @@ "icon_embargo": "Закреслений знак долара — Ембарго. Цей гравець припинив торгівлю з вами; автоматично чи свідомо.", "icon_request": "Конверт — Запрошення до союзу. Гравець надіслав вам запит на укладення союзу.", "info_enemy_panel": "Панель інформації про ворога", - "exit_confirmation": "Ви впевнені, що хочете вийти з гри?" + "exit_confirmation": "Ви впевнені, що хочете вийти з гри?", + "bomb_direction": "Траєкторія польоту атомної/водородної бомби" }, "single_modal": { - "title": "Гра наодинці", + "title": "Соло", "random_spawn": "Випадкова поява", "allow_alliances": "Дозволити союзи", + "toggle_achievements": "Перемикання досягнень", + "sign_in_for_achievements": "Увійдіть, щоб отримувати досягнення", "options_title": "Налаштування", "bots": "Боти: ", "bots_disabled": "Відключені", @@ -145,6 +167,8 @@ "infinite_troops": "Необмежені війська", "compact_map": "Компактна мапа", "max_timer": "Тривалість гри (хвилини)", + "max_timer_placeholder": "Хвилини", + "max_timer_invalid": "Будь ласка, введіть дійсне максимальне значення таймера (1–120 хвилин)", "disable_nukes": "Вимкнути бомби", "enables_title": "Дозволи", "start": "Розпочати гру" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Акаунт", - "logged_in_as": "Ви увійшли як {email}", + "connected_as": "Підʼєднано як", + "stats_overview": "Огляд статистики", + "link_discord": "Повʼязати акаунт Discord", + "log_out": "Вийти", + "sign_in_desc": "Увійдіть, щоб зберегти статистику та прогрес", + "or": "АБО", + "email_placeholder": "Введіть свою електронну пошту", + "get_magic_link": "Отримати чарівне посилання", + "linked_account": "Ви увійшли як {account_name}", "fetching_account": "Отримання інформації про акаунт...", - "logged_in_with_discord": "Ви увійшли через Discord", - "recovery_email_sent": "Лист для відновлення надіслано на {email}" + "recovery_email_sent": "Лист для відновлення надіслано на {email}", + "not_found": "Не знайдено", + "clear_session": "Очистити сесію", + "failed_to_send_recovery_email": "Не вдалося надіслати електронний лист для відновлення", + "enter_email_address": "Будь ласка, введіть адресу електронної пошти" }, "stats_modal": { "title": "Статистика", @@ -167,11 +202,40 @@ "loading": "Завантаження...", "error": "Помилка завантаження статистики кланів", "no_stats": "Статистика кланів недоступна", + "no_data_yet": "Дані поки що відсутні", "clan": "Клан", "games": "Ігри", "win_score": "Рахунок перемог", + "win_score_tooltip": "Зважені перемоги на основі участі клану та складності матчу", "loss_score": "Рахунок поразок", - "win_loss_ratio": "Перемоги/Поразки" + "loss_score_tooltip": "Зважені поразки на основі участі клану та складності матчу", + "win_loss_ratio": "Перемоги/Поразки", + "ratio": "Коефіцієнт", + "rank": "Ранг", + "try_again": "Спробуйте ще раз" + }, + "game_info_modal": { + "title": "Інформація про гру", + "players": "Гравці", + "atoms": "Атомні бомби", + "hydros": "Водневі бомби", + "mirv": "РГЧ ІН", + "bombs": "Бомби", + "total_gold": "Загалом", + "all_gold": "Усе золото", + "trade": "Торгівля", + "conquest_gold": "Загарбане золото гравців", + "stolen_gold": "Викрадено воєнними кораблями", + "num_of_conquests": "Кількість підкорених гравців", + "duration": "Тривалість", + "survival_time": "Час виживання", + "war": "Війна", + "economy": "Економіка", + "conquests": "Завоювання", + "pirate": "Піратство", + "conquered": "Завойовано", + "loading_game_info": "Завантаження статистики ігор", + "no_winner": "Ця гра закінчилася без переможця (або перемогла нація)" }, "map": { "map": "Мапа", @@ -186,6 +250,7 @@ "asia": "Азія", "mars": "Марс", "southamerica": "Південна Америка", + "britanniaclassic": "Британія (класична)", "britannia": "Британія", "gatewaytotheatlantic": "Гібралтарська протока", "australia": "Австралія", @@ -206,22 +271,36 @@ "yenisei": "Єнісей", "pluto": "Плутон", "montreal": "Монреаль", + "newyorkcity": "Нью-Йорк", "achiran": "Акіран", "baikalnukewars": "Байкал (ядерні війни)", "fourislands": "Чотири острови", "gulfofstlawrence": "Затока Св. Лаврентія", - "lisbon": "Лісабон" + "lisbon": "Лісабон", + "svalmel": "Свалмел", + "manicouagan": "Манікуаган", + "lemnos": "Лемнос", + "sierpinski": "Серпінський", + "twolakes": "Два озера", + "straitofhormuz": "Ормузька протока", + "surrounded": "Оточення", + "didier": "Дідьє", + "didierfrance": "Дідьє (Франція)", + "amazonriver": "Річка Амазонка" }, "map_categories": { "continental": "Континентальні", "regional": "Регіональні", - "fantasy": "Інші" + "fantasy": "Інші", + "special": "Особливі", + "arcade": "Аркадні" }, "map_component": { - "loading": "Завантаження..." + "loading": "Завантаження...", + "error": "Помилка" }, "private_lobby": { - "title": "Приєднатися до приватного лобі", + "title": "Приєднання до приватного лобі", "enter_id": "Введіть ID лобі", "player": "Гравець", "players": "Гравці(в)", @@ -229,42 +308,55 @@ "checking": "Перевірка лобі...", "not_found": "Лобі не знайдено. Будь ласка, перевірте дійсність ID і спробуйте знову.", "error": "Сталася помилка. Спробуйте ще раз або зверніться до служби підтримки.", - "joined_waiting": "Ви успішно приєдналися! Очікування початку гри...", - "version_mismatch": "Цю гру створено в іншій версії. Неможливо приєднатися." + "joined_waiting": "Лобі приєднано! Очікуємо, доки хост почне гру...", + "version_mismatch": "Цю гру створено в іншій версії. Неможливо приєднатися.", + "disabled_units": "Вимкнені споруди" }, "public_lobby": { "join": "Приєднатися до наступної гри", "waiting": "гравці(в) очікують", - "teams_Duos": "по 2 (дуо)", - "teams_Trios": "по 3 (тріо)", - "teams_Quads": "по 4 (квади)", + "teams_Duos": "{team_count} команд по 2 (дуо)", + "teams_Trios": "{team_count} команд по 3 (тріо)", + "teams_Quads": "{team_count} команд по 4 (квади)", + "waiting_for_players": "Очікування гравців", + "starting_game": "Початок гри…", "teams_hvn": "Люди проти націй", - "teams": "Команд: {num}", - "players_per_team": "по {num}" + "teams_hvn_detailed": "{num} людей проти {num} націй", + "teams": "Команди: {num}", + "players_per_team": "по {num}", + "started": "Почато" }, "matchmaking_modal": { - "title": "Підбір гравців", + "title": "Рейтинговий підбір 1v1 (АЛЬФА)", "connecting": "Приєднання до сервера підбору гравців...", "searching": "Пошук гри...", - "waiting_for_game": "Очікування початку гри..." + "waiting_for_game": "Очікування початку гри...", + "elo": "Ваш ELO: {elo}" }, "username": { "enter_username": "Введіть своє імʼя гравця", "not_string": "Імʼя гравця має бути рядком.", "too_short": "Імʼя гравця повинно містити щонайменше {min} символів.", "too_long": "Довжина імʼя гравця не повинна перевищувати {max} символів.", - "invalid_chars": "Імʼя гравця може містити лише латинські літери, цифри, пробіли, знаки підкреслення та [квадратні дужки]." + "invalid_chars": "Імʼя гравця може містити лише латинські літери, цифри, пробіли та підкреслення.", + "tag": "ТЕГ", + "tag_too_short": "Тег клану має складатися з 2–5 абетко-цифрових символів.", + "tag_invalid_chars": "Тег клану може містити лише латинські літери та цифри." }, "host_modal": { - "title": "Приватне лобі", + "title": "Створення приватного лобі", + "label": "Приватний", "mode": "Режим", "team_count": "Кількість команд", + "team_type": "Тип команди", "options_title": "Налаштування", "bots": "Боти: ", "bots_disabled": "Відключені", + "player_immunity_duration": "Тривалість імунітету в PVP (хвилини)", "nations": "Нації: ", "disable_nations": "Вимкнути нації", "max_timer": "Тривалість гри (хвилини)", + "mins_placeholder": "Хвилини", "instant_build": "Миттєве будівництво", "infinite_gold": "Безмежне золото", "donate_gold": "Пожертвування золота", @@ -283,7 +375,11 @@ "assigned_teams": "Розподілені команди", "empty_teams": "Порожні команди", "empty_team": "Немає", - "remove_player": "Вилучити {username}" + "remove_player": "Вилучити {username}", + "teams_Duos": "Дуо (команди по 2)", + "teams_Trios": "Тріо (команди по 3)", + "teams_Quads": "Квади (команди по 4)", + "teams_Humans Vs Nations": "Люди проти націй" }, "team_colors": { "red": "Червоний", @@ -301,18 +397,22 @@ "code_license": "Код ліцензовано під AGPL-3.0 (без гарантій)" }, "difficulty": { - "difficulty": "Складність", - "Easy": "Розслаблена", - "Medium": "Збалансована", - "Hard": "Напружена", - "Impossible": "Неможлива" + "difficulty": "Складність націй", + "easy": "Легко", + "medium": "Середньо", + "hard": "Важко", + "impossible": "Неможливо" }, "game_mode": { "ffa": "Усі проти всіх", "teams": "Команди" }, + "public_game_modifier": { + "random_spawn": "Випадкова поява", + "compact_map": "Компактна мапа" + }, "select_lang": { - "title": "Виберіть мову" + "title": "Вибір мови" }, "unit_type": { "city": "Місто", @@ -327,51 +427,54 @@ "factory": "Фабрика" }, "user_setting": { - "title": "Користувацькі налаштування", + "title": "Налаштування", "tab_basic": "Основні налаштування", "tab_keybinds": "Призначення клавіш", "dark_mode_label": "Темний режим", "dark_mode_desc": "Перемикання зовнішнього вигляду сайту між світлою та темною темою", "emojis_label": "Емоджі", - "emojis_desc": "Увімкнення/вимкнення видимости емоджі під час гри", + "emojis_desc": "Перемкнути видимість емоджі під час гри", "alert_frame_label": "Рамка тривоги", - "alert_frame_desc": "Увімкнути/вимкнути рамку тривоги. Якщо увімкнено, вона показуватиметься, коли вас зраджують або атакують по суші.", + "alert_frame_desc": "Перемкнути рамку тривоги. Якщо увімкнено, рамка показуватиметься, коли вас зраджують або атакують по суші.", "special_effects_label": "Спецефекти", - "special_effects_desc": "Увімкнути/вимкнути спецефекти. Вимкніть для поліпшення продуктивности", + "special_effects_desc": "Перемкнути спецефекти. Вимкніть для поліпшення продуктивности", "structure_sprites_label": "Спрайти споруд", - "structure_sprites_desc": "Увімкнення/вимкнення спрайтів споруд", + "structure_sprites_desc": "Перемкнути спрайти споруд", + "cursor_cost_label_label": "Вартість будування під указівником", + "cursor_cost_label_desc": "Показувати вартість будівництва під указівником", "anonymous_names_label": "Приховані імена", "anonymous_names_desc": "Приховати справжні імена гравців і замінити їх випадковими.", "lobby_id_visibility_label": "Приховані ID лобі", "lobby_id_visibility_desc": "Приховати ID при створенні приватного лобі", + "toggle_visibility": "Перемикання видимости", "left_click_label": "Відкриття меню лівою кнопкою миші", "left_click_desc": "УВІМКНЕНО — лівий клац відкриває меню, кнопкою з мечем здійснює атаку. ВИМКНЕНО — лівий клац одразу атакує.", "left_click_menu": "Меню на лівий клац миші", "attack_ratio_label": "⚔️ Коефіцієнт атаки", - "attack_ratio_desc": "Який відсоток ваших бере учать в атаці (1–100%)", - "troop_ratio_desc": "Налаштуйте співвідношення між військами (для бою) та працівниками (для видобування золота) (1–100%)", + "attack_ratio_desc": "Який відсоток ваших військ відправляти в наступ (1–100%)", "territory_patterns_label": "🏳️ Скіни території", "territory_patterns_desc": "Виберіть, чи показувати скіни територій у грі", "performance_overlay_label": "Оверлей продуктивности", - "performance_overlay_desc": "Увімкнення/вимкнення оверлея продуктивности. Якщо увімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб увімкнути/вимкнути його.", + "performance_overlay_desc": "Перемкнути оверлей продуктивности. Якщо увімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб перемкнути його.", "easter_writing_speed_label": "Множник швидкості друку", "easter_writing_speed_desc": "Налаштуйте швидкість, з якою ви удаєте, що програмуєте (x1–x100)", "easter_bug_count_label": "Кількість багів", "easter_bug_count_desc": "Кількість багів, що ви вважаєте прийнятною (0–1000, емоційно)", + "press_a_key": "Натисніть клавішу", "view_options": "Налаштування вигляду", - "toggle_view": "Змінити вигляд", + "toggle_view": "Перемкнути вигляд", "toggle_view_desc": "Альтернативний вигляд (рельєф/країни)", "build_controls": "Керування розміщенням", "build_city": "Розмістити місто", "build_city_desc": "Розмістити місто під указівником.", "build_factory": "Розмістити фабрику", - "build_factory_desc": "Будувати фабрику під указівником.", + "build_factory_desc": "Розмістити фабрику під указівником.", "build_defense_post": "Розмістити пункт оборони", "build_defense_post_desc": "Розмістити пункт оборони під указівником.", "build_port": "Розмістити порт", "build_port_desc": "Розмістити порт під указівником.", "build_warship": "Розмістити військовий корабель", - "build_warship_desc": "Будувати військовий корабель під указівником.", + "build_warship_desc": "Розмістити військовий корабель під указівником.", "build_missile_silo": "Розмістити ракетну шахту", "build_missile_silo_desc": "Розмістити ракетну шахту під указівником.", "build_sam_launcher": "Розмістити ПУ ЗРК", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "Розмістити водневу бомбу під указівником.", "build_mirv": "Розмістити РГЧ ІН", "build_mirv_desc": "Розмістити РГЧ ІН під указівником.", + "menu_shortcuts": "Скорочення меню", + "build_menu_modifier": "Модифікатор меню будівництва", + "build_menu_modifier_desc": "Утримуйте цю клавішу під час клацання, щоб відкрити меню будівництва.", + "emoji_menu_modifier": "Модифікатор меню емоджі", + "emoji_menu_modifier_desc": "Утримуйте цю клавішу під час клацання, щоб відкрити меню емоджі.", "attack_ratio_controls": "Керування коефіцієнтом атаки", "attack_ratio_up": "Збільшити коефіцієнт атаки", "attack_ratio_up_desc": "Збільшити коефіцієнт атаки на 10%", @@ -392,6 +500,8 @@ "boat_attack_desc": "Відправити човен на клітинку під указівником.", "ground_attack": "Наземна атака", "ground_attack_desc": "Відправити наземну атаку на клітинку під указівником.", + "swap_direction": "Змінити напрямок ракети", + "swap_direction_desc": "Перемкнути напрямок ракети (угору/вниз).", "zoom_controls": "Масштабування", "zoom_out": "Зменшити масштаб", "zoom_out_desc": "Зменшити масштаб мапи", @@ -412,11 +522,12 @@ "unbind": "Звільнити", "on": "Увімкнено", "off": "Вимкнено", - "toggle_terrain": "Увімкнення/вимкнення рельєфу", + "toggle_terrain": "Перемикання рельєфу", "exit_game_label": "Вийти з гри", "exit_game_info": "Повернутися до головного меню", "background_music_volume": "Гучність фонової музики", - "sound_effects_volume": "Гучність звукових ефектів" + "sound_effects_volume": "Гучність звукових ефектів", + "keybind_conflict_error": "Клавішу {key} вже привʼязано до іншої дії." }, "chat": { "title": "Швидкий чат", @@ -516,7 +627,7 @@ "warship": "Захоплює торгові кораблі, знищує кораблі та човни", "port": "Відправляє торгові кораблі для генерації золота", "defense_post": "Підсилює оборону найближчих кордонів", - "city": "Збільшує максимальне населення", + "city": "Збільшує ліміт населення", "factory": "Прокладає залізничні колії та створює поїзди" }, "not_enough_money": "Недостатньо коштів" @@ -529,6 +640,7 @@ "other_team": "Команда «{team}» перемогла!", "you_won": "Ви перемогли!", "other_won": "Гравець {player} переміг!", + "nation_won": "Нація {nation} перемогла!", "exit": "Вийти з гри", "keep": "Продовжити гру", "spectate": "Спостерігати", @@ -537,19 +649,19 @@ "ofm_winter_description": "Приєднуйтеся до турніру та змагайтеся з найкращими гравцями", "join_tournament": "Приєднатися до турніру", "join_discord": "Приєднуйтеся до нашої спільноти Discord!", - "discord_description": "Спілкуйтеся з іншими гравцями, отримуйте новини та діліться стратегіями", + "discord_description": "Звʼязуйтеся з гравцями, відкривайте нові можливості та вигравайте призи!", "join_server": "Приєднатися до сервера", "youtube_tutorial": "Потрібна допомога?" }, "leaderboard": { "title": "Таблиця лідерів", "hide": "Приховати", - "rank": "Місце", + "rank": "Ранг", "player": "Гравець", "team": "Команда", "owned": "Влада", "gold": "Золото", - "troops": "Війська", + "maxtroops": "Ліміт військ", "launchers": "Установки", "sams": "ЗРК", "warships": "Військові кораблі", @@ -565,6 +677,7 @@ "team": "Команда", "alliance_timeout": "Кінець союзу через", "troops": "Війська", + "maxtroops": "Ліміт військ", "a_troops": "Наступальні війська", "gold": "Золото", "ports": "Порти", @@ -575,7 +688,9 @@ "warships": "Військові кораблі", "health": "Здоровʼя", "attitude": "Ставлення", - "levels": "Рівні" + "levels": "Рівні", + "wilderness_title": "Пустир", + "irradiated_wilderness_title": "Радіоактивний пустир" }, "events_display": { "retreating": "відступає", @@ -600,8 +715,21 @@ "alliance_renewed": "Союз із {name} було поновлено", "wants_to_renew_alliance": "{name} хоче поновити ваш союз", "ignore": "Ігнорувати", - "unit_voluntarily_deleted": "Обʼєкт добровільно видалено", - "betrayal_debuff_ends": "Залишилося {time} сек до закінчення покарання зрадника" + "unit_voluntarily_deleted": "Споруду добровільно видалено", + "betrayal_debuff_ends": "Залишилося {time} сек до закінчення покарання зрадника", + "attack_cancelled_retreat": "Атаку скасовано, {troops} солдатів загинули під час відступу", + "received_gold_from_captured_ship": "Отримано {gold} золота з корабля, захопленого у {name}", + "received_gold_from_trade": "Отримано {gold} золота від торгівлі з {name}", + "missile_intercepted": "{unit} перехоплює ракету", + "mirv_warheads_intercepted": "{count, plural, one {Перехоплено {count} боєголовку РГЧ ІН} few {Перехоплено {count} боєголовки РГЧ ІН} many {Перехоплено {count} боєголовок РГЧ ІН} other {Перехоплено {count} боєголовок РГЧ ІН}}", + "sent_troops_to_player": "Відправлено {troops} військ до {name}", + "received_troops_from_player": "Отримано {troops} військ від {name}", + "sent_gold_to_player": "Надіслано {gold} золота для {name}", + "received_gold_from_player": "Отримано {gold} золота від {name}", + "unit_captured_by_enemy": "{name} захоплює вашу споруду «{unit}»", + "captured_enemy_unit": "Захоплено споруду «{unit}» у {name}", + "unit_destroyed": "Вашу споруду «{unit}» було знищено", + "no_boats_available": "Немає доступних човнів, максимум — {max}" }, "unit_info_modal": { "structure_info": "Інформація про споруду", @@ -653,7 +781,10 @@ "send_alliance": "Надіслати союз", "send_troops": "Надіслати війська", "send_gold": "Надіслати золото", - "emotes": "Емоджі" + "emotes": "Емоджі", + "arc_up": "Верхня дуга", + "arc_down": "Нижня дуга", + "flip_rocket_trajectory": "Обернути траєкторію ракети" }, "send_troops_modal": { "title_with_name": "Надіслати війська до {name}", @@ -672,7 +803,7 @@ "replay_panel": { "replay_speed": "Швидкість відтворення", "game_speed": "Швидкість гри", - "fastest_game_speed": "Максимальна" + "fastest_game_speed": "Макс." }, "error_modal": { "crashed": "Гра крашнулася!", @@ -698,27 +829,33 @@ "frame": "Кадр:", "tick_exec": "Виконання на тік:", "tick_delay": "Затримка на тік:", - "layers_header": "Шари (сер. / макс., відсортовано за заг. часом):" + "layers_header": "Шари (сер. / макс., відсортовано за загальним часом):" }, "heads_up_message": { "choose_spawn": "Оберіть стартове розташування", - "random_spawn": "Випадкову появу увімкнено. Обираємо стартове розташування за вас..." + "random_spawn": "Випадкову появу увімкнено. Обираємо стартове розташування за вас...", + "singleplayer_game_paused": "Гру призупинено", + "multiplayer_game_paused": "Гра призупинена творцем лобі" }, "territory_patterns": { "title": "Скіни", "colors": "Кольори", "purchase": "Придбати", "show_only_owned": "Мої скіни", + "all_owned": "Усі скіни придбані! Повертайтеся пізніше за новими товарами.", + "not_logged_in": "Вхід не здійснено", "blocked": { "login": "Ви повинні ввійти, щоб отримати доступ до цього скіна.", "purchase": "Придбайте цей скін, щоб розблокувати його." }, "pattern": { "default": "Типово" - } + }, + "select_skin": "Оберіть скін", + "selected": "обрано" }, "flag_input": { - "title": "Виберіть прапор", + "title": "Вибір прапора", "button_title": "Обери прапор!", "search_flag": "Пошук..." }, @@ -732,8 +869,8 @@ "contact_admin": "Якщо ви вважаєте, що бачите це повідомлення помилково, зверніться до адміністратора сайту." }, "radial_menu": { - "delete_unit_title": "Видалити обʼєкт", - "delete_unit_description": "Клацніть, щоб видалити найближчий обʼєкт" + "delete_unit_title": "Видалити споруду", + "delete_unit_description": "Клацніть, щоб видалити найближчу споруду" }, "discord_user_header": { "avatar_alt": "Аватар" @@ -743,7 +880,7 @@ "ship_arrivals": "Прибуття кораблів", "nuke_stats": "Статистика бомбардувань", "player_metrics": "Статистика гравця", - "building": "Будівництво", + "building": "Споруда", "ship_type": "Тип корабля", "weapon": "Зброя", "built": "Побудовано", @@ -762,7 +899,7 @@ "gold": "Золото", "workers": "Робітники", "war": "Війни", - "trade": "Обмін", + "trade": "Торгівля", "steal": "Украдено", "unit": { "city": "Місто", @@ -786,8 +923,9 @@ "mode": "Режим", "mode_ffa": "Усі проти всіх", "mode_team": "Команда", - "view": "Оглянути", + "replay": "Повтор", "details": "Подробиці", + "ranking": "Рейтинг", "started": "Почато", "map": "Мапа", "difficulty": "Складність", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Публічний", "private": "Приватний", - "singleplayer": "Гра наодинці", + "singleplayer": "Соло", "mode": "Режим", "stats_wins": "Перемоги", "stats_losses": "Поразки", - "stats_wlr": "Співвідношення перемог і поразок", + "stats_wlr": "Коефіцієнт перемог і поразок", "stats_games_played": "Зіграні ігри", "mode_ffa": "Усі проти всіх", - "mode_team": "Команда" + "mode_team": "Команда", + "no_stats": "Немає даних для цієї вибірки." + }, + "matchmaking_button": { + "play_ranked": "Рейтинговий підбір 1v1", + "description": "(АЛЬФА)", + "login_required": "Увійдіть, щоб грати в рейтинговому режимі!", + "must_login": "Ви повинні увійти, щоб грати в рейтинговому режимі." } } diff --git a/resources/lang/zh-CN.json b/resources/lang/zh-CN.json index 1952eacd3..c090cd319 100644 --- a/resources/lang/zh-CN.json +++ b/resources/lang/zh-CN.json @@ -7,6 +7,7 @@ }, "common": { "close": "关闭", + "back": "返回", "available": "剩余", "preset_max": "最大", "summary_send": "发送", @@ -17,26 +18,42 @@ "cap_tooltip": "接收者的可接收数量", "target_dead": "目标已淘汰", "target_dead_note": "你不能向已淘汰玩家发送资源。", - "none": "空" + "none": "空", + "copied": "已复制!", + "click_to_copy": "点击复制" }, "main": { - "title": "OpenFront (ALPHA)", + "title": "OpenFront (内测版)", "join_discord": "Discord", "login_discord": "用 Discord 登录", + "sign_in": "登录", + "discord_avatar_alt": "Discord 头像", + "user_avatar_alt": "{username} 的头像", "checking_login": "正在检查登录...", "logged_in": "登录成功!", "log_out": "退出登录", - "create_lobby": "创建房间", - "join_lobby": "加入房间", - "single_player": "单人游戏", + "create": "创建房间", + "join": "加入房间", + "solo": "单人模式", "instructions": "操作说明", + "game_info": "游戏信息", "wiki": "游戏百科", "privacy_policy": "隐私政策", "terms_of_service": "服务条款", - "reddit": "Reddit" + "copyright": "© OpenFront™ 和贡献者们", + "reddit": "Reddit", + "play": "游戏", + "news": "公告", + "store": "商店", + "settings": "设置", + "keys": "按键", + "stats": "统计", + "account": "账号", + "help": "帮助", + "menu": "菜单", + "pick_pattern": "选择一个图案!" }, "news": { - "see_all_releases": "查看所有版本信息", "github_link": "在 Github 上", "title": "发行说明" }, @@ -83,6 +100,8 @@ "radial_attack": "打开攻击菜单。", "radial_info": "打开信息菜单。", "radial_boat": "发送一艘运输船攻击选中的区域。仅当你与水域毗邻时才可用。", + "radial_donate_troops": "捐赠相当于你攻击比例的军队给该盟友。", + "radial_donate_gold": "打开黄金捐赠菜单,可快速向盟友发送黄金。", "radial_close": "关闭菜单。", "info_title": "信息菜单", "info_enemy_desc": "包含以下信息:所选玩家的名称、黄金数量、军队数量、是否已停止与你贸易、是否对你发射了核弹,以及该玩家是否为叛徒。“停止贸易”表示你将无法从该玩家处获得金币,对方也无法通过商船向你发送金币。这种状态可能是手动触发(该玩家点击了“停止贸易”,此状态将持续,直到你们双方都点击“开始贸易”)或自动触发(当你背叛了联盟时,此状态会持续,直到你们重新结盟或5分钟后自动结束)。当玩家背叛并攻击其盟友时,“叛徒”状态将显示为“是”,持续30秒。下方图标表示你与该玩家的互动关系:", @@ -114,7 +133,7 @@ "build_silo": "导弹发射井", "build_silo_desc": "允许发射导弹。", "build_sam": "防空塔", - "build_sam_desc": "可以截获100像素范围内的敌方导弹。原子弹、氢弹和单个MIRV弹头的拦截命中概率分别是100%、80%和50%。该防空导弹拥有7.5秒冷却。", + "build_sam_desc": "可以截获100像素范围内的敌方导弹。防空塔有7.5秒的冷却时间。", "build_atom": "原子弹", "build_atom_desc": "小型爆弹可摧毁领土、建筑、船只。从最近的导弹发射井发射并坠落在你初次点击部署它的区域。", "build_hydrogen": "氢弹", @@ -129,12 +148,15 @@ "icon_embargo": "美元符号停止标志 - 禁商。该玩家已自动或手动停止与您的交易。", "icon_request": "信封 - 结盟请求。该玩家已向你发送结盟请求。", "info_enemy_panel": "敌人信息面板", - "exit_confirmation": "确定要退出游戏吗?" + "exit_confirmation": "确定要退出游戏吗?", + "bomb_direction": "原子弹 / 氢弹抛物线方向" }, "single_modal": { - "title": "单人玩家", + "title": "单人模式", "random_spawn": "随机出生点", "allow_alliances": "允许结盟", + "toggle_achievements": "切换成就", + "sign_in_for_achievements": "登录以获取成就", "options_title": "选项", "bots": "机器人: ", "bots_disabled": "已禁用", @@ -145,6 +167,8 @@ "infinite_troops": "无限军队", "compact_map": "紧凑地图", "max_timer": "游戏时长(分钟)", + "max_timer_placeholder": "分钟", + "max_timer_invalid": "请输入一个有效的最大计时器值(1-120分钟)", "disable_nukes": "禁用核弹", "enables_title": "启用设置", "start": "开始游戏" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "账号", - "logged_in_as": "以 {email} 身份登录成功", + "connected_as": "已连接为", + "stats_overview": "统计概览", + "link_discord": "链接 Discord 帐号", + "log_out": "退出登录", + "sign_in_desc": "登录以保存您的统计数据和进度", + "or": "或", + "email_placeholder": "请输入您的电子邮件地址", + "get_magic_link": "获取魔法链接", + "linked_account": "以 {account_name} 身份登录成功", "fetching_account": "正在获取帐户信息......", - "logged_in_with_discord": "使用 Discord 登录", - "recovery_email_sent": "账号找回邮件已发送至 {email}" + "recovery_email_sent": "账号找回邮件已发送至 {email}", + "not_found": "未找到", + "clear_session": "清除会话", + "failed_to_send_recovery_email": "发送恢复邮件失败", + "enter_email_address": "请输入电子邮件地址" }, "stats_modal": { "title": "统计", @@ -167,11 +202,40 @@ "loading": "正在加载……", "error": "加载军团统计数据时出错", "no_stats": "暂无军团统计数据", + "no_data_yet": "暂无数据", "clan": "军团", "games": "游戏场数", "win_score": "胜者积分", + "win_score_tooltip": "加权胜场数基于战队参与度和比赛难度计算", "loss_score": "败者积分", - "win_loss_ratio": "胜负比" + "loss_score_tooltip": "加权败场数基于战队参与度和比赛难度计算", + "win_loss_ratio": "胜负比", + "ratio": "比率", + "rank": "排名", + "try_again": "再试一次" + }, + "game_info_modal": { + "title": "游戏信息", + "players": "玩家", + "atoms": "原子弹", + "hydros": "氢弹", + "mirv": "MIRV", + "bombs": "炸弹", + "total_gold": "总计", + "all_gold": "总黄金", + "trade": "交易", + "conquest_gold": "已抢夺黄金", + "stolen_gold": "被军舰偷走", + "num_of_conquests": "征服的玩家数", + "duration": "时长", + "survival_time": "存活时长", + "war": "战争", + "economy": "经济", + "conquests": "征服数", + "pirate": "抢劫", + "conquered": "被征服", + "loading_game_info": "正在加载游戏统计数据", + "no_winner": "这场游戏最终无人胜出(或者一个人机国获胜了)" }, "map": { "map": "地图", @@ -186,6 +250,7 @@ "asia": "亚洲", "mars": "火星", "southamerica": "南美洲", + "britanniaclassic": "不列颠尼亚(经典)", "britannia": "不列颠尼亚", "gatewaytotheatlantic": "大西洋枢纽", "australia": "澳大利亚", @@ -196,7 +261,7 @@ "betweentwoseas": "二海之间", "faroeislands": "法罗群岛", "deglaciatedantarctica": "冰消的南极洲", - "europeclassic": "欧洲 (经典)", + "europeclassic": "欧洲(经典)", "falklandislands": "福克兰群岛", "baikal": "贝加尔湖", "halkidiki": "哈尔基季基", @@ -206,19 +271,33 @@ "yenisei": "叶尼塞河", "pluto": "冥王星", "montreal": "蒙特利尔", + "newyorkcity": "纽约城", "achiran": "阿基尔岛/阿伦群岛", "baikalnukewars": "贝加尔湖(核战争)", "fourislands": "四岛争霸", "gulfofstlawrence": "圣劳伦斯湾", - "lisbon": "里斯本" + "lisbon": "里斯本", + "svalmel": "斯瓦尔梅尔", + "manicouagan": "马尼夸根陨石坑", + "lemnos": "利姆诺斯岛", + "sierpinski": "谢尔宾斯基分形", + "twolakes": "双湖", + "straitofhormuz": "霍尔木兹海峡", + "surrounded": "环岛", + "didier": "迪迪埃", + "didierfrance": "迪迪埃(法国)", + "amazonriver": "亚马逊河" }, "map_categories": { "continental": "大陆", "regional": "地区", - "fantasy": "其他" + "fantasy": "其他", + "special": "特殊", + "arcade": "街机" }, "map_component": { - "loading": "正在加载..." + "loading": "正在加载...", + "error": "错误" }, "private_lobby": { "title": "加入私人房间", @@ -229,42 +308,55 @@ "checking": "正在确认房间...", "not_found": "找不到房间。请检查 ID 然后重试。", "error": "发生错误。请再试一次或联系支持人员。", - "joined_waiting": "加入成功!正在等待游戏开始...", - "version_mismatch": "这场游戏基于另一个版本,无法加入。" + "joined_waiting": "房间已加入!等待房主开始游戏……", + "version_mismatch": "这场游戏基于另一个版本,无法加入。", + "disabled_units": "禁用单位" }, "public_lobby": { "join": "加入下一场游戏", "waiting": "等待中的玩家", - "teams_Duos": "/ 2(2人小队)", - "teams_Trios": "/ 3(3人小队)", - "teams_Quads": "/ 4(4人小队)", + "teams_Duos": "{team_count} 个 2 人小队", + "teams_Trios": "{team_count} 个 3 人小队", + "teams_Quads": "{team_count} 个 4 人小队", + "waiting_for_players": "正在等待玩家", + "starting_game": "正在启动游戏……", "teams_hvn": "人类 VS 国家", + "teams_hvn_detailed": "{num} 个人类 VS {num} 个国家", "teams": "{num} 个队伍", - "players_per_team": "/ {num}" + "players_per_team": "每队 {num} 人", + "started": "已开始" }, "matchmaking_modal": { - "title": "匹配中", + "title": "1v1 排位赛(内测版)", "connecting": "正在连接到匹配服务器……", "searching": "正在搜索游戏……", - "waiting_for_game": "正在等待游戏开始……" + "waiting_for_game": "正在等待游戏开始……", + "elo": "你的 ELO 分:{elo}" }, "username": { "enter_username": "输入用户名", "not_string": "用户名必须是字符串。", "too_short": "用户名最少包含 {min} 个字符。", "too_long": "用户名不得超过 {max} 个字符。", - "invalid_chars": "用户名只能包含字母、数字、空格、下划线和 [方括号]。" + "invalid_chars": "用户名只能包含字母、数字、空格、下划线和 [方括号]。", + "tag": "标签", + "tag_too_short": "战队标签必须是 2-5 位字母或数字字符。", + "tag_invalid_chars": "战队标签只能包含字母和数字。" }, "host_modal": { - "title": "私人房间", + "title": "创建私人房间", + "label": "私有", "mode": "模式", "team_count": "队伍数量", + "team_type": "队伍类型", "options_title": "选项", "bots": "机器人: ", "bots_disabled": "禁用", + "player_immunity_duration": "PVP 豁免期限(分钟)", "nations": "国家:", "disable_nations": "禁用国家", "max_timer": "游戏时长(分钟)", + "mins_placeholder": "分钟", "instant_build": "立即建造", "infinite_gold": "无限金钱", "donate_gold": "捐赠金币", @@ -283,7 +375,11 @@ "assigned_teams": "已分配的队伍", "empty_teams": "空队伍", "empty_team": "空", - "remove_player": "移除 {username}" + "remove_player": "移除 {username}", + "teams_Duos": "2人小队", + "teams_Trios": "3人小队", + "teams_Quads": "4人小队", + "teams_Humans Vs Nations": "人类 VS 国家" }, "team_colors": { "red": "红色", @@ -301,16 +397,20 @@ "code_license": "代码采用 AGPL-3.0 许可证授权(无担保)" }, "difficulty": { - "difficulty": "难度", - "Easy": "休闲", - "Medium": "平衡", - "Hard": "困难", - "Impossible": "地狱" + "difficulty": "国家难度", + "easy": "简单", + "medium": "中等", + "hard": "困难", + "impossible": "地狱" }, "game_mode": { "ffa": "混战", "teams": "团队" }, + "public_game_modifier": { + "random_spawn": "随机出生点", + "compact_map": "紧凑地图" + }, "select_lang": { "title": "选择语言" }, @@ -327,7 +427,7 @@ "factory": "工厂" }, "user_setting": { - "title": "用户设置", + "title": "设置", "tab_basic": "基本设置", "tab_keybinds": "热键绑定", "dark_mode_label": "深色模式", @@ -340,16 +440,18 @@ "special_effects_desc": "切换特效开关。停用以改进性能", "structure_sprites_label": "建筑贴图", "structure_sprites_desc": "切换建筑贴图", + "cursor_cost_label_label": "建造按钮显示消耗", + "cursor_cost_label_desc": "在建造按钮下显示花费", "anonymous_names_label": "隐藏的名称", "anonymous_names_desc": "将真实玩家名字替换为随机名字。", "lobby_id_visibility_label": "隐藏的房间ID", "lobby_id_visibility_desc": "在创建私人房间时隐藏房间ID", + "toggle_visibility": "切换是否可见", "left_click_label": "左键单击打开菜单", "left_click_desc": "开启时,先左键单击打开菜单,然后再点进攻。关闭时,左键将直接进攻。", "left_click_menu": "左键点击菜单", "attack_ratio_label": "⚔️ 攻击比例", "attack_ratio_desc": "你要派出多少比例的军队进攻 (1–100%)", - "troop_ratio_desc": "调整军队 (用于战斗) 和工人 (用于生产黄金) 之间的比例 (1-100%)", "territory_patterns_label": "🏳️ 领土皮肤", "territory_patterns_desc": "选择是否在游戏中显示领土皮肤", "performance_overlay_label": "性能叠层", @@ -358,6 +460,7 @@ "easter_writing_speed_desc": "调节你“假装写代码”的速度 (x1–x100)", "easter_bug_count_label": "Bug 计数", "easter_bug_count_desc": "你能接受多少个 Bug? (0–1000,心理承受范围)", + "press_a_key": "按下一个按键", "view_options": "视图选项", "toggle_view": "切换视图", "toggle_view_desc": "备选视图 (地形/国家)", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "向鼠标位置发射氢弹。", "build_mirv": "发射 MIRV", "build_mirv_desc": "向鼠标位置发射 MIRV。", + "menu_shortcuts": "菜单快捷键", + "build_menu_modifier": "建造菜单编辑器", + "build_menu_modifier_desc": "按住此键并点击以打开建造菜单。", + "emoji_menu_modifier": "Emoji 表情菜单编辑器", + "emoji_menu_modifier_desc": "按住此键并点击以打开 Emoji 表情菜单。", "attack_ratio_controls": "攻击比例控制", "attack_ratio_up": "增加攻击比例", "attack_ratio_up_desc": "增加 10% 攻击比例", @@ -392,6 +500,8 @@ "boat_attack_desc": "向鼠标所指地块发送船只攻击。", "ground_attack": "对地攻击", "ground_attack_desc": "向鼠标所指地块发送船只攻击。", + "swap_direction": "调换火箭方向", + "swap_direction_desc": "切换火箭发射方向(上/下)。", "zoom_controls": "缩放控制", "zoom_out": "缩小", "zoom_out_desc": "缩小地图", @@ -416,7 +526,8 @@ "exit_game_label": "退出游戏", "exit_game_info": "返回主菜单", "background_music_volume": "背景音量", - "sound_effects_volume": "音效音量" + "sound_effects_volume": "音效音量", + "keybind_conflict_error": "按键 {key} 已经绑定到另一动作上了。" }, "chat": { "title": "快捷聊天", @@ -529,6 +640,7 @@ "other_team": "{team} 队获胜了!", "you_won": "你获胜了!", "other_won": "{player} 获胜了!", + "nation_won": "国家 {nation} 获胜了!", "exit": "退出游戏", "keep": "继续游戏", "spectate": "观战", @@ -537,7 +649,7 @@ "ofm_winter_description": "加入竞技比赛,与最强玩家一较高下", "join_tournament": "加入比赛", "join_discord": "加入我们的 Discord 社区!", - "discord_description": "与其他玩家交流,获取最新消息,分享游戏战略", + "discord_description": "与玩家交流,发现新功能,并赢取奖品!", "join_server": "加入服务器", "youtube_tutorial": "需要帮助吗?" }, @@ -549,7 +661,7 @@ "team": "队伍", "owned": "已占领", "gold": "黄金", - "troops": "军队", + "maxtroops": "最大军队", "launchers": "导弹发射井", "sams": "防空塔", "warships": "军舰", @@ -565,6 +677,7 @@ "team": "队伍", "alliance_timeout": "结盟剩余时长", "troops": "军队", + "maxtroops": "最大军队", "a_troops": "进攻军队", "gold": "黄金", "ports": "港口", @@ -575,7 +688,9 @@ "warships": "军舰", "health": "生命值", "attitude": "态度", - "levels": "等级" + "levels": "等级", + "wilderness_title": "荒野", + "irradiated_wilderness_title": "受辐射的荒野" }, "events_display": { "retreating": "正在撤退", @@ -601,7 +716,20 @@ "wants_to_renew_alliance": "{name} 想与你续签盟约", "ignore": "忽略", "unit_voluntarily_deleted": "单位已自毁", - "betrayal_debuff_ends": "距离背叛减益效果结束还剩 {time} 秒" + "betrayal_debuff_ends": "距离背叛减益效果结束还剩 {time} 秒", + "attack_cancelled_retreat": "已取消进攻,在撤退时损失了 {troops} 兵力", + "received_gold_from_captured_ship": "捕获了 {name} 的商船,获得 {gold} 黄金", + "received_gold_from_trade": "与 {name} 贸易获得了 {gold} 黄金", + "missile_intercepted": "已拦截导弹 {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} 个 MIRV 弹头被拦截} other {{count} 个 MIRV 弹头被拦截}}", + "sent_troops_to_player": "已向 {name} 发送 {troops} 军队", + "received_troops_from_player": "已从 {name} 收到 {troops} 军队", + "sent_gold_to_player": "已向 {name} 发送 {gold} 黄金", + "received_gold_from_player": "已从 {name} 收到 {gold} 黄金", + "unit_captured_by_enemy": "你的 {unit} 被 {name} 捕获", + "captured_enemy_unit": "已捕获 {name} 的 {unit}", + "unit_destroyed": "你的 {unit} 已被摧毁", + "no_boats_available": "无可用船,最多 {max} 个" }, "unit_info_modal": { "structure_info": "建筑信息", @@ -653,7 +781,10 @@ "send_alliance": "请求结盟", "send_troops": "发送军队", "send_gold": "发送黄金", - "emotes": "表情符号" + "emotes": "表情符号", + "arc_up": "向上的弧", + "arc_down": "向下的弧", + "flip_rocket_trajectory": "翻转火箭轨道" }, "send_troops_modal": { "title_with_name": "向 {name} 发送军队", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "选择出生点", - "random_spawn": "随机出生点已启用。正在为你选择出生点……" + "random_spawn": "随机出生点已启用。正在为你选择出生点……", + "singleplayer_game_paused": "游戏已暂停", + "multiplayer_game_paused": "游戏已被房主暂停" }, "territory_patterns": { "title": "皮肤", "colors": "颜色", "purchase": "购买", "show_only_owned": "我的皮肤", + "all_owned": "您已拥有所有皮肤!请稍后再来查看新皮肤。", + "not_logged_in": "未登录", "blocked": { "login": "您必须登录才能使用此皮肤。", "purchase": "购买以解锁此皮肤。" }, "pattern": { "default": "默认" - } + }, + "select_skin": "选择皮肤", + "selected": "已选择" }, "flag_input": { "title": "选择旗帜", @@ -786,8 +923,9 @@ "mode": "模式", "mode_ffa": "混战", "mode_team": "团队", - "view": "查看", + "replay": "回放", "details": "详情", + "ranking": "排行", "started": "已开始", "map": "地图", "difficulty": "难度", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "公开", "private": "私有", - "singleplayer": "单人玩家", + "singleplayer": "单人模式", "mode": "模式", "stats_wins": "胜场数", "stats_losses": "败场数", "stats_wlr": "胜败比", "stats_games_played": "游戏场数", "mode_ffa": "混战", - "mode_team": "团队" + "mode_team": "团队", + "no_stats": "所选项没有统计记录。" + }, + "matchmaking_button": { + "play_ranked": "1v1 排位赛", + "description": "(内测版)", + "login_required": "登录后开始排位赛!", + "must_login": "您必须登录才能玩排位赛。" } } From f367ea1940fb002e56082e856728c3e86d48bc31 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Mon, 19 Jan 2026 05:51:12 +0100 Subject: [PATCH 02/45] Record human/nation/bot conquests (#2949) ## Description: Conquests are currently mixing all player types. This is not ideal as people wonders why a 50 player game can lead to hundred of kills. Having separate records can also help with achievements and better balancing. This PR splits the conquests record into 3 categories: human, nations and bots. It is linked to this infra PR: https://github.com/openfrontio/infra/pull/246 image While the recorded data make a distinction between bots/nations, it's only displayed here as a single "bot" category. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- resources/lang/en.json | 3 ++- .../baseComponents/ranking/GameInfoRanking.ts | 20 ++++++++++---- .../baseComponents/ranking/PlayerRow.ts | 3 ++- .../baseComponents/ranking/RankingControls.ts | 18 ++++++++----- .../baseComponents/ranking/RankingHeader.ts | 21 +++++++++++---- .../baseComponents/stats/PlayerStatsTree.ts | 4 +-- src/core/StatsSchemas.ts | 7 ++++- src/core/game/StatsImpl.ts | 26 +++++++++++++------ tests/GameInfoRanking.test.ts | 16 ++++++------ tests/Stats.test.ts | 4 +-- 10 files changed, 83 insertions(+), 39 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index c9adfcf33..3cb7cfba5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -233,7 +233,8 @@ "naval_trade": "Tradeship", "conquest_gold": "Conquered player gold", "stolen_gold": "Stolen with warships", - "num_of_conquests": "Number of conquered players", + "num_of_conquests_humans": "Player kills", + "num_of_conquests_bots": "Bot kills", "duration": "Duration", "survival_time": "Survival time", "war": "War", diff --git a/src/client/components/baseComponents/ranking/GameInfoRanking.ts b/src/client/components/baseComponents/ranking/GameInfoRanking.ts index fa78d36f0..dff65db0f 100644 --- a/src/client/components/baseComponents/ranking/GameInfoRanking.ts +++ b/src/client/components/baseComponents/ranking/GameInfoRanking.ts @@ -5,10 +5,14 @@ import { GOLD_INDEX_TRAIN_OTHER, GOLD_INDEX_TRAIN_SELF, GOLD_INDEX_WAR, + PLAYER_INDEX_BOT, + PLAYER_INDEX_HUMAN, + PLAYER_INDEX_NATION, } from "../../../../core/StatsSchemas"; export enum RankType { - Conquests = "Conquests", + ConquestHumans = "ConquestHumans", + ConquestBots = "ConquestBots", Atoms = "Atoms", Hydros = "Hydros", MIRV = "MIRV", @@ -27,7 +31,7 @@ export interface PlayerInfo { tag?: string; killedAt?: number; gold: bigint[]; - conquests: number; + conquests: bigint[]; flag?: string; winner: boolean; atoms: number; @@ -79,12 +83,13 @@ export class Ranking { username = match[2]; } const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0)); + const conquests = (stats.conquests ?? []).map((v) => BigInt(v ?? 0)); players[player.clientID] = { id: player.clientID, rawUsername: player.username, username, tag: player.clanTag, - conquests: Number(stats.conquests) || 0, + conquests, flag: player.cosmetics?.flag ?? undefined, killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined, gold, @@ -125,8 +130,13 @@ export class Ranking { return (player.killedAt / Math.max(this.duration, 1)) * 10; } return 100; - case RankType.Conquests: - return player.conquests; + case RankType.ConquestHumans: + return Number(player.conquests[PLAYER_INDEX_HUMAN] ?? 0n); + case RankType.ConquestBots: + return ( + Number(player.conquests[PLAYER_INDEX_BOT] ?? 0n) + + Number(player.conquests[PLAYER_INDEX_NATION] ?? 0n) + ); case RankType.Atoms: return player.atoms; case RankType.Hydros: diff --git a/src/client/components/baseComponents/ranking/PlayerRow.ts b/src/client/components/baseComponents/ranking/PlayerRow.ts index 773188c8b..ed3cfc6ba 100644 --- a/src/client/components/baseComponents/ranking/PlayerRow.ts +++ b/src/client/components/baseComponents/ranking/PlayerRow.ts @@ -63,7 +63,8 @@ export class PlayerRow extends LitElement { private renderPlayerInfo() { switch (this.rankType) { case RankType.Lifetime: - case RankType.Conquests: + case RankType.ConquestHumans: + case RankType.ConquestBots: return this.renderScoreAsBar(); case RankType.Atoms: case RankType.Hydros: diff --git a/src/client/components/baseComponents/ranking/RankingControls.ts b/src/client/components/baseComponents/ranking/RankingControls.ts index e32933efe..527681c7f 100644 --- a/src/client/components/baseComponents/ranking/RankingControls.ts +++ b/src/client/components/baseComponents/ranking/RankingControls.ts @@ -10,19 +10,25 @@ const economyRankings = new Set([ RankType.NavalTrade, RankType.TrainTrade, ]); -const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]); -const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]); const warRankings = new Set([ - RankType.Conquests, + RankType.ConquestHumans, + RankType.ConquestBots, RankType.Atoms, RankType.Hydros, RankType.MIRV, ]); +const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]); +const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]); +const conquestRankings = new Set([ + RankType.ConquestHumans, + RankType.ConquestBots, +]); const isEconomyRanking = (t: RankType) => economyRankings.has(t); const isTradeRanking = (t: RankType) => tradeRankings.has(t); const isBombRanking = (t: RankType) => bombRankings.has(t); const isWarRanking = (t: RankType) => warRankings.has(t); +const isConquestRanking = (t: RankType) => conquestRankings.has(t); @customElement("ranking-controls") export class RankingControls extends LitElement { @@ -41,7 +47,7 @@ export class RankingControls extends LitElement { "game_info_modal.duration", )} ${this.renderButton( - RankType.Conquests, + RankType.ConquestHumans, isWarRanking(this.rankType), "game_info_modal.war", )} @@ -78,8 +84,8 @@ export class RankingControls extends LitElement { "game_info_modal.bombs", )} ${this.renderSubButton( - RankType.Conquests, - this.rankType === RankType.Conquests, + RankType.ConquestHumans, + isConquestRanking(this.rankType), "game_info_modal.conquests", )} diff --git a/src/client/components/baseComponents/ranking/RankingHeader.ts b/src/client/components/baseComponents/ranking/RankingHeader.ts index dae3db3c1..cd1270d15 100644 --- a/src/client/components/baseComponents/ranking/RankingHeader.ts +++ b/src/client/components/baseComponents/ranking/RankingHeader.ts @@ -14,7 +14,7 @@ export class RankingHeader extends LitElement { render() { return html`
  • ${this.renderHeaderContent()}
  • @@ -27,10 +27,21 @@ export class RankingHeader extends LitElement { return html`
    ${translateText("game_info_modal.survival_time")}
    `; - case RankType.Conquests: - return html`
    - ${translateText("game_info_modal.num_of_conquests")} -
    `; + case RankType.ConquestHumans: + case RankType.ConquestBots: + return html` +
    + ${this.renderMultipleChoiceHeaderButton( + translateText("game_info_modal.num_of_conquests_humans"), + RankType.ConquestHumans, + )} + / + ${this.renderMultipleChoiceHeaderButton( + translateText("game_info_modal.num_of_conquests_bots"), + RankType.ConquestBots, + )} +
    + `; case RankType.Atoms: case RankType.Hydros: case RankType.MIRV: diff --git a/src/client/components/baseComponents/stats/PlayerStatsTree.ts b/src/client/components/baseComponents/stats/PlayerStatsTree.ts index e703ee2a2..1f412e00f 100644 --- a/src/client/components/baseComponents/stats/PlayerStatsTree.ts +++ b/src/client/components/baseComponents/stats/PlayerStatsTree.ts @@ -149,7 +149,7 @@ export class PlayerStatsTreeView extends LitElement { attacks: this.mergeStatArrays(base.attacks, next.attacks), betrayals: this.mergeStatValue(base.betrayals, next.betrayals), killedAt: this.mergeStatValue(base.killedAt, next.killedAt), - conquests: this.mergeStatValue(base.conquests, next.conquests), + conquests: this.mergeStatArrays(base.conquests, next.conquests), boats: this.mergeStatRecord(base.boats, next.boats), bombs: this.mergeStatRecord(base.bombs, next.bombs), gold: this.mergeStatArrays(base.gold, next.gold), @@ -203,7 +203,7 @@ export class PlayerStatsTreeView extends LitElement { attacks: stats.attacks ? [...stats.attacks] : undefined, betrayals: stats.betrayals, killedAt: stats.killedAt, - conquests: stats.conquests, + conquests: stats.conquests ? [...stats.conquests] : undefined, boats: stats.boats ? { ...stats.boats } : undefined, bombs: stats.bombs ? { ...stats.bombs } : undefined, gold: stats.gold ? [...stats.gold] : undefined, diff --git a/src/core/StatsSchemas.ts b/src/core/StatsSchemas.ts index 7596e9fbf..c37607bd6 100644 --- a/src/core/StatsSchemas.ts +++ b/src/core/StatsSchemas.ts @@ -62,6 +62,11 @@ export const ATTACK_INDEX_SENT = 0; // Outgoing attack troops export const ATTACK_INDEX_RECV = 1; // Incmoing attack troops export const ATTACK_INDEX_CANCEL = 2; // Cancelled attack troops +// Player types +export const PLAYER_INDEX_HUMAN = 0; +export const PLAYER_INDEX_NATION = 1; +export const PLAYER_INDEX_BOT = 2; + // Boats export const BOAT_INDEX_SENT = 0; // Boats launched export const BOAT_INDEX_ARRIVE = 1; // Boats arrived @@ -102,7 +107,7 @@ export const PlayerStatsSchema = z attacks: AtLeastOneNumberSchema.optional(), betrayals: BigIntStringSchema.optional(), killedAt: BigIntStringSchema.optional(), - conquests: BigIntStringSchema.optional(), + conquests: AtLeastOneNumberSchema.optional(), boats: z.partialRecord(BoatUnitSchema, AtLeastOneNumberSchema).optional(), bombs: z.partialRecord(BombUnitSchema, AtLeastOneNumberSchema).optional(), gold: AtLeastOneNumberSchema.optional(), diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index 56e394769..c2195bf72 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -24,11 +24,14 @@ import { OTHER_INDEX_LOST, OTHER_INDEX_UPGRADE, OtherUnitType, + PLAYER_INDEX_BOT, + PLAYER_INDEX_HUMAN, + PLAYER_INDEX_NATION, PlayerStats, unitTypeToBombUnit, unitTypeToOtherUnit, } from "../StatsSchemas"; -import { Player, TerraNullius, UnitType } from "./Game"; +import { Player, PlayerType, TerraNullius, UnitType } from "./Game"; import { Stats } from "./Stats"; type BigIntLike = bigint | number; @@ -41,6 +44,12 @@ function _bigint(value: BigIntLike): bigint { } } +const conquest_by_type: Record = { + [PlayerType.Human]: PLAYER_INDEX_HUMAN, + [PlayerType.Nation]: PLAYER_INDEX_NATION, + [PlayerType.Bot]: PLAYER_INDEX_BOT, +}; + export class StatsImpl implements Stats { private readonly data: AllPlayersStats = {}; @@ -138,14 +147,12 @@ export class StatsImpl implements Stats { p.units[type][index] += _bigint(value); } - private _addConquest(player: Player) { + private _addConquest(player: Player, index: number) { const p = this._makePlayerStats(player); if (p === undefined) return; - if (p.conquests === undefined) { - p.conquests = _bigint(1); - } else { - p.conquests += _bigint(1); - } + p.conquests ??= [0n]; + while (p.conquests.length <= index) p.conquests.push(0n); + p.conquests[index] += _bigint(1); } private _addPlayerKilled(player: Player, tick: number) { @@ -249,7 +256,10 @@ export class StatsImpl implements Stats { goldWar(player: Player, captured: Player, gold: BigIntLike): void { this._addGold(player, GOLD_INDEX_WAR, gold); - this._addConquest(player); + const conquestType = conquest_by_type[captured.type()]; + if (conquestType !== undefined) { + this._addConquest(player, conquestType); + } } unitBuild(player: Player, type: OtherUnitType): void { diff --git a/tests/GameInfoRanking.test.ts b/tests/GameInfoRanking.test.ts index 1e523ae93..1eeefebb9 100644 --- a/tests/GameInfoRanking.test.ts +++ b/tests/GameInfoRanking.test.ts @@ -56,7 +56,7 @@ describe("Ranking class", () => { cosmetics: { flag: "USA" }, stats: { units: { port: [2n, 0n, 0n, 2n] }, - conquests: 5n, + conquests: [5n], gold: [0n, 100n, 20n, 0n, 15n, 5n], // total 140 bombs: { abomb: [1n], @@ -71,7 +71,7 @@ describe("Ranking class", () => { username: "Bob", stats: { units: { city: [2n, 0n, 0n, 2n] }, - conquests: 8n, + conquests: [8n], gold: [0n, 50n, 10n, 5n], // total 65, no train trade bombs: { abomb: [0n], @@ -86,7 +86,7 @@ describe("Ranking class", () => { username: "Charlie", stats: { // no units, but has conquests/killedAt to count as played - conquests: 8n, + conquests: [8n], killedAt: BigInt(600), gold: [0n, 10n, 2n, 10n, 0n, 5n], // total 27 bombs: {}, @@ -110,21 +110,21 @@ describe("Ranking class", () => { test("summarizes players correctly", () => { const r = new Ranking(makeSession()); - const players = r.sortedBy(RankType.Conquests); + const players = r.sortedBy(RankType.ConquestHumans); expect(players.length).toBe(3); const p1 = players.find((p) => p.id === "p1")!; expect(p1.username).toBe("Alice"); expect(p1.flag).toBe("USA"); - expect(p1.conquests).toBe(5); + expect(p1.conquests).toStrictEqual([5n]); expect(p1.atoms).toBe(1); expect(p1.mirv).toBe(2); }); test("correctly identifies winner", () => { const r = new Ranking(makeSession()); - const p2 = r.sortedBy(RankType.Conquests).find((p) => p.id === "p2")!; + const p2 = r.sortedBy(RankType.ConquestHumans).find((p) => p.id === "p2")!; expect(p2.winner).toBe(true); }); @@ -157,7 +157,7 @@ describe("Ranking class", () => { test("lifetime score is percentage of duration", () => { const r = new Ranking(makeSession()); - const p3 = r.sortedBy(RankType.Conquests).find((p) => p.id === "p3")!; + const p3 = r.sortedBy(RankType.ConquestHumans).find((p) => p.id === "p3")!; const expected = Number(BigInt(600)) / gameDuration; expect(r.score(p3, RankType.Lifetime)).toBe(expected); }); @@ -170,7 +170,7 @@ describe("Ranking class", () => { test("winners should be ahead of players with same score", () => { const r = new Ranking(makeSession()); - const sortedPlayers = r.sortedBy(RankType.Conquests); + const sortedPlayers = r.sortedBy(RankType.ConquestHumans); expect(sortedPlayers[0].id).toBe("p2"); // p2 & p3 same score but winner first }); diff --git a/tests/Stats.test.ts b/tests/Stats.test.ts index 1b654210b..4cab4b2fe 100644 --- a/tests/Stats.test.ts +++ b/tests/Stats.test.ts @@ -162,14 +162,14 @@ describe("Stats", () => { expect(stats.stats()).toStrictEqual({ client1: { gold: [0n, 1n], - conquests: 1n, + conquests: [1n], }, }); stats.goldWar(player1, player2, 1); expect(stats.stats()).toStrictEqual({ client1: { gold: [0n, 2n], - conquests: 2n, + conquests: [2n], }, }); }); From c71af0e6026f64c6811ffe0b3feaf86f09af1759 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:55:06 +0100 Subject: [PATCH 03/45] =?UTF-8?q?Fix=20map=20name=20formatting=20for=20Bai?= =?UTF-8?q?kal=20Nuke=20Wars=20=F0=9F=94=A7=20(#2922)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Fixes this little i18n problem: Screenshot 2026-01-16 050833 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/core/game/Game.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index fdfff12d8..313bd58de 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -103,7 +103,7 @@ export enum GameMapType { Montreal = "Montreal", NewYorkCity = "New York City", Achiran = "Achiran", - BaikalNukeWars = "Baikal (Nuke Wars)", + BaikalNukeWars = "Baikal Nuke Wars", FourIslands = "Four Islands", Svalmel = "Svalmel", GulfOfStLawrence = "Gulf of St. Lawrence", From ac56fccd8e17009d6a6e45eb7610be2d08b357d9 Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Mon, 19 Jan 2026 17:09:17 +0100 Subject: [PATCH 04/45] Fix warship pathfinding (#2955) ## Description: As reported on Discord, warship could get stuck. This PR fixes the issue. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole --- src/core/pathfinding/PathFinder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index 9a625956f..f77776c36 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -47,8 +47,8 @@ export class PathFinding { return PathFinderBuilder.create(pf) .wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn)) .wrap((pf) => new SmoothingWaterTransformer(pf, miniMap)) + .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) - .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } @@ -57,8 +57,8 @@ export class PathFinding { const pf = new AStarWater(miniMap); return PathFinderBuilder.create(pf) + .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) - .wrap((pf) => new ShoreCoercingTransformer(pf, game.map())) .buildWithStepper(tileStepperConfig(game)); } From 21a035cdb41702cb6fe92c9f231c344b918e21db Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:49:10 +0100 Subject: [PATCH 05/45] Fix random spawn (#2958) ## Description: "You can pick your spawn in random spawn games in v29. You need to open the menu and click on the attack button. That's it." Thats the fix for this problem. Radial menu no longer allows to attack (pick a spawn) while random spawn is enabled. And SpawnExecution got a check so you cannot send malicious intents. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/graphics/layers/RadialMenuElements.ts | 3 +++ src/core/execution/SpawnExecution.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 6f72f7149..44d63bb1f 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -572,6 +572,9 @@ export const centerButtonElement: CenterButtonElement = { return true; } if (params.game.inSpawnPhase()) { + if (params.game.config().isRandomSpawn()) { + return true; + } if (tileOwner.isPlayer()) { return true; } diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts index 5f0694fd8..4162e85fc 100644 --- a/src/core/execution/SpawnExecution.ts +++ b/src/core/execution/SpawnExecution.ts @@ -42,6 +42,11 @@ export class SpawnExecution implements Execution { player = this.mg.addPlayer(this.playerInfo); } + // Security: If random spawn is enabled, prevent players from re-rolling their spawn location + if (this.mg.config().isRandomSpawn() && player.hasSpawned()) { + return; + } + this.tile ??= this.randomSpawnLand(); if (this.tile === undefined) { From 9d0ae109128d38865bf58b3301492ada2eb3bbda Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 01:34:22 +0100 Subject: [PATCH 06/45] Quickfix: Disable nations in ranked and change map selection (#2957) ## Description: Quickfix: Disable nations in ranked and change map selection (Lewis wanted these, Australia three times so it occurs more often) Just a quickfix, we will probably have to improve the map selection later on, and maybe play on non-compact maps too? ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/server/MapPlaylist.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 93926de44..c9cf49a17 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -144,12 +144,11 @@ export class MapPlaylist { } public get1v1Config(): GameConfig { - const ffaMaps = [ + const maps = [ GameMapType.Iceland, - GameMapType.World, - GameMapType.EuropeClassic, GameMapType.Australia, - GameMapType.FaroeIslands, + GameMapType.Australia, + GameMapType.Australia, GameMapType.Pangaea, GameMapType.Italia, GameMapType.FalklandIslands, @@ -158,7 +157,7 @@ export class MapPlaylist { return { donateGold: false, donateTroops: false, - gameMap: ffaMaps[Math.floor(Math.random() * ffaMaps.length)], + gameMap: maps[Math.floor(Math.random() * maps.length)], maxPlayers: 2, gameType: GameType.Public, gameMapSize: GameMapSize.Compact, @@ -169,7 +168,7 @@ export class MapPlaylist { maxTimerValue: 10, // 10 minutes instantBuild: false, randomSpawn: false, - disableNations: false, + disableNations: true, gameMode: GameMode.FFA, bots: 100, spawnImmunityDuration: 5 * 10, From 697a346c86fe8962b71152cca8b90c5bcda7ab7a Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 19 Jan 2026 19:26:09 -0800 Subject: [PATCH 07/45] Increase worker initailization timeout from 5=>20s to prevent worker timeout, add duration logging to some longer operations --- src/core/game/FetchGameMapLoader.ts | 4 ++++ src/core/game/GameImpl.ts | 6 ++++++ src/core/worker/WorkerClient.ts | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/core/game/FetchGameMapLoader.ts b/src/core/game/FetchGameMapLoader.ts index 00464c76f..ff6a3ca5f 100644 --- a/src/core/game/FetchGameMapLoader.ts +++ b/src/core/game/FetchGameMapLoader.ts @@ -51,6 +51,7 @@ export class FetchGameMapLoader implements GameMapLoader { } private async loadBinaryFromUrl(url: string) { + const startTime = performance.now(); const response = await fetch(url); if (!response.ok) { @@ -58,6 +59,9 @@ export class FetchGameMapLoader implements GameMapLoader { } const data = await response.arrayBuffer(); + console.log( + `[MapLoader] ${url}: ${(performance.now() - startTime).toFixed(0)}ms`, + ); return new Uint8Array(data); } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index a2bd1c902..4439ae847 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -103,6 +103,8 @@ export class GameImpl implements Game { private _config: Config, private _stats: Stats, ) { + const constructorStart = performance.now(); + this._terraNullius = new TerraNulliusImpl(); this._width = _map.width(); this._height = _map.height(); @@ -123,6 +125,10 @@ export class GameImpl implements Game { { cachePaths: true }, ); } + + console.log( + `[GameImpl] Constructor total: ${(performance.now() - constructorStart).toFixed(0)}ms`, + ); } private populateTeams() { diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index e6e80b82d..fe0ac38fc 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -80,7 +80,7 @@ export class WorkerClient { this.messageHandlers.delete(messageId); reject(new Error("Worker initialization timeout")); } - }, 5000); // 5 second timeout + }, 20000); // 20 second timeout }); } From f6454963b215e46b69456307425bbe0629d7fd3a Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Tue, 20 Jan 2026 04:28:28 +0100 Subject: [PATCH 08/45] Pathfinding refinements (#2959) ## Description: ### Short path for multi-source HPA* Math was not mathing, increased the bounds to 260x260, it is a bit slower but should work better. The short path was breaking when player owned a lot of shores. This is because the bounding box of tiles with less than 120 distance + 10 padding could be as big as 260x260 and the optimized array was set to 140x140. I made mistake of calculating it as `2 * (60 + 10)` instead of `2 * (120 + 10)`. ### LoS path refinement Previously, we ran 2 passes of LoS smoothing on the path. However, since we are effectively tracing the same path, the line of sight is essentially the same. This PR makes second line of sight stop on water tiles with magnitude `n + 1` compared to first path. Practically, this means it'll attempt LoS exactly 1 tile after previous corner. See screenshot. image ### SendBoatAttackIntentEvent The flow of sending transport ships is currently strange. This PR makes the flow more sane. **Old flow** ``` - Player clicks TARGET tile, it can be deep inland - Client asks Worker for the best START tile to TARGET tile - Worker answers `false`, since the tile is inland - Client sends BoatAttackIntent with START=false and TARGET tiles set - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker re-computes best START to DESTINATION - Worker sends boat from START to DESTINATION ``` **New flow** ``` - Player clicks TARGET tile, it can be deep inland - Client sends BoatAttackIntent with TARGET - Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET - Worker computes START as the best tile to DESTINATION - Worker sends boat from START to DESTINATION ``` ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole --- src/client/ClientGameRunner.ts | 17 ++-- src/client/Transport.ts | 4 - .../graphics/layers/PlayerActionHandler.ts | 11 +-- .../graphics/layers/RadialMenuElements.ts | 12 +-- src/core/Schemas.ts | 2 - src/core/execution/ExecutionManager.ts | 8 +- src/core/execution/TransportShipExecution.ts | 89 ++++++------------- src/core/execution/utils/AiAttackBehavior.ts | 16 +--- .../algorithms/AStar.WaterHierarchical.ts | 4 +- .../transformers/SmoothingWaterTransformer.ts | 31 ++++--- tests/Attack.test.ts | 32 ++----- tests/Disconnected.test.ts | 24 +---- 12 files changed, 67 insertions(+), 183 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 971a9cd71..bd377d35d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -713,17 +713,12 @@ export class ClientGameRunner { private sendBoatAttackIntent(tile: TileRef) { if (!this.myPlayer) return; - this.myPlayer.bestTransportShipSpawn(tile).then((spawn: number | false) => { - if (this.myPlayer === null) throw new Error("not initialized"); - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.gameView.owner(tile).id(), - tile, - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - spawn === false ? null : spawn, - ), - ); - }); + this.eventBus.emit( + new SendBoatAttackIntentEvent( + tile, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + ), + ); } private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 1f35131a4..69d8d1ac0 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -81,10 +81,8 @@ export class SendAttackIntentEvent implements GameEvent { export class SendBoatAttackIntentEvent implements GameEvent { constructor( - public readonly targetID: PlayerID | null, public readonly dst: TileRef, public readonly troops: number, - public readonly src: TileRef | null = null, ) {} } @@ -498,10 +496,8 @@ export class Transport { this.sendIntent({ type: "boat", clientID: this.lobbyConfig.clientID, - targetID: event.targetID, troops: event.troops, dst: event.dst, - src: event.src, }); } diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 672cc2baf..54714cadb 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -1,5 +1,5 @@ import { EventBus } from "../../../core/EventBus"; -import { PlayerActions, PlayerID } from "../../../core/game/Game"; +import { PlayerActions } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { PlayerView } from "../../../core/game/GameView"; import { @@ -39,18 +39,11 @@ export class PlayerActionHandler { ); } - handleBoatAttack( - player: PlayerView, - targetId: PlayerID | null, - targetTile: TileRef, - spawnTile: TileRef | null, - ) { + handleBoatAttack(player: PlayerView, targetTile: TileRef) { this.eventBus.emit( new SendBoatAttackIntentEvent( - targetId, targetTile, this.uiState.attackRatio * player.troops(), - spawnTile, ), ); } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 44d63bb1f..1dcf3eb90 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -548,17 +548,7 @@ export const boatMenuElement: MenuElement = { color: COLORS.boat, action: async (params: MenuElementParams) => { - const spawn = await params.playerActionHandler.findBestTransportShipSpawn( - params.myPlayer, - params.tile, - ); - - params.playerActionHandler.handleBoatAttack( - params.myPlayer, - params.selected?.id() ?? null, - params.tile, - spawn !== false ? spawn : null, - ); + params.playerActionHandler.handleBoatAttack(params.myPlayer, params.tile); params.closeMenu(); }, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 28362063f..d225857c5 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -284,10 +284,8 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({ export const BoatAttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("boat"), - targetID: ID.nullable(), troops: z.number().nonnegative(), dst: z.number(), - src: z.number().nullable(), }); export const AllianceRequestIntentSchema = BaseIntentSchema.extend({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index a161e5eb1..56d66e547 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -72,13 +72,7 @@ export class Executor { case "spawn": return new SpawnExecution(this.gameID, player.info(), intent.tile); case "boat": - return new TransportShipExecution( - player, - intent.targetID, - intent.dst, - intent.troops, - intent.src, - ); + return new TransportShipExecution(player, intent.dst, intent.troops); case "allianceRequest": return new AllianceRequestExecution(player, intent.recipient); case "allianceRequestReply": diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 6020d0489..0b93aa9fc 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -4,7 +4,6 @@ import { Game, MessageType, Player, - PlayerID, TerraNullius, Unit, UnitType, @@ -16,33 +15,28 @@ import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { AttackExecution } from "./AttackExecution"; const malusForRetreat = 25; + export class TransportShipExecution implements Execution { - private lastMove: number; + private active = true; // TODO: make this configurable private ticksPerMove = 1; - - private active = true; + private lastMove: number; private mg: Game; private target: Player | TerraNullius; - - // TODO make private - public path: TileRef[]; - private dst: TileRef | null; - - private boat: Unit; - private pathFinder: SteppingPathFinder; + private dst: TileRef | null; + private src: TileRef | null; + private boat: Unit; + private originalOwner: Player; constructor( private attacker: Player, - private targetID: PlayerID | null, private ref: TileRef, - private startTroops: number, - private src: TileRef | null, + private troops: number, ) { this.originalOwner = this.attacker; } @@ -52,24 +46,15 @@ export class TransportShipExecution implements Execution { } init(mg: Game, ticks: number) { - if (this.targetID !== null && !mg.hasPlayer(this.targetID)) { - console.warn(`TransportShipExecution: target ${this.targetID} not found`); - this.active = false; - return; - } if (!mg.isValidRef(this.ref)) { console.warn(`TransportShipExecution: ref ${this.ref} not valid`); this.active = false; return; } - if (this.src !== null && !mg.isValidRef(this.src)) { - console.warn(`TransportShipExecution: src ${this.src} not valid`); - this.active = false; - return; - } this.lastMove = ticks; this.mg = mg; + this.target = mg.owner(this.ref); this.pathFinder = PathFinding.Water(mg); if ( @@ -87,73 +72,51 @@ export class TransportShipExecution implements Execution { return; } - if ( - this.targetID === null || - this.targetID === this.mg.terraNullius().id() - ) { - this.target = mg.terraNullius(); - } else { - this.target = mg.player(this.targetID); - } if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) { this.active = false; return; } - this.startTroops ??= this.mg + this.troops ??= this.mg .config() .boatAttackAmount(this.attacker, this.target); - - this.startTroops = Math.min(this.startTroops, this.attacker.troops()); + this.troops = Math.min(this.troops, this.attacker.troops()); this.dst = targetTransportTile(this.mg, this.ref); + if (this.dst === null) { console.warn( - `${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`, + `${this.attacker} cannot send ship to ${this.target}, cannot find target tile`, ); this.active = false; return; } - const closestTileSrc = this.attacker.canBuild( - UnitType.TransportShip, - this.dst, - ); - if (closestTileSrc === false) { - console.warn(`can't build transport ship`); + const src = this.attacker.canBuild(UnitType.TransportShip, this.dst); + + if (src === false) { + console.warn( + `${this.attacker} cannot send ship to ${this.target}, cannot find start tile`, + ); this.active = false; return; } - if (this.src === null) { - // Only update the src if it's not already set - // because we assume that the src is set to the best spawn tile - this.src = closestTileSrc; - } else { - if ( - this.mg.owner(this.src) !== this.attacker || - !this.mg.isShore(this.src) - ) { - console.warn( - `src is not a shore tile or not owned by: ${this.attacker.name()}`, - ); - this.src = closestTileSrc; - } - } + this.src = src; this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, { - troops: this.startTroops, - targetTile: this.dst ?? undefined, + troops: this.troops, + targetTile: this.dst, }); // Notify the target player about the incoming naval invasion - if (this.targetID && this.targetID !== mg.terraNullius().id()) { + if (this.target.id() !== mg.terraNullius().id()) { mg.displayIncomingUnit( this.boat.id(), // TODO TranslateText `Naval invasion incoming from ${this.attacker.displayName()}`, MessageType.NAVAL_INVASION_INBOUND, - this.targetID, + this.target.id(), ); } @@ -254,7 +217,7 @@ export class TransportShipExecution implements Execution { new AttackExecution( this.boat.troops(), this.attacker, - this.targetID, + this.target.id(), this.dst, false, ), @@ -278,7 +241,7 @@ export class TransportShipExecution implements Execution { const map = this.mg.map(); const boatTile = this.boat.tile(); console.warn( - `TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.targetID}`, + `TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.target.id()}`, ); this.attacker.addTroops(this.boat.troops()); this.boat.delete(false); diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 4e27b6bb4..fd252316b 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -114,13 +114,7 @@ export class AiAttackBehavior { } this.game.addExecution( - new TransportShipExecution( - this.player, - this.game.owner(dst).id(), - dst, - this.player.troops() / 5, - null, - ), + new TransportShipExecution(this.player, dst, this.player.troops() / 5), ); return; } @@ -741,13 +735,7 @@ export class AiAttackBehavior { } this.game.addExecution( - new TransportShipExecution( - this.player, - target.id(), - closest.y, - troops, - null, - ), + new TransportShipExecution(this.player, closest.y, troops), ); } diff --git a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts index 78a8ff6bc..2958de79a 100644 --- a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts @@ -42,8 +42,8 @@ export class AStarWaterHierarchical implements PathFinder { maxMultiClusterNodes, ); - // BoundedAStar for short path multi-source (120 + 2*10 padding = 140) - const shortPathSize = 140; + // BoundedAStar for short path multi-source + const shortPathSize = 260; // 2 * (120 + padding 10) const maxShortPathNodes = shortPathSize * shortPathSize; this.localAStarShortPath = new AStarWaterBounded(map, maxShortPathNodes); diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts index 5b4bd0b0c..549e047b5 100644 --- a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -8,13 +8,15 @@ import { PathFinder } from "../types"; const ENDPOINT_REFINEMENT_TILES = 50; const LOCAL_ASTAR_MAX_AREA = 100 * 100; -const LOS_MIN_MAGNITUDE = 3; +const LOS_MIN_MAGNITUDE_PASS1 = 2; +const LOS_MIN_MAGNITUDE_PASS2 = 3; const MAGNITUDE_MASK = 0x1f; /** - * Water path smoother transformer with two passes: + * Water path smoother transformer: * 1. Binary search LOS smoothing (avoids shallow water) * 2. Local A* refinement on endpoints (first/last N tiles) + * 3. Binary search LOS smoothing again (farther from shore) */ export class SmoothingWaterTransformer implements PathFinder { private readonly mapWidth: number; @@ -47,20 +49,24 @@ export class SmoothingWaterTransformer implements PathFinder { } // Pass 1: LOS smoothing with binary search - let smoothed = DebugSpan.wrap("smoother:los", () => this.losSmooth(path)); + let smoothed = DebugSpan.wrap("smoother:los", () => + this.losSmooth(path, LOS_MIN_MAGNITUDE_PASS1), + ); // Pass 2: Local A* refinement on endpoints smoothed = DebugSpan.wrap("smoother:refine", () => this.refineEndpoints(smoothed), ); - // Pass 3: LOS smoothing again (refinement may create new shortcut opportunities) - smoothed = DebugSpan.wrap("smoother:los2", () => this.losSmooth(smoothed)); + // Pass 3: LOS smoothing again, farther from the shore + smoothed = DebugSpan.wrap("smoother:los2", () => + this.losSmooth(smoothed, LOS_MIN_MAGNITUDE_PASS2), + ); return smoothed; } - private losSmooth(path: TileRef[]): TileRef[] { + private losSmooth(path: TileRef[], minMagnitude: number): TileRef[] { const result: TileRef[] = [path[0]]; let current = 0; @@ -72,7 +78,7 @@ export class SmoothingWaterTransformer implements PathFinder { while (lo <= hi) { const mid = (lo + hi) >>> 1; - if (this.canSee(path[current], path[mid])) { + if (this.canSee(path[current], path[mid], minMagnitude)) { farthest = mid; lo = mid + 1; } else { @@ -188,7 +194,7 @@ export class SmoothingWaterTransformer implements PathFinder { return this.localAStar.searchBounded(from, to, bounds); } - private canSee(from: TileRef, to: TileRef): boolean { + private canSee(from: TileRef, to: TileRef, minMagnitude: number): boolean { const x0 = from % this.mapWidth; const y0 = (from / this.mapWidth) | 0; const x1 = to % this.mapWidth; @@ -214,7 +220,7 @@ export class SmoothingWaterTransformer implements PathFinder { // Check magnitude - avoid shallow water const magnitude = this.terrain[tile] & MAGNITUDE_MASK; - if (magnitude < LOS_MIN_MAGNITUDE) return false; + if (magnitude < minMagnitude) return false; if (x === x1 && y === y1) return true; @@ -229,10 +235,7 @@ export class SmoothingWaterTransformer implements PathFinder { const intermediateTile = (y * this.mapWidth + x) as TileRef; const intMag = this.terrain[intermediateTile] & MAGNITUDE_MASK; - if ( - !this.isTraversable(intermediateTile) || - intMag < LOS_MIN_MAGNITUDE - ) { + if (!this.isTraversable(intermediateTile) || intMag < minMagnitude) { // Try alternative path x -= sx; err += dy; @@ -241,7 +244,7 @@ export class SmoothingWaterTransformer implements PathFinder { const altTile = (y * this.mapWidth + x) as TileRef; const altMag = this.terrain[altTile] & MAGNITUDE_MASK; - if (!this.isTraversable(altTile) || altMag < LOS_MIN_MAGNITUDE) + if (!this.isTraversable(altTile) || altMag < minMagnitude) return false; x += sx; diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 75e536f08..e2bf619dc 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -21,10 +21,8 @@ let defender: Player; let defenderSpawn: TileRef; let attackerSpawn: TileRef; -function sendBoat(target: TileRef, source: TileRef, troops: number) { - game.addExecution( - new TransportShipExecution(defender, null, target, troops, source), - ); +function sendBoat(target: TileRef, troops: number) { + game.addExecution(new TransportShipExecution(defender, target, troops)); } const immunityPhaseTicks = 10; @@ -114,7 +112,7 @@ describe("Attack", () => { constructionExecution(game, defender, 1, 1, UnitType.MissileSilo); expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); - sendBoat(game.ref(15, 8), game.ref(10, 5), 100); + sendBoat(game.ref(15, 8), 100); constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3); const nuke = defender.units(UnitType.AtomBomb)[0]; @@ -133,7 +131,7 @@ describe("Attack", () => { const player_start_troops = defender.troops(); const boat_troops = player_start_troops * 0.5; - sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops); + sendBoat(game.ref(15, 8), boat_troops); game.executeNextTick(); @@ -357,7 +355,7 @@ describe("Attack immunity", () => { null, "playerB_id", ); - playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11)); + playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15)); while (game.inSpawnPhase()) { game.executeNextTick(); @@ -412,15 +410,7 @@ describe("Attack immunity", () => { test("Should not be able to send a boat during immunity phase", async () => { // Player A sends a boat targeting Player B - game.addExecution( - new TransportShipExecution( - playerA, - playerB.id(), - game.ref(15, 8), - 10, - game.ref(10, 5), - ), - ); + game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10)); game.executeNextTick(); expect(playerA.units(UnitType.TransportShip)).toHaveLength(0); }); @@ -428,15 +418,7 @@ describe("Attack immunity", () => { test("Should be able to send a boat after immunity phase", async () => { waitForImmunityToEnd(); // Player A sends a boat targeting Player B - game.addExecution( - new TransportShipExecution( - playerA, - playerB.id(), - game.ref(15, 8), - 10, - game.ref(7, 0), - ), - ); + game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10)); game.executeNextTick(); expect(playerA.units(UnitType.TransportShip)).toHaveLength(1); }); diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index 3dcc7011f..c52f00911 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -350,13 +350,7 @@ describe("Disconnected", () => { const enemyShoreTile = game.map().ref(coastX, 15); game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - 100, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, 100), ); executeTicks(game, 1); @@ -387,13 +381,7 @@ describe("Disconnected", () => { const enemyShoreTile = game.map().ref(coastX, 15); game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - 100, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, 100), ); executeTicks(game, 1); @@ -425,13 +413,7 @@ describe("Disconnected", () => { const boatTroops = 100; game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - boatTroops, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, boatTroops), ); executeTicks(game, 1); From 776c644a84ce91019c10e090de56f0b20285d8a3 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 20 Jan 2026 06:25:36 -0800 Subject: [PATCH 09/45] increase frequency of didier --- src/server/MapPlaylist.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index c9cf49a17..d09da6eca 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -62,6 +62,7 @@ const frequency: Partial> = { StraitOfHormuz: 4, Surrounded: 4, DidierFrance: 1, + Didier: 40, AmazonRiver: 3, Sierpinski: 10, }; From cf63340227d6d712ec46e7871adfd64e9b5256f8 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 20 Jan 2026 10:31:07 -0800 Subject: [PATCH 10/45] reduce frequency of didier 40=>1 --- src/server/MapPlaylist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index d09da6eca..3eac4ce45 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -62,7 +62,7 @@ const frequency: Partial> = { StraitOfHormuz: 4, Surrounded: 4, DidierFrance: 1, - Didier: 40, + Didier: 1, AmazonRiver: 3, Sierpinski: 10, }; From 4d668e299c0d846a478db2f1d86cec48cfe09f13 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:29:58 +0100 Subject: [PATCH 11/45] Optimize team game frequency (#2970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: I analyzed the avg fill time of team games (past 30 days) and was able to confirm what people in the main discord said: Duos / Trios / Quads fill slower. Might be something for v29. | Game Mode | Games | Avg Fill Time | |-----------|-------|---------------| | **FFA** (Excluding ranked) | 53,654 | **29s** | | Team: 2 teams | 3,379 | 33s | | Team: 3 teams | 3,291 | 32s | | Team: 4 teams | 3,242 | 31s | | Team: 5 teams | 3,364 | 32s | | Team: 6 teams | 3,381 | 31s | | Team: 7 teams | 3,227 | 31s | | Team: Duos | 3,295 | **43s** | | Team: Trios | 3,300 | 39s | | Team: Quads | 3,299 | 37s | | Team: Humans Vs Nations | 101 | **24s** | Therefore I propose to decrease the chance of Duos, Trios and Quads (especially Duos). Also, increase the chance of HumansVsNations because its special and unlike all the other team modes. | Team Config | Previous | New | |-------------|----------|-----| | 2 teams | 10% | 10% | | 3 teams | 10% | 10% | | 4 teams | 10% | 10% | | 5 teams | 10% | 10% | | 6 teams | 10% | 10% | | 7 teams | 10% | 10% | | **Duos** | 10% | **5%** ↓ | | **Trios** | 10% | **7.5%** ↓ | | **Quads** | 10% | **7.5%** ↓ | | **HumansVsNations** | 10% | **20%** ↑ | ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/server/MapPlaylist.ts | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 3eac4ce45..22b47c51b 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -72,18 +72,18 @@ interface MapWithMode { mode: GameMode; } -const TEAM_COUNTS = [ - 2, - 3, - 4, - 5, - 6, - 7, - Duos, - Trios, - Quads, - HumansVsNations, -] as const satisfies TeamCountConfig[]; +const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ + { config: 2, weight: 10 }, + { config: 3, weight: 10 }, + { config: 4, weight: 10 }, + { config: 5, weight: 10 }, + { config: 6, weight: 10 }, + { config: 7, weight: 10 }, + { config: Duos, weight: 5 }, + { config: Trios, weight: 7.5 }, + { config: Quads, weight: 7.5 }, + { config: HumansVsNations, weight: 20 }, +]; export class MapPlaylist { private mapsPlaylist: MapWithMode[] = []; @@ -193,7 +193,17 @@ export class MapPlaylist { } private getTeamCount(): TeamCountConfig { - return TEAM_COUNTS[Math.floor(Math.random() * TEAM_COUNTS.length)]; + const totalWeight = TEAM_WEIGHTS.reduce((sum, w) => sum + w.weight, 0); + const roll = Math.random() * totalWeight; + + let cumulativeWeight = 0; + for (const { config, weight } of TEAM_WEIGHTS) { + cumulativeWeight += weight; + if (roll < cumulativeWeight) { + return config; + } + } + return TEAM_WEIGHTS[0].config; } private getRandomPublicGameModifiers(): PublicGameModifiers { From e9e2f06d69c6ca5a3d4e33b147a67434d537095d Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:43:48 +0100 Subject: [PATCH 12/45] =?UTF-8?q?Humans=20are=20severely=20skill=20issued?= =?UTF-8?q?=20=E2=9A=A0=EF=B8=8F=20Change=20HvN=20difficulty=20to=20Medium?= =?UTF-8?q?=20(#2971)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: For v29 HvN winrate is between 10 and 15%, but should be around 50%. 1. Change HvN difficulty to Medium 2. Little balance change in `NationAllianceBehavior` ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/core/execution/nation/NationAllianceBehavior.ts | 8 ++++---- src/server/MapPlaylist.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index cbf78304b..5743b6d67 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -240,13 +240,13 @@ export class NationAllianceBehavior { const { difficulty } = this.game.config().gameConfig(); switch (difficulty) { case Difficulty.Easy: - return false; // 0% chance to reject on easy + return this.random.nextInt(0, 100) < 25; // 25% chance to reject on easy case Difficulty.Medium: - return this.random.nextInt(0, 100) < 20; // 20% chance to reject on medium + return this.random.nextInt(0, 100) < 50; // 50% chance to reject on medium case Difficulty.Hard: - return this.random.nextInt(0, 100) < 40; // 40% chance to reject on hard + return this.random.nextInt(0, 100) < 75; // 75% chance to reject on hard case Difficulty.Impossible: - return this.random.nextInt(0, 100) < 60; // 60% chance to reject on impossible + return true; // 100% chance to reject on impossible default: assertNever(difficulty); } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 22b47c51b..6e05022d8 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -129,7 +129,7 @@ export class MapPlaylist { publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, startingGold, difficulty: - playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy, + playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, From 207d6b0a2844fbf5de44ca1941db20a0dd0b2e02 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 21 Jan 2026 15:13:09 -0800 Subject: [PATCH 13/45] cache cosmetics.json on client --- src/client/Cosmetics.ts | 37 ++++++++++++++++++++++--------------- src/client/PatternInput.ts | 16 +--------------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 821d8e25e..d1f00e88d 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -29,23 +29,30 @@ export async function handlePurchase( window.location.href = url; } +let __cosmetics: Promise | null = null; export async function fetchCosmetics(): Promise { - try { - const response = await fetch(`${getApiBase()}/cosmetics.json`); - if (!response.ok) { - console.error(`HTTP error! status: ${response.status}`); - return null; - } - const result = CosmeticsSchema.safeParse(await response.json()); - if (!result.success) { - console.error(`Invalid cosmetics: ${result.error.message}`); - return null; - } - return result.data; - } catch (error) { - console.error("Error getting cosmetics:", error); - return null; + if (__cosmetics !== null) { + return __cosmetics; } + __cosmetics = (async () => { + try { + const response = await fetch(`${getApiBase()}/cosmetics.json`); + if (!response.ok) { + console.error(`HTTP error! status: ${response.status}`); + return null; + } + const result = CosmeticsSchema.safeParse(await response.json()); + if (!result.success) { + console.error(`Invalid cosmetics: ${result.error.message}`); + return null; + } + return result.data; + } catch (error) { + console.error("Error getting cosmetics:", error); + return null; + } + })(); + return __cosmetics; } export function patternRelationship( diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts index 7c435c63c..755c7c834 100644 --- a/src/client/PatternInput.ts +++ b/src/client/PatternInput.ts @@ -7,20 +7,6 @@ import { renderPatternPreview } from "./components/PatternButton"; import { fetchCosmetics } from "./Cosmetics"; import { translateText } from "./Utils"; -// Module-level cosmetics cache to avoid refetching on every component mount -let cosmeticsCache: Promise | null = null; - -function getCachedCosmetics(): Promise { - if (!cosmeticsCache) { - const fetchPromise = fetchCosmetics(); - cosmeticsCache = fetchPromise.catch((err) => { - cosmeticsCache = null; - throw err; - }); - } - return cosmeticsCache; -} - @customElement("pattern-input") export class PatternInput extends LitElement { @state() public pattern: PlayerPattern | null = null; @@ -63,7 +49,7 @@ export class PatternInput extends LitElement { super.connectedCallback(); this._abortController = new AbortController(); this.isLoading = true; - const cosmetics = await getCachedCosmetics(); + const cosmetics = await fetchCosmetics(); if (!this.isConnected) return; this.cosmetics = cosmetics; this.updateFromSettings(); From ae3adf915c34b9767e114c4a964f74ab2a4af420 Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Thu, 22 Jan 2026 00:13:55 +0100 Subject: [PATCH 14/45] Fix nuke telegraph for allies (#2983) ## Description: Adds back the nuke overlay for teammates ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: Mr. Box --- src/client/graphics/layers/DynamicUILayer.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/client/graphics/layers/DynamicUILayer.ts b/src/client/graphics/layers/DynamicUILayer.ts index affd9f0f4..b151a1b83 100644 --- a/src/client/graphics/layers/DynamicUILayer.ts +++ b/src/client/graphics/layers/DynamicUILayer.ts @@ -116,14 +116,25 @@ export class DynamicUILayer implements Layer { } onBombEvent(unit: UnitView) { - if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + if ( + this.createdThisTick(unit) && + (unit.owner() === myPlayer || unit.owner().isOnSameTeam(myPlayer)) + ) { const target = new NukeTelegraph(this.transformHandler, this.game, unit); this.uiElements.push(target); } } onTransportShipEvent(unit: UnitView) { - if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + if (this.createdThisTick(unit) && unit.owner() === myPlayer) { const target = new NavalTarget(this.transformHandler, this.game, unit); this.uiElements.push(target); } @@ -146,11 +157,6 @@ export class DynamicUILayer implements Layer { } } - private isOwnedByPlayer(unit: UnitView): boolean { - const my = this.game.myPlayer(); - return my !== null && unit.owner() === my; - } - private createdThisTick(unit: UnitView): boolean { return unit.createdAt() === this.game.ticks(); } From c90435fc202d1e8ea10a19e88557b09263ac7683 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:34:57 +0100 Subject: [PATCH 15/45] Fix: Nuking an Ally that is Disconnected shows a Red background ghost. Shouldn't be Red as not a Betrayal (#2988) ## Description: Previous behavior: https://youtu.be/Lv0RuBYh9qw?t=1359 New behavior: https://github.com/user-attachments/assets/acfcc4f0-157e-44a0-be28-802927a3c787 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/graphics/layers/StructureIconsLayer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index a8a7862dc..6f57e10c9 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -270,8 +270,8 @@ export class StructureIconsLayer implements Layer { myPlayer && (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) ) { - // Only check if player has allies - const allies = myPlayer.allies(); + // Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff + const allies = myPlayer.allies().filter((a) => !a.isDisconnected()); if (allies.length > 0) { targetingAlly = wouldNukeBreakAlliance({ game: this.game, From be958dd6c2f1ffe0a9471f97d0aabb326243fd2b Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:52:34 +0100 Subject: [PATCH 16/45] =?UTF-8?q?Fix=20NewsModal=20design=20a=20bit=20?= =?UTF-8?q?=F0=9F=96=8C=EF=B8=8F=20(#3002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Fix \
      margins. Maybe for v29, so people get less eye cancer 😄 Previous: Screenshot 2026-01-22 234602 Now: Screenshot 2026-01-22 234535 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/NewsModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 5a66d4c9e..4c67f10c8 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -33,7 +33,7 @@ export class NewsModal extends BaseModal { [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4 [&_h1]:text-white [&_h1]:border-b [&_h1]:border-white/10 [&_h1]:pb-2 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-blue-200 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-100 - [&_ul]:pl-5 [&_ul]:list-disc [&_ul]:space-y-1 + [&_ul]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_li]:text-gray-300 [&_li]:leading-relaxed [&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent" From dfd6a1f5f9430c8da810b36b4c75b5bfdc726f51 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Thu, 22 Jan 2026 13:21:14 +0100 Subject: [PATCH 17/45] Replace donate buttons with attack ones for AFK friendly players in radial menu (#2987) Resolves #2986 ## Description: Shows donate actions in radial menu only when friendly player is NOT disconnected. This is needed in order to let mobile/touch users attack AFK teammates. Current behavior: image With this PR: image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: deshack_82603 --- src/client/graphics/layers/MainRadialMenu.ts | 4 +++- src/client/graphics/layers/RadialMenuElements.ts | 14 +++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index 3151c6a48..c0b707577 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -134,7 +134,9 @@ export class MainRadialMenu extends LitElement implements Layer { }; const isFriendlyTarget = - recipient !== null && recipient.isFriendly(myPlayer); + recipient !== null && + recipient.isFriendly(myPlayer) && + !recipient.isDisconnected(); this.radialMenu.setCenterButtonAppearance( isFriendlyTarget ? donateTroopIcon : swordIcon, diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 1dcf3eb90..0cf28cebb 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -117,6 +117,14 @@ function isFriendlyTarget(params: MenuElementParams): boolean { return isFriendly.call(selectedPlayer, params.myPlayer); } +function isDisconnectedTarget(params: MenuElementParams): boolean { + const selectedPlayer = params.selected; + if (selectedPlayer === null) return false; + const isDisconnected = (selectedPlayer as PlayerView).isDisconnected; + if (typeof isDisconnected !== "function") return false; + return isDisconnected.call(selectedPlayer); +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars const infoChatElement: MenuElement = { id: "info_chat", @@ -571,7 +579,7 @@ export const centerButtonElement: CenterButtonElement = { return false; } - if (isFriendlyTarget(params)) { + if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { return !params.playerActions.interaction?.canDonateTroops; } @@ -581,7 +589,7 @@ export const centerButtonElement: CenterButtonElement = { if (params.game.inSpawnPhase()) { params.playerActionHandler.handleSpawn(params.tile); } else { - if (isFriendlyTarget(params)) { + if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { const selectedPlayer = params.selected as PlayerView; const ratio = params.uiState?.attackRatio ?? 1; const troopsToDonate = Math.floor(ratio * params.myPlayer.troops()); @@ -626,7 +634,7 @@ export const rootMenuElement: MenuElement = { : [ boatMenuElement, ally, - isFriendlyTarget(params) + isFriendlyTarget(params) && !isDisconnectedTarget(params) ? donateGoldRadialElement : attackMenuElement, ]), From 87dab71e42b7309daa99c65314978a8f03cd5c0d Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 24 Jan 2026 15:50:54 -0800 Subject: [PATCH 18/45] delay 1v1 game start from 5s=>7s to give more time for players to join the game --- src/server/Worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 32b2eff6e..e9f340c27 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -531,7 +531,7 @@ async function startMatchmakingPolling(gm: GameManager) { // Wait a few seconds to allow clients to connect. console.log(`Starting game ${gameId}`); game.start(); - }, 5000); + }, 7000); } } catch (error) { log.error(`Error polling lobby:`, error); From 3f95a45eaffb6d556f1cd612d51e11ae97a0f449 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:33:04 +0100 Subject: [PATCH 19/45] =?UTF-8?q?Nations=20donate=20troops=20now=20?= =?UTF-8?q?=F0=9F=92=80=20(In=20team=20games)=20(#2984)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: For v29, balances the HvN winrate. In team games, nations now donate troops to their weakest team members (if they have no attack options available). How often they donate depends on the difficulty. This PR also has some other little fixes: - For HvN games, always return true in `shouldAttack()` (make nations a bit more aggressive). - Early exit in `attackWithRandomBoat()` for performance - Early exit in `findNearestIslandEnemy()` for performance AND to make sure nations which are encircled by friends don't run into this method (=> no donation happening!) ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- .../execution/nation/NationEmojiBehavior.ts | 55 +---- src/core/execution/utils/AiAttackBehavior.ts | 206 ++++++++++++++---- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 6 + 4 files changed, 184 insertions(+), 84 deletions(-) diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts index 7507466f4..e62515bfd 100644 --- a/src/core/execution/nation/NationEmojiBehavior.ts +++ b/src/core/execution/nation/NationEmojiBehavior.ts @@ -6,7 +6,6 @@ import { Player, PlayerType, Relation, - Team, Tick, } from "../../game/Game"; import { PseudoRandom } from "../../PseudoRandom"; @@ -55,6 +54,8 @@ export class NationEmojiBehavior { ) {} maybeSendCasualEmoji() { + if (this.gameOver) return; + this.checkOverwhelmedByAttacks(); this.checkVerySmallAttack(); this.congratulateWinner(); @@ -107,60 +108,23 @@ export class NationEmojiBehavior { // Check if game is over - send congratulations private congratulateWinner(): void { - if (this.gameOver) return; + const winner = this.game.getWinner(); + if (winner === null) return; + + this.gameOver = true; - const percentToWin = this.game.config().percentageTilesOwnedToWin(); - const numTilesWithoutFallout = - this.game.numLandTiles() - this.game.numTilesWithFallout(); const isTeamGame = this.game.config().gameConfig().gameMode === GameMode.Team; if (isTeamGame) { // Team game: all nations congratulate if another team won - const teamToTiles = new Map(); - for (const player of this.game.players()) { - const team = player.team(); - if (team === null) continue; - teamToTiles.set( - team, - (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), - ); - } - - const sorted = Array.from(teamToTiles.entries()).sort( - (a, b) => b[1] - a[1], - ); - if (sorted.length === 0) return; - - const [winningTeam, winningTiles] = sorted[0]; - const winningPercent = (winningTiles / numTilesWithoutFallout) * 100; - if (winningPercent < percentToWin) return; - - this.gameOver = true; - // Don't congratulate if it's our own team - if (winningTeam === this.player.team()) return; + if (winner === this.player.team()) return; this.sendEmoji(AllPlayers, EMOJI_CONGRATULATE); } else { // FFA game: The largest nation congratulates if a human player won - const sorted = this.game - .players() - .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); - - if (sorted.length === 0) return; - - const firstPlace = sorted[0]; - - // Check if first place has won (crossed the win threshold) - const firstPlacePercent = - (firstPlace.numTilesOwned() / numTilesWithoutFallout) * 100; - if (firstPlacePercent < percentToWin) return; - - this.gameOver = true; - - // Only send if first place is a human - if (firstPlace.type() !== PlayerType.Human) return; + if (typeof winner === "string") return; // It's a team, not a player // Only the largest nation sends the congratulation const largestNation = this.game @@ -169,13 +133,12 @@ export class NationEmojiBehavior { .sort((a, b) => b.numTilesOwned() - a.numTilesOwned())[0]; if (largestNation !== this.player) return; - this.sendEmoji(firstPlace, EMOJI_CONGRATULATE); + this.sendEmoji(winner, EMOJI_CONGRATULATE); } } // Brag with our crown private brag(): void { - if (this.gameOver) return; if (!this.random.chance(300)) return; const sorted = this.game diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index fd252316b..5d6cedc08 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -1,11 +1,14 @@ import { Difficulty, Game, + GameMode, + HumansVsNations, Player, PlayerID, PlayerType, Relation, TerraNullius, + UnitType, } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; import { canBuildTransportShip } from "../../game/TransportShipUtils"; @@ -16,6 +19,7 @@ import { calculateBoundingBoxCenter, } from "../../Util"; import { AttackExecution } from "../AttackExecution"; +import { DonateTroopsExecution } from "../DonateTroopExecution"; import { NationAllianceBehavior } from "../nation/NationAllianceBehavior"; import { EMOJI_ASSIST_ACCEPT, @@ -94,6 +98,16 @@ export class AiAttackBehavior { private attackWithRandomBoat(borderingEnemies: Player[] = []) { if (this.player === null) throw new Error("not initialized"); + + // Check if we've already sent out the maximum number of transport ships + if ( + this.player.unitCount(UnitType.TransportShip) >= + this.game.config().boatMaxNumber() + ) { + return; + } + + // Check if we have any ocean shore tiles to launch from const oceanShore = Array.from(this.player.borderTiles()).filter((t) => this.game.isOceanShore(t), ); @@ -309,6 +323,8 @@ export class AiAttackBehavior { return false; }; + const donate = (): boolean => this.donateTroops(); + // Return strategies in order based on difficulty // Easy nations get the dumbest order, impossible nations get the smartest order switch (difficulty) { @@ -317,13 +333,13 @@ export class AiAttackBehavior { return [nuked, bots, retaliate, assist, betray, hated, weakest]; case Difficulty.Medium: // prettier-ignore - return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island]; + return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island, donate]; case Difficulty.Hard: // prettier-ignore - return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island]; + return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island, donate]; case Difficulty.Impossible: // prettier-ignore - return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island]; + return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island, donate]; default: assertNever(difficulty); } @@ -519,54 +535,67 @@ export class AiAttackBehavior { } private findNearestIslandEnemy(): Player | null { - const myBorder = this.player.borderTiles(); - if (myBorder.size === 0) return null; + // Check if we've already sent out the maximum number of transport ships + if ( + this.player.unitCount(UnitType.TransportShip) >= + this.game.config().boatMaxNumber() + ) { + return null; + } + + // Check if we have any ocean shore tiles to launch from + const hasOceanShore = Array.from(this.player.borderTiles()).some((t) => + this.game.isOceanShore(t), + ); + if (!hasOceanShore) return null; const filteredPlayers = this.game.players().filter((p) => { if (p === this.player) return false; - if (!p.isAlive()) return false; - if (p.borderTiles().size === 0) return false; if (this.player.isFriendly(p)) return false; // Don't spam boats into players with more troops return p.troops() < this.player.troops(); }); - if (filteredPlayers.length > 0) { - const playerCenter = this.getPlayerCenter(this.player); + if (filteredPlayers.length === 0) return null; - const sortedPlayers = filteredPlayers - .map((filteredPlayer) => { - const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer); + const playerCenter = this.getPlayerCenter(this.player); - const playerCenterTile = this.game.ref( - playerCenter.x, - playerCenter.y, - ); - const filteredPlayerCenterTile = this.game.ref( - filteredPlayerCenter.x, - filteredPlayerCenter.y, - ); + const sortedPlayers = filteredPlayers + .map((filteredPlayer) => { + const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer); - const distance = this.game.manhattanDist( - playerCenterTile, - filteredPlayerCenterTile, - ); - return { player: filteredPlayer, distance }; - }) - .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) + const playerCenterTile = this.game.ref(playerCenter.x, playerCenter.y); + const filteredPlayerCenterTile = this.game.ref( + filteredPlayerCenter.x, + filteredPlayerCenter.y, + ); - // Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one) - let selectedEnemy: Player | null; - if (sortedPlayers.length > 1 && this.random.chance(2)) { - selectedEnemy = sortedPlayers[1].player; - } else { - selectedEnemy = sortedPlayers[0].player; - } + const distance = this.game.manhattanDist( + playerCenterTile, + filteredPlayerCenterTile, + ); + return { player: filteredPlayer, distance }; + }) + .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) - if (selectedEnemy !== null) { - return selectedEnemy; + // Try players in order of distance until we find one reachable by boat + for (const entry of sortedPlayers) { + const closest = closestTwoTiles( + this.game, + Array.from(this.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ), + Array.from(entry.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ), + ); + if (closest === null) continue; + + if (canBuildTransportShip(this.game, this.player, closest.y)) { + return entry.player; } } + return null; } @@ -646,12 +675,14 @@ export class AiAttackBehavior { } shouldAttack(other: Player | TerraNullius): boolean { - // Always attack Terra Nullius, non-humans and traitors (or if we are a bot) if ( + // Always attack Terra Nullius, non-humans and traitors other.isPlayer() === false || other.type() !== PlayerType.Human || other.isTraitor() || - this.player.type() === PlayerType.Bot + // Always attack if we are a bot or in an HvN game + this.player.type() === PlayerType.Bot || + this.game.config().gameConfig().playerTeams === HumansVsNations ) { return true; } @@ -718,6 +749,10 @@ export class AiAttackBehavior { return; } + if (!canBuildTransportShip(this.game, this.player, closest.y)) { + return; + } + let troops; if (target.type() === PlayerType.Bot) { troops = this.calculateBotAttackTroops(target, this.player.troops() / 5); @@ -759,4 +794,99 @@ export class AiAttackBehavior { this.botAttackTroopsSent += troops; return troops; } + + private donateTroops(): boolean { + // Only donate in team games + if (this.game.config().gameConfig().gameMode !== GameMode.Team) { + return false; + } + + // Check if donating troops is allowed + if (this.game.config().donateTroops() === false) { + return false; + } + + // Don't donate if the game has a winner + if (this.game.getWinner() !== null) { + return false; + } + + // Skip donating based on difficulty + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + // Easy nations don't donate + return false; + case Difficulty.Medium: + // Medium nations donate 25% of the time + if (!this.random.chance(4)) { + return false; + } + break; + case Difficulty.Hard: + // Hard nations donate 50% of the time + if (!this.random.chance(2)) { + return false; + } + break; + case Difficulty.Impossible: + // Impossible nations always try to donate + break; + default: + assertNever(difficulty); + } + + // Find teammates who are currently in combat + const teammates = this.game + .players() + .filter((p) => this.player.isOnSameTeam(p)) + .filter( + (p) => p.incomingAttacks().length > 0 || p.outgoingAttacks().length > 0, + ); + + if (teammates.length === 0) { + return false; + } + + // Find teammate with lowest troop percentage (troops / maxTroops) + const teammatesWithTroopPercentage = teammates + .map((teammate) => { + const maxTroops = this.game.config().maxTroops(teammate); + const troopPercentage = teammate.troops() / Math.max(maxTroops, 1); + return { teammate, troopPercentage }; + }) + .sort((a, b) => a.troopPercentage - b.troopPercentage); + + // Try to donate to teammates in order of lowest troop percentage + let selectedTeammate: Player | null = null; + for (const entry of teammatesWithTroopPercentage) { + if (this.player.canDonateTroops(entry.teammate)) { + selectedTeammate = entry.teammate; + break; + } + } + + if (selectedTeammate === null) { + return false; + } + + // Donate a portion of our troops (keeping reserve) + const maxTroops = this.game.config().maxTroops(this.player); + const troopsToKeep = maxTroops * this.reserveRatio; + const availableTroops = this.player.troops() - troopsToKeep; + + if (availableTroops < 1) { + return false; + } + + this.game.addExecution( + new DonateTroopsExecution( + this.player, + selectedTeammate.id(), + availableTroops, + ), + ); + + return true; + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 313bd58de..1c56d5d46 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -755,6 +755,7 @@ export interface Game extends GameMap { inSpawnPhase(): boolean; executeNextTick(): GameUpdates; setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void; + getWinner(): Player | Team | null; config(): Config; isPaused(): boolean; setPaused(paused: boolean): void; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 4439ae847..6d409fc58 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -92,6 +92,7 @@ export class GameImpl implements Game { private nextAllianceID: number = 0; private _isPaused: boolean = false; + private _winner: Player | Team | null = null; private _miniWaterGraph: AbstractGraph | null = null; private _miniWaterHPA: AStarWaterHierarchical | null = null; @@ -718,6 +719,7 @@ export class GameImpl implements Game { } setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void { + this._winner = winner; this.addUpdate({ type: GameUpdateType.Win, winner: this.makeWinner(winner), @@ -725,6 +727,10 @@ export class GameImpl implements Game { }); } + getWinner(): Player | Team | null { + return this._winner; + } + private makeWinner(winner: string | Player): Winner | undefined { if (typeof winner === "string") { return [ From a72c87baa0ee3eb460b2056187016e09a8d505c2 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Wed, 21 Jan 2026 05:32:59 +0100 Subject: [PATCH 20/45] Change join-changed event listener to fix Game Replay functionality (#2968) Resolves #2967 ## Description: The "Replay" action on recent games doesn't work anymore after the release of v29. The problem arises because `AccountModal.viewGame()` correctly calls `history.pushState()` with the game URL and then dispatches the `join-changed` event. The `join-changed` event listener in `Main.ts` calls `onHashUpdate()`, which first calls `JoinPrivateLobbyModal.close()` and then handles the new URL. The problem is that `JoinPrivateLobbyModal.onClose()` resets the modal UI, but also replaces the history state with `/`, therefore `handleUrl()` receives the homepage URL instead of the game URL. This PR fixes the above by creating a dedicated callback for the `join-changed` event (which is dispatched only by `AccountModal` ATM), skipping the `JoinPrivateLobbyModal.close()` call. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: deshack_82603 --- src/client/Main.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 8d1df2004..5f4342e3e 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -588,12 +588,8 @@ class Client { const onHashUpdate = () => { // Reset the UI to its initial state this.joinModal?.close(); - if (this.gameStop !== null) { - this.handleLeaveLobby(); - } - // Attempt to join lobby - this.handleUrl(); + onJoinChanged(); }; const onPopState = () => { @@ -627,10 +623,19 @@ class Client { } }; + const onJoinChanged = () => { + if (this.gameStop !== null) { + this.handleLeaveLobby(); + } + + // Attempt to join lobby + this.handleUrl(); + }; + // Handle browser navigation & manual hash edits window.addEventListener("popstate", onPopState); window.addEventListener("hashchange", onHashUpdate); - window.addEventListener("join-changed", onHashUpdate); + window.addEventListener("join-changed", onJoinChanged); function updateSliderProgress(slider: HTMLInputElement) { const percent = From 3d9f0aec6c5e95478db9c85ea50a30c0ce5d0727 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 26 Jan 2026 13:34:04 -0800 Subject: [PATCH 21/45] Migrate from publift to playwire ads (#3039) ## Description: Use playwire ad integration ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- index.html | 18 ++- src/client/GutterAds.ts | 152 +++++++++---------- src/client/Main.ts | 58 ++----- src/client/graphics/GameRenderer.ts | 12 +- src/client/graphics/layers/AdTimer.ts | 28 ---- src/client/graphics/layers/InGameHeaderAd.ts | 112 ++++++++++++++ 6 files changed, 219 insertions(+), 161 deletions(-) delete mode 100644 src/client/graphics/layers/AdTimer.ts create mode 100644 src/client/graphics/layers/InGameHeaderAd.ts diff --git a/index.html b/index.html index cad490b1c..b1f825ae2 100644 --- a/index.html +++ b/index.html @@ -75,11 +75,11 @@ defer > - - + +
      + +
      diff --git a/src/client/GutterAds.ts b/src/client/GutterAds.ts index caa33623a..91cc08e30 100644 --- a/src/client/GutterAds.ts +++ b/src/client/GutterAds.ts @@ -1,70 +1,46 @@ -import { LitElement, html } from "lit"; +import { LitElement, css, html } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { UserMeResponse } from "../core/ApiSchemas"; -import { isInIframe } from "./Utils"; - -const LEFT_FUSE = "gutter-ad-container-left"; -const RIGHT_FUSE = "gutter-ad-container-right"; -// Minimum screen width to show ads (larger than typical Chromebook) -const MIN_SCREEN_WIDTH = 1400; @customElement("gutter-ads") export class GutterAds extends LitElement { @state() private isVisible: boolean = false; + @state() + private adLoaded: boolean = false; + + private leftAdType: string = "standard_iab_left2"; + private rightAdType: string = "standard_iab_rght1"; + private leftContainerId: string = "gutter-ad-container-left"; + private rightContainerId: string = "gutter-ad-container-right"; + private margin: string = "10px"; + // Override createRenderRoot to disable shadow DOM createRenderRoot() { return this; } - private readonly boundUserMeHandler = (event: Event) => - this.onUserMe((event as CustomEvent).detail); + static styles = css``; connectedCallback() { super.connectedCallback(); - document.addEventListener( - "userMeResponse", - this.boundUserMeHandler as EventListener, - ); - } - - private onUserMe(userMeResponse: UserMeResponse | false): void { - const flares = - userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); - const hasFlare = flares.some((flare) => flare.startsWith("pattern:")); - if (hasFlare) { - console.log("No ads because you have patterns"); - window.enableAds = false; - } else { - console.log("No flares, showing ads"); - this.show(); - window.enableAds = true; - } - } - - private isScreenLargeEnough(): boolean { - return window.innerWidth >= MIN_SCREEN_WIDTH; + document.addEventListener("userMeResponse", () => { + if (window.adsEnabled) { + console.log("showing gutter ads"); + this.show(); + } else { + console.log("not showing gutter ads"); + } + }); } // Called after the component's DOM is first rendered firstUpdated() { // DOM is guaranteed to be available here - console.log("GutterAd DOM is ready"); + console.log("GutterAdModal DOM is ready"); } public show(): void { - if (!this.isScreenLargeEnough()) { - console.log("Screen too small for gutter ads, skipping"); - return; - } - - if (isInIframe()) { - console.log("In iframe, showing gutter ads"); - return; - } - - console.log("showing GutterAds"); this.isVisible = true; this.requestUpdate(); @@ -74,58 +50,57 @@ export class GutterAds extends LitElement { }); } - public hide(): void { - this.isVisible = false; - console.log("hiding GutterAds"); - this.destroyAds(); - document.removeEventListener( - "userMeResponse", - this.boundUserMeHandler as EventListener, - ); - this.requestUpdate(); - } - private loadAds(): void { + console.log("loading ramp ads"); // Ensure the container elements exist before loading ads - const leftContainer = this.querySelector(`#${LEFT_FUSE}`); - const rightContainer = this.querySelector(`#${RIGHT_FUSE}`); + const leftContainer = this.querySelector(`#${this.leftContainerId}`); + const rightContainer = this.querySelector(`#${this.rightContainerId}`); if (!leftContainer || !rightContainer) { console.warn("Ad containers not found in DOM"); return; } - if (!window.fusetag) { - console.warn("Fuse tag not available"); + if (!window.ramp) { + console.warn("Playwire RAMP not available"); + return; + } + + if (this.adLoaded) { + console.log("Ads already loaded, skipping"); return; } try { - console.log("registering zones"); - window.fusetag.que.push(() => { - window.fusetag.registerZone(LEFT_FUSE); - window.fusetag.registerZone(RIGHT_FUSE); + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([ + { + type: this.leftAdType, + selectorId: this.leftContainerId, + }, + { + type: this.rightAdType, + selectorId: this.rightContainerId, + }, + ]); + this.adLoaded = true; + console.log( + "Playwire ads loaded:", + this.leftAdType, + this.rightAdType, + ); + } catch (e) { + console.log(e); + } }); } catch (error) { - console.error("Failed to load fuse ads:", error); - this.hide(); + console.error("Failed to load Playwire ads:", error); } } - private destroyAds(): void { - if (!window.fusetag) { - return; - } - window.fusetag.que.push(() => { - window.fusetag.destroyZone(LEFT_FUSE); - window.fusetag.destroyZone(RIGHT_FUSE); - }); - this.requestUpdate(); - } - disconnectedCallback() { super.disconnectedCallback(); - this.hide(); } render() { @@ -134,11 +109,26 @@ export class GutterAds extends LitElement { } return html` -
      -
      + + -
      -
      + + + `; } diff --git a/src/client/Main.ts b/src/client/Main.ts index 5f4342e3e..57644d6b8 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -7,7 +7,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; -import { getUserMe } from "./Api"; +import { getUserMe, hasLinkedAccount } from "./Api"; import { userAuth } from "./Auth"; import { joinLobby } from "./ClientGameRunner"; import { fetchCosmetics } from "./Cosmetics"; @@ -163,19 +163,12 @@ declare global { GIT_COMMIT: string; INSTANCE_ID: string; turnstile: any; - enableAds: boolean; + adsEnabled: boolean; PageOS: { session: { newPageView: () => void; }; }; - fusetag: { - registerZone: (id: string) => void; - destroyZone: (id: string) => void; - pageInit: (options?: any) => void; - que: Array<() => void>; - destroySticky: () => void; - }; ramp: { que: Array<() => void>; passiveMode: boolean; @@ -184,7 +177,7 @@ declare global { settings?: { slots?: any; }; - spaNewPage: (url: string) => void; + spaNewPage: (url?: string) => void; }; showPage?: (pageId: string) => void; } @@ -475,15 +468,14 @@ class Client { const onUserMe = async (userMeResponse: UserMeResponse | false) => { // Check if user has actual authentication (discord or email), not just a publicId - const loggedIn = - userMeResponse !== false && - userMeResponse !== null && - typeof userMeResponse === "object" && - userMeResponse.user && - (userMeResponse.user.discord !== undefined || - userMeResponse.user.email !== undefined); - updateMatchmakingButton(loggedIn); + const isLinked: boolean = hasLinkedAccount(userMeResponse); + updateMatchmakingButton(isLinked); updateAccountNavButton(userMeResponse); + const adsEnabled = + !crazyGamesSDK.isOnCrazyGames() && + ((userMeResponse || null)?.player?.flares?.length ?? 0) === 0; + console.log("ads enabled: ", adsEnabled); + window.adsEnabled = adsEnabled; document.dispatchEvent( new CustomEvent("userMeResponse", { detail: userMeResponse, @@ -653,8 +645,6 @@ class Client { updateSliderProgress(slider); slider.addEventListener("input", () => updateSliderProgress(slider)); }); - - this.initializeFuseTag(); } private handleUrl() { @@ -847,7 +837,6 @@ class Client { if (startingModal && startingModal instanceof GameStartingModal) { startingModal.show(); } - this.gutterAds.hide(); }, () => { this.joinModal.close(); @@ -858,6 +847,9 @@ class Client { (ad as HTMLElement).style.display = "none"; }); + if (window.PageOS?.session?.newPageView) { + window.PageOS.session.newPageView(); + } crazyGamesSDK.loadingStop(); crazyGamesSDK.gameplayStart(); document.body.classList.add("in-game"); @@ -902,8 +894,6 @@ class Client { document.body.classList.remove("in-game"); crazyGamesSDK.gameplayStop(); - - this.gutterAds.hide(); this.publicLobby.leaveLobby(); } @@ -925,28 +915,6 @@ class Client { } } - private initializeFuseTag() { - const tryInitFuseTag = (): boolean => { - if (window.fusetag && typeof window.fusetag.pageInit === "function") { - console.log("initializing fuse tag"); - window.fusetag.que.push(() => { - window.fusetag.pageInit({ - blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"], - }); - }); - return true; - } else { - return false; - } - }; - - const interval = setInterval(() => { - if (tryInitFuseTag()) { - clearInterval(interval); - } - }, 100); - } - private async getTurnstileToken( lobby: JoinLobbyEvent, ): Promise { diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index b4cd3eb38..d501c095f 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -6,7 +6,6 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; import { FrameProfiler } from "./FrameProfiler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; -import { AdTimer } from "./layers/AdTimer"; import { AlertFrame } from "./layers/AlertFrame"; import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; @@ -20,6 +19,7 @@ import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { ImmunityTimer } from "./layers/ImmunityTimer"; +import { InGameHeaderAd } from "./layers/InGameHeaderAd"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; import { MainRadialMenu } from "./layers/MainRadialMenu"; @@ -244,6 +244,14 @@ export function createRenderer( } immunityTimer.game = game; + const inGameHeaderAd = document.querySelector( + "in-game-header-ad", + ) as InGameHeaderAd; + if (!(inGameHeaderAd instanceof InGameHeaderAd)) { + console.error("in-game header ad not found"); + } + inGameHeaderAd.game = game; + // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -287,7 +295,7 @@ export function createRenderer( playerPanel, headsUpMessage, multiTabModal, - new AdTimer(game), + inGameHeaderAd, alertFrame, performanceOverlay, ]; diff --git a/src/client/graphics/layers/AdTimer.ts b/src/client/graphics/layers/AdTimer.ts deleted file mode 100644 index 367744df9..000000000 --- a/src/client/graphics/layers/AdTimer.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { GameView } from "../../../core/game/GameView"; -import { Layer } from "./Layer"; - -const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minute - -export class AdTimer implements Layer { - private isHidden: boolean = false; - - constructor(private g: GameView) {} - - init() {} - - public async tick() { - if (this.isHidden) { - return; - } - - const gameTicks = this.g.ticks() - this.g.config().numSpawnPhaseTurns(); - if (gameTicks > AD_SHOW_TICKS) { - console.log("destroying sticky ads"); - window.fusetag?.que?.push(() => { - window.fusetag?.destroySticky?.(); - }); - this.isHidden = true; - return; - } - } -} diff --git a/src/client/graphics/layers/InGameHeaderAd.ts b/src/client/graphics/layers/InGameHeaderAd.ts new file mode 100644 index 000000000..f3925508a --- /dev/null +++ b/src/client/graphics/layers/InGameHeaderAd.ts @@ -0,0 +1,112 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes +const HEADER_AD_TYPE = "standard_iab_head1"; +const HEADER_AD_CONTAINER_ID = "header-ad-container"; +const TWO_XL_BREAKPOINT = 1536; + +@customElement("in-game-header-ad") +export class InGameHeaderAd extends LitElement implements Layer { + public game: GameView; + + private isHidden: boolean = false; + private adLoaded: boolean = false; + private shouldShow: boolean = false; + + createRenderRoot() { + return this; + } + + init() { + this.showHeaderAd(); + } + + private showHeaderAd(): void { + // Don't show header ad on screens smaller than 2xl + if (window.innerWidth < TWO_XL_BREAKPOINT) { + return; + } + if (!window.adsEnabled) { + return; + } + + this.shouldShow = true; + this.requestUpdate(); + + // Wait for the element to render before loading the ad + this.updateComplete.then(() => { + this.loadAd(); + }); + } + + private loadAd(): void { + if (!window.ramp) { + console.warn("Playwire RAMP not available for header ad"); + return; + } + + try { + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([ + { + type: HEADER_AD_TYPE, + selectorId: HEADER_AD_CONTAINER_ID, + }, + ]); + this.adLoaded = true; + console.log("Header ad loaded:", HEADER_AD_TYPE); + } catch (e) { + console.error("Failed to add header ad:", e); + } + }); + } catch (error) { + console.error("Failed to load header ad:", error); + } + } + + private hideHeaderAd(): void { + this.shouldShow = false; + this.adLoaded = false; + this.requestUpdate(); + } + + public tick() { + if (this.isHidden) { + return; + } + + const gameTicks = + this.game.ticks() - this.game.config().numSpawnPhaseTurns(); + if (gameTicks > AD_SHOW_TICKS) { + console.log("destroying header ad and refreshing PageOS"); + this.hideHeaderAd(); + this.isHidden = true; + + if (window.PageOS?.session?.newPageView) { + window.PageOS.session.newPageView(); + } + return; + } + } + + shouldTransform(): boolean { + return false; + } + + render() { + if (!this.shouldShow) { + return html``; + } + + return html` + + `; + } +} From 9aed372425d1ca3d50a76c17da086e598f39d81a Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 25 Jan 2026 20:34:48 -0800 Subject: [PATCH 22/45] Added afterEach cleanup to call inputHandler.destroy(), which clears the setInterval before jsdom tears down and removes window. (#3030) ## Description: Fixes the failing test:coverage ci. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- tests/InputHandler.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 132e88419..18f0c6497 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -40,6 +40,10 @@ describe("InputHandler AutoUpgrade", () => { ); }); + afterEach(() => { + inputHandler.destroy(); + }); + describe("Middle Mouse Button Handling", () => { test("should emit AutoUpgradeEvent on middle mouse button press", () => { const mockEmit = vi.spyOn(eventBus, "emit"); From 2984bec4d1b66db9d07fbe983ea8d961690389c8 Mon Sep 17 00:00:00 2001 From: Simon Schaarschmidt <112267398+xTonai@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:30:08 +0100 Subject: [PATCH 23/45] Fix: Extended spawn immunity in 1v1s (#3010) (#3028) If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #3010 ## Description: Extended the spawn immunity in 1v1s from 5 to 30 seconds, to prevent spawn killing. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: @xtonai Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> --- src/server/MapPlaylist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 6e05022d8..20369f529 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -172,7 +172,7 @@ export class MapPlaylist { disableNations: true, gameMode: GameMode.FFA, bots: 100, - spawnImmunityDuration: 5 * 10, + spawnImmunityDuration: 30 * 10, disabledUnits: [], } satisfies GameConfig; } From 71c5102981dff42438ce847cb6f53457dba81d64 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Tue, 27 Jan 2026 06:56:10 +0900 Subject: [PATCH 24/45] mls (v4.15) (#3019) ## Description: mls for v29 Version identifier within MLS: 4.14 This is the last mls for v29. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- resources/lang/fr.json | 241 +++++++-- resources/lang/id.json | 953 +++++++++++++++++++++++++++++++++++ resources/lang/metadata.json | 6 + 3 files changed, 1152 insertions(+), 48 deletions(-) create mode 100644 resources/lang/id.json diff --git a/resources/lang/fr.json b/resources/lang/fr.json index 31328191e..0df822e4e 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -7,6 +7,7 @@ }, "common": { "close": "Fermer", + "back": "Retour", "available": "Disponible", "preset_max": "Max", "summary_send": "Envoyer", @@ -17,26 +18,42 @@ "cap_tooltip": "Capacité restante du destinataire", "target_dead": "Cible éliminée", "target_dead_note": "Vous ne pouvez pas envoyer de ressources à un joueur éliminé.", - "none": "Aucun" + "none": "Aucun", + "copied": "Copié !", + "click_to_copy": "Cliquer pour copier" }, "main": { "title": "OpenFront (ALPHA)", "join_discord": "Discord", "login_discord": "Se connecter avec Discord", + "sign_in": "Se connecter", + "discord_avatar_alt": "Avatar du profil Discord", + "user_avatar_alt": "Avatar de {username}", "checking_login": "Vérification de la connexion...", "logged_in": "Connecté !", "log_out": "Se déconnecter", - "create_lobby": "Créer un salon", - "join_lobby": "Rejoindre un salon", - "single_player": "Mode solo", + "create": "Créer un salon", + "join": "Rejoindre un salon", + "solo": "Solo", "instructions": "Instructions", + "game_info": "Infos sur la partie", "wiki": "Wiki", "privacy_policy": "Politique de confidentialité", "terms_of_service": "Conditions d'utilisation", - "reddit": "Reddit" + "copyright": "©️ OpenFront™ et Contributeurs", + "reddit": "Reddit", + "play": "Jouer", + "news": "Actus", + "store": "Boutique", + "settings": "Paramètres", + "keys": "Touches", + "stats": "Stats", + "account": "Compte", + "help": "Aide", + "menu": "Menu", + "pick_pattern": "Choisis un motif !" }, "news": { - "see_all_releases": "Voir toutes les versions", "github_link": "sur Github", "title": "Notes de version" }, @@ -67,7 +84,7 @@ "ui_events_desc": "Le panneau des événements affiche les derniers événements, demandes et messages de chat rapide. Quelques exemples sont :", "ui_events_alliance": "Alliance - Les demandes d'Alliance peuvent être acceptées ou rejetées. Les alliés peuvent partager des ressources et des troupes, mais ne peuvent pas s'attaquer. Cliquer sur Focus déplace la vue vers le joueur qui a envoyé la requête.", "ui_events_attack": "Attaques - Les attaques entrantes et sortantes sont affichées. Cliquez sur le message pour centrer la vue sur l'attaque, la bombe ou le bateau (navire de transport). Vous pouvez envoyer les troupes en retraite en cliquant sur le bouton rouge X. Cela coûtera la vie à 25% de vos troupes attaquantes. Si vous annulez une attaque de bateau, le bateau revient à son point de départ et y attaquera si la terre a été capturée depuis. Les bombes atomiques ne peuvent pas être retirées une fois lancées.", - "ui_events_quickchat": "Chat rapide - Vous pouvez voir les messages envoyés et reçus ici. Envoyez un message à un joueur en cliquant sur l'icône Chat rapide dans son menu Infos.", + "ui_events_quickchat": "Chat Rapide - Vous pouvez voir les messages envoyés et reçus ici. Envoyez un message à un joueur en cliquant sur l'icône Chat Rapide dans son menu Infos.", "ui_options": "Options", "ui_options_desc": "Les éléments suivants peuvent être trouvés à l'intérieur :", "ui_playeroverlay": "Informations sur le joueur", @@ -83,6 +100,8 @@ "radial_attack": "Ouvrez le menu d'attaque.", "radial_info": "Ouvrir le menu d'informations.", "radial_boat": "Envoyer un bateau (navire de transport) pour attaquer l'endroit sélectionné. Disponible uniquement si vous avez accès à l'eau.", + "radial_donate_troops": "Donner des troupes équivalent à votre ratio d'attaque à l'allié sur lequel vous avez ouvert le menu radial.", + "radial_donate_gold": "Ouvre le menu du curseur de don d'or pour que vous puissiez envoyer rapidement de l'or aux alliés.", "radial_close": "Fermer le menu.", "info_title": "Menu d'informations", "info_enemy_desc": "Contient des informations telles que le nom du joueur sélectionné, son or, ses troupes, s'il a cessé de commercer avec vous, les bombes qu'il vous a envoyées, et si le joueur est un traître. L'arrêt du commerce signifie que vous ne recevrez pas d'or de sa part et qu'il ne vous enverra pas d'or via des navires commerciaux. Manuellement (si le joueur a cliqué sur « Arrêter le commerce », qui dure jusqu'à ce que vous cliquiez sur « Commencer le commerce ») ou automatiquement (si vous avez trahi votre alliance, ce qui dure jusqu'à ce que vous deveniez alliés de nouveau ou après 5 minutes). Le traître affiche Oui pendant 30 secondes lorsque le joueur a trahi et attaqué un joueur qui était dans une alliance avec lui. Les icônes ci-dessous représentent les interactions suivantes :", @@ -114,7 +133,7 @@ "build_silo": "Silo à missiles", "build_silo_desc": "Permet de lancer des missiles.", "build_sam": "Lanceur SAM", - "build_sam_desc": "Vous pouvez intercepter les missiles ennemis à portée de 100 pixels. Avec une probabilité de 100% pour la Bombe Atomique, 80% pour la Bombe Hydrogène et 50% pour les Ogives MIRV individuelles. Le SAM a un temps de recharge de 7,5 secondes.", + "build_sam_desc": "Peut intercepter les missiles ennemis dans un rayon de 100 pixels. Le SAM a un temps de recharge de 7,5 secondes.", "build_atom": "Bombe atomique", "build_atom_desc": "Petite bombe explosive qui détruit le territoire, les bâtiments, les navires et les bateaux. Apparaît depuis le Silo à missiles le plus proche et atterrit dans la zone cliquée.", "build_hydrogen": "Bombe à hydrogène", @@ -129,12 +148,15 @@ "icon_embargo": "Signe dollar barré - Embargo. Ce joueur a cessé de commercer avec vous automatiquement ou manuellement.", "icon_request": "Enveloppe - Demande d'alliance. Ce joueur vous a envoyé une demande d'alliance.", "info_enemy_panel": "Panneau d'information de l'ennemi", - "exit_confirmation": "Êtes-vous sûr de vouloir quitter le jeu ?" + "exit_confirmation": "Êtes-vous sûr de vouloir quitter le jeu ?", + "bomb_direction": "Direction de l'arc de bombe Atomique / Hydrogène" }, "single_modal": { - "title": "Joueur seul", + "title": "Solo", "random_spawn": "Spawn aléatoire", "allow_alliances": "Autoriser les alliances", + "toggle_achievements": "Activer / Désactiver les succès", + "sign_in_for_achievements": "Connectez-vous pour obtenir des succès", "options_title": "Options", "bots": "Bots : ", "bots_disabled": "Désactivé", @@ -145,6 +167,8 @@ "infinite_troops": "Troupes infinies", "compact_map": "Carte compacte", "max_timer": "Durée de jeu (minutes)", + "max_timer_placeholder": "Mins", + "max_timer_invalid": "Veuillez entrer une valeur max valide pour le minuteur (1-120 minutes)", "disable_nukes": "Désactiver les armes nucléaires", "enables_title": "Activer les paramètres", "start": "Commencer la partie" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Compte", - "logged_in_as": "Connecté en tant que {email}", + "connected_as": "Connecté en tant que", + "stats_overview": "Aperçu des Statistiques", + "link_discord": "Lier un Compte Discord", + "log_out": "Se Déconnecter", + "sign_in_desc": "Connectez-vous pour enregistrer vos statistiques et progrès", + "or": "OU", + "email_placeholder": "Entrez votre adresse email", + "get_magic_link": "Obtenir un Lien Magique", + "linked_account": "Connecté en tant que {account_name}", "fetching_account": "Récupération des informations du compte...", - "logged_in_with_discord": "Connecté avec Discord", - "recovery_email_sent": "Courriel de récupération envoyé à {email}" + "recovery_email_sent": "Courriel de récupération envoyé à {email}", + "not_found": "Introuvable", + "clear_session": "Effacer la session", + "failed_to_send_recovery_email": "Échec de l'envoi de l'e-mail de récupération", + "enter_email_address": "Veuillez saisir une adresse e-mail" }, "stats_modal": { "title": "Statistiques", @@ -167,11 +202,40 @@ "loading": "Chargement...", "error": "Erreur lors du chargement des statistiques du clan", "no_stats": "Pas de statistique de clan disponible", + "no_data_yet": "Aucune donnée pour le moment", "clan": "Clan", "games": "Parties", "win_score": "Score de Victoire", + "win_score_tooltip": "Victoires pondérées en fonction de la participation du clan et de la difficulté du match", "loss_score": "Score de Défaite", - "win_loss_ratio": "Victoires/Défaites" + "loss_score_tooltip": "Défaites pondérées en fonction de la participation du clan et de la difficulté du match", + "win_loss_ratio": "Victoires/Défaites", + "ratio": "Coefficient", + "rank": "Rang", + "try_again": "Réessayer" + }, + "game_info_modal": { + "title": "Infos sur la partie", + "players": "Joueurs", + "atoms": "Atomes", + "hydros": "Hydros", + "mirv": "MIRV", + "bombs": "Bombes", + "total_gold": "Total", + "all_gold": "Tout l'or", + "trade": "Commercer", + "conquest_gold": "Or de joueur conquis", + "stolen_gold": "Volé avec des navires de guerre", + "num_of_conquests": "Nombre de joueurs conquis", + "duration": "Durée", + "survival_time": "Temps de survie", + "war": "Guerre", + "economy": "Économie", + "conquests": "Conquêtes", + "pirate": "Pirate", + "conquered": "Conquis", + "loading_game_info": "Chargement des stats du jeu", + "no_winner": "Cette partie s'est terminée sans aucun gagnant (ou une Nation a gagné)" }, "map": { "map": "Carte", @@ -186,6 +250,7 @@ "asia": "Asie", "mars": "Mars", "southamerica": "Amérique du Sud", + "britanniaclassic": "Grande-Bretagne (Classique)", "britannia": "Grande-Bretagne", "gatewaytotheatlantic": "Porte de l'Atlantique", "australia": "Australie", @@ -196,7 +261,7 @@ "betweentwoseas": "Entre deux mers", "faroeislands": "Îles Féroé", "deglaciatedantarctica": "Antarctique Déglacée", - "europeclassic": "Europe (classique)", + "europeclassic": "Europe (Classique)", "falklandislands": "Îles Malouines", "baikal": "Lac Baïkal", "halkidiki": "Chalcidique", @@ -206,19 +271,33 @@ "yenisei": "Ienisseï", "pluto": "Pluton", "montreal": "Montréal", + "newyorkcity": "New York City", "achiran": "Achiran", "baikalnukewars": "Baïkal (Guerres Nucléaires)", "fourislands": "Quatre Îles", "gulfofstlawrence": "Golfe du Saint-Laurent", - "lisbon": "Lisbonne" + "lisbon": "Lisbonne", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "sierpinski": "Sierpinski", + "twolakes": "Deux Lacs", + "straitofhormuz": "Détroit d'Ormuz", + "surrounded": "Encerclé", + "didier": "Didier", + "didierfrance": "Didier (France)", + "amazonriver": "Fleuve Amazone" }, "map_categories": { "continental": "Continental", "regional": "Régional", - "fantasy": "Autre" + "fantasy": "Autre", + "special": "Spéciales", + "arcade": "Arcade" }, "map_component": { - "loading": "Chargement..." + "loading": "Chargement...", + "error": "Erreur" }, "private_lobby": { "title": "Rejoindre un salon privé", @@ -229,42 +308,55 @@ "checking": "Vérification du salon...", "not_found": "Salon introuvable. Veuillez vérifier l'ID et réessayer.", "error": "Une erreur s'est produite. Veuillez réessayer ou contacter le support.", - "joined_waiting": "Rejoint avec succès ! En attente du début de la partie...", - "version_mismatch": "Cette partie a été créée avec une version différente. Impossible de rejoindre." + "joined_waiting": "Salon rejoint ! En attente du démarrage de l'hôte...", + "version_mismatch": "Cette partie a été créée avec une version différente. Impossible de rejoindre.", + "disabled_units": "Unités désactivées" }, "public_lobby": { "join": "Rejoindre la prochaine partie", "waiting": "joueurs en attente", - "teams_Duos": "de 2 (Duos)", - "teams_Trios": "de 3 (Trios)", - "teams_Quads": "de 4 (Quatuors)", - "teams_hvn": "Humains Vs Nations", + "teams_Duos": "{team_count} équipes de 2 (Duos)", + "teams_Trios": "{team_count} équipes de 3 (Trios)", + "teams_Quads": "{team_count} équipes de 4 (Quatuors)", + "waiting_for_players": "En attente de joueurs", + "starting_game": "Démarrage en cours...", + "teams_hvn": "Humains vs Nations", + "teams_hvn_detailed": "{num} Humains vs {num} Nations", "teams": "{num} équipes", - "players_per_team": "de {num}" + "players_per_team": "de {num}", + "started": "Lancé" }, "matchmaking_modal": { - "title": "Matchmaking", + "title": "Matchmaking 1v1 Classé (ALPHA)", "connecting": "Connexion au serveur de matchmaking...", "searching": "Recherche d'une partie...", - "waiting_for_game": "En attente du début de la partie..." + "waiting_for_game": "En attente du début de la partie...", + "elo": "Votre ELO : {elo}" }, "username": { "enter_username": "Entrez votre nom d'utilisateur", "not_string": "Le nom d'utilisateur doit être une chaîne de caractères.", "too_short": "Le nom d'utilisateur doit comporter au moins {min} caractères.", "too_long": "Le nom d'utilisateur ne doit pas dépasser {max} caractères.", - "invalid_chars": "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres, des espaces, des tirets bas et des [crochets]." + "invalid_chars": "Le pseudonyme peut seulement contenir des lettres, chiffres, espaces et underscores.", + "tag": "TAG", + "tag_too_short": "Le tag de clan doit faire 2 à 5 caractères alphanumériques.", + "tag_invalid_chars": "Le tag de clan peut seulement contenir des lettres et des chiffres." }, "host_modal": { - "title": "Salon privé", + "title": "Créer un Salon Privé", + "label": "Privé", "mode": "Mode", "team_count": "Nombre d'équipes", + "team_type": "Type d'équipe", "options_title": "Paramètres", "bots": "Bots : ", "bots_disabled": "Désactivé", + "player_immunity_duration": "Immunité au JcJ (minutes)", "nations": "Nations : ", "disable_nations": "Désactiver les nations", "max_timer": "Durée de jeu (minutes)", + "mins_placeholder": "Mins", "instant_build": "Construction instantanée", "infinite_gold": "Or infini", "donate_gold": "Donner de l'or", @@ -283,7 +375,11 @@ "assigned_teams": "Équipes Attribuées", "empty_teams": "Équipes vides", "empty_team": "Vide", - "remove_player": "Retirer {username}" + "remove_player": "Retirer {username}", + "teams_Duos": "Duos (équipes de 2)", + "teams_Trios": "Trios (équipes de 3)", + "teams_Quads": "Quatuors (équipes de 4)", + "teams_Humans Vs Nations": "Humains vs Nations" }, "team_colors": { "red": "Rouge", @@ -301,16 +397,20 @@ "code_license": "Code sous licence AGPL-3.0 (sans garantie)" }, "difficulty": { - "difficulty": "Difficulté", - "Easy": "Détendu", - "Medium": "Équilibré", - "Hard": "Intense", - "Impossible": "Impossible" + "difficulty": "Difficulté des nations", + "easy": "Facile", + "medium": "Moyen", + "hard": "Difficile", + "impossible": "Impossible" }, "game_mode": { "ffa": "Chacun pour soi", "teams": "Équipes" }, + "public_game_modifier": { + "random_spawn": "Spawn aléatoire", + "compact_map": "Carte compacte" + }, "select_lang": { "title": "Sélectionner une langue" }, @@ -327,7 +427,7 @@ "factory": "Usine" }, "user_setting": { - "title": "Paramètres utilisateur", + "title": "Paramètres", "tab_basic": "Réglages de base", "tab_keybinds": "Raccourcis clavier", "dark_mode_label": "Mode sombre", @@ -340,16 +440,18 @@ "special_effects_desc": "Activer/désactiver les effets spéciaux. Désactiver pour améliorer les performances", "structure_sprites_label": "Sprites de structure", "structure_sprites_desc": "Activer/désactiver les sprites de structure", + "cursor_cost_label_label": "Coûts de construction", + "cursor_cost_label_desc": "Afficher une pastille indiquant le coût sous l'icône du curseur de construction", "anonymous_names_label": "Noms masqués", "anonymous_names_desc": "Cacher le vrai nom des joueurs avec des noms aléatoires sur votre écran.", "lobby_id_visibility_label": "ID du salon masqué", "lobby_id_visibility_desc": "Cacher l'ID du salon lors de la création du salon privé", + "toggle_visibility": "Changer la visibilité", "left_click_label": "Clic gauche pour ouvrir le menu", "left_click_desc": "Activé, un clic gauche ouvre le menu et le bouton épée d'attaque. Désactivé, un clic gauche attaque directement.", "left_click_menu": "Menu Clic gauche", "attack_ratio_label": "⚔️ Ratio d'attaque", "attack_ratio_desc": "Quel pourcentage de vos troupes envoyer dans une attaque (1–100%)", - "troop_ratio_desc": "Ajuster l'équilibre entre les troupes (pour le combat) et les ouvriers (pour la production d'or) (1–100%)", "territory_patterns_label": "🏳️ Skins de territoire", "territory_patterns_desc": "Choisissez d'afficher ou non les designs des skins de territoire dans le jeu", "performance_overlay_label": "Surcouche de performances", @@ -358,6 +460,7 @@ "easter_writing_speed_desc": "Ajuster la vitesse à laquelle vous prétendez coder (x1–x100)", "easter_bug_count_label": "Nombre de bugs", "easter_bug_count_desc": "Combien de bugs vous acceptez (0-1000, émotionnellement)", + "press_a_key": "Appuyez sur une touche", "view_options": "Options d'affichage", "toggle_view": "Activer/désactiver l'affichage", "toggle_view_desc": "Vue alternative (terrain/pays)", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "Envoyer une bombe à hydrogène sous votre curseur.", "build_mirv": "Construire un MIRV", "build_mirv_desc": "Construire un MIRV sous votre curseur.", + "menu_shortcuts": "Menu des raccourcis ", + "build_menu_modifier": "Menu de modification des constructions", + "build_menu_modifier_desc": "Maintenez cette touche enfoncée en cliquant pour ouvrir le menu de construction.", + "emoji_menu_modifier": "Menu de modifications des émojis", + "emoji_menu_modifier_desc": "Maintenez cette touche enfoncée en cliquant pour ouvrir le menu des emojis.", "attack_ratio_controls": "Contrôles du ratio d'attaque", "attack_ratio_up": "Augmenter le ratio d'attaque", "attack_ratio_up_desc": "Augmenter le ratio d'attaque de 10%", @@ -392,6 +500,8 @@ "boat_attack_desc": "Envoyer une attaque navale à la tuile sous votre curseur.", "ground_attack": "Attaque au sol", "ground_attack_desc": "Envoyez une attaque au sol sur la tuile sous votre curseur.", + "swap_direction": "Inverser la trajectoire balistique", + "swap_direction_desc": "Inverser la trajectoire balistique de lancement", "zoom_controls": "Contrôles de zoom", "zoom_out": "Zoom arrière", "zoom_out_desc": "Dézoom de la carte", @@ -416,7 +526,8 @@ "exit_game_label": "Quitter la partie", "exit_game_info": "Retour au menu principal", "background_music_volume": "Volume de la musique de fond", - "sound_effects_volume": "Volume des effets sonores" + "sound_effects_volume": "Volume des effets sonores", + "keybind_conflict_error": "La clé {key} est déjà liée à une autre action." }, "chat": { "title": "Discussion", @@ -529,6 +640,7 @@ "other_team": "L'équipe {team} a gagné !", "you_won": "Vous avez gagné !", "other_won": "{player} a gagné !", + "nation_won": "La nation {nation} a gagné !", "exit": "Quitter la partie", "keep": "Continuer à jouer", "spectate": "Regarder", @@ -537,7 +649,7 @@ "ofm_winter_description": "Rejoignez le tournoi et affrontez les meilleurs joueurs", "join_tournament": "Rejoindre le tournoi", "join_discord": "Rejoignez notre communauté Discord !", - "discord_description": "Connectez-vous avec d'autres joueurs, recevez les nouvelles et partagez des stratégies", + "discord_description": "Parlez avec des joueurs, découvrez de nouvelles fonctionnalités et gagnez des prix !", "join_server": "Rejoindre le Serveur", "youtube_tutorial": "Besoin d'aide ?" }, @@ -549,7 +661,7 @@ "team": "Équipe", "owned": "Possédé", "gold": "Or", - "troops": "Troupes", + "maxtroops": "Troupes max", "launchers": "Lanceurs", "sams": "SAMs", "warships": "Vaisseaux de guerre", @@ -565,6 +677,7 @@ "team": "Équipe", "alliance_timeout": "L'alliance se termine dans", "troops": "Troupes", + "maxtroops": "Troupes max", "a_troops": "Troupes en attaque", "gold": "Or", "ports": "Ports", @@ -575,7 +688,9 @@ "warships": "Navires de guerre", "health": "Santé", "attitude": "Attitude", - "levels": "Niveaux" + "levels": "Niveaux", + "wilderness_title": "Étendues sauvages", + "irradiated_wilderness_title": "Terre irradiée" }, "events_display": { "retreating": "en retraite", @@ -601,7 +716,20 @@ "wants_to_renew_alliance": "{name} souhaite renouveler votre alliance", "ignore": "Ignorer", "unit_voluntarily_deleted": "Unité volontairement supprimée", - "betrayal_debuff_ends": "{time} secondes restantes jusqu'à la fin du malus de trahison" + "betrayal_debuff_ends": "{time} secondes restantes jusqu'à la fin du malus de trahison", + "attack_cancelled_retreat": "Attaque annulée, {troops} soldats ont été tués pendant la retraite", + "received_gold_from_captured_ship": "{gold} ors reçu pour la capture d'un navire de {name}", + "received_gold_from_trade": "{gold} ors reçu pour le commerce avec {name}", + "missile_intercepted": "Le missile a intercepté {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} ogive nucléaire MIRV a été interceptée} other {{count} ogives nucléaire MIRV ont été interceptées}}", + "sent_troops_to_player": "Vous avez envoyé {troops} troupes à {name}", + "received_troops_from_player": "Vous avez reçu {troops} troupes de {name}", + "sent_gold_to_player": "Vous avez envoyé {gold} ors à {name}", + "received_gold_from_player": "Vous avez reçu {gold} ors de {name}", + "unit_captured_by_enemy": "Votre {unit} a été capturé par {name}", + "captured_enemy_unit": "{unit} de {name} capturé", + "unit_destroyed": "Votre {unit} a été détruit", + "no_boats_available": "Aucun bateau disponible, max {max}" }, "unit_info_modal": { "structure_info": "Infos sur la structure", @@ -653,7 +781,10 @@ "send_alliance": "Envoyer une alliance", "send_troops": "Envoyer des troupes", "send_gold": "Envoyer de l'or", - "emotes": "Émojis" + "emotes": "Émojis", + "arc_up": "Arc vers le haut", + "arc_down": "Arc vers le bas", + "flip_rocket_trajectory": "Inverser la trajectoire de la fusée" }, "send_troops_modal": { "title_with_name": "Envoyer des troupes à {name}", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "Choisissez un emplacement de départ", - "random_spawn": "Le spawn aléatoire est activé. Sélection de l'emplacement de départ pour vous..." + "random_spawn": "Le spawn aléatoire est activé. Sélection de l'emplacement de départ pour vous...", + "singleplayer_game_paused": "Jeu en pause", + "multiplayer_game_paused": "Jeu mis en pause par le créateur du salon" }, "territory_patterns": { "title": "Skins", "colors": "Couleurs", "purchase": "Acheter", "show_only_owned": "Mes skins", + "all_owned": "Vous possédez déjà tous les motifs ! Revenez plus tard pour de nouveau.", + "not_logged_in": "Non connecté", "blocked": { "login": "Vous devez être connecté pour accéder à ce skin.", "purchase": "Achetez ce skin pour le débloquer." }, "pattern": { "default": "Par défaut" - } + }, + "select_skin": "Sélectionnez le motif", + "selected": "sélectionné" }, "flag_input": { "title": "Sélectionner un drapeau", @@ -786,8 +923,9 @@ "mode": "Mode", "mode_ffa": "Chacun pour soi", "mode_team": "Équipe", - "view": "Vue", + "replay": "Revoir", "details": "Détails", + "ranking": "Classé", "started": "Débuté", "map": "Carte", "difficulty": "Difficulté", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Public", "private": "Privé", - "singleplayer": "Mode solo", + "singleplayer": "Solo", "mode": "Mode", "stats_wins": "Victoires", "stats_losses": "Défaites", "stats_wlr": "Ratio Victoires:Défaites", "stats_games_played": "Parties jouées", "mode_ffa": "Chacun pour soi", - "mode_team": "En équipe" + "mode_team": "En équipe", + "no_stats": "Aucune statistique enregistrée pour cette sélection." + }, + "matchmaking_button": { + "play_ranked": "Matchmaking 1v1 classé", + "description": "(ALPHA)", + "login_required": "Connectez-vous pour jouer en mode classé", + "must_login": "Vous devez être connecté pour jouer en mode classé." } } diff --git a/resources/lang/id.json b/resources/lang/id.json new file mode 100644 index 000000000..7f7a55fc4 --- /dev/null +++ b/resources/lang/id.json @@ -0,0 +1,953 @@ +{ + "lang": { + "en": "Indonesian", + "native": "Bahasa Indonesia", + "svg": "id", + "lang_code": "id" + }, + "common": { + "close": "Keluar", + "back": "Kembali", + "available": "Tersedia", + "preset_max": "Maks", + "summary_send": "Kirim", + "summary_keep": "Simpan", + "cancel": "Batalkan", + "send": "Kirim", + "cap_label": "Batas maksimal", + "cap_tooltip": "Kapasitas penerima yang tersisa", + "target_dead": "Target dieliminasi", + "target_dead_note": "Anda tidak dapat mengirim sumber daya ke pemain yang telah tereliminasi.", + "none": "Tidak Satupun", + "copied": "Tersalin", + "click_to_copy": "Klik untuk salin" + }, + "main": { + "title": "OpenFront (ALPHA)", + "join_discord": "Discord", + "login_discord": "Masuk dengan Discord", + "sign_in": "Masuk", + "discord_avatar_alt": "Avatar profil Discord", + "user_avatar_alt": "Avatar {username}", + "checking_login": "Memeriksa login...", + "logged_in": "Berhasil masuk!", + "log_out": "Keluar", + "create": "Buat Lobi", + "join": "Bergabung ke Lobi", + "solo": "Sendiri", + "instructions": "Petunjuk", + "game_info": "Informasi Permainan", + "wiki": "Wiki", + "privacy_policy": "Kebijakan Privasi", + "terms_of_service": "Ketentuan Layanan", + "copyright": "© OpenFront™ dan para kontributor", + "reddit": "Reddit", + "play": "Main", + "news": "Berita", + "store": "Toko", + "settings": "Pengaturan", + "keys": "Tombol", + "stats": "Statistik", + "account": "Akun", + "help": "Bantuan", + "menu": "Menu", + "pick_pattern": "Pilih pola!" + }, + "news": { + "github_link": "di GitHub", + "title": "Catatan Rilis" + }, + "help_modal": { + "hotkeys": "Tombol pintas", + "table_key": "Kunci", + "table_action": "Tindakan", + "action_alt_view": "Ganti Tampilan (Medan / Negara)", + "action_attack_altclick": "Serang (saat klik kiri diatur untuk membuka menu)", + "action_build": "Buka menu Pembangunan", + "action_emote": "Buka menu Ekspresi", + "action_center": "Pusatkan kamera pada pemain", + "action_zoom": "Perkecil / Perbesar tampilan", + "action_move_camera": "Pindahkan kamera", + "action_ratio_change": "Kurangi / Tingkatkan rasio serangan", + "action_reset_gfx": "Atur ulang grafis", + "action_auto_upgrade": "Tingkatkan bangunan terdekat secara otomatis", + "ui_section": "UI Permainan", + "ui_leaderboard": "Papan Peringkat", + "ui_your_team": "Tim anda:", + "ui_leaderboard_desc": "Menampilkan pemain teratas dalam permainan beserta nama mereka, persentase wilayah yang dikuasai, jumlah emas, dan pasukan. Opsi Tampilkan Semua akan menampilkan seluruh pemain dalam permainan. Jika tidak ingin melihat papan peringkat, klik Sembunyikan.", + "ui_control": "Panel kendali", + "ui_control_desc": "Panel kontrol berisi elemen berikut:", + "ui_pop": "Populasi - Jumlah unit yang kamu miliki, batas populasi maksimum, serta laju pertambahannya.", + "ui_gold": "Emas - Jumlah emas yang kamu miliki dan laju perolehannya.", + "ui_attack_ratio": "Rasio Serangan - Jumlah pasukan yang akan digunakan saat kamu menyerang. Kamu dapat menyesuaikan rasio serangan menggunakan penggeser. Memiliki pasukan penyerang lebih banyak daripada pasukan bertahan akan mengurangi jumlah pasukan yang hilang saat menyerang, sedangkan jumlah pasukan yang lebih sedikit akan meningkatkan kerusakan yang diterima pasukan penyerang. Efek ini tidak berlaku di atas rasio 2:1.", + "ui_events": "Panel Event", + "ui_events_desc": "Panel Event menampilkan peristiwa, permintaan, dan pesan Obrolan Cepat terbaru. Beberapa contohnya adalah:", + "ui_events_alliance": "Aliansi - Permintaan aliansi dapat diterima atau ditolak. Sekutu dapat berbagi sumber daya dan pasukan, tetapi tidak dapat saling menyerang. Menekan Fokus akan memusatkan tampilan ke pemain yang mengirim permintaan.", + "ui_events_attack": "Serangan - Menampilkan serangan yang masuk dan serangan yang kamu lakukan. Klik pesan untuk memusatkan tampilan ke lokasi serangan, nuklir, atau Kapal (kapal pengangkut). Kamu dapat menarik mundur pasukan dengan menekan tombol X merah. Tindakan ini akan mengorbankan 25% dari pasukan penyerang.\nJika serangan Kapal ditarik kembali, kapal akan kembali ke titik awal dan akan menyerang kembali di sana jika wilayah tersebut telah dikuasai sejak saat itu. Serangan nuklir tidak dapat ditarik kembali setelah diluncurkan.", + "ui_events_quickchat": "Obrolan Cepat - Di sini kamu dapat melihat pesan obrolan yang dikirim dan diterima. Untuk mengirim pesan ke pemain, klik ikon Obrolan Cepat di menu Info pemain tersebut.", + "ui_options": "Pilihan", + "ui_options_desc": "Elemen-elemen berikut dapat ditemukan di dalamnya:", + "ui_playeroverlay": "Overlay Info Pemain", + "ui_playeroverlay_desc": "Saat kamu mengarahkan kursor ke suatu negara, overlay Info Pemain akan ditampilkan di bawah menu Opsi. Overlay ini menampilkan jenis pemain: Manusia, Negara (bot pintar), atau Bot; sikap suatu Negara terhadapmu, mulai dari Bermusuhan hingga Ramah; serta jumlah pasukan bertahan, emas, jumlah Kapal Perang, dan berbagai bangunan yang dimiliki pemain tersebut.", + "ui_wilderness": "Alam Liar", + "option_pause": "Jeda / Lanjutkan permainan – Hanya tersedia dalam mode single-player.", + "option_timer": "Timer – Waktu yang telah berlalu sejak permainan dimulai.", + "option_exit": "Tombol keluar.", + "option_settings": "Pengaturan – Membuka menu pengaturan. Di dalamnya kamu dapat mengaktifkan atau menonaktifkan Tampilan Alternatif, Emoji, Mode Gelap, Ninja (mode anonim / nama acak), serta aksi pada klik kiri.", + "radial_title": "Menu Radial", + "radial_desc": "Klik kanan (atau sentuhan di perangkat seluler) akan membuka Menu Radial. Klik kanan di luar menu untuk menutupnya. Dari menu ini kamu dapat:", + "radial_build": "Buka menu Pembangunan.", + "radial_attack": "Buka menu Serangan.", + "radial_info": "Buka menu informasi.", + "radial_boat": "Kirim Kapal (kapal pengangkut) untuk menyerang lokasi yang dipilih. Hanya tersedia jika kamu memiliki akses ke perairan.", + "radial_donate_troops": "Donasikan pasukan kepada sekutu sesuai dengan persentase pada penggeser rasio serangan yang sedang kamu gunakan pada menu radial tersebut.", + "radial_donate_gold": "Membuka menu penggeser donasi emas sehingga kamu dapat dengan cepat mengirim emas kepada sekutu.", + "radial_close": "Tutup menu.", + "info_title": "Menu Informasi", + "info_enemy_desc": "Berisi informasi seperti nama pemain yang dipilih, jumlah emas, pasukan, status berhenti berdagang dengan kamu, nuklir yang dikirim ke arahmu, serta apakah pemain tersebut adalah pengkhianat.\nStatus Berhenti Berdagang berarti kamu tidak akan menerima emas dari pemain tersebut dan mereka juga tidak akan mengirimkan emas kepadamu melalui kapal dagang. Status ini dapat terjadi secara manual (jika pemain menekan tombol “Hentikan Perdagangan”, yang akan berlangsung sampai kalian berdua menekan “Mulai Perdagangan”) atau secara otomatis (jika kamu mengkhianati aliansi, yang akan berlangsung sampai kalian kembali menjadi sekutu atau setelah 5 menit).\nStatus Pengkhianat akan menampilkan “Ya” selama 30 detik ketika pemain tersebut mengkhianati dan menyerang pemain yang sebelumnya berada dalam aliansi dengannya.\nIkon-ikon di bawah ini mewakili interaksi berikut:", + "info_chat": "Kirim pesan Obrolan Cepat ke pemain. Pilih Kategori, Frasa, dan jika frasa berisi [P1], pilih nama pemain untuk menggantikannya. Lalu tekan Kirim.", + "info_target": "Pasang tanda target pada pemain, sehingga terlihat oleh semua sekutu. Digunakan untuk mengoordinasikan serangan.", + "info_alliance": "Kirim permintaan aliansi ke pemain. Sekutu dapat berbagi sumber daya dan pasukan, tetapi tidak dapat saling menyerang.", + "info_emoji": "Kirim emoji ke pemainnya.", + "info_trade": "Gunakan “Hentikan Perdagangan” untuk berhenti memberikan emas kepada pemain tersebut dan berhenti menerima emas dari mereka melalui kapal dagang. Jika kalian berdua menekan “Mulai\".", + "info_ally_panel": "Panel Info Sekutu", + "info_ally_desc": "Saat kamu beraliansi dengan seorang pemain, ikon-ikon baru berikut akan tersedia:", + "ally_betray": "Mengkhianati sekutumu akan mengakhiri aliansi, menghentikan perdagangan, dan melemahkan pertahananmu. Perdagangan di antara kalian akan dijeda selama 5 menit (atau sampai kalian kembali menjadi sekutu), dan pemain lain juga dapat menghentikan perdagangan. Kecuali jika pemain lain tersebut memang sudah berstatus pengkhianat, kamu akan ditandai sebagai Pengkhianat selama 30 detik.\nSelama waktu ini, sebuah ikon akan muncul di atas namamu dan kamu akan menerima debuff pertahanan sebesar 50%. Bot akan lebih enggan beraliansi denganmu, dan pemain lain akan berpikir dua kali sebelum melakukannya.", + "ally_donate": "Donasikan sebagian pasukanmu kepada sekutu. Digunakan ketika mereka kekurangan pasukan, sedang diserang, atau membutuhkan kekuatan tambahan untuk menghancurkan musuh.", + "ally_donate_gold": "Donasikan sebagian emasmu kepada sekutu. Digunakan saat mereka kekurangan emas dan membutuhkannya untuk membangun, atau ketika anggota tim sedang menabung untuk MIRV.", + "build_menu_title": "Menu Pembangunan", + "build_menu_desc": "Bangun item berikut atau lihat jumlah yang sudah kamu bangun:", + "build_name": "Judul", + "build_icon": "Ikon", + "build_desc": "Deskripsi", + "build_city": "Kota", + "build_city_desc": "Meningkatkan batas populasi maksimum. Berguna saat kamu tidak dapat memperluas wilayah atau hampir mencapai batas populasi.", + "build_factory": "Pabrik", + "build_factory_desc": "Secara otomatis membangun jalur kereta api ke kota, pelabuhan, dan pabrik lain di sekitarnya, serta dapat terhubung dengan negara tetangga yang bersahabat. Kereta akan muncul secara berkala dan memberimu sejumlah emas tetap untuk setiap bangunan yang dikunjungi sepanjang rute, dengan bonus emas tambahan saat mengunjungi bangunan milik tetanggamu.", + "build_defense": "Pos Pertahanan", + "build_defense_desc": "Meningkatkan pertahanan di sekitar perbatasan terdekat, yang ditandai dengan pola kotak-kotak. Serangan musuh menjadi lebih lambat dan menyebabkan lebih banyak korban.", + "build_port": "Pelabuhan", + "build_port_desc": "Hanya dapat dibangun di dekat air. Memungkinkan pembangunan Kapal Perang. Secara otomatis mengirim kapal dagang antara pelabuhan di negaramu dan negara lain (kecuali saat perdagangan dihentikan), yang memberikan emas bagi kedua pihak.\nPerdagangan dengan seorang pemain akan berhenti secara otomatis ketika kamu menyerang atau diserang oleh pemain tersebut. Perdagangan akan dilanjutkan kembali setelah 5 menit atau jika kalian menjadi sekutu. Kamu juga dapat mengatur perdagangan secara manual dengan memilih “Hentikan Perdagangan” atau “Mulai Perdagangan”.", + "build_warship": "Kapal Perang", + "build_warship_desc": "Berpatroli di suatu area, menangkap kapal dagang musuh serta menghancurkan Kapal (kapal pengangkut) dan Kapal Perang mereka. Unit ini muncul dari Pelabuhan terdekat dan akan berpatroli di area yang pertama kali kamu klik saat membangunnya.\nKamu dapat mengendalikan Kapal Perang dengan klik-serang pada unit tersebut (lihat aksi Serang pada menu Hotkeys), lalu klik-serang area baru yang ingin dituju.", + "build_silo": "Silo Peluncur Rudal", + "build_silo_desc": "Memungkinkan peluncuran rudal.", + "build_sam": "Peluncur Rudal SAM", + "build_sam_desc": "Dapat mencegat rudal musuh dalam jangkauan 100 piksel. Peluncur SAM memiliki waktu jeda cooldown 7,5 detik.", + "build_atom": "Bom Atom", + "build_atom_desc": "Bom kecil berdaya ledak tinggi yang menghancurkan wilayah, bangunan, kapal, dan perahu. Muncul dari Silo Rudal terdekat dan mendarat di area yang pertama kali kamu klik saat membangunnya.", + "build_hydrogen": "Bom Hidrogen", + "build_hydrogen_desc": "Bom berdaya ledak besar. Muncul dari Silo Rudal terdekat dan mendarat di area yang pertama kali kamu klik saat membangunnya.", + "build_mirv": "MIRV", + "build_mirv_desc": "Bom paling kuat di dalam permainan. Akan terpecah menjadi bom-bom yang lebih kecil dan mencakup area wilayah yang sangat luas. Hanya memberikan kerusakan kepada pemain yang pertama kali kamu klik saat membangunnya.\nSenjata ini muncul dari Silo Rudal terdekat dan akan mendarat di area yang pertama kali kamu klik saat membangunnya.", + "player_icons": "Ikon Pemain", + "icon_desc": "Berikut beberapa ikon yang akan kamu temui di dalam permainan beserta artinya:", + "icon_crown": "Mahkota – Peringkat 1. Pemain teratas di papan peringkat.", + "icon_traitor": "Perisai Retak – Pengkhianat. Pemain ini menyerang sekutu.", + "icon_ally": "Jabat Tangan – Sekutu. Pemain ini adalah sekutumu.", + "icon_embargo": "Tanda Dolar Dicoret – Embargo. Pemain ini menghentikan perdagangan denganmu, baik secara otomatis maupun manual.", + "icon_request": "Amplop – Permintaan Aliansi. Pemain ini mengirim permintaan aliansi kepadamu.", + "info_enemy_panel": "Panel Info Musuh", + "exit_confirmation": "Apakah yakin keluar dari game?", + "bomb_direction": "Arah busur bom atom/hidrogen" + }, + "single_modal": { + "title": "Sendiri", + "random_spawn": "Kemunculan acak", + "allow_alliances": "Perbolehkan Aliansi", + "toggle_achievements": "Tampilkan / Sembunyikan pencapaian", + "sign_in_for_achievements": "Masuk untuk melihat pencapaian", + "options_title": "Opsi", + "bots": "Bot: ", + "bots_disabled": "Dinonaktifkan", + "nations": "Bangsa-bangsa: ", + "disable_nations": "Nonaktifkan negara", + "instant_build": "Bangun instan", + "infinite_gold": "Emas tak terbatas", + "infinite_troops": "Pasukan tak terbatas", + "compact_map": "Peta Kecil", + "max_timer": "Lama permainan (menit)", + "max_timer_placeholder": "Menit", + "max_timer_invalid": "Silakan masukkan nilai pengatur waktu maksimum yang valid (1-120 menit)", + "disable_nukes": "Nonaktifkan Senjata Nuklir", + "enables_title": "Aktifkan Pengaturan", + "start": "Mulai Permainan" + }, + "token_login_modal": { + "title": "Sedang masuk...", + "logging_in": "Sedang masuk...", + "success": "Berhasil masuk sebagai {email}!" + }, + "account_modal": { + "title": "Akun", + "connected_as": "Terhubung sebagai", + "stats_overview": "Gambaran Umum Statistik", + "link_discord": "Tautkan Akun Discord", + "log_out": "Keluar", + "sign_in_desc": "Masuk untuk menyimpan statistik dan kemajuan Anda", + "or": "ATAU", + "email_placeholder": "Masukkan alamat email Anda", + "get_magic_link": "Dapatkan Tautan Ajaib", + "linked_account": "Masuk sebagai {account_name}", + "fetching_account": "Mengambil informasi akun...", + "recovery_email_sent": "Pemulihan email dikirim ke {email}", + "not_found": "Tidak Ditemukan", + "clear_session": "Hapus Sesi", + "failed_to_send_recovery_email": "Gagal mengirim pemulihan email", + "enter_email_address": "Silahkan masukan alamat email" + }, + "stats_modal": { + "title": "Statistik", + "clan_stats": "Statistik Klan", + "loading": "Loading...", + "error": "Error saat memuat statistik klan", + "no_stats": "Tidak ada klan yang tersedia", + "no_data_yet": "Data belum tersedia", + "clan": "Klan", + "games": "Permainan", + "win_score": "Skor Kemenangan", + "win_score_tooltip": "Kemenangan dihitung berdasarkan bobot partisipasi klan dan tingkat kesulitan pertandingan", + "loss_score": "Skor Kekalahan", + "loss_score_tooltip": "Kerugian dihitung berdasarkan partisipasi klan dan kesulitan pertandingan", + "win_loss_ratio": "Menang/Kalah", + "ratio": "Rasio", + "rank": "Peringkat", + "try_again": "Coba Lagi" + }, + "game_info_modal": { + "title": "Informasi Permainan", + "players": "Pemain", + "atoms": "Atom", + "hydros": "Hidro", + "mirv": "MIRV", + "bombs": "Bom", + "total_gold": "Total", + "all_gold": "Semua emas", + "trade": "Perdagangan", + "conquest_gold": "Emas pemain yang ditaklukan", + "stolen_gold": "Dicuri oleh Kapal Perang", + "num_of_conquests": "Jumlah pemain yang ditaklukan", + "duration": "Durasi", + "survival_time": "Menit Bertahan", + "war": "Perang", + "economy": "Ekonomi", + "conquests": "Penaklukan", + "pirate": "Bajak Laut", + "conquered": "Ditaklukan", + "loading_game_info": "Memuat Statistik Permainan", + "no_winner": "Permainan ini berakhir tanpa pemenang (atau Negara menang)" + }, + "map": { + "map": "Peta", + "world": "Dunia", + "giantworldmap": "Map Dunia Besar", + "europe": "Eropa", + "mena": "MENA", + "northamerica": "Amerika Utara", + "oceania": "Oseania", + "blacksea": "Laut Hitam", + "africa": "Afrika", + "asia": "Asia", + "mars": "Mars", + "southamerica": "Amerika Selatan", + "britanniaclassic": "Britania (klasik)", + "britannia": "Britania", + "gatewaytotheatlantic": "Pintu masuk menuju Atlantik", + "australia": "Australia", + "random": "Random", + "iceland": "Islandia", + "pangaea": "Pangea", + "eastasia": "Asia Timur", + "betweentwoseas": "Diantara Dua Laut", + "faroeislands": "Kepulauan Faroe", + "deglaciatedantarctica": "Antartika yang telah bebas dari gletser", + "europeclassic": "Eropa (klasik)", + "falklandislands": "Kepulauan Falkland", + "baikal": "Baikal", + "halkidiki": "Kalkidiki", + "straitofgibraltar": "Selat Gibraltar", + "italia": "Italia", + "japan": "Jepang", + "yenisei": "Sungai Yenisei", + "pluto": "Pluto", + "montreal": "Montreal", + "newyorkcity": "Kota New York", + "achiran": "Sungai Akheron", + "baikalnukewars": "Baikal (Perang Nuklir)", + "fourislands": "Empat Pulau", + "gulfofstlawrence": "Teluk St. Lawrence", + "lisbon": "Lisboa", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "sierpinski": "Sierpinski", + "twolakes": "Dua Danau", + "straitofhormuz": "Selat Hormuz", + "surrounded": "Surrourded", + "didier": "Didier", + "didierfrance": "Didier (Prancis)", + "amazonriver": "Sungai Amazon" + }, + "map_categories": { + "continental": "Kontinental", + "regional": "Regional", + "fantasy": "Lain", + "special": "Spesial", + "arcade": "Arkade" + }, + "map_component": { + "loading": "Loading...", + "error": "Kesalahan" + }, + "private_lobby": { + "title": "Gabung Lobi Privat", + "enter_id": "Masukan ID Lobi", + "player": "Pemain", + "players": "Pemain", + "join_lobby": "Bergabung ke Lobi", + "checking": "Memeriksa Lobi...", + "not_found": "Lobi tidak ditemukan. Mohon periksa ID dan coba lagi.", + "error": "Beberapa kesalahan terjadi. Silakan coba lagi atau hubungi dukungan.", + "joined_waiting": "Berhasil gabung ke lobi! Menunggu untuk penyelenggara untuk memulai...", + "version_mismatch": "Permainan ini dibuat dengan versi yang berbeda. Tidak dapat gabung.", + "disabled_units": "Nonaktfikan Units" + }, + "public_lobby": { + "join": "Gabung ke permainan selanjutnya", + "waiting": "Pemain menunggu", + "teams_Duos": "{team_count} tim berisi 2 pemain (Berdua)", + "teams_Trios": "{team_count} tim berisi 3 pemain (Bertiga)", + "teams_Quads": "{team_count} tim berisi 4 pemain (Berempat)", + "waiting_for_players": "Menunggu pemain", + "starting_game": "Memulai permainan…", + "teams_hvn": "Pemain vs Negara", + "teams_hvn_detailed": "{num} Pemain vs {num} Negara", + "teams": "{num} tim", + "players_per_team": "dari {num}", + "started": "Dimulai" + }, + "matchmaking_modal": { + "title": "Pertandingan 1v1 Ranked (ALPHA)", + "connecting": "Menghubungkan ke server pencarian lawan...", + "searching": "Mencari permainan...", + "waiting_for_game": "Menunggu permainan untuk dimulai...", + "elo": "ELO anda: {elo}" + }, + "username": { + "enter_username": "Masukkan nama pengguna", + "not_string": "Nama pengguna harus berupa string.", + "too_short": "Nama pengguna harus memiliki panjang minimal {min} karakter.", + "too_long": "Nama pengguna tidak boleh melebihi {max} karakter.", + "invalid_chars": "Nama pengguna hanya boleh berupa huruf, angka, spasi dan garis bawah.", + "tag": "Tag", + "tag_too_short": "Nama klan harus terdiri dari 2-5 karakter alfanumerik.", + "tag_invalid_chars": "Tag klan hanya boleh berisi huruf dan angka" + }, + "host_modal": { + "title": "Buat Lobi Tertutup", + "label": "Tertutup", + "mode": "Mode", + "team_count": "Jumlah Tim", + "team_type": "Tipe Tim", + "options_title": "Pilihan", + "bots": "Bot: ", + "bots_disabled": "Nonaktif", + "player_immunity_duration": "Durasi imunitas PVP (menit)", + "nations": "Bangsa-bangsa: ", + "disable_nations": "Nonaktifkan Negara", + "max_timer": "Lama permainan (menit)", + "mins_placeholder": "Menit", + "instant_build": "Bangun instan", + "infinite_gold": "Emas tak terbatas", + "donate_gold": "Donasikan emas", + "infinite_troops": "Pasukan tak terbatas", + "donate_troops": "Donasikan pasukan", + "compact_map": "Peta Kecil", + "enables_title": "Aktifkan Pengaturan", + "player": "Pemain", + "players": "Pemain", + "nation_players": "Bangsa-bangsa", + "nation_player": "Bangsa", + "waiting": "Menunggu pemain...", + "random_spawn": "Kemunculan Acak", + "start": "Mulai Permainan", + "host_badge": "Host", + "assigned_teams": "Tim yang Ditugaskan", + "empty_teams": "Tim Kosong", + "empty_team": "Kosong", + "remove_player": "Hapus {username}", + "teams_Duos": "Berdua (tim yang terdiri dari 2 orang)", + "teams_Trios": "Bertiga (tim yang terdiri dari 3 orang)", + "teams_Quads": "Berempat (tim yang teridri dari 4 orang)", + "teams_Humans Vs Nations": "Pemain vs Negara" + }, + "team_colors": { + "red": "Merah", + "blue": "Biru", + "teal": "Hijau Laut", + "purple": "Ungu", + "yellow": "Kuning", + "orange": "Oranye", + "green": "Hijau", + "bot": "Bot" + }, + "game_starting_modal": { + "title": "Memulai Permainan...", + "credits": "Kredit", + "code_license": "Kode berlisensi AGPL-3.0 (tanpa garansi)" + }, + "difficulty": { + "difficulty": "Kesulitan Negara", + "easy": "Mudah", + "medium": "Sedang", + "hard": "Sulit", + "impossible": "Mustahil" + }, + "game_mode": { + "ffa": "Siapapun bisa bergabung", + "teams": "Tim-tim" + }, + "public_game_modifier": { + "random_spawn": "Kemunculan Acak", + "compact_map": "Peta Kecil" + }, + "select_lang": { + "title": "Pilih Bahasa" + }, + "unit_type": { + "city": "Kota", + "defense_post": "Pos Pertahanan", + "port": "Pelabuhan", + "warship": "Kapal Perang", + "missile_silo": "Silo Peluncur Rudal", + "sam_launcher": "Peluncur Rudal SAM", + "atom_bomb": "Bom Atom", + "hydrogen_bomb": "Bom Hidrogen", + "mirv": "MIRV", + "factory": "Pabrik" + }, + "user_setting": { + "title": "Pengaturan", + "tab_basic": "Pengaturan Dasasr", + "tab_keybinds": "Tombol pintasan", + "dark_mode_label": "Mode Gelap", + "dark_mode_desc": "Beralih tampilan situs antara tema terang dan gelap", + "emojis_label": "Emoji", + "emojis_desc": "Alihkan tampilan emoji di dalam game", + "alert_frame_label": "Bingkai Peringatan", + "alert_frame_desc": "Aktifkan / Nonaktifkan bingkai peringatan. Saat diaktifkan, bingkai akan ditampilkan ketika kamu dikhianati atau diserang melalui darat.", + "special_effects_label": "Efek Spesial", + "special_effects_desc": "Alihkan efek khusus. Nonaktifkan untuk meningkatkan performa", + "structure_sprites_label": "Sprite Bangunan", + "structure_sprites_desc": "Alihkan tampilan sprite bangunan", + "cursor_cost_label_label": "Biaya Pembangunan Kursor", + "cursor_cost_label_desc": "Tampilkan label biaya di bawah ikon kursor pembangunan", + "anonymous_names_label": "Sembunyikan Nama", + "anonymous_names_desc": "Sembunyikan nama asli pemain dengan nama acak di layar Anda.", + "lobby_id_visibility_label": "Sembunyikan ID Lobby", + "lobby_id_visibility_desc": "Sembunyikan ID Lobby saat membuat lobby pribadi", + "toggle_visibility": "Alihkan Visibilitas", + "left_click_label": "Klik Kiri untuk Membuka Menu", + "left_click_desc": "Saat AKTIF, klik kiri membuka menu dan tombol pedang digunakan untuk menyerang. Saat NONAKTIF, klik kiri langsung melakukan serangan.", + "left_click_menu": "Klik Kiri untuk Menu", + "attack_ratio_label": "⚔️ Rasio Serangan", + "attack_ratio_desc": "Persentase pasukan yang dikirim saat menyerang (1–100%)", + "territory_patterns_label": "🏳️ Skin Wilayah", + "territory_patterns_desc": "Pilih apakah ingin menampilkan desain skin wilayah di dalam game", + "performance_overlay_label": "Tampilan Performa", + "performance_overlay_desc": "Aktifkan / Nonaktifkan overlay performa.\nSaat diaktifkan, overlay performa akan ditampilkan. Tekan Shift + D saat permainan berlangsung untuk mengaktifkan atau menonaktifkannya.", + "easter_writing_speed_label": "Multiplier Kecepatan Menulis", + "easter_writing_speed_desc": "Atur seberapa cepat kamu berpura-pura coding (x1–x100)", + "easter_bug_count_label": "Jumlah Bug", + "easter_bug_count_desc": "Seberapa banyak bug yang masih bisa Anda toleransi (0–1000, secara emosional)", + "press_a_key": "Tekan tombol", + "view_options": "Opsi Tampilan", + "toggle_view": "Alihkan Tampilan", + "toggle_view_desc": "Ganti Tampilan (Medan / Negara)", + "build_controls": "Kontrol Pembangunan", + "build_city": "Membangun Kota", + "build_city_desc": "Bangun Kota di bawah kursor Anda.", + "build_factory": "Bangun Pabrik", + "build_factory_desc": "Bangun Pabrik di bawah kursor Anda.", + "build_defense_post": "Bangun Pos Pertahanan", + "build_defense_post_desc": "Bangun Pos Pertahanan di bawah kursor Anda.", + "build_port": "Membangun Pelabuhan", + "build_port_desc": "Bangun Pelabuhan di bawah kursor Anda.", + "build_warship": "Bangun Kapal Perang", + "build_warship_desc": "Bangun Kapal Perang di bawah kursor Anda.", + "build_missile_silo": "Bangun Silo Peluncur Rudal", + "build_missile_silo_desc": "Bangun Silo Peluncur Rudal di bawah kursor Anda.", + "build_sam_launcher": "Bangun Peluncur Rudal SAM", + "build_sam_launcher_desc": "Bangun Peluncur Rudal SAM di bawah kursor Anda.", + "build_atom_bomb": "Bangun Bom Atom", + "build_atom_bomb_desc": "Bangun Bom Atom di bawah kursor Anda.", + "build_hydrogen_bomb": "Bangun Bom Hidrogen", + "build_hydrogen_bomb_desc": "Membangun Bom Hidrogen di bawah kursor Anda.", + "build_mirv": "Membangun MIRV", + "build_mirv_desc": "Bangun MIRV di bawah kursor Anda.", + "menu_shortcuts": "Menu Pintasan", + "build_menu_modifier": "Pengubah Menu Pembangunan", + "build_menu_modifier_desc": "Tahan tombol ini sambil mengklik untuk membuka menu pembuatan.", + "emoji_menu_modifier": "Pengubah Menu Emoji", + "emoji_menu_modifier_desc": "Tahan tombol ini sambil mengklik untuk membuka menu emoji.", + "attack_ratio_controls": "Kontrol Rasio Serangan", + "attack_ratio_up": "Tingkatkan Rasio Serangan", + "attack_ratio_up_desc": "Tingkatkan Rasio Serangan sebesar 10%", + "attack_ratio_down": "Kurangi Rasio Serangan", + "attack_ratio_down_desc": "Kurangi Rasio Serangan sebesar 10%", + "attack_keybinds": "Tombol pintas untuk Serangan", + "boat_attack": "Serangan Kapal Pengangkut", + "boat_attack_desc": "Kirim serangan kapal ke petak di bawah kursor Anda.", + "ground_attack": "Serangan Darat", + "ground_attack_desc": "Kirim serangan darat ke petak di bawah kursor Anda.", + "swap_direction": "Tukar Arah Roket", + "swap_direction_desc": "Ubah arah peluncuran roket (atas/bawah).", + "zoom_controls": "Kontrol Zoom", + "zoom_out": "Perkecil tampilan", + "zoom_out_desc": "Perkecil tampilan peta", + "zoom_in": "Perbesar", + "zoom_in_desc": "Perbesar tampilan peta", + "camera_movement": "Pergerakan Kamera", + "center_camera": "Sorot kamera ke tengah", + "center_camera_desc": "Pusatkan kamera pada pemain", + "move_up": "Pindahkan kamera ke atas", + "move_up_desc": "Memindahkan kamera ke atas", + "move_left": "Pindahkan kamera ke kiri", + "move_left_desc": "Memindahkan kamera ke kiri", + "move_down": "Pindahkan kamera ke bawah", + "move_down_desc": "Memindahkan kamera ke bawah", + "move_right": "Pindahkan kemara ke kanan", + "move_right_desc": "Memindahkan kamera ke kanan", + "reset": "Reset", + "unbind": "Batalkan pengikatan tombol", + "on": "Hidup", + "off": "Mati", + "toggle_terrain": "Tampilkan / Sembunyikan Medan", + "exit_game_label": "Keluar Game", + "exit_game_info": "Kembali ke menu utama", + "background_music_volume": "Volume latar belakang musik", + "sound_effects_volume": "Volume Efek Suara", + "keybind_conflict_error": "Tombol {key} sudah terikat ke aksi lain." + }, + "chat": { + "title": "Obrolan Cepat", + "to": "Dari {user}: {msg}", + "from": "Dari {user}: {msg}", + "category": "Kategori", + "phrase": "Frase", + "player": "Pemain", + "send": "Kirim", + "search": "Cari pemain...", + "build": "Ketik pesanmu...", + "cat": { + "help": "Bantuan", + "attack": "Serang", + "defend": "Bertahan", + "greet": "Salam", + "misc": "Lain-lain", + "warnings": "Peringatan" + }, + "help": { + "troops": "Tolong berikan saya tentara!", + "troops_frontlines": "Kirim pasukan ke garis depan!", + "gold": "Tolong berikan saya emas!", + "no_attack": "Tolong jangan serang saya!", + "sorry_attack": "Maaf, Saya tidak bermaksud untuk menyerang Anda.", + "alliance": "Aliansi?", + "help_defend": "Bantu saya bertahan dari [P1]!", + "trade_partners": "Mari menjadi mitra dagang!" + }, + "attack": { + "attack": "Serang [P1]!", + "mirv": "Luncurkan MIRV ke [P1]!", + "focus": "Fokus serangan pada [P1]!", + "finish": "Mari selesaikan [P1]!", + "build_warships": "Bangun Kapal-Kapal Perang!" + }, + "defend": { + "defend": "Pertahankan [P1]!", + "defend_from": "Bertahan dari [P1]!", + "dont_attack": "Jangan serang [P1]!", + "ally": "[P1] adalah aliansi saya!", + "build_posts": "Bangun Pos Pertahanan!" + }, + "greet": { + "hello": "Halo!", + "good_job": "Kerja bagus!", + "good_luck": "Semoga sukses!", + "have_fun": "Selamat bersenang-senang!", + "gg": "GG!", + "nice_to_meet": "Senang bertemu denganmu!", + "well_played": "Bagus Sekali!", + "hi_again": "Halo lagi!", + "bye": "Da!", + "thanks": "Terima kasih!", + "oops": "Ups, salah tombol!", + "trust_me": "Anda bisa percaya saya. Janji!", + "trust_broken": "Aku percaya padamu...", + "ruining_games": "Kamu bikin permainan kita berdua jadi kacau.", + "dont_do_that": "Jangan!", + "same_team": "Saya di pihak Anda!" + }, + "misc": { + "go": "Ayo!", + "strategy": "Strategi yang mantap!", + "fun": "Permainan ini seru!", + "team_up": "Mari menyerang [P1] bersama-sama!", + "pr": "Kapan PR-ku akhirnya akan digabungkan...?", + "build_closer": "Bangun lebih dekat agar membuat jalur kereta!", + "coastline": "Tolong izinkan saya mendapatkan garis pantai." + }, + "warnings": { + "strong": "[P1] kuat.", + "weak": "[P1] lemah.", + "mirv_soon": "[P1] akan meluncurkan MIRV segera!", + "number1_warning": "Pemain nomor 1 akan segera menang kecuali kita bekerja sama!", + "stalemate": "Mari berdamai. Ini jalan buntu, kita berdua akan kalah.", + "has_allies": "[P1] punya banyak sekutu.", + "no_allies": "[P1] tidak punya sekutu.", + "betrayed": "[P1] menkhianati sekutu dia!", + "betrayed_me": "[P1] menkhianati saya!", + "getting_big": "[P1] berkembang sangat cepat!", + "danger_base": "[P1] tidak terproteksi!", + "saving_for_mirv": "[P1] sedang menabung untuk meluncurkan MIRV.", + "mirv_ready": "[P1] punya cukup emas untuk meluncurkan MIRV!", + "snowballing": "[P1] berkembang terlalu cepat!", + "cheating": "[P1] curang!", + "stop_trading": "Stop berdangan dengan [P1]!" + } + }, + "build_menu": { + "desc": { + "atom_bomb": "Ledakan kecil", + "hydrogen_bomb": "Ledakan dahsyat", + "mirv": "Ledakan Dahsyat, hanya menargetkan pemain yang dipilih", + "missile_silo": "Digunakan untuk meluncurkan nuklir", + "sam_launcher": "Penangkalan nuklir yang mendekat", + "warship": "Menangkap kapal dagang, menghancurkan kapal dan perahu.", + "port": "Mengirim kapal untuk mendapatkan emas", + "defense_post": "Meningkatkan pertahanan perbatasan", + "city": "Meningkatkan jumlah maksimal populasi", + "factory": "Membuat rel dan memunculkan kereta" + }, + "not_enough_money": "Uang tidak cukup" + }, + "win_modal": { + "support_openfront": "Dukung OpenFront!", + "territory_pattern": "Beli skin wilayah untuk bebas iklan!", + "died": "Anda meninggal", + "your_team": "Tim Anda menang!", + "other_team": "tim {team} menang!", + "you_won": "Anda Menang!", + "other_won": "{player} menang!", + "nation_won": "Negara {nation} menang!", + "exit": "Keluar Game", + "keep": "Terus Main", + "spectate": "Menonton", + "wishlist": "Wishlist di Steam!", + "ofm_winter": "Turnamen Musim Dingin OpenFront Masters!", + "ofm_winter_description": "Ikuti turnamen kompetitif dan bersaing melawan pemain terbaik", + "join_tournament": "Ikut Turnamen", + "join_discord": "Gabung Komunitas Discord Kami!", + "discord_description": "Terhubung dengan pemain lain, temukan fitur baru, dan menangkan hadiah!", + "join_server": "Bergabung dengan Server", + "youtube_tutorial": "Butuh bantuan?" + }, + "leaderboard": { + "title": "Papan Peringkat", + "hide": "Sembunyikan", + "rank": "Peringkat", + "player": "Pemain", + "team": "Tim", + "owned": "Dimiliki", + "gold": "Emas", + "maxtroops": "Maksimal pasukan", + "launchers": "Peluncur", + "sams": "SAM-SAM", + "warships": "Kapal Perang", + "cities": "Kota-Kota", + "show_control": "Tampilkan Kontrol", + "show_units": "Tampilkan Unit" + }, + "player_info_overlay": { + "type": "Jenis", + "bot": "Bot", + "nation": "Bangsa", + "player": "Pemain", + "team": "Tim", + "alliance_timeout": "Aliansi berakhir dalam", + "troops": "Pasukan", + "maxtroops": "Maksimal pasukan", + "a_troops": "Pasukan menyerang", + "gold": "Emas", + "ports": "Pelabuhan-Pelabuhan", + "cities": "Kota-kota", + "factories": "Pabrik-pabrik", + "missile_launchers": "Peluncur rudal", + "sams": "SAM", + "warships": "Kapal Perang", + "health": "Kesehatan", + "attitude": "Sikap", + "levels": "Tingkat", + "wilderness_title": "Alam Liar", + "irradiated_wilderness_title": "Hutan Belantara yang Terkena Radiasi" + }, + "events_display": { + "retreating": "mundur", + "retaliate": "Membalas", + "boat": "Perahu", + "alliance_request_status": "{name} {status} permintaan aliansi Anda", + "alliance_accepted": "diterima", + "alliance_rejected": "ditolak", + "duration_second": "1 detik", + "betrayal_description": "Kamu memutus aliansi dengan {name}, menjadikanmu PENGKHIANAT ({malusPercent}% pengurangan pertahanan selama {durationText})", + "duration_seconds_plural": "{seconds} detik", + "betrayed_you": "{name} memutus aliansi dengan Anda", + "about_to_expire": "Aliansi Anda dengan {name} hampir berakhir!", + "alliance_expired": "Aliansi Anda dengan {name} berakhir", + "attack_request": "{name} meminta Anda untuk menyerang {target}", + "sent_emoji": "Dari {name}: {emoji}", + "renew_alliance": "Minta untuk memperpanjang", + "request_alliance": "{name} meminta aliansi!", + "focus": "Fokus", + "accept_alliance": "Setuju", + "reject_alliance": "Tolak", + "alliance_renewed": "Aliansi anda dengan {name} sudah di perpanjang", + "wants_to_renew_alliance": "{name} ingin memperpanjang aliansi", + "ignore": "Abaikan", + "unit_voluntarily_deleted": "Unit dihapus secara sukarela", + "betrayal_debuff_ends": "{time} detik tersisa hingga efek negatif pengkhianatan berakhir", + "attack_cancelled_retreat": "Penyerangan dibatalkan, {troops} pasukan terbunuh saat mundur", + "received_gold_from_captured_ship": "Menerima {gold} emas dari kapal yang di tawan dari {name}", + "received_gold_from_trade": "Menerima {gold} emas dari perdagangan dengan {name}", + "missile_intercepted": "Rudal dicegat {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} hulu ledak MIRV berhasil dicegat} other {{count} hulu ledak MIRV berhasil dicegat}}", + "sent_troops_to_player": "Mengirim {troops} pasukan ke {name}", + "received_troops_from_player": "Menerima {troops} pasukan dari {name}", + "sent_gold_to_player": "Mengirim {gold} emas ke {name}", + "received_gold_from_player": "Menerima {gold} emas dari {name}", + "unit_captured_by_enemy": "{unit} Anda ditangkap oleh {name}", + "captured_enemy_unit": "Menangkap {unit} dari {name}", + "unit_destroyed": "{unit} Anda dihancurkan", + "no_boats_available": "Tidak ada kapal yang tersedia, maksmial {max}" + }, + "unit_info_modal": { + "structure_info": "Informasi Struktur", + "unit_type_unknown": "Tidak Diketahui", + "close": "Keluar", + "cooldown": "Cooldown", + "type": "Jenis", + "upgrade": "Tingkatkan", + "level": "Tingkat" + }, + "player_type": { + "player": "Pemain", + "nation": "Bangsa", + "bot": "Bot" + }, + "relation": { + "hostile": "Berseteru", + "distrustful": "Tak dapat dipercaya", + "neutral": "Netral", + "friendly": "Ramah", + "default": "Default" + }, + "control_panel": { + "gold": "Emas", + "troops": "Pasukan", + "attack_ratio": "Rasio Serangan" + }, + "player_panel": { + "gold": "Emas", + "troops": "Pasukan", + "betrayals": "Pengkhianatan", + "traitor": "Pengkhianat", + "trading": "Perdagangan", + "active": "Aktif", + "stopped": "Berhenti", + "alliance_time_remaining": "Aliansi Berakhir Dalam", + "embargo": "Berhenti berdangan dengan Anda", + "nuke": "Nuklir dikirim oleh mereka kepada Anda", + "start_trade": "Mulai Berdagang", + "stop_trade": "Stop Berdagang", + "stop_trade_all": "Stop Berdagang degnan Semuanya", + "start_trade_all": "Mulai Berdagang dengan Semuanya", + "alliances": "Aliansi", + "flag": "Bendera", + "chat": "Chat", + "target": "Sasaran", + "break_alliance": "Rusak Aliansi", + "alliance": "Aliansi", + "send_alliance": "Kirim Proposal Aliansi", + "send_troops": "Kirim Pasukan", + "send_gold": "Kirim Emas", + "emotes": "Emoji", + "arc_up": "Lengkungan ke Atas", + "arc_down": "Lengkungan ke Bawah", + "flip_rocket_trajectory": "Balikkan lintasan roket" + }, + "send_troops_modal": { + "title_with_name": "Kirim Pasukan ke {name}", + "available_tooltip": "Pasukan Anda yang tersedia saat ini", + "min_keep": "Minimal yang ditinggalkan", + "slider_tooltip": "{{percent}}% • {{amount}}", + "aria_slider": "Penggeser pasukan", + "capacity_note": "Penerima hanya dapat menerima {{amount}} saat ini." + }, + "send_gold_modal": { + "title_with_name": "Kirim Emas ke {name}", + "available_tooltip": "Emas yang Anda miliki saat ini", + "aria_slider": "Penggeser jumlah", + "slider_tooltip": "{{percent}}% • {{amount}}" + }, + "replay_panel": { + "replay_speed": "Kecepatan tanyangan ulang", + "game_speed": "Kecepatan Permainan", + "fastest_game_speed": "Maks" + }, + "error_modal": { + "crashed": "Game berhenti / rusak!", + "connection_error": "Kesalahan koneksi!", + "paste_discord": "Silakan tempelkan teks berikut di laporan bug Anda di Discord:", + "copy_clipboard": "Salin ke papan klip", + "copied": "Tersalin!", + "failed_copy": "Gagal menyalin", + "spawn_failed": { + "title": "Kemunculan gagal", + "description": "Pemilihan titik awal otomatis gagal. Anda tidak dapat memainkan game ini." + }, + "desync_notice": "Anda tidak tersinkronisasi dengan pemain lain. Apa yang Anda lihat mungkin berbeda dari pemain lain." + }, + "performance_overlay": { + "reset": "Set ulang", + "copy_json_title": "Salin metrik kinerja saat ini sebagai JSON.", + "copy_clipboard": "Menyalin JSON", + "copied": "Tersalin!", + "failed_copy": "Gagal menyalin", + "fps": "FPS:", + "avg_60s": "Rata-rata (60d):", + "frame": "Bingkai:", + "tick_exec": "Eksekutif Tick:", + "tick_delay": "Penundaan Detik:", + "layers_header": "Lapisan (rata-rata / maksimum, diurutkan berdasarkan total waktu):" + }, + "heads_up_message": { + "choose_spawn": "Pilih lokasi awal", + "random_spawn": "Kemunculan acak diaktifkan. Memilih lokasi awal untuk Anda...", + "singleplayer_game_paused": "Permainan dijeda", + "multiplayer_game_paused": "Permainan di tunda oleh Pembuat Lobi" + }, + "territory_patterns": { + "title": "Tampilan", + "colors": "Warna-Warna", + "purchase": "Beli", + "show_only_owned": "Skin Saya", + "all_owned": "Semua skin sudah dimiliki! Silakan periksa kembali nanti untuk item baru.", + "not_logged_in": "Belum masuk", + "blocked": { + "login": "Anda harus login untuk mengakses skin ini.", + "purchase": "Beli skin ini untuk membukanya." + }, + "pattern": { + "default": "Default" + }, + "select_skin": "Pilih Skin", + "selected": "dipilih" + }, + "flag_input": { + "title": "Pilih Bendera", + "button_title": "Pilih bendera!", + "search_flag": "Cari..." + }, + "spawn_ad": { + "loading": "Memuat iklan..." + }, + "auth": { + "login_required": "Masuk dibutuhkan untuk mengakses website ini.", + "redirecting": "Anda sedang diarahkan...", + "not_authorized": "Anda tidak punya izin untuk mengakses website ini.", + "contact_admin": "Jika Anda yakin melihat pesan ini karena kesalahan, silakan hubungi administrator situs web." + }, + "radial_menu": { + "delete_unit_title": "Hapus Unit", + "delete_unit_description": "Klik untuk menghapus unit terdekat" + }, + "discord_user_header": { + "avatar_alt": "Avatar" + }, + "player_stats_table": { + "building_stats": "Statistik Bangunan", + "ship_arrivals": "Kedatangan Kapal", + "nuke_stats": "Statistik Nuklir", + "player_metrics": "Metrik Pemain", + "building": "Gedung", + "ship_type": "Jenis Kapal", + "weapon": "Senjata", + "built": "Bangun", + "destroyed": "Telah Hancur", + "captured": "Ditangkap", + "lost": "Kalah", + "hits": "Hits", + "launched": "Telah Diluncurkan", + "landed": "Mendarat", + "sent": "Terkirim", + "arrived": "Tiba", + "attack": "Serang", + "received": "Diterima", + "cancelled": "Dibatalkan", + "count": "Hitungan", + "gold": "Emas", + "workers": "Pekerja", + "war": "Perang", + "trade": "Perdagangan", + "steal": "Steal", + "unit": { + "city": "Kota", + "port": "Pelabuhan", + "defp": "Pos Pertahanan", + "saml": "Peluncur Rudal SAM", + "silo": "Silo Peluncur Rudal", + "wshp": "Kapal Perang", + "fact": "Pabrik", + "trade": "Kapal Perdagangan", + "trans": "Kapal Pengangkut", + "abomb": "Bom Atom", + "hbomb": "Bom Hidrogen", + "mirv": "MIRV", + "mirvw": "Hulu ledak MIRV" + } + }, + "game_list": { + "recent_games": "Permainan Terbaru", + "game_id": "ID Permainan", + "mode": "Mode", + "mode_ffa": "Siapapun bisa bergabung", + "mode_team": "Tim", + "replay": "Tayangan ulang", + "details": "Detail", + "ranking": "Peringkat", + "started": "Dimulai", + "map": "Peta", + "difficulty": "Tingkat Kesulitan", + "type": "Jenis" + }, + "player_stats_tree": { + "public": "Publik", + "private": "Tertutup", + "singleplayer": "Sendiri", + "mode": "Mode", + "stats_wins": "Jumlah Kemenangan", + "stats_losses": "Jumlah Kehilangan", + "stats_wlr": "Menang:Kalah Rasio", + "stats_games_played": "Permainan Dimainkan", + "mode_ffa": "Siapapun bisa bergabung", + "mode_team": "Tim", + "no_stats": "Tidak ada statistik yang tercatat untuk pilihan ini." + }, + "matchmaking_button": { + "play_ranked": "Pertandingan 1v1 Ranked", + "description": "(ALPHA)", + "login_required": "Masuk untuk bermain peringkat!", + "must_login": "Anda harus masuk untuk bermain di pertandingan 1v1 Ranked." + } +} diff --git a/resources/lang/metadata.json b/resources/lang/metadata.json index cfb9af301..4c1f989ae 100644 --- a/resources/lang/metadata.json +++ b/resources/lang/metadata.json @@ -101,6 +101,12 @@ "en": "Hungarian", "svg": "hu" }, + { + "code": "id", + "native": "Bahasa Indonesia", + "en": "Indonesian", + "svg": "id" + }, { "code": "it", "native": "Italiano", From cb3128f390998a0ed095ebfd63be25aafa7e7847 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 28 Jan 2026 11:29:27 -0800 Subject: [PATCH 25/45] Better CrazyGames integration (#3055) ## Description: Better integration with CrazyGames: * Don't show login because accounts have not been integrated with CrazyGames yet * Integrate CG invite links & usernames * Refactor match making logic to Matchmaking.ts * Allow periods to support crazy game usernames * Create a no-crazygames class that disabled elements when on crazygames ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/CrazyGamesSDK.ts | 161 +++++++++++++++++- src/client/HostLobbyModal.ts | 10 +- src/client/LangSelector.ts | 1 + src/client/Main.ts | 111 ++++++------ src/client/Matchmaking.ts | 73 +++++++- src/client/PatternInput.ts | 5 + src/client/SinglePlayerModal.ts | 6 + src/client/UsernameInput.ts | 35 ++-- src/client/components/CopyButton.ts | 11 +- src/client/components/DesktopNavBar.ts | 6 +- src/client/components/MobileNavBar.ts | 4 +- src/client/components/PatternButton.ts | 2 +- src/client/components/PlayPage.ts | 27 +-- .../graphics/layers/GameRightSidebar.ts | 15 +- src/client/graphics/layers/SettingsModal.ts | 11 +- src/client/graphics/layers/UnitDisplay.ts | 2 +- src/client/graphics/layers/WinModal.ts | 1 + src/core/Schemas.ts | 2 +- 18 files changed, 358 insertions(+), 125 deletions(-) diff --git a/src/client/CrazyGamesSDK.ts b/src/client/CrazyGamesSDK.ts index b00cfe7de..1933074ec 100644 --- a/src/client/CrazyGamesSDK.ts +++ b/src/client/CrazyGamesSDK.ts @@ -3,6 +3,28 @@ declare global { CrazyGames?: { SDK: { init: () => Promise; + user: { + getUser(): Promise<{ + username: string; + } | null>; + addAuthListener: ( + listener: ( + user: { + username: string; + } | null, + ) => void, + ) => void; + }; + ad: { + requestAd: ( + adType: string, + callbacks: { + adStarted: () => void; + adFinished: () => void; + adError: (error: any) => void; + }, + ) => void; + }; game: { gameplayStart: () => Promise; gameplayStop: () => Promise; @@ -14,7 +36,9 @@ declare global { [key: string]: string | number; }) => string; hideInviteButton: () => void; + inviteLink: (params: { [key: string]: string | number }) => string; getInviteParam: (paramName: string) => string | null; + isInstantMultiplayer?: boolean; }; }; }; @@ -24,6 +48,24 @@ declare global { export class CrazyGamesSDK { private initialized = false; private isGameplayActive = false; + private readyPromise: Promise; + private resolveReady!: () => void; + + constructor() { + this.readyPromise = new Promise((resolve) => { + this.resolveReady = resolve; + }); + } + + async ready(): Promise { + const timeout = new Promise((resolve) => { + setTimeout(() => resolve(false), 3000); + }); + + const ready = this.readyPromise.then(() => true); + + return Promise.race([ready, timeout]); + } isOnCrazyGames(): boolean { try { @@ -34,9 +76,17 @@ export class CrazyGamesSDK { } return false; } catch (e) { + console.log("[CrazyGames]: ", e); // If we get a cross-origin error, we're definitely iframed // Check our own referrer as fallback - return document.referrer.includes("crazygames"); + const isCrazyGames = document.referrer.includes("crazygames"); + console.log("[CrazyGames], contains referrer: ", isCrazyGames); + if (isCrazyGames) { + return true; + } + + // Fallback: on safari private we can't get referrer, so just assume we are in crazygames if in iframe + return window.self !== window.top; } } @@ -70,12 +120,63 @@ export class CrazyGamesSDK { try { await window.CrazyGames.SDK.init(); this.initialized = true; + this.resolveReady(); console.log("CrazyGames SDK initialized"); } catch (error) { console.error("Failed to initialize CrazyGames SDK:", error); } } + async getUsername(): Promise { + const isReady = await this.ready(); + if (!isReady) { + return null; + } + try { + return (await window.CrazyGames!.SDK.user.getUser())?.username ?? null; + } catch (e) { + console.log("error getting CrazyGames username: ", e); + return null; + } + } + + async addAuthListener( + listener: ( + user: { + username: string; + } | null, + ) => void, + ): Promise { + if (!(await this.ready())) { + return; + } + + try { + console.log("registering CrazyGames auth listener"); + window.CrazyGames!.SDK.user.addAuthListener(listener); + } catch (error) { + console.error("Failed to add auth listener:", error); + } + } + + async isInstantMultiplayer(): Promise { + const isReady = await this.ready(); + if (!isReady) { + return false; + } + const gameId = await this.getInviteGameId(); + if (gameId !== null) { + // Game id exists, meaning we are joining the game, not hosting it. + return false; + } + try { + return window.CrazyGames!.SDK.game.isInstantMultiplayer ?? false; + } catch (e) { + console.log("Error getting instant multiplayer: ", e); + return false; + } + } + async gameplayStart(): Promise { if (!this.isReady()) { return; @@ -156,7 +257,6 @@ export class CrazyGamesSDK { if (!this.isReady()) { return null; } - try { const options: { gameId: string | number; @@ -165,6 +265,9 @@ export class CrazyGamesSDK { gameId, }; const link = window.CrazyGames!.SDK.game.showInviteButton(options); + // Store the game so we know that we are host. This way when player refreshes page, + // It won't show up as "joining" a game we created. + localStorage.setItem(gameId, "true"); console.log("CrazyGames: invite button shown, link:", link); return link; } catch (error) { @@ -186,20 +289,66 @@ export class CrazyGamesSDK { } } - getInviteGameId(): string | null { + createInviteLink(gameId: string): string | null { if (!this.isReady()) { + console.warn("CrazyGames SDK not ready, cannot create invite link"); return null; } try { - const value = window.CrazyGames!.SDK.game.getInviteParam("gameId"); - console.log(`CrazyGames: got invite gameId:`, value); - return value; + const link = window.CrazyGames!.SDK.game.inviteLink({ gameId }); + console.log("CrazyGames: created invite link:", link); + return link; + } catch (error) { + console.error("Failed to create invite link:", error); + return null; + } + } + + async getInviteGameId(): Promise { + if (!(await this.ready())) { + return null; + } + try { + const gameId = window.CrazyGames!.SDK.game.getInviteParam("gameId"); + if (gameId) { + console.log("[CrazyGames] found invite link", gameId); + // We already created this game, can't join a game we created. + return localStorage.getItem(gameId) === "true" ? null : gameId; + } + return null; } catch (error) { console.error(`Failed to get invite gameId:`, error); return null; } } + + requestMidgameAd(): Promise { + return new Promise((resolve) => { + if (!this.isReady()) { + resolve(); + return; + } + + try { + const callbacks = { + adFinished: () => { + console.log("End midgame ad"); + resolve(); + }, + adError: (error: any) => { + console.log("Error midgame ad", error); + resolve(); + }, + adStarted: () => console.log("Start midgame ad"), + }; + window.CrazyGames!.SDK.ad.requestAd("midgame", callbacks); + } catch (error) { + console.error("Failed to request midgame ad:", error); + resolve(); + } + }); + } } export const crazyGamesSDK = new CrazyGamesSDK(); diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 03e0a6ed0..cd49edf11 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -113,6 +113,12 @@ export class HostLobbyModal extends BaseModal { } private async buildLobbyUrl(): Promise { + if (crazyGamesSDK.isOnCrazyGames()) { + const link = crazyGamesSDK.createInviteLink(this.lobbyId); + if (link !== null) { + return link; + } + } const config = await getServerConfigFromClient(); return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`; } @@ -123,7 +129,9 @@ export class HostLobbyModal extends BaseModal { } private updateHistory(url: string): void { - history.replaceState(null, "", url); + if (!crazyGamesSDK.isOnCrazyGames()) { + history.replaceState(null, "", url); + } } render() { diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 3097ea7a8..f80415d06 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -228,6 +228,7 @@ export class LangSelector extends LitElement { "stats-modal", "flag-input-modal", "flag-input", + "matchmaking-button", "token-login", ]; diff --git a/src/client/Main.ts b/src/client/Main.ts index 57644d6b8..2cfe82ad3 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -7,7 +7,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; -import { getUserMe, hasLinkedAccount } from "./Api"; +import { getUserMe } from "./Api"; import { userAuth } from "./Auth"; import { joinLobby } from "./ClientGameRunner"; import { fetchCosmetics } from "./Cosmetics"; @@ -43,7 +43,7 @@ import { } from "./Transport"; import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; -import { UsernameInput } from "./UsernameInput"; +import { genAnonUsername, UsernameInput } from "./UsernameInput"; import { getDiscordAvatarUrl, incrementGamesPlayed, @@ -209,6 +209,7 @@ class Client { private usernameInput: UsernameInput | null = null; private flagInput: FlagInput | null = null; + private hostModal: HostPrivateLobbyModal; private joinModal: JoinPrivateLobbyModal; private publicLobby: PublicLobby; private userSettings: UserSettings = new UserSettings(); @@ -426,56 +427,14 @@ class Client { ) { console.warn("Matchmaking modal element not found"); } - const matchmakingButton = document.getElementById("matchmaking-button"); - const matchmakingButtonLoggedOut = document.getElementById( - "matchmaking-button-logged-out", - ); - - const updateMatchmakingButton = (loggedIn: boolean) => { - if (!loggedIn) { - matchmakingButton?.classList.add("hidden"); - matchmakingButtonLoggedOut?.classList.remove("hidden"); - } else { - matchmakingButton?.classList.remove("hidden"); - matchmakingButtonLoggedOut?.classList.add("hidden"); - } - }; - - if (matchmakingButton) { - matchmakingButton.addEventListener("click", () => { - if (this.usernameInput?.isValid()) { - window.showPage?.("page-matchmaking"); - this.publicLobby.leaveLobby(); - } else { - window.dispatchEvent( - new CustomEvent("show-message", { - detail: { - message: this.usernameInput?.validationError, - color: "red", - duration: 3000, - }, - }), - ); - } - }); - } - - if (matchmakingButtonLoggedOut) { - matchmakingButtonLoggedOut.addEventListener("click", () => { - window.showPage?.("page-account"); - }); - } const onUserMe = async (userMeResponse: UserMeResponse | false) => { - // Check if user has actual authentication (discord or email), not just a publicId - const isLinked: boolean = hasLinkedAccount(userMeResponse); - updateMatchmakingButton(isLinked); updateAccountNavButton(userMeResponse); - const adsEnabled = + const hasLinkedAccount = !crazyGamesSDK.isOnCrazyGames() && - ((userMeResponse || null)?.player?.flares?.length ?? 0) === 0; - console.log("ads enabled: ", adsEnabled); - window.adsEnabled = adsEnabled; + ((userMeResponse || null)?.player?.flares?.length ?? 0) > 0; + console.log("ads enabled: ", hasLinkedAccount); + window.adsEnabled = !hasLinkedAccount && !crazyGamesSDK.isOnCrazyGames(); document.dispatchEvent( new CustomEvent("userMeResponse", { detail: userMeResponse, @@ -516,10 +475,10 @@ class Client { } }); - const hostModal = document.querySelector( + this.hostModal = document.querySelector( "host-lobby-modal", ) as HostPrivateLobbyModal; - if (!hostModal || !(hostModal instanceof HostPrivateLobbyModal)) { + if (!this.hostModal || !(this.hostModal instanceof HostPrivateLobbyModal)) { console.warn("Host private lobby modal element not found"); } const hostLobbyButton = document.getElementById("host-lobby-button"); @@ -575,7 +534,11 @@ class Client { } // Attempt to join lobby - this.handleUrl(); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => this.handleUrl()); + } else { + this.handleUrl(); + } const onHashUpdate = () => { // Reset the UI to its initial state @@ -647,17 +610,36 @@ class Client { }); } - private handleUrl() { + private async handleUrl() { + // Wait for modal custom elements to be defined + await Promise.all([ + customElements.whenDefined("join-private-lobby-modal"), + customElements.whenDefined("host-lobby-modal"), + ]); + // Check if CrazyGames SDK is enabled first (no hash needed in CrazyGames) if (crazyGamesSDK.isOnCrazyGames()) { - const lobbyId = crazyGamesSDK.getInviteGameId(); + const lobbyId = await crazyGamesSDK.getInviteGameId(); + console.log("got game id", lobbyId); if (lobbyId && GAME_ID_REGEX.test(lobbyId)) { + console.log("game parsed successfully"); + // Wait 2 seconds to ensure all elements are actually loaded, + // On low end-chromebooks the join modal was not registered in time. + await new Promise((resolve) => setTimeout(resolve, 2000)); window.showPage?.("page-join-private-lobby"); this.joinModal?.open(lobbyId); console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`); return; } } + crazyGamesSDK.isInstantMultiplayer().then((isInstant) => { + if (isInstant) { + console.log( + `CrazyGames: joining instant multiplayer lobby from CrazyGames`, + ); + this.hostModal.open(); + } + }); const strip = () => history.replaceState( @@ -780,7 +762,8 @@ class Client { : this.flagInput.getCurrentFlag(), }, turnstileToken: await this.getTurnstileToken(lobby), - playerName: this.usernameInput?.getCurrentUsername() ?? "", + playerName: + this.usernameInput?.getCurrentUsername() ?? genAnonUsername(), clientID: lobby.clientID, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, @@ -926,7 +909,8 @@ class Client { return null; } - if (this.turnstileTokenPromise === null) { + // Always request a new token on crazygames. + if (this.turnstileTokenPromise === null || crazyGamesSDK.isOnCrazyGames()) { console.log("No prefetched turnstile token, getting new token"); return (await getTurnstileToken())?.token ?? null; } @@ -942,6 +926,7 @@ class Client { const tokenTTL = 3 * 60 * 1000; if (Date.now() < token.createdAt + tokenTTL) { console.log("Prefetched turnstile token is valid"); + return token.token; } else { console.log("Turnstile token expired, getting new token"); @@ -950,11 +935,27 @@ class Client { } } +// Hide elements with no-crazygames class if on CrazyGames +const hideCrazyGamesElements = () => { + if (crazyGamesSDK.isOnCrazyGames()) { + document.querySelectorAll(".no-crazygames").forEach((el) => { + (el as HTMLElement).style.display = "none"; + }); + } +}; + // Initialize the client when the DOM is loaded const bootstrap = () => { initLayout(); new Client().initialize(); initNavigation(); + + // Hide elements immediately + hideCrazyGamesElements(); + + // Also hide elements after a short delay to catch late-rendered components + setTimeout(hideCrazyGamesElements, 100); + setTimeout(hideCrazyGamesElements, 500); }; if (document.readyState === "loading") { diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index 73307d15d..0c4b634f0 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -3,7 +3,7 @@ import { customElement, query, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { generateID } from "../core/Util"; -import { getUserMe } from "./Api"; +import { getUserMe, hasLinkedAccount } from "./Api"; import { getPlayToken } from "./Auth"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; @@ -240,6 +240,7 @@ export class MatchmakingModal extends BaseModal { @customElement("matchmaking-button") export class MatchmakingButton extends LitElement { @query("matchmaking-modal") private matchmakingModal?: MatchmakingModal; + @state() private isLoggedIn = false; constructor() { super(); @@ -247,6 +248,14 @@ export class MatchmakingButton extends LitElement { async connectedCallback() { super.connectedCallback(); + // Listen for user authentication changes + document.addEventListener("userMeResponse", (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail) { + const userMeResponse = customEvent.detail as UserMeResponse | false; + this.isLoggedIn = hasLinkedAccount(userMeResponse); + } + }); } createRenderRoot() { @@ -254,19 +263,65 @@ export class MatchmakingButton extends LitElement { } render() { + if (this.isLoggedIn) { + return html` + + + + `; + } + return html` -
      - -
      + + `; } + private handleLoggedInClick() { + const usernameInput = document.querySelector("username-input") as any; + const publicLobby = document.querySelector("public-lobby") as any; + + if (usernameInput?.isValid()) { + this.open(); + publicLobby?.leaveLobby(); + } else { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: usernameInput?.validationError, + color: "red", + duration: 3000, + }, + }), + ); + } + } + + private handleLoggedOutClick() { + window.showPage?.("page-account"); + } + private open() { this.matchmakingModal?.open(); } diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts index 755c7c834..483bef75c 100644 --- a/src/client/PatternInput.ts +++ b/src/client/PatternInput.ts @@ -5,6 +5,7 @@ import { UserSettings } from "../core/game/UserSettings"; import { PlayerPattern } from "../core/Schemas"; import { renderPatternPreview } from "./components/PatternButton"; import { fetchCosmetics } from "./Cosmetics"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { translateText } from "./Utils"; @customElement("pattern-input") @@ -73,6 +74,10 @@ export class PatternInput extends LitElement { } render() { + if (crazyGamesSDK.isOnCrazyGames()) { + return html``; + } + const isDefault = this.pattern === null && this.selectedColor === null; const showSelect = this.showSelectLabel && isDefault; const buttonTitle = translateText("territory_patterns.title"); diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index c34dfe268..440d1948a 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -27,6 +27,7 @@ import "./components/FluentSlider"; import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; @@ -89,6 +90,9 @@ export class SinglePlayerModal extends BaseModal { }; private renderNotLoggedInBanner(): TemplateResult { + if (crazyGamesSDK.isOnCrazyGames()) { + return html``; + } return html`
      @@ -1057,6 +1061,8 @@ export class SinglePlayerModal extends BaseModal { const selectedColor = this.userSettings.getSelectedColor(); + await crazyGamesSDK.requestMidgameAd(); + this.dispatchEvent( new CustomEvent("join-lobby", { detail: { diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 20b2bb372..0434f4674 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -8,6 +8,7 @@ import { MIN_USERNAME_LENGTH, validateUsername, } from "../core/validations/username"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; const usernameKey: string = "username"; @@ -39,8 +40,18 @@ export class UsernameInput extends LitElement { connectedCallback() { super.connectedCallback(); - const stored = this.getStoredUsername(); + const stored = this.getUsername(); this.parseAndSetUsername(stored); + crazyGamesSDK.getUsername().then((username) => { + this.parseAndSetUsername(username ?? genAnonUsername()); + this.requestUpdate(); + }); + crazyGamesSDK.addAuthListener((user) => { + if (user) { + this.parseAndSetUsername(user?.username); + } + this.requestUpdate(); + }); } private parseAndSetUsername(fullUsername: string) { @@ -52,6 +63,8 @@ export class UsernameInput extends LitElement { this.clanTag = ""; this.baseUsername = fullUsername; } + + this.validateAndStore(); } render() { @@ -161,7 +174,7 @@ export class UsernameInput extends LitElement { } } - private getStoredUsername(): string { + private getUsername(): string { const storedUsername = localStorage.getItem(usernameKey); if (storedUsername) { return storedUsername; @@ -176,20 +189,20 @@ export class UsernameInput extends LitElement { } private generateNewUsername(): string { - const newUsername = "Anon" + this.uuidToThreeDigits(); + const newUsername = genAnonUsername(); this.storeUsername(newUsername); return newUsername; } - private uuidToThreeDigits(): string { - const uuid = uuidv4(); - const cleanUuid = uuid.replace(/-/g, "").toLowerCase(); - const decimal = BigInt(`0x${cleanUuid}`); - const threeDigits = decimal % 1000n; - return threeDigits.toString().padStart(3, "0"); - } - public isValid(): boolean { return this._isValid; } } + +export function genAnonUsername(): string { + const uuid = uuidv4(); + const cleanUuid = uuid.replace(/-/g, "").toLowerCase(); + const decimal = BigInt(`0x${cleanUuid}`); + const threeDigits = decimal % 1000n; + return "Anon" + threeDigits.toString().padStart(3, "0"); +} diff --git a/src/client/components/CopyButton.ts b/src/client/components/CopyButton.ts index 13742cc58..5a7b7fbec 100644 --- a/src/client/components/CopyButton.ts +++ b/src/client/components/CopyButton.ts @@ -2,6 +2,7 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { getServerConfigFromClient } from "../../core/configuration/ConfigLoader"; import { UserSettings } from "../../core/game/UserSettings"; +import { crazyGamesSDK } from "../CrazyGamesSDK"; import { copyToClipboard, translateText } from "../Utils"; @customElement("copy-button") @@ -73,15 +74,21 @@ export class CopyButton extends LitElement { return url; } - private async resolveCopyText(): Promise { + private async resolveCopyText(): Promise { if (this.copyText) return this.copyText; + if (crazyGamesSDK.isOnCrazyGames()) { + return crazyGamesSDK.createInviteLink(this.lobbyId); + } if (!this.lobbyId) return ""; return await this.buildCopyUrl(); } private async handleCopy() { const text = await this.resolveCopyText(); - if (!text) return; + if (!text) { + alert("Error copying game id"); + return; + } await copyToClipboard( text, () => (this.copySuccess = true), diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 74ea1b498..b6b67b435 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -105,7 +105,7 @@ export class DesktopNavBar extends LitElement { data-i18n="main.news" > @@ -127,14 +127,14 @@ export class DesktopNavBar extends LitElement { @@ -135,7 +135,7 @@ export class MobileNavBar extends LitElement { data-i18n="main.settings" > diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index 037dc4743..6d6b94b18 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -72,7 +72,7 @@ export class PatternButton extends LitElement { return html`
      - - - - - +
      diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index e86317a7c..2ee9ee276 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -105,10 +105,15 @@ export class GameRightSidebar extends LitElement implements Layer { private onPauseButtonClick() { this.isPaused = !this.isPaused; + if (this.isPaused) { + crazyGamesSDK.gameplayStop(); + } else { + crazyGamesSDK.gameplayStart(); + } this.eventBus.emit(new PauseGameIntentEvent(this.isPaused)); } - private onExitButtonClick() { + private async onExitButtonClick() { const isAlive = this.game.myPlayer()?.isAlive(); if (isAlive) { const isConfirmed = confirm( @@ -116,10 +121,10 @@ export class GameRightSidebar extends LitElement implements Layer { ); if (!isConfirmed) return; } - crazyGamesSDK.gameplayStop().then(() => { - // redirect to the home page - window.location.href = "/"; - }); + await crazyGamesSDK.requestMidgameAd(); + await crazyGamesSDK.gameplayStop(); + // redirect to the home page + window.location.href = "/"; } private onSettingsButtonClick() { diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 92d8f1fe4..0a5b184bf 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -1,9 +1,10 @@ import { html, LitElement } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { crazyGamesSDK } from "src/client/CrazyGamesSDK"; +import { PauseGameIntentEvent } from "src/client/Transport"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler"; -import { PauseGameIntentEvent } from "../../Transport"; import { translateText } from "../../Utils"; import SoundManager from "../../sound/SoundManager"; import { Layer } from "./Layer"; @@ -105,8 +106,14 @@ export class SettingsModal extends LitElement implements Layer { } private pauseGame(pause: boolean) { - if (this.shouldPause && !this.wasPausedWhenOpened) + if (this.shouldPause && !this.wasPausedWhenOpened) { + if (pause) { + crazyGamesSDK.gameplayStop(); + } else { + crazyGamesSDK.gameplayStart(); + } this.eventBus.emit(new PauseGameIntentEvent(pause)); + } } private onTerrainButtonClick() { diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts index 4b9a5d60c..c08bcb183 100644 --- a/src/client/graphics/layers/UnitDisplay.ts +++ b/src/client/graphics/layers/UnitDisplay.ts @@ -130,7 +130,7 @@ export class UnitDisplay extends LitElement implements Layer { return html` `; diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 0b93aa9fc..fd02d3b84 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -114,7 +114,7 @@ export class TransportShipExecution implements Execution { mg.displayIncomingUnit( this.boat.id(), // TODO TranslateText - `Naval invasion incoming from ${this.attacker.displayName()}`, + `Naval invasion incoming from ${this.attacker.displayName()} (${renderTroops(this.boat.troops())})`, MessageType.NAVAL_INVASION_INBOUND, this.target.id(), ); From 936b689769c58421416fc9ab730717fc0552f1ba Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 28 Jan 2026 13:06:43 -0800 Subject: [PATCH 31/45] bugfix: duplicate matchmaking modals causing elo to display unknown --- index.html | 5 ---- src/client/Matchmaking.ts | 58 ++++++++++++++++++--------------------- 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/index.html b/index.html index b1f825ae2..f5861d94e 100644 --- a/index.html +++ b/index.html @@ -189,11 +189,6 @@ inline class="hidden w-full h-full page-content" > - - - ${translateText("matchmaking_button.play_ranked")} - - - ${translateText("matchmaking_button.description")} - - + + ${translateText("matchmaking_button.play_ranked")} + + + ${translateText("matchmaking_button.description")} + + + ` + : html` + + `; - - `; - } - - return html` - - - - `; + return html` ${button} `; } private handleLoggedInClick() { From 6d3d2738893158cc1cf2a017d1662c4a71758b6b Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 28 Jan 2026 23:56:53 +0000 Subject: [PATCH 32/45] Shop new tag (#3057) ## Description: adds a "tag" of NEW to the shop which pulses image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- resources/lang/en.json | 1 + src/client/components/DesktopNavBar.ts | 16 +++++++++++----- src/client/components/MobileNavBar.ts | 16 +++++++++++----- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 56c8941cd..80ce52fcd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -46,6 +46,7 @@ "play": "Play", "news": "News", "store": "Store", + "store_new_badge": "NEW", "settings": "Settings", "keys": "Keys", "stats": "Stats", diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 74ea1b498..18f1e439d 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -104,11 +104,17 @@ export class DesktopNavBar extends LitElement { data-page="page-news" data-i18n="main.news" > - +
      + + +
      - +
      + + +
      - +
      + + +
      - +
      + + +
      - +
      + + ${this.showHelpDot() + ? html` + + + ` + : ""} +