diff --git a/resources/lang/en.json b/resources/lang/en.json index 074a35c4a..5be58f591 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -156,10 +156,15 @@ }, "account_modal": { "title": "Account", - "logged_in_as": "Logged in as {email}", + "linked_account": "Logged in as {account_name}", "fetching_account": "Fetching account information...", "logged_in_with_discord": "Logged in with Discord", - "recovery_email_sent": "Recovery email sent to {email}" + "recovery_email_sent": "Recovery email sent to {email}", + "player_id": "Player ID: {id}", + "not_found": "Not Found", + "clear_session": "Clear Session", + "failed_to_send_recovery_email": "Failed to send recovery email", + "enter_email_address": "Please enter an email address" }, "stats_modal": { "title": "Stats", @@ -712,6 +717,7 @@ "colors": "Colors", "purchase": "Purchase", "show_only_owned": "My Skins", + "not_logged_in": "Not logged in", "blocked": { "login": "You must be logged in to access this skin.", "purchase": "Purchase this skin to unlock it." diff --git a/resources/lang/nl.json b/resources/lang/nl.json index 4bec242f3..6af11a6b6 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -6,7 +6,18 @@ "lang_code": "nl" }, "common": { - "close": "Sluiten" + "close": "Sluiten", + "available": "Beschikbaar", + "preset_max": "Max", + "summary_send": "Verstuur", + "summary_keep": "Behoud", + "cancel": "Annuleer", + "send": "Verstuur", + "cap_label": "Limiet", + "cap_tooltip": "Resterende capaciteit ontvanger", + "target_dead": "Doelwit uitgeschakeld", + "target_dead_note": "Je kunt geen middelen sturen naar een dode speler.", + "none": "Geen" }, "main": { "title": "OpenFront (ALFA)", @@ -23,10 +34,11 @@ "advertise": "Adverteren", "wiki": "Wiki", "privacy_policy": "Privacybeleid", - "terms_of_service": "Servicevoorwaarden" + "terms_of_service": "Servicevoorwaarden", + "reddit": "Reddit" }, "news": { - "full_changelog": "Bekijk het volledige changelog", + "see_all_releases": "Bekijk alle releases", "github_link": "op GitHub", "title": "Release-opmerkingen" }, @@ -43,6 +55,7 @@ "action_move_camera": "Camera bewegen", "action_ratio_change": "Aanvalsverhouding verlagen/verhogen", "action_reset_gfx": "Grafische instellingen herstellen", + "action_auto_upgrade": "Auto-upgrade het dichtstbijzijnde gebouw", "ui_section": "Spel-UI", "ui_leaderboard": "Leidersbord", "ui_your_team": "Jouw team:", @@ -51,7 +64,6 @@ "ui_control_desc": "Het controlepaneel bevat de volgende elementen:", "ui_pop": "Pop - Je totale bevolking, je maximale bevolking en de snelheid waarmee je ze verwerft.", "ui_gold": "Goud - Het goud dat je hebt en de snelheid waarmee je het verwerft.", - "ui_troops_workers": "Troepen en Werkers - Het aantal toegewezen troepen en werkers. Troepen worden gebruikt om aan te vallen of verdedigen. Werkers worden gebruikt om goud te genereren. Je kunt het aantal troepen en werkers aanpassen met de schuifbalk.", "ui_attack_ratio": "Aanvalsverhouding - Het aantal troepen dat wordt gebruikt wanneer je aanvalt. Je kunt de aanvalsverhouding aanpassen met de schuifbalk. Gebruik je bij een aanval meer aanvallende troepen dan er verdedigende troepen zijn, dan verlies je er minder dan wanneer je met minder troepen aanvalt. Dit effect gaat niet verder dan verhoudingen van 2:1.", "ui_events": "Gebeurtenispaneel", "ui_events_desc": "Het Gebeurtenispaneel toont de laatste gebeurtenissen, verzoeken en Snelchat-berichten. Wat voorbeelden:", @@ -70,11 +82,12 @@ "radial_title": "Radiale menu", "radial_desc": "Rechtermuisknop (of tikken op touchscreen) opent het Radiale menu. Om het te sluiten klik je met de rechtermuisknop erbuiten. Vanuit het menu kun je:", "radial_build": "Het bouwmenu openen.", + "radial_attack": "Het aanvalsmenu openen.", "radial_info": "Infomenu openen.", "radial_boat": "Stuur een Boot (transportschip) voor een aanval op de geselecteerde locatie. Alleen beschikbaar als je toegang hebt tot water.", "radial_close": "Het menu sluiten.", "info_title": "Infomenu", - "info_enemy_desc": "Bevat informatie zoals de naam van de geselecteerde speler, goud, troepen, of ze de handel hebben stopgezet, hoeveel kernwapens ze op je hebben afgevuurd, en of de speler een verrader is. Een verrader is een speler die een bondgenoot heeft aangevallen. Handel stopgezet betekent dat jullie geen goud meer van elkaar ontvangen via handelsschepen. Handmatig (als de speler op \"Stop handel\" heeft geklikt, wat duurt totdat jullie beide op \"Start handel\" hebben geklikt) of automatisch (als jij jullie bondgenootschap hebt verraden, wat 5 minuten duurt of korter als jullie weer bondgenoten worden). Verrader toont 30 seconden lang Ja als de speler een bondgenoot heeft aangevallen. De iconen hieronder staan voor de volgende interacties:", + "info_enemy_desc": "Bevat informatie zoals de naam van de geselecteerde speler, goud, troepen, of ze de handel hebben stopgezet, hoeveel kernwapens ze op je hebben afgevuurd, en of de speler een verrader is. Een verrader is een speler die een bondgenoot heeft aangevallen. Handel gestopt betekent dat jullie geen goud meer van elkaar ontvangen via handelsschepen. Handmatig (als de speler op \"Stop handel\" heeft geklikt, wat duurt totdat jullie beide op \"Start handel\" hebben geklikt) of automatisch (als jij jullie bondgenootschap hebt verraden, wat 5 minuten duurt of korter als jullie weer bondgenoten worden). Verrader toont 30 seconden lang Ja als de speler een bondgenoot heeft aangevallen. De iconen hieronder staan voor de volgende interacties:", "info_chat": "Stuur een snelchat-bericht naar de speler. Kies een categorie, een zin, en een spelersnaam om op de plek van [P1] te zetten in een zin. Klik op Verzenden.", "info_target": "Plaats een doelmarkering op de speler, zichtbaar voor alle bondgenoten, wordt gebruikt om aanvallen te coördineren.", "info_alliance": "Stuur een alliantieverzoek naar de speler. Bondgenoten kunnen goud en troepen delen, maar kunnen elkaar niet aanvallen.", @@ -93,11 +106,11 @@ "build_city": "Stad", "build_city_desc": "Verhoogt je maximale bevolking. Handig wanneer je je gebied niet kunt uitbreiden of bijna je bevolkingslimiet heb bereikt.", "build_factory": "Fabriek", - "build_factory_desc": "Legt automatisch spoorlijnen aan naar nabijgelegen gebouwen, en af en toe verschijnt een trein.", + "build_factory_desc": "Bouwt automatisch spoorwegen naar nabije steden, havens en andere fabrieken, en kan ook met vriendelijke buren verbinden. Treinen vertrekken regelmatig en geven je een vast bedrag aan goud voor elk gebouw dat ze onderweg aandoen, met extra goud voor gebouwen van buren en bondgenoten.", "build_defense": "Verdedigingspost", "build_defense_desc": "Versterkt de verdediging rondom, binnen dit gebied krijgen grenzen een geruit patroon. Aanvallen van vijanden zijn trager en maken meer slachtoffers.", "build_port": "Haven", - "build_port_desc": "Kan alleen bij water worden gebouwd. Maakt het bouwen van Oorlogsschepen mogelijk. Stuurt automatisch handelsschepen tussen havens van jouw land en andere landen (behalve als de handel is stopgezet), wat goud oplevert voor beide partijen. Handel stopt automatisch wanneer jij een ander aanvalt of zij jou. Het wordt na 5 minuten hervat of eerder wanneer je bondgenoten wordt. Je kunt handel handmatig in-/uitschakelen met \"Stop handel\" of \"Start handel\".", + "build_port_desc": "Kan alleen bij water worden gebouwd. Maakt het bouwen van Oorlogsschepen mogelijk. Stuurt automatisch handelsschepen tussen je eigen havens en die van andere landen (behalve als de handel is stopgezet), wat goud oplevert voor beide partijen. Handel stopt automatisch wanneer jij een ander aanvalt of zij jou. Het wordt hervat na 5 minuten of als je bondgenoten wordt. Je kunt de handel handmatig in- of uitschakelen met \"Stop handel\" of \"Start handel\".", "build_warship": "Oorlogsschip", "build_warship_desc": "Patrouilleert in een gebied, vangt handelsschepen en vernietigt vijandelijke Boten (transsportschepen) en Oorlogsschepen. Komt vanuit de dichtstbijzijnde Haven en patrouilleert in het gebied waar je hebt geklikt om het te bouwen. Je kunt Oorlogsschepen besturen door op ze te klikken (of shift+klik als linkermuisknop is ingesteld op menu openen) en daarna op de plek waar je ze naartoe wilt laten gaan.", "build_silo": "Raketsilo", @@ -115,7 +128,7 @@ "icon_crown": "Kroon - Nummer 1. Dit is de topspeler op het leidersbord.", "icon_traitor": "Gebroken schild - Verrader. Deze speler heeft een bondgenoot aangevallen.", "icon_ally": "Handdruk - Bondgenoot. Deze speler is je bondgenoot.", - "icon_embargo": "Dollar stopbord - Embargo. Deze speler heeft de handel met jou automatisch of handmatig stopgezet.", + "icon_embargo": "Dollar stopbord - Embargo. Deze speler heeft de handel met jou gestopt, automatisch of handmatig.", "icon_request": "Envelop - Alliantieverzoek. Deze speler stuurde je een verzoek om bondgenoten te worden.", "info_enemy_panel": "Infopaneel vijand", "exit_confirmation": "Weet je zeker dat je dit spel wilt verlaten?" @@ -126,14 +139,30 @@ "options_title": "Opties", "bots": "Bots:", "bots_disabled": "Uitgeschakeld", + "nations": "Naties: ", "disable_nations": "Naties uitschakelen", "instant_build": "Bouwwachttijd uitschakelen", "infinite_gold": "Oneindig goud", "infinite_troops": "Oneindige troepen", + "compact_map": "Compacte Kaart", + "max_timer": "Spellengte (minuten)", "disable_nukes": "Kernwapens uitschakelen", + "automatic_difficulty": "Automatische Moeilijkheidsgraad", "enables_title": "Onderdelen inschakelen", "start": "Start Spel" }, + "token_login_modal": { + "title": "Aan het inloggen...", + "logging_in": "Aan het inloggen...", + "success": "Succesvol ingelogd als {email}!" + }, + "account_modal": { + "title": "Account", + "logged_in_as": "Ingelogd als {email}", + "fetching_account": "Accountgegevens ophalen...", + "logged_in_with_discord": "Ingelogd met Discord", + "recovery_email_sent": "Herstelmail verzonden naar {email}" + }, "map": { "map": "Kaart", "world": "Wereld", @@ -162,7 +191,13 @@ "baikal": "Baikalmeer", "halkidiki": "Chalkidiki", "straitofgibraltar": "Straat van Gibraltar", - "italia": "Italië" + "italia": "Italië", + "japan": "Japan", + "yenisei": "Jenisej", + "pluto": "Pluto", + "montreal": "Montreal", + "achiran": "Achiran", + "baikalnukewars": "Baikal (Kernoorlog)" }, "map_categories": { "continental": "Continent", @@ -180,8 +215,9 @@ "join_lobby": "Lobby toetreden", "checking": "Lobby controleren...", "not_found": "Lobby niet gevonden. Controleer het ID en probeer het opnieuw.", - "error": "Er is een fout opgetreden. Probeer het opnieuw.", - "joined_waiting": "Succesvol toegetreden! Wachten tot het spel begint..." + "error": "Er is een fout opgetreden. Probeer het opnieuw of neem contact op met support.", + "joined_waiting": "Succesvol toegetreden! Wachten tot het spel begint...", + "version_mismatch": "Dit spel is aangemaakt met een andere versie. Kan niet afspelen of deelnemen." }, "public_lobby": { "join": "Deelnemen aan volgende Spel", @@ -189,8 +225,15 @@ "teams_Duos": "Duo's (teams van 2)", "teams_Trios": "Trio's (teams van 3)", "teams_Quads": "Viertallen (teams van 4)", + "teams_hvn": "Mensen vs Naties", "teams": "{num} Teams" }, + "matchmaking_modal": { + "title": "Matchmaking", + "connecting": "Verbinden met matchmakingserver...", + "searching": "Zoeken naar een spel...", + "waiting_for_game": "Wachten tot het spel begint..." + }, "username": { "enter_username": "Voer je gebruikersnaam in", "not_string": "Gebruikersnaam moet een tekenreeks zijn.", @@ -205,15 +248,22 @@ "options_title": "Opties", "bots": "Bots:", "bots_disabled": "Uitgeschakeld", + "nations": "Naties: ", "disable_nations": "Naties uitschakelen", + "max_timer": "Spellengte (minuten)", "instant_build": "Bouwwachttijd uitschakelen", "infinite_gold": "Oneindig goud", + "donate_gold": "Goud geven", "infinite_troops": "Oneindige troepen", + "donate_troops": "Troepen geven", + "compact_map": "Compacte Kaart", + "automatic_difficulty": "Automatische Moeilijkheidsgraad", "enables_title": "Onderdelen inschakelen", "player": "Speler", "players": "Spelers", "waiting": "Wachten op spelers...", - "start": "Start Spel" + "start": "Start Spel", + "host_badge": "Host" }, "team_colors": { "red": "Rood", @@ -227,17 +277,18 @@ }, "game_starting_modal": { "title": "Spel gaat starten...", - "desc": "Voorbereiden op het starten van de lobby. Even geduld." + "credits": "Credits", + "code_license": "Code gelicenseerd onder AGPL-3.0 (geen garantie)" }, "difficulty": { "difficulty": "Moeilijkheidsgraad", - "Relaxed": "Ontspannen", - "Balanced": "Gebalanceerd", - "Intense": "Intens", + "Easy": "Ontspannen", + "Medium": "Gebalanceerd", + "Hard": "Intens", "Impossible": "Onmogelijk" }, "game_mode": { - "ffa": "Iedereen tegen iedereen (FFA)", + "ffa": "Iedereen tegen elkaar (FFA)", "teams": "Teams" }, "select_lang": { @@ -261,33 +312,28 @@ "tab_keybinds": "Sneltoetsen", "dark_mode_label": "Donkere Modus", "dark_mode_desc": "Schakel tussen lichte en donkere thema's voor de site", - "dark_mode_enabled": "Donkere modus ingeschakeld", - "light_mode_enabled": "Lichte modus ingeschakeld", "emojis_label": "Emoji's", - "emojis_visible": "Emoji's zijn zichtbaar", - "emojis_hidden": "Emoji's worden verborgen", "emojis_desc": "Schakel het tonen van emoji's in de game uit/aan", "alert_frame_label": "Waarshuwingskader", "alert_frame_desc": "Schakel het waarschuwingskader aan/uit. Als het is ingeschakeld, wordt het kader getoond wanneer je wordt verraden.", "special_effects_label": "Visuele effecten", "special_effects_desc": "Visuele effecten aanzetten. Zet uit om de prestaties van het spel te verbeteren", - "special_effects_enabled": "Visuele effecten ingeschakeld", - "special_effects_disabled": "Visuele effecten uitgeschakeld", + "structure_sprites_label": "Gebouw afbeeldingen", + "structure_sprites_desc": "3D-afbeeldingen gebouwen in-/uitschakelen", "anonymous_names_label": "Verborgen Namen", "anonymous_names_desc": "Vervang echte spelersnamen door willekeurige namen op je scherm.", - "anonymous_names_enabled": "Anonieme namen ingeschakeld", - "real_names_shown": "Echte namen worden getoond", + "lobby_id_visibility_label": "Verborgen Lobby-ID's", + "lobby_id_visibility_desc": "Verberg Lobby-ID tijdens het maken van een privélobby", "left_click_label": "Linkermuisknop voor openen menu", "left_click_desc": "Als AAN: linkermuisknop opent het Radiale menu met zwaard-aanvalsknop. Als UIT: linkermuisknop opent direct de aanval.", "left_click_menu": "Linkermuisknop Radiale Menu", - "left_click_opens_menu": "Linkermuisknop opent menu", - "right_click_opens_menu": "Rechtermuisknop opent menu", "attack_ratio_label": "⚔️ Aanvalsverhouding", "attack_ratio_desc": "Welk percentage van je troepen je bij een aanval stuurt (1-100%)", - "troop_ratio_label": "🪖🛠️ Troepen en Werkers-verhouding", "troop_ratio_desc": "De balans tussen troepen (voor gevechten) en werkers (voor goudproductie) aanpassen (1-100%)", - "territory_patterns_label": "🏳️ Gebiedspatronen", - "territory_patterns_desc": "Kies of je gebiedspatronen in het spel wilt weergeven", + "territory_patterns_label": "🏳️ Skins voor gebieden", + "territory_patterns_desc": "Kies of je skins op gebieden wilt weergeven in het spel", + "performance_overlay_label": "Prestatie-overlay", + "performance_overlay_desc": "Prestatie-overlay in-/uitschakelen. De overlay wordt weergegeven als het is ingeschakeld. Met shift-D zet je het aan of uit tijdens een spel.", "easter_writing_speed_label": "Schrijfsnelheidsvermenigvuldiger", "easter_writing_speed_desc": "Pas aan hoe snel je pretendeert te programmeren (x1-x100)", "easter_bug_count_label": "Aantal bugs", @@ -295,6 +341,27 @@ "view_options": "Weergave-opties", "toggle_view": "Weergave wisselen", "toggle_view_desc": "Weergave wisselen (terrein/landen)", + "build_controls": "Bouwbediening", + "build_city": "Bouw Stad", + "build_city_desc": "Bouw een Stad onder je cursor.", + "build_factory": "Bouw Fabriek", + "build_factory_desc": "Bouw een Fabriek onder je cursor.", + "build_defense_post": "Bouw Verdedigingspost", + "build_defense_post_desc": "Bouw een Verdedigingspost onder je cursor.", + "build_port": "Bouw Haven", + "build_port_desc": "Bouw een Haven onder je cursor.", + "build_warship": "Bouw Oorlogsschip", + "build_warship_desc": "Bouw een Oorlogsschip onder je cursor.", + "build_missile_silo": "Bouw Raketsilo", + "build_missile_silo_desc": "Bouw een Raketsilo onder je cursor.", + "build_sam_launcher": "Bouw SAM-lanceerder", + "build_sam_launcher_desc": "Bouw een Luchtdoelraket (SAM)-lanceerder onder je cursor.", + "build_atom_bomb": "Bouw Atoombom", + "build_atom_bomb_desc": "Bouw een Atoombom onder je cursor.", + "build_hydrogen_bomb": "Bouw Waterstofbom", + "build_hydrogen_bomb_desc": "Bouw een Waterstofbom onder je cursor.", + "build_mirv": "Bouw MIRV", + "build_mirv_desc": "Bouw een MIRV onder je cursor.", "attack_ratio_controls": "Aanvalsverhouding-bediening", "attack_ratio_up": "Verhoog Aanvalsverhouding", "attack_ratio_up_desc": "Verhoog aanvalsverhouding met 10%", @@ -326,10 +393,10 @@ "on": "Aan", "off": "Uit", "toggle_terrain": "Terrein Aan/Uit", - "terrain_enabled": "Terreinweergave ingeschakeld", - "terrain_disabled": "Terreinweergave uitgeschakeld", "exit_game_label": "Spel Verlaten", - "exit_game_info": "Terug naar hoofdmenu" + "exit_game_info": "Terug naar hoofdmenu", + "background_music_volume": "Volume achtergrondmuziek", + "sound_effects_volume": "Volume geluidseffecten" }, "chat": { "title": "Snelchat", @@ -351,26 +418,31 @@ }, "help": { "troops": "Geef me troepen alsjeblieft!", + "troops_frontlines": "Stuur troepen naar de frontlinies!", "gold": "Geef me goud alsjeblieft!", "no_attack": "Val me niet aan alsjeblieft!", "sorry_attack": "Sorry, ik wilde je niet aanvallen.", "alliance": "Bondgenoten worden?", "help_defend": "Help me tegen [P1] te verdedigen!", - "team_up": "Laten we het opnemen tegen [P1]!" + "trade_partners": "Laten we handelspartners worden!" }, "attack": { "attack": "Val [P1] aan!", "mirv": "Vuur een MIRV af op [P1]!", "focus": "Vuur op [P1]!", - "finish": "Laten we [P1] uitschakelen!" + "finish": "Laten we [P1] uitschakelen!", + "build_warships": "Bouw Oorlogsschepen!" }, "defend": { "defend": "Verdedig [P1]!", + "defend_from": "Verdedig jezelf tegen [P1]!", "dont_attack": "Val [P1] niet aan!", - "ally": "[P1] is mijn bondgenoot!" + "ally": "[P1] is mijn bondgenoot!", + "build_posts": "Bouw Verdedigingsposten!" }, "greet": { "hello": "Hoi", + "good_job": "Goed gedaan!", "good_luck": "Succes!", "have_fun": "Veel plezier!", "gg": "GG!", @@ -381,13 +453,19 @@ "thanks": "Dank je!", "oops": "Oeps, verkeerde knop!", "trust_me": "Je kunt me vertrouwen. Echt!", - "trust_broken": "Ik vertrouwde je..." + "trust_broken": "Ik vertrouwde je...", + "ruining_games": "Je verpest onze beide spellen.", + "dont_do_that": "Doe dat niet!", + "same_team": "Ik sta aan jouw kant!" }, "misc": { "go": "Yes!", "strategy": "Goeie strategie!", "fun": "Dit spel is leuk!", - "pr": "Wanneer wordt mijn pull request eindelijk gemerged?" + "team_up": "Laten we samenwerken tegen [P1]!", + "pr": "Wanneer wordt mijn pull request eindelijk gemerged?", + "build_closer": "Bouw dichterbij om treinen te krijgen!", + "coastline": "Laat mij een kustlijn krijgen alsjeblieft." }, "warnings": { "strong": "[P1] is sterk.", @@ -398,10 +476,14 @@ "has_allies": "[P1] heeft veel bondgenoten.", "no_allies": "[P1] heeft geen bondgenoten.", "betrayed": "[P1] heeft een bondgenoot verraden!", + "betrayed_me": "[P1] heeft me verraden!", "getting_big": "[P1] wordt te snel groter!", "danger_base": "[P1] is onbeschermd!", "saving_for_mirv": "[P1] spaart voor een MIRV.", - "mirv_ready": "[P1] heeft genoeg goud om een MIRV te lanceren!" + "mirv_ready": "[P1] heeft genoeg goud om een MIRV te lanceren!", + "snowballing": "[P1] groeit te snel!", + "cheating": "[P1] speelt vals!", + "stop_trading": "Stop de handel met [P1]!" } }, "build_menu": { @@ -420,6 +502,8 @@ "not_enough_money": "Niet genoeg goud" }, "win_modal": { + "support_openfront": "Steun OpenFront!", + "territory_pattern": "Koop een skin om advertentievrij te gaan!", "died": "Je bent dood", "your_team": "Je team heeft gewonnen!", "other_team": "{team} team heeft gewonnen!", @@ -427,6 +511,7 @@ "other_won": "{player} heeft gewonnen!", "exit": "Verlaat spel", "keep": "Blijf spelen", + "spectate": "Toekijken", "wishlist": "Op je Verlanglijst op Steam!" }, "leaderboard": { @@ -437,15 +522,22 @@ "team": "Team", "owned": "Bezit", "gold": "Goud", - "troops": "Troepen" + "troops": "Troepen", + "launchers": "Raketsilo's", + "sams": "SAM-lanceerders", + "warships": "Oorlogsschepen", + "cities": "Steden", + "show_control": "Toon bezit", + "show_units": "Toon gebouwen" }, "player_info_overlay": { "type": "Type", "bot": "Bot", - "nation": "Natie-bot", + "nation": "Natie", "player": "Mens", "team": "Team", - "d_troops": "Verdedigende troepen", + "alliance_timeout": "Alliantie eindigt over", + "troops": "Troepen", "a_troops": "Aanvallende troepen", "gold": "Goud", "ports": "Havens", @@ -478,7 +570,8 @@ "accept_alliance": "Aanvaarden", "reject_alliance": "Afwijzen", "alliance_renewed": "Je alliantie met {name} is hernieuwd", - "ignore": "Negeren" + "ignore": "Negeren", + "unit_voluntarily_deleted": "Eenheid vrijwillig verwijderd" }, "unit_info_modal": { "structure_info": "Gebouw Info", @@ -489,6 +582,11 @@ "upgrade": "Upgrade", "level": "Level" }, + "player_type": { + "player": "Speler", + "nation": "Natie", + "bot": "Bot" + }, "relation": { "hostile": "Vijandig", "distrustful": "Wantrouwend", @@ -497,10 +595,8 @@ "default": "Standaard" }, "control_panel": { - "pop": "Bevolking", "gold": "Goud", "troops": "Troepen", - "workers": "Werkers", "attack_ratio": "Aanvalsverhouding" }, "player_panel": { @@ -508,20 +604,49 @@ "troops": "Troepen", "betrayals": "Aantal verraadacties", "traitor": "Verrader", + "trading": "Handel", + "active": "Actief", + "stopped": "Gestopt", "alliance_time_remaining": "Alliantie verloopt over", - "embargo": "Handel met jou stopgezet", + "embargo": "Handel met jou stilgelegd", "nuke": "Kernwapens op jou afgevuurd", "start_trade": "Start handel", "stop_trade": "Stop handel", - "yes": "Ja", - "no": "Nee", - "none": "Geen", - "alliances": "Allianties" + "stop_trade_all": "Stop handel met iedereen", + "start_trade_all": "Start handel met iedereen", + "alliances": "Allianties", + "flag": "Vlag", + "chat": "Chat", + "target": "Doelmarkering", + "break": "Verbreek", + "break_alliance": "Verbreek Alliantie", + "alliance": "Alliantie", + "send_alliance": "Stuur Alliantieverzoek", + "send_troops": "Geef Troepen", + "send_gold": "Geef Goud", + "emotes": "Emoji's" + }, + "send_troops_modal": { + "title_with_name": "Stuur Troepen naar {name}", + "available_tooltip": "Jouw nu beschikbare troepen", + "min_keep": "Min. houden", + "min_keep_pct": "(30%)", + "slider_tooltip": "{{percent}}% • {{amount}}", + "toggle_attack_bar_mode": "Gebruik aanvalsverhouding-balk voor het aantal te versturen troepen", + "warning_attackbar": "Eenmaal ingeschakeld kan je deze modus niet rechtstreeks open. Je zult alleen via de aanvalsbalk troepen kunnen sturen.", + "aria_slider": "Troepen schuifbalk", + "capacity_note": "Ontvanger kan slechts {{amount}} accepteren momenteel." + }, + "send_gold_modal": { + "title_with_name": "Stuur Goud naar {name}", + "available_tooltip": "Jouw nu beschikbare goud", + "aria_slider": "Bedrag schuifbalk", + "slider_tooltip": "{{percent}}% • {{amount}}" }, "replay_panel": { "replay_speed": "Afspeelsnelheid", "game_speed": "Spel snelheid", - "fastest_game_speed": "max" + "fastest_game_speed": "Max" }, "error_modal": { "crashed": "Spel gecrasht!", @@ -536,47 +661,22 @@ "choose_spawn": "Kies een startlocatie" }, "territory_patterns": { - "title": "Selecteer gebiedspatroon", + "title": "Skins ", + "colors": "Kleuren", "purchase": "Kopen", "blocked": { - "login": "Je moet ingelogd zijn voor toegang tot dit patroon.", - "purchase": "Koop dit patroon om het te ontgrendelen." + "login": "Je moet ingelogd zijn voor toegang tot deze skin.", + "purchase": "Koop deze skin om te ontgrendelen." }, "pattern": { - "default": "Standaard", - "custom": "Op maat", - "stripes_v": "Verticaal", - "stripes_h": "Horizontaal", - "horizontal_stripes": "Horizontaal (Alt)", - "vertical_bars": "Verticaal (Alt)", - "checkerboard": "Dambord", - "choco": "Choco", - "diagonal": "Diagonaal", - "cross": "Gekruisd", - "mini_cross": "Mini Gekruisd", - "sword": "Zwaard", - "sparse_dots": "Spaarzame Stippen", - "evan": "Evan", - "diagonal_stripe": "Diagonale Streep", - "mountain_ridge": "Bergrug", - "scattered_dots": "Verspreide Stippen", - "circuit_board": "Printplaat", - "shells": "Schelpen", - "-w-": ".w.", - "white_rabbit": "Wit Konijn", - "goat": "Geit", - "cats": "Katten", - "cursor": "Cursor", - "hand": "Hand", - "radiation": "Straling", - "openfront_qr": "OpenFront.io QR-code", - "openfront": "OpenFront", - "t_rex": "T-Rex", - "embelem": "Embleem", - "grogu_head": "Grogu-hoofd", - "grogu": "Grogu" + "default": "Standaard" } }, + "flag_input": { + "title": "Selecteer Vlag", + "button_title": "Kies een vlag!", + "search_flag": "Zoeken..." + }, "spawn_ad": { "loading": "Advertentie laden..." }, @@ -585,5 +685,79 @@ "redirecting": "Je wordt omgeleid...", "not_authorized": "Je bent niet gemachtigd voor toegang tot deze website.", "contact_admin": "Als je denkt dat dit bericht niet klopt, neem dan contact op met de websitebeheerder." + }, + "radial_menu": { + "delete_unit_title": "Verwijder gebouw", + "delete_unit_description": "Klik om het dichtstbijzijnde gebouw te verwijderen" + }, + "discord_user_header": { + "avatar_alt": "Avatar" + }, + "player_stats_table": { + "building_stats": "Bouw-statistieken", + "ship_arrivals": "Scheeps-statistieken", + "nuke_stats": "Kernbom-statistieken", + "player_metrics": "Speler-statistieken", + "building": "Gebouw", + "ship_type": "Scheepstype", + "weapon": "Wapen", + "built": "Gebouwd", + "destroyed": "Verwoest", + "captured": "Veroverd", + "lost": "Verloren", + "hits": "Treffers", + "launched": "Gelanceerd", + "landed": "Geland", + "sent": "Verstuurd", + "arrived": "Aangekomen", + "attack": "Aanval", + "received": "Ontvangen", + "cancelled": "Geannuleerd", + "count": "Aantal", + "gold": "Goud", + "workers": "Werkers", + "war": "Oorlog", + "trade": "Handel", + "steal": "Gestolen", + "unit": { + "city": "Stad", + "port": "Haven", + "defp": "Verdedigingspost", + "saml": "SAM-lanceerder", + "silo": "Raketsilo", + "wshp": "Oorlogsschip", + "fact": "Fabriek", + "trade": "Handelsschip", + "trans": "Transportschip", + "abomb": "Atoombom", + "hbomb": "Waterstofbom", + "mirv": "MIRV", + "mirvw": "MIRV-kernkop" + } + }, + "game_list": { + "recent_games": "Recente spellen", + "game_id": "Spel-ID", + "mode": "Modus", + "mode_ffa": "Iedereen tegen elkaar", + "mode_team": "Team", + "view": "Weergeven", + "details": "Details", + "started": "Begonnen", + "map": "Kaart", + "difficulty": "Moeilijkheidsgraad", + "type": "Type" + }, + "player_stats_tree": { + "public": "Openbaar", + "private": "Privé", + "singleplayer": "Eén Speler", + "mode": "Modus", + "stats_wins": "Overwinningen", + "stats_losses": "Nederlagen", + "stats_wlr": "Winst:verliesverhouding", + "stats_games_played": "Gespeelde spellen", + "mode_ffa": "Iedereen tegen elkaar", + "mode_team": "Team" } } diff --git a/resources/lang/pl.json b/resources/lang/pl.json index a64ed2e70..9ab359593 100644 --- a/resources/lang/pl.json +++ b/resources/lang/pl.json @@ -6,7 +6,18 @@ "lang_code": "pl" }, "common": { - "close": "Zamknij" + "close": "Zamknij", + "available": "Dostępne", + "preset_max": "Maks", + "summary_send": "Wyślij", + "summary_keep": "Zatrzymaj", + "cancel": "Anuluj", + "send": "Wyślij", + "cap_label": "Pojemność", + "cap_tooltip": "Pozostała pojemność odbiorcy", + "target_dead": "Cel wyeliminowany", + "target_dead_note": "Nie możesz wysłać zasobów do wyeliminowanego gracza.", + "none": "Brak" }, "main": { "title": "OpenFront (ALPHA)", @@ -23,7 +34,8 @@ "advertise": "Reklamuj", "wiki": "Wiki", "privacy_policy": "Polityka prywatności", - "terms_of_service": "Regulamin" + "terms_of_service": "Regulamin", + "reddit": "Reddit" }, "news": { "see_all_releases": "Wyświetl wszystkie wersje", @@ -127,13 +139,15 @@ "options_title": "Opcje", "bots": "Boty: ", "bots_disabled": "Wyłączone", + "nations": "Narody: ", "disable_nations": "Wyłącz Państwa", "instant_build": "Natychmiastowa budowa", "infinite_gold": "Nieskończone złoto", - "donate_gold": "Wyślij złoto", "infinite_troops": "Nieskończone wojsko", - "donate_troops": "Wyślij jednostki", + "compact_map": "Mini Mapa", + "max_timer": "Długość gry (minuty)", "disable_nukes": "Wyłącz broń nuklearną", + "automatic_difficulty": "Automatyczna Trudność", "enables_title": "Włącz ustawienia", "start": "Rozpocznij Grę" }, @@ -145,6 +159,7 @@ "account_modal": { "title": "Konto", "logged_in_as": "Zalogowany jako {email}", + "fetching_account": "Pobieranie informacji o koncie...", "logged_in_with_discord": "Zalogowany przez Discord'a", "recovery_email_sent": "Wysłano e-mail odzyskiwania na adres {email}" }, @@ -174,11 +189,15 @@ "europeclassic": "Europa (klasyczna)", "falklandislands": "Falklandy", "baikal": "Bajkał", - "halkidiki": "Półwysep Chalcydycki\n", + "halkidiki": "Półwysep Chalcydycki", "straitofgibraltar": "Cieśnina Gibraltarska", "italia": "Włochy", + "japan": "Japonia", "yenisei": "Jenisej", - "pluto": "Pluton" + "pluto": "Pluton", + "montreal": "Montreal", + "achiran": "Achiran", + "baikalnukewars": "Bajkał (Wojny Atomowe)" }, "map_categories": { "continental": "Kontynentalny", @@ -196,8 +215,9 @@ "join_lobby": "Dołącz do Lobby", "checking": "Sprawdzanie lobby...", "not_found": "Lobby nie zostało znalezione. Proszę sprawdzić ID i spróbować ponownie.", - "error": "Wystąpił błąd. Proszę spróbować ponownie.", - "joined_waiting": "Dołączono pomyślnie! Oczekiwanie na rozpoczęcie gry..." + "error": "Wystąpił błąd. Spróbuj ponownie lub skontaktuj się z pomocą techniczną.", + "joined_waiting": "Dołączono pomyślnie! Oczekiwanie na rozpoczęcie gry...", + "version_mismatch": "Ta gra została utworzona w innej wersji. Nie można dołączyć." }, "public_lobby": { "join": "Dołącz do następnej gry", @@ -205,8 +225,15 @@ "teams_Duos": "Duety (zespoły 2-osobowe)", "teams_Trios": "Trójki (zespoły 3-osobowe)", "teams_Quads": "Czwórki (zespoły 4-osobowe)", + "teams_hvn": "Ludzie Kontra Narody", "teams": "{num} drużyn" }, + "matchmaking_modal": { + "title": "Wyszukiwanie meczy", + "connecting": "Łączenie z serwerem wyszukiwania meczów...", + "searching": "Wyszukiwanie gier...", + "waiting_for_game": "Oczekiwanie na rozpoczęcie gry..." + }, "username": { "enter_username": "Wprowadź swoją nazwę użytkownika", "not_string": "Nazwa użytkownika musi być ciągiem znaków.", @@ -221,12 +248,16 @@ "options_title": "Opcje", "bots": "Boty: ", "bots_disabled": "Wyłączone", + "nations": "Narody: ", "disable_nations": "Wyłącz Państwa", + "max_timer": "Długość gry (minuty)", "instant_build": "Natychmiastowa budowa", "infinite_gold": "Nieskończone złoto", "donate_gold": "Wyślij złoto", "infinite_troops": "Nieskończone wojsko", "donate_troops": "Wyślij jednostki", + "compact_map": "Mini Mapa", + "automatic_difficulty": "Automatyczna Trudność", "enables_title": "Włącz ustawienia", "player": "Gracz", "players": "Gracze", @@ -246,7 +277,8 @@ }, "game_starting_modal": { "title": "Gra się rozpoczyna...", - "desc": "Przygotowanie do uruchomienia lobby. Proszę czekać." + "credits": "Autorzy", + "code_license": "Kod licencjonowany zgodnie z AGPL-3.0 (bez gwarancji)" }, "difficulty": { "difficulty": "Poziom trudności", @@ -280,33 +312,21 @@ "tab_keybinds": "Skróty klawiszowe", "dark_mode_label": "Tryb ciemny", "dark_mode_desc": "Przełącz wygląd strony pomiędzy jasnym a ciemnym trybem", - "dark_mode_enabled": "Tryb ciemny włączony", - "light_mode_enabled": "Tryb jasny włączony", "emojis_label": "Emotikony", - "emojis_visible": "Emotikony są widoczne", - "emojis_hidden": "Emotikony są ukryte", "emojis_desc": "Przełącz, czy emotki mają być wyświetlane w grze", "alert_frame_label": "Ramka ostrzeżenia", "alert_frame_desc": "Przełącz ramkę ostrzeżenia. Po włączeniu ramka wyświetli się, gdy zostaniesz zdradzony.", "special_effects_label": "Efekty specjalne", "special_effects_desc": "Włącz/wyłącz efekty specjalne. Wyłącz, żeby gra działała płynniej", - "special_effects_enabled": "Specjalne efekty włączone", - "special_effects_disabled": "Specjalne efekty wyłączone", "structure_sprites_label": "Grafiki budynków", "structure_sprites_desc": "Przełącz grafikę struktur", - "structure_sprites_enabled": "Włączono grafikę struktur", - "structure_sprites_disabled": "Wyłączono grafikę struktur", "anonymous_names_label": "Ukryte Nazwy", "anonymous_names_desc": "Ukryj prawdziwe nazwy graczy, zastępując je losowymi na swoim ekranie.", - "anonymous_names_enabled": "Anonimowe nazwy włączone", "lobby_id_visibility_label": "Ukryte ID lobby", "lobby_id_visibility_desc": "Ukryj ID lobby podczas tworzenia prywatnej poczekalni", - "real_names_shown": "Prawdziwe nazwy widoczne", "left_click_label": "Kliknij lewym przyciskiem myszy, aby otworzyć menu", "left_click_desc": "Gdy WŁĄCZONE, lewy przycisk myszy otwiera menu, a przycisk z mieczem atakuje. Gdy WYŁĄCZONE, lewy przycisk myszy atakuje bezpośrednio.", "left_click_menu": "Menu lewego przycisku myszy", - "left_click_opens_menu": "Lewy przycisk myszy otwiera menu", - "right_click_opens_menu": "Prawy przycisk myszy otwiera menu", "attack_ratio_label": "⚔️ Współczynnik ataku", "attack_ratio_desc": "Jaki procent swoich żołnierzy chcesz wysłać do ataku (1–100%)", "troop_ratio_desc": "Dostosuj balans między żołnierzami (do walki) a pracownikami (do produkcji złota) (1–100%)", @@ -321,6 +341,27 @@ "view_options": "Opcje widoku", "toggle_view": "Przełącz widok", "toggle_view_desc": "Alternatywny widok (teren/państwa)", + "build_controls": "Skróty Klawiszowe Budowania", + "build_city": "Zbuduj miasto", + "build_city_desc": "Zbuduj miasto pod twoim kursorem.", + "build_factory": "Zbuduj fabrykę", + "build_factory_desc": "Zbuduj fabrykę pod twoim kursorem.", + "build_defense_post": "Buduj posterunek obronny", + "build_defense_post_desc": "Zbuduj posterunek obronny pod twoim kursorem.", + "build_port": "Zbuduj port", + "build_port_desc": "Zbuduj port pod twoim kursorem.", + "build_warship": "Zbuduj okręt wojenny", + "build_warship_desc": "Zbuduj okręt wojenny pod twoim kursorem.", + "build_missile_silo": "Zbuduj silos rakietowy", + "build_missile_silo_desc": "Zbuduj silos rakietowy pod twoim kursorem.", + "build_sam_launcher": "Zbuduj wyrzutnię SAM", + "build_sam_launcher_desc": "Zbuduj wyrzutnię SAM pod twoim kursorem.", + "build_atom_bomb": "Zbuduj Bombę Atomową", + "build_atom_bomb_desc": "Zbuduj Bombę Atomową pod twoim kursorem.", + "build_hydrogen_bomb": "Zbuduj Bombę Wodorową", + "build_hydrogen_bomb_desc": "Zbuduj Bombę Wodorową pod twoim kursorem.", + "build_mirv": "Zbuduj MIRV", + "build_mirv_desc": "Zbuduj MIRV pod twoim kursorem.", "attack_ratio_controls": "Sterowanie współczynnikiem ataku", "attack_ratio_up": "Zwiększ współczynnik ataku", "attack_ratio_up_desc": "Zwiększ współczynnik ataku o 10%", @@ -352,10 +393,10 @@ "on": "Włączone", "off": "Wyłączone", "toggle_terrain": "Przełącz teren", - "terrain_enabled": "Widok terenu włączony", - "terrain_disabled": "Widok terenu wyłączony", "exit_game_label": "Wyjdź z gry", - "exit_game_info": "Powrót do menu głównego" + "exit_game_info": "Powrót do menu głównego", + "background_music_volume": "Głośność muzyki w tle", + "sound_effects_volume": "Głośność efektów dźwiękowych" }, "chat": { "title": "Szybka rozmowa", @@ -462,7 +503,7 @@ }, "win_modal": { "support_openfront": "Wesprzyj OpenFront!", - "territory_pattern": "Kup wzór terytorium, aby wesprzeć OpenFront!", + "territory_pattern": "Kup skórkę na terytorium, aby zwolnić z reklam!", "died": "Nie żyjesz", "your_team": "Twoja drużyna wygrała!", "other_team": "Drużyna {team} wygrała!", @@ -470,6 +511,7 @@ "other_won": "{player} wygrał!", "exit": "Wyjdź z gry", "keep": "Graj dalej", + "spectate": "Obserwuj", "wishlist": "Dodaj do listy życzeń na Steam!" }, "leaderboard": { @@ -494,7 +536,8 @@ "nation": "Naród", "player": "Gracz", "team": "Drużyna", - "d_troops": "Jednostki Obronne", + "alliance_timeout": "Sojusz kończy się za", + "troops": "Wojsko", "a_troops": "Atakujące wojska", "gold": "Złoto", "ports": "Porty", @@ -539,6 +582,11 @@ "upgrade": "Ulepsz", "level": "Poziom" }, + "player_type": { + "player": "Gracz", + "nation": "Naród", + "bot": "Bot" + }, "relation": { "hostile": "Nieprzyjazny", "distrustful": "Nieufne", @@ -554,23 +602,51 @@ "player_panel": { "gold": "Złoto", "troops": "Wojsko", - "betrayals": "Liczba zdrad", + "betrayals": "Liczba Zdrad", "traitor": "Zdrajca", + "trading": "Handel", + "active": "Aktywny", + "stopped": "Zatrzymany", "alliance_time_remaining": "Sojusz wygasa za", "embargo": "Zaprzestali wymiany handlowej z tobą", "nuke": "Bomby nuklearne, które oni wysłali w twoją stronę", "start_trade": "Rozpocznij Handel", "stop_trade": "Zatrzymaj handel", - "yes": "Tak", - "no": "Nie", - "none": "Brak", + "stop_trade_all": "Zatrzymaj handel ze Wszystkimi", + "start_trade_all": "Rozpocznij handel ze Wszystkimi", "alliances": "Sojusze", - "flag": "Flaga" + "flag": "Flaga", + "chat": "Czat", + "target": "Cel", + "break": "Zerwij", + "break_alliance": "Zerwij Sojusz", + "alliance": "Sojusz", + "send_alliance": "Poproś o Sojusz", + "send_troops": "Wyślij wojska", + "send_gold": "Wyślij Złoto", + "emotes": "Emotikony" + }, + "send_troops_modal": { + "title_with_name": "Wyślij wojsko do {name}", + "available_tooltip": "Obecnie dostępne wojsko", + "min_keep": "Min. utrzymanie", + "min_keep_pct": "(30%)", + "slider_tooltip": "{{percent}}% • {{amount}}", + "toggle_attack_bar_mode": "Użyj suwaka ataku, aby wysłać wojsko", + "warning_attackbar": "Po włączeniu tej opcji nie możesz bezpośrednio otworzyć tego modułu. Będziesz wysyłał wojsko tylko przez suwak ataku.", + "aria_slider": "Suwak Jednostek", + "capacity_note": "Odbiorca może teraz zaakceptować tylko {{amount}}." + }, + "send_gold_modal": { + "title_with_name": "Wyślij Złoto do {name}", + "available_tooltip": "Obecnie dostępne złoto", + "aria_slider": "Suwak Ilości Złota", + "slider_tooltip": "{{percent}}% • {{amount}}" }, "replay_panel": { "replay_speed": "Prędkość powtórki", "game_speed": "Prędkość gry", - "fastest_game_speed": "maks" + "fastest_game_speed": "Maksymalna" }, "error_modal": { "crashed": "Gra uległa awarii!", @@ -585,46 +661,15 @@ "choose_spawn": "Wybierz lokalizację początkową" }, "territory_patterns": { - "title": "Wybierz wzór terytorium", + "title": "Skórki", + "colors": "Kolory", "purchase": "Kup", "blocked": { "login": "Musisz być zalogowany, aby uzyskać dostęp do tego wzoru.", "purchase": "Kup ten wzór, aby go odblokować." }, "pattern": { - "default": "Domyślny", - "custom": "Niestandardowy", - "stripes_v": "Pionowy", - "stripes_h": "Poziomy", - "horizontal_stripes": "Poziomy (Alt)", - "vertical_bars": "Pionowy (Alt)", - "checkerboard": "Szachownica", - "choco": "Czekolada", - "diagonal": "Skośny", - "cross": "Krzyż", - "mini_cross": "Mini krzyż", - "sword": "Miecz", - "sparse_dots": "Rzadkie kropki", - "evan": "Evan", - "diagonal_stripe": "Skośne pasy", - "mountain_ridge": "Grzbiet górski", - "scattered_dots": "Rozproszone kropki", - "circuit_board": "Płytka drukowana", - "shells": "Muszle", - "-w-": ".w.", - "white_rabbit": "Biały królik", - "goat": "Koza", - "cats": "Koty", - "cursor": "Kursor", - "hand": "Ręka", - "radiation": "Promieniowanie", - "openfront_qr": "Kod QR OpenFront.io", - "openfront": "OpenFront", - "t_rex": "T-Rex", - "embelem": "Emblemat", - "contributor": "Współtwórca", - "grogu_head": "Głowa Grogu", - "grogu": "Grogu" + "default": "Domyślny" } }, "flag_input": { @@ -644,5 +689,75 @@ "radial_menu": { "delete_unit_title": "Usuń jednostkę", "delete_unit_description": "Kliknij, aby usunąć najbliższą jednostkę" + }, + "discord_user_header": { + "avatar_alt": "Awatar" + }, + "player_stats_table": { + "building_stats": "Statystyki budynków", + "ship_arrivals": "Przybycia statków", + "nuke_stats": "Statystyki bomb", + "player_metrics": "Statystyki Gracza", + "building": "Budynek", + "ship_type": "Typ Statku", + "weapon": "Broń", + "built": "Wybudowano", + "destroyed": "Zniszczono", + "captured": "Przechwycono", + "lost": "Utracono", + "hits": "Trafienia", + "launched": "Wysłane", + "landed": "Wylądowano", + "sent": "Wysłano", + "arrived": "Dostarczono", + "attack": "Atak", + "received": "Otrzymano", + "cancelled": "Anulowano", + "count": "Ilość", + "gold": "Złoto", + "workers": "Robotnicy", + "war": "Wojna", + "trade": "Handel", + "steal": "Kradzież", + "unit": { + "city": "Miasto", + "port": "Port", + "defp": "Punkt Obronny", + "saml": "Wyrzutnia SAM", + "silo": "Silos rakietowy", + "wshp": "Okręt wojenny", + "fact": "Fabryka", + "trade": "Statek Handlowy", + "trans": "Statek Transportowy", + "abomb": "Bomba atomowa", + "hbomb": "Bomba wodorowa", + "mirv": "MIRV", + "mirvw": "Głowica MIRV" + } + }, + "game_list": { + "recent_games": "Ostatnie Rozgrywki", + "game_id": "Identyfikator Gry", + "mode": "Tryb", + "mode_ffa": "Każdy na Każdego", + "mode_team": "Drużyna", + "view": "Widok", + "details": "Szczegóły", + "started": "Rozpoczęto", + "map": "Mapa", + "difficulty": "Poziom trudności", + "type": "Rodzaj" + }, + "player_stats_tree": { + "public": "Publiczne", + "private": "Prywatne", + "singleplayer": "Gra Jednoosobowa", + "mode": "Tryb", + "stats_wins": "Wygrane", + "stats_losses": "Przegrane", + "stats_wlr": "Współczynnik Wygranych do Przegranych", + "stats_games_played": "Gry zagrane", + "mode_ffa": "Każdy na Każdego", + "mode_team": "Drużyna" } } diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index e20a9613e..226b15414 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -5,19 +5,14 @@ import { PlayerStatsTree, UserMeResponse, } from "../core/ApiSchemas"; +import { fetchPlayerById, getUserMe } from "./Api"; +import { discordLogin, logOut, sendMagicLink } from "./Auth"; import "./components/baseComponents/stats/DiscordUserHeader"; import "./components/baseComponents/stats/GameList"; import "./components/baseComponents/stats/PlayerStatsTable"; import "./components/baseComponents/stats/PlayerStatsTree"; import "./components/Difficulties"; import "./components/PatternButton"; -import { - discordLogin, - fetchPlayerById, - getApiBase, - getUserMe, - logOut, -} from "./jwt"; import { isInIframe, translateText } from "./Utils"; @customElement("account-modal") @@ -30,10 +25,7 @@ export class AccountModal extends LitElement { @state() private email: string = ""; @state() private isLoadingUser: boolean = false; - private loggedInEmail: string | null = null; - private loggedInDiscord: string | null = null; private userMeResponse: UserMeResponse | null = null; - private playerId: string | null = null; private statsTree: PlayerStatsTree | null = null; private recentGames: PlayerGame[] = []; @@ -44,8 +36,7 @@ export class AccountModal extends LitElement { const customEvent = event as CustomEvent; if (customEvent.detail) { this.userMeResponse = customEvent.detail as UserMeResponse; - this.playerId = this.userMeResponse?.player?.publicId; - if (this.playerId === undefined) { + if (this.userMeResponse?.player?.publicId === undefined) { this.statsTree = null; this.recentGames = []; } @@ -67,31 +58,90 @@ export class AccountModal extends LitElement { id="account-modal" title="${translateText("account_modal.title") || "Account"}" > - ${this.renderInner()} + ${this.isLoadingUser + ? html` +
+

+ ${translateText("account_modal.fetching_account")} +

+
+
+ ` + : this.renderInner()} `; } private renderInner() { - if (this.isLoadingUser) { - return html` -
-

${translateText("account_modal.fetching_account")}

-
-
- `; - } - if (this.loggedInDiscord) { - return this.renderLoggedInDiscord(); - } else if (this.loggedInEmail) { - return this.renderLoggedInEmail(); + if (this.userMeResponse?.user) { + return this.renderAccountInfo(); } else { return this.renderLoginOptions(); } } + private renderAccountInfo() { + return html` +
+
+

+ ${translateText("account_modal.player_id", { + id: + this.userMeResponse?.player?.publicId ?? + translateText("account_modal.not_found"), + })} +

+
+
+

${this.renderLoggedInAs()}

+
+
+ +
+ ${this.renderPlayerStats()} +
+ `; + } + + private renderLoggedInAs(): TemplateResult { + const me = this.userMeResponse?.user; + if (me?.discord) { + return html`

+ ${translateText("account_modal.linked_account", { + account_name: me.discord.global_name ?? "", + })} +

+ ${this.renderLogoutButton()}`; + } else if (me?.email) { + return html`

+ ${translateText("account_modal.linked_account", { + account_name: me.email, + })} +

+ ${this.renderLogoutButton()}`; + } + return this.renderLoginOptions(); + } + + private renderPlayerStats(): TemplateResult { + return html` + +
+ this.viewGame(id)} + > + `; + } + private viewGame(gameId: string): void { this.close(); const path = location.pathname; @@ -103,46 +153,7 @@ export class AccountModal extends LitElement { window.dispatchEvent(new HashChangeEvent("hashchange")); } - private renderLoggedInDiscord() { - return html` -
-
-

- Logged in with Discord as ${this.loggedInDiscord} -

- ${this.logoutButton()} -
-
- - -
- this.viewGame(id)} - > -
-
- `; - } - - private renderLoggedInEmail(): TemplateResult { - return html` -
-
-

- Logged in as ${this.loggedInEmail} -

-
- ${this.logoutButton()} -
- `; - } - - private logoutButton(): TemplateResult { + private renderLogoutButton(): TemplateResult { return html` `; } @@ -235,41 +247,19 @@ export class AccountModal extends LitElement { private async handleSubmit() { if (!this.email) { - alert("Please enter an email address"); + alert(translateText("account_modal.enter_email_address")); return; } - try { - const apiBase = getApiBase(); - const response = await fetch(`${apiBase}/magic-link`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - redirectDomain: window.location.origin, + const success = await sendMagicLink(this.email); + if (success) { + alert( + translateText("account_modal.recovery_email_sent", { email: this.email, }), - }); - - if (response.ok) { - alert( - translateText("account_modal.recovery_email_sent", { - email: this.email, - }), - ); - this.close(); - } else { - console.error( - "Failed to send recovery email:", - response.status, - response.statusText, - ); - alert("Failed to send recovery email. Please try again."); - } - } catch (error) { - console.error("Error sending recovery email:", error); - alert("Error sending recovery email. Please try again."); + ); + } else { + alert(translateText("account_modal.failed_to_send_recovery_email")); } } @@ -284,14 +274,10 @@ export class AccountModal extends LitElement { void getUserMe() .then((userMe) => { if (userMe) { - this.loggedInEmail = userMe.user.email ?? null; - this.loggedInDiscord = userMe.user.discord?.global_name ?? null; - if (this.playerId) { - this.loadFromApi(this.playerId); + this.userMeResponse = userMe; + if (this.userMeResponse?.player?.publicId) { + this.loadPlayerProfile(this.userMeResponse.player.publicId); } - } else { - this.loggedInEmail = null; - this.loggedInDiscord = null; } this.isLoadingUser = false; this.requestUpdate(); @@ -315,9 +301,9 @@ export class AccountModal extends LitElement { window.location.reload(); } - private async loadFromApi(playerId: string): Promise { + private async loadPlayerProfile(publicId: string): Promise { try { - const data = await fetchPlayerById(playerId); + const data = await fetchPlayerById(publicId); if (!data) { this.requestUpdate(); return; @@ -382,11 +368,11 @@ export class AccountButton extends LitElement { let buttonTitle = ""; if (this.loggedInEmail) { - buttonTitle = translateText("account_modal.logged_in_as", { - email: this.loggedInEmail, + buttonTitle = translateText("account_modal.linked_account", { + account_name: this.loggedInEmail, }); } else if (this.loggedInDiscord) { - buttonTitle = translateText("account_modal.logged_in_with_discord"); + buttonTitle = translateText("account_modal.linked_account"); } return html` diff --git a/src/client/Api.ts b/src/client/Api.ts new file mode 100644 index 000000000..a02ebe5c9 --- /dev/null +++ b/src/client/Api.ts @@ -0,0 +1,144 @@ +import { z } from "zod"; +import { + PlayerProfile, + PlayerProfileSchema, + UserMeResponse, + UserMeResponseSchema, +} from "../core/ApiSchemas"; +import { getAuthHeader, logOut, userAuth } from "./Auth"; + +export async function fetchPlayerById( + playerId: string, +): Promise { + try { + const userAuthResult = await userAuth(); + if (!userAuthResult) return false; + const { jwt } = userAuthResult; + + const url = `${getApiBase()}/player/${encodeURIComponent(playerId)}`; + + const res = await fetch(url, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${jwt}`, + }, + }); + + if (res.status !== 200) { + console.warn( + "fetchPlayerById: unexpected status", + res.status, + res.statusText, + ); + return false; + } + + const json = await res.json(); + const parsed = PlayerProfileSchema.safeParse(json); + if (!parsed.success) { + console.warn("fetchPlayerById: Zod validation failed", parsed.error); + return false; + } + + return parsed.data; + } catch (err) { + console.warn("fetchPlayerById: request failed", err); + return false; + } +} +export async function getUserMe(): Promise { + try { + const userAuthResult = await userAuth(); + if (!userAuthResult) return false; + const { jwt } = userAuthResult; + + // Get the user object + const response = await fetch(getApiBase() + "/users/@me", { + headers: { + authorization: `Bearer ${jwt}`, + }, + }); + if (response.status === 401) { + await logOut(); + return false; + } + if (response.status !== 200) return false; + const body = await response.json(); + const result = UserMeResponseSchema.safeParse(body); + if (!result.success) { + const error = z.prettifyError(result.error); + console.error("Invalid response", error); + return false; + } + return result.data; + } catch (e) { + return false; + } +} + +export async function createCheckoutSession( + priceId: string, + colorPaletteName: string | null, +): Promise { + try { + const response = await fetch( + `${getApiBase()}/stripe/create-checkout-session`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: await getAuthHeader(), + }, + body: JSON.stringify({ + priceId: priceId, + hostname: window.location.origin, + colorPaletteName: colorPaletteName, + }), + }, + ); + if (!response.ok) { + console.error( + "createCheckoutSession: request failed", + response.status, + response.statusText, + ); + return false; + } + const json = await response.json(); + return json.url; + } catch (e) { + console.error("createCheckoutSession: request failed", e); + return false; + } +} + +export function getApiBase() { + const domainname = getAudience(); + + if (domainname === "localhost") { + const apiDomain = process?.env?.API_DOMAIN; + if (apiDomain) { + return `https://${apiDomain}`; + } + return localStorage.getItem("apiHost") ?? "http://localhost:8787"; + } + + return `https://api.${domainname}`; +} + +export function getAudience() { + const { hostname } = new URL(window.location.href); + const domainname = hostname.split(".").slice(-2).join("."); + return domainname; +} + +// Check if the user's account is linked to a Discord or email account. +export function hasLinkedAccount( + userMeResponse: UserMeResponse | false, +): boolean { + return ( + userMeResponse !== false && + (userMeResponse.user?.discord !== undefined || + userMeResponse.user?.email !== undefined) + ); +} diff --git a/src/client/Auth.ts b/src/client/Auth.ts new file mode 100644 index 000000000..fbc46f121 --- /dev/null +++ b/src/client/Auth.ts @@ -0,0 +1,222 @@ +import { decodeJwt } from "jose"; +import { z } from "zod"; +import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; +import { base64urlToUuid } from "../core/Base64"; +import { getApiBase, getAudience } from "./Api"; +import { generateCryptoRandomUUID } from "./Utils"; + +export type UserAuth = { jwt: string; claims: TokenPayload } | false; + +const PERSISTENT_ID_KEY = "player_persistent_id"; + +let __jwt: string | null = null; + +export function discordLogin() { + const redirectUri = encodeURIComponent(window.location.href); + window.location.href = `${getApiBase()}/auth/login/discord?redirect_uri=${redirectUri}`; +} + +export async function tempTokenLogin(token: string): Promise { + const response = await fetch( + `${getApiBase()}/auth/login/token?login-token=${token}`, + { + credentials: "include", + }, + ); + if (response.status !== 200) { + console.error("Token login failed", response); + return null; + } + const json = await response.json(); + const { email } = json; + return email; +} + +export async function getAuthHeader(): Promise { + const userAuthResult = await userAuth(); + if (!userAuthResult) return ""; + const { jwt } = userAuthResult; + return `Bearer ${jwt}`; +} + +export async function logOut(allSessions: boolean = false): Promise { + try { + const response = await fetch( + getApiBase() + (allSessions ? "/auth/revoke" : "/auth/logout"), + { + method: "POST", + credentials: "include", + }, + ); + + if (response.ok === false) { + console.error("Logout failed", response); + return false; + } + + return true; + } catch (e) { + console.error("Logout failed", e); + return false; + } finally { + __jwt = null; + localStorage.removeItem(PERSISTENT_ID_KEY); + } +} + +export async function isLoggedIn(): Promise { + const userAuthResult = await userAuth(); + return userAuthResult !== false; +} + +export async function userAuth( + shouldRefresh: boolean = true, +): Promise { + try { + const jwt = __jwt; + if (!jwt) { + if (!shouldRefresh) { + console.warn("No JWT found and shouldRefresh is false"); + return false; + } + console.log("No JWT found"); + await refreshJwt(); + return userAuth(false); + } + + // Verify the JWT (requires browser support) + // const jwks = createRemoteJWKSet( + // new URL(getApiBase() + "/.well-known/jwks.json"), + // ); + // const { payload, protectedHeader } = await jwtVerify(token, jwks, { + // issuer: getApiBase(), + // audience: getAudience(), + // }); + + const payload = decodeJwt(jwt); + const { iss, aud, exp } = payload; + + if (iss !== getApiBase()) { + // JWT was not issued by the correct server + console.error('unexpected "iss" claim value'); + logOut(); + return false; + } + const myAud = getAudience(); + if (myAud !== "localhost" && aud !== myAud) { + // JWT was not issued for this website + console.error('unexpected "aud" claim value'); + logOut(); + return false; + } + const now = Math.floor(Date.now() / 1000); + if (exp !== undefined && now >= exp - 3 * 60) { + console.log("jwt expired or about to expire"); + if (!shouldRefresh) { + console.error("jwt expired and shouldRefresh is false"); + return false; + } + await refreshJwt(); + + // Try to get login info again after refreshing + return userAuth(false); + } + + const result = TokenPayloadSchema.safeParse(payload); + if (!result.success) { + const error = z.prettifyError(result.error); + console.error("Invalid payload", error); + return false; + } + + const claims = result.data; + return { jwt, claims }; + } catch (e) { + console.error("isLoggedIn failed", e); + return false; + } +} + +async function refreshJwt(): Promise { + try { + console.log("Refreshing jwt"); + const response = await fetch(getApiBase() + "/auth/refresh", { + method: "POST", + credentials: "include", + }); + if (response.status !== 200) { + console.error("Refresh failed", response); + logOut(); + return; + } + const json = await response.json(); + const { jwt } = json; + console.log("Refresh succeeded"); + __jwt = jwt; + } catch (e) { + console.error("Refresh failed", e); + logOut(); + return; + } +} + +export async function sendMagicLink(email: string): Promise { + try { + const apiBase = getApiBase(); + const response = await fetch(`${apiBase}/auth/magic-link`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + redirectDomain: window.location.origin, + email: email, + }), + }); + + if (response.ok) { + return true; + } else { + console.error( + "Failed to send recovery email:", + response.status, + response.statusText, + ); + return false; + } + } catch (error) { + console.error("Error sending recovery email:", error); + return false; + } +} + +// WARNING: DO NOT EXPOSE THIS ID +export async function getPlayToken(): Promise { + const result = await userAuth(); + if (result !== false) return result.jwt; + return getPersistentIDFromLocalStorage(); +} + +// WARNING: DO NOT EXPOSE THIS ID +export function getPersistentID(): string { + const jwt = __jwt; + if (!jwt) return getPersistentIDFromLocalStorage(); + const payload = decodeJwt(jwt); + const sub = payload.sub; + if (!sub) return getPersistentIDFromLocalStorage(); + return base64urlToUuid(sub); +} + +// WARNING: DO NOT EXPOSE THIS ID +function getPersistentIDFromLocalStorage(): string { + // Try to get existing localStorage + const value = localStorage.getItem(PERSISTENT_ID_KEY); + if (value) return value; + + // If no localStorage exists, create new ID and set localStorage + const newID = generateCryptoRandomUUID(); + localStorage.setItem(PERSISTENT_ID_KEY, newID); + + return newID; +} diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index ecfb4c6ea..5089b6be2 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -26,6 +26,7 @@ import { GameView, PlayerView } from "../core/game/GameView"; import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader"; import { UserSettings } from "../core/game/UserSettings"; import { WorkerClient } from "../core/worker/WorkerClient"; +import { getPersistentID } from "./Auth"; import { AutoUpgradeEvent, DoBoatAttackEvent, @@ -36,7 +37,6 @@ import { TickMetricsEvent, } from "./InputHandler"; import { endGame, startGame, startTime } from "./LocalPersistantStats"; -import { getPersistentID } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { SendAttackIntentEvent, @@ -57,7 +57,6 @@ export interface LobbyConfig { playerName: string; clientID: ClientID; gameID: GameID; - token: string; turnstileToken: string | null; // GameStartInfo only exists when playing a singleplayer game. gameStartInfo?: GameStartInfo; @@ -238,7 +237,7 @@ export class ClientGameRunner { this.lastMessageTime = Date.now(); } - private saveGame(update: WinUpdate) { + private async saveGame(update: WinUpdate) { if (this.myPlayer === null) { return; } diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 8eeb1f74d..821d8e25e 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -5,8 +5,7 @@ import { CosmeticsSchema, Pattern, } from "../core/CosmeticSchemas"; -import { getApiBase, getAuthHeader } from "./jwt"; -import { getPersistentID } from "./Main"; +import { createCheckoutSession, getApiBase } from "./Api"; export async function handlePurchase( pattern: Pattern, @@ -17,37 +16,15 @@ export async function handlePurchase( return; } - const response = await fetch( - `${getApiBase()}/stripe/create-checkout-session`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - authorization: getAuthHeader(), - "X-Persistent-Id": getPersistentID(), - }, - body: JSON.stringify({ - priceId: pattern.product.priceId, - hostname: window.location.origin, - colorPaletteName: colorPalette?.name, - }), - }, + const url = await createCheckoutSession( + pattern.product.priceId, + colorPalette?.name ?? null, ); - - if (!response.ok) { - console.error( - `Error purchasing pattern:${response.status} ${response.statusText}`, - ); - if (response.status === 401) { - alert("You are not logged in. Please log in to purchase a pattern."); - } else { - alert("Something went wrong. Please try again later."); - } + if (url === false) { + alert("Failed to create checkout session."); return; } - const { url } = await response.json(); - // Redirect to Stripe checkout window.location.href = url; } diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 2c89e9804..300ea08a9 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -4,10 +4,10 @@ import { translateText } from "../client/Utils"; import { GameInfo, GameRecordSchema } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { getApiBase } from "./Api"; import { JoinLobbyEvent } from "./Main"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; -import { getApiBase } from "./jwt"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends LitElement { @query("o-modal") private modalEl!: HTMLElement & { diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index d813c52a3..cba428739 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -17,9 +17,9 @@ import { getClanTag, replacer, } from "../core/Util"; +import { getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { ReplaySpeedChangeEvent } from "./InputHandler"; -import { getPersistentID } from "./Main"; import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; export class LocalServer { diff --git a/src/client/Main.ts b/src/client/Main.ts index 1cf5ff543..0461690d1 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -7,6 +7,8 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; +import { getUserMe } from "./Api"; +import { userAuth } from "./Auth"; import { joinLobby } from "./ClientGameRunner"; import { fetchCosmetics } from "./Cosmetics"; import "./DarkModeButton"; @@ -36,14 +38,9 @@ import { SendKickPlayerIntentEvent } from "./Transport"; import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; import { UsernameInput } from "./UsernameInput"; -import { - generateCryptoRandomUUID, - incrementGamesPlayed, - isInIframe, -} from "./Utils"; +import { incrementGamesPlayed, isInIframe } from "./Utils"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; -import { getUserMe, isLoggedIn } from "./jwt"; import "./styles.css"; declare global { @@ -115,7 +112,7 @@ class Client { constructor() {} - initialize(): void { + async initialize(): Promise { // Prefetch turnstile token so it is available when // the user joins a lobby. this.turnstileTokenPromise = getTurnstileToken(); @@ -284,7 +281,7 @@ class Client { } }; - if (isLoggedIn() === false) { + if ((await userAuth()) === false) { // Not logged in onUserMe(false); } else { @@ -498,7 +495,6 @@ class Client { }, turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getCurrentUsername() ?? "", - token: getPlayToken(), clientID: lobby.clientID, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, @@ -650,46 +646,6 @@ document.addEventListener("DOMContentLoaded", () => { new Client().initialize(); }); -// WARNING: DO NOT EXPOSE THIS ID -export function getPlayToken(): string { - const result = isLoggedIn(); - if (result !== false) return result.token; - return getPersistentIDFromCookie(); -} - -// WARNING: DO NOT EXPOSE THIS ID -export function getPersistentID(): string { - const result = isLoggedIn(); - if (result !== false) return result.claims.sub; - return getPersistentIDFromCookie(); -} - -// WARNING: DO NOT EXPOSE THIS ID -function getPersistentIDFromCookie(): string { - const COOKIE_NAME = "player_persistent_id"; - - // Try to get existing cookie - const cookies = document.cookie.split(";"); - for (const cookie of cookies) { - const [cookieName, cookieValue] = cookie.split("=").map((c) => c.trim()); - if (cookieName === COOKIE_NAME) { - return cookieValue; - } - } - - // If no cookie exists, create new ID and set cookie - const newID = generateCryptoRandomUUID(); - document.cookie = [ - `${COOKIE_NAME}=${newID}`, - `max-age=${5 * 365 * 24 * 60 * 60}`, // 5 years - "path=/", - "SameSite=Strict", - "Secure", - ].join(";"); - - return newID; -} - async function getTurnstileToken(): Promise<{ token: string; createdAt: number; diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index e9118f790..1e1a2b405 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -2,9 +2,10 @@ import { html, LitElement } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { generateID } from "../core/Util"; +import { getPlayToken } from "./Auth"; import "./components/Difficulties"; import "./components/PatternButton"; -import { getPlayToken, JoinLobbyEvent } from "./Main"; +import { JoinLobbyEvent } from "./Main"; import { translateText } from "./Utils"; @customElement("matchmaking-modal") @@ -53,7 +54,7 @@ export class MatchmakingModal extends LitElement { const config = await getServerConfigFromClient(); this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`); - this.socket.onopen = () => { + this.socket.onopen = async () => { console.log("Connected to matchmaking server"); setTimeout(() => { // Set a delay so the user can see the "connecting" message, @@ -64,7 +65,7 @@ export class MatchmakingModal extends LitElement { this.socket?.send( JSON.stringify({ type: "auth", - playToken: getPlayToken(), + playToken: await getPlayToken(), }), ); }; diff --git a/src/client/StatsModal.ts b/src/client/StatsModal.ts index 1547eb404..0c17b68eb 100644 --- a/src/client/StatsModal.ts +++ b/src/client/StatsModal.ts @@ -4,7 +4,7 @@ import { ClanLeaderboardResponse, ClanLeaderboardResponseSchema, } from "../core/ApiSchemas"; -import { getApiBase } from "./jwt"; +import { getApiBase } from "./Api"; import { translateText } from "./Utils"; @customElement("stats-modal") diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 79f4666f6..39a28ff03 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -5,6 +5,7 @@ import { UserMeResponse } from "../core/ApiSchemas"; import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; import { PlayerPattern } from "../core/Schemas"; +import { hasLinkedAccount } from "./Api"; import "./components/Difficulties"; import "./components/PatternButton"; import { renderPatternPreview } from "./components/PatternButton"; @@ -55,7 +56,7 @@ export class TerritoryPatternsModal extends LitElement { } async onUserMe(userMeResponse: UserMeResponse | false) { - if (userMeResponse === false) { + if (!hasLinkedAccount(userMeResponse)) { this.userSettings.setSelectedPatternName(undefined); this.selectedPattern = null; this.selectedColor = null; @@ -134,17 +135,9 @@ export class TerritoryPatternsModal extends LitElement { return html`
- + ${hasLinkedAccount(this.userMeResponse) + ? this.renderMySkinsButton() + : this.renderNotLoggedInWarning()}
{ + this.showOnlyOwned = !this.showOnlyOwned; + }} + > + ${translateText("territory_patterns.show_only_owned")} + `; + } + + private renderNotLoggedInWarning(): TemplateResult { + return html``; + } + private renderColorSwatchGrid(): TemplateResult { const hexCodes = ( this.userMeResponse === false diff --git a/src/client/TokenLoginModal.ts b/src/client/TokenLoginModal.ts index 4aba112fb..cb7ef143e 100644 --- a/src/client/TokenLoginModal.ts +++ b/src/client/TokenLoginModal.ts @@ -1,8 +1,8 @@ import { html, LitElement } from "lit"; import { customElement, query } from "lit/decorators.js"; +import { tempTokenLogin } from "./Auth"; import "./components/Difficulties"; import "./components/PatternButton"; -import { tokenLogin } from "./jwt"; import { translateText } from "./Utils"; @customElement("token-login") @@ -79,7 +79,7 @@ export class TokenLoginModal extends LitElement { return; } try { - this.email = await tokenLogin(this.token); + this.email = await tempTokenLogin(this.token); if (!this.email) { return; } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 9f4f1f5a7..8c94d215f 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -25,6 +25,7 @@ import { Winner, } from "../core/Schemas"; import { replacer } from "../core/Util"; +import { getPlayToken } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { LocalServer } from "./LocalServer"; @@ -388,25 +389,25 @@ export class Transport { } } - joinGame() { + async joinGame() { this.sendMsg({ type: "join", gameID: this.lobbyConfig.gameID, clientID: this.lobbyConfig.clientID, - token: this.lobbyConfig.token, username: this.lobbyConfig.playerName, cosmetics: this.lobbyConfig.cosmetics, turnstileToken: this.lobbyConfig.turnstileToken, + token: await getPlayToken(), } satisfies ClientJoinMessage); } - rejoinGame(lastTurn: number) { + async rejoinGame(lastTurn: number) { this.sendMsg({ type: "rejoin", gameID: this.lobbyConfig.gameID, clientID: this.lobbyConfig.clientID, lastTurn: lastTurn, - token: this.lobbyConfig.token, + token: await getPlayToken(), } satisfies ClientRejoinMessage); } diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 14a94d8fd..8de7b4abf 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -10,13 +10,13 @@ import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas"; import { EventBus } from "../../../core/EventBus"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; +import { getUserMe } from "../../Api"; import "../../components/PatternButton"; import { fetchCosmetics, handlePurchase, patternRelationship, } from "../../Cosmetics"; -import { getUserMe } from "../../jwt"; import { SendWinnerEvent } from "../../Transport"; import { Layer } from "./Layer"; diff --git a/src/client/jwt.ts b/src/client/jwt.ts deleted file mode 100644 index 31cef3e2a..000000000 --- a/src/client/jwt.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { decodeJwt } from "jose"; -import { z } from "zod"; -import { - PlayerProfile, - PlayerProfileSchema, - RefreshResponseSchema, - TokenPayload, - TokenPayloadSchema, - UserMeResponse, - UserMeResponseSchema, -} from "../core/ApiSchemas"; -import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; - -function getAudience() { - const { hostname } = new URL(window.location.href); - const domainname = hostname.split(".").slice(-2).join("."); - return domainname; -} - -export function getApiBase() { - const domainname = getAudience(); - - if (domainname === "localhost") { - const apiDomain = process?.env?.API_DOMAIN; - if (apiDomain) { - return `https://${apiDomain}`; - } - return localStorage.getItem("apiHost") ?? "http://localhost:8787"; - } - - return `https://api.${domainname}`; -} - -function getToken(): string | null { - // Check cookie - const cookie = document.cookie - .split(";") - .find((c) => c.trim().startsWith("token=")) - ?.trim() - .substring(6); - if (cookie !== undefined) { - return cookie; - } - - // Check local storage - return localStorage.getItem("token"); -} - -async function clearToken() { - localStorage.removeItem("token"); - __isLoggedIn = false; - const config = await getServerConfigFromClient(); - const audience = config.jwtAudience(); - const isSecure = window.location.protocol === "https:"; - const secure = isSecure ? "; Secure" : ""; - document.cookie = `token=logged_out; Path=/; Max-Age=0; Domain=${audience}${secure}`; -} - -export function discordLogin() { - window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`; -} - -export async function tokenLogin(token: string): Promise { - const response = await fetch( - `${getApiBase()}/login/token?login-token=${token}`, - { - credentials: "include", - }, - ); - if (response.status !== 200) { - console.error("Token login failed", response); - return null; - } - const json = await response.json(); - const { email } = json; - return email; -} - -export function getAuthHeader(): string { - const token = getToken(); - if (!token) return ""; - return `Bearer ${token}`; -} - -export async function logOut(allSessions: boolean = false) { - const token = getToken(); - if (token === null) return; - clearToken(); - - const response = await fetch( - getApiBase() + (allSessions ? "/revoke" : "/logout"), - { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - }, - }, - ); - - if (response.ok === false) { - console.error("Logout failed", response); - return false; - } - return true; -} - -export type IsLoggedInResponse = - | { token: string; claims: TokenPayload } - | false; -let __isLoggedIn: IsLoggedInResponse | undefined = undefined; -export function isLoggedIn(): IsLoggedInResponse { - __isLoggedIn ??= _isLoggedIn(); - - return __isLoggedIn; -} -function _isLoggedIn(): IsLoggedInResponse { - try { - const token = getToken(); - if (!token) { - // console.log("No token found"); - return false; - } - - // Verify the JWT (requires browser support) - // const jwks = createRemoteJWKSet( - // new URL(getApiBase() + "/.well-known/jwks.json"), - // ); - // const { payload, protectedHeader } = await jwtVerify(token, jwks, { - // issuer: getApiBase(), - // audience: getAudience(), - // }); - - // Decode the JWT - const payload = decodeJwt(token); - const { iss, aud, exp, iat } = payload; - - if (iss !== getApiBase()) { - // JWT was not issued by the correct server - console.error( - 'unexpected "iss" claim value', - // JSON.stringify(payload, null, 2), - ); - logOut(); - return false; - } - const myAud = getAudience(); - if (myAud !== "localhost" && aud !== myAud) { - // JWT was not issued for this website - console.error( - 'unexpected "aud" claim value', - // JSON.stringify(payload, null, 2), - ); - logOut(); - return false; - } - const now = Math.floor(Date.now() / 1000); - if (exp !== undefined && now >= exp) { - // JWT expired - console.error( - 'after "exp" claim value', - // JSON.stringify(payload, null, 2), - ); - logOut(); - return false; - } - const refreshAge: number = 3 * 24 * 3600; // 3 days - if (iat !== undefined && now >= iat + refreshAge) { - console.log("Refreshing access token..."); - postRefresh().then((success) => { - if (success) { - console.log("Refreshed access token successfully."); - } else { - console.error("Failed to refresh access token."); - // TODO: Update the UI to show logged out state - } - }); - } - - const result = TokenPayloadSchema.safeParse(payload); - if (!result.success) { - const error = z.prettifyError(result.error); - // Invalid response - console.error("Invalid payload", error); - return false; - } - - const claims = result.data; - return { token, claims }; - } catch (e) { - console.log(e); - return false; - } -} - -export async function postRefresh(): Promise { - try { - const token = getToken(); - if (!token) return false; - - // Refresh the JWT - const response = await fetch(getApiBase() + "/refresh", { - method: "POST", - credentials: "include", - headers: { - authorization: `Bearer ${token}`, - }, - }); - if (response.status === 401) { - clearToken(); - return false; - } - if (response.status !== 200) return false; - const body = await response.json(); - const result = RefreshResponseSchema.safeParse(body); - if (!result.success) { - const error = z.prettifyError(result.error); - console.error("Invalid response", error); - return false; - } - localStorage.setItem("token", result.data.token); - // Clear the cached logged in state - // so that the next call to isLoggedIn() will refresh the token - __isLoggedIn = undefined; - return true; - } catch (e) { - __isLoggedIn = false; - return false; - } -} - -export async function getUserMe(): Promise { - try { - const token = getToken(); - if (!token) return false; - - // Get the user object - const response = await fetch(getApiBase() + "/users/@me", { - headers: { - authorization: `Bearer ${token}`, - }, - }); - if (response.status === 401) { - clearToken(); - return false; - } - if (response.status !== 200) return false; - const body = await response.json(); - const result = UserMeResponseSchema.safeParse(body); - if (!result.success) { - const error = z.prettifyError(result.error); - console.error("Invalid response", error); - return false; - } - return result.data; - } catch (e) { - __isLoggedIn = false; - return false; - } -} - -export async function fetchPlayerById( - playerId: string, -): Promise { - try { - const base = getApiBase(); - const token = getToken(); - if (!token) return false; - const url = `${base}/player/${encodeURIComponent(playerId)}`; - - const res = await fetch(url, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - if (res.status !== 200) { - console.warn( - "fetchPlayerById: unexpected status", - res.status, - res.statusText, - ); - return false; - } - - const json = await res.json(); - const parsed = PlayerProfileSchema.safeParse(json); - if (!parsed.success) { - console.warn("fetchPlayerById: Zod validation failed", parsed.error); - return false; - } - - return parsed.data; - } catch (err) { - console.warn("fetchPlayerById: request failed", err); - return false; - } -} diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index dbde911bc..f02f805cc 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -364,7 +364,7 @@ export class DefaultConfig implements Config { trainSpawnRate(numPlayerFactories: number): number { // hyperbolic decay, midpoint at 10 factories // expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories) - return (numPlayerFactories + 10) * 16; + return (numPlayerFactories + 10) * 18; } trainGold(rel: "self" | "team" | "ally" | "other"): Gold { switch (rel) { diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index ec0aef806..2718d9438 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -138,8 +138,6 @@ export class MapPlaylist { const ffa1: GameMapType[] = rand.shuffleArray([...maps]); const team1: GameMapType[] = rand.shuffleArray([...maps]); const ffa2: GameMapType[] = rand.shuffleArray([...maps]); - const team2: GameMapType[] = rand.shuffleArray([...maps]); - const ffa3: GameMapType[] = rand.shuffleArray([...maps]); this.mapsPlaylist = []; for (let i = 0; i < maps.length; i++) { @@ -154,14 +152,6 @@ export class MapPlaylist { if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) { return false; } - if (!this.disableTeams) { - if (!this.addNextMap(this.mapsPlaylist, team2, GameMode.Team)) { - return false; - } - } - if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) { - return false; - } } return true; } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 87b3e1d65..50ba20b90 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -337,9 +337,14 @@ export async function startWorker() { // Verify token signature const result = await verifyClientToken(clientMsg.token, config); - if (result === false) { - log.warn("Unauthorized: Invalid token"); - ws.close(1002, "Unauthorized"); + if (result.type === "error") { + log.warn(`Invalid token: ${result.message}`, { + clientID: clientMsg.clientID, + }); + ws.close( + 1002, + `Unauthorized: invalid token for client ${clientMsg.clientID}`, + ); return; } const { persistentId, claims } = result; @@ -374,13 +379,18 @@ export async function startWorker() { } else { // Verify token and get player permissions const result = await getUserMe(clientMsg.token, config); - if (result === false) { - log.warn("Unauthorized: Invalid session"); - ws.close(1002, "Unauthorized"); + if (result.type === "error") { + log.warn(`Unauthorized: ${result.message}`, { + clientID: clientMsg.clientID, + }); + ws.close( + 1002, + `Unauthorized: user me fetch failed for client ${clientMsg.clientID}`, + ); return; } - roles = result.player.roles; - flares = result.player.flares; + roles = result.response.player.roles; + flares = result.response.player.flares; if (allowedFlares !== undefined) { const allowed = @@ -422,7 +432,7 @@ export async function startWorker() { clientID: clientMsg.clientID, reason: turnstileResult.reason, }); - ws.close(1002, "Unauthorized"); + ws.close(1002, "Unauthorized: Turnstile token rejected"); return; case "error": // Fail open, allow the client to join. diff --git a/src/server/jwt.ts b/src/server/jwt.ts index b0a81dc8b..11ab6a369 100644 --- a/src/server/jwt.ts +++ b/src/server/jwt.ts @@ -11,17 +11,18 @@ import { PersistentIdSchema } from "../core/Schemas"; type TokenVerificationResult = | { + type: "success"; persistentId: string; claims: TokenPayload | null; } - | false; + | { type: "error"; message: string }; export async function verifyClientToken( token: string, config: ServerConfig, ): Promise { if (PersistentIdSchema.safeParse(token).success) { - return { persistentId: token, claims: null }; + return { type: "success", persistentId: token, claims: null }; } try { const issuer = config.jwtIssuer(); @@ -34,22 +35,33 @@ export async function verifyClientToken( }); const result = TokenPayloadSchema.safeParse(payload); if (!result.success) { - const error = z.prettifyError(result.error); - console.warn("Error parsing token payload", error); - return false; + return { + type: "error", + message: z.prettifyError(result.error), + }; } const claims = result.data; const persistentId = claims.sub; - return { persistentId, claims }; + return { type: "success", persistentId, claims }; } catch (e) { - return false; + const message = + e instanceof Error + ? e.message + : typeof e === "string" + ? e + : "An unknown error occurred"; + + return { type: "error", message }; } } export async function getUserMe( token: string, config: ServerConfig, -): Promise { +): Promise< + | { type: "success"; response: UserMeResponse } + | { type: "error"; message: string } +> { try { // Get the user object const response = await fetch(config.jwtIssuer() + "/users/@me", { @@ -57,19 +69,25 @@ export async function getUserMe( authorization: `Bearer ${token}`, }, }); - if (response.status !== 200) return false; + if (response.status !== 200) { + return { + type: "error", + message: `Failed to fetch user me: ${response.statusText}`, + }; + } const body = await response.json(); const result = UserMeResponseSchema.safeParse(body); if (!result.success) { - console.error( - "Invalid response", - JSON.stringify(body), - JSON.stringify(result.error), - ); - return false; + return { + type: "error", + message: `Invalid response: ${z.prettifyError(result.error)}`, + }; } - return result.data; + return { type: "success", response: result.data }; } catch (e) { - return false; + return { + type: "error", + message: `Failed to fetch user me: ${e}`, + }; } }