mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:30:43 +00:00
Merge branch 'main' into sounds
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+31
-7
@@ -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": "Recipient’s 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
@@ -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 (x1–x100)",
|
||||
"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
@@ -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 (x1–x100)",
|
||||
"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
@@ -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 (x1–x100)",
|
||||
"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
@@ -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
@@ -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
@@ -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": "Тип"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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``;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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>[] {
|
||||
|
||||
@@ -494,6 +494,7 @@ export interface Unit {
|
||||
// Upgradable Structures
|
||||
level(): number;
|
||||
increaseLevel(): void;
|
||||
decreaseLevel(destroyer?: Player): void;
|
||||
|
||||
// Warships
|
||||
setPatrolTile(tile: TileRef): void;
|
||||
|
||||
+26
-18
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user