Merge branch 'main' into sounds

This commit is contained in:
icslucas
2025-10-10 17:40:57 +02:00
committed by GitHub
43 changed files with 2660 additions and 567 deletions
+8 -4
View File
@@ -13,10 +13,14 @@ jobs:
steps:
- uses: actions/stale@8f717f0dfca33b78d3c933452e42558e4456c8e7
with:
days-before-close: 14
days-before-stale: 14
days-before-pr-close: 14
days-before-pr-stale: 14
days-before-issue-close: -1
days-before-issue-stale: -1
exempt-draft-pr: true
exempt-pr-assignees: evanpelle
stale-pr-label: "stale"
exempt-pr-labels: "will not stale"
stale-pr-label: "Stale"
stale-pr-message: "This pull request is stale because it has been open for 14 days with no activity. If you want to keep this pull request open, add a comment or update the branch."
start-date: 2025-09-03T00:00:00+00:00
close-pr-message: "This pull request has been closed because twenty-eight days have passed without activity. If someone wants to keep working on it, feel free to take the code."
close-pr-label: "Orphaned"
+2 -2
View File
@@ -17,8 +17,8 @@ export default {
coverageThreshold: {
global: {
statements: 21.5,
branches: 16.5,
lines: 22.0,
branches: 16,
lines: 21.0,
functions: 20.5,
},
},
+1011
View File
File diff suppressed because it is too large Load Diff
+31 -7
View File
@@ -6,7 +6,18 @@
"lang_code": "en"
},
"common": {
"close": "Close"
"close": "Close",
"available": "Available",
"preset_max": "Max",
"summary_send": "Send",
"summary_keep": "Keep",
"cancel": "Cancel",
"send": "Send",
"cap_label": "Cap",
"cap_tooltip": "Recipients remaining capacity",
"target_dead": "Target eliminated",
"target_dead_note": "You can't send resources to an eliminated player.",
"none": "None"
},
"main": {
"title": "OpenFront (ALPHA)",
@@ -590,8 +601,6 @@
"troops": "Troops",
"betrayals": "Betrayals",
"traitor": "Traitor",
"stable": "Stable",
"trust": "Trust",
"trading": "Trading",
"active": "Active",
"stopped": "Stopped",
@@ -600,9 +609,6 @@
"nuke": "Nukes sent by them to you",
"start_trade": "Start Trading",
"stop_trade": "Stop Trading",
"yes": "Yes",
"no": "No",
"none": "None",
"alliances": "Alliances",
"flag": "Flag",
"chat": "Chat",
@@ -615,6 +621,23 @@
"send_gold": "Send Gold",
"emotes": "Emojis"
},
"send_troops_modal": {
"title_with_name": "Send Troops to {name}",
"available_tooltip": "Your current available troops",
"min_keep": "Min keep",
"min_keep_pct": "(30%)",
"slider_tooltip": "{{percent}}% • {{amount}}",
"toggle_attack_bar_mode": "Use attack bar to send troops",
"warning_attackbar": "Once enabled, you can't open this modal directly. You'll only send troops via the attack bar.",
"aria_slider": "Troops slider",
"capacity_note": "Receiver can accept only {{amount}} right now."
},
"send_gold_modal": {
"title_with_name": "Send Gold to {name}",
"available_tooltip": "Your current available gold",
"aria_slider": "Amount slider",
"slider_tooltip": "{{percent}}% • {{amount}}"
},
"replay_panel": {
"replay_speed": "Replay speed",
"game_speed": "Game speed",
@@ -633,7 +656,8 @@
"choose_spawn": "Choose a starting location"
},
"territory_patterns": {
"title": "Select Territory Skin",
"title": "Skins",
"colors": "Colors",
"purchase": "Purchase",
"blocked": {
"login": "You must be logged in to access this pattern.",
+71 -7
View File
@@ -130,9 +130,8 @@
"disable_nations": "Malŝalti naciojn",
"instant_build": "Tujkonstruaĵo",
"infinite_gold": "Senfina oro",
"donate_gold": "Donaci oron",
"infinite_troops": "Senfinaj trupoj",
"donate_troops": "Donaci trupojn",
"compact_map": "Karteto",
"disable_nukes": "Malŝalti nukleajn armilojn",
"enables_title": "Ebligi Agordojn",
"start": "Komenci la ludon"
@@ -177,8 +176,10 @@
"halkidiki": "Ĥalkidiko",
"straitofgibraltar": "Ĝibraltara Markolo",
"italia": "Italio",
"japan": "Japanio",
"yenisei": "Jenisejo",
"pluto": "Plutono"
"pluto": "Plutono",
"montreal": "Montrealo"
},
"map_categories": {
"continental": "Kontinenta",
@@ -196,8 +197,9 @@
"join_lobby": "Aliĝi al la ludejo",
"checking": "Kontrolado de la ludejo...",
"not_found": "Ludejo ne trovita. Bonvolu kontroli la ID kaj reprovi.",
"error": "Eraro okazis. Bonvolu provi denove.",
"joined_waiting": "Sukcese aliĝis! Atendante la komencon de la ludo..."
"error": "Eraro okazis. Bonvolu reprovi aŭ kontaktu helpon.",
"joined_waiting": "Sukcese aliĝis! Atendante la komencon de la ludo...",
"version_mismatch": "Ĉi tiu ludo estis kreita kun malsama versio. Ne eblas aliĝi."
},
"public_lobby": {
"join": "Kunigi la baldaŭan ludon",
@@ -227,6 +229,7 @@
"donate_gold": "Donacu oron",
"infinite_troops": "Senfinaj trupoj",
"donate_troops": "Donacu trupojn",
"compact_map": "Karteto",
"enables_title": "Ebligi Agordojn",
"player": "Ludanto",
"players": "Ludantoj",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Elektu ĉu montri teritoriajn ŝablonajn dezajnojn en la ludo",
"performance_overlay_label": "Efikeco Supermetaĵo",
"performance_overlay_desc": "Ŝalti la efikecon supermetaĵon. Kiam ebligita, la efikeco supermetaĵo estos montrata. Premu la Majuskligan klavon\n+ D dum la ludo por ŝalti.",
"performance_overlay_enabled": "Funkciada supermetaĵo ŝaltita",
"performance_overlay_disabled": "Funkciada supermetaĵo malŝaltita",
"easter_writing_speed_label": "Rapidskriba multiganto",
"easter_writing_speed_desc": "Alĝustigu kiom rapide vi ŝajnigas kodi (x1x100)",
"easter_bug_count_label": "Nombro da cimoj",
@@ -494,7 +499,8 @@
"nation": "Nacio",
"player": "Ludanto",
"team": "Teamo",
"d_troops": "Defendante trupoj",
"alliance_timeout": "Alianco finiĝas en",
"troops": "Trupoj",
"a_troops": "Atakante trupoj",
"gold": "Oro",
"ports": "Havenoj",
@@ -585,7 +591,7 @@
"choose_spawn": "Elektu komencan lokon"
},
"territory_patterns": {
"title": "Elekti Teritoriajn Ŝablonojn",
"title": "Elekti Teritoria Ŝablono",
"purchase": "Aĉeti",
"blocked": {
"login": "Vi devas esti ensalutinta por aliri ĉi tiun ŝablonon.",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Forigi trupunon",
"delete_unit_description": "Alklaku por forigi la plej proksiman trupunon"
},
"discord_user_header": {
"avatar_alt": "Profilbildo"
},
"player_stats_table": {
"building_stats": "Konstruaĵaj statistikoj",
"ship_arrivals": "Ŝipoj alvenoj",
"nuke_stats": "Bomboj statistikoj",
"player_metrics": "Ludanta statistikoj",
"building": "Konstruaĵo",
"ship_type": "Ŝipa tipo",
"weapon": "Armilo",
"built": "Konstruita",
"destroyed": "Detruita",
"captured": "Ekkaptita",
"lost": "Perdita",
"hits": "Frapoj",
"launched": "Lanĉita",
"landed": "Surterigita",
"sent": "Sendita",
"arrived": "Alveninta",
"attack": "Atako",
"received": "Ricevita",
"cancelled": "Nuligita",
"count": "Nombro",
"gold": "Oro",
"workers": "Laboristoj",
"war": "Milito",
"trade": "Komerci",
"steal": "Ŝteli",
"unit": {
"city": "Urbo",
"port": "Haveno",
"defp": "Defenda Posteno",
"saml": "SAM-lanĉilo",
"silo": "Misila Silo",
"wshp": "Militŝipo",
"fact": "Fabriko",
"trade": "Komerca ŝipo",
"trans": "Transporta ŝipo",
"abomb": "Atombombo",
"hbomb": "Hidrogenbombo",
"mirv": "MIRV",
"mirvw": "MIRV-kapo"
}
},
"game_list": {
"recent_games": "Lastaj ludoj",
"game_id": "Ludo ID",
"mode": "Reĝimo",
"mode_ffa": "Ĉiu por si",
"mode_team": "Teamo",
"view": "Vido",
"details": "Detaloj",
"started": "Komencita",
"map": "Karto",
"difficulty": "Malfacileco",
"type": "Tipo"
}
}
+71 -7
View File
@@ -130,9 +130,8 @@
"disable_nations": "Désactiver les nations",
"instant_build": "Construction instantanée",
"infinite_gold": "Or infini",
"donate_gold": "Donner de l'or",
"infinite_troops": "Troupes infinies",
"donate_troops": "Donner des troupes",
"compact_map": "Mini-carte",
"disable_nukes": "Désactiver les armes nucléaires",
"enables_title": "Activer les paramètres",
"start": "Commencer la partie"
@@ -177,8 +176,10 @@
"halkidiki": "Chalcidique",
"straitofgibraltar": "Détroit de Gibraltar",
"italia": "Italie",
"japan": "Japon",
"yenisei": "Ienisseï",
"pluto": "Pluton"
"pluto": "Pluton",
"montreal": "Montréal"
},
"map_categories": {
"continental": "Continental",
@@ -196,8 +197,9 @@
"join_lobby": "Rejoindre le salon",
"checking": "Vérification du salon...",
"not_found": "Salon introuvable. Veuillez vérifier l'ID et réessayer.",
"error": "Une erreur est survenue. Veuillez réessayer.",
"joined_waiting": "Rejoint avec succès ! En attente du début de la partie..."
"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."
},
"public_lobby": {
"join": "Rejoindre la prochaine partie",
@@ -227,6 +229,7 @@
"donate_gold": "Donner de l'or",
"infinite_troops": "Troupes infinies",
"donate_troops": "Donner des troupes",
"compact_map": "Mini-carte",
"enables_title": "Activer les paramètres",
"player": "Joueur",
"players": "Joueurs",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Choisissez si vous voulez afficher les motifs de territoire en jeu",
"performance_overlay_label": "Surcouche de performances",
"performance_overlay_desc": "Activer/désactiver la surcouche de performances. Lorsqu'elle est activée, la surcouche de performances sera affichée. Appuyez sur Maj+D pendant le jeu pour l\"activer.",
"performance_overlay_enabled": "Affichage des performances activé",
"performance_overlay_disabled": "Affichage des performances désactivé",
"easter_writing_speed_label": "Multiplicateur de vitesse d'écriture",
"easter_writing_speed_desc": "Ajuster la vitesse à laquelle vous prétendez coder (x1x100)",
"easter_bug_count_label": "Nombre de bugs",
@@ -494,7 +499,8 @@
"nation": "Nation",
"player": "Joueur",
"team": "Équipe",
"d_troops": "Troupes en défense",
"alliance_timeout": "L'alliance se termine dans",
"troops": "Troupes",
"a_troops": "Troupes en attaque",
"gold": "Or",
"ports": "Ports",
@@ -585,7 +591,7 @@
"choose_spawn": "Choisissez un emplacement de départ"
},
"territory_patterns": {
"title": "Sélectionnez un motif de territoire",
"title": "Sélectionner un motif de territoire",
"purchase": "Acheter",
"blocked": {
"login": "Vous devez être connecté pour accéder à ce motif.",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Supprimer une unité",
"delete_unit_description": "Cliquez pour supprimer l'unité la plus proche"
},
"discord_user_header": {
"avatar_alt": "Avatar"
},
"player_stats_table": {
"building_stats": "Statistiques de construction",
"ship_arrivals": "Arrivées de navire",
"nuke_stats": "Statistiques de bombes",
"player_metrics": "Statistiques du joueur",
"building": "Batîment",
"ship_type": "Type de navire",
"weapon": "Arme",
"built": "Construit",
"destroyed": "Détruit",
"captured": "Capturé",
"lost": "Perdu",
"hits": "Coups",
"launched": "Lancés",
"landed": "Atterris",
"sent": "Envoyés",
"arrived": "Arrivés",
"attack": "Attaque",
"received": "Reçu",
"cancelled": "Annulé",
"count": "Compte",
"gold": "Or",
"workers": "Ouvriers",
"war": "Guerre",
"trade": "Commercer",
"steal": "Voler",
"unit": {
"city": "Ville",
"port": "Port",
"defp": "Poste de défense",
"saml": "Lanceur de SAM",
"silo": "Silo à missiles",
"wshp": "Navire de guerre",
"fact": "Usine",
"trade": "Bateau de commerce",
"trans": "Bateau de transport",
"abomb": "Bombe atomique",
"hbomb": "Bombe à hydrogène",
"mirv": "MIRV",
"mirvw": "Ogive de MIRV"
}
},
"game_list": {
"recent_games": "Parties récentes",
"game_id": "ID de la partie",
"mode": "Mode",
"mode_ffa": "Chacun pour soi",
"mode_team": "Équipe",
"view": "Vue",
"details": "Détails",
"started": "Débuté",
"map": "Carte",
"difficulty": "Difficulté",
"type": "Type"
}
}
+72 -8
View File
@@ -130,9 +130,8 @@
"disable_nations": "Letiltott nemzetek",
"instant_build": "Instant építés",
"infinite_gold": "Végtelen arany",
"donate_gold": "Arany adományomzása",
"infinite_troops": "Végtelen katonák",
"donate_troops": "Katonák adományozása",
"compact_map": "Mini térkép",
"disable_nukes": "Nukleáris fegyverek letiltása",
"enables_title": "Beállítások engedélyezése",
"start": "Játék indítása"
@@ -177,8 +176,10 @@
"halkidiki": "Halkidiki",
"straitofgibraltar": "Gibraltári-szoros",
"italia": "Olaszország",
"japan": "Japán",
"yenisei": "Jenisej",
"pluto": "Plútó"
"pluto": "Plútó",
"montreal": "Montreal"
},
"map_categories": {
"continental": "Kontinentális",
@@ -196,8 +197,9 @@
"join_lobby": "Csatlakozás lobbyhoz",
"checking": "Lobby ellenőrzése...",
"not_found": "Lobby nem található. Kérlek, ellenőrizd az azonosítót, és próbáld újra.",
"error": "Hiba történt. Kérlek, próbáld újra.",
"joined_waiting": "Sikeresen csatlakoztál! Várakozás a játék kezdésére..."
"error": "Hiba történt. \nKérjük próbáld újra, vagy lépj kapcsolatba velünk.",
"joined_waiting": "Sikeresen csatlakoztál! Várakozás a játék kezdésére...",
"version_mismatch": "Ez a játék egy másik verzióval lett létrehozva. Nem lehet csatlakozni."
},
"public_lobby": {
"join": "Csatlakozás a következő játékhoz",
@@ -227,6 +229,7 @@
"donate_gold": "Arany adományozása",
"infinite_troops": "Végtelen katonák",
"donate_troops": "Katonák adományozása",
"compact_map": "Mini térkép",
"enables_title": "Beállítások engedélyezése",
"player": "Játékos",
"players": "Játékosok",
@@ -264,10 +267,10 @@
},
"unit_type": {
"city": "Város",
"defense_post": "Védelmi poszt",
"defense_post": "Védelmi állás",
"port": "Kikötő",
"warship": "Hadihajó",
"missile_silo": "Rakéta siló",
"missile_silo": "Rakétasiló",
"sam_launcher": "Rakétaelhárító rendszer",
"atom_bomb": "Atombomba",
"hydrogen_bomb": "Hidrogénbomba",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Válaszd ki, hogy megjelenjenek-e a területmintázatok a játékban",
"performance_overlay_label": "Teljesítmény kijelző",
"performance_overlay_desc": "Kapcsoló a teljesítmény kijelzőhöz. Ha engedélyezve van, a teljesítmény kijelző megjelenik. A játék közben nyomd meg a Shift-D-t a váltáshoz.",
"performance_overlay_enabled": "Teljesítmény kijelző engedélyezve",
"performance_overlay_disabled": "Teljesítmény kijelző letiltva",
"easter_writing_speed_label": "Írási sebesség szorzó",
"easter_writing_speed_desc": "Állítsd be, milyen gyorsan szimulálod a kódolást (x1x100)",
"easter_bug_count_label": "Hiba számláló",
@@ -494,7 +499,8 @@
"nation": "Nemzet",
"player": "Játékos",
"team": "Csapat",
"d_troops": "Védekező katonák",
"alliance_timeout": "Szövetség felbomlik",
"troops": "Katonák",
"a_troops": "Támadó katonák",
"gold": "Arany",
"ports": "Kikötők",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Egység törlése",
"delete_unit_description": "Kattints a legközelebbi egység törléséhez"
},
"discord_user_header": {
"avatar_alt": "Profilkép"
},
"player_stats_table": {
"building_stats": "Épületstatisztikák",
"ship_arrivals": "Hajóérkezések",
"nuke_stats": "Nukleáris statisztikák",
"player_metrics": "Játékos mutatók",
"building": "Épület",
"ship_type": "Hajó típus",
"weapon": "Fegyver",
"built": "Épített",
"destroyed": "Megsemmisült",
"captured": "Elfoglalva",
"lost": "Elveszett",
"hits": "Találatok",
"launched": "Elindított",
"landed": "Célba ért",
"sent": "Elküldött",
"arrived": "Megérkezett",
"attack": "Támadás",
"received": "Fogadott",
"cancelled": "Megszakítva",
"count": "Mennyiség",
"gold": "Arany",
"workers": "Munkások",
"war": "Háború",
"trade": "Kereskedelem",
"steal": "Lopás",
"unit": {
"city": "Város",
"port": "Kikötő",
"defp": "Védelmi állás",
"saml": "Rakétaelhárító rendszer",
"silo": "Rakétasiló",
"wshp": "Hadihajó",
"fact": "Gyár",
"trade": "Kereskedelmi hajó",
"trans": "Szállítóhajó",
"abomb": "Atombomba",
"hbomb": "Hidrogénbomba",
"mirv": "MIRV",
"mirvw": "MIRV robbanófej"
}
},
"game_list": {
"recent_games": "Legutóbbi Játékok",
"game_id": "Játék azonosító",
"mode": "Mód",
"mode_ffa": "Mindenki mindenki ellen",
"mode_team": "Csapat",
"view": "Nézet",
"details": "Részletek",
"started": "Elkezdődött",
"map": "Térkép",
"difficulty": "Nehézség",
"type": "Típus"
}
}
+71 -7
View File
@@ -130,9 +130,8 @@
"disable_nations": "国家を無効化",
"instant_build": "即時建設",
"infinite_gold": "資金無限",
"donate_gold": "資金援助",
"infinite_troops": "兵士無限",
"donate_troops": "軍事支援",
"compact_map": "ミニマップ",
"disable_nukes": "核兵器使用禁止",
"enables_title": "機能の有効化",
"start": "ゲーム開始"
@@ -177,8 +176,10 @@
"halkidiki": "ハルキディキ半島",
"straitofgibraltar": "ジブラルタル海峡",
"italia": "イタリア",
"japan": "日本",
"yenisei": "エニセイ川",
"pluto": "冥王星"
"pluto": "冥王星",
"montreal": "モントリオール"
},
"map_categories": {
"continental": "大陸",
@@ -196,8 +197,9 @@
"join_lobby": "ロビーに参加",
"checking": "ロビーを確認中...",
"not_found": "ロビーが見つかりません。IDを確認してもう一度お試しください。",
"error": "エラーが発生しました。もう一度お試しください。",
"joined_waiting": "参加に成功しました!ゲーム開始をお待ちください..."
"error": "エラーが発生しました。もう一度試すか、サポートにお問い合わせください。",
"joined_waiting": "参加に成功しました!ゲーム開始をお待ちください...",
"version_mismatch": "このゲームは別のバージョンで作成されました。参加できません。"
},
"public_lobby": {
"join": "次のゲームに参加",
@@ -227,6 +229,7 @@
"donate_gold": "資金援助",
"infinite_troops": "兵士無限",
"donate_troops": "軍事支援",
"compact_map": "ミニマップ",
"enables_title": "機能設定",
"player": "プレイヤー",
"players": "プレイヤー",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "ゲーム内で領土の模様を表示するかどうか",
"performance_overlay_label": "パフォーマンスオーバーレイ",
"performance_overlay_desc": "パフォーマンス表示(オーバーレイ)を切り替えます。有効にすると画面上にパフォーマンスオーバーレイが表示されます。ゲーム中は Shift+D を押して切り替えられます。",
"performance_overlay_enabled": "パフォーマンスオーバーレイ有効",
"performance_overlay_disabled": "パフォーマンスオーバーレイ無効",
"easter_writing_speed_label": "書き込み速度の倍率",
"easter_writing_speed_desc": "コードを書く速さを調節する(x1-x100)",
"easter_bug_count_label": "バグの個数",
@@ -494,7 +499,8 @@
"nation": "国家",
"player": "プレイヤー",
"team": "チーム",
"d_troops": "防衛兵士数",
"alliance_timeout": "同盟終了まで",
"troops": "軍隊",
"a_troops": "攻撃兵士数",
"gold": "資金",
"ports": "港",
@@ -585,7 +591,7 @@
"choose_spawn": "スタート地点を選んで下さい"
},
"territory_patterns": {
"title": "領土の模様を選択",
"title": "領土スキンを選択",
"purchase": "購入",
"blocked": {
"login": "このページにアクセスするにはログインしてください。",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "ユニットを削除する",
"delete_unit_description": "クリックで最も近いユニットを削除します"
},
"discord_user_header": {
"avatar_alt": "アバター"
},
"player_stats_table": {
"building_stats": "建築物統計情報",
"ship_arrivals": "船舶到着数",
"nuke_stats": "核兵器統計情報",
"player_metrics": "プレイヤー統計情報",
"building": "建築物",
"ship_type": "船舶の種類",
"weapon": "兵器",
"built": "建設",
"destroyed": "破壊",
"captured": "鹵獲",
"lost": "損失",
"hits": "着弾",
"launched": "発射",
"landed": "着陸",
"sent": "送信",
"arrived": "到着",
"attack": "攻撃",
"received": "受信",
"cancelled": "取消",
"count": "数",
"gold": "資金",
"workers": "労働者",
"war": "戦争",
"trade": "取引",
"steal": "略奪",
"unit": {
"city": "都市",
"port": "港",
"defp": "防衛ポスト",
"saml": "SAMランチャー",
"silo": "ミサイル格納庫",
"wshp": "戦艦",
"fact": "工場",
"trade": "交易船",
"trans": "輸送船",
"abomb": "原子爆弾",
"hbomb": "水素爆弾",
"mirv": "MIRV",
"mirvw": "MIRV 弾頭"
}
},
"game_list": {
"recent_games": "最近のゲーム",
"game_id": "ゲームID",
"mode": "モード",
"mode_ffa": "バトルロワイヤル",
"mode_team": "チーム",
"view": "見る",
"details": "詳細",
"started": "既に開始",
"map": "地図",
"difficulty": "難易度",
"type": "タイプ"
}
}
+70 -6
View File
@@ -130,9 +130,8 @@
"disable_nations": "Отключить нации",
"instant_build": "Мгновенная стройка",
"infinite_gold": "Неограниченное золото",
"donate_gold": "Пожертвовать золото",
"infinite_troops": "Неограниченные войска",
"donate_troops": "Пожертвовать войска",
"compact_map": "Мини-карта",
"disable_nukes": "Отключить бомбы",
"enables_title": "Разрешения",
"start": "Начать игру"
@@ -177,8 +176,10 @@
"halkidiki": "Халкидики",
"straitofgibraltar": "Гибралтарский пролив",
"italia": "Италия",
"japan": "Япония",
"yenisei": "Енисей",
"pluto": "Плутон"
"pluto": "Плутон",
"montreal": "Монреаль"
},
"map_categories": {
"continental": "Континентальные",
@@ -196,8 +197,9 @@
"join_lobby": "Присоединиться к лобби",
"checking": "Проверка лобби...",
"not_found": "Лобби не найдено. Пожалуйста, проверьте правильность ID и попробуйте ещё раз.",
"error": "Произошла ошибка. Пожалуйста, попробуйте ещё раз.",
"joined_waiting": "Вы успешно присоединились! Ожидание начала игры..."
"error": "Произошла ошибка. Пожалуйста, попробуйте ещё раз или обратитесь в службу поддержки.",
"joined_waiting": "Вы успешно присоединились! Ожидание начала игры...",
"version_mismatch": "Эта игра была создана в другой версии. Невозможно присоединиться."
},
"public_lobby": {
"join": "Присоединиться к следующей игре",
@@ -227,6 +229,7 @@
"donate_gold": "Пожертвование золота",
"infinite_troops": "Неограниченные войска",
"donate_troops": "Пожертвование войск",
"compact_map": "Мини-карта",
"enables_title": "Разрешения",
"player": "Игрок",
"players": "Игрока(-ов)",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Выберите, показывать ли узоры территорий в игре",
"performance_overlay_label": "Оверлей производительности",
"performance_overlay_desc": "Включить/выключить оверлей производительности. Если включено, будет отображаться оверлей производительности. Нажмите Shift+D во время игры для включения/выключения.",
"performance_overlay_enabled": "Оверлей производительности включён",
"performance_overlay_disabled": "Оверлей производительности выключен",
"easter_writing_speed_label": "Множитель скорости печати",
"easter_writing_speed_desc": "Настройте скорость, с которой вы делаете вид, что программируете (x1–x100)",
"easter_bug_count_label": "Количество багов",
@@ -494,7 +499,8 @@
"nation": "Нация",
"player": "Игрок",
"team": "Команда",
"d_troops": "Войска защиты",
"alliance_timeout": "Конец союза через",
"troops": "Войска",
"a_troops": "Войска атаки",
"gold": "Золото",
"ports": "Порты",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Удалить объект",
"delete_unit_description": "Нажмите, чтобы удалить ближайший объект"
},
"discord_user_header": {
"avatar_alt": "Аватар"
},
"player_stats_table": {
"building_stats": "Статистика строительства",
"ship_arrivals": "Прибытия кораблей",
"nuke_stats": "Статистика бомбардирования",
"player_metrics": "Статистика игрока",
"building": "Строительство",
"ship_type": "Тип корабля",
"weapon": "Оружие",
"built": "Построено",
"destroyed": "Уничтожено",
"captured": "Захвачено",
"lost": "Утеряно",
"hits": "Удары",
"launched": "Запущено",
"landed": "Приземлено",
"sent": "Отправлено",
"arrived": "Прибыло",
"attack": "Атака",
"received": "Получено",
"cancelled": "Отменено",
"count": "Количество",
"gold": "Золото",
"workers": "Рабочие",
"war": "Войны",
"trade": "Обмен",
"steal": "Украдено",
"unit": {
"city": "Город",
"port": "Порт",
"defp": "Укрепление",
"saml": "Пусковая установка ЗРК",
"silo": "Ракетная шахта",
"wshp": "Военный корабль",
"fact": "Фабрика",
"trade": "Торговый корабль",
"trans": "Транспортный корабль",
"abomb": "Атомная бомба",
"hbomb": "Водородная бомба",
"mirv": "РГЧ ИН",
"mirvw": "Боеголовка РГЧ ИН"
}
},
"game_list": {
"recent_games": "Недавние игры",
"game_id": "ID игры",
"mode": "Режим",
"mode_ffa": "Каждый против каждого",
"mode_team": "Команда",
"view": "Осмотреть",
"details": "Подробности",
"started": "Начато",
"map": "Карта",
"difficulty": "Сложность",
"type": "Тип"
}
}
+78 -14
View File
@@ -130,9 +130,8 @@
"disable_nations": "Вимкнути нації",
"instant_build": "Миттєве будівництво",
"infinite_gold": "Необмежене золото",
"donate_gold": "Пожертвувати золото",
"infinite_troops": "Необмежені війська",
"donate_troops": "Пожертвувати війська",
"compact_map": "Мінімапа",
"disable_nukes": "Вимкнути бомби",
"enables_title": "Дозволи",
"start": "Розпочати гру"
@@ -177,8 +176,10 @@
"halkidiki": "Халкідіки",
"straitofgibraltar": "Гібралтарська протока",
"italia": "Італія",
"japan": "Японія",
"yenisei": "Єнісей",
"pluto": "Плутон"
"pluto": "Плутон",
"montreal": "Монреаль"
},
"map_categories": {
"continental": "Континентальні",
@@ -196,8 +197,9 @@
"join_lobby": "Приєднатися до лобі",
"checking": "Перевірка лобі...",
"not_found": "Лобі не знайдено. Будь ласка, перевірте дійсність ID і спробуйте знову.",
"error": "Сталася помилка. Будь ласка, спробуйте знову.",
"joined_waiting": "Ви успішно приєдналися! Очікування початку гри..."
"error": "Сталася помилка. Спробуйте ще раз або зверніться до служби підтримки.",
"joined_waiting": "Ви успішно приєдналися! Очікування початку гри...",
"version_mismatch": "Цю гру створено в іншій версії. Неможливо приєднатися."
},
"public_lobby": {
"join": "Приєднатися до наступної гри",
@@ -211,7 +213,7 @@
"enter_username": "Введіть своє ім'я гравця",
"not_string": "Ім'я гравця має бути рядком.",
"too_short": "Ім'я гравця повинно містити щонайменше {min} символів.",
"too_long": "Ім'я гравця не повинно перевищувати {max} символів.",
"too_long": "Довжина ім'я гравця не повинна перевищувати {max} символів.",
"invalid_chars": "Ім'я гравця може містити лише латинські літери, цифри, пробіли, знаки підкреслення та [квадратні дужки]."
},
"host_modal": {
@@ -227,6 +229,7 @@
"donate_gold": "Пожертвування золота",
"infinite_troops": "Безмежні війська",
"donate_troops": "Пожертвування військ",
"compact_map": "Мінімапа",
"enables_title": "Дозволи",
"player": "Гравець",
"players": "Гравці(в)",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Виберіть, чи показувати візерунки територій у грі",
"performance_overlay_label": "Оверлей продуктивности",
"performance_overlay_desc": "Увімкнення/вимкнення оверлея продуктивности. Якщо ввімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб увімкнути/вимкнути його.",
"performance_overlay_enabled": "Оверлей продуктивності увімкнено",
"performance_overlay_disabled": "Оверлей продуктивності вимкнено",
"easter_writing_speed_label": "Множник швидкості друку",
"easter_writing_speed_desc": "Налаштуйте швидкість, з якою ви удаєте, що програмуєте (x1–x100)",
"easter_bug_count_label": "Кількість багів",
@@ -494,7 +499,8 @@
"nation": "Нація",
"player": "Гравець",
"team": "Команда",
"d_troops": "Оборонні війська",
"alliance_timeout": "Кінець союзу через",
"troops": "Війська",
"a_troops": "Наступальні війська",
"gold": "Золото",
"ports": "Порти",
@@ -511,14 +517,14 @@
"retreating": "відступає",
"boat": "Човен",
"alliance_request_status": "{name} {status} запрошення до союзу",
"alliance_accepted": "прийняв",
"alliance_rejected": "відхилив",
"alliance_accepted": "приймає",
"alliance_rejected": "відхиляє",
"duration_second": "1 сек",
"betrayal_description": "Ви розірвали союз із {name}, ставши ЗРАДНИКОМ (оборону знижено на {malusPercent}% протягом {durationText})",
"duration_seconds_plural": "{seconds} сек",
"betrayed_you": "{name} розірвав союз із вами",
"about_to_expire": "Ваш союз із {name} скоро закінчиться!",
"alliance_expired": "Ваш союз із {name} закінчився",
"betrayed_you": "{name} розриває союз із вами",
"about_to_expire": "Союз із {name} скоро закінчиться!",
"alliance_expired": "Союз із {name} закінчився",
"attack_request": "{name} просить вас атакувати {target}",
"sent_emoji": "Надіслано {name}: {emoji}",
"renew_alliance": "Запит на поновлення",
@@ -526,7 +532,7 @@
"focus": "Оглянути",
"accept_alliance": "Прийняти",
"reject_alliance": "Відхилити",
"alliance_renewed": "Ваш союз із {name} було поновлено",
"alliance_renewed": "Союз із {name} було поновлено",
"ignore": "Ігнорувати",
"unit_voluntarily_deleted": "Об'єкт добровільно видалено"
},
@@ -585,7 +591,7 @@
"choose_spawn": "Виберіть початкове розташування"
},
"territory_patterns": {
"title": "Виберіть візерунок території",
"title": "Вибір візерунка території",
"purchase": "Придбати",
"blocked": {
"login": "Ви повинні ввійти, щоб отримати доступ до цього візерунку.",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Видалити об'єкт",
"delete_unit_description": "Клацніть, щоб видалити найближчий об'єкт"
},
"discord_user_header": {
"avatar_alt": "Аватар"
},
"player_stats_table": {
"building_stats": "Статистика будівництва",
"ship_arrivals": "Прибуття кораблів",
"nuke_stats": "Статистика бомбардувань",
"player_metrics": "Статистика гравця",
"building": "Будівництво",
"ship_type": "Тип корабля",
"weapon": "Зброя",
"built": "Побудовано",
"destroyed": "Знищено",
"captured": "Захоплено",
"lost": "Втрачено",
"hits": "Удари",
"launched": "Запущено",
"landed": "Приземлено",
"sent": "Відправлено",
"arrived": "Прибуло",
"attack": "Атаки",
"received": "Отримано",
"cancelled": "Скасовано",
"count": "Кількість",
"gold": "Золото",
"workers": "Робітники",
"war": "Війни",
"trade": "Обмін",
"steal": "Украдено",
"unit": {
"city": "Місто",
"port": "Порт",
"defp": "Пункт оборони",
"saml": "Пускова установка ЗРК",
"silo": "Ракетна шахта",
"wshp": "Військовий корабель",
"fact": "Фабрика",
"trade": "Торговий корабель",
"trans": "Транспортний корабель",
"abomb": "Атомна бомба",
"hbomb": "Воднева бомба",
"mirv": "РГЧ ІН",
"mirvw": "Боєголовка РГЧ ІН"
}
},
"game_list": {
"recent_games": "Нещодавні ігри",
"game_id": "ID гри",
"mode": "Режим",
"mode_ffa": "Всі проти всіх",
"mode_team": "Команда",
"view": "Оглянути",
"details": "Подробиці",
"started": "Почато",
"map": "Мапа",
"difficulty": "Складність",
"type": "Тип"
}
}
+5 -1
View File
@@ -4,7 +4,7 @@ import { UserMeResponse } from "../core/ApiSchemas";
import "./components/Difficulties";
import "./components/PatternButton";
import { discordLogin, getApiBase, getUserMe, logOut } from "./jwt";
import { translateText } from "./Utils";
import { isInIframe, translateText } from "./Utils";
@customElement("account-modal")
export class AccountModal extends LitElement {
@@ -268,6 +268,10 @@ export class AccountButton extends LitElement {
}
render() {
if (isInIframe()) {
return html``;
}
if (!this.isVisible) {
return html``;
}
+2 -3
View File
@@ -5,7 +5,7 @@ import {
GameID,
GameRecord,
GameStartInfo,
PlayerPattern,
PlayerCosmeticRefs,
PlayerRecord,
ServerMessage,
} from "../core/Schemas";
@@ -52,8 +52,7 @@ import { SoundManager } from "./sound/SoundManager";
export interface LobbyConfig {
serverConfig: ServerConfig;
pattern: PlayerPattern | undefined;
flag: string;
cosmetics: PlayerCosmeticRefs;
playerName: string;
clientID: ClientID;
gameID: GameID;
+9 -3
View File
@@ -188,9 +188,10 @@ export class InputHandler {
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildAtomBomb: "Digit7",
buildHydrogenBomb: "Digit8",
buildWarship: "Digit9",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
...saved,
};
@@ -409,6 +410,11 @@ export class InputHandler {
this.uiState.ghostStructure = UnitType.Warship;
}
if (e.code === this.keybinds.buildMIRV) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.MIRV;
}
// Shift-D to toggle performance overlay
console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey);
if (e.code === "KeyD" && e.shiftKey) {
+18 -7
View File
@@ -34,6 +34,7 @@ import { UsernameInput } from "./UsernameInput";
import {
generateCryptoRandomUUID,
incrementGamesPlayed,
isInIframe,
translateText,
} from "./Utils";
import "./components/NewsButton";
@@ -207,6 +208,10 @@ class Client {
const patternButton = document.getElementById(
"territory-patterns-input-preview-button",
);
if (isInIframe() && patternButton) {
patternButton.style.display = "none";
}
this.patternsModal instanceof TerritoryPatternsModal;
if (patternButton === null)
throw new Error("territory-patterns-input-preview-button");
@@ -504,18 +509,24 @@ class Client {
}
const config = await getServerConfigFromClient();
const pattern = this.userSettings.getSelectedPatternName(
await fetchCosmetics(),
);
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
pattern:
this.userSettings.getSelectedPatternName(await fetchCosmetics()) ??
undefined,
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
cosmetics: {
color: this.userSettings.getSelectedColor() ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
},
playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: getPlayToken(),
clientID: lobby.clientID,
+3
View File
@@ -449,6 +449,8 @@ export class SinglePlayerModal extends LitElement {
? (this.userSettings.getDevOnlyPattern() ?? null)
: null;
const selectedColor = this.userSettings.getSelectedColor();
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
@@ -466,6 +468,7 @@ export class SinglePlayerModal extends LitElement {
? ""
: flagInput.getCurrentFlag(),
pattern: selectedPattern ?? undefined,
color: selectedColor ? { color: selectedColor } : undefined,
},
},
],
+84 -3
View File
@@ -25,6 +25,9 @@ export class TerritoryPatternsModal extends LitElement {
public previewButton: HTMLElement | null = null;
@state() private selectedPattern: PlayerPattern | null;
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "colors" = "patterns";
private cosmetics: Cosmetics | null = null;
@@ -44,6 +47,7 @@ export class TerritoryPatternsModal extends LitElement {
if (userMeResponse === null) {
this.userSettings.setSelectedPatternName(undefined);
this.selectedPattern = null;
this.selectedColor = null;
}
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
@@ -51,6 +55,7 @@ export class TerritoryPatternsModal extends LitElement {
this.cosmetics !== null
? this.userSettings.getSelectedPatternName(this.cosmetics)
: null;
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
this.refresh();
}
@@ -58,6 +63,31 @@ export class TerritoryPatternsModal extends LitElement {
return this;
}
private renderTabNavigation(): TemplateResult {
return html`
<div class="flex border-b border-gray-600 mb-4 justify-center">
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
.activeTab === "patterns"
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
: "text-gray-400 hover:text-white"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("territory_patterns.title")}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
.activeTab === "colors"
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
: "text-gray-400 hover:text-white"}"
@click=${() => (this.activeTab = "colors")}
>
${translateText("territory_patterns.colors")}
</button>
</div>
`;
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
for (const pattern of Object.values(this.cosmetics?.patterns ?? {})) {
@@ -105,14 +135,39 @@ export class TerritoryPatternsModal extends LitElement {
`;
}
private renderColorSwatchGrid(): TemplateResult {
const hexCodes = (this.userMeResponse?.player.flares ?? [])
.filter((flare) => flare.startsWith("color:"))
.map((flare) => "#" + flare.split(":")[1]);
return html`
<div class="flex flex-wrap gap-3 p-2 justify-center items-center">
${hexCodes.map(
(hexCode) => html`
<div
class="w-12 h-12 rounded-lg border-2 border-white/30 cursor-pointer transition-all duration-200 hover:scale-110 hover:shadow-lg"
style="background-color: ${hexCode};"
title="${hexCode}"
@click=${() => this.selectColor(hexCode)}
></div>
`,
)}
</div>
`;
}
render() {
if (!this.isActive) return html``;
return html`
<o-modal
id="territoryPatternsModal"
title="${translateText("territory_patterns.title")}"
title="${this.activeTab === "patterns"
? translateText("territory_patterns.title")
: translateText("territory_patterns.colors")}"
>
${this.renderPatternGrid()}
${this.renderTabNavigation()}
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.renderColorSwatchGrid()}
</o-modal>
`;
}
@@ -130,6 +185,8 @@ export class TerritoryPatternsModal extends LitElement {
}
private selectPattern(pattern: PlayerPattern | null) {
this.selectedColor = null;
this.userSettings.setSelectedColor(undefined);
if (pattern === null) {
this.userSettings.setSelectedPatternName(undefined);
} else {
@@ -145,8 +202,32 @@ export class TerritoryPatternsModal extends LitElement {
this.close();
}
private selectColor(hexCode: string) {
this.selectedPattern = null;
this.userSettings.setSelectedPatternName(undefined);
this.selectedColor = hexCode;
this.userSettings.setSelectedColor(hexCode);
this.refresh();
this.close();
}
private renderColorPreview(
hexCode: string,
width: number,
height: number,
): TemplateResult {
return html`
<div
class="rounded"
style="width: ${width}px; height: ${height}px; background-color: ${hexCode};"
></div>
`;
}
public async refresh() {
const preview = renderPatternPreview(this.selectedPattern ?? null, 48, 48);
const preview = this.selectedColor
? this.renderColorPreview(this.selectedColor, 48, 48)
: renderPatternPreview(this.selectedPattern ?? null, 48, 48);
this.requestUpdate();
// Wait for the DOM to be updated and the o-modal element to be available
+1 -5
View File
@@ -377,11 +377,7 @@ export class Transport {
lastTurn: numTurns,
token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName,
cosmetics: {
flag: this.lobbyConfig.flag,
patternName: this.lobbyConfig.pattern?.name,
patternColorPaletteName: this.lobbyConfig.pattern?.colorPalette?.name,
},
cosmetics: this.lobbyConfig.cosmetics,
} satisfies ClientJoinMessage);
}
+16 -7
View File
@@ -494,11 +494,20 @@ export class UserSettingModal extends LitElement {
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildWarship"
label=${translateText("user_setting.build_warship")}
description=${translateText("user_setting.build_warship_desc")}
defaultKey="Digit7"
.value=${this.keybinds["buildWarship"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildAtomBomb"
label=${translateText("user_setting.build_atom_bomb")}
description=${translateText("user_setting.build_atom_bomb_desc")}
defaultKey="Digit7"
defaultKey="Digit8"
.value=${this.keybinds["buildAtomBomb"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -507,17 +516,17 @@ export class UserSettingModal extends LitElement {
action="buildHydrogenBomb"
label=${translateText("user_setting.build_hydrogen_bomb")}
description=${translateText("user_setting.build_hydrogen_bomb_desc")}
defaultKey="Digit8"
defaultKey="Digit9"
.value=${this.keybinds["buildHydrogenBomb"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildWarship"
label=${translateText("user_setting.build_warship")}
description=${translateText("user_setting.build_warship_desc")}
defaultKey="Digit9"
.value=${this.keybinds["buildWarship"]?.key ?? ""}
action="buildMIRV"
label=${translateText("user_setting.build_MIRV")}
description=${translateText("user_setting.build_MIRV_desc")}
defaultKey="Digit0"
.value=${this.keybinds["buildMIRV"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
+10
View File
@@ -235,3 +235,13 @@ export function incrementGamesPlayed(): void {
console.warn("Failed to increment games played in localStorage:", error);
}
}
export function isInIframe(): boolean {
try {
return window.self !== window.top;
} catch (e) {
// If we can't access window.top due to cross-origin restrictions,
// we're definitely in an iframe
return true;
}
}
+1 -1
View File
@@ -123,7 +123,7 @@ export class FxLayer implements Layer {
const my = this.game.myPlayer();
if (!my) return;
if (unit.owner() !== my) return;
if (!unit.isActive()) return;
if (!unit.isActive() || unit.retreating()) return;
if (this.boatTargetFxByUnitId.has(unit.id())) return;
const t = unit.targetTile();
if (t !== undefined) {
@@ -2,12 +2,12 @@ import { LitElement, TemplateResult, html } from "lit";
import { ref } from "lit-html/directives/ref.js";
import { customElement, property, state } from "lit/decorators.js";
import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
import portIcon from "../../../../resources/images/AnchorIcon.png";
import warshipIcon from "../../../../resources/images/BattleshipIconWhite.svg";
import cityIcon from "../../../../resources/images/CityIconWhite.svg";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import missileSiloIcon from "../../../../resources/images/MissileSiloIconWhite.svg";
import portIcon from "../../../../resources/images/PortIcon.svg";
import samLauncherIcon from "../../../../resources/images/SamLauncherIconWhite.svg";
import { renderPlayerFlag } from "../../../core/CustomFlag";
import { EventBus } from "../../../core/EventBus";
@@ -364,18 +364,18 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
cityIcon,
"player_info_overlay.cities",
)}
${this.displayUnitCount(
player,
UnitType.Port,
portIcon,
"player_info_overlay.ports",
)}
${this.displayUnitCount(
player,
UnitType.Factory,
factoryIcon,
"player_info_overlay.factories",
)}
${this.displayUnitCount(
player,
UnitType.Port,
portIcon,
"player_info_overlay.ports",
)}
${this.displayUnitCount(
player,
UnitType.MissileSilo,
+77 -46
View File
@@ -28,8 +28,6 @@ import { CloseViewEvent, MouseUpEvent } from "../../InputHandler";
import {
SendAllianceRequestIntentEvent,
SendBreakAllianceIntentEvent,
SendDonateGoldIntentEvent,
SendDonateTroopsIntentEvent,
SendEmbargoIntentEvent,
SendEmojiIntentEvent,
SendTargetPlayerIntentEvent,
@@ -44,6 +42,7 @@ import { UIState } from "../UIState";
import { ChatModal } from "./ChatModal";
import { EmojiTable } from "./EmojiTable";
import { Layer } from "./Layer";
import "./SendResourceModal";
@customElement("player-panel")
export class PlayerPanel extends LitElement implements Layer {
@@ -51,21 +50,17 @@ export class PlayerPanel extends LitElement implements Layer {
public eventBus: EventBus;
public emojiTable: EmojiTable;
public uiState: UIState;
private actions: PlayerActions | null = null;
private tile: TileRef | null = null;
private _profileForPlayerId: number | null = null;
@state()
public isVisible: boolean = false;
@state()
private allianceExpiryText: string | null = null;
@state()
private allianceExpirySeconds: number | null = null;
@state()
private otherProfile: PlayerProfile | null = null;
@state() private sendTarget: PlayerView | null = null;
@state() private sendMode: "troops" | "gold" | "none" = "none";
@state() public isVisible: boolean = false;
@state() private allianceExpiryText: string | null = null;
@state() private allianceExpirySeconds: number | null = null;
@state() private otherProfile: PlayerProfile | null = null;
private ctModal: ChatModal;
@@ -138,6 +133,8 @@ export class PlayerPanel extends LitElement implements Layer {
public hide() {
this.isVisible = false;
this.sendMode = "none";
this.sendTarget = null;
this.requestUpdate();
}
@@ -166,19 +163,23 @@ export class PlayerPanel extends LitElement implements Layer {
this.hide();
}
private openSendTroops(target: PlayerView) {
this.sendTarget = target;
this.sendMode = "troops";
}
private openSendGold(target: PlayerView) {
this.sendTarget = target;
this.sendMode = "gold";
}
private handleDonateTroopClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(
new SendDonateTroopsIntentEvent(
other,
myPlayer.troops() * this.uiState.attackRatio,
),
);
this.hide();
this.openSendTroops(other);
}
private handleDonateGoldClick(
@@ -187,10 +188,20 @@ export class PlayerPanel extends LitElement implements Layer {
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendDonateGoldIntentEvent(other, null));
this.hide();
this.openSendGold(other);
}
private closeSend = () => {
this.sendTarget = null;
};
private confirmSend = (
e: CustomEvent<{ amount: number; closePanel?: boolean }>,
) => {
this.closeSend();
if (e.detail?.closePanel) this.hide();
};
private handleEmbargoClick(
e: Event,
myPlayer: PlayerView,
@@ -312,10 +323,11 @@ export class PlayerPanel extends LitElement implements Layer {
}
private getExpiryColorClass(seconds: number | null): string {
if (seconds === null) return "text-white";
if (seconds <= 30) return "text-red-400";
if (seconds <= 60) return "text-yellow-400";
return "text-emerald-400";
if (seconds === null) return "text-white"; // Default color
if (seconds <= 30) return "text-red-400"; // Last 30 seconds: Red
if (seconds <= 60) return "text-yellow-400"; // Last 60 seconds: Yellow
return "text-emerald-400"; // More than 60 seconds: Green
}
private getTraitorRemainingSeconds(player: PlayerView): number | null {
@@ -433,31 +445,27 @@ export class PlayerPanel extends LitElement implements Layer {
return html`
<div class="mb-1 flex justify-between gap-2">
<div
class="inline-flex items-center gap-1.5 rounded-full bg-zinc-800 px-2.5 py-1
text-lg font-semibold text-zinc-100"
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
text-base font-semibold text-zinc-200"
>
<span class="mr-0.5">💰</span>
<span
translate="no"
class="inline-block w-[45px] text-right text-zinc-50"
>
<span translate="no" class="inline-block w-[45px] text-right">
${renderNumber(other.gold() || 0)}
</span>
<span class="opacity-95">${translateText("player_panel.gold")}</span>
<span class="opacity-95 whitespace-nowrap"
>${translateText("player_panel.gold")}</span
>
</div>
<div
class="inline-flex items-center gap-1.5 rounded-full bg-zinc-800 px-2.5 py-1
text-lg font-semibold text-zinc-100"
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
text-base font-semibold text-zinc-200"
>
<span class="mr-0.5">🛡️</span>
<span
translate="no"
class="inline-block w-[45px] text-right text-zinc-50"
>
<span translate="no" class="inline-block w-[45px] text-right">
${renderTroops(other.troops() || 0)}
</span>
<span class="opacity-95"
<span class="opacity-95 whitespace-nowrap"
>${translateText("player_panel.troops")}</span
>
</div>
@@ -554,7 +562,7 @@ export class PlayerPanel extends LitElement implements Layer {
})
: html`
<div class="py-2 text-zinc-300">
${translateText("player_panel.none")}
${translateText("common.none")}
</div>
`}
</div>
@@ -567,7 +575,7 @@ export class PlayerPanel extends LitElement implements Layer {
if (this.allianceExpiryText === null) return html``;
return html`
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
<div class="font-semibold text-zinc-400">
<div class="font-semibold text-zinc-300">
${translateText("player_panel.alliance_time_remaining")}
</div>
<div class="text-right font-semibold">
@@ -713,6 +721,8 @@ export class PlayerPanel extends LitElement implements Layer {
return html``;
}
const other = owner as PlayerView;
const myGoldNum = my.gold();
const myTroopsNum = Number(my.troops());
return html`
<style>
@@ -744,12 +754,11 @@ export class PlayerPanel extends LitElement implements Layer {
<div
class="fixed inset-0 z-[1001] flex items-center justify-center overflow-auto
bg-black/40 backdrop-blur-sm backdrop-brightness-110 pointer-events-auto"
bg-black/15 backdrop-blur-sm backdrop-brightness-110 pointer-events-auto"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
@wheel=${(e: MouseEvent) => e.stopPropagation()}
@click=${() => this.hide()}
>
<!-- Stop clicks inside the panel from closing it -->
<div
class="pointer-events-auto max-h-[90vh] overflow-y-auto min-w-[240px] w-auto px-4 py-2"
@click=${(e: MouseEvent) => e.stopPropagation()}
@@ -763,8 +772,8 @@ export class PlayerPanel extends LitElement implements Layer {
@click=${this.handleClose}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
aria-label=${translateText("player_panel.close") || "Close"}
title=${translateText("player_panel.close") || "Close"}
aria-label=${translateText("common.close") || "Close"}
title=${translateText("common.close") || "Close"}
>
</button>
@@ -775,6 +784,28 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Identity (flag, name, type, traitor, relation) -->
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
${this.sendTarget
? html`
<send-resource-modal
.open=${this.sendMode !== "none"}
.mode=${this.sendMode}
.total=${this.sendMode === "troops"
? myTroopsNum
: myGoldNum}
.uiState=${this.uiState}
.myPlayer=${my}
.target=${this.sendTarget}
.gameView=${this.g}
.eventBus=${this.eventBus}
.format=${this.sendMode === "troops"
? renderTroops
: renderNumber}
@confirm=${this.confirmSend}
@close=${this.closeSend}
></send-resource-modal>
`
: ""}
<ui-divider></ui-divider>
<!-- Resources -->
+8 -7
View File
@@ -96,11 +96,15 @@ export class RailroadLayer implements Layer {
renderLayer(context: CanvasRenderingContext2D) {
this.updateRailColors();
if (this.transformHandler.scale <= 2) {
// When zoomed out, don't show the railroads
// to prevent map clutter.
const scale = this.transformHandler.scale;
if (scale <= 1) {
return;
}
const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1
const alpha = Math.max(0, Math.min(1, rawAlpha));
context.save();
context.globalAlpha = alpha;
context.drawImage(
this.canvas,
-this.game.width() / 2,
@@ -108,14 +112,11 @@ export class RailroadLayer implements Layer {
this.game.width(),
this.game.height(),
);
context.restore();
}
private handleRailroadRendering(railUpdate: RailroadUpdate) {
for (const railRoad of railUpdate.railTiles) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const x = this.game.x(railRoad.tile);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const y = this.game.y(railRoad.tile);
if (railUpdate.isActive) {
this.paintRailroad(railRoad);
} else {
@@ -0,0 +1,588 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { within } from "../../../core/Util";
import {
SendDonateGoldIntentEvent,
SendDonateTroopsIntentEvent,
} from "../../Transport";
import { renderTroops, translateText } from "../../Utils";
import { UIState } from "../UIState";
@customElement("send-resource-modal")
export class SendResourceModal extends LitElement {
@property({ attribute: false }) eventBus: EventBus | null = null;
@property({ type: Boolean }) open: boolean = false;
@property({ type: String }) mode: "troops" | "gold" = "troops";
@property({ type: Object }) total: number | bigint = 0;
@property({ type: Object }) uiState: UIState | null = null; // to seed initial %
@property({ attribute: false }) format: (n: number) => string = renderTroops;
@property({ attribute: false }) myPlayer: PlayerView | null = null;
@property({ attribute: false }) target: PlayerView | null = null;
@property({ attribute: false }) gameView: GameView | null = null;
@property({ type: String }) heading: string | null = null;
@state() private sendAmount: number = 0;
@state() private selectedPercent: number | null = null;
private PRESETS = [10, 25, 50, 75, 100] as const;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
const initPct =
this.uiState && typeof this.uiState.attackRatio === "number"
? Math.round(this.uiState.attackRatio * 100)
: 100;
this.selectedPercent = this.sanitizePercent(initPct);
const basis = this.getPercentBasis();
this.sendAmount = this.clampSend(
Math.floor((basis * this.selectedPercent) / 100),
);
}
updated(changed: Map<string, unknown>) {
if (changed.has("open") && this.open) {
// If either side is dead, just close and do nothing
if (!this.isSenderAlive() || !this.isTargetAlive()) {
this.closeModal();
return;
}
queueMicrotask(() =>
(this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(),
);
}
if (
changed.has("total") ||
changed.has("mode") ||
changed.has("target") ||
changed.has("gameView")
) {
const basis = this.getPercentBasis();
if (this.selectedPercent !== null) {
const pct = this.sanitizePercent(this.selectedPercent);
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
} else {
this.sendAmount = this.clampSend(this.sendAmount);
}
}
}
private closeModal() {
this.dispatchEvent(new CustomEvent("close"));
}
private confirm() {
if (!this.isSenderAlive() || !this.isTargetAlive() || !this.eventBus) {
return;
}
const myPlayer = this.myPlayer;
const target = this.target;
const amount = this.limitAmount(this.sendAmount);
if (!myPlayer || !target || amount <= 0) return;
if (this.mode === "troops") {
const myTroops = Number(myPlayer.troops());
if (amount > myTroops) return;
this.eventBus.emit(new SendDonateTroopsIntentEvent(target, amount));
} else {
const myGold = Number(myPlayer.gold());
if (amount > myGold) return;
this.eventBus.emit(new SendDonateGoldIntentEvent(target, BigInt(amount)));
}
this.dispatchEvent(
new CustomEvent("confirm", {
detail: { amount, closePanel: true, success: true },
}),
);
this.closeModal();
}
private handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
this.closeModal();
}
if (e.key === "Enter") {
e.preventDefault();
this.confirm();
}
};
private toNum(x: unknown): number {
if (typeof x === "bigint") return Number(x);
return Number(x ?? 0);
}
private getTotalNumber(): number {
const base = this.toNum(this.total);
return this.isSenderAlive() ? base : 0;
}
private sanitizePercent(p: number) {
return within(p, 0, 100);
}
/** Internal capacity only for troops; gold is unlimited. */
private getCapacityLeft(): number | null {
if (!this.isTargetAlive()) return 0;
if (this.mode !== "troops") return null;
if (!this.gameView || !this.target) return null;
const current = this.toNum(this.target.troops());
const max = this.toNum(this.gameView.config().maxTroops(this.target));
return Math.max(0, max - current);
}
private getPercentBasis(): number {
return this.getTotalNumber();
}
private limitAmount(proposed: number): number {
const cap = this.getCapacityLeft();
const total = this.getTotalNumber();
const hardMax = cap === null ? total : Math.min(total, cap);
return within(proposed, 0, hardMax);
}
private clampSend(n: number) {
const total = this.getTotalNumber();
const byTotal = within(n, 0, total);
return this.limitAmount(byTotal);
}
private percentOfBasis(n: number): number {
const basis = this.getPercentBasis();
return basis ? Math.round((n / basis) * 100) : 0;
}
private keepAfter(allowed: number): number {
const total = this.getTotalNumber();
return Math.max(0, total - allowed);
}
private getFillColor(): string {
return this.mode === "troops"
? "rgb(168 85 247)" /* purple */
: "rgb(234 179 8)" /* amber */;
}
private getMinKeepRatio(): number {
return this.mode === "troops" ? 0.3 : 0;
}
private isTargetAlive(): boolean {
return this.target?.isAlive() ?? false;
}
private isSenderAlive(): boolean {
return this.myPlayer?.isAlive() ?? false;
}
private i18n = {
title: (name: string) =>
this.mode === "troops"
? translateText("send_troops_modal.title_with_name", { name })
: translateText("send_gold_modal.title_with_name", { name }),
availableChip: () => translateText("common.available"),
availableTooltip: () =>
this.mode === "troops"
? translateText("send_troops_modal.available_tooltip")
: translateText("send_gold_modal.available_tooltip"),
max: () => translateText("common.preset_max"),
ariaSlider: () =>
this.mode === "troops"
? translateText("send_troops_modal.aria_slider")
: translateText("send_gold_modal.aria_slider"),
summarySend: () => translateText("common.summary_send"),
summaryKeep: () => translateText("common.summary_keep"),
closeLabel: () => translateText("common.close"),
cancel: () => translateText("common.cancel"),
send: () => translateText("common.send"),
cap: () => translateText("common.cap_label"),
capTooltip: () => translateText("common.cap_tooltip"),
sliderTooltip: (percent: number, amountStr: string) =>
this.mode === "troops"
? translateText("send_troops_modal.slider_tooltip", {
percent,
amount: amountStr,
})
: translateText("send_gold_modal.slider_tooltip", {
percent,
amount: amountStr,
}),
capacityNote: (amountStr: string) =>
translateText("send_troops_modal.capacity_note", { amount: amountStr }),
targetDeadTitle: () => translateText("common.target_dead"),
targetDeadNote: () => translateText("common.target_dead_note"),
};
private renderHeader() {
const name = this.target?.name?.() ?? "";
return html`
<div class="mb-3 flex items-center justify-between">
<h2
id="send-title"
class="text-lg font-semibold tracking-tight text-zinc-100"
>
${this.heading ?? this.i18n.title(name)}
</h2>
<button
class="rounded-md px-2 text-2xl leading-none text-zinc-300 hover:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-white/30"
@click=${() => this.closeModal()}
aria-label=${this.i18n.closeLabel()}
>
×
</button>
</div>
`;
}
private renderAvailable() {
const total = this.getTotalNumber();
const cap = this.getCapacityLeft();
return html`
<div class="mb-4 pb-3 border-b border-zinc-800">
<div class="flex items-center gap-2 text-[13px]">
<!-- Available -->
<span
class="inline-flex items-center gap-1 rounded-full bg-indigo-600/15 px-2 py-0.5 ring-1 ring-indigo-400/40 text-indigo-100"
title=${this.i18n.availableTooltip()}
>
<span class="opacity-90">${this.i18n.availableChip()}</span>
<span class="font-mono tabular-nums">${this.format(total)}</span>
</span>
${cap !== null
? html`
<!-- Cap -->
<span
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 ring-1 ring-amber-400/40 text-amber-200"
title=${this.i18n.capTooltip()}
>
<span class="opacity-90">${this.i18n.cap()}</span>
<span class="font-mono tabular-nums"
>${this.format(cap)}</span
>
</span>
`
: html``}
</div>
</div>
`;
}
private renderPresets(percentNow: number) {
const basis = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
return html`
<div class="mb-8 grid grid-cols-5 gap-2">
${this.PRESETS.map((p) => {
const pct = this.sanitizePercent(p);
const active = (this.selectedPercent ?? percentNow) === pct;
const label = pct === 100 ? this.i18n.max() : `${pct}%`;
return html`
<button
?disabled=${dead}
class="rounded-lg px-3 py-2 text-sm ring-1 transition
${dead
? "bg-zinc-800/70 text-zinc-400 ring-zinc-700 cursor-not-allowed"
: active
? "bg-indigo-600 text-white ring-indigo-300/60"
: "bg-zinc-800 text-zinc-200 ring-zinc-700 hover:bg-zinc-700 hover:text-zinc-50"}"
@click=${() => {
if (dead) return;
this.selectedPercent = pct;
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
}}
?aria-pressed=${active}
title="${pct}%"
>
${label}
</button>
`;
})}
</div>
`;
}
private renderSlider(percentNow: number) {
const basis = this.getTotalNumber();
const cap = this.getCapacityLeft();
const hardMax = cap === null ? basis : Math.min(basis, cap);
const dead = !this.isSenderAlive() || !this.isTargetAlive();
// Where to draw the cap marker (as % of Available)
const capPercent =
cap === null
? null
: Math.max(
0,
Math.min(
100,
Math.round((Math.min(cap, basis) / (basis || 1)) * 100),
),
);
const fill = this.getFillColor();
const disabled = basis <= 0 || dead;
const sliderOuterMb = capPercent !== null ? "mb-8" : "mb-2";
return html`
<div class="${sliderOuterMb}">
<div
class="relative px-1 rounded-lg overflow-visible focus-within:ring-2 focus-within:ring-indigo-500/30"
>
<input
type="range"
min="0"
.max=${basis}
.value=${this.sendAmount}
?disabled=${disabled}
@input=${(e: Event) => {
if (dead) return;
const raw = Number((e.target as HTMLInputElement).value);
const pctRaw = basis ? Math.round((raw / basis) * 100) : 0;
this.selectedPercent = this.sanitizePercent(pctRaw);
const clamped = Math.min(raw, hardMax);
this.sendAmount = this.clampSend(clamped);
}}
class="w-full appearance-none bg-transparent range-x focus:outline-none"
aria-label=${this.i18n.ariaSlider()}
aria-valuemin="0"
aria-valuemax=${hardMax}
aria-valuetext=${this.i18n.sliderTooltip(
percentNow,
this.format(this.sendAmount),
)}
style="--percent:${percentNow}%; --fill:${fill}; --track: rgba(255,255,255,.28); --thumb-ring: rgb(24 24 27);"
/>
<!-- Tooltip -->
<div
class="pointer-events-none absolute -top-6 -translate-x-1/2 select-none"
style="left:${percentNow}%"
>
<div
class="rounded bg-[#0f1116] ring-1 ring-zinc-700 text-zinc-100 px-1.5 py-0.5 text-[12px] shadow whitespace-nowrap w-max z-50"
>
${percentNow}% • ${this.format(this.sendAmount)}
</div>
</div>
<!-- Cap marker -->
${capPercent !== null
? html`
<div
class="pointer-events-none absolute top-1/2 -translate-y-1/2 h-3 w-[2px] bg-amber-400/80 shadow"
style="left:${capPercent}%;"
title=${this.i18n.capTooltip()}
></div>
<div
class="pointer-events-none absolute top-full mt-1.5 -translate-x-1/2 select-none"
style="left:${capPercent}%"
>
<div
class="rounded bg-[#0f1116] ring-1 ring-amber-400/40 text-amber-200 px-1 py-0.5 text-[11px] shadow whitespace-nowrap"
>
${this.i18n.cap()}
</div>
</div>
`
: html``}
</div>
</div>
`;
}
private renderCapacityNote(allowed: number) {
const capped = allowed !== this.sendAmount;
if (!capped) return html``;
return html`<p class="mt-1 text-xs text-amber-300">
${this.i18n.capacityNote(this.format(allowed))}
</p>`;
}
private renderSummary(allowed: number) {
const total = this.getTotalNumber();
const keep = this.keepAfter(allowed);
const belowMinKeep =
this.getMinKeepRatio() > 0 &&
keep < Math.floor(total * this.getMinKeepRatio());
return html`
<div class="mt-3 text-center text-sm text-zinc-200">
${this.i18n.summarySend()}
<span class="font-semibold text-indigo-400 font-mono"
>${this.format(allowed)}</span
>
· ${this.i18n.summaryKeep()}
<span
class="font-semibold font-mono ${belowMinKeep
? "text-amber-400"
: "text-emerald-400"}"
>
${this.format(keep)}
</span>
</div>
`;
}
private renderActions() {
const total = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
const disabled = total <= 0 || this.clampSend(this.sendAmount) <= 0 || dead;
return html`
<div class="mt-5 flex justify-end gap-2">
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold
text-zinc-100 bg-zinc-800 ring-1 ring-zinc-700
hover:bg-zinc-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-white/20"
@click=${() => this.closeModal()}
>
${this.i18n.cancel()}
</button>
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold text-white
bg-indigo-600 enabled:hover:bg-indigo-500
focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/50
disabled:cursor-not-allowed disabled:opacity-50"
?disabled=${disabled}
@click=${() => this.confirm()}
>
${this.i18n.send()}
</button>
</div>
`;
}
private renderDeadNote() {
return html`
<div
class="mb-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-amber-200 text-sm"
>
<div class="font-semibold">${this.i18n.targetDeadTitle()}</div>
<div>${this.i18n.targetDeadNote()}</div>
</div>
`;
}
private renderSliderStyles() {
return html`
<style>
.range-x {
-webkit-appearance: none;
appearance: none;
height: 8px;
outline: none;
background: transparent;
}
.range-x::-webkit-slider-runnable-track {
height: 8px;
border-radius: 9999px;
background: linear-gradient(
90deg,
var(--fill) 0,
var(--fill) var(--percent),
/* allowed (clamped) fill */ rgba(255, 255, 255, 0.22)
var(--percent),
rgba(255, 255, 255, 0.22) 100%
);
}
.range-x::-webkit-slider-thumb {
-webkit-appearance: none;
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
margin-top: -5px;
}
.range-x::-moz-range-track {
height: 8px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.22);
}
.range-x::-moz-range-progress {
height: 8px;
border-radius: 9999px;
background: var(--fill);
}
.range-x::-moz-range-thumb {
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
}
</style>
`;
}
render() {
if (!this.open) return html``;
const percent = this.percentOfBasis(this.sendAmount);
const allowed = this.limitAmount(this.sendAmount);
return html`
<div class="fixed inset-0 z-[1100] flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm rounded-2xl"
@click=${() => this.closeModal()}
></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="send-title"
class="relative z-10 w-full max-w-[540px] focus:outline-none"
tabindex="0"
@keydown=${this.handleKeydown}
>
<div
class="rounded-2xl bg-zinc-900 p-5 shadow-2xl ring-1 ring-zinc-800 max-h-[90vh] text-zinc-200"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
${this.renderHeader()} ${this.renderAvailable()}
${!this.isTargetAlive() ? this.renderDeadNote() : html``}
${this.renderPresets(percent)} ${this.renderSlider(percent)}
${this.mode === "troops"
? this.renderCapacityNote(allowed)
: html``}
${this.renderSummary(allowed)} ${this.renderActions()}
${this.renderSliderStyles()}
</div>
</div>
</div>
`;
}
}
@@ -18,9 +18,10 @@ export const STRUCTURE_SHAPES: Partial<Record<UnitType, ShapeType>> = {
[UnitType.DefensePost]: "octagon",
[UnitType.SAMLauncher]: "square",
[UnitType.MissileSilo]: "triangle",
[UnitType.Warship]: "cross",
[UnitType.AtomBomb]: "cross",
[UnitType.HydrogenBomb]: "cross",
[UnitType.Warship]: "cross",
[UnitType.MIRV]: "cross",
};
export const LEVEL_SCALE_FACTOR = 3;
export const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5;
+34 -25
View File
@@ -149,6 +149,11 @@ export class TerritoryLayer implements Layer {
if (!this.game.inSpawnPhase()) {
return;
}
this.spawnHighlight();
}
private spawnHighlight() {
if (this.game.ticks() % 5 === 0) {
return;
}
@@ -159,11 +164,18 @@ export class TerritoryLayer implements Layer {
this.game.width(),
this.game.height(),
);
this.drawFocusedPlayerHighlight();
const humans = this.game
.playerViews()
.filter((p) => p.type() === PlayerType.Human);
const focusedPlayer = this.game.focusedPlayer();
for (const human of humans) {
if (human === focusedPlayer) {
continue;
}
const center = human.nameLocation();
if (!center) {
continue;
@@ -190,37 +202,34 @@ export class TerritoryLayer implements Layer {
}
}
}
}
private drawFocusedPlayerHighlight() {
const focusedPlayer = this.game.focusedPlayer();
if (!focusedPlayer) {
return;
}
const center = focusedPlayer.nameLocation();
if (!center) {
return;
}
// Breathing border animation
this.borderAnimTime += 1;
const minPadding = 3;
const maxPadding = 8;
this.borderAnimTime += 3;
const minPadding = 6;
const maxPadding = 12;
// Range: [minPadding..maxPadding]
const breathingPadding =
minPadding +
(maxPadding - minPadding) *
(0.5 + 0.5 * Math.sin(this.borderAnimTime * 0.3));
if (focusedPlayer) {
// Clear previous animated border
if (this.highlightContext) {
this.highlightContext.clearRect(
0,
0,
this.game.width(),
this.game.height(),
);
}
const center = focusedPlayer.nameLocation();
if (center) {
this.drawBreathingRing(
center.x,
center.y,
breathingPadding,
this.theme.spawnHighlightColor(),
);
}
}
this.drawBreathingRing(
center.x,
center.y,
breathingPadding,
this.theme.spawnHighlightColor(),
);
}
init() {
@@ -560,7 +569,7 @@ export class TerritoryLayer implements Layer {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = color.toRgbString();
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.stroke();
}
}
+20 -10
View File
@@ -3,7 +3,8 @@ import { customElement } from "lit/decorators.js";
import warshipIcon from "../../../../resources/images/BattleshipIconWhite.svg";
import cityIcon from "../../../../resources/images/CityIconWhite.svg";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import mirvIcon from "../../../../resources/images/MIRVIcon.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloIconWhite.svg";
import hydrogenBombIcon from "../../../../resources/images/MushroomCloudIconWhite.svg";
import atomBombIcon from "../../../../resources/images/NukeIconWhite.svg";
import portIcon from "../../../../resources/images/PortIcon.svg";
@@ -59,7 +60,8 @@ export class UnitDisplay extends LitElement implements Layer {
config.isUnitDisabled(UnitType.SAMLauncher) &&
config.isUnitDisabled(UnitType.Warship) &&
config.isUnitDisabled(UnitType.AtomBomb) &&
config.isUnitDisabled(UnitType.HydrogenBomb);
config.isUnitDisabled(UnitType.HydrogenBomb) &&
config.isUnitDisabled(UnitType.MIRV);
this.requestUpdate();
}
@@ -78,6 +80,7 @@ export class UnitDisplay extends LitElement implements Layer {
switch (item) {
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
case UnitType.MIRV:
return (
this.cost(item) <= (player?.gold() ?? 0n) &&
(player?.units(UnitType.MissileSilo).length ?? 0) > 0
@@ -124,7 +127,7 @@ export class UnitDisplay extends LitElement implements Layer {
return html`
<div
class="hidden xl:flex md:flex fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] xl:flex-row lg:flex-col md:flex-col xl:gap-5 lg:gap-2 md:gap-2 justify-center items-center"
class="hidden 2xl:flex lg:flex fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] 2xl:flex-row xl:flex-col lg:flex-col 2xl:gap-5 xl:gap-2 lg:gap-2 justify-center items-center"
>
<div class="bg-gray-800/70 backdrop-blur-sm rounded-lg p-0.5">
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1 w-fit">
@@ -174,26 +177,33 @@ export class UnitDisplay extends LitElement implements Layer {
</div>
<div class="bg-gray-800/70 backdrop-blur-sm rounded-lg p-0.5 w-fit">
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1">
${this.renderUnitItem(
warshipIcon,
this._warships,
UnitType.Warship,
"warship",
this.keybinds["buildWarship"]?.key ?? "7",
)}
${this.renderUnitItem(
atomBombIcon,
null,
UnitType.AtomBomb,
"atom_bomb",
this.keybinds["buildAtomBomb"]?.key ?? "7",
this.keybinds["buildAtomBomb"]?.key ?? "8",
)}
${this.renderUnitItem(
hydrogenBombIcon,
null,
UnitType.HydrogenBomb,
"hydrogen_bomb",
this.keybinds["buildHydrogenBomb"]?.key ?? "8",
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
)}
${this.renderUnitItem(
warshipIcon,
this._warships,
UnitType.Warship,
"warship",
this.keybinds["buildWarship"]?.key ?? "9",
mirvIcon,
null,
UnitType.MIRV,
"mirv",
this.keybinds["buildMIRV"]?.key ?? "0",
)}
</div>
</div>
+6 -1
View File
@@ -1,6 +1,6 @@
import { LitElement, TemplateResult, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { isInIframe, translateText } from "../../../client/Utils";
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import { GameUpdateType } from "../../../core/game/GameUpdates";
@@ -36,6 +36,8 @@ export class WinModal extends LitElement implements Layer {
private _title: string;
private rand = Math.random();
// Override to prevent shadow DOM creation
createRenderRoot() {
return this;
@@ -98,6 +100,9 @@ export class WinModal extends LitElement implements Layer {
}
innerHtml() {
if (isInIframe() || this.rand < 0.25) {
return this.steamWishlist();
}
return this.renderPatternButton();
}
+9
View File
@@ -115,6 +115,7 @@ export type Player = z.infer<typeof PlayerSchema>;
export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
@@ -386,6 +387,7 @@ export const FlagSchema = z
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagSchema.optional(),
color: z.string().optional(),
patternName: PatternNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
});
@@ -395,10 +397,17 @@ export const PlayerPatternSchema = z.object({
patternData: PatternDataSchema,
colorPalette: ColorPaletteSchema.optional(),
});
export const PlayerColorSchema = z.object({
color: z.string(),
});
export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
});
export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
-21
View File
@@ -114,27 +114,6 @@ export function inscribed(
);
}
export function getMode(list: Set<number>): number {
// Count occurrences
const counts = new Map<number, number>();
for (const item of list) {
counts.set(item, (counts.get(item) ?? 0) + 1);
}
// Find the item with the highest count
let mode = 0;
let maxCount = 0;
for (const [item, count] of counts) {
if (count > maxCount) {
maxCount = count;
mode = item;
}
}
return mode;
}
export function sanitize(name: string): string {
return Array.from(name)
.join("")
+1 -1
View File
@@ -183,7 +183,7 @@ export interface Theme {
// Don't call directly, use PlayerView
territoryColor(playerInfo: PlayerView): Colord;
// Don't call directly, use PlayerView
borderColor(playerInfo: PlayerView): Colord;
borderColor(territoryColor: Colord): Colord;
// Don't call directly, use PlayerView
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
focusedBorderColor(): Colord;
+2 -13
View File
@@ -55,19 +55,8 @@ export class PastelTheme implements Theme {
}
// Don't call directly, use PlayerView
borderColor(player: PlayerView): Colord {
if (this.borderColorCache.has(player.id())) {
return this.borderColorCache.get(player.id())!;
}
const tc = this.territoryColor(player).rgba;
const color = colord({
r: Math.max(tc.r - 40, 0),
g: Math.max(tc.g - 40, 0),
b: Math.max(tc.b - 40, 0),
});
this.borderColorCache.set(player.id(), color);
return color;
borderColor(territoryColor: Colord): Colord {
return territoryColor.darken(0.125);
}
defendedBorderColors(territoryColor: Colord): {
+49 -32
View File
@@ -2,7 +2,7 @@ import { Config } from "../configuration/Config";
import { Execution, Game, Player, UnitType } from "../game/Game";
import { GameImpl } from "../game/GameImpl";
import { GameMap, TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
import { calculateBoundingBox, inscribed, simpleHash } from "../Util";
export class PlayerExecution implements Execution {
private readonly ticksPerClusterCalc = 20;
@@ -27,18 +27,30 @@ export class PlayerExecution implements Execution {
tick(ticks: number) {
this.player.decayRelations();
this.player.units().forEach((u) => {
const tileOwner = this.mg!.owner(u.tile());
if (u.info().territoryBound) {
if (tileOwner.isPlayer()) {
if (tileOwner !== this.player) {
this.mg!.player(tileOwner.id()).captureUnit(u);
}
} else {
u.delete();
}
for (const u of this.player.units()) {
if (!u.info().territoryBound) {
continue;
}
});
const owner = this.mg!.owner(u.tile());
if (!owner?.isPlayer()) {
u.delete();
continue;
}
if (owner === this.player) {
continue;
}
const captor = this.mg!.player(owner.id());
if (u.type() === UnitType.DefensePost) {
u.decreaseLevel(captor);
if (u.isActive()) {
captor.captureUnit(u);
}
} else {
captor.captureUnit(u);
}
}
if (!this.player.isAlive()) {
// Player has no tiles, delete any remaining units and gold
@@ -209,22 +221,37 @@ export class PlayerExecution implements Execution {
}
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
const neighborsIDs = new Set<number>();
// Collect unique neighbor IDs (excluding self) as candidates
const candidatesIDs = new Set<number>();
const selfID = this.player.smallID();
for (const t of cluster) {
for (const neighbor of this.mg.neighbors(t)) {
if (this.mg.ownerID(neighbor) !== this.player.smallID()) {
neighborsIDs.add(this.mg.ownerID(neighbor));
if (this.mg.ownerID(neighbor) !== selfID) {
candidatesIDs.add(this.mg.ownerID(neighbor));
}
}
}
let largestNeighborAttack: Player | null = null;
let largestTroopCount: number = 0;
for (const id of neighborsIDs) {
// Filter out friendly and non-player candidates
const neighbors = new Set<Player>();
for (const id of candidatesIDs) {
const neighbor = this.mg.playerBySmallID(id);
if (!neighbor.isPlayer() || this.player.isFriendly(neighbor)) {
if (!neighbor.isPlayer() || neighbor.isFriendly(this.player)) {
continue;
}
neighbors.add(neighbor);
}
// If there are no enemies, return null
if (neighbors.size === 0) {
return null;
}
// Get the largest attack from the neighbors
let largestNeighborAttack: Player | null = null;
let largestTroopCount = 0;
for (const neighbor of neighbors) {
for (const attack of neighbor.outgoingAttacks()) {
if (attack.target() === this.player) {
if (attack.troops() > largestTroopCount) {
@@ -234,20 +261,10 @@ export class PlayerExecution implements Execution {
}
}
}
if (largestNeighborAttack !== null) {
return largestNeighborAttack;
}
// fall back to getting mode if no attacks
const mode = getMode(neighborsIDs);
if (!this.mg.playerBySmallID(mode).isPlayer()) {
return null;
}
const capturing = this.mg.playerBySmallID(mode);
if (!capturing.isPlayer()) {
return null;
}
return capturing;
// Return the largest neighbor attack
// If there is no largest neighbor attack, this will return null
return largestNeighborAttack;
}
private calculateClusters(): Set<TileRef>[] {
+1
View File
@@ -494,6 +494,7 @@ export interface Unit {
// Upgradable Structures
level(): number;
increaseLevel(): void;
decreaseLevel(destroyer?: Player): void;
// Warships
setPatrolTile(tile: TileRef): void;
+26 -18
View File
@@ -197,36 +197,44 @@ export class PlayerView {
);
}
const defaultTerritoryColor = this.game
.config()
.theme()
.territoryColor(this);
const defaultBorderColor = this.game
.config()
.theme()
.borderColor(defaultTerritoryColor);
const pattern = this.cosmetics.pattern;
if (pattern) {
const territoryColor = this.game.config().theme().territoryColor(this);
pattern.colorPalette ??= {
name: "",
primaryColor: territoryColor.toHex(),
secondaryColor: territoryColor.darken(0.125).toHex(),
primaryColor: defaultTerritoryColor.toHex(),
secondaryColor: defaultBorderColor.toHex(),
} satisfies ColorPalette;
}
if (
this.team() === null &&
this.cosmetics.pattern?.colorPalette?.primaryColor !== undefined
) {
if (this.team() === null) {
this._territoryColor = colord(
this.cosmetics.pattern.colorPalette.primaryColor,
this.cosmetics.color?.color ??
this.cosmetics.pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
);
} else {
this._territoryColor = this.game.config().theme().territoryColor(this);
this._territoryColor = defaultTerritoryColor;
}
if (this.cosmetics.pattern?.colorPalette?.secondaryColor !== undefined) {
this._borderColor = colord(
this.cosmetics.pattern.colorPalette.secondaryColor,
);
} else if (this.game.myClientID() === this.data.clientID) {
this._borderColor = this.game.config().theme().focusedBorderColor();
} else {
this._borderColor = this.game.config().theme().borderColor(this);
}
const maybeFocusedBorderColor =
this.game.myClientID() === this.data.clientID
? this.game.config().theme().focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
pattern?.colorPalette?.secondaryColor ??
this.cosmetics.color?.color ??
maybeFocusedBorderColor.toHex(),
);
this._defendedBorderColors = this.game
.config()
+10 -2
View File
@@ -576,7 +576,11 @@ export class PlayerImpl implements Player {
}
canDonateGold(recipient: Player): boolean {
if (!this.isFriendly(recipient)) {
if (
!this.isAlive() ||
!recipient.isAlive() ||
!this.isFriendly(recipient)
) {
return false;
}
if (
@@ -599,7 +603,11 @@ export class PlayerImpl implements Player {
}
canDonateTroops(recipient: Player): boolean {
if (!this.isFriendly(recipient)) {
if (
!this.isAlive() ||
!recipient.isAlive() ||
!this.isFriendly(recipient)
) {
return false;
}
if (
+12
View File
@@ -397,6 +397,18 @@ export class UnitImpl implements Unit {
this.mg.addUpdate(this.toUpdate());
}
decreaseLevel(destroyer?: Player): void {
this._level--;
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
this._missileTimerQueue.pop();
}
if (this._level <= 0) {
this.delete(true, destroyer);
return;
}
this.mg.addUpdate(this.toUpdate());
}
trainType(): TrainType | undefined {
return this._trainType;
}
+14
View File
@@ -169,6 +169,20 @@ export class UserSettings {
}
}
getSelectedColor(): string | undefined {
const data = localStorage.getItem("settings.territoryColor") ?? undefined;
if (data === undefined) return undefined;
return data;
}
setSelectedColor(color: string | undefined): void {
if (color === undefined) {
localStorage.removeItem("settings.territoryColor");
} else {
localStorage.setItem("settings.territoryColor", color);
}
}
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}
+2 -6
View File
@@ -126,16 +126,12 @@ export class MapPlaylist {
const rand = new PseudoRandom(Date.now());
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
const ffa: GameMapType[] = rand.shuffleArray([...maps]);
const team: GameMapType[] = rand.shuffleArray([...maps]);
this.mapsPlaylist = [];
for (let i = 0; i < maps.length; i++) {
if (!this.addNextMap(this.mapsPlaylist, ffa1, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
if (!this.addNextMap(this.mapsPlaylist, ffa, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
+54 -106
View File
@@ -1,22 +1,18 @@
import { Cosmetics } from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerPattern } from "../core/Schemas";
import {
PlayerColor,
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
type PatternResult =
| { type: "allowed"; pattern: PlayerPattern }
| { type: "unknown" }
type CosmeticResult =
| { type: "allowed"; cosmetics: PlayerCosmetics }
| { type: "forbidden"; reason: string };
export interface PrivilegeChecker {
isPatternAllowed(
flares: readonly string[],
name: string,
colorPaletteName: string | null,
): PatternResult;
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid";
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
}
export class PrivilegeCheckerImpl implements PrivilegeChecker {
@@ -25,28 +21,53 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
private b64urlDecode: (base64: string) => Uint8Array,
) {}
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
const cosmetics: PlayerCosmetics = {};
if (refs.patternName) {
try {
cosmetics.pattern = this.isPatternAllowed(
flares,
refs.patternName,
refs.patternColorPaletteName ?? null,
);
} catch (e) {
return { type: "forbidden", reason: "invalid pattern: " + e.message };
}
}
if (refs.color) {
try {
cosmetics.color = this.isColorAllowed(flares, refs.color);
} catch (e) {
return { type: "forbidden", reason: "invalid color: " + e.message };
}
}
return { type: "allowed", cosmetics };
}
isPatternAllowed(
flares: readonly string[],
name: string,
colorPaletteName: string | null,
): PatternResult {
): PlayerPattern {
// Look for the pattern in the cosmetics.json config
const found = this.cosmetics.patterns[name];
if (!found) return { type: "forbidden", reason: "pattern not found" };
if (!found) throw new Error(`Pattern ${name} not found`);
try {
decodePatternData(found.pattern, this.b64urlDecode);
} catch (e) {
return { type: "forbidden", reason: "invalid pattern" };
throw new Error(`Invalid pattern ${name}`);
}
const colorPalette = this.cosmetics.colorPalettes?.[colorPaletteName ?? ""];
if (flares.includes("pattern:*")) {
return {
type: "allowed",
pattern: { name: found.name, patternData: found.pattern, colorPalette },
};
name: found.name,
patternData: found.pattern,
colorPalette,
} satisfies PlayerPattern;
}
const flareName =
@@ -56,101 +77,28 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
if (flares.includes(flareName)) {
// Player has a flare for this pattern
return {
type: "allowed",
pattern: { name: found.name, patternData: found.pattern, colorPalette },
};
name: found.name,
patternData: found.pattern,
colorPalette,
} satisfies PlayerPattern;
} else {
return { type: "forbidden", reason: "no flares for pattern" };
throw new Error(`No flares for pattern ${name}`);
}
}
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid" {
if (!flag.startsWith("!")) return "invalid";
const code = flag.slice(1);
if (!code) return "invalid";
const segments = code.split("_");
if (segments.length === 0) return "invalid";
const MAX_LAYERS = 6; // Maximum number of layers allowed
if (segments.length > MAX_LAYERS) return "invalid";
const superFlare = flares?.includes("flag:*") ?? false;
for (const segment of segments) {
const [layerKey, colorKey] = segment.split("-");
if (!layerKey || !colorKey) return "invalid";
const layer = this.cosmetics.flag?.layers[layerKey];
const color = this.cosmetics.flag?.color[colorKey];
if (!layer || !color) return "invalid";
// Super-flare bypasses all restrictions
if (superFlare) {
continue;
}
// Check layer restrictions
const layerSpec = layer;
let layerAllowed = false;
if (!layerSpec.flares) {
layerAllowed = true;
} else {
// By flare
if (
layerSpec.flares &&
flares?.some((f) => layerSpec.flares?.includes(f))
) {
layerAllowed = true;
}
// By named flag:layer:{name}
if (flares?.includes(`flag:layer:${layerSpec.name}`)) {
layerAllowed = true;
}
}
// Check color restrictions
const colorSpec = color;
let colorAllowed = false;
if (!colorSpec.flares) {
colorAllowed = true;
} else {
// By flare
if (
colorSpec.flares &&
flares?.some((f) => colorSpec.flares?.includes(f))
) {
colorAllowed = true;
}
// By named flag:color:{name}
if (flares?.includes(`flag:color:${colorSpec.name}`)) {
colorAllowed = true;
}
}
// If either part is restricted, block
if (!(layerAllowed && colorAllowed)) {
return "restricted";
}
isColorAllowed(flares: string[], color: string): PlayerColor {
const allowedColors = flares
.filter((flare) => flare.startsWith("color:"))
.map((flare) => "#" + flare.split(":")[1]);
if (!allowedColors.includes(color)) {
throw new Error(`Color ${color} not allowed`);
}
return true;
return { color };
}
}
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isPatternAllowed(
flares: readonly string[],
name: string,
colorPaletteName: string | null,
): PatternResult {
return { type: "unknown" };
}
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid" {
return true;
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
return { type: "allowed", cosmetics: {} };
}
}
+8 -82
View File
@@ -13,9 +13,6 @@ import {
ClientMessageSchema,
ID,
PartialGameRecordSchema,
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
ServerErrorMessage,
} from "../core/Schemas";
import { replacer } from "../core/Util";
@@ -26,7 +23,6 @@ import { GameManager } from "./GameManager";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { assertNever } from "../core/Util";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { initWorkerMetrics } from "./WorkerMetrics";
@@ -366,15 +362,15 @@ export async function startWorker() {
}
}
const { perm, cosmetics, error } = checkCosmetics(
clientMsg.cosmetics,
flares ?? [],
);
if (perm === "forbidden") {
log.warn(`Forbidden: ${error}`, {
const cosmeticResult = privilegeRefresher
.get()
.isAllowed(flares ?? [], clientMsg.cosmetics ?? {});
if (cosmeticResult.type === "forbidden") {
log.warn(`Forbidden: ${cosmeticResult.reason}`, {
clientID: clientMsg.clientID,
});
ws.close(1002, error);
ws.close(1002, cosmeticResult.reason);
return;
}
@@ -388,7 +384,7 @@ export async function startWorker() {
ip,
clientMsg.username,
ws,
cosmetics,
cosmeticResult.cosmetics,
);
const wasFound = gm.addClient(
@@ -424,76 +420,6 @@ export async function startWorker() {
});
});
function checkCosmetics(
cosmetics: PlayerCosmeticRefs | undefined,
flares: readonly string[],
): {
perm: "forbidden" | "allowed";
cosmetics?: PlayerCosmetics | undefined;
error?: string;
} {
if (cosmetics === undefined) {
return {
perm: "allowed",
cosmetics: undefined,
};
}
// Check if the flag is allowed
if (cosmetics.flag !== undefined) {
if (cosmetics.flag.startsWith("!")) {
const allowed = privilegeRefresher
.get()
.isCustomFlagAllowed(cosmetics.flag, flares);
if (allowed !== true) {
log.warn(`Custom flag ${allowed}: ${cosmetics.flag}`);
return {
perm: "forbidden",
error: `Custom flag ${allowed}`,
};
}
}
}
let pattern: PlayerPattern | undefined;
// Check if the pattern is allowed
if (cosmetics.patternName !== undefined) {
const result = privilegeRefresher
.get()
.isPatternAllowed(
flares,
cosmetics.patternName,
cosmetics.patternColorPaletteName ?? null,
);
switch (result.type) {
case "allowed":
pattern = result.pattern;
break;
case "unknown":
log.warn(`Pattern ${cosmetics.patternName} unknown`);
return {
perm: "forbidden",
error: "Could not look up pattern, backend may be offline",
};
case "forbidden":
log.warn(`Pattern ${cosmetics.patternName}: ${result.reason}`);
return {
perm: "forbidden",
error: `Pattern ${cosmetics.patternName}: ${result.reason}`,
};
default:
assertNever(result);
}
}
return {
perm: "allowed",
cosmetics: {
flag: cosmetics.flag,
pattern: pattern,
},
};
}
// The load balancer will handle routing to this server based on path
const PORT = config.workerPortByIndex(workerId);
server.listen(PORT, () => {
@@ -0,0 +1,95 @@
import { PlayerExecution } from "../../../src/core/execution/PlayerExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../../../src/core/game/Game";
import { setup } from "../../util/Setup";
import { executeTicks } from "../../util/utils";
let game: Game;
let player: Player;
let otherPlayer: Player;
describe("PlayerExecution", () => {
beforeEach(async () => {
game = await setup(
"big_plains",
{
infiniteGold: true,
instantBuild: true,
},
[
new PlayerInfo("player", PlayerType.Human, "client_id1", "player_id"),
new PlayerInfo("other", PlayerType.Human, "client_id2", "other_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player = game.player("player_id");
otherPlayer = game.player("other_id");
game.addExecution(new PlayerExecution(player));
game.addExecution(new PlayerExecution(otherPlayer));
});
test("DefensePost lv. 1 is destroyed when tile owner changes", () => {
const tile = game.ref(50, 50);
player.conquer(tile);
const defensePost = player.buildUnit(UnitType.DefensePost, tile, {});
game.executeNextTick();
expect(game.unitCount(UnitType.DefensePost)).toBe(1);
expect(defensePost.level()).toBe(1);
otherPlayer.conquer(tile);
executeTicks(game, 2);
expect(game.unitCount(UnitType.DefensePost)).toBe(0);
});
test("DefensePost lv. 2+ is downgraded when tile owner changes", () => {
const tile = game.ref(50, 50);
player.conquer(tile);
const defensePost = player.buildUnit(UnitType.DefensePost, tile, {});
defensePost.increaseLevel();
expect(defensePost.level()).toBe(2);
expect(game.unitCount(UnitType.DefensePost)).toBe(2); // unitCount sums levels
expect(player.units(UnitType.DefensePost)).toHaveLength(1);
expect(defensePost.isActive()).toBe(true);
otherPlayer.conquer(tile);
executeTicks(game, 2);
expect(defensePost.level()).toBe(1);
expect(game.unitCount(UnitType.DefensePost)).toBe(1);
expect(otherPlayer.units(UnitType.DefensePost)).toHaveLength(1);
expect(defensePost.owner()).toBe(otherPlayer);
expect(defensePost.isActive()).toBe(true);
});
test("Non-DefensePost structures are transferred (not downgraded) when tile owner changes", () => {
const tile = game.ref(50, 50);
player.conquer(tile);
const city = player.buildUnit(UnitType.City, tile, {});
expect(game.unitCount(UnitType.City)).toBe(1);
expect(city.level()).toBe(1);
expect(city.owner()).toBe(player);
expect(city.isActive()).toBe(true);
otherPlayer.conquer(tile);
executeTicks(game, 2);
expect(game.unitCount(UnitType.City)).toBe(1);
expect(city.level()).toBe(1);
expect(city.owner()).toBe(otherPlayer);
expect(city.isActive()).toBe(true);
});
});
-96
View File
@@ -1,96 +0,0 @@
import type { Cosmetics } from "../../src/core/CosmeticSchemas";
import { PrivilegeCheckerImpl } from "../../src/server/Privilege";
describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => {
const dummyPatternDecoder = (_base64: string) => {
throw new Error("Method not implemented");
};
const mockCosmetics: Cosmetics = {
patterns: {},
flag: {
layers: {
a: {
name: "chocolate",
flares: ["cosmetic:flags"],
},
b: { name: "center_hline" },
c: { name: "admin_layer" },
},
color: {
a: { color: "#ff0000", name: "red", flares: ["cosmetic:red"] },
b: { color: "#00ff00", name: "green" },
c: { color: "#0000ff", name: "blue", flares: ["cosmetic:blue"] },
},
},
};
const checker = new PrivilegeCheckerImpl(mockCosmetics, dummyPatternDecoder);
it("allowed: unrestricted layer/color", () => {
expect(checker.isCustomFlagAllowed("!b-b", [])).toBe(true);
});
it("allowed: donor layer with correct flare", () => {
expect(checker.isCustomFlagAllowed("!a-b", ["cosmetic:flags"])).toBe(true);
});
it("allowed: color with correct flare", () => {
expect(checker.isCustomFlagAllowed("!b-c", ["cosmetic:blue"])).toBe(true);
});
it("invalid: non-existent layer", () => {
expect(checker.isCustomFlagAllowed("!zzz-a", [])).toBe("invalid");
});
it("invalid: non-existent color", () => {
expect(checker.isCustomFlagAllowed("!a-zzz", [])).toBe("invalid");
});
it("allowed: superFlare allows all listed", () => {
expect(checker.isCustomFlagAllowed("!a-a", ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!b-b", ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!c-a", ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!a-c", ["flag:*"])).toBe(true);
});
it("invalid: superFlare does not allow non-existent", () => {
expect(checker.isCustomFlagAllowed("!zzz-zzz", ["flag:*"])).toBe("invalid");
});
it("allowed: flare flag:layer:chocolate allows chocolate layer", () => {
expect(checker.isCustomFlagAllowed("!a-b", ["flag:layer:chocolate"])).toBe(
true,
);
});
it("allowed: flare flag:color:blue allows blue color", () => {
expect(checker.isCustomFlagAllowed("!b-c", ["flag:color:blue"])).toBe(true);
});
it("restricted: only color flare, layer still restricted", () => {
expect(checker.isCustomFlagAllowed("!a-c", ["cosmetic:blue"])).toBe(
"restricted",
);
});
it("restricted: only layer flare, color still restricted", () => {
expect(checker.isCustomFlagAllowed("!c-a", ["cosmetic:flags"])).toBe(
"restricted",
);
});
it("allowed: two segments, both unrestricted", () => {
expect(checker.isCustomFlagAllowed("!b-b_b-b", [])).toBe(true);
});
it("allowed: two segments, both by flare", () => {
expect(
checker.isCustomFlagAllowed("!a-c_a-c", [
"cosmetic:flags",
"cosmetic:blue",
]),
).toBe(true);
expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:flags"])).toBe(
"restricted",
);
expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:blue"])).toBe(
"restricted",
);
});
});