From 1220a331ba4fac4c3b4e8eae70decea542c947e2 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:48:17 +0100 Subject: [PATCH 01/15] =?UTF-8?q?Remove=20modifiers=20from=20normal=20FFA/?= =?UTF-8?q?Team=20games=20(And=20increase=20chance=20of=20gold=20multiplie?= =?UTF-8?q?r=20for=20special=20games,=20decrease=20random=20spawn)=20?= =?UTF-8?q?=F0=9F=8E=B2=20(#3471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Normal (FFA and Team) public games no longer roll random modifiers. Special games remain fully unaffected and continue to use random modifiers as before. I also increased the gold multiplier ticket count in the special modifier pool from 1 to 4 because of player feedback. Edit: And because I saw complaints of too much random spawn I decreased the chance of it. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/server/MapPlaylist.ts | 90 +++++++-------------------------------- 1 file changed, 16 insertions(+), 74 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 5dd7dbaa7..23d01f219 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -110,13 +110,13 @@ type ModifierKey = // Each entry represents one "ticket" in the pool. More tickets = higher chance of selection. const SPECIAL_MODIFIER_POOL: ModifierKey[] = [ - ...Array(4).fill("isRandomSpawn"), + ...Array(2).fill("isRandomSpawn"), ...Array(8).fill("isCompact"), ...Array(1).fill("isCrowded"), ...Array(1).fill("isHardNations"), ...Array(8).fill("startingGold"), ...Array(1).fill("startingGoldHigh"), - ...Array(1).fill("goldMultiplier"), + ...Array(4).fill("goldMultiplier"), ...Array(1).fill("isAlliancesDisabled"), ]; @@ -144,84 +144,35 @@ export class MapPlaylist { const playerTeams = mode === GameMode.Team ? this.getTeamCount(map) : undefined; - const modifiers = this.getRandomPublicGameModifiers(playerTeams); - const { startingGold } = modifiers; - let { isCompact, isRandomSpawn, isCrowded, isHardNations } = modifiers; - - // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) - if ( - playerTeams === Duos || - playerTeams === Trios || - playerTeams === Quads - ) { - isRandomSpawn = false; - } - - // Hard nations modifier only applies when nations are present (not HvN, which is always hard) - if (mode === GameMode.Team) { - isHardNations = false; - } - - // Check if compact map would leave every team with at least 2 players - if ( - isCompact && - mode === GameMode.Team && - !(await this.supportsCompactMapForTeams(map, playerTeams!)) - ) { - isCompact = false; - } - - // Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps), - // set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled) - let crowdedMaxPlayers: number | undefined; - if (isCrowded) { - crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact); - if (crowdedMaxPlayers === undefined) { - isCrowded = false; - } else { - crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams); - } - } - - // Create the default public game config (from your GameManager) return { donateGold: mode === GameMode.Team, donateTroops: mode === GameMode.Team, gameMap: map, - maxPlayers: - crowdedMaxPlayers ?? - (await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)), + maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, false), gameType: GameType.Public, - gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, + gameMapSize: GameMapSize.Normal, publicGameModifiers: { - isCompact, - isRandomSpawn, - isCrowded, - isHardNations, - startingGold, + isCompact: false, + isRandomSpawn: false, + isCrowded: false, + isHardNations: false, isAlliancesDisabled: false, }, - startingGold, difficulty: - isHardNations || playerTeams === HumansVsNations - ? Difficulty.Hard - : Difficulty.Medium, + playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Medium, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, instantBuild: false, - randomSpawn: isRandomSpawn, + randomSpawn: false, nations: mode === GameMode.Team && playerTeams !== HumansVsNations ? "disabled" : "default", gameMode: mode, playerTeams, - bots: isCompact ? 100 : 400, - spawnImmunityDuration: this.getSpawnImmunityDuration( - playerTeams, - startingGold, - ), + bots: 400, + spawnImmunityDuration: this.getSpawnImmunityDuration(playerTeams), disabledUnits: [], } satisfies GameConfig; } @@ -234,6 +185,7 @@ export class MapPlaylist { const excludedModifiers: ModifierKey[] = []; + // Check if compact map would leave every team with at least 2 players const supportsCompact = mode !== GameMode.Team || (await this.supportsCompactMapForTeams(map, playerTeams!)); @@ -241,6 +193,7 @@ export class MapPlaylist { excludedModifiers.push("isCompact"); } + // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) if ( playerTeams === Duos || playerTeams === Trios || @@ -268,6 +221,8 @@ export class MapPlaylist { isHardNations, } = poolResult; + // Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps), + // set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled) let crowdedMaxPlayers: number | undefined; if (isCrowded) { crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact); @@ -484,19 +439,6 @@ export class MapPlaylist { return TEAM_WEIGHTS[0].config; } - private getRandomPublicGameModifiers( - playerTeams?: TeamCountConfig, - ): PublicGameModifiers { - return { - isRandomSpawn: Math.random() < 0.05, // 5% chance - isCompact: Math.random() < 0.05, // 5% chance - isCrowded: Math.random() < 0.05, // 5% chance - startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance - isHardNations: Math.random() < 0.025, // 2.5% chance - isAlliancesDisabled: false, - }; - } - private getRandomSpecialGameModifiers( excludedModifiers: ModifierKey[] = [], count?: number, From 1ebe23d9d86a62945f29b224c2a9ddbdad567dac Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Fri, 20 Mar 2026 06:51:52 +0900 Subject: [PATCH 02/15] mls (v4.18) (#3470) ## Description: mls for v30 Version identifier within MLS: 4.18 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --- resources/flags/gsw.svg | 132 +++++ resources/lang/de-CH.json | 990 +++++++++++++++++++++++++++++++++++ resources/lang/id.json | 113 ++-- resources/lang/metadata.json | 6 + resources/lang/ru.json | 113 ++-- resources/lang/uk.json | 113 ++-- 6 files changed, 1350 insertions(+), 117 deletions(-) create mode 100644 resources/flags/gsw.svg create mode 100644 resources/lang/de-CH.json diff --git a/resources/flags/gsw.svg b/resources/flags/gsw.svg new file mode 100644 index 000000000..d48d7832e --- /dev/null +++ b/resources/flags/gsw.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/lang/de-CH.json b/resources/lang/de-CH.json new file mode 100644 index 000000000..11482594b --- /dev/null +++ b/resources/lang/de-CH.json @@ -0,0 +1,990 @@ +{ + "common": { + "close": "Schliesse", + "copy": "Kopiere", + "paste": "Ifüege", + "back": "Zrügg", + "available": "Verfüegbar", + "preset_max": "Maximau", + "summary_send": "Sände", + "summary_keep": "Bhaute", + "cancel": "Abbräche", + "send": "Sände", + "cap_label": "Kapazität", + "cap_tooltip": "Verblibendi Kapazität vom Empfänger", + "target_dead": "Zieu usgschaute", + "target_dead_note": "Du chasch keini Resourse am ne eliminierte Spiler sände.", + "none": "Nüt", + "copied": "Kopiert!", + "click_to_copy": "Klick zum Kopiere", + "enabled": "Igschaute", + "map_default": "Kartestandard" + }, + "main": { + "title": "OpenFront (ALPHA)", + "login_discord": "Mäud di a mit Discord", + "sign_in": "Amäude", + "discord_avatar_alt": "Discord Profiubüud", + "user_avatar_alt": "{username}'s Profiubüud", + "create": "Lobby erstelle", + "join": "Lobby biträtte", + "solo": "Elei", + "game_info": "Spieuinfo", + "wiki": "Wiki", + "privacy_policy": "Dateschutzerklärig", + "terms_of_service": "Nutzigsbedingige", + "copyright": "©️ OpenFront™ und Mitwirkendi", + "play": "Spiele", + "news": "Neuigkeite", + "store": "Shop", + "settings": "Isteuige", + "leaderboard": "Ranglischte", + "account": "Account", + "help": "Hiuf", + "menu": "Menü", + "troubleshooting": "Fählerbehäbig", + "go_to_troubleshooting": "Gang zu üsere Fehlerbehebigssitte" + }, + "news": { + "github_link": "uf GitHub", + "title": "Versionshiwise" + }, + "troubleshooting": { + "title": "Fählerbehäbig", + "environment": "Umgäbig", + "rendering": "Rendering", + "power": "Leistig", + "browser": "Browser", + "platform": "Plattform", + "copied_to_clipboard": "Informatione i d Zwüscheablag kopiert! Du chasch se im Discord teile, wenn du Hüuf bruchsch.", + "os": "Betriebssystem", + "device_pixel_ratio": "Pixelverhäutniss vom Grät", + "chromium_tip": "OpenFront louft am beschte uf Chromium-basierte Browser.", + "hardware_acceleration_tip": "Steu sicher, dass d Hardwarebeschleunigung in dine Browseristellige aktiviert ist, um e optimale Leistig z erziele.", + "renderer": "Renderer", + "max_texture_size": "Maximali Texturgrössi", + "high_precision_shaders": "Hochpräzisionshaders", + "gpu": "Grafikkarte", + "unavailable": "Unverfüegbar", + "gpu_tip": "Bestätig, dass das die dedizierti Grafikkarte isch, faus eini vorhande isch.", + "battery": "Batterie", + "charging": "Am uflade", + "battery_level": "Batteriestand", + "power_saving_tip": "Sorg derfür, dass di Browser nid im Energiesparmodus isch.", + "yes": "Ja", + "no": "Nei", + "unknown": "Unbekannt", + "software_rendering": "Software-Rendering", + "canvas_2d_no_gpu": "2D-Linwang (keini GPU)" + }, + "help_modal": { + "video_tutorial": "Video-Aleitig", + "video_tutorial_title": "OpenFront.io Aleitig", + "hotkeys": "Tastaturbelegige", + "table_key": "Schlüssu", + "table_action": "Aktion", + "action_esc": "Schliesst s Menü. Bircht d Bouvorschou ab.", + "action_enter": "Bout e Einheit ungerem Muszeiger", + "action_alt_view": "Alternativi Asicht (Bode/Länder)", + "action_coordinate_grid": "Koordinateraster-Overlay umschalute", + "action_attack_altclick": "Agriff usfüehre (wenn Linksklick ufs \"öffne Menü\" gsteut isch)", + "action_build": "Baumenü ufmache", + "action_emote": "Emote-Menü ufmache", + "action_center": "Karte ufe Spiler zentriere", + "action_zoom": "Asicht vergrössere/verchlinere", + "action_move_camera": "Kamera bewege", + "action_ratio_change": "Agriffsrate verringere/erhöhe", + "action_reset_gfx": "Grafike zrüggsetze", + "action_auto_upgrade": "Automatisch s nöchschte Gebäude verbessere", + "ui_section": "Spieuoberflächi", + "ui_leaderboard": "Ranglischte", + "ui_your_team": "Dis Team:", + "ui_leaderboard_desc": "Zeigt d beste Spieler vom Spieu und ihri Namen, % vom Besitz vom Land, Gold u Truppene a. Mit \"Alle azeige\" werde alle Spieler im Spiel azeigt. Wenn du d Rangliste nid gseh möchtisch, drück uf \"Verstecke\".", + "ui_control": "Aktionsmenü", + "ui_control_desc": "Der Kontrollbereich beinhautet fougendi Element:", + "ui_gold": "Gold - D Menge a Gold wo du bsizisch, und d' Geschwindigkeit mit der me Gold überchunt.", + "ui_attack_ratio": "Agriffsverhäutniss - D Anzahl vo de Truppene, wo bim Agriff verwendet werden, cha mit em Schieberegler apasst werde. Desto meh Truppe bim Agriff verwendet werde, desto geringer si die eigene Verluste. Während weniger Truppen zu grössere Verluste füehre. Dä Effekt geit nid über s Verhältnis vo 2:1 use.", + "ui_events": "Ereignis - Fäud", + "ui_events_desc": "S Ereignis - Fäud zeigt die neuesten Ereignisse, Anfragen und Schnell-Nachrichten a. Es bar Beispiele sind:", + "ui_events_alliance": "Allianz - Allianzafroge chöi akzeptiert oder abgelehnt werde. Alliierti chöi Ressourcen und Truppen teile, chöi sich aber nicht gegensitig angreifen. Dür Klicke auf \"Fokussiere\" wird d Ansicht uf dä Spieler verschoben, wo d Anfrag gesendet het.", + "ui_events_attack": "Agriff - Igehendi agriff und dini usgehende Attackene werde azeigt. Klick d Nachricht a, um dini Asicht uf e Agriff, Atomraggete oder (Transport-) Boot z zentriere. Du chasch Truppene zrüggzieh indäm du uf s rote X drücksch. Das choschtet s läbe vo 25% vo dine agrifende Truppene. Wenn du a Boot attacke zrüggziesch, geits Boot zrügg zum Startpunkt und wird dörte agriffe, wenn Land sit er abfahrt bsetzt worde isch. Atomwaffe chöi nid zrüggzoge wärde, wenn si si gstartet worde.", + "ui_events_quickchat": "Schnäuchat - Hie chöit der gesendete und empfangene Chat-Nachrichten gseh. Sendet e Nachricht a ne Spieler, indem du im Info-Menü auf s Schnell-Chat-Symbol klicksch.", + "ui_options": "Optione", + "ui_options_desc": "Die fougende Schautflächene si i de Optione verfüegbar:", + "ui_playeroverlay": "Spieler-Info-Overlay", + "ui_playeroverlay_desc": "Wenn du über es Land fahrsch, wird d Info-Übersicht vom ne Spieler unger de Optione azeigt. Es zeigt der Typ vom Spieler a: Mönschleche Spieler, Nation (schlaue Bot) oder Bot, D Hautig vore Nation zu dir, vo findsehlig bis fündlech und verteidigendi Truppene, Gold, sowie d Azau vo Chriegsschiff und verschideni Gebäude wo der spieler het.", + "ui_wilderness": "Wüudniss", + "option_pause": "Spiel pausiere/fortsetze - Nume im Einzuspielermodus verfüegbar.", + "option_timer": "Timer - D Zit wo sitem spieustart vergange isch.", + "option_exit": "Spieu verlasse chnopf.", + "option_settings": "Istellige - Öffnet d Istellige. Dörte chasch die auternativi Asicht, Emojis, Dunkumodus, Ninja (anonyme/zufällige namen) und d Aktione bi Linksklick igsteut wärde.", + "radial_title": "Radialmenü", + "radial_desc": "Rechtsklick (oder Touch ufem Smartphone) öffnet s Menü. Rechtsklick usserhaub em Menü schliesst es. Us em menü use cha me:", + "radial_build": "Baumenü ufdue.", + "radial_info": "Infobereich ufdue.", + "radial_boat": "Sänd es Boot (transportschiff) um ar usgwäute stöu azgriffe. Nume verfüegbar, wenn du zuegriff zu wasser hesch.", + "radial_donate_troops": "Send Truppene ir Höhi vo dim Agriffsverhäutniss a e Verbündete, uf dem du das Menü göffnet hesch.", + "radial_donate_gold": "Öffnet s Goldspändemenü, um Verbündete schnäu Gold z sände.", + "info_title": "Infobereich", + "info_enemy_desc": "Enthautet Informatione wie der Name vom usgwäute Spieler, Gold, Truppene, op der Handu gstoppt worde isch, Azau vo de Atombimbene wo er gsändet het und ob der Spieler e Verräter isch. Gstoppte Hanu bedütet, dass du kei Gold vo ihm überchunsch, und du kei Gold über Handusschiff schicksch. Manuell (wenn der Spieler uf \"Handu stoppe\" klickt, wo güut bis beidi wieder \"Hanu starte\" klicke) oder automatisch (wenn du dini Allianz verrote hesch und die ernöierisch oder nach 5 Minute). Verräter zeigt Ja für 30 sekundene, wenn der spieler betroge und agriffe het, wo sech ire allianz mit ihne het befunde. Die fougende Symbou repräsentiere die fougende Interaktione:", + "info_chat": "Send e churzi chatnachricht a ne Spieler. Wäu e Kategorie, e Usdruck, und wenn der Usdruck [P1] enthautet, wäu e Spielername us, um ihn z ersetze. Klick uf sände.", + "info_target": "Platziert e Zieuschibe uf em Spieler, aus Markierig für aui Verbündete zur Koordination vo gmeinsame Agriffe.", + "info_alliance": "Sendet e Bündnissafrog zum Spieler. Verbündeti chöi Ressourcene und Truppene teile, aber sech nid agriffe.", + "info_emoji": "Send es Emoji a Spieler.", + "info_trade": "Benutz \"Hanu stoppe\" um ufzhöre em Spieler Gold über euchi Handusschiff z gäh und Gold vo Handusschiffe vom Spieler z erhaute. Wenn dir beidi wieder uf \"Handu beginne\" drücket, geit es wieder los.", + "info_ally_panel": "Bündnis Infoberich", + "info_ally_desc": "Wenn me sech mit emne Spieler verbündet, wärde fougendi Icons sichtbar:", + "ally_betray": "Verrat di Verbündete, beänd d Alliant, stopp der Handu und schwäch dini Verteidigung. Der Handu zwüsche Ihne wird für 5 Minute ungerbroche (oder bis du di wieder verbündisch) und angeri chöi ou der Handu isteue. Und wenn der anger Spieler nid säuber Verräter isch gsi, wirsch du 30 Sekunge lang aus Verräter markiert. Während dere Zit wird es Symbou oberhaub vo dim Name azeigt und dini Verteidigung wird um 50% reduziert. Dass Bots sech verbünde isch eher unwahrschinlech aber Spieler werde es sech sicher vorher zwöimou überlege.", + "ally_donate": "Spend einigi vo dine truppe a emne verbündete. Cha brucht wärde, wenn si weni truppe hei, oder um se bi emne vernichtende Schlag z ungerschtütze.", + "ally_donate_gold": "Spend e Teil vo dim Gold a dini verbündete. Wird brucht, wenn si kei Gold für gebäude hei, oder wenn di Teampartner isch am Spare für e MIRV.", + "build_menu_title": "Baumenü", + "build_menu_desc": "Bou eis vo dene oder gseh wievüu du scho bout hesch:", + "build_name": "Name", + "build_icon": "Symbou", + "build_desc": "Beschribig", + "build_city": "Stadt", + "build_city_desc": "Erhöht dini maximali Bevöukerigsazau. Nützlech, wenn s territorium nid cha erwiteret werde oder s Bevöukerigslimit erreicht wird.", + "build_factory": "Fabrigg", + "build_factory_desc": "Bout automatisch Isebahnverbindige zu nahglägene Städt, Häfe und angere Fabrigge und cha sech ou mit befründete Nachbare vürbinge. Züg erschine regumässig und bringe für jedes Gebäude, wo si uf ihrem wäg bsueche e feste Goldbetrag, mit zuesätzlechem Goud füre besuech vo gebäude vo dine Nochbere.", + "build_defense": "Verteidigungs poschte", + "build_defense_desc": "Erhöht de Verteidigung vo aliegende Gränze, markiert mit em ne karierte Muster. Agriffe von Finde si langsamer und sorge für meh Verluste bim Find.", + "build_port": "Hafe", + "build_port_desc": "Cha nume ir nöchi vo Wasser bout wärde. Erloubt der Bou vo Chroegsschiffe. Sändet outomatisch Handusschiff zwische de Häfe vo dim Land und angere länder (usser wenn der Handu gstoppt isch), wodüre beidi Sitte Gold erhaute. Der Handu stoppt outomatisch, wenn du attackiert wirsch oder e Spieler agriffsch. Es fahrt witer nach 5 Minutene oder wenn du Alliert wirsch. Du chasch manuell der Handu i oder usschaute mit \"Handu starte\" oder \"Handu stoppe\".", + "build_warship": "Kriegsschiff", + "build_warship_desc": "Patrouilliert i emne Gebiet und kapert Handuschiff, zerstört findlechi Kriegsschiff und Boote (Transportschiff). Erschint bim nächschtglägene Hafe und patrouilliert im Gebiet wo es bout worde isch. Mit emne Klick uf s Kriegsschiff cha es gstürt und mit em ne witere Klick ines angeres gebiet gschickt werde.", + "build_silo": "Raggetesilo", + "build_silo_desc": "Ermüglechts abführe vo Raggetene.", + "build_sam": "Flugabwehr - Rageetestöuig", + "build_sam_desc": "Cha findlechi Raggetene i sim 100-Pixel-Bereich abfoh. E SAM het e Abklingzit von 7.5 Sekunden.", + "build_atom": "Atombombe", + "build_atom_desc": "Chlini explosivi bombe wo Territorium, Gebäude, Schiff und Boot zerstört. Wird vom nächstglägene Raggetesilo abgfüret und schlot am punkt wo si isch bout worde i.", + "build_hydrogen": "Wasserstoffbombe", + "build_hydrogen_desc": "Grossi explosivi bombe. Wird vom nächstglägene Raggetesilo abgfüret und schlot am punkt wo si isch bout worde i.", + "build_mirv": "Mehrfach Zieu Rageete - MIRV", + "build_mirv_desc": "Die stärchschti bombe im Spiel. Teilt sech i chlineri bombene wo es risigs territorium düe zerstöre. Duet nume dä Spieler beschädige wo du zersch hesch dermit acklickt- Wird vom nächstglägene Raggetesilo abgfüret und landet im territorium wo si isch bout worde.", + "player_icons": "Spieler-Icons", + "troubleshooting_desc": "Wenn du Leistigsproblem, Abstürz oder angeri Problem bim Spiele vo OpenFront hesch, bsuech bitte üsi sitte zur Fehlerbehebig und Diagnose vo hüfige problem:", + "icon_desc": "Beispüu für einigi ingame Icons, wo du wirsch gseh und was sie bedüte:", + "icon_crown": "Krone - Nummer 1. Das isch der Top-Spieler ir Ranglsichte.", + "icon_traitor": "Verbrochene Schild - Verräter. Dä Spieler het e Verbündete agriffe.", + "icon_ally": "Handschlag - Verbündete. Dä Spieler isch e Verbündete.", + "icon_embargo": "Dollar Stoppschild - Embargo. Dä Spieler het der Handu mit dir automatisch oder manuell gstoppt.", + "icon_request": "Umschlag - Allianzafrog. Dä Spieler het dir e Allianzafrog gschickt.", + "info_enemy_panel": "Gägner Info fäud", + "exit_confirmation": "Bisch du dir würklech sicher, dass du s Spieu wosch verlasse?", + "bomb_direction": "Atom / Wasserstoffbombe Bogerichtig", + "icon_alt_player_leaderboard": "Spieler-Rangliste-Symbou", + "icon_alt_team_leaderboard": "Team-Rangliste-Symbou" + }, + "single_modal": { + "random_spawn": "Zuefäuige Spawn", + "toggle_achievements": "Errungenschaften a/usschaute", + "sign_in_for_achievements": "Log di i für Errungeschafte", + "options_title": "Optione", + "bots": "Stämm: ", + "bots_disabled": "Usgschaute", + "nations": "Natione: ", + "nations_disabled": "Usgschaute", + "instant_build": "Sofort boue", + "infinite_gold": "Unendlech gold", + "infinite_troops": "Unendlech truppe", + "compact_map": "Kompakti Karte", + "disable_alliances": "Allianze deaktiviere", + "max_timer": "Spieulängi (minute)", + "max_timer_placeholder": "Minute", + "max_timer_invalid": "Bitte gib e gültige maximale Zitwärt i (1-120 Minute)", + "enables_title": "Aktivierti Istellige", + "start": "Spieu starte", + "options_changed_no_achievements": "Benutzerdefinierte Istellige - Erfolge deaktiviert", + "gold_multiplier": "Gold Multiplikator", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Start-Gold (Millionen)", + "starting_gold_placeholder": "5" + }, + "token_login_modal": { + "title": "Amäudig louft...", + "logging_in": "Amäude ...", + "success": "Erfolgrich agmäudet aus {email}!" + }, + "account_modal": { + "title": "Konto", + "connected_as": "Agmäudet aus", + "stats_overview": "Statistike Übersicht", + "link_discord": "Discord Konto verknüpfe", + "log_out": "Abmäude", + "sign_in_desc": "Mäud di a, um dini Statistike und Fortschritte z spichere", + "or": "ODER", + "email_placeholder": "Gib dini email adrässe i", + "get_magic_link": "Überchum e magische Link", + "linked_account": "Agmäudet aus {account_name}", + "fetching_account": "Kontoinformationen wärde glade...", + "recovery_email_sent": "Wiederherstelligs-E-Mail a {email} gesendet", + "not_found": "Nid gfunge", + "clear_session": "Sitzig lösche", + "failed_to_send_recovery_email": "Fehler bim Senden vor Wiederherstelligs E-Mail", + "enter_email_address": "Bitte gib e E-Mail Adresse a", + "personal_player_id": "Persönlechi Spieler ID:" + }, + "leaderboard_modal": { + "title": "Ranglischte", + "ranked_tab": "1v1 Bewertet", + "clans_tab": "Clans", + "refresh_time": "Aktualisiert jedi Stund", + "loading": "Laden...", + "error": "Bim Lade vor Rangliste isch e Fehler aufträtte", + "no_stats": "Kei Statistiken verfügbar", + "no_data_yet": "No keini Date hie", + "clan": "Clan", + "player": "Spieler", + "games": "Spiele", + "elo": "ELO", + "win_score": "Siegpunkte", + "win_score_tooltip": "Gewichtete Siege basierend auf Clan Beteiligung und Spielschwierigkeit", + "loss_score": "Niederlagenpunkte", + "loss_score_tooltip": "Gewichtete Niederlagen basierend auf Clan Beteiligung und Spielschwierigkeit", + "win_loss_ratio": "Sieg/Niederla", + "ratio": "Verhältnis", + "rank": "Rang", + "try_again": "Versuch's nomou", + "your_ranking": "Di Rang" + }, + "game_info_modal": { + "title": "Spieuinfo", + "players": "Spieler", + "atoms": "Atome", + "hydros": "Hydros", + "mirv": "MIRV", + "bombs": "Bombene", + "total_gold": "Total", + "all_gold": "Gsamtes Gold", + "trade": "Handel", + "train_trade": "Zug", + "naval_trade": "Handelschiff", + "conquest_gold": "Erobertes Spieler Gold", + "stolen_gold": "Mit Kriegsschiff gstohle", + "num_of_conquests_humans": "Spieler tötet", + "num_of_conquests_bots": "Stammeskills", + "duration": "Dur", + "survival_time": "Überlebenszit", + "war": "Chrieg", + "economy": "Wirtschaft", + "conquests": "Eroberige", + "pirate": "Übernäh", + "conquered": "Erobert", + "loading_game_info": "Lade Spielstatistiken", + "no_winner": "S Spieu het ohni Sieger gändet (oder Siegernation)" + }, + "map": { + "map": "Charte", + "featured": "Empfohle", + "all": "Alli", + "world": "Welt", + "giantworldmap": "Riesigi Weutcharte", + "europe": "Europa", + "mena": "MENA", + "northamerica": "Nord Amerika", + "oceania": "Oceanie", + "blacksea": "Schwarzes Meer", + "africa": "Afrika", + "asia": "Asien", + "mars": "Mars", + "southamerica": "Südamerika", + "britanniaclassic": "Gross Britannien (klassisch)", + "britannia": "Gross Britannien", + "gatewaytotheatlantic": "Tor zum Atlantik", + "australia": "Australien", + "random": "Zuefäuig", + "iceland": "Islang", + "pangaea": "Pangaea", + "eastasia": "Ostasien", + "betweentwoseas": "Zwische zwöi Meere", + "faroeislands": "Färöer Inseln", + "deglaciatedantarctica": "Entisti Antarktis", + "europeclassic": "Europa (klassisch)", + "falklandislands": "Falkland Inseln", + "baikal": "Baikalsee", + "halkidiki": "Halkidiki", + "straitofgibraltar": "Stross vo Gibraltar", + "italia": "Italie", + "japan": "Japan", + "yenisei": "Yenisei", + "pluto": "Pluto", + "montreal": "Montreal", + "newyorkcity": "New York Stadt", + "achiran": "Achiran", + "baikalnukewars": "Baikal (Bombenkrieg)", + "fourislands": "Vier Inseln", + "gulfofstlawrence": "Golf von St. Lawrence", + "lisbon": "Lissabon", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "passage": "Passage", + "sierpinski": "Sierpinski", + "thebox": "Die Box", + "twolakes": "Zwöi Seen", + "straitofhormuz": "Strasse vo Hormuz", + "surrounded": "Umzinglet", + "didier": "Didier", + "didierfrance": "Didier (Frankrich)", + "amazonriver": "Amazonas Fluss", + "bosphorusstraits": "Bosphorus Strasse", + "beringstrait": "Behringer Strasse", + "tradersdream": "Händlertraum", + "hawaii": "Hawaii", + "alps": "Alpen", + "niledelta": "Nildelta", + "arctic": "Arktis", + "sanfrancisco": "San Francisco", + "aegean": "Aegean" + }, + "map_categories": { + "featured": "Empfohle", + "continental": "Kontinental", + "regional": "Regional", + "fantasy": "Andere", + "special": "Spezial", + "arcade": "Arcade" + }, + "map_component": { + "loading": "Laden...", + "error": "Fehler" + }, + "private_lobby": { + "title": "Privater Lobby biträtte", + "enter_id": "Lobby ID eingeben", + "join_lobby": "Lobby biträtte", + "not_found": "Lobby nid gfungen. Bitte Lobby ID überprüefe und noeinisch versuche.", + "error": "Es isch e Fehler auftrette. Bitte versuche s nochmau oder wend di a Support.", + "joined_waiting": "Lobby biträtte! Warte uf e Gastgeber...", + "version_mismatch": "Das Spieu nutzt e angeri Version. Du chasch nid biträtte.", + "disabled_units": "Deaktivierti Einheite" + }, + "public_lobby": { + "title": "Wart uf Spielbeginn...", + "waiting_for_players": "Warte uf Spiler", + "connecting": "Mit Lobby verbinge...", + "starting_in": "Startet i {time}", + "starting_game": "Starte…", + "teams_hvn": "Mönsche gäge Natione", + "teams_hvn_detailed": "{num} Mönsche gäge {num} Natione", + "teams": "{num} teams", + "players_per_team": "vo {num}", + "started": "Gstartet", + "status": "Status", + "join_timeout": "Du bisch am Spiu nid rechtzetig biträtte." + }, + "matchmaking_modal": { + "title": "1gägä1 Ranglischte-Matchmaking (ALPHA)", + "connecting": "Verbinge mit em Server...", + "searching": "Spiel wird gsuecht...", + "waiting_for_game": "Wart ufe Spielbeginn...", + "elo": "Dini ELO: {elo}", + "no_elo": "No kei ELO" + }, + "username": { + "enter_username": "Gib e Benutzername i", + "not_string": "Benutzername muss ein Text sein.", + "too_short": "Benutzername muss mindestens {min} Zeichen lang sein.", + "too_long": "Benutzername darf {max} Zeichen nicht überschreiten.", + "invalid_chars": "Benutzername darf nur Buchstaben, Zahlen, Leerzeichen und Unterstriche enthalten.", + "tag": "TAG", + "tag_too_short": "Clan-Tag mues us 2-5 alphanumerischen Zeichen bestoh.", + "tag_invalid_chars": "Clan-Tag darf nume us Buchstaben und Zahlen bestoh." + }, + "host_modal": { + "title": "Privati Lobby erstellen", + "mode": "Modus", + "team_count": "Teamazau", + "team_type": "Team Typ", + "options_title": "Optione", + "bots": "Stämm: ", + "bots_disabled": "Usgschaute", + "nations": "Natione: ", + "nations_disabled": "Usgschaute", + "player_immunity_duration": "PVP Immunitätsdur (Minute)", + "max_timer": "Spieulängi (minute)", + "mins_placeholder": "Minute", + "instant_build": "Sofort boue", + "infinite_gold": "Unendlech gold", + "donate_gold": "Gold spenden", + "infinite_troops": "Unändlech truppene", + "donate_troops": "Truppe spende", + "compact_map": "Kompakti Karte", + "disable_alliances": "Allianze deaktiviere", + "enables_title": "Aktivierti Istellige", + "player": "Spieler", + "players": "Spieler", + "nation_players": "Natione", + "nation_player": "Nation", + "waiting": "Warte uf Spiler...", + "random_spawn": "Zuefäuige Spawn", + "start": "Spieu starte", + "host_badge": "Host", + "assigned_teams": "Zuegwiseni Teams", + "empty_teams": "Leeri Teams", + "empty_team": "Leer", + "remove_player": "{username} entfärne", + "teams_Duos": "Duos (Teams vo 2)", + "teams_Trios": "Trios (Teams vo 3)", + "teams_Quads": "Quads (Teams vo 4)", + "teams_Humans Vs Nations": "Mönsche gäge Natione", + "crowded": "Überfüut Modifikator", + "hard_nations": "Schweeri Natione", + "gold_multiplier": "Gold Multiplikator", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Start-Gold (Millionen)", + "starting_gold_placeholder": "5", + "leave_confirmation": "Bisch du dir würklech sicher, dass du s Spieu wosch verlasse?" + }, + "team_colors": { + "red": "Rot", + "blue": "Blau", + "teal": "Türkis", + "purple": "Lila", + "yellow": "Gelb", + "orange": "Orange", + "green": "Grün", + "bot": "Stämm", + "humans": "Mönsche", + "nations": "Natione" + }, + "game_starting_modal": { + "title": "s Spiel startet...", + "credits": "Credits", + "code_license": "Code lizenziert unter AGPL-3.0 (ohne Gewährleistung)" + }, + "difficulty": { + "difficulty": "Schwierigkeitsgrad vo de Natione", + "easy": "Eifach", + "medium": "Mittu", + "hard": "Schwer", + "impossible": "Unmöglech" + }, + "game_mode": { + "ffa": "Jede gäge jede", + "teams": "Teams" + }, + "mode_selector": { + "teams_title": "Teams", + "teams_count": "{teamCount} teams", + "teams_of": "{teamCount} teams vo {playersPerTeam}", + "ranked_title": "Bewärtet", + "ranked_1v1_title": "1v1", + "ranked_2v2_title": "2v2", + "coming_soon": "Chunt gli" + }, + "public_game_modifier": { + "random_spawn": "Zuefäuige Spawn", + "compact_map": "Kompakti Karte", + "crowded": "Überfüllt", + "hard_nations": "Schweeri Natione", + "starting_gold": "{amount}M Start Gold", + "gold_multiplier": "x{amount} Gold Multiplikator", + "disable_alliances": "Allianze deaktiviert" + }, + "select_lang": { + "title": "Sprach auswähle" + }, + "unit_type": { + "boat": "Boot", + "city": "Stadt", + "defense_post": "Verteidigungs poschte", + "port": "Hafe", + "warship": "Kriegsschiff", + "missile_silo": "Raggetesilo", + "sam_launcher": "Flugabwehr - Rageetestöuig", + "atom_bomb": "Atombombe", + "hydrogen_bomb": "Wasserstoffbombe", + "mirv": "Mehrfach Zieu Rageete - MIRV", + "factory": "Fabrigg" + }, + "user_setting": { + "title": "Isteuige", + "tab_basic": "Grundeinstellungen", + "tab_keybinds": "Tastenkombinationen", + "dark_mode_label": "Dunku - Modus", + "dark_mode_desc": "Wechsle zwüschem dunkle und helle Design vor Website", + "emojis_label": "Emojis", + "emojis_desc": "Emojis im Spiel ein-/ausblenden", + "alert_frame_label": "Warnrahmen", + "alert_frame_desc": "Aktiviert oder deaktiviert der Alarmrahme. Wenn aktiviert, wird der Rahmen azeigt, wenn du verrote wirsch oder e Landagriff erleidisch.", + "special_effects_label": "Speziaueffekte", + "special_effects_desc": "Spezialeffekte de/aktiviere. Deaktiviere um d Leistig z verbessere", + "structure_sprites_label": "Struktur-Grafike", + "structure_sprites_desc": "Struktur-Grafike i-/usschaute", + "cursor_cost_label_label": "Cursor Bauchoschte", + "cursor_cost_label_desc": "Choschte ungerem Bou-Cursor azeige", + "anonymous_names_label": "Vürborgeni näme", + "anonymous_names_desc": "Vürsteckt echti Spielernäme mit zuefäuige Näme uf dim Büudschirm.", + "lobby_id_visibility_label": "Versteckti Lobby IDs", + "lobby_id_visibility_desc": "Versteck d Lobby ID während der ersteuig vore Private Lobby", + "toggle_visibility": "Sichtbarkeit umschaute", + "left_click_label": "Linksklik, um s Menü z öffne", + "left_click_desc": "Wenn An, öffnet Linksklick s Menü und d Schwertattacke-Schautflächi. Wenn Us, grifft Linksklick direkt a.", + "left_click_menu": "Linksklick Menü", + "attack_ratio_label": "⚔️ Agriffsverhäutnis", + "attack_ratio_desc": "Prozentsatz vo dine Truppe wo i Angriff gschickt werde (1–100 %)", + "territory_patterns_label": "🏳️ Gebietsmuster", + "territory_patterns_desc": "Wäu us, ob Gebietsmuster im Spieu angezeigt wärde", + "coordinate_grid_label": "Kordinategitter", + "coordinate_grid_desc": "Alphanumerische Gitterüberlagerig i/usschaute", + "performance_overlay_label": "Leistigsazeig", + "performance_overlay_desc": "Leistigsazeig i/usschaute. Wenn aktiviert, wird s Overlay azeigt.\nDrück während em Spiel Shift + D, um es umzschaute.", + "easter_writing_speed_label": "Schribgschwindikeits Multiplikator", + "easter_writing_speed_desc": "Apasse, wie schnäu du vorgisch z programmiere (x1-x100)", + "easter_bug_count_label": "Fehlerazau", + "easter_bug_count_desc": "Wie viu Bugs chasch du emotional verkraften? (0–1000, emotional)", + "press_a_key": "Drück e Taste", + "view_options": "Optione azeigen", + "toggle_view": "Asicht umschaute", + "toggle_view_desc": "Alternativi Asicht (Bode/Länder)", + "build_controls": "Bau-Steuerung", + "build_city": "Stadt boue", + "build_city_desc": "Bou e stadt ungerm Muszeiger.", + "build_factory": "Fabrigg boue", + "build_factory_desc": "Bou e Fabrigg ungerm Muszeiger.", + "build_defense_post": "Bou e Verteidigungsposte", + "build_defense_post_desc": "Bou e Verteidigungsposte ungerm Muszeiger.", + "build_port": "Bou e Hafe", + "build_port_desc": "Bou e Hafe ungerm Muszeiger.", + "build_warship": "Bou es Kriegsschiff", + "build_warship_desc": "Bou es Kriefsschiff ungerm Muszeiger.", + "build_missile_silo": "Bou es Raggetesilo", + "build_missile_silo_desc": "Bou es Raggetesilo ungerm Muszeiger.", + "build_sam_launcher": "Bou es Flugabwehrsystem", + "build_sam_launcher_desc": "Bou es Flugabwehrsystem ungerm Muszeiger.", + "build_atom_bomb": "Bou e Atombombe", + "build_atom_bomb_desc": "Bou e Atombombe ungerm Muszeiger.", + "build_hydrogen_bomb": "Bou e Wasserstoffbombe", + "build_hydrogen_bomb_desc": "Bou e Wasserstoffbombe ungerm Muszeiger.", + "build_mirv": "Bou e MIRV", + "build_mirv_desc": "Bou e MIRV ungerm Muszeiger.", + "menu_shortcuts": "Menü Tastekombinatione", + "build_menu_modifier": "Boumenü Modifikator", + "build_menu_modifier_desc": "Haut die Taste drückt und klick glichzitig, um s Baumenü z öffne.", + "emoji_menu_modifier": "Emoji-Menü Modifikator", + "emoji_menu_modifier_desc": "Haut die Taste drückt und klick glichzitig, um s Emojimenu z öffne.", + "attack_ratio_controls": "Agriffsverhäutnisstürig", + "attack_ratio_up": "Agriffsverhältnis erhöhe", + "attack_ratio_up_desc": "Agriffsverhältnis erhöhe um {amount}%", + "attack_ratio_down": "Agriffsverhältnis sänke", + "attack_ratio_down_desc": "Agriffsverhältnis senke um {amount}%", + "attack_ratio_increment_label": "Agriffsverhäutnis Tastekombination erhöhe", + "attack_ratio_increment_desc": "Wie fescht sechs Verhäutnis vor Taschtekombination pro Taschtedruck veränderet.", + "attack_keybinds": "Agriffs-Tastenkürzel", + "boat_attack": "Bootsagriff", + "boat_attack_desc": "Send a Bootsagriff uf s Pixel unger dim Muszeiger.", + "ground_attack": "Bodenagriff", + "ground_attack_desc": "Send a Bodeagriff uf s Pixel unger dim Muszeiger.", + "swap_direction": "Raggetenrichtig wechsle", + "swap_direction_desc": "Umschaute vor Raggetestartrichtig (ufe/abe).", + "zoom_controls": "Zoomistellige", + "zoom_out": "Usezoome", + "zoom_out_desc": "Karte verchlinere", + "zoom_in": "Inezoome", + "zoom_in_desc": "Karte vergrössere", + "camera_movement": "Kamerabewegig", + "center_camera": "Kamera zentriere", + "center_camera_desc": "Karte ufe Spiler zentriere", + "move_up": "Beweg d Kamera ufe", + "move_up_desc": "Beweg d Kamera nach obe", + "move_left": "Beweg d Kamera links", + "move_left_desc": "Beweg d Kamera nach links", + "move_down": "Beweg d Kamera abe", + "move_down_desc": "Beweg d Kamera nach unge", + "move_right": "Beweg d Kamera rächts", + "move_right_desc": "Beweg d Kamera nach rächts", + "reset": "Zrüggsetze", + "unbind": "Loslöse", + "on": "An", + "off": "Us", + "toggle_terrain": "Gländ azeige/verberge", + "exit_game_label": "Spieu verloh", + "exit_game_info": "Zrügg zum Houptmenu", + "background_music_volume": "Hingergrundmusiglutsterchi", + "sound_effects_volume": "Soundeffektlutsterchi", + "keybind_conflict_error": "D Taste {key} isch bereits a e angeri Aktion bunde." + }, + "chat": { + "title": "Schnäuchat", + "to": "Gsendet a {user}: {msg}", + "from": "Vo {user}: {msg}", + "category": "Kategorie", + "phrase": "Satz", + "player": "Spieler", + "send": "Sände", + "search": "Spieler sueche...", + "build": "Ersteu dini Nachricht...", + "cat": { + "help": "Hiuf", + "attack": "Agriff", + "defend": "Verteidige", + "greet": "Grüess", + "misc": "Verschiedenes", + "warnings": "Warnige" + }, + "help": { + "troops": "Bitte gib mir Truppene!", + "troops_frontlines": "Schick Truppene a d Front!", + "gold": "Bitte gib mir Gold!", + "no_attack": "Bitte griff mi nid a!", + "sorry_attack": "Entschuldigung, i ha nid wöue agriffe.", + "alliance": "Allianz?", + "help_defend": "Hüuf mir mi gäge [P1] z verteidige!", + "trade_partners": "Lass Handelspartner wärde!" + }, + "attack": { + "attack": "Griff [P1] a!", + "mirv": "Start e MIRV uf [P1]!", + "focus": "Fokussier s Für uf [P1]!", + "finish": "Lass üs mit [P1] fertig wärde!", + "build_warships": "Bou Kriegsschiff!" + }, + "defend": { + "defend": "Verteidig [P1]!", + "defend_from": "Verteidig gäge [P1]!", + "dont_attack": "Griff [P1] nid a!", + "ally": "[P1] isch mi Verbündete!", + "build_posts": "Bou Verteidigungspostene!" + }, + "greet": { + "hello": "Hallo!", + "good_job": "Guet gmacht!", + "good_luck": "Viel Glück!", + "have_fun": "Viel Spass!", + "gg": "GG!", + "nice_to_meet": "Schön, di kennezlerne!", + "well_played": "Guet gspiut!", + "hi_again": "Hallo nomau!", + "bye": "Tschüss!", + "thanks": "Danke!", + "oops": "Hoppla, fausche chnopf drückt!", + "trust_me": "Du chasch mir vertraue. Versproche!", + "trust_broken": "I ha dir vertrout...", + "ruining_games": "Du versausch grad beidi üseri Spieu.", + "dont_do_that": "Mach das nid!", + "same_team": "I bi uf dire Sitte!" + }, + "misc": { + "go": "Los geits!", + "strategy": "Gueti strategie!", + "fun": "Das Spiel macht Spass!", + "team_up": "Lass zäme gäge [P1] teame!", + "pr": "Wenn wird mi PR endlech übernoh...?", + "build_closer": "Bou dichter a mir, damit mini Züg zu dir chöme!", + "coastline": "Bitte lass mi e Küste übercho." + }, + "warnings": { + "strong": "[P1] isch stark.", + "weak": "[P1] isch schwach.", + "mirv_soon": "[P1] cha gli e MIRV starte!", + "number1_warning": "Der #1 Spieler wird gli gwünne, usser mir werde es Team!", + "stalemate": "Lass friede schliesse. Das isch e Sackgass, mir werde beidi verliere.", + "has_allies": "[P1] het viu Verbündeti.", + "no_allies": "[P1] het keini Verbündeti.", + "betrayed": "[P1] het si Verbündete verrote!", + "betrayed_me": "[P1] het mi verrote!", + "getting_big": "[P1] wachset z schnäu!", + "danger_base": "[P1] isch ungschützt!", + "saving_for_mirv": "[P1] spart, um e MIRV-Rakete z starte.", + "mirv_ready": "[P1] het gnue Gold, um e MIRV-Rakete z starte!", + "snowballing": "[P1] wachst z schnäu!", + "cheating": "[P1] betrüegt!", + "stop_trading": "Stoppe de Handu mit [P1]!", + "stop_trading_all": "Bitte stopp der Handu mit aue!" + } + }, + "build_menu": { + "desc": { + "atom_bomb": "Chlini explosion", + "hydrogen_bomb": "Grossi explosion", + "mirv": "Riesigi explosion, trifft nume usgwäuti spieler", + "missile_silo": "Ermüglecht der Isatz vo Atomraggetene", + "sam_launcher": "Verteidigt gäge igehendi Atomraggetene", + "warship": "Erobert Handuschiff, zerstört Schiff und Boot", + "port": "Sendet Handuschiff, um Gold z generiere", + "defense_post": "Erhöht d Verteidigung vo umliegende Grenzene", + "city": "Erhöht die maximali Bevöukerig", + "factory": "Bout Isebahne und schickt Züg auf d Strecki" + }, + "not_enough_money": "Nid gnue Gäud" + }, + "win_modal": { + "support_openfront": "Ungerstütz OpenFront!", + "territory_pattern": "Chouf es Gebietsmuster, um werbefrei z spiele!", + "died": "Du bisch gstorbe", + "your_team": "Dis team het gwunne!", + "other_team": "Team {team} het gwunne!", + "you_won": "Du hesch gwunne!", + "other_won": "{player} het gewunne!", + "nation_won": "Nation {nation} het gwunne!", + "exit": "Spieu verloh", + "keep": "Witerspile", + "spectate": "Zueluege", + "requeue": "No einisch spiele", + "wishlist": "Zur Steam-Wunschliste hinzufüege!", + "join_discord": "Tritt üsere Discord-Community bi!", + "discord_description": "Verbind di mit Spieler, entdeck neui Features und gwinn Prise!", + "join_server": "Server biträtte", + "youtube_tutorial": "Bruchsch hüuf?" + }, + "leaderboard": { + "hide": "Verstecke", + "player": "Spieler", + "team": "Team", + "owned": "Besitze", + "gold": "Gold", + "maxtroops": "Maximali Truppene", + "launchers": "Raggetesilos", + "sams": "Flugabwehrsysteme", + "warships": "Kriegsschiff", + "cities": "Städt", + "show_control": "Stürige azeige", + "show_units": "Einheite azeige" + }, + "events_display": { + "events": "Ereigniss", + "retreating": "Zieht sech zrügg", + "alliance_request_status": "{name} het dini allianzafrog {status}", + "alliance_accepted": "agnoh", + "alliance_rejected": "abglehnt", + "alliance_nukes_destroyed_outgoing": "{count, plural, one {# Atombombe i Richtig {name} si ufgrund vor Allianz zerstört worde} other {# Atombombene i Richtig {name} si ufgrund vor Allianz zerstört worde}}", + "alliance_nukes_destroyed_incoming": "{count, plural, one {# Atombombe vo {name} si ufgrund vor Allianz zerstört worde} other {# Atombombene vo {name} si ufgrund vor Allianz zerstört worde}}", + "duration_second": "1 Sekunde", + "betrayal_description": "Du hesch dini Allianz mit {name} broche, und bisch zum VERRÄTER worde ({malusPercent}% verteidigungs malus für {durationText})", + "duration_seconds_plural": "{seconds} Sekunde", + "betrayed_you": "{name} het d Allianz mit dir broche", + "about_to_expire": "Dini Allianz mit {name} louft gli ab!", + "alliance_expired": "D Allianz mit {name} isch abgloffe", + "attack_request": "{name} bittet di, {target} azgrife", + "sent_emoji": "Gsendet a {name}: {emoji}", + "renew_alliance": "Verlängerig beatrage", + "request_alliance": "{name} bittet um e Allianz!", + "focus": "Fokus", + "accept_alliance": "Akzeptiere", + "reject_alliance": "Ablehne", + "alliance_renewed": "D Allianz mit {name} isch erneuert worde", + "wants_to_renew_alliance": "{name} möchti euri Allianz erneuere", + "ignore": "Ignoriere", + "unit_voluntarily_deleted": "Einheit freiwüuig glöscht", + "betrayal_debuff_ends": "No {time} Sekunde, bis der Verrats-Malus endet", + "attack_cancelled_retreat": "Agriff abbroche, {troops} getöteti Soldate bim Rückzug", + "received_gold_from_captured_ship": "{gold} Gold vo emne Schiff erhalten, wo von {name} erbeutet worde isch", + "received_gold_from_trade": "{gold} Gold us em Handu mit {name} erhaute", + "received_gold_from_conquest": "Du hesch {name} erobert und {gold} Gold erhaute", + "conquered_no_gold": "Du hesch {name} erobert. (Kei Gold, het nid gspiut)", + "missile_intercepted": "Raggete abgfange {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} abgfangene MIRV-Sprengkopf} other {{count} abgefangeni MIRV-Sprengköpfe}}", + "sent_troops_to_player": "{troops} Truppe a {name} gsendet", + "received_troops_from_player": "{troops} Truppe vo {name} erhaute", + "sent_gold_to_player": "{gold} Gold a {name} gsendet", + "received_gold_from_player": "{gold} Gold vo {name} erhaute", + "unit_captured_by_enemy": "Di {unit} isch vo {name} gfange worde", + "captured_enemy_unit": "{unit} vo {name} erfasst", + "unit_destroyed": "Di {unit} isch zerstört worde", + "no_boats_available": "Kei Boot verfüegbar, max. {max}" + }, + "player_type": { + "player": "Spieler", + "nation": "Nation", + "bot": "Stamm" + }, + "relation": { + "hostile": "Findlech", + "distrustful": "Misstrouisch", + "neutral": "Neutrau", + "friendly": "Fründlech", + "default": "Normau" + }, + "player_panel": { + "gold": "Gold", + "troops": "Truppene", + "betrayals": "Spieler verrate", + "traitor": "Verräter", + "trading": "Handu", + "active": "Aktiv", + "stopped": "Aghaute", + "alliance_time_remaining": "Allianz endet i", + "start_trade": "Handu starte", + "stop_trade": "Handu stoppe", + "stop_trade_all": "Handu mit aune stoppe", + "start_trade_all": "Handu mit aune starte", + "alliances": "Allianze", + "chat": "Chat", + "target": "Ziel", + "break_alliance": "Allianz uflöse", + "send_alliance": "Allianz sände", + "send_troops": "Truppe sände", + "send_gold": "Gold sände", + "emotes": "Emojis", + "moderation": "Moderation", + "kick": "Spieler kicke", + "kicked": "Scho kickt", + "kick_confirm": "Kick {name}?\n\nSie werde däm Spiel nid wieder biträtte chönne.", + "arc_up": "Ufwärtsboge", + "arc_down": "Abwärtsboge", + "flip_rocket_trajectory": "Raggetebahn umdräie" + }, + "kick_reason": { + "duplicate_session": "Du bisch us em Spiel kickt worde. (Du hesch wahrschinlech i em ne angere Tab gspüut)", + "lobby_creator": "Du bisch vom Lobby-Ersteller us em Spiel kickt worde" + }, + "send_troops_modal": { + "title_with_name": "Truppe a {name} sende", + "available_tooltip": "Verfügbari Truppe", + "slider_tooltip": "{percent}% • {amount}", + "aria_slider": "Trupperegler", + "capacity_note": "Der Empfänger cha im Moment nume {amount} empfoh." + }, + "send_gold_modal": { + "title_with_name": "Gold a {name} sende", + "available_tooltip": "Verfügbares Gold", + "aria_slider": "Mengeregler", + "slider_tooltip": "{percent}% • {amount}" + }, + "replay_panel": { + "replay_speed": "Wiedergabgschwindigkeit", + "game_speed": "Spiugschwindigkeit", + "fastest_game_speed": "Max" + }, + "error_modal": { + "crashed": "Spiu isch abgstürzt!", + "connection_error": "Verbindigsfähler!", + "paste_discord": "Bitte füeg das i di Fehlerbericht auf Discord i:", + "copy_clipboard": "I d Zwüschenablag kopiere", + "copied": "Kopiert!", + "failed_copy": "Kopiere fäugschlage", + "spawn_failed": { + "title": "Spawn fäugschlage", + "description": "Automatischi Spawn-Uswau fäugschlage. Du chasch das Spiel nid spiele." + }, + "desync_notice": "Du bisch vo angere Spieler desynchronisiert worde. Was du gsehsch, chönnt sech vo angere Spieler ungerscheide." + }, + "performance_overlay": { + "reset": "Zrüggsetze", + "copy_json_title": "Aktuelli Leistigswerte aus JSON kopiere", + "copy_clipboard": "JSON kopiere", + "copied": "Kopiert!", + "failed_copy": "Kopiere fäugschlage", + "fps": "FPS:", + "avg_60s": "Durschnitt (60s):", + "frame": "Frame:", + "tps": "TPS:", + "tps_avg_60s": "Durchschnitt:", + "tick_exec": "Tick usfüerig:", + "max_label": "max:", + "tick_delay": "Tick verzögerig:", + "layers_header": "Ebine rendere", + "render_layers_table_header": "durchschnitt / maximau | tick durchschnitt", + "render_layers_summary": "Letschte Tick: {frames} Frames, {ms}ms", + "tick_layers_header": "Tick Ebine", + "tick_layers_table_header": "durchschnitt / max", + "tick_layers_summary": "Letschte Tick: {count} Ebene, {ms}ms", + "expand": "Ausklappe", + "collapse": "Iklappe" + }, + "heads_up_message": { + "choose_spawn": "Wäu e Startposition", + "random_spawn": "Zuefäuige Spawn isch aktiviert. Startposition wird usgwäut...", + "singleplayer_game_paused": "Spiel pausiert", + "multiplayer_game_paused": "Spiel pausiert vom Lobby-Ersteuer", + "pvp_immunity_active": "PVP Immunität aktiv für {seconds} Sekunde", + "catching_up": "Ufhole..." + }, + "territory_patterns": { + "title": "Gebietsmuschter", + "colors": "Farbene", + "purchase": "Choufe", + "show_only_owned": "Mini Designs", + "all_owned": "Du besitztisch aui Skins! Lueg später wieder ine für neui Artikle.", + "not_logged_in": "Nid agmäudet", + "pattern": { + "default": "Normau" + }, + "select_skin": "Skin uswähle", + "selected": "usgwäut" + }, + "flag_input": { + "title": "Flagge uswähle", + "button_title": "Wäu ä Flagge us!", + "search_flag": "Sueche..." + }, + "radial_menu": { + "delete_unit_title": "Einheit lösche", + "delete_unit_description": "Klick, um die nächstgelegeni Einheit z lösche" + }, + "discord_user_header": { + "avatar_alt": "Profilbüud" + }, + "player_stats_table": { + "building_stats": "Gebäudestatistike", + "ship_arrivals": "Schiffsankünft", + "nuke_stats": "Atomwaffestatistike", + "player_metrics": "Spieler-Metrike", + "building": "Gebäude", + "ship_type": "Schiffstyp", + "weapon": "Waffe", + "built": "Erbout", + "destroyed": "Vernichtet", + "captured": "Ignoh", + "lost": "Verlore", + "hits": "Treffer", + "launched": "Gstartet", + "landed": "glandet", + "sent": "gsendet", + "arrived": "acho", + "attack": "agriff", + "received": "übercho", + "cancelled": "abbroche", + "count": "azau", + "gold": "Gold", + "workers": "Arbeiter", + "war": "Chrieg", + "trade": "Handu", + "steal": "Stähle", + "unit": { + "city": "Stadt", + "port": "Hafe", + "defp": "Verteidigungs poschte", + "saml": "Flugabwehr - Rageetestöuig", + "silo": "Raggetesilo", + "wshp": "Kriegsschiff", + "fact": "Fabrigg", + "trade": "Handuschiff", + "trans": "Transportschiff", + "abomb": "Atombombe", + "hbomb": "Wasserstoffbombe", + "mirv": "Mehrfach Zieu Rageete - MIRV", + "mirvw": "MIRv Sprängchopf" + } + }, + "game_list": { + "recent_games": "Letschti Partie", + "game_id": "Spieu-ID", + "mode": "Modus", + "replay": "Wiederholig", + "details": "Details", + "ranking": "Plazierig", + "map": "Charte", + "difficulty": "Schwierikeitsgrad", + "type": "Typ" + }, + "player_stats_tree": { + "public": "Öffentlech", + "private": "Privat", + "solo": "Elei", + "mode": "Modus", + "stats_wins": "Sieg", + "stats_losses": "Niederlage", + "stats_wlr": "Sieg/Niederlage-Verhäutnis", + "stats_games_played": "Spiel Gspüut", + "no_stats": "Kei Statistike für die Uswau ufzeichnet." + }, + "matchmaking_button": { + "play_ranked": "1gägä1 Ranglischte-Matchmaking", + "description": "(ALPHA)", + "login_required": "Mäud di a um Ranked z spiele!", + "must_login": "Du muesch igloggt si, um Ranked Matchmaking z spiele." + } +} diff --git a/resources/lang/id.json b/resources/lang/id.json index 57abfdd6a..f3fb60918 100644 --- a/resources/lang/id.json +++ b/resources/lang/id.json @@ -23,7 +23,8 @@ "none": "Tidak Satupun", "copied": "Tersalin", "click_to_copy": "Klik untuk salin", - "enabled": "Diaktifkan" + "enabled": "Diaktifkan", + "map_default": "Peta Default" }, "main": { "title": "OpenFront (ALPHA)", @@ -42,7 +43,6 @@ "play": "Main", "news": "Berita", "store": "Toko", - "store_new_badge": "BARU", "settings": "Pengaturan", "leaderboard": "Papan Peringkat", "account": "Akun", @@ -89,7 +89,10 @@ "hotkeys": "Tombol pintas", "table_key": "Kunci", "table_action": "Tindakan", + "action_esc": "Menutup menu. Membatalkan pratinjau pembangunan unit.", + "action_enter": "Membangun unit di posisi kursor", "action_alt_view": "Ganti Tampilan (Medan / Negara)", + "action_coordinate_grid": "Tampilkan/Sembunyikan grid koordinat", "action_attack_altclick": "Serang (saat klik kiri diatur untuk membuka menu)", "action_build": "Buka menu Pembangunan", "action_emote": "Buka menu Ekspresi", @@ -115,7 +118,7 @@ "ui_options": "Pilihan", "ui_options_desc": "Elemen-elemen berikut dapat ditemukan di dalamnya:", "ui_playeroverlay": "Overlay Info Pemain", - "ui_playeroverlay_desc": "Saat kamu mengarahkan kursor ke suatu negara, overlay Info Pemain akan ditampilkan di bawah menu Opsi. Overlay ini menampilkan jenis pemain: Manusia, Negara (bot pintar), atau Bot; sikap suatu Negara terhadapmu, mulai dari Bermusuhan hingga Ramah; serta jumlah pasukan bertahan, emas, jumlah Kapal Perang, dan berbagai bangunan yang dimiliki pemain tersebut.", + "ui_playeroverlay_desc": "Saat kursor diarahkan ke sebuah negara, overlay info pemain akan muncul di bawah Options. Informasi ini menampilkan jenis pemain: Human, Nation, atau Tribe. Ditampilkan juga sikap Nation terhadapmu, dari Hostile hingga Friendly. Selain itu terlihat jumlah pasukan pertahanan, emas, jumlah kapal perang, serta berbagai bangunan yang dimiliki pemain.", "ui_wilderness": "Alam Liar", "option_pause": "Jeda / Lanjutkan permainan – Hanya tersedia dalam mode single-player.", "option_timer": "Timer – Waktu yang telah berlalu sejak permainan dimulai.", @@ -137,7 +140,7 @@ "info_trade": "Gunakan “Hentikan Perdagangan” untuk berhenti memberikan emas kepada pemain tersebut dan berhenti menerima emas dari mereka melalui kapal dagang. Jika kalian berdua menekan “Mulai\".", "info_ally_panel": "Panel Info Sekutu", "info_ally_desc": "Saat kamu beraliansi dengan seorang pemain, ikon-ikon baru berikut akan tersedia:", - "ally_betray": "Mengkhianati sekutumu akan mengakhiri aliansi, menghentikan perdagangan, dan melemahkan pertahananmu. Perdagangan di antara kalian akan dijeda selama 5 menit (atau sampai kalian kembali menjadi sekutu), dan pemain lain juga dapat menghentikan perdagangan. Kecuali jika pemain lain tersebut memang sudah berstatus pengkhianat, kamu akan ditandai sebagai Pengkhianat selama 30 detik.\nSelama waktu ini, sebuah ikon akan muncul di atas namamu dan kamu akan menerima debuff pertahanan sebesar 50%. Bot akan lebih enggan beraliansi denganmu, dan pemain lain akan berpikir dua kali sebelum melakukannya.", + "ally_betray": "Mengkhianati sekutumu akan mengakhiri aliansi, menghentikan perdagangan, dan melemahkan pertahananmu. Perdagangan antara kalian dijeda selama 5 menit (atau sampai kalian bersekutu kembali), dan pemain lain juga bisa berhenti berdagang denganmu.\n\nJika pemain tersebut bukan pengkhianat, kamu akan ditandai sebagai pengkhianat selama 30 detik. Selama waktu ini, sebuah ikon akan muncul di atas namamu dan kamu menerima debuff pertahanan sebesar 50%. Suku (Tribe) akan lebih jarang bersekutu denganmu dan pemain lain akan berpikir dua kali sebelum melakukannya.", "ally_donate": "Donasikan sebagian pasukanmu kepada sekutu. Digunakan ketika mereka kekurangan pasukan, sedang diserang, atau membutuhkan kekuatan tambahan untuk menghancurkan musuh.", "ally_donate_gold": "Donasikan sebagian emasmu kepada sekutu. Digunakan saat mereka kekurangan emas dan membutuhkannya untuk membangun, atau ketika anggota tim sedang menabung untuk MIRV.", "build_menu_title": "Menu Pembangunan", @@ -184,13 +187,15 @@ "toggle_achievements": "Tampilkan / Sembunyikan pencapaian", "sign_in_for_achievements": "Masuk untuk melihat pencapaian", "options_title": "Opsi", - "bots": "Bot: ", + "bots": "Suku-suku: ", "bots_disabled": "Dinonaktifkan", - "disable_nations": "Nonaktifkan negara", + "nations": "Negara: ", + "nations_disabled": "Nonaktif", "instant_build": "Bangun instan", "infinite_gold": "Emas tak terbatas", "infinite_troops": "Pasukan tak terbatas", "compact_map": "Peta Kecil", + "disable_alliances": "Nonaktifkan aliansi", "max_timer": "Lama permainan (menit)", "max_timer_placeholder": "Menit", "max_timer_invalid": "Silakan masukkan nilai pengatur waktu maksimum yang valid (1-120 menit)", @@ -199,8 +204,8 @@ "options_changed_no_achievements": "Pengaturan khusus – pencapaian dinonaktifkan", "gold_multiplier": "Pengganda emas", "gold_multiplier_placeholder": "2.0x", - "starting_gold": "Emas awal", - "starting_gold_placeholder": "5000000" + "starting_gold": "Emas Awal (Juta)", + "starting_gold_placeholder": "5" }, "token_login_modal": { "title": "Sedang masuk...", @@ -264,7 +269,7 @@ "conquest_gold": "Emas pemain yang ditaklukan", "stolen_gold": "Dicuri oleh Kapal Perang", "num_of_conquests_humans": "Pemain membunuh", - "num_of_conquests_bots": "Bot membunuh", + "num_of_conquests_bots": "Suku-suku dikalahkan", "duration": "Durasi", "survival_time": "Menit Bertahan", "war": "Perang", @@ -320,6 +325,7 @@ "svalmel": "Svalmel", "manicouagan": "Manicouagan", "lemnos": "Lemnos", + "passage": "Passage", "sierpinski": "Sierpinski", "thebox": "Kotak", "twolakes": "Dua Danau", @@ -328,8 +334,15 @@ "didier": "Didier", "didierfrance": "Didier (Prancis)", "amazonriver": "Sungai Amazon", + "bosphorusstraits": "Selat Bosphorus", + "beringstrait": "Selat Bering", "tradersdream": "Mimpi para Pedagang", - "hawaii": "Hawaii" + "hawaii": "Hawaii", + "alps": "Alpen", + "niledelta": "Delta Sungai Nil", + "arctic": "Arktik", + "sanfrancisco": "San Francisco", + "aegean": "Aegean" }, "map_categories": { "featured": "Unggulan", @@ -355,14 +368,10 @@ }, "public_lobby": { "title": "Menunggu permainan untuk dimulai...", - "join": "Gabung ke permainan selanjutnya", - "teams_Duos": "{team_count} tim berisi 2 pemain (Berdua)", - "teams_Trios": "{team_count} tim berisi 3 pemain (Bertiga)", - "teams_Quads": "{team_count} tim berisi 4 pemain (Berempat)", "waiting_for_players": "Menunggu pemain", "connecting": "Menghubungkan ke lobi...", "starting_in": "Dimulai dalam {time}", - "starting_game": "Memulai permainan…", + "starting_game": "Memulai…", "teams_hvn": "Pemain vs Negara", "teams_hvn_detailed": "{num} Pemain vs {num} Negara", "teams": "{num} tim", @@ -376,7 +385,8 @@ "connecting": "Menghubungkan ke server pencarian lawan...", "searching": "Mencari permainan...", "waiting_for_game": "Menunggu permainan untuk dimulai...", - "elo": "ELO anda: {elo}" + "elo": "ELO anda: {elo}", + "no_elo": "Belum ada ELO" }, "username": { "enter_username": "Masukkan nama pengguna", @@ -390,15 +400,15 @@ }, "host_modal": { "title": "Buat Lobi Tertutup", - "label": "Tertutup", "mode": "Mode", "team_count": "Jumlah Tim", "team_type": "Tipe Tim", "options_title": "Pilihan", - "bots": "Bot: ", + "bots": "Suku-suku: ", "bots_disabled": "Nonaktif", + "nations": "Negara: ", + "nations_disabled": "Nonaktif", "player_immunity_duration": "Durasi imunitas PVP (menit)", - "disable_nations": "Nonaktifkan Negara", "max_timer": "Lama permainan (menit)", "mins_placeholder": "Menit", "instant_build": "Bangun instan", @@ -407,6 +417,7 @@ "infinite_troops": "Pasukan tak terbatas", "donate_troops": "Donasikan pasukan", "compact_map": "Peta Kecil", + "disable_alliances": "Nonaktifkan aliansi", "enables_title": "Aktifkan Pengaturan", "player": "Pemain", "players": "Pemain", @@ -424,8 +435,13 @@ "teams_Trios": "Bertiga (tim yang terdiri dari 3 orang)", "teams_Quads": "Berempat (tim yang teridri dari 4 orang)", "teams_Humans Vs Nations": "Pemain vs Negara", - "starting_gold": "Emas awal", - "crowded": "Pengubah yang ramai" + "crowded": "Pengubah yang ramai", + "hard_nations": "Negara Sulit", + "gold_multiplier": "Pengganda emas", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Emas Awal (Juta)", + "starting_gold_placeholder": "5", + "leave_confirmation": "Apakah Anda yakin ingin meninggalkan lobi?" }, "team_colors": { "red": "Merah", @@ -435,7 +451,9 @@ "yellow": "Kuning", "orange": "Oranye", "green": "Hijau", - "bot": "Bot" + "bot": "Suku-suku", + "humans": "Manusia", + "nations": "Negara" }, "game_starting_modal": { "title": "Memulai Permainan...", @@ -453,16 +471,29 @@ "ffa": "Siapapun bisa bergabung", "teams": "Tim-tim" }, + "mode_selector": { + "teams_title": "Tim-tim", + "teams_count": "{teamCount} tim", + "teams_of": "{teamCount} tim dari {playersPerTeam}", + "ranked_title": "Ranked", + "ranked_1v1_title": "1v1", + "ranked_2v2_title": "2v1", + "coming_soon": "Segera Hadir" + }, "public_game_modifier": { "random_spawn": "Kemunculan Acak", "compact_map": "Peta Kecil", "crowded": "Ramai", - "starting_gold": "Emas awal 5 Juta" + "hard_nations": "Negara Sulit", + "starting_gold": "{amount} Juta Emas Awal", + "gold_multiplier": "x{amount} Pengganda Emas", + "disable_alliances": "Aliansi Dinonaktifkan" }, "select_lang": { "title": "Pilih Bahasa" }, "unit_type": { + "boat": "Perahu", "city": "Kota", "defense_post": "Pos Pertahanan", "port": "Pelabuhan", @@ -502,6 +533,8 @@ "attack_ratio_desc": "Persentase pasukan yang dikirim saat menyerang (1–100%)", "territory_patterns_label": "🏳️ Skin Wilayah", "territory_patterns_desc": "Pilih apakah ingin menampilkan desain skin wilayah di dalam game", + "coordinate_grid_label": "Koordinat Grid", + "coordinate_grid_desc": "Tampilkan/Sembunyikan grid alfanumerik", "performance_overlay_label": "Tampilan Performa", "performance_overlay_desc": "Aktifkan / Nonaktifkan overlay performa.\nSaat diaktifkan, overlay performa akan ditampilkan. Tekan Shift + D saat permainan berlangsung untuk mengaktifkan atau menonaktifkannya.", "easter_writing_speed_label": "Multiplier Kecepatan Menulis", @@ -540,9 +573,11 @@ "emoji_menu_modifier_desc": "Tahan tombol ini sambil mengklik untuk membuka menu emoji.", "attack_ratio_controls": "Kontrol Rasio Serangan", "attack_ratio_up": "Tingkatkan Rasio Serangan", - "attack_ratio_up_desc": "Tingkatkan Rasio Serangan sebesar 10%", + "attack_ratio_up_desc": "Tambah rasio serangan sebesar {amount}%", "attack_ratio_down": "Kurangi Rasio Serangan", - "attack_ratio_down_desc": "Kurangi Rasio Serangan sebesar 10%", + "attack_ratio_down_desc": "Kurangi rasio serangan sebesar {amount}%", + "attack_ratio_increment_label": "Kenaikan Rasio Serangan (Keybind)", + "attack_ratio_increment_desc": "Seberapa besar rasio serangan berubah setiap kali tombol keybind ditekan.", "attack_keybinds": "Tombol pintas untuk Serangan", "boat_attack": "Serangan Kapal Pengangkut", "boat_attack_desc": "Kirim serangan kapal ke petak di bawah kursor Anda.", @@ -662,7 +697,8 @@ "mirv_ready": "[P1] punya cukup emas untuk meluncurkan MIRV!", "snowballing": "[P1] berkembang terlalu cepat!", "cheating": "[P1] curang!", - "stop_trading": "Stop berdangan dengan [P1]!" + "stop_trading": "Stop berdangan dengan [P1]!", + "stop_trading_all": "Tolong hentikan perdagangan dengan semua pemain!" } }, "build_menu": { @@ -758,7 +794,7 @@ "player_type": { "player": "Pemain", "nation": "Bangsa", - "bot": "Bot" + "bot": "Suku" }, "relation": { "hostile": "Berseteru", @@ -840,9 +876,19 @@ "fps": "FPS:", "avg_60s": "Rata-rata (60d):", "frame": "Bingkai:", + "tps": "TPS:", + "tps_avg_60s": "Rata-rata:", "tick_exec": "Eksekutif Tick:", + "max_label": "maks:", "tick_delay": "Penundaan Detik:", - "layers_header": "Lapisan (rata-rata / maksimum, diurutkan berdasarkan total waktu):" + "layers_header": "Lapisan Render", + "render_layers_table_header": "rata-rata / max | rata-rata tick", + "render_layers_summary": "Tick terakhir: {frames} frame, {ms} ms", + "tick_layers_header": "Lapisan Tick", + "tick_layers_table_header": "rata-rata / maks", + "tick_layers_summary": "Tick terakhir: {count} lapisan, {ms} ms", + "expand": "Perluas", + "collapse": "Ciutkan" }, "heads_up_message": { "choose_spawn": "Pilih lokasi awal", @@ -862,13 +908,6 @@ "pattern": { "default": "Default" }, - "try_me": "Coba aku!", - "trial_remaining": "tersisa", - "trial_granted": "Skin trial didapatkan!", - "trial_cooldown": "Hanya satu trial dalam 24 jam. Mohon coba lagi nanti.", - "trial_login_required": "Harus masuk untuk mencoba skin", - "reward_countdown": "Hadiah dalam {seconds} detik...", - "steam_wishlist_prompt": "Dukung OpenFront dengan cara menambahkannya di wishlist Steam Anda", "select_skin": "Pilih Skin", "selected": "dipilih" }, @@ -930,8 +969,6 @@ "recent_games": "Permainan Terbaru", "game_id": "ID Permainan", "mode": "Mode", - "mode_ffa": "Siapapun bisa bergabung", - "mode_team": "Tim", "replay": "Tayangan ulang", "details": "Detail", "ranking": "Peringkat", @@ -948,8 +985,6 @@ "stats_losses": "Jumlah Kehilangan", "stats_wlr": "Menang:Kalah Rasio", "stats_games_played": "Permainan Dimainkan", - "mode_ffa": "Siapapun bisa bergabung", - "mode_team": "Tim", "no_stats": "Tidak ada statistik yang tercatat untuk pilihan ini." }, "matchmaking_button": { diff --git a/resources/lang/metadata.json b/resources/lang/metadata.json index 114898706..dfd325b51 100644 --- a/resources/lang/metadata.json +++ b/resources/lang/metadata.json @@ -35,6 +35,12 @@ "en": "Danish", "svg": "dk" }, + { + "code": "de-CH", + "native": "Schweizerdeutsch", + "en": "Swiss-German", + "svg": "gsw" + }, { "code": "de", "native": "Deutsch", diff --git a/resources/lang/ru.json b/resources/lang/ru.json index d471bc96e..45206d752 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -23,7 +23,8 @@ "none": "Ничего", "copied": "Скопировано!", "click_to_copy": "Нажмите, чтобы скопировать", - "enabled": "Включено" + "enabled": "Включено", + "map_default": "Стандартно для карты" }, "main": { "title": "OpenFront (АЛЬФА)", @@ -42,7 +43,6 @@ "play": "Играть", "news": "Новости", "store": "Магазин", - "store_new_badge": "НОВОЕ", "settings": "Настройки", "leaderboard": "Таблица лидеров", "account": "Аккаунт", @@ -89,7 +89,10 @@ "hotkeys": "Горячие клавиши", "table_key": "Клавиша", "table_action": "Действие", + "action_esc": "Закрывает меню. Отменяет просмотр размещения юнита.", + "action_enter": "Размещает юнит под указателем", "action_alt_view": "Альтернативное представление (рельеф/страны)", + "action_coordinate_grid": "Переключить оверлей координатной сетки", "action_attack_altclick": "Атака (если левая кнопка мыши назначена на открытие меню)", "action_build": "Открыть меню строительства", "action_emote": "Открыть меню эмодзи", @@ -115,7 +118,7 @@ "ui_options": "Настройки", "ui_options_desc": "Среди них можно найти следующие элементы:", "ui_playeroverlay": "Панель информации об игроке", - "ui_playeroverlay_desc": "Когда вы наведите курсор на страну, под кнопкой «Настройки» отображается панель информации об игроке. Он показывает тип игрока: человек, нация (умный бот) или бот. Отношение нации к вам: от враждебного до дружеского. И количество войск защиты, золота, военных кораблей и разных строений в собственности игрока.", + "ui_playeroverlay_desc": "Когда вы наведите курсор на страну, под кнопкой «Настройки» отображается панель информации об игроке. Он показывает тип игрока: человек, нация или племя. Отношение нации к вам: от враждебного до дружеского. И количество войск защиты, золота, военных кораблей и разных строений в собственности игрока.", "ui_wilderness": "Пустошь", "option_pause": "Приостановить/Продолжить игру — Доступно только в режиме одиночной игры.", "option_timer": "Таймер — Время, прошедшее с начала игры.", @@ -137,7 +140,7 @@ "info_trade": "Используйте «Прекратить торговлю», чтобы перестать давать игроку золото и получать золото от него через торговые корабли. Если вы оба нажмёте «Начать торговлю», то она начнётся снова.", "info_ally_panel": "Панель информации о союзнике", "info_ally_desc": "Когда вы заключите союз с игроком, станут доступны следующие значки:", - "ally_betray": "Предать своего союзника, разорвав союз, прекратив торговлю и ослабив свою защиту. Торговля между вами приостановиться на 5 минут (или до возобновления союза), а другие игроки также могут перестать торговать с вами. Если другой игрок не был предателем, вы получите метку предателя на 30 секунд. В это время над вашим именем появится особый значок и ваша защита будет снижена на 50%. Боты с меньшей вероятность будут заключать с вами союзы, а игроки дважды подумают перед тем, как иметь с вами дело.", + "ally_betray": "Предать своего союзника, разорвав союз, прекратив торговлю и ослабив свою защиту. Торговля между вами приостановиться на 5 минут (или до возобновления союза), а другие игроки также могут перестать торговать с вами. Если другой игрок не был предателем, вы получите метку предателя на 30 секунд. В это время над вашим именем появится особый значок и ваша защита будет снижена на 50%. Племена с меньшей вероятность будут заключать с вами союзы, а игроки дважды подумают перед тем, как иметь с вами дело.", "ally_donate": "Пожертвовать часть войска союзнику. Используется, когда у него мало войск и его атакуют, или когда ему нужна дополнительная мощь для уничтожения врага.", "ally_donate_gold": "Пожертвовать немного золота союзнику. Используйте, когда у него мало золота, необходимого для сооружений, или когда член команды копит на РГЧ ИН.", "build_menu_title": "Меню строительства", @@ -184,13 +187,15 @@ "toggle_achievements": "Переключение достижений", "sign_in_for_achievements": "Войдите, чтобы получать достижения", "options_title": "Настройки", - "bots": "Боты: ", + "bots": "Племена: ", "bots_disabled": "Отключены", - "disable_nations": "Отключить нации", + "nations": "Нации: ", + "nations_disabled": "Отключены", "instant_build": "Мгновенная стройка", "infinite_gold": "Неограниченное золото", "infinite_troops": "Неограниченные войска", "compact_map": "Компактная карта", + "disable_alliances": "Отключить союзы", "max_timer": "Продолжительность игры (минуты)", "max_timer_placeholder": "Минуты", "max_timer_invalid": "Пожалуйста, введите допустимое максимальное значение таймера (1–120 минут)", @@ -199,8 +204,8 @@ "options_changed_no_achievements": "Пользовательские настройки – достижения выключены", "gold_multiplier": "Множитель золота", "gold_multiplier_placeholder": "2.0x", - "starting_gold": "Начальное золото", - "starting_gold_placeholder": "5000000" + "starting_gold": "Начальное золото (миллионы)", + "starting_gold_placeholder": "5" }, "token_login_modal": { "title": "Вход...", @@ -264,7 +269,7 @@ "conquest_gold": "Захваченное золото игроков", "stolen_gold": "Украдено с помощью военных кораблей", "num_of_conquests_humans": "Убийства игроков", - "num_of_conquests_bots": "Убийства ботов", + "num_of_conquests_bots": "Убийства племён", "duration": "Продолжительность", "survival_time": "Время выживания", "war": "Война", @@ -320,6 +325,7 @@ "svalmel": "Свалмель", "manicouagan": "Маникуаган", "lemnos": "Лемнос", + "passage": "Проход", "sierpinski": "Серпинский", "thebox": "Коробка", "twolakes": "Два озера", @@ -328,8 +334,15 @@ "didier": "Дидье", "didierfrance": "Дидье (Франция)", "amazonriver": "Река Амазонка", + "bosphorusstraits": "Босфорский пролив", + "beringstrait": "Берингов пролив", "tradersdream": "Мечта торговца", - "hawaii": "Гавайи" + "hawaii": "Гавайи", + "alps": "Альпы", + "niledelta": "Дельта Нила", + "arctic": "Арктика", + "sanfrancisco": "Сан-Франциско", + "aegean": "Эгейск" }, "map_categories": { "featured": "Рекомендованные", @@ -355,14 +368,10 @@ }, "public_lobby": { "title": "Ожидание начала игры...", - "join": "Присоединиться к следующей игре", - "teams_Duos": "{team_count} команды по 2 (дуо)", - "teams_Trios": "{team_count} команды по 3 (трио)", - "teams_Quads": "{team_count} команды по 4 (квады)", "waiting_for_players": "Ожидание игроков", "connecting": "Подключение к лобби...", "starting_in": "Начало через {time}", - "starting_game": "Запуск игры…", + "starting_game": "Запуск…", "teams_hvn": "Люди против наций", "teams_hvn_detailed": "{num} людей против {num} наций", "teams": "Команд: {num}", @@ -376,7 +385,8 @@ "connecting": "Подключение к серверу подбора игроков...", "searching": "Поиск игры...", "waiting_for_game": "Ожидание начала игры...", - "elo": "Ваш ELO: {elo}" + "elo": "Ваш ELO: {elo}", + "no_elo": "ELO отсутствует" }, "username": { "enter_username": "Введите своё имя игрока", @@ -390,15 +400,15 @@ }, "host_modal": { "title": "Создание приватного лобби", - "label": "Приватно", "mode": "Режим", "team_count": "Количество команд", "team_type": "Тип команды", "options_title": "Настройки", - "bots": "Боты: ", + "bots": "Племена: ", "bots_disabled": "Отключены", + "nations": "Нации: ", + "nations_disabled": "Отключены", "player_immunity_duration": "Продолжительность иммунитета в PVP (минуты)", - "disable_nations": "Отключить нации", "max_timer": "Продолжительность игры (минуты)", "mins_placeholder": "Минуты", "instant_build": "Мгновенная стройка", @@ -407,6 +417,7 @@ "infinite_troops": "Неограниченные войска", "donate_troops": "Пожертвование войск", "compact_map": "Компактная карта", + "disable_alliances": "Отключить союзы", "enables_title": "Разрешения", "player": "Игрок", "players": "Игрока(-ов)", @@ -424,8 +435,13 @@ "teams_Trios": "Трио (команды по 3)", "teams_Quads": "Квады (команды по 4)", "teams_Humans Vs Nations": "Люди против наций", - "starting_gold": "Начальное золото", - "crowded": "Модификатор перенаселения" + "crowded": "Модификатор перенаселения", + "hard_nations": "Сложные нации", + "gold_multiplier": "Множитель золота", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Начальное золото (миллионы)", + "starting_gold_placeholder": "5", + "leave_confirmation": "Вы уверены, что хотите покинуть лобби?" }, "team_colors": { "red": "Красный", @@ -435,7 +451,9 @@ "yellow": "Жёлтый", "orange": "Оранжевый", "green": "Зелёный", - "bot": "Бот" + "bot": "Племена", + "humans": "Люди", + "nations": "Нации" }, "game_starting_modal": { "title": "Игра начинается...", @@ -453,16 +471,29 @@ "ffa": "Каждый против каждого (FFA)", "teams": "Команды" }, + "mode_selector": { + "teams_title": "Команды", + "teams_count": "Команды: {teamCount}", + "teams_of": "Команды: {teamCount} по {playersPerTeam}", + "ranked_title": "Рейтинговая игра", + "ranked_1v1_title": "1v1", + "ranked_2v2_title": "2v2", + "coming_soon": "Скоро" + }, "public_game_modifier": { "random_spawn": "Случайное появления", "compact_map": "Компактная карта", "crowded": "Перенаселение", - "starting_gold": "5 млн начального золота" + "hard_nations": "Сложные нации", + "starting_gold": "Начальное золото: {amount} млн", + "gold_multiplier": "Множитель золота: x{amount}", + "disable_alliances": "Союзы отключены" }, "select_lang": { "title": "Выбор языка" }, "unit_type": { + "boat": "Судно", "city": "Город", "defense_post": "Укрепление", "port": "Порт", @@ -502,6 +533,8 @@ "attack_ratio_desc": "Какой процент ваших войск отправлять в бой (1–100%)", "territory_patterns_label": "🏳️ Скины территории", "territory_patterns_desc": "Выберите, показывать ли скины территорий в игре", + "coordinate_grid_label": "Координатная сетка", + "coordinate_grid_desc": "Переключить оверлей буквенно-цифровой сетки", "performance_overlay_label": "Оверлей производительности", "performance_overlay_desc": "Переключить оверлей производительности. При включении будет показан оверлей производительности. Нажмите Shift+D во время игры для переключения.", "easter_writing_speed_label": "Множитель скорости печати", @@ -540,9 +573,11 @@ "emoji_menu_modifier_desc": "Удерживайте эту клавишу при нажатии, чтобы открыть меню эмодзи.", "attack_ratio_controls": "Управление соотношением атаки", "attack_ratio_up": "Увеличить соотношение атаки", - "attack_ratio_up_desc": "Увеличить соотношение атаки на 10%", + "attack_ratio_up_desc": "Увеличить соотношение атаки на {amount}%", "attack_ratio_down": "Уменьшить соотношение атаки", - "attack_ratio_down_desc": "Уменьшить соотношение атаки на 10%", + "attack_ratio_down_desc": "Уменьшить соотношение атаки на {amount}%", + "attack_ratio_increment_label": "Разница соотношения атаки", + "attack_ratio_increment_desc": "Насколько соотношение атаки изменяется за одно нажатие клавиши.", "attack_keybinds": "Привязки клавиш атаки", "boat_attack": "Атака судом", "boat_attack_desc": "Отправить атаку лодкой на ячейку под указателем.", @@ -662,7 +697,8 @@ "mirv_ready": "У [P1] достаточно золота на запуск РГЧ ИН!", "snowballing": "[P1] атакует слишком быстро!", "cheating": "[P1] мухлюет!", - "stop_trading": "Прекратите торговать с [P1]!" + "stop_trading": "Прекратите торговать с [P1]!", + "stop_trading_all": "Пожалуйста, прекратите торговлю со всеми!" } }, "build_menu": { @@ -758,7 +794,7 @@ "player_type": { "player": "Игрок", "nation": "Нация", - "bot": "Бот" + "bot": "Племя" }, "relation": { "hostile": "Враждебное", @@ -840,9 +876,19 @@ "fps": "FPS:", "avg_60s": "Среднее (60 сек):", "frame": "Кадр:", + "tps": "TPS:", + "tps_avg_60s": "Сред.:", "tick_exec": "Выполнение на тик:", + "max_label": "Макс.:", "tick_delay": "Задержка на тик:", - "layers_header": "Слои (ср. / макс., отсортированы по суммарному времени):" + "layers_header": "Слои визуализации", + "render_layers_table_header": "сред. / макс. | тики", + "render_layers_summary": "Последний тик: {frames} кадров, {ms} мс", + "tick_layers_header": "Слои тиков", + "tick_layers_table_header": "сред. / макс.", + "tick_layers_summary": "Последний тик: {count} слоёв, {ms} мс", + "expand": "Развернуть", + "collapse": "Свернуть" }, "heads_up_message": { "choose_spawn": "Выберите стартовое местоположение", @@ -862,13 +908,6 @@ "pattern": { "default": "По умолчанию" }, - "try_me": "Примерь меня!", - "trial_remaining": "осталось", - "trial_granted": "Пробный скин предоставлен!", - "trial_cooldown": "Можно примерить только один скин в течение 24 часов. Пожалуйста, попробуйте ещё раз позже.", - "trial_login_required": "Необходимо войти в систему, чтобы получить пробный скин", - "reward_countdown": "Вознаграждение через {seconds} сек...", - "steam_wishlist_prompt": "Поддержите OpenFront, добавив его в список желаемого Steam", "select_skin": "Выберете скин", "selected": "выбрано" }, @@ -930,8 +969,6 @@ "recent_games": "Недавние игры", "game_id": "ID игры", "mode": "Режим", - "mode_ffa": "Каждый против каждого", - "mode_team": "Команда", "replay": "Повтор", "details": "Подробности", "ranking": "Рейтинг", @@ -948,8 +985,6 @@ "stats_losses": "Поражения", "stats_wlr": "Соотношение побед:поражений", "stats_games_played": "Игр сыграно", - "mode_ffa": "Все против всех", - "mode_team": "Команда", "no_stats": "Нет данных для этой выборки." }, "matchmaking_button": { diff --git a/resources/lang/uk.json b/resources/lang/uk.json index 3a399be46..1faf88a33 100644 --- a/resources/lang/uk.json +++ b/resources/lang/uk.json @@ -23,7 +23,8 @@ "none": "Немає", "copied": "Скопійовано!", "click_to_copy": "Натисніть, щоб скопіювати", - "enabled": "Увімкнено" + "enabled": "Увімкнено", + "map_default": "Усталене для мапи" }, "main": { "title": "OpenFront (АЛЬФА)", @@ -42,7 +43,6 @@ "play": "Грати", "news": "Новини", "store": "Крамниця", - "store_new_badge": "НОВЕ", "settings": "Налаштування", "leaderboard": "Таблиця лідерів", "account": "Акаунт", @@ -89,7 +89,10 @@ "hotkeys": "Гарячі клавіші", "table_key": "Клавіша", "table_action": "Дія", + "action_esc": "Закриває меню. Скасовує перегляд розміщення юніту.", + "action_enter": "Розміщає юніт під указівником", "action_alt_view": "Альтернативний вигляд (рельєф/країни)", + "action_coordinate_grid": "Перемкнути оверлей координатної сітки", "action_attack_altclick": "Атака (коли лівий клац призначено на відкриття меню)", "action_build": "Відкрити меню будівництва", "action_emote": "Відкрити меню емоджі", @@ -115,7 +118,7 @@ "ui_options": "Налаштування", "ui_options_desc": "Серед них можна знайти наступні елементи:", "ui_playeroverlay": "Панель інформації про гравця", - "ui_playeroverlay_desc": "Коли ви наводите вказівник на країну, під значком налаштувань відображається панель інформації про гравця. Вона показує тип гравця: людина, нація (розумний бот) або бот; ставлення нації до вас — від ворожого до товариського; а також кількість оборонних військ, золота, військових кораблів і різних будівель, що належать гравцеві.", + "ui_playeroverlay_desc": "Коли ви наводите вказівник на країну, під значком налаштувань відображається панель інформації про гравця. Вона показує тип гравця: людина, нація або племʼя; ставлення нації до вас — від ворожого до товариського; а також кількість оборонних військ, золота, військових кораблів і різних будівель, що належать гравцеві.", "ui_wilderness": "Пустир", "option_pause": "Призупинити/Продовжити гру — Доступно лише в режимі гри наодинці.", "option_timer": "Таймер — Час, що минув із початку гри.", @@ -137,7 +140,7 @@ "info_trade": "Використайте «Припинити торгівлю», щоб припинити давати гравцеві золото та отримувати золото від нього через торгові кораблі. Якщо ви обидва натиснете «Розпочати торгівлю», вона розпочнеться знову.", "info_ally_panel": "Панель інформації про союзника", "info_ally_desc": "Коли ви укладете союз із гравцем, буде розблоковано наступні значки:", - "ally_betray": "Зрадити свого союзника, розірвавши союз, припинивши торгівлю та послабивши свою оборону. Торгівля між вами призупиняється на 5 хвилин (або до відновлення союзу), і інші також можуть припинити торгівлю з вами. Якщо інший гравець сам не був зрадником, ви отримаєте мітку зрадника на 30 секунд. У цей час над вашим імʼям зʼявиться особливий значок, а ваша оборона знизиться на 50%. Боти рідше укладатимуть із вами союзи, а гравці двічі подумають, перш ніж мати з вами справу.", + "ally_betray": "Зрадити свого союзника, розірвавши союз, припинивши торгівлю та послабивши свою оборону. Торгівля між вами призупиняється на 5 хвилин (або до відновлення союзу), і інші також можуть припинити торгувати з вами. Якщо інший гравець сам не був зрадником, ви отримаєте мітку зрадника на 30 секунд. У цей час над вашим імʼям зʼявиться особливий значок, а ваша оборона знизиться на 50%. Племена рідше укладатимуть з вами союзи, а гравці двічі подумають, перш ніж мати з вами справу.", "ally_donate": "Пожертвувати частину своїх військ союзнику. Використовується, коли в нього мало військ і його атакують, або коли йому необхідна додаткова сила для знищення ворога.", "ally_donate_gold": "Пожертвувати частину свого золота союзнику. Використовуйте, коли в нього мало золота, яке він потребує для будівель, або коли член команди заощаджує на РГЧ ІН.", "build_menu_title": "Меню будівництва", @@ -184,13 +187,15 @@ "toggle_achievements": "Перемикання досягнень", "sign_in_for_achievements": "Увійдіть, щоб отримувати досягнення", "options_title": "Налаштування", - "bots": "Боти: ", + "bots": "Племена: ", "bots_disabled": "Відключені", - "disable_nations": "Вимкнути нації", + "nations": "Нації: ", + "nations_disabled": "Відключені", "instant_build": "Миттєве будівництво", "infinite_gold": "Необмежене золото", "infinite_troops": "Необмежені війська", "compact_map": "Компактна мапа", + "disable_alliances": "Вимкнути союзи", "max_timer": "Тривалість гри (хвилини)", "max_timer_placeholder": "Хвилини", "max_timer_invalid": "Будь ласка, введіть дійсне максимальне значення таймера (1–120 хвилин)", @@ -199,8 +204,8 @@ "options_changed_no_achievements": "Власні налаштування — досягнення вимкнені", "gold_multiplier": "Множник золота", "gold_multiplier_placeholder": "2.0х", - "starting_gold": "Початкове золото", - "starting_gold_placeholder": "5000000" + "starting_gold": "Початкове золото (мільйони)", + "starting_gold_placeholder": "5" }, "token_login_modal": { "title": "Вхід...", @@ -264,7 +269,7 @@ "conquest_gold": "Загарбане золото гравців", "stolen_gold": "Викрадено воєнними кораблями", "num_of_conquests_humans": "Убивства гравців", - "num_of_conquests_bots": "Убивства ботів", + "num_of_conquests_bots": "Убивства племен", "duration": "Тривалість", "survival_time": "Час виживання", "war": "Війна", @@ -320,6 +325,7 @@ "svalmel": "Свалмел", "manicouagan": "Манікуаган", "lemnos": "Лемнос", + "passage": "Прохід", "sierpinski": "Серпінський", "thebox": "Коробка", "twolakes": "Два озера", @@ -328,8 +334,15 @@ "didier": "Дідьє", "didierfrance": "Дідьє (Франція)", "amazonriver": "Річка Амазонка", + "bosphorusstraits": "Босфорська протока", + "beringstrait": "Берингова протока", "tradersdream": "Мрія крамаря", - "hawaii": "Гаваї" + "hawaii": "Гаваї", + "alps": "Альпи", + "niledelta": "Дельта Нілу", + "arctic": "Арктика", + "sanfrancisco": "Сан-Франциско", + "aegean": "Егейськ" }, "map_categories": { "featured": "Рекомендовані", @@ -355,14 +368,10 @@ }, "public_lobby": { "title": "Очікування початку гри...", - "join": "Приєднатися до наступної гри", - "teams_Duos": "{team_count} команд по 2 (дуо)", - "teams_Trios": "{team_count} команд по 3 (тріо)", - "teams_Quads": "{team_count} команд по 4 (квади)", "waiting_for_players": "Очікування гравців", "connecting": "Підключення до лобі...", "starting_in": "Початок через {time}", - "starting_game": "Початок гри…", + "starting_game": "Запуск…", "teams_hvn": "Люди проти націй", "teams_hvn_detailed": "{num} людей проти {num} націй", "teams": "Команди: {num}", @@ -376,7 +385,8 @@ "connecting": "Приєднання до сервера підбору гравців...", "searching": "Пошук гри...", "waiting_for_game": "Очікування початку гри...", - "elo": "Ваш ELO: {elo}" + "elo": "Ваш ELO: {elo}", + "no_elo": "ELO відсутній" }, "username": { "enter_username": "Введіть своє імʼя гравця", @@ -390,15 +400,15 @@ }, "host_modal": { "title": "Створення приватного лобі", - "label": "Приватно", "mode": "Режим", "team_count": "Кількість команд", "team_type": "Тип команди", "options_title": "Налаштування", - "bots": "Боти: ", + "bots": "Племена: ", "bots_disabled": "Відключені", + "nations": "Нації: ", + "nations_disabled": "Відключені", "player_immunity_duration": "Тривалість імунітету в PVP (хвилини)", - "disable_nations": "Вимкнути нації", "max_timer": "Тривалість гри (хвилини)", "mins_placeholder": "Хвилини", "instant_build": "Миттєве будівництво", @@ -407,6 +417,7 @@ "infinite_troops": "Безмежні війська", "donate_troops": "Пожертвування військ", "compact_map": "Компактна мапа", + "disable_alliances": "Вимкнути союзи", "enables_title": "Дозволи", "player": "Гравець", "players": "Гравці(в)", @@ -424,8 +435,13 @@ "teams_Trios": "Тріо (команди по 3)", "teams_Quads": "Квади (команди по 4)", "teams_Humans Vs Nations": "Люди проти націй", - "starting_gold": "Початкове золото", - "crowded": "Модифікатор перенаселення" + "crowded": "Модифікатор перенаселення", + "hard_nations": "Важкі нації", + "gold_multiplier": "Множник золота", + "gold_multiplier_placeholder": "2.0х", + "starting_gold": "Початкове золото (мільйони)", + "starting_gold_placeholder": "5", + "leave_confirmation": "Ви впевнені, що хочете вийти з лобі?" }, "team_colors": { "red": "Червоний", @@ -435,7 +451,9 @@ "yellow": "Жовтий", "orange": "Помаранчевий", "green": "Зелений", - "bot": "Бот" + "bot": "Племена", + "humans": "Люди", + "nations": "Нації" }, "game_starting_modal": { "title": "Гра починається...", @@ -453,16 +471,29 @@ "ffa": "Усі проти всіх", "teams": "Команди" }, + "mode_selector": { + "teams_title": "Команди", + "teams_count": "Команди: {teamCount}", + "teams_of": "Команди: {teamCount} по {playersPerTeam}", + "ranked_title": "Рейтингова гра", + "ranked_1v1_title": "1v1", + "ranked_2v2_title": "2v2", + "coming_soon": "Незабаром" + }, "public_game_modifier": { "random_spawn": "Випадкова поява", "compact_map": "Компактна мапа", "crowded": "Перенаселення", - "starting_gold": "5 млн початкового золота" + "hard_nations": "Важкі нації", + "starting_gold": "Початкове золото: {amount} млн", + "gold_multiplier": "Множник золота: x{amount}", + "disable_alliances": "Союзи вимкнено" }, "select_lang": { "title": "Вибір мови" }, "unit_type": { + "boat": "Човен", "city": "Місто", "defense_post": "Пункт оборони", "port": "Порт", @@ -502,6 +533,8 @@ "attack_ratio_desc": "Який відсоток ваших військ відправляти в наступ (1–100%)", "territory_patterns_label": "🏳️ Скіни території", "territory_patterns_desc": "Виберіть, чи показувати скіни територій у грі", + "coordinate_grid_label": "Координатна сітка", + "coordinate_grid_desc": "Перемкнути оверлей літеро-цифрової сітки", "performance_overlay_label": "Оверлей продуктивности", "performance_overlay_desc": "Перемкнути оверлей продуктивности. Якщо увімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб перемкнути його.", "easter_writing_speed_label": "Множник швидкості друку", @@ -540,9 +573,11 @@ "emoji_menu_modifier_desc": "Утримуйте цю клавішу під час клацання, щоб відкрити меню емоджі.", "attack_ratio_controls": "Керування коефіцієнтом атаки", "attack_ratio_up": "Збільшити коефіцієнт атаки", - "attack_ratio_up_desc": "Збільшити коефіцієнт атаки на 10%", + "attack_ratio_up_desc": "Збільшити коефіцієнт атаки на {amount}%", "attack_ratio_down": "Зменшити коефіцієнт атаки", - "attack_ratio_down_desc": "Зменшити коефіцієнт атаки на 10%", + "attack_ratio_down_desc": "Зменшити коефіцієнт атаки на {amount}%", + "attack_ratio_increment_label": "Різниця коефіцієнту атаки", + "attack_ratio_increment_desc": "Наскільки коефіцієнт атаки змінюється за один натиск клавіші.", "attack_keybinds": "Призначення клавіш для атаки", "boat_attack": "Атака човном", "boat_attack_desc": "Відправити човен на клітинку під указівником.", @@ -662,7 +697,8 @@ "mirv_ready": "[P1] має достатньо золота для запуску РГЧ ІН!", "snowballing": "[P1] атакує занадто швидко!", "cheating": "[P1] шахрує!", - "stop_trading": "Припиніть торгівлю з [P1]!" + "stop_trading": "Припиніть торгівлю з [P1]!", + "stop_trading_all": "Будь ласка, припиніть торгувати з усіма!" } }, "build_menu": { @@ -758,7 +794,7 @@ "player_type": { "player": "Гравець", "nation": "Нація", - "bot": "Бот" + "bot": "Племʼя" }, "relation": { "hostile": "Вороже", @@ -840,9 +876,19 @@ "fps": "FPS:", "avg_60s": "Сер. (60 сек):", "frame": "Кадр:", + "tps": "TPS:", + "tps_avg_60s": "Сер.:", "tick_exec": "Виконання на тік:", + "max_label": "Макс.:", "tick_delay": "Затримка на тік:", - "layers_header": "Шари (сер. / макс., відсортовано за загальним часом):" + "layers_header": "Шари візуалізації", + "render_layers_table_header": "сер. / макс. | тіки", + "render_layers_summary": "Останній тік: {frames} кадрів, {ms} мс", + "tick_layers_header": "Шари тіків", + "tick_layers_table_header": "сер. / макс.", + "tick_layers_summary": "Останній тік: {count} шарів, {ms} мс", + "expand": "Розгорнути", + "collapse": "Згорнути" }, "heads_up_message": { "choose_spawn": "Оберіть стартове розташування", @@ -862,13 +908,6 @@ "pattern": { "default": "Типово" }, - "try_me": "Примір мене!", - "trial_remaining": "залишилися", - "trial_granted": "Пробний скін надано!", - "trial_cooldown": "Можна приміряти лише один скін протягом 24 годин. Будь ласка, спробуйте ще раз пізніше.", - "trial_login_required": "Ви повинні увійти, щоб отримати пробний скін", - "reward_countdown": "Нагородження через {seconds} сек...", - "steam_wishlist_prompt": "Підтримайте OpenFront, додавши його до списку бажаного Steam", "select_skin": "Оберіть скін", "selected": "обрано" }, @@ -930,8 +969,6 @@ "recent_games": "Нещодавні ігри", "game_id": "ID гри", "mode": "Режим", - "mode_ffa": "Усі проти всіх", - "mode_team": "Команда", "replay": "Повтор", "details": "Подробиці", "ranking": "Рейтинг", @@ -948,8 +985,6 @@ "stats_losses": "Поразки", "stats_wlr": "Коефіцієнт перемог і поразок", "stats_games_played": "Зіграні ігри", - "mode_ffa": "Усі проти всіх", - "mode_team": "Команда", "no_stats": "Немає даних для цієї вибірки." }, "matchmaking_button": { From 76c0f766af4fd740fc55421d45c2439e591b41fe Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 19 Mar 2026 20:00:33 -0700 Subject: [PATCH 03/15] make ffa the large game card --- src/client/GameModeSelector.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 0163e8c68..8589d4b5a 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -156,21 +156,17 @@ export class GameModeSelector extends LitElement { class="grid grid-cols-1 sm:grid-cols-[2fr_1fr] gap-4 sm:h-[min(24rem,40vh)]" > - ${special + ${ffa ? html`` - : ffa - ? html`` - : nothing} + : nothing} - + @@ -694,8 +695,7 @@ export class HelpModal extends BaseModal {
  • ${translateText("help_modal.radial_info")}
  • @@ -824,14 +824,11 @@ export class HelpModal extends BaseModal { ${translateText("help_modal.info_emoji")}
  • -
    - -
    + ${translateText("help_modal.info_trade")}
  • From 2a6e8762866d80d49075da8b1343f35171df4b6f Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 23 Mar 2026 13:40:21 -0700 Subject: [PATCH 08/15] remove falk1 from deployment --- .github/workflows/deploy.yml | 4 ---- build-deploy.sh | 14 +++++++------- deploy.sh | 13 +++++-------- example.env | 1 - 4 files changed, 12 insertions(+), 20 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dd0ea91be..4220bc991 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,6 @@ on: - masters - staging - falk2 - - falk1 target_subdomain: description: "Deployment Subdomain" required: false @@ -94,7 +93,6 @@ jobs: - name: 🔑 Create SSH private key env: SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }} - SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }} SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} @@ -103,7 +101,6 @@ jobs: mkdir -p ~/.ssh echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa test -n "$SERVER_HOST_MASTERS" && ssh-keyscan -H "$SERVER_HOST_MASTERS" >> ~/.ssh/known_hosts - test -n "$SERVER_HOST_FALK1" && ssh-keyscan -H "$SERVER_HOST_FALK1" >> ~/.ssh/known_hosts test -n "$SERVER_HOST_FALK2" && ssh-keyscan -H "$SERVER_HOST_FALK2" >> ~/.ssh/known_hosts test -n "$SERVER_HOST_STAGING" && ssh-keyscan -H "$SERVER_HOST_STAGING" >> ~/.ssh/known_hosts chmod 600 ~/.ssh/id_rsa @@ -120,7 +117,6 @@ jobs: TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }} - SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }} SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }} SSH_KEY: ~/.ssh/id_rsa diff --git a/build-deploy.sh b/build-deploy.sh index 6471fcc51..790146884 100755 --- a/build-deploy.sh +++ b/build-deploy.sh @@ -15,34 +15,34 @@ print_header "BUILD AND DEPLOY WRAPPER" echo "This script will run build.sh and deploy.sh in sequence." echo "You can also run them separately:" echo " ./build.sh [prod|staging] [version_tag]" -echo " ./deploy.sh [prod|staging] [falk1|falk2|nbg1|staging|masters] [version_tag] [subdomain]" +echo " ./deploy.sh [prod|staging] [falk2|nbg1|staging|masters] [version_tag] [subdomain]" echo "" # Check command line arguments if [ $# -lt 3 ] || [ $# -gt 5 ]; then echo "Error: Please specify environment, host, and subdomain" - echo "Usage: $0 [prod|staging] [falk1|falk2|nbg1|staging|masters] [subdomain]" + echo "Usage: $0 [prod|staging] [falk2|nbg1|staging|masters] [subdomain]" exit 1 fi # Validate first argument (environment) if [ "$1" != "prod" ] && [ "$1" != "staging" ]; then echo "Error: First argument must be either 'prod' or 'staging'" - echo "Usage: $0 [prod|staging] [falk1|falk2|nbg1|staging|masters] [subdomain]" + echo "Usage: $0 [prod|staging] [falk2|nbg1|staging|masters] [subdomain]" exit 1 fi # Validate second argument (host) -if [ "$2" != "falk1" ] && [ "$2" != "falk2" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then - echo "Error: Second argument must be either 'falk1', 'nbg1', 'staging', or 'masters'" - echo "Usage: $0 [prod|staging] [falk1|falk2|nbg1|staging|masters] [subdomain]" +if [ "$2" != "falk2" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then + echo "Error: Second argument must be either 'falk2', 'nbg1', 'staging', or 'masters'" + echo "Usage: $0 [prod|staging] [falk2|nbg1|staging|masters] [subdomain]" exit 1 fi # Validate third argument (subdomain) if [ -z "$3" ]; then echo "Error: Subdomain is required" - echo "Usage: $0 [prod|staging] [falk1|falk2|nbg1|staging|masters] [subdomain]" + echo "Usage: $0 [prod|staging] [falk2|nbg1|staging|masters] [subdomain]" exit 1 fi diff --git a/deploy.sh b/deploy.sh index dc2f9edac..03bd9b488 100755 --- a/deploy.sh +++ b/deploy.sh @@ -16,21 +16,21 @@ print_header() { # Check command line arguments if [ $# -ne 4 ]; then echo "Error: Please specify environment, host, version tag, and subdomain" - echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain]" + echo "Usage: $0 [prod|staging] [nbg1|staging|masters|falk2] [version_tag] [subdomain]" exit 1 fi # Validate first argument (environment) if [ "$1" != "prod" ] && [ "$1" != "staging" ]; then echo "Error: First argument must be either 'prod' or 'staging'" - echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain]" + echo "Usage: $0 [prod|staging] [nbg1|staging|masters|falk2] [version_tag] [subdomain]" exit 1 fi # Validate second argument (host) -if [ "$2" != "falk1" ] && [ "$2" != "falk2" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then - echo "Error: Second argument must be either 'falk1', 'falk2', 'nbg1', 'staging', or 'masters'" - echo "Usage: $0 [prod|staging] [falk1|falk2|nbg1|staging|masters] [version_tag] [subdomain]" +if [ "$2" != "falk2" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then + echo "Error: Second argument must be either 'falk2', 'nbg1', 'staging', or 'masters'" + echo "Usage: $0 [prod|staging] [nbg1|staging|masters|falk2] [version_tag] [subdomain]" exit 1 fi @@ -78,9 +78,6 @@ elif [ "$HOST" == "masters" ]; then elif [ "$HOST" == "falk2" ]; then print_header "DEPLOYING TO FALK2 HOST" SERVER_HOST=$SERVER_HOST_FALK2 -else - print_header "DEPLOYING TO FALK1 HOST" - SERVER_HOST=$SERVER_HOST_FALK1 fi # Check required environment variables diff --git a/example.env b/example.env index 01cb270ea..38c3a95bd 100644 --- a/example.env +++ b/example.env @@ -16,7 +16,6 @@ API_KEY=your_api_key_here # Server Hosts SERVER_HOST_STAGING=123.456.78.90 -SERVER_HOST_FALK1=123.456.78.91 SERVER_HOST_NBG1=123.456.78.92 # Version From 8f4e6c2e2af3f56b141380ffbf44c9cf42cf1fee Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:48:17 +0100 Subject: [PATCH 09/15] =?UTF-8?q?Remove=20modifiers=20from=20normal=20FFA/?= =?UTF-8?q?Team=20games=20(And=20increase=20chance=20of=20gold=20multiplie?= =?UTF-8?q?r=20for=20special=20games,=20decrease=20random=20spawn)=20?= =?UTF-8?q?=F0=9F=8E=B2=20(#3471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normal (FFA and Team) public games no longer roll random modifiers. Special games remain fully unaffected and continue to use random modifiers as before. I also increased the gold multiplier ticket count in the special modifier pool from 1 to 4 because of player feedback. Edit: And because I saw complaints of too much random spawn I decreased the chance of it. - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced regression is found: FloPinguin --- src/server/MapPlaylist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 273c9cddf..f2a2e030f 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -159,7 +159,7 @@ export class MapPlaylist { gameMap: map, maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact), gameType: GameType.Public, - gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, + gameMapSize: GameMapSize.Normal, publicGameModifiers: { isCompact, isRandomSpawn: false, From 426806299f0a7b511098319b87e91e12ba8d21f1 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 23 Mar 2026 14:00:47 -0700 Subject: [PATCH 10/15] bugfix: map was not compact size in compact gamemode --- src/server/MapPlaylist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index f2a2e030f..273c9cddf 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -159,7 +159,7 @@ export class MapPlaylist { gameMap: map, maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact), gameType: GameType.Public, - gameMapSize: GameMapSize.Normal, + gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, publicGameModifiers: { isCompact, isRandomSpawn: false, From 39ad547c0419207a8108fcd4f66ab4d4bbd2b5b9 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 23 Mar 2026 17:09:18 -0700 Subject: [PATCH 11/15] support for unlockable flags (#3479) ## Description: Add support for purchasable/gated flags. * Create a new "Store" modal that renders both skins & flags * move all store related logic out of TerritoryPatternsModal * use nation:code for existing nation flags & flag:key for gated flags * check if user has the appropriate flags before purchasing ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- index.html | 4 +- resources/cosmetics/cosmetics.json | 531 ------------------ resources/lang/en.json | 10 +- src/client/Api.ts | 2 +- src/client/Auth.ts | 3 + src/client/Cosmetics.ts | 185 ++++-- src/client/FlagInput.ts | 82 +-- src/client/FlagInputModal.ts | 134 +++-- src/client/LangSelector.ts | 1 + src/client/Main.ts | 69 +-- src/client/PatternInput.ts | 18 +- src/client/Store.ts | 327 +++++++++++ src/client/TerritoryPatternsModal.ts | 207 +------ src/client/UserSettingModal.ts | 45 -- src/client/components/FlagButton.ts | 97 ++++ src/client/components/PatternButton.ts | 39 +- src/client/components/PlayPage.ts | 5 + src/client/components/PurchaseButton.ts | 37 ++ src/client/graphics/layers/NameLayer.ts | 19 +- .../graphics/layers/PerformanceOverlay.ts | 14 +- .../graphics/layers/PlayerInfoOverlay.ts | 21 +- src/client/graphics/layers/WinModal.ts | 2 +- src/core/CosmeticSchemas.ts | 34 +- src/core/CustomFlag.ts | 80 --- src/core/Schemas.ts | 34 +- src/core/game/GameView.ts | 2 +- src/core/game/UserSettings.ts | 32 +- src/server/Privilege.ts | 38 +- tests/CosmeticRelationship.test.ts | 147 +++++ tests/Privilege.test.ts | 80 ++- 30 files changed, 1144 insertions(+), 1155 deletions(-) delete mode 100644 resources/cosmetics/cosmetics.json create mode 100644 src/client/Store.ts create mode 100644 src/client/components/FlagButton.ts create mode 100644 src/client/components/PurchaseButton.ts delete mode 100644 src/core/CustomFlag.ts create mode 100644 tests/CosmeticRelationship.test.ts diff --git a/index.html b/index.html index 5db9deb74..c15810e54 100644 --- a/index.html +++ b/index.html @@ -204,11 +204,11 @@ inline class="hidden w-full h-full page-content relative z-50" > - + > { export async function createCheckoutSession( priceId: string, - colorPaletteName: string | null, + colorPaletteName?: string, ): Promise { try { const response = await fetch( diff --git a/src/client/Auth.ts b/src/client/Auth.ts index afd47908c..be0899146 100644 --- a/src/client/Auth.ts +++ b/src/client/Auth.ts @@ -1,4 +1,5 @@ import { decodeJwt } from "jose"; +import { UserSettings } from "src/core/game/UserSettings"; import { z } from "zod"; import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; import { base64urlToUuid } from "../core/Base64"; @@ -63,6 +64,8 @@ export async function logOut(allSessions: boolean = false): Promise { } finally { __jwt = null; localStorage.removeItem(PERSISTENT_ID_KEY); + new UserSettings().clearFlag(); + new UserSettings().setSelectedPatternName(undefined); } } diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index a09470d33..0bf0b4ad1 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -1,9 +1,10 @@ import { UserMeResponse } from "../core/ApiSchemas"; import { - ColorPalette, Cosmetics, CosmeticsSchema, + Flag, Pattern, + Product, } from "../core/CosmeticSchemas"; import { PlayerCosmeticRefs, @@ -12,34 +13,26 @@ import { } from "../core/Schemas"; import { UserSettings } from "../core/game/UserSettings"; import { createCheckoutSession, getApiBase, getUserMe } from "./Api"; +import { translateText } from "./Utils"; export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute -export async function handlePurchase( - pattern: Pattern, - colorPalette: ColorPalette | null, -) { - if (pattern.product === null) { - alert("This pattern is not available for purchase."); - return; - } +let __cosmetics: Promise | null = null; +let __cosmeticsHash: string | null = null; - const url = await createCheckoutSession( - pattern.product.priceId, - colorPalette?.name ?? null, - ); +export async function handlePurchase( + product: Product, + colorPaletteName?: string, +) { + const url = await createCheckoutSession(product.priceId, colorPaletteName); if (url === false) { alert("Failed to create checkout session."); return; } - // Redirect to Stripe checkout window.location.href = url; } -let __cosmetics: Promise | null = null; -let __cosmeticsHash: string | null = null; - function simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -80,54 +73,118 @@ export async function fetchCosmetics(): Promise { return __cosmetics; } +export async function resolveFlagUrl( + flagRef: string, +): Promise { + if (flagRef.startsWith("flag:")) { + const key = flagRef.slice("flag:".length); + const cosmetics = await fetchCosmetics(); + const flagData = cosmetics?.flags?.[key]; + return flagData?.url; + } + if (flagRef.startsWith("country:")) { + const code = flagRef.slice("country:".length); + return `/flags/${code}.svg`; + } + return undefined; +} + export async function getCosmeticsHash(): Promise { await fetchCosmetics(); return __cosmeticsHash; } +export function cosmeticRelationship( + opts: { + wildcardFlare: string; + requiredFlare: string; + product: Product | null; + affiliateCode: string | null; + itemAffiliateCode: string | null; + }, + userMeResponse: UserMeResponse | false, +): "owned" | "purchasable" | "blocked" { + const flares = + userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); + + if (flares.includes(opts.wildcardFlare)) { + return "owned"; + } + + if (flares.includes(opts.requiredFlare)) { + return "owned"; + } + + if (opts.product === null) { + return "blocked"; + } + + if (opts.affiliateCode !== opts.itemAffiliateCode) { + return "blocked"; + } + + return "purchasable"; +} + export function patternRelationship( pattern: Pattern, colorPalette: { name: string; isArchived?: boolean } | null, userMeResponse: UserMeResponse | false, affiliateCode: string | null, ): "owned" | "purchasable" | "blocked" { - const flares = - userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); - if (flares.includes("pattern:*")) { - return "owned"; - } - if (colorPalette === null) { // For backwards compatibility only show non-colored patterns if they are owned. - if (flares.includes(`pattern:${pattern.name}`)) { + const flares = + userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); + if ( + flares.includes("pattern:*") || + flares.includes(`pattern:${pattern.name}`) + ) { return "owned"; } return "blocked"; } - const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`; - - if (flares.includes(requiredFlare)) { - return "owned"; - } - - if (pattern.product === null) { - // We don't own it and it's not for sale, so don't show it. + if (colorPalette.isArchived) { + // Check ownership first — if owned, show it even if archived. + const flares = + userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); + if ( + flares.includes("pattern:*") || + flares.includes(`pattern:${pattern.name}:${colorPalette.name}`) + ) { + return "owned"; + } return "blocked"; } - if (colorPalette?.isArchived) { - // We don't own the color palette, and it's archived, so don't show it. - return "blocked"; - } + return cosmeticRelationship( + { + wildcardFlare: "pattern:*", + requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`, + product: pattern.product, + affiliateCode, + itemAffiliateCode: pattern.affiliateCode, + }, + userMeResponse, + ); +} - if (affiliateCode !== pattern.affiliateCode) { - // Pattern is for sale, but it's not the right store to show it on. - return "blocked"; - } - - // Patterns is for sale, and it's the right store to show it on. - return "purchasable"; +export function flagRelationship( + flag: Flag, + userMeResponse: UserMeResponse | false, + affiliateCode: string | null, +): "owned" | "purchasable" | "blocked" { + return cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: `flag:${flag.name}`, + product: flag.product, + affiliateCode, + itemAffiliateCode: flag.affiliateCode, + }, + userMeResponse, + ); } export async function getPlayerCosmeticsRefs(): Promise { @@ -154,8 +211,34 @@ export async function getPlayerCosmeticsRefs(): Promise { } } + let flag = userSettings.getFlag(); + if (flag?.startsWith("flag:")) { + const key = flag.slice("flag:".length); + const flagData = cosmetics?.flags?.[key]; + if (!flagData) { + // Only clear if cosmetics loaded successfully but the key is missing + if (cosmetics) { + flag = null; + } + } else { + const userMe = await getUserMe(); + if (!userMe) { + flag = null; + } else { + const flares = userMe.player.flares ?? []; + const hasWildcard = flares.includes("flag:*"); + if (!hasWildcard && !flares.includes(`flag:${flagData.name}`)) { + flag = null; + } + } + } + } + if (flag === null) { + userSettings.clearFlag(); + } + return { - flag: userSettings.getFlag(), + flag: flag ?? undefined, color: userSettings.getSelectedColor() ?? undefined, patternName: pattern?.name ?? undefined, patternColorPaletteName: pattern?.colorPalette?.name ?? undefined, @@ -169,7 +252,7 @@ export async function getPlayerCosmetics(): Promise { const result: PlayerCosmetics = {}; if (refs.flag) { - result.flag = refs.flag; + result.flag = await resolveFlagUrl(refs.flag); } if (refs.color) { @@ -191,3 +274,15 @@ export async function getPlayerCosmetics(): Promise { return result; } + +export function translateCosmetic(prefix: string, name: string): string { + const translation = translateText(`${prefix}.${name}`); + if (translation.startsWith(prefix)) { + return name + .split("_") + .filter((word) => word.length > 0) + .map((word) => word[0].toUpperCase() + word.substring(1)) + .join(" "); + } + return translation; +} diff --git a/src/client/FlagInput.ts b/src/client/FlagInput.ts index 0622937cf..edcdb06b1 100644 --- a/src/client/FlagInput.ts +++ b/src/client/FlagInput.ts @@ -1,11 +1,10 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { renderPlayerFlag } from "../core/CustomFlag"; -import { FlagSchema } from "../core/Schemas"; +import { FlagName } from "../core/Schemas"; +import { UserSettings } from "../core/game/UserSettings"; +import { resolveFlagUrl } from "./Cosmetics"; import { translateText } from "./Utils"; -const flagKey: string = "flag"; - @customElement("flag-input") export class FlagInput extends LitElement { @state() public flag: string = ""; @@ -14,36 +13,16 @@ export class FlagInput extends LitElement { public showSelectLabel: boolean = false; private isDefaultFlagValue(flag: string): boolean { - return !flag || flag === "xx"; + return !flag || flag === "xx" || flag === "country:xx"; } - public getCurrentFlag(): string { - return this.flag; - } - - private getStoredFlag(): string { - const storedFlag = localStorage.getItem(flagKey); - if (storedFlag) { - return storedFlag; + private updateFlag = (e: CustomEvent) => { + const parsed = FlagName.safeParse(e.detail); + if (!parsed.success) { + console.warn(`error parsing flag ${e.detail.value}, ${parsed.error}`); } - return ""; - } - - private dispatchFlagEvent() { - this.dispatchEvent( - new CustomEvent("flag-change", { - detail: { flag: this.flag }, - bubbles: true, - composed: true, - }), - ); - } - - private updateFlag = (ev: Event) => { - const e = ev as CustomEvent<{ flag: string }>; - if (!FlagSchema.safeParse(e.detail.flag).success) return; - if (this.flag !== e.detail.flag) { - this.flag = e.detail.flag; + if (this.flag !== e.detail) { + this.flag = e.detail; } }; @@ -60,14 +39,19 @@ export class FlagInput extends LitElement { connectedCallback() { super.connectedCallback(); - this.flag = this.getStoredFlag(); - this.dispatchFlagEvent(); - window.addEventListener("flag-change", this.updateFlag as EventListener); + this.flag = new UserSettings().getFlag() ?? ""; + window.addEventListener( + "event:user-settings-changed:flag", + this.updateFlag as EventListener, + ); } disconnectedCallback() { super.disconnectedCallback(); - window.removeEventListener("flag-change", this.updateFlag as EventListener); + window.removeEventListener( + "event:user-settings-changed:flag", + this.updateFlag as EventListener, + ); } createRenderRoot() { @@ -94,7 +78,7 @@ export class FlagInput extends LitElement { > ${showSelect ? html` ${translateText("flag_input.title")} ` @@ -103,32 +87,26 @@ export class FlagInput extends LitElement { `; } - updated() { + async updated() { const preview = this.renderRoot.querySelector( "#flag-preview", ) as HTMLElement; if (!preview) return; - if (this.showSelectLabel && this.isDefaultFlagValue(this.flag)) { + if (this.isDefaultFlagValue(this.flag)) { preview.innerHTML = ""; return; } preview.innerHTML = ""; - if (this.flag?.startsWith("!")) { - renderPlayerFlag(this.flag, preview); - } else { - const img = document.createElement("img"); - img.src = this.flag ? `/flags/${this.flag}.svg` : `/flags/xx.svg`; - img.className = "w-full h-full object-cover pointer-events-none"; - img.draggable = false; - img.onerror = () => { - if (!img.src.endsWith("/flags/xx.svg")) { - img.src = "/flags/xx.svg"; - } - }; - preview.appendChild(img); - } + const url = await resolveFlagUrl(this.flag); + if (!url) return; + + const img = document.createElement("img"); + img.src = url; + img.className = "w-full h-full object-cover pointer-events-none"; + img.draggable = false; + preview.appendChild(img); } } diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index 8451b4e5d..c51fc2036 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -1,21 +1,93 @@ import { html } from "lit"; -import { customElement, query, state } from "lit/decorators.js"; +import { customElement, state } from "lit/decorators.js"; import Countries from "resources/countries.json" with { type: "json" }; +import { UserMeResponse } from "../core/ApiSchemas"; +import { Cosmetics } from "../core/CosmeticSchemas"; +import { UserSettings } from "../core/game/UserSettings"; +import { getUserMe } from "./Api"; +import { fetchCosmetics, flagRelationship } from "./Cosmetics"; import { translateText } from "./Utils"; import { BaseModal } from "./components/BaseModal"; +import "./components/FlagButton"; import { modalHeader } from "./components/ui/ModalHeader"; @customElement("flag-input-modal") export class FlagInputModal extends BaseModal { - @query("#flag-input-modal") private modalRef!: HTMLElement; - @state() private search = ""; + @state() private cosmetics: Cosmetics | null = null; + @state() private userMe: UserMeResponse | false = false; public returnTo = ""; updated(changedProperties: Map) { super.updated(changedProperties); } + private renderFlags() { + const userSettings = new UserSettings(); + const selectedFlag = userSettings.getFlag() ?? ""; + const onSelect = (flagKey: string) => { + this.setFlag(flagKey); + this.close(); + }; + + const cosmeticFlags = Object.entries(this.cosmetics?.flags ?? {}) + .filter(([, flag]) => { + if (!this.includedInSearch({ name: flag.name, code: flag.name })) + return false; + return flagRelationship(flag, this.userMe, null) === "owned"; + }) + .map( + ([key, flag]) => html` + + `, + ); + + const noFlag = this.search + ? null + : html` + + `; + + const countryFlags = Countries.filter( + (country) => + country.code !== "xx" && + !country.restricted && + this.includedInSearch(country), + ).map( + (country) => html` + + `, + ); + + return html` +
    + ${noFlag} ${cosmeticFlags} ${countryFlags} +
    + `; + } + render() { const content = html`
    @@ -35,6 +107,7 @@ export class FlagInputModal extends BaseModal { focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all" type="text" placeholder=${translateText("flag_input.search_flag")} + .value=${this.search} @change=${this.handleSearch} @keyup=${this.handleSearch} /> @@ -42,43 +115,9 @@ export class FlagInputModal extends BaseModal {
    -
    - ${Countries.filter( - (country) => - !country.restricted && this.includedInSearch(country), - ).map( - (country) => html` - - `, - )} -
    + ${this.renderFlags()}
    `; @@ -112,21 +151,18 @@ export class FlagInputModal extends BaseModal { } private setFlag(flag: string) { - localStorage.setItem("flag", flag); - this.dispatchEvent( - new CustomEvent("flag-change", { - detail: { flag }, - bubbles: true, - composed: true, - }), - ); + new UserSettings().setFlag(flag); } - protected onOpen(): void { - // No custom logic needed + protected async onOpen(): Promise { + [this.cosmetics, this.userMe] = await Promise.all([ + fetchCosmetics(), + getUserMe().then((r) => r || (false as const)), + ]); } protected onClose(): void { + this.search = ""; if (this.returnTo) { const returnEl = document.querySelector(this.returnTo) as any; if (returnEl?.open) { diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 2ba0ee498..cff2e079e 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -223,6 +223,7 @@ export class LangSelector extends LitElement { "o-modal", "o-button", "territory-patterns-modal", + "store-modal", "pattern-input", "fluent-slider", "news-modal", diff --git a/src/client/Main.ts b/src/client/Main.ts index 07fda48cd..6a134573f 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -41,6 +41,8 @@ import { initNavigation } from "./Navigation"; import "./NewsModal"; import "./PatternInput"; import "./SinglePlayerModal"; +import { StoreModal } from "./Store"; +import "./TerritoryPatternsModal"; import { TerritoryPatternsModal } from "./TerritoryPatternsModal"; import { TokenLoginModal } from "./TokenLoginModal"; import { @@ -247,7 +249,7 @@ class Client { private joinModal: JoinLobbyModal; private gameModeSelector: GameModeSelector; private userSettings: UserSettings = new UserSettings(); - private patternsModal: TerritoryPatternsModal; + private storeModal: StoreModal; private tokenLoginModal: TokenLoginModal; private matchmakingModal: MatchmakingModal; @@ -361,30 +363,22 @@ class Client { }); }); - this.patternsModal = document.getElementById( + this.storeModal = document.getElementById("page-item-store") as StoreModal; + if (!this.storeModal || !(this.storeModal instanceof StoreModal)) { + console.warn("Store modal element not found"); + } + + const patternsModal = document.getElementById( "territory-patterns-modal", ) as TerritoryPatternsModal; - if ( - !this.patternsModal || - !(this.patternsModal instanceof TerritoryPatternsModal) - ) { - console.warn("Territory patterns modal element not found"); + if (!patternsModal || !(patternsModal instanceof TerritoryPatternsModal)) { + console.warn("Patterns modal element not found"); } // Attach listener to any pattern-input component document.querySelectorAll("pattern-input").forEach((patternInput) => { patternInput.addEventListener("pattern-input-click", () => { - // Open the Store page which contains the patterns UI - window.showPage?.("page-item-store"); - const skinStoreModal = document.getElementById( - "page-item-store", - ) as HTMLElement & { open?: (opts: any) => void }; - if (skinStoreModal) { - skinStoreModal.classList.remove("hidden"); - if (typeof skinStoreModal.open === "function") { - skinStoreModal.open({ showOnlyOwned: true }); - } - } + patternsModal.open(); }); }); @@ -393,29 +387,20 @@ class Client { if (mobilePat) mobilePat.style.display = "none"; } - if ( - !this.patternsModal || - !(this.patternsModal instanceof TerritoryPatternsModal) - ) { - console.warn("Territory patterns modal element not found"); + if (!this.storeModal || !(this.storeModal instanceof StoreModal)) { + console.warn("Store modal element not found"); } // We no longer need to manually manage the preview button as PatternInput handles it component-side. // However, we still want to ensure the modal can be opened. // The setupPatternInput above handles the click event for the new buttons. - this.patternsModal.refresh(); - - // Listen for pattern selection to update any other listeners if needed, - // though PatternInput handles its own updates via window event. - this.patternsModal.addEventListener("pattern-selected", () => { - // PatternInput components will update themselves. - }); + this.storeModal.refresh(); window.addEventListener("showPage", (e: any) => { if (typeof e?.detail === "string" && e.detail === "page-play") { setTimeout(() => { - this.patternsModal.refresh(); + this.storeModal.refresh(); }, 50); } }); @@ -647,14 +632,20 @@ class Client { return; } - const patternName = params.get("cosmetic"); - if (!patternName) { + const cosmeticName = params.get("cosmetic"); + if (!cosmeticName) { alert("Something went wrong. Please contact support."); console.error("purchase-completed but no pattern name"); return; } - this.userSettings.setSelectedPatternName(patternName); + const setCosmetic = () => { + if (cosmeticName.startsWith("pattern:")) { + this.userSettings.setSelectedPatternName(cosmeticName); + } else if (cosmeticName.startsWith("flag:")) { + this.userSettings.setFlag(cosmeticName); + } + }; const token = params.get("login-token"); if (token) { @@ -662,12 +653,13 @@ class Client { window.addEventListener("beforeunload", () => { // The page reloads after token login, so we need to save the pattern name // in case it is unset during reload. - this.userSettings.setSelectedPatternName(patternName); + setCosmetic(); }); this.tokenLoginModal.openWithToken(token); } else { - alertAndStrip(`purchase succeeded: ${patternName}`); - this.patternsModal.refresh(); + alertAndStrip(`purchase succeeded: ${cosmeticName}`); + setCosmetic(); + this.storeModal.refresh(); } return; } @@ -702,7 +694,7 @@ class Client { const affiliateCode = decodedHash.replace("#affiliate=", ""); strip(); if (affiliateCode) { - this.patternsModal?.open(affiliateCode); + this.storeModal?.open(affiliateCode); } } if (decodedHash.startsWith("#refresh")) { @@ -781,6 +773,7 @@ class Client { "user-setting", "troubleshooting-modal", "territory-patterns-modal", + "store-modal", "language-modal", "news-modal", "flag-input-modal", diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts index 008eadb55..46910c486 100644 --- a/src/client/PatternInput.ts +++ b/src/client/PatternInput.ts @@ -46,9 +46,13 @@ export class PatternInput extends LitElement { this.pattern = cosmetics.pattern ?? null; if (!this.isConnected) return; this.isLoading = false; - window.addEventListener("pattern-selected", this._onPatternSelected, { - signal: this._abortController.signal, - }); + window.addEventListener( + "event:user-settings-changed:pattern", + this._onPatternSelected, + { + signal: this._abortController.signal, + }, + ); } disconnectedCallback() { @@ -79,10 +83,10 @@ export class PatternInput extends LitElement { } const showSelect = this.showSelectLabel && this.getIsDefaultPattern(); - this.style.setProperty("height", "3rem"); + this.style.setProperty("height", "2.5rem"); this.style.setProperty( "width", - showSelect ? "clamp(6.5rem, 28vw, 9.5rem)" : "3rem", + showSelect ? "clamp(3.25rem, 14vw, 4.75rem)" : "2.5rem", ); } @@ -136,7 +140,9 @@ export class PatternInput extends LitElement { ${showSelect ? html` ${translateText("territory_patterns.select_skin")} ` diff --git a/src/client/Store.ts b/src/client/Store.ts new file mode 100644 index 000000000..29c23d54f --- /dev/null +++ b/src/client/Store.ts @@ -0,0 +1,327 @@ +import type { TemplateResult } from "lit"; +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +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 { BaseModal } from "./components/BaseModal"; +import "./components/FlagButton"; +import "./components/PatternButton"; +import { modalHeader } from "./components/ui/ModalHeader"; +import { + fetchCosmetics, + flagRelationship, + getPlayerCosmetics, + handlePurchase, + patternRelationship, +} from "./Cosmetics"; +import { translateText } from "./Utils"; + +@customElement("store-modal") +export class StoreModal extends BaseModal { + @state() private selectedPattern: PlayerPattern | null; + @state() private selectedColor: string | null = null; + @state() private activeTab: "patterns" | "flags" = "patterns"; + + private cosmetics: Cosmetics | null = null; + private userSettings: UserSettings = new UserSettings(); + private isActive = false; + private affiliateCode: string | null = null; + private userMeResponse: UserMeResponse | false = false; + + private _onPatternSelected = async () => { + await this.updateFromSettings(); + this.refresh(); + }; + + connectedCallback() { + super.connectedCallback(); + document.addEventListener( + "userMeResponse", + (event: CustomEvent) => { + this.onUserMe(event.detail); + }, + ); + window.addEventListener( + "event:user-settings-changed:pattern", + this._onPatternSelected, + ); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener( + "event:user-settings-changed:pattern", + this._onPatternSelected, + ); + } + + private async updateFromSettings() { + const cosmetics = await getPlayerCosmetics(); + this.selectedPattern = cosmetics.pattern ?? null; + this.selectedColor = cosmetics.color?.color ?? null; + } + + async onUserMe(userMeResponse: UserMeResponse | false) { + this.userMeResponse = userMeResponse; + this.cosmetics = await fetchCosmetics(); + await this.updateFromSettings(); + this.refresh(); + } + + private renderHeader(): TemplateResult { + return html` + ${modalHeader({ + title: translateText("store.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + rightContent: !hasLinkedAccount(this.userMeResponse) + ? html`
    + ${this.renderNotLoggedInWarning()} +
    ` + : undefined, + })} +
    + + +
    + `; + } + + private renderPatternGrid(): TemplateResult { + const buttons: TemplateResult[] = []; + const patterns: (Pattern | null)[] = [ + null, + ...Object.values(this.cosmetics?.patterns ?? {}), + ]; + for (const pattern of patterns) { + const colorPalettes = pattern + ? [...(pattern.colorPalettes ?? []), null] + : [null]; + for (const colorPalette of colorPalettes) { + let rel = "owned"; + if (pattern) { + rel = patternRelationship( + pattern, + colorPalette, + this.userMeResponse, + this.affiliateCode, + ); + } + if (rel === "blocked" || rel === "owned") { + continue; + } + const isDefaultPattern = pattern === null; + const isSelected = + (isDefaultPattern && this.selectedPattern === null) || + (!isDefaultPattern && + this.selectedPattern && + this.selectedPattern.name === pattern?.name && + (this.selectedPattern.colorPalette?.name ?? null) === + (colorPalette?.name ?? null)); + buttons.push(html` + this.selectPattern(p)} + .onPurchase=${(p: Pattern, cp: ColorPalette | null) => + handlePurchase(p.product!, cp?.name)} + > + `); + } + } + + if (buttons.length === 0) { + return html`
    + ${translateText("store.no_skins")} +
    `; + } + + return html` +
    + ${buttons} +
    + `; + } + + private renderFlagGrid(): TemplateResult { + const buttons: TemplateResult[] = []; + const flags = Object.entries(this.cosmetics?.flags ?? {}); + for (const [key, flag] of flags) { + const rel = flagRelationship( + flag, + this.userMeResponse, + this.affiliateCode, + ); + if (rel === "blocked" || rel === "owned") continue; + const selectedFlag = new UserSettings().getFlag() ?? ""; + buttons.push(html` + handlePurchase(flag.product!)} + > + `); + } + + if (buttons.length === 0) { + return html`
    + ${translateText("store.no_flags")} +
    `; + } + + return html` +
    + ${buttons} +
    + `; + } + + private renderNotLoggedInWarning(): TemplateResult { + return html``; + } + + render() { + if (!this.isActive && !this.inline) return html``; + + const content = html` +
    + ${this.renderHeader()} +
    + ${this.activeTab === "patterns" + ? this.renderPatternGrid() + : this.renderFlagGrid()} +
    +
    + `; + + if (this.inline) { + return content; + } + + return html` + + ${content} + + `; + } + + public async open(options?: string | { affiliateCode?: string }) { + if (this.isModalOpen) return; + this.isActive = true; + if (typeof options === "string") { + this.affiliateCode = options; + } else if ( + options !== null && + typeof options === "object" && + !Array.isArray(options) + ) { + this.affiliateCode = options.affiliateCode ?? null; + } else { + this.affiliateCode = null; + } + + this.cosmetics ??= await fetchCosmetics(); + await this.refresh(); + super.open(); + } + + public close() { + this.isActive = false; + this.affiliateCode = null; + super.close(); + } + + private selectPattern(pattern: PlayerPattern | null) { + this.selectedColor = null; + this.userSettings.setSelectedColor(undefined); + if (pattern === null) { + this.userSettings.setSelectedPatternName(undefined); + } else { + const name = + pattern.colorPalette?.name === undefined + ? pattern.name + : `${pattern.name}:${pattern.colorPalette.name}`; + this.userSettings.setSelectedPatternName(`pattern:${name}`); + } + this.selectedPattern = pattern; + this.refresh(); + this.showSelectedPopup(pattern); + this.close(); + } + + private showSelectedPopup(pattern: PlayerPattern | null) { + let skinName = translateText("territory_patterns.pattern.default"); + if (pattern && pattern.name) { + skinName = pattern.name + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + if (pattern.colorPalette && pattern.colorPalette.name) { + skinName += ` (${pattern.colorPalette.name})`; + } + } + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: `${skinName} ${translateText("territory_patterns.selected")}`, + duration: 2000, + }, + }), + ); + } + + public async refresh() { + this.requestUpdate(); + } +} diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 1f87891fd..c4349f113 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -2,18 +2,16 @@ import type { TemplateResult } from "lit"; import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; -import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas"; +import { Cosmetics, Pattern } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; import { PlayerPattern } from "../core/Schemas"; import { hasLinkedAccount } from "./Api"; import { BaseModal } from "./components/BaseModal"; -import "./components/Difficulties"; import "./components/PatternButton"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics, getPlayerCosmetics, - handlePurchase, patternRelationship, } from "./Cosmetics"; import { translateText } from "./Utils"; @@ -25,17 +23,9 @@ export class TerritoryPatternsModal extends BaseModal { @state() private selectedPattern: PlayerPattern | null; @state() private selectedColor: string | null = null; - @state() private activeTab: "patterns" | "colors" = "patterns"; - @state() private showOnlyOwned: boolean = false; - private cosmetics: Cosmetics | null = null; - private userSettings: UserSettings = new UserSettings(); - private isActive = false; - - private affiliateCode: string | null = null; - private userMeResponse: UserMeResponse | false = false; private _onPatternSelected = async () => { @@ -43,10 +33,6 @@ export class TerritoryPatternsModal extends BaseModal { this.refresh(); }; - constructor() { - super(); - } - connectedCallback() { super.connectedCallback(); document.addEventListener( @@ -55,12 +41,18 @@ export class TerritoryPatternsModal extends BaseModal { this.onUserMe(event.detail); }, ); - window.addEventListener("pattern-selected", this._onPatternSelected); + window.addEventListener( + "event:user-settings-changed:pattern", + this._onPatternSelected, + ); } disconnectedCallback() { super.disconnectedCallback(); - window.removeEventListener("pattern-selected", this._onPatternSelected); + window.removeEventListener( + "event:user-settings-changed:pattern", + this._onPatternSelected, + ); } private async updateFromSettings() { @@ -76,42 +68,6 @@ export class TerritoryPatternsModal extends BaseModal { this.refresh(); } - private renderTabNavigation(): TemplateResult { - return html` - ${modalHeader({ - title: translateText("territory_patterns.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - rightContent: !hasLinkedAccount(this.userMeResponse) - ? html`
    - ${this.renderNotLoggedInWarning()} -
    ` - : undefined, - })} - - `; - } - private renderPatternGrid(): TemplateResult { const buttons: TemplateResult[] = []; const patterns: (Pattern | null)[] = [ @@ -129,19 +85,12 @@ export class TerritoryPatternsModal extends BaseModal { pattern, colorPalette, this.userMeResponse, - this.affiliateCode, + null, ); } - if (rel === "blocked") { + if (rel !== "owned") { continue; } - if (this.showOnlyOwned) { - if (rel !== "owned") continue; - } else { - // Store mode: hide owned items - if (rel === "owned") continue; - } - // Determine if this pattern/color is selected const isDefaultPattern = pattern === null; const isSelected = (isDefaultPattern && this.selectedPattern === null) || @@ -156,11 +105,9 @@ export class TerritoryPatternsModal extends BaseModal { .colorPalette=${this.cosmetics?.colorPalettes?.[ colorPalette?.name ?? "" ] ?? null} - .requiresPurchase=${rel === "purchasable"} + .requiresPurchase=${false} .selected=${isSelected} .onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)} - .onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) => - handlePurchase(p, colorPalette)} > `); } @@ -168,42 +115,15 @@ export class TerritoryPatternsModal extends BaseModal { return html`
    -
    - ${hasLinkedAccount(this.userMeResponse) - ? this.renderMySkinsButton() - : html``} +
    + ${buttons}
    - ${!this.showOnlyOwned && buttons.length === 0 - ? html`
    - ${translateText("territory_patterns.all_owned")} -
    ` - : html` -
    - ${buttons} -
    - `}
    `; } - private renderMySkinsButton(): TemplateResult { - return html``; - } - private renderNotLoggedInWarning(): TemplateResult { return html`
    - `; - } - public async refresh() { this.requestUpdate(); } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 7bf6612d3..c6a692de2 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -10,14 +10,8 @@ import "./components/baseComponents/setting/SettingSlider"; import "./components/baseComponents/setting/SettingToggle"; import { BaseModal } from "./components/BaseModal"; import { modalHeader } from "./components/ui/ModalHeader"; -import "./FlagInputModal"; import { Platform } from "./Platform"; -interface FlagInputModalElement extends HTMLElement { - open(): void; - returnTo?: string; -} - const isMac = Platform.isMac; const DefaultKeybinds: Record = { @@ -396,16 +390,6 @@ export class UserSettingModal extends BaseModal { this.userSettings.set("settings.performanceOverlay", enabled); } - private openFlagSelector = () => { - const flagInputModal = - document.querySelector("#flag-input-modal"); - if (flagInputModal?.open) { - this.close(); - flagInputModal.returnTo = "#" + (this.id || "page-settings"); - flagInputModal.open(); - } - }; - render() { const activeContent = this.activeTab === "basic" @@ -786,35 +770,6 @@ export class UserSettingModal extends BaseModal { private renderBasicSettings() { return html` - -
    { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - this.openFlagSelector(); - } - }} - > -
    -
    - ${translateText("flag_input.title")} -
    -
    - ${translateText("flag_input.button_title")} -
    -
    - -
    - -
    -
    - void; + + @property({ type: Function }) + onPurchase?: () => void; + + createRenderRoot() { + return this; + } + + private handleClick() { + this.onSelect?.(this.flag.key); + } + + render() { + return html` +
    + + + ${this.requiresPurchase && this.flag.product + ? html` + this.onPurchase?.()} + > + ` + : null} +
    + `; + } +} diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index 06dffdc69..e2cd99205 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -9,7 +9,9 @@ import { } from "../../core/CosmeticSchemas"; import { PatternDecoder } from "../../core/PatternDecoder"; import { PlayerPattern } from "../../core/Schemas"; +import { translateCosmetic } from "../Cosmetics"; import { translateText } from "../Utils"; +import "./PurchaseButton"; export const BUTTON_WIDTH = 150; @@ -36,18 +38,6 @@ export class PatternButton extends LitElement { return this; } - private translateCosmetic(prefix: string, patternName: string): string { - const translation = translateText(`${prefix}.${patternName}`); - if (translation.startsWith(prefix)) { - return patternName - .split("_") - .filter((word) => word.length > 0) - .map((word) => word[0].toUpperCase() + word.substring(1)) - .join(" "); - } - return translation; - } - private handleClick() { if (this.pattern === null) { this.onSelect?.(null); @@ -60,8 +50,7 @@ export class PatternButton extends LitElement { } satisfies PlayerPattern); } - private handlePurchase(e: Event) { - e.stopPropagation(); + private handlePurchase() { if (this.pattern?.product) { this.onPurchase?.(this.pattern, this.colorPalette ?? null); } @@ -91,14 +80,14 @@ export class PatternButton extends LitElement { : ""}" title="${isDefaultPattern ? translateText("territory_patterns.pattern.default") - : this.translateCosmetic( + : translateCosmetic( "territory_patterns.pattern", this.pattern!.name, )}" > ${isDefaultPattern ? translateText("territory_patterns.pattern.default") - : this.translateCosmetic( + : translateCosmetic( "territory_patterns.pattern", this.pattern!.name, )} @@ -111,7 +100,7 @@ export class PatternButton extends LitElement { ? "opacity-50" : ""}" > - ${this.translateCosmetic( + ${translateCosmetic( "territory_patterns.color_palette", this.colorPalette!.name, )} @@ -139,18 +128,10 @@ export class PatternButton extends LitElement { ${this.requiresPurchase && this.pattern?.product ? html` -
    - -
    + this.handlePurchase()} + > ` : null} diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts index 02c77e93d..9417b5fd2 100644 --- a/src/client/components/PlayPage.ts +++ b/src/client/components/PlayPage.ts @@ -121,6 +121,11 @@ export class PlayPage extends LitElement { adaptive-size class="shrink-0 lg:hidden" > + diff --git a/src/client/components/PurchaseButton.ts b/src/client/components/PurchaseButton.ts new file mode 100644 index 000000000..47bacf4b1 --- /dev/null +++ b/src/client/components/PurchaseButton.ts @@ -0,0 +1,37 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { Product } from "../../core/CosmeticSchemas"; +import { translateText } from "../Utils"; + +@customElement("purchase-button") +export class PurchaseButton extends LitElement { + @property({ type: Object }) + product!: Product; + + @property({ type: Function }) + onPurchase?: () => void; + + createRenderRoot() { + return this; + } + + private handleClick(e: Event) { + e.stopPropagation(); + this.onPurchase?.(); + } + + render() { + return html` +
    + +
    + `; + } +} diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 9f49f6f26..167d85c75 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,4 +1,3 @@ -import { renderPlayerFlag } from "../../../core/CustomFlag"; import { EventBus } from "../../../core/EventBus"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; @@ -210,22 +209,14 @@ export class NameLayer implements Layer { element.classList.add("player-flag"); element.style.opacity = "0.8"; element.style.zIndex = "1"; - element.style.aspectRatio = "3/4"; + element.style.objectFit = "contain"; }; if (player.cosmetics.flag) { - const flag = player.cosmetics.flag; - if (flag !== undefined && flag !== null && flag.startsWith("!")) { - const flagWrapper = document.createElement("div"); - applyFlagStyles(flagWrapper); - renderPlayerFlag(flag, flagWrapper); - nameDiv.appendChild(flagWrapper); - } else if (flag !== undefined && flag !== null) { - const flagImg = document.createElement("img"); - applyFlagStyles(flagImg); - flagImg.src = "/flags/" + flag + ".svg"; - nameDiv.appendChild(flagImg); - } + const flagImg = document.createElement("img"); + applyFlagStyles(flagImg); + flagImg.src = player.cosmetics.flag; + nameDiv.appendChild(flagImg); } nameDiv.classList.add("player-name"); nameDiv.style.color = this.theme.textColor(player); diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index ec08d024b..9dd097ab9 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -476,14 +476,8 @@ export class PerformanceOverlay extends LitElement implements Layer { this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay); }; - private onUserSettingsChanged = (event: Event) => { - const customEvent = event as CustomEvent<{ - key?: string; - value?: unknown; - }>; - if (customEvent.detail?.key !== "settings.performanceOverlay") return; - - const nextVisible = customEvent.detail.value === true; + private onUserSettingsChanged = (event: CustomEvent) => { + const nextVisible = (event.detail as boolean) === true; if (this.isVisible === nextVisible) return; this.setVisible(nextVisible); }; @@ -511,7 +505,7 @@ export class PerformanceOverlay extends LitElement implements Layer { if (!this.isUserSettingsListenerAttached) { globalThis.addEventListener( - "user-settings-changed", + "event:user-settings-changed:settings.performanceOverlay", this.onUserSettingsChanged, ); this.isUserSettingsListenerAttached = true; @@ -523,7 +517,7 @@ export class PerformanceOverlay extends LitElement implements Layer { if (this.isUserSettingsListenerAttached) { globalThis.removeEventListener( - "user-settings-changed", + "event:user-settings-changed:settings.performanceOverlay", this.onUserSettingsChanged, ); this.isUserSettingsListenerAttached = false; diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index fe9ed185a..43236d3a0 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -1,7 +1,5 @@ import { LitElement, TemplateResult, html } from "lit"; -import { ref } from "lit-html/directives/ref.js"; import { customElement, property, state } from "lit/decorators.js"; -import { renderPlayerFlag } from "../../../core/CustomFlag"; import { EventBus } from "../../../core/EventBus"; import { PlayerProfile, @@ -364,21 +362,10 @@ export class PlayerInfoOverlay extends LitElement implements Layer { )}" > ${player.cosmetics.flag - ? player.cosmetics.flag!.startsWith("!") - ? html`
    { - if (el instanceof HTMLElement) { - requestAnimationFrame(() => { - renderPlayerFlag(player.cosmetics.flag!, el); - }); - } - })} - >
    ` - : html`` + ? html`` : html``} ${player.name()} ${playerTeam !== "" && player.type() !== PlayerType.Bot diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index e4fcd3286..e967776db 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -203,7 +203,7 @@ export class WinModal extends LitElement implements Layer { .requiresPurchase=${true} .onSelect=${(p: Pattern | null) => {}} .onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) => - handlePurchase(p, colorPalette)} + handlePurchase(p.product!, colorPalette?.name)} > `, )} diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index a4bcd6762..39405f96d 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -5,7 +5,8 @@ import { PlayerPattern } from "./Schemas"; export type Cosmetics = z.infer; export type Pattern = z.infer; -export type PatternName = z.infer; +export type Flag = z.infer; +export type PatternName = z.infer; export type Product = z.infer; export type ColorPalette = z.infer; export type PatternData = z.infer; @@ -16,7 +17,7 @@ export const ProductSchema = z.object({ price: z.string(), }); -export const PatternNameSchema = z +export const CosmeticNameSchema = z .string() .regex(/^[a-z0-9_]+$/) .max(32); @@ -51,7 +52,7 @@ export const ColorPaletteSchema = z.object({ }); export const PatternSchema = z.object({ - name: PatternNameSchema, + name: CosmeticNameSchema, pattern: PatternDataSchema, colorPalettes: z .object({ @@ -64,29 +65,18 @@ export const PatternSchema = z.object({ product: ProductSchema.nullable(), }); +export const FlagSchema = z.object({ + name: CosmeticNameSchema, + url: z.string(), + affiliateCode: z.string().nullable(), + product: ProductSchema.nullable(), +}); + // Schema for resources/cosmetics/cosmetics.json export const CosmeticsSchema = z.object({ colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(), patterns: z.record(z.string(), PatternSchema), - flag: z - .object({ - layers: z.record( - z.string(), - z.object({ - name: z.string(), - flares: z.array(z.string()).optional(), - }), - ), - color: z.record( - z.string(), - z.object({ - color: z.string(), - name: z.string(), - flares: z.array(z.string()).optional(), - }), - ), - }) - .optional(), + flags: z.record(z.string(), FlagSchema), }); export const DefaultPattern = { diff --git a/src/core/CustomFlag.ts b/src/core/CustomFlag.ts deleted file mode 100644 index 3347e5e8f..000000000 --- a/src/core/CustomFlag.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Cosmetics } from "./CosmeticSchemas"; - -const ANIMATION_DURATIONS: Record = { - rainbow: 4000, - "bright-rainbow": 4000, - "copper-glow": 3000, - "silver-glow": 3000, - "gold-glow": 3000, - neon: 3000, - lava: 6000, - water: 6200, -}; - -// TODO: Pass in cosmetics as a parameter when -// remote cosmetics are implemented for custom flags -export function renderPlayerFlag( - flag: string, - target: HTMLElement, - cosmetics: Cosmetics | undefined = undefined, -) { - if (cosmetics === undefined) { - console.warn("No cosmetics provided for flag", flag); - return; - } - - if (!flag.startsWith("!")) return; - - const code = flag.slice("!".length); - const layers = code.split("_").map((segment) => { - const [layerKey, colorKey] = segment.split("-"); - return { layerKey, colorKey }; - }); - - target.innerHTML = ""; - target.style.overflow = "hidden"; - target.style.position = "relative"; - target.style.aspectRatio = "3/4"; - - for (const { layerKey, colorKey } of layers) { - const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey; - - const mask = `/flags/custom/${layerName}.svg`; - if (!mask) continue; - - const layer = document.createElement("div"); - layer.style.position = "absolute"; - layer.style.top = "0"; - layer.style.left = "0"; - layer.style.width = "100%"; - layer.style.height = "100%"; - - const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey; - const isSpecial = - !colorValue.startsWith("#") && - !/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue); - - if (isSpecial) { - const duration = ANIMATION_DURATIONS[colorValue] ?? 5000; - const now = performance.now(); - const offset = now % duration; - if (!duration) console.warn(`No animation duration for: ${colorValue}`); - layer.classList.add(`flag-color-${colorValue}`); - layer.style.animationDelay = `-${offset}ms`; - } else { - layer.style.backgroundColor = colorValue; - } - - layer.style.maskImage = `url(${mask})`; - layer.style.maskRepeat = "no-repeat"; - layer.style.maskPosition = "center"; - layer.style.maskSize = "contain"; - - layer.style.webkitMaskImage = `url(${mask})`; - layer.style.webkitMaskRepeat = "no-repeat"; - layer.style.webkitMaskPosition = "center"; - layer.style.webkitMaskSize = "contain"; - - target.appendChild(layer); - } -} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 07b7f263e..b3504e670 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,10 +1,9 @@ -import countries from "resources/countries.json"; import quickChatData from "resources/QuickChat.json"; import { z } from "zod"; import { ColorPaletteSchema, + CosmeticNameSchema, PatternDataSchema, - PatternNameSchema, } from "./CosmeticSchemas"; import type { GameEvent } from "./EventBus"; import { @@ -132,7 +131,6 @@ export type PlayerCosmetics = z.infer; export type PlayerCosmeticRefs = z.infer; export type PlayerPattern = z.infer; export type PlayerColor = z.infer; -export type Flag = z.infer; export type GameStartInfo = z.infer; export type GameInfo = z.infer; export type PublicGames = z.infer; @@ -284,7 +282,6 @@ export const UsernameSchema = z .regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u) .min(3) .max(27); -const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code); export const QuickChatKeySchema = z.enum( Object.entries(quickChatData).flatMap(([category, entries]) => @@ -471,28 +468,23 @@ export const TurnSchema = z.object({ hash: z.number().nullable().optional(), }); -export const FlagSchema = z +export const FlagName = z .string() .max(128) - .optional() .refine( (val) => { if (val === undefined || val === "") return true; - if (val.startsWith("!")) return true; - return countryCodes.includes(val); + return val.startsWith("flag:") || val.startsWith("country:"); + }, + { + message: "Invalid flag: must start with country: or flag:", }, - { message: "Invalid flag: must be a valid country code or start with !" }, ); -export const PlayerCosmeticRefsSchema = z.object({ - flag: FlagSchema.optional(), - color: z.string().optional(), - patternName: PatternNameSchema.optional(), - patternColorPaletteName: z.string().optional(), -}); +export const FlagSchema = z.string(); export const PlayerPatternSchema = z.object({ - name: PatternNameSchema, + name: CosmeticNameSchema, patternData: PatternDataSchema, colorPalette: ColorPaletteSchema.optional(), }); @@ -501,6 +493,16 @@ export const PlayerColorSchema = z.object({ color: z.string(), }); +// Refs contain cosmetics names, will be replaced by the actual +// content in the server +export const PlayerCosmeticRefsSchema = z.object({ + flag: FlagName.optional(), + color: z.string().optional(), + patternName: CosmeticNameSchema.optional(), + patternColorPaletteName: z.string().optional(), +}); + +// Server converts refs to the actual cosmetics here export const PlayerCosmeticsSchema = z.object({ flag: FlagSchema.optional(), pattern: PlayerPatternSchema.optional(), diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d3e3ad87e..5e7df5c0b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -678,7 +678,7 @@ export class GameView implements GameMap { for (const nation of this._mapData.nations) { // Nations don't have client ids, so we use their name as the key instead. this._cosmetics.set(nation.name, { - flag: nation.flag, + flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined, } satisfies PlayerCosmetics); } } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 958410a19..fe7a2a481 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -4,13 +4,13 @@ import { PlayerPattern } from "../Schemas"; const PATTERN_KEY = "territoryPattern"; export class UserSettings { - private emitChange(key: string, value: boolean | number): void { + private emitChange(key: string, value: any): void { try { const maybeDispatch = (globalThis as any)?.dispatchEvent; if (typeof maybeDispatch !== "function") return; (globalThis as any).dispatchEvent( - new CustomEvent("user-settings-changed", { - detail: { key, value }, + new CustomEvent(`event:user-settings-changed:${key}`, { + detail: value, }), ); } catch { @@ -192,6 +192,7 @@ export class UserSettings { } else { localStorage.setItem(PATTERN_KEY, patternName); } + this.emitChange("pattern", patternName); } getSelectedColor(): string | undefined { @@ -208,12 +209,31 @@ export class UserSettings { } } - getFlag(): string | undefined { - const flag = localStorage.getItem("flag"); - if (!flag || flag === "xx") return undefined; + getFlag(): string | null { + let flag = localStorage.getItem("flag"); + if (!flag) return null; + // Migrate bare country codes to country: prefix + if (!flag.startsWith("flag:") && !flag.startsWith("country:")) { + flag = `country:${flag}`; + localStorage.setItem("flag", flag); + } return flag; } + setFlag(flag: string): void { + if (flag === "country:xx") { + this.clearFlag(); + } else { + localStorage.setItem("flag", flag); + } + console.log("emitting change!"); + this.emitChange("flag", flag); + } + + clearFlag(): void { + localStorage.removeItem("flag"); + } + backgroundMusicVolume(): number { return this.getFloat("settings.backgroundMusicVolume", 0); } diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index b0584232d..22ef36b5a 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -9,10 +9,11 @@ import { skipNonAlphabeticTransformer, toAsciiLowerCaseTransformer, } from "obscenity"; +import countries from "resources/countries.json"; + import { Cosmetics } from "../core/CosmeticSchemas"; import { decodePatternData } from "../core/PatternDecoder"; import { - FlagSchema, PlayerColor, PlayerCosmeticRefs, PlayerCosmetics, @@ -20,6 +21,8 @@ import { } from "../core/Schemas"; import { getClanTagOriginalCase, simpleHash } from "../core/Util"; +const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code); + export const shadowNames = [ "UnhuggedToday", "DaddysLilChamp", @@ -153,14 +156,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { } } if (refs.flag) { - const result = FlagSchema.safeParse(refs.flag); - if (!result.success) { - return { - type: "forbidden", - reason: "invalid flag: " + result.error.message, - }; + try { + cosmetics.flag = this.isFlagAllowed(flares, refs.flag); + } catch (e) { + return { type: "forbidden", reason: "invalid flag: " + e.message }; } - cosmetics.flag = result.data; } return { type: "allowed", cosmetics }; @@ -207,6 +207,28 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { } } + isFlagAllowed(flares: string[], flagRef: string): string { + if (flagRef.startsWith("flag:")) { + const key = flagRef.slice("flag:".length); + const found = this.cosmetics.flags[key]; + if (!found) throw new Error(`Flag ${key} not found`); + + if (flares.includes("flag:*") || flares.includes(`flag:${found.name}`)) { + return found.url; + } + + throw new Error(`No flares for flag ${key}`); + } else if (flagRef.startsWith("country:")) { + const code = flagRef.slice("country:".length); + if (!countryCodes.includes(code)) { + throw new Error(`invalid country code`); + } + return `/flags/${code}.svg`; + } else { + throw new Error(`invalid flag prefix`); + } + } + isColorAllowed(flares: string[], color: string): PlayerColor { const allowedColors = flares .filter((flare) => flare.startsWith("color:")) diff --git a/tests/CosmeticRelationship.test.ts b/tests/CosmeticRelationship.test.ts new file mode 100644 index 000000000..824c2352e --- /dev/null +++ b/tests/CosmeticRelationship.test.ts @@ -0,0 +1,147 @@ +import { cosmeticRelationship } from "../src/client/Cosmetics"; +import { UserMeResponse } from "../src/core/ApiSchemas"; + +const product = { productId: "prod_123", priceId: "price_123", price: "$4.99" }; + +function makeUserMe(flares: string[]): UserMeResponse { + return { + player: { flares }, + } as unknown as UserMeResponse; +} + +describe("cosmeticRelationship", () => { + it("returns owned when user has wildcard flare", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe(["flag:*"]), + ), + ).toBe("owned"); + }); + + it("returns owned when user has the specific flare", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe(["flag:cool"]), + ), + ).toBe("owned"); + }); + + it("returns blocked when no product and user does not own it", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product: null, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe([]), + ), + ).toBe("blocked"); + }); + + it("returns blocked when affiliate codes do not match", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: "storeA", + itemAffiliateCode: "storeB", + }, + makeUserMe([]), + ), + ).toBe("blocked"); + }); + + it("returns purchasable when product exists and affiliate matches", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe([]), + ), + ).toBe("purchasable"); + }); + + it("returns purchasable when affiliate codes match", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "pattern:*", + requiredFlare: "pattern:stripes:red", + product, + affiliateCode: "storeA", + itemAffiliateCode: "storeA", + }, + makeUserMe([]), + ), + ).toBe("purchasable"); + }); + + it("returns blocked when user is not logged in and no product", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product: null, + affiliateCode: null, + itemAffiliateCode: null, + }, + false, + ), + ).toBe("blocked"); + }); + + it("returns purchasable when user is not logged in but product exists", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "flag:*", + requiredFlare: "flag:cool", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + false, + ), + ).toBe("purchasable"); + }); + + it("returns owned when user has wildcard flare for patterns", () => { + expect( + cosmeticRelationship( + { + wildcardFlare: "pattern:*", + requiredFlare: "pattern:stripes:red", + product, + affiliateCode: null, + itemAffiliateCode: null, + }, + makeUserMe(["pattern:*"]), + ), + ).toBe("owned"); + }); +}); diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index e3acc62b3..d28f8dbbb 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -18,7 +18,7 @@ const bannedWords = [ const matcher = createMatcher(bannedWords); // Create a minimal PrivilegeCheckerImpl for testing censorUsername -const mockCosmetics = { patterns: {}, colorPalettes: {} }; +const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {} }; const mockDecoder = () => new Uint8Array(); const checker = new PrivilegeCheckerImpl( mockCosmetics, @@ -27,6 +27,24 @@ const checker = new PrivilegeCheckerImpl( ); const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []); +const flagCosmetics = { + patterns: {}, + colorPalettes: {}, + flags: { + cool_flag: { + name: "cool_flag", + url: "https://example.com/cool.png", + affiliateCode: null, + product: { productId: "prod_1", priceId: "price_1", price: "$4.99" }, + }, + }, +}; +const flagChecker = new PrivilegeCheckerImpl( + flagCosmetics, + mockDecoder, + bannedWords, +); + describe("UsernameCensor", () => { describe("isProfane (via matcher.hasMatch)", () => { test("detects exact banned words", () => { @@ -145,3 +163,63 @@ describe("UsernameCensor", () => { }); }); }); + +describe("Flag validation in isAllowed", () => { + test("allows valid country flag and resolves to SVG path", () => { + const result = flagChecker.isAllowed([], { flag: "country:us" }); + expect(result.type).toBe("allowed"); + if (result.type === "allowed") { + expect(result.cosmetics.flag).toBe("/flags/us.svg"); + } + }); + + test("rejects invalid country code", () => { + const result = flagChecker.isAllowed([], { flag: "country:zzzz" }); + expect(result.type).toBe("forbidden"); + }); + + test("rejects flag with no prefix", () => { + const result = flagChecker.isAllowed([], { flag: "us" }); + expect(result.type).toBe("forbidden"); + }); + + test("allows cosmetic flag when user has wildcard flare", () => { + const result = flagChecker.isAllowed(["flag:*"], { + flag: "flag:cool_flag", + }); + expect(result.type).toBe("allowed"); + if (result.type === "allowed") { + expect(result.cosmetics.flag).toBe("https://example.com/cool.png"); + } + }); + + test("allows cosmetic flag when user has specific flare", () => { + const result = flagChecker.isAllowed(["flag:cool_flag"], { + flag: "flag:cool_flag", + }); + expect(result.type).toBe("allowed"); + if (result.type === "allowed") { + expect(result.cosmetics.flag).toBe("https://example.com/cool.png"); + } + }); + + test("rejects cosmetic flag when user lacks flare", () => { + const result = flagChecker.isAllowed([], { flag: "flag:cool_flag" }); + expect(result.type).toBe("forbidden"); + }); + + test("rejects cosmetic flag that does not exist", () => { + const result = flagChecker.isAllowed(["flag:*"], { + flag: "flag:nonexistent", + }); + expect(result.type).toBe("forbidden"); + }); + + test("allows no flag", () => { + const result = flagChecker.isAllowed([], {}); + expect(result.type).toBe("allowed"); + if (result.type === "allowed") { + expect(result.cosmetics.flag).toBeUndefined(); + } + }); +}); From d83a4d2dc6789aefa6b142550cffe4849ab80b79 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:21:13 +0100 Subject: [PATCH 12/15] =?UTF-8?q?For=20v30:=20Fix=20base=20language=20pref?= =?UTF-8?q?erred=20over=20regional=20variant=20in=20auto-detection=20?= =?UTF-8?q?=F0=9F=8C=90=20(#3506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: When the browser reports a locale like `de-DE`, the language selector didn't find an exact match and fell through to candidate matching, where it picked `de-CH` (Swiss German) over `de` (German) because longer codes were sorted first. This adds an early check: if the base language code (e.g. `de`) is directly supported, return it immediately before scanning regional candidates. FYI @Aotumuri ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/LangSelector.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index cff2e079e..766a0ddeb 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -71,6 +71,7 @@ export class LangSelector extends LitElement { if (supported.has(lang)) return lang; const base = lang.slice(0, 2); + if (supported.has(base)) return base; const candidates = Array.from(supported).filter((key) => key.startsWith(base), ); From 4bf18dfafe2e465f63b7d03d41acfde3f4395304 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:26:57 +0100 Subject: [PATCH 13/15] =?UTF-8?q?Maybe=20for=20v30:=20Add=20leave=20confir?= =?UTF-8?q?mation=20dialog=20to=20JoinLobbyModal=20=F0=9F=9A=AA=20(#3507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Adds a `confirmBeforeClose()` override to `JoinLobbyModal`, matching the existing behavior in `HostLobbyModal`. Because the german streamers had a lot of problems with accidentally leaving today. When a user is in a lobby and tries to close the modal (Escape key or clicking outside), they now get a confirmation dialog asking if they really want to leave. If the user hasn't joined a lobby yet (still on the join form), the modal closes without prompting. Reuses the existing `host_modal.leave_confirmation` translation key. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/client/JoinLobbyModal.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 4bea53d43..65d65bfe5 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -369,6 +369,11 @@ export class JoinLobbyModal extends BaseModal { ); } + public confirmBeforeClose(): boolean { + if (!this.currentLobbyId) return true; + return confirm(translateText("host_modal.leave_confirmation")); + } + protected onClose(): void { this.clearCountdownTimer(); this.stopLobbyUpdates(); From dbba1dccb5e3b89dc539e02020e46f1fe4176db8 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 24 Mar 2026 15:17:25 -0700 Subject: [PATCH 14/15] Display the name of the creator for flags & skins (#3510) ## Description: So artists get credit for the work they do. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- resources/lang/en.json | 3 +++ src/client/FlagInputModal.ts | 7 ++++- src/client/Store.ts | 1 + src/client/components/ArtistInfo.ts | 37 ++++++++++++++++++++++++++ src/client/components/FlagButton.ts | 26 +++++++++--------- src/client/components/PatternButton.ts | 8 ++++-- src/core/CosmeticSchemas.ts | 2 ++ 7 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 src/client/components/ArtistInfo.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 7df65f56e..931304a3e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -919,6 +919,9 @@ "select_skin": "Select Skin", "selected": "selected" }, + "cosmetics": { + "artist_label": "Artist:" + }, "flag_input": { "title": "Select Flag", "button_title": "Pick a flag!", diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index c51fc2036..950b9afcd 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -39,7 +39,12 @@ export class FlagInputModal extends BaseModal { .map( ([key, flag]) => html` diff --git a/src/client/Store.ts b/src/client/Store.ts index 29c23d54f..5cf67cdc8 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -188,6 +188,7 @@ export class StoreModal extends BaseModal { name: flag.name, url: flag.url, product: flag.product, + artist: flag.artist, }} .selected=${selectedFlag === `flag:${key}`} .requiresPurchase=${rel === "purchasable"} diff --git a/src/client/components/ArtistInfo.ts b/src/client/components/ArtistInfo.ts new file mode 100644 index 000000000..3a55856aa --- /dev/null +++ b/src/client/components/ArtistInfo.ts @@ -0,0 +1,37 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateText } from "../Utils"; + +@customElement("artist-info") +export class ArtistInfo extends LitElement { + @property({ type: String }) + artist?: string; + + createRenderRoot() { + return this; + } + + render() { + if (!this.artist) { + return nothing; + } + + return html` +
    e.stopPropagation()} + > +
    + ? +
    + +
    + `; + } +} diff --git a/src/client/components/FlagButton.ts b/src/client/components/FlagButton.ts index f2575dad5..938087ef2 100644 --- a/src/client/components/FlagButton.ts +++ b/src/client/components/FlagButton.ts @@ -2,6 +2,7 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; import { Product } from "../../core/CosmeticSchemas"; import { translateCosmetic } from "../Cosmetics"; +import "./ArtistInfo"; import "./PurchaseButton"; export interface FlagItem { @@ -9,6 +10,7 @@ export interface FlagItem { name: string; url: string; product?: Product | null; + artist?: string; } @customElement("flag-button") @@ -39,7 +41,7 @@ export class FlagButton extends LitElement { render() { return html`
    -
    -
    - ${translateCosmetic("flags", this.flag.name)} -
    -
    + +
    + ${translateCosmetic("flags", this.flag.name)}
    +
    diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index c4349f113..83cfe207f 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -25,7 +25,6 @@ export class TerritoryPatternsModal extends BaseModal { private cosmetics: Cosmetics | null = null; private userSettings: UserSettings = new UserSettings(); - private isActive = false; private userMeResponse: UserMeResponse | false = false; private _onPatternSelected = async () => { @@ -137,21 +136,25 @@ export class TerritoryPatternsModal extends BaseModal { } render() { - if (!this.isActive && !this.inline) return html``; - const content = html`
    - ${modalHeader({ - title: translateText("territory_patterns.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - rightContent: !hasLinkedAccount(this.userMeResponse) - ? html`
    - ${this.renderNotLoggedInWarning()} -
    ` - : undefined, - })} -
    +
    + ${modalHeader({ + title: translateText("territory_patterns.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + rightContent: !hasLinkedAccount(this.userMeResponse) + ? html`
    + ${this.renderNotLoggedInWarning()} +
    ` + : undefined, + })} +
    +
    ${this.renderPatternGrid()}
    @@ -174,15 +177,8 @@ export class TerritoryPatternsModal extends BaseModal { `; } - public async open() { - this.isActive = true; + protected async onOpen(): Promise { await this.refresh(); - super.open(); - } - - public close() { - this.isActive = false; - super.close(); } private selectPattern(pattern: PlayerPattern | null) {