From eb6b2a9948dbe60053ecd66e16d8256b280cfb94 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:42:26 +0100 Subject: [PATCH 1/5] =?UTF-8?q?Rebalance=20nation=20difficulty=20?= =?UTF-8?q?=F0=9F=93=8A=20More=20oriented=20towards=20beginners=20now=20(#?= =?UTF-8?q?3184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: **3 problems:** * Nation difficulty steps in `DefaultConfig` don't look good (max troops: 0.5 → 1 → 1.125 → 1.25). * We previously reduced the difficulty of easy nations for singleplayer, but now they are too easy for public FFAs. * In Discord we discussed HvN difficulty and concluded that a 50% human win rate (my previous target) is too low; nations should be easier. This PR addresses all of them: * Difficulty steps in `DefaultConfig` are now cleaner (max troops: 0.5 → 0.75 → 1 → 1.25). * Nation difficulty in public-game FFAs is restored to *medium* (0.75 max troops - still weaker than humans, but not too weak). * HvN difficulty (medium) is now easier. Regarding singleplayer: These rebalances make nation difficulties more beginner-friendly, while experts still have their challenge at *Impossible*. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/core/configuration/DefaultConfig.ts | 12 ++++++------ src/server/MapPlaylist.ts | 3 +-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 6ad7efb27..3cb2e1af8 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -727,9 +727,9 @@ export class DefaultConfig implements Config { case Difficulty.Easy: return 12_500; case Difficulty.Medium: - return 25_000; // Like humans + return 18_750; case Difficulty.Hard: - return 28_125; + return 25_000; // Like humans case Difficulty.Impossible: return 31_250; default: @@ -762,9 +762,9 @@ export class DefaultConfig implements Config { case Difficulty.Easy: return maxTroops * 0.5; case Difficulty.Medium: - return maxTroops * 1; // Like humans + return maxTroops * 0.75; case Difficulty.Hard: - return maxTroops * 1.125; + return maxTroops * 1; // Like humans case Difficulty.Impossible: return maxTroops * 1.25; default: @@ -790,10 +790,10 @@ export class DefaultConfig implements Config { toAdd *= 0.9; break; case Difficulty.Medium: - toAdd *= 1; // Like humans + toAdd *= 0.95; break; case Difficulty.Hard: - toAdd *= 1.025; + toAdd *= 1; // Like humans break; case Difficulty.Impossible: toAdd *= 1.05; diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 0b0bcacec..ed80175c1 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -150,8 +150,7 @@ export class MapPlaylist { startingGold, }, startingGold, - difficulty: - playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy, + difficulty: Difficulty.Medium, infiniteGold: false, infiniteTroops: false, maxTimerValue: undefined, From bd96bcca0d29749dcb11ca1675df30245c28596e Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:42:59 +0000 Subject: [PATCH 2/5] ICU validation (#3183) ## Description: not sure if this should be approved or not? ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- resources/lang/bg.json | 6 +++--- resources/lang/de.json | 6 +++--- resources/lang/el.json | 6 +++--- resources/lang/fa.json | 6 +++--- resources/lang/fr.json | 6 +++--- resources/lang/id.json | 6 +++--- resources/lang/ja.json | 6 +++--- resources/lang/ko.json | 6 +++--- resources/lang/nl.json | 6 +++--- resources/lang/pl.json | 6 +++--- resources/lang/ru.json | 6 +++--- resources/lang/tr.json | 6 +++--- resources/lang/uk.json | 6 +++--- resources/lang/zh-CN.json | 6 +++--- 14 files changed, 42 insertions(+), 42 deletions(-) diff --git a/resources/lang/bg.json b/resources/lang/bg.json index 562270927..601803e73 100644 --- a/resources/lang/bg.json +++ b/resources/lang/bg.json @@ -790,15 +790,15 @@ "title_with_name": "Изпрати войници на {name}", "available_tooltip": "Текущите ти войници на разположение", "min_keep": "Минимално запазени", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "Плъзгач за войници", - "capacity_note": "Получателят може да приеме само {{amount}} в момента." + "capacity_note": "Получателят може да приеме само {amount} в момента." }, "send_gold_modal": { "title_with_name": "Изпрати злато на {name}", "available_tooltip": "Текущото ти злато на разположение", "aria_slider": "Количествен плъзгач", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "Скорост на повторението", diff --git a/resources/lang/de.json b/resources/lang/de.json index f1ad97fad..5161f74e6 100644 --- a/resources/lang/de.json +++ b/resources/lang/de.json @@ -659,15 +659,15 @@ "title_with_name": "Truppen an {name} senden", "available_tooltip": "Verfügbare Truppen", "min_keep": "Mindestbestand", - "slider_tooltip": "{{percent}} % • {{amount}}", + "slider_tooltip": "{percent} % • {amount}", "aria_slider": "Truppenregler", - "capacity_note": "Der Empfänger kann derzeit nur {{amount}} annehmen." + "capacity_note": "Der Empfänger kann derzeit nur {amount} annehmen." }, "send_gold_modal": { "title_with_name": "Gold an {name} senden", "available_tooltip": "Verfügbares Gold", "aria_slider": "Mengenregler", - "slider_tooltip": "{{percent}} % • {{amount}}" + "slider_tooltip": "{percent} % • {amount}" }, "replay_panel": { "replay_speed": "Wiedergabegeschwindigkeit", diff --git a/resources/lang/el.json b/resources/lang/el.json index cde595ed1..1b56b61f2 100644 --- a/resources/lang/el.json +++ b/resources/lang/el.json @@ -631,17 +631,17 @@ "available_tooltip": "Τα τρέχοντα διαθέσιμα στρατεύματά σου", "min_keep": "Ελάχιστη διατήρηση", "min_keep_pct": "(30%)", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "toggle_attack_bar_mode": "Χρησιμοποίησε την μπάρα επίθεσης για να στείλεις στρατεύματα", "warning_attackbar": "Μόλις ενεργοποιηθεί, δεν θα μπορείς να ανοίξεις απευθείας αυτό το modal. Θα στέλνεις στρατεύματα μόνο μέσω της μπάρας επίθεσης.", "aria_slider": "Ρυθμιστής στρατευμάτων", - "capacity_note": "Ο παραλήπτης μπορεί να δεχτεί μόνο {{amount}} τώρα." + "capacity_note": "Ο παραλήπτης μπορεί να δεχτεί μόνο {amount} τώρα." }, "send_gold_modal": { "title_with_name": "Αποστολή Χρυσού στον {name}", "available_tooltip": "Διαθέσιμος χρυσός", "aria_slider": "Ρυθμιστικό ποσού", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "Ταχύτητα επανάληψης", diff --git a/resources/lang/fa.json b/resources/lang/fa.json index 34cc78ce2..2651bb558 100644 --- a/resources/lang/fa.json +++ b/resources/lang/fa.json @@ -659,15 +659,15 @@ "title_with_name": "ارسال نیروها به {name}", "available_tooltip": "نیروهای فعلی در دسترس شما", "min_keep": "حداقل نگهداری", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "نوار نیروها", - "capacity_note": "گیرنده فقط می‌تواند {{amount}} را در حال حاضر بپذیرد." + "capacity_note": "گیرنده فقط می‌تواند {amount} را در حال حاضر بپذیرد." }, "send_gold_modal": { "title_with_name": "ارسال طلا به {name}", "available_tooltip": "طلا فعلی در دسترس شما", "aria_slider": "اسلاید مقدار", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "سرعت بازپخش", diff --git a/resources/lang/fr.json b/resources/lang/fr.json index 0df822e4e..3b00095aa 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -790,15 +790,15 @@ "title_with_name": "Envoyer des troupes à {name}", "available_tooltip": "Vos troupes actuellement disponibles", "min_keep": "Garder au minimum", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "Curseur de troupes", - "capacity_note": "Le destinataire ne peut accepter que {{amount}} pour le moment." + "capacity_note": "Le destinataire ne peut accepter que {amount} pour le moment." }, "send_gold_modal": { "title_with_name": "Envoyer de l'or à {name}", "available_tooltip": "Votre or disponible", "aria_slider": "Curseur de quantité", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "Vitesse de relecture", diff --git a/resources/lang/id.json b/resources/lang/id.json index 7f7a55fc4..7336db6e4 100644 --- a/resources/lang/id.json +++ b/resources/lang/id.json @@ -790,15 +790,15 @@ "title_with_name": "Kirim Pasukan ke {name}", "available_tooltip": "Pasukan Anda yang tersedia saat ini", "min_keep": "Minimal yang ditinggalkan", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "Penggeser pasukan", - "capacity_note": "Penerima hanya dapat menerima {{amount}} saat ini." + "capacity_note": "Penerima hanya dapat menerima {amount} saat ini." }, "send_gold_modal": { "title_with_name": "Kirim Emas ke {name}", "available_tooltip": "Emas yang Anda miliki saat ini", "aria_slider": "Penggeser jumlah", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "Kecepatan tanyangan ulang", diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 89751baae..52cb8b127 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -790,15 +790,15 @@ "title_with_name": "{name}へ軍隊を送信", "available_tooltip": "あなたの兵士数", "min_keep": "最小値を保つ", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "軍隊スライダー", - "capacity_note": "現在、受取主は {{amount}} のみ受け取ることができます。" + "capacity_note": "現在、受取主は {amount} のみ受け取ることができます。" }, "send_gold_modal": { "title_with_name": "{name}へゴールドを送信", "available_tooltip": "現在利用可能な資金", "aria_slider": "スライダー", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "再生速度", diff --git a/resources/lang/ko.json b/resources/lang/ko.json index c7addd441..986b2aa64 100644 --- a/resources/lang/ko.json +++ b/resources/lang/ko.json @@ -659,15 +659,15 @@ "title_with_name": "{name}에게 병력 보내기", "available_tooltip": "현재 지원 가능한 병력", "min_keep": "최소 보유량", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "병력 슬라이더", - "capacity_note": "상대방은 지금 {{amount}}까지만 받을 수 있습니다." + "capacity_note": "상대방은 지금 {amount}까지만 받을 수 있습니다." }, "send_gold_modal": { "title_with_name": "{name}에게 금 보내기", "available_tooltip": "현재 보유 중인 금", "aria_slider": "수량 슬라이더", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "리플레이 속도", diff --git a/resources/lang/nl.json b/resources/lang/nl.json index f8784c045..f4f30f145 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -790,15 +790,15 @@ "title_with_name": "Stuur Troepen naar {name}", "available_tooltip": "Jouw nu beschikbare troepen", "min_keep": "Min. houden", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "Troepen schuifbalk", - "capacity_note": "Ontvanger kan slechts {{amount}} accepteren momenteel." + "capacity_note": "Ontvanger kan slechts {amount} accepteren momenteel." }, "send_gold_modal": { "title_with_name": "Stuur Goud naar {name}", "available_tooltip": "Jouw nu beschikbare goud", "aria_slider": "Bedrag schuifbalk", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "Afspeelsnelheid", diff --git a/resources/lang/pl.json b/resources/lang/pl.json index 9ab359593..a0be1ccbc 100644 --- a/resources/lang/pl.json +++ b/resources/lang/pl.json @@ -631,17 +631,17 @@ "available_tooltip": "Obecnie dostępne wojsko", "min_keep": "Min. utrzymanie", "min_keep_pct": "(30%)", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "toggle_attack_bar_mode": "Użyj suwaka ataku, aby wysłać wojsko", "warning_attackbar": "Po włączeniu tej opcji nie możesz bezpośrednio otworzyć tego modułu. Będziesz wysyłał wojsko tylko przez suwak ataku.", "aria_slider": "Suwak Jednostek", - "capacity_note": "Odbiorca może teraz zaakceptować tylko {{amount}}." + "capacity_note": "Odbiorca może teraz zaakceptować tylko {amount}." }, "send_gold_modal": { "title_with_name": "Wyślij Złoto do {name}", "available_tooltip": "Obecnie dostępne złoto", "aria_slider": "Suwak Ilości Złota", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "Prędkość powtórki", diff --git a/resources/lang/ru.json b/resources/lang/ru.json index 89e9daf93..96d3d7a3d 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -790,15 +790,15 @@ "title_with_name": "Отправить войска игроку {name}", "available_tooltip": "Ваши доступные в данный момент войска", "min_keep": "Минимальный остаток", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "Ползунок войск", - "capacity_note": "Получатель в данный момент может принять только {{amount}}." + "capacity_note": "Получатель в данный момент может принять только {amount}." }, "send_gold_modal": { "title_with_name": "Отправить золото игроку {name}", "available_tooltip": "Ваше доступное в данный момент золото", "aria_slider": "Ползунок количества", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "Скорость повтора", diff --git a/resources/lang/tr.json b/resources/lang/tr.json index 940e12098..d6c483718 100644 --- a/resources/lang/tr.json +++ b/resources/lang/tr.json @@ -790,15 +790,15 @@ "title_with_name": "{name}'e Birlik Gönder", "available_tooltip": "Şu anda mevcut olan birliklerin", "min_keep": "Minimum Elinde Tutma", - "slider_tooltip": "%{{percent}} •{{amount}}", + "slider_tooltip": "%{percent} •{amount}", "aria_slider": "Birlik çubuğu", - "capacity_note": "Alıcı ancak {{amount}} kadar alabilir." + "capacity_note": "Alıcı ancak {amount} kadar alabilir." }, "send_gold_modal": { "title_with_name": "{name}'e Altın Gönder", "available_tooltip": "Mevcut altının", "aria_slider": "Miktar çubuğu", - "slider_tooltip": "%{{percent}} • {{amount}}" + "slider_tooltip": "%{percent} • {amount}" }, "replay_panel": { "replay_speed": "Tekrar oynatma hızı", diff --git a/resources/lang/uk.json b/resources/lang/uk.json index 80e797bac..bf680f6f3 100644 --- a/resources/lang/uk.json +++ b/resources/lang/uk.json @@ -790,15 +790,15 @@ "title_with_name": "Надіслати війська до {name}", "available_tooltip": "Ваші поточні доступні війська", "min_keep": "Мінімальний залишок", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "Повзунок військ", - "capacity_note": "Зараз отримувач може прийняти лише {{amount}}." + "capacity_note": "Зараз отримувач може прийняти лише {amount}." }, "send_gold_modal": { "title_with_name": "Надіслати золото до {name}", "available_tooltip": "Ваше поточне доступне золото", "aria_slider": "Повзунок кількості", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "Швидкість відтворення", diff --git a/resources/lang/zh-CN.json b/resources/lang/zh-CN.json index c090cd319..9cb843e61 100644 --- a/resources/lang/zh-CN.json +++ b/resources/lang/zh-CN.json @@ -790,15 +790,15 @@ "title_with_name": "向 {name} 发送军队", "available_tooltip": "你的剩余军队", "min_keep": "最小保留值", - "slider_tooltip": "{{percent}}% • {{amount}}", + "slider_tooltip": "{percent}% • {amount}", "aria_slider": "军队滑块", - "capacity_note": "接收者现在仅能接收 {{amount}}。" + "capacity_note": "接收者现在仅能接收 {amount}。" }, "send_gold_modal": { "title_with_name": "向 {name} 发送黄金", "available_tooltip": "你的剩余黄金", "aria_slider": "数量滑块", - "slider_tooltip": "{{percent}}% • {{amount}}" + "slider_tooltip": "{percent}% • {amount}" }, "replay_panel": { "replay_speed": "回放速度", From f8c14398c81e13f413b546af1fef64e9cf281b2c Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:41:14 +0000 Subject: [PATCH 3/5] UI Extraction Host/Solo Modal (#3181) ## Description: UI Extraction Host/Solo Modal - Made all buttons do the same "press" feel (options/settings/random map) are now the same as the map button. - Also fixed a bug where you could "drag" the map image off the button. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/client/HostLobbyModal.ts | 900 +++++++----------- src/client/SinglePlayerModal.ts | 791 +++++---------- src/client/components/GameConfigSettings.ts | 447 +++++++++ src/client/components/ToggleInputCard.ts | 173 ++++ src/client/components/map/MapDisplay.ts | 10 +- src/client/components/map/MapPicker.ts | 33 +- src/client/utilities/GameConfigHelpers.ts | 93 ++ src/client/utilities/RenderToggleInputCard.ts | 162 ---- src/client/utilities/RenderUnitTypeOptions.ts | 47 - 9 files changed, 1322 insertions(+), 1334 deletions(-) create mode 100644 src/client/components/GameConfigSettings.ts create mode 100644 src/client/components/ToggleInputCard.ts create mode 100644 src/client/utilities/GameConfigHelpers.ts delete mode 100644 src/client/utilities/RenderToggleInputCard.ts delete mode 100644 src/client/utilities/RenderUnitTypeOptions.ts diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 3a8f5c48f..71e661842 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,17 +1,14 @@ -import { TemplateResult, html } from "lit"; +import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { EventBus } from "../core/EventBus"; import { Difficulty, - Duos, GameMapSize, GameMapType, GameMode, HumansVsNations, - Quads, - Trios, UnitType, } from "../core/game/Game"; import { @@ -27,19 +24,23 @@ import { getPlayToken } from "./Auth"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; -import "./components/Difficulties"; -import "./components/FluentSlider"; +import "./components/GameConfigSettings"; import "./components/LobbyPlayerView"; -import "./components/map/MapPicker"; +import "./components/ToggleInputCard"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { - renderToggleInputCard, - renderToggleInputCardInput, -} from "./utilities/RenderToggleInputCard"; -import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; + getBotsForCompactMap, + getRandomMapType, + getUpdatedDisabledUnits, + parseBoundedFloatFromInput, + parseBoundedIntegerFromInput, + preventDisallowedKeys, + toOptionalNumber, +} from "./utilities/GameConfigHelpers"; + @customElement("host-lobby-modal") export class HostLobbyModal extends BaseModal { @state() private selectedMap: GameMapType = GameMapType.World; @@ -93,32 +94,6 @@ export class HostLobbyModal extends BaseModal { } }; - private renderOptionToggle( - labelKey: string, - checked: boolean, - onChange: (val: boolean) => void, - hidden: boolean = false, - ): TemplateResult { - if (hidden) return html``; - - return html` - - `; - } - private getRandomString(): string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; return Array.from( @@ -165,34 +140,73 @@ export class HostLobbyModal extends BaseModal { } render() { - const maxTimerHandlers = this.createToggleHandlers( - () => this.maxTimer, - (val) => (this.maxTimer = val), - () => this.maxTimerValue, - (val) => (this.maxTimerValue = val), - 30, - ); - const spawnImmunityHandlers = this.createToggleHandlers( - () => this.spawnImmunity, - (val) => (this.spawnImmunity = val), - () => this.spawnImmunityDurationMinutes, - (val) => (this.spawnImmunityDurationMinutes = val), - 5, - ); - const goldMultiplierHandlers = this.createToggleHandlers( - () => this.goldMultiplier, - (val) => (this.goldMultiplier = val), - () => this.goldMultiplierValue, - (val) => (this.goldMultiplierValue = val), - 2, - ); - const startingGoldHandlers = this.createToggleHandlers( - () => this.startingGold, - (val) => (this.startingGold = val), - () => this.startingGoldValue, - (val) => (this.startingGoldValue = val), - 5000000, - ); + const inputCards = [ + html``, + html``, + html``, + html``, + ]; const content = html`
-
-
- -
-
-
- - - -
-

- ${translateText("map.map")} -

-
- - this.handleMapSelection(mapValue)} - .onSelectRandom=${() => this.handleSelectRandomMap()} - > -
+
+ - -
-
-
- - - -
-

- ${translateText("difficulty.difficulty")} -

-
- -
- ${Object.entries(Difficulty) - .filter(([key]) => isNaN(Number(key))) - .map(([key, value]) => { - const isSelected = this.selectedDifficulty === value; - const isDisabled = this.disableNations; - return html` - - `; - })} -
-
- - -
-
-
- - - -
-

- ${translateText("host_modal.mode")} -

-
-
- ${[GameMode.FFA, GameMode.Team].map((mode) => { - const isSelected = this.gameMode === mode; - return html` - - `; - })} -
-
- - ${this.gameMode === GameMode.FFA - ? "" - : html` - -
-
- ${translateText("host_modal.team_count")} -
-
- ${[ - 2, - 3, - 4, - 5, - 6, - 7, - Quads, - Trios, - Duos, - HumansVsNations, - ].map((o) => { - const isSelected = this.teamCount === o; - return html` - - `; - })} -
-
- `} - - -
-
-
- - - -
-

- ${translateText("host_modal.options_title")} -

-
-
- -
- -
- - ${!( - this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations - ) - ? this.renderOptionToggle( - "host_modal.disable_nations", - this.disableNations, - this.handleDisableNationsChange, - ) - : ""} - ${this.renderOptionToggle( - "host_modal.instant_build", - this.instantBuild, - this.handleInstantBuildChange, - )} - ${this.renderOptionToggle( - "host_modal.random_spawn", - this.randomSpawn, - this.handleRandomSpawnChange, - )} - ${this.renderOptionToggle( - "host_modal.donate_gold", - this.donateGold, - this.handleDonateGoldChange, - )} - ${this.renderOptionToggle( - "host_modal.donate_troops", - this.donateTroops, - this.handleDonateTroopsChange, - )} - ${this.renderOptionToggle( - "host_modal.infinite_gold", - this.infiniteGold, - this.handleInfiniteGoldChange, - )} - ${this.renderOptionToggle( - "host_modal.infinite_troops", - this.infiniteTroops, - this.handleInfiniteTroopsChange, - )} - ${this.renderOptionToggle( - "host_modal.compact_map", - this.compactMap, - this.handleCompactMapChange, - )} - - - ${renderToggleInputCard({ - labelKey: "host_modal.max_timer", - checked: this.maxTimer, - onClick: maxTimerHandlers.click, - input: renderToggleInputCardInput({ - min: 0, - max: 120, - value: this.maxTimerValue ?? 0, - ariaLabel: translateText("host_modal.max_timer"), - placeholder: translateText("host_modal.mins_placeholder"), - onInput: this.handleMaxTimerValueChanges, - onKeyDown: this.handleMaxTimerValueKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "host_modal.player_immunity_duration", - checked: this.spawnImmunity, - onClick: spawnImmunityHandlers.click, - input: renderToggleInputCardInput({ - min: 0, - max: 120, - step: 1, - value: this.spawnImmunityDurationMinutes ?? 0, - ariaLabel: translateText( - "host_modal.player_immunity_duration", - ), - placeholder: translateText("host_modal.mins_placeholder"), - onInput: this.handleSpawnImmunityDurationInput, - onKeyDown: this.handleSpawnImmunityDurationKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "single_modal.gold_multiplier", - checked: this.goldMultiplier, - onClick: goldMultiplierHandlers.click, - input: renderToggleInputCardInput({ - id: "gold-multiplier-value", - min: 0.1, - max: 1000, - step: "any", - value: this.goldMultiplierValue ?? "", - ariaLabel: translateText("single_modal.gold_multiplier"), - placeholder: translateText( - "single_modal.gold_multiplier_placeholder", - ), - onChange: this.handleGoldMultiplierValueChanges, - onKeyDown: this.handleGoldMultiplierValueKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "single_modal.starting_gold", - checked: this.startingGold, - onClick: startingGoldHandlers.click, - input: renderToggleInputCardInput({ - id: "starting-gold-value", - min: 0, - max: 1000000000, - step: 100000, - value: this.startingGoldValue ?? "", - ariaLabel: translateText("single_modal.starting_gold"), - placeholder: translateText( - "single_modal.starting_gold_placeholder", - ), - onInput: this.handleStartingGoldValueChanges, - onKeyDown: this.handleStartingGoldValueKeyDown, - }), - })} -
-
- - -
-
-
- - - -
-

- ${translateText("host_modal.enables_title")} -

-
-
- ${renderUnitTypeOptions({ - disabledUnits: this.disabledUnits, - toggleUnit: this.toggleUnit.bind(this), - })} -
-
- - - this.kickPlayer(clientID)} - > -
+ this.kickPlayer(clientID)} + >
@@ -700,40 +395,6 @@ export class HostLobbyModal extends BaseModal { this.loadNationCount(); } - private createToggleHandlers( - toggleStateGetter: () => boolean, - toggleStateSetter: (val: boolean) => void, - valueGetter: () => number | undefined, - valueSetter: (val: number | undefined) => void, - defaultValue: number = 0, - ) { - const toggleLogic = () => { - const newState = !toggleStateGetter(); - toggleStateSetter(newState); - if (newState) { - valueSetter(valueGetter() ?? defaultValue); - } else { - valueSetter(undefined); - } - this.putGameConfig(); - this.requestUpdate(); - }; - - return { - click: (e: Event) => { - if ((e.target as HTMLElement).tagName.toLowerCase() === "input") return; - toggleLogic(); - }, - keydown: (e: KeyboardEvent) => { - if ((e.target as HTMLElement).tagName.toLowerCase() === "input") return; - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleLogic(); - } - }, - }; - } - private leaveLobby() { if (!this.lobbyId) { return; @@ -800,11 +461,15 @@ export class HostLobbyModal extends BaseModal { private async handleSelectRandomMap() { this.useRandomMap = true; - this.selectedMap = this.getRandomMap(); + this.selectedMap = getRandomMapType(); await this.loadNationCount(); this.putGameConfig(); } + private handleConfigRandomMapSelected = () => { + void this.handleSelectRandomMap(); + }; + private async handleMapSelection(value: GameMapType) { this.selectedMap = value; this.useRandomMap = false; @@ -812,13 +477,81 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); } + private handleConfigMapSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ map: GameMapType }>; + void this.handleMapSelection(customEvent.detail.map); + }; + private async handleDifficultySelection(value: Difficulty) { this.selectedDifficulty = value; this.putGameConfig(); } + private handleConfigDifficultySelected = (e: Event) => { + const customEvent = e as CustomEvent<{ difficulty: Difficulty }>; + void this.handleDifficultySelection(customEvent.detail.difficulty); + }; + + private handleConfigGameModeSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ mode: GameMode }>; + void this.handleGameModeSelection(customEvent.detail.mode); + }; + + private handleConfigTeamCountSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ count: TeamCountConfig }>; + void this.handleTeamCountSelection(customEvent.detail.count); + }; + + private handleConfigOptionToggleChanged = (e: Event) => { + const customEvent = e as CustomEvent<{ + labelKey: string; + checked: boolean; + }>; + const { labelKey, checked } = customEvent.detail; + + switch (labelKey) { + case "host_modal.disable_nations": + void this.handleDisableNationsChange(checked); + break; + case "host_modal.instant_build": + this.handleInstantBuildChange(checked); + break; + case "host_modal.random_spawn": + this.handleRandomSpawnChange(checked); + break; + case "host_modal.donate_gold": + this.handleDonateGoldChange(checked); + break; + case "host_modal.donate_troops": + this.handleDonateTroopsChange(checked); + break; + case "host_modal.infinite_gold": + this.handleInfiniteGoldChange(checked); + break; + case "host_modal.infinite_troops": + this.handleInfiniteTroopsChange(checked); + break; + case "host_modal.compact_map": + this.handleCompactMapChange(checked); + break; + default: + break; + } + }; + + private handleConfigUnitToggleChanged = (e: Event) => { + const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>; + const { unit, checked } = customEvent.detail; + this.disabledUnits = getUpdatedDisabledUnits( + this.disabledUnits, + unit, + checked, + ); + this.putGameConfig(); + }; + // Modified to include debouncing - private handleBotsChange(e: Event) { + private handleBotsChange = (e: Event) => { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { @@ -838,67 +571,94 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); this.botsUpdateTimer = null; }, 300); - } + }; private handleInstantBuildChange = (val: boolean) => { this.instantBuild = val; this.putGameConfig(); }; - private handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) { - if (["-", "+", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleMaxTimerToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.maxTimer = checked; + this.maxTimerValue = toOptionalNumber(value); + this.putGameConfig(); + }; - private handleSpawnImmunityDurationInput(e: Event) { + private handleSpawnImmunityToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.spawnImmunity = checked; + this.spawnImmunityDurationMinutes = toOptionalNumber(value); + this.putGameConfig(); + }; + + private handleGoldMultiplierToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.goldMultiplier = checked; + this.goldMultiplierValue = toOptionalNumber(value); + this.putGameConfig(); + }; + + private handleStartingGoldToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.startingGold = checked; + this.startingGoldValue = toOptionalNumber(value); + this.putGameConfig(); + }; + + private handleSpawnImmunityDurationKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e", "E"]); + }; + + private handleSpawnImmunityDurationInput = (e: Event) => { const input = e.target as HTMLInputElement; - input.value = input.value.replace(/[eE+-]/g, ""); - const value = parseInt(input.value, 10); - if (Number.isNaN(value) || value < 0 || value > 120) { + const value = parseBoundedIntegerFromInput(input, { min: 0, max: 120 }); + if (value === undefined) { return; } this.spawnImmunityDurationMinutes = value; this.putGameConfig(); - } + }; - private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { - if (["+", "-", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["+", "-", "e", "E"]); + }; - private handleGoldMultiplierValueChanges(e: Event) { + private handleGoldMultiplierValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - const value = parseFloat(input.value); + const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 }); - if (isNaN(value) || value < 0.1 || value > 1000) { + if (value === undefined) { this.goldMultiplierValue = undefined; input.value = ""; } else { this.goldMultiplierValue = value; } this.putGameConfig(); - } + }; - private handleStartingGoldValueKeyDown(e: KeyboardEvent) { - if (["-", "+", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e", "E"]); + }; - private handleStartingGoldValueChanges(e: Event) { + private handleStartingGoldValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - input.value = input.value.replace(/[eE+-]/g, ""); - const value = parseInt(input.value); + const value = parseBoundedIntegerFromInput(input, { + min: 0, + max: 1000000000, + }); - if (isNaN(value) || value < 0 || value > 1000000000) { - this.startingGoldValue = undefined; - } else { - this.startingGoldValue = value; - } + this.startingGoldValue = value; this.putGameConfig(); - } + }; private handleRandomSpawnChange = (val: boolean) => { this.randomSpawn = val; @@ -922,11 +682,7 @@ export class HostLobbyModal extends BaseModal { private handleCompactMapChange = (val: boolean) => { this.compactMap = val; - if (val && this.bots === 400) { - this.bots = 100; - } else if (!val && this.bots === 100) { - this.bots = 400; - } + this.bots = getBotsForCompactMap(this.bots, val); this.putGameConfig(); }; @@ -935,24 +691,24 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; - private handleMaxTimerValueKeyDown(e: KeyboardEvent) { - if (["-", "+", "e"].includes(e.key)) { - e.preventDefault(); - } - } + private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e"]); + }; - private handleMaxTimerValueChanges(e: Event) { - (e.target as HTMLInputElement).value = ( - e.target as HTMLInputElement - ).value.replace(/[e+-]/gi, ""); - const value = parseInt((e.target as HTMLInputElement).value); + private handleMaxTimerValueChanges = (e: Event) => { + const input = e.target as HTMLInputElement; + const value = parseBoundedIntegerFromInput(input, { + min: 1, + max: 120, + stripPattern: /[e+-]/gi, + }); - if (isNaN(value) || value < 0 || value > 120) { + if (value === undefined) { return; } this.maxTimerValue = value; this.putGameConfig(); - } + }; private handleDisableNationsChange = async (val: boolean) => { this.disableNations = val; @@ -1029,20 +785,6 @@ export class HostLobbyModal extends BaseModal { ); } - private toggleUnit(unit: UnitType, checked: boolean): void { - this.disabledUnits = checked - ? [...this.disabledUnits, unit] - : this.disabledUnits.filter((u) => u !== unit); - - this.putGameConfig(); - } - - private getRandomMap(): GameMapType { - const maps = Object.values(GameMapType); - const randIdx = Math.floor(Math.random() * maps.length); - return maps[randIdx] as GameMapType; - } - private async startGame() { await this.putGameConfig(); console.log( diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 4f7bf279c..6a72726bc 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -4,14 +4,11 @@ import { translateText } from "../client/Utils"; import { UserMeResponse } from "../core/ApiSchemas"; import { Difficulty, - Duos, GameMapSize, GameMapType, GameMode, GameType, HumansVsNations, - Quads, - Trios, UnitType, } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; @@ -21,9 +18,8 @@ import { hasLinkedAccount } from "./Api"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; -import "./components/Difficulties"; -import "./components/FluentSlider"; -import "./components/map/MapPicker"; +import "./components/GameConfigSettings"; +import "./components/ToggleInputCard"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; @@ -31,10 +27,14 @@ import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; import { - renderToggleInputCard, - renderToggleInputCardInput, -} from "./utilities/RenderToggleInputCard"; -import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; + getBotsForCompactMap, + getRandomMapType, + getUpdatedDisabledUnits, + parseBoundedFloatFromInput, + parseBoundedIntegerFromInput, + preventDisallowedKeys, + toOptionalNumber, +} from "./utilities/GameConfigHelpers"; const DEFAULT_OPTIONS = { selectedMap: GameMapType.World, @@ -166,6 +166,60 @@ export class SinglePlayerModal extends BaseModal { } render() { + const inputCards = [ + html``, + html``, + html``, + ]; + const content = html`
-
-
- -
-
-
- - - -
-

- ${translateText("map.map")} -

-
- - - this.handleMapSelection(mapValue)} - .onSelectRandom=${() => this.handleSelectRandomMap()} - > -
- - -
-
-
- - - -
-

- ${translateText("difficulty.difficulty")} -

-
- -
- ${Object.entries(Difficulty) - .filter(([key]) => isNaN(Number(key))) - .map( - ([key, value]) => html` - - `, - )} -
-
- - -
-
-
- - - -
-

- ${translateText("host_modal.mode")} -

-
- -
- ${[GameMode.FFA, GameMode.Team].map((mode) => { - const isSelected = this.gameMode === mode; - const label = - mode === GameMode.FFA - ? translateText("game_mode.ffa") - : translateText("game_mode.teams"); - - return html` - - `; - })} -
-
- - ${this.gameMode === GameMode.FFA - ? "" - : html` - -
-
- ${translateText("host_modal.team_count")} -
-
- ${[ - 2, - 3, - 4, - 5, - 6, - 7, - Quads, - Trios, - Duos, - HumansVsNations, - ].map( - (o) => html` - - `, - )} -
-
- `} - - -
-
-
- - - -
-

- ${translateText("single_modal.options_title")} -

-
- -
- -
- -
- - ${this.renderOptionToggle( - "single_modal.disable_nations", - this.disableNations, - (val) => (this.disableNations = val), - this.gameMode === GameMode.Team && - this.teamCount === HumansVsNations, - )} - ${this.renderOptionToggle( - "single_modal.instant_build", - this.instantBuild, - (val) => (this.instantBuild = val), - )} - ${this.renderOptionToggle( - "single_modal.random_spawn", - this.randomSpawn, - (val) => (this.randomSpawn = val), - )} - ${this.renderOptionToggle( - "single_modal.infinite_gold", - this.infiniteGold, - (val) => (this.infiniteGold = val), - )} - ${this.renderOptionToggle( - "single_modal.infinite_troops", - this.infiniteTroops, - (val) => (this.infiniteTroops = val), - )} - ${this.renderOptionToggle( - "single_modal.compact_map", - this.compactMap, - (val) => { - this.compactMap = val; - if (val && this.bots === 400) { - this.bots = 100; - } else if (!val && this.bots === 100) { - this.bots = 400; - } +
+ { - this.maxTimer = !this.maxTimer; - if (!this.maxTimer) { - this.maxTimerValue = undefined; - } else { - // Set default value when enabling if not already set or invalid - if (!this.maxTimerValue || this.maxTimerValue <= 0) { - this.maxTimerValue = 30; - } - // Focus the input after render - setTimeout(() => { - const input = this.getEndTimerInput(); - if (input) { - input.focus(); - input.select(); - } - }, 0); - } + { + labelKey: "single_modal.instant_build", + checked: this.instantBuild, }, - input: renderToggleInputCardInput({ - id: "end-timer-value", - min: 1, - max: 120, - value: this.maxTimerValue ?? "", - ariaLabel: translateText("single_modal.max_timer"), - placeholder: translateText( - "single_modal.max_timer_placeholder", - ), - onInput: this.handleMaxTimerValueChanges, - onKeyDown: this.handleMaxTimerValueKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "single_modal.gold_multiplier", - checked: this.goldMultiplier, - onClick: () => { - this.goldMultiplier = !this.goldMultiplier; - if (!this.goldMultiplier) { - this.goldMultiplierValue = undefined; - } else { - if ( - !this.goldMultiplierValue || - this.goldMultiplierValue <= 0 - ) { - this.goldMultiplierValue = 2; - } - setTimeout(() => { - const input = this.renderRoot.querySelector( - "#gold-multiplier-value", - ) as HTMLInputElement; - if (input) { - input.focus(); - input.select(); - } - }, 0); - } + { + labelKey: "single_modal.random_spawn", + checked: this.randomSpawn, }, - input: renderToggleInputCardInput({ - id: "gold-multiplier-value", - min: 0.1, - max: 1000, - step: "any", - value: this.goldMultiplierValue ?? "", - ariaLabel: translateText("single_modal.gold_multiplier"), - placeholder: translateText( - "single_modal.gold_multiplier_placeholder", - ), - onChange: this.handleGoldMultiplierValueChanges, - onKeyDown: this.handleGoldMultiplierValueKeyDown, - }), - })} - - - ${renderToggleInputCard({ - labelKey: "single_modal.starting_gold", - checked: this.startingGold, - onClick: () => { - this.startingGold = !this.startingGold; - if (!this.startingGold) { - this.startingGoldValue = undefined; - } else { - if ( - !this.startingGoldValue || - this.startingGoldValue < 0 - ) { - this.startingGoldValue = 5000000; - } - setTimeout(() => { - const input = this.renderRoot.querySelector( - "#starting-gold-value", - ) as HTMLInputElement; - if (input) { - input.focus(); - input.select(); - } - }, 0); - } + { + labelKey: "single_modal.infinite_gold", + checked: this.infiniteGold, }, - input: renderToggleInputCardInput({ - id: "starting-gold-value", - min: 0, - max: 1000000000, - step: 100000, - value: this.startingGoldValue ?? "", - ariaLabel: translateText("single_modal.starting_gold"), - placeholder: translateText( - "single_modal.starting_gold_placeholder", - ), - onInput: this.handleStartingGoldValueChanges, - onKeyDown: this.handleStartingGoldValueKeyDown, - }), - })} -
-
- - -
-
-
- - - -
-

- ${translateText("single_modal.enables_title")} -

-
-
- ${renderUnitTypeOptions({ - disabledUnits: this.disabledUnits, - toggleUnit: this.toggleUnit.bind(this), - })} -
-
-
+ { + labelKey: "single_modal.infinite_troops", + checked: this.infiniteTroops, + }, + { + labelKey: "single_modal.compact_map", + checked: this.compactMap, + }, + ], + inputCards, + }, + unitTypes: { + titleKey: "single_modal.enables_title", + disabledUnits: this.disabledUnits, + }, + }} + @map-selected=${this.handleConfigMapSelected} + @random-map-selected=${this.handleConfigRandomMapSelected} + @difficulty-selected=${this.handleConfigDifficultySelected} + @game-mode-selected=${this.handleConfigGameModeSelected} + @team-count-selected=${this.handleConfigTeamCountSelected} + @bots-changed=${this.handleBotsChange} + @option-toggle-changed=${this.handleConfigOptionToggleChanged} + @unit-toggle-changed=${this.handleConfigUnitToggleChanged} + >
@@ -698,33 +383,6 @@ export class SinglePlayerModal extends BaseModal { ); } - // Helper for consistent option buttons - private renderOptionToggle( - labelKey: string, - checked: boolean, - onChange: (val: boolean) => void, - hidden: boolean = false, - ): TemplateResult { - if (hidden) return html``; - - return html` - - `; - } - protected onClose(): void { // Reset all transient form state to ensure clean slate this.selectedMap = DEFAULT_OPTIONS.selectedMap; @@ -752,29 +410,121 @@ export class SinglePlayerModal extends BaseModal { this.useRandomMap = true; } + private handleConfigRandomMapSelected = () => { + this.handleSelectRandomMap(); + }; + private handleMapSelection(value: GameMapType) { this.selectedMap = value; this.useRandomMap = false; } + private handleConfigMapSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ map: GameMapType }>; + this.handleMapSelection(customEvent.detail.map); + }; + private handleDifficultySelection(value: Difficulty) { this.selectedDifficulty = value; } - private handleBotsChange(e: Event) { + private handleConfigDifficultySelected = (e: Event) => { + const customEvent = e as CustomEvent<{ difficulty: Difficulty }>; + this.handleDifficultySelection(customEvent.detail.difficulty); + }; + + private handleConfigGameModeSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ mode: GameMode }>; + this.handleGameModeSelection(customEvent.detail.mode); + }; + + private handleConfigTeamCountSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ count: TeamCountConfig }>; + this.handleTeamCountSelection(customEvent.detail.count); + }; + + private handleCompactMapChange(val: boolean) { + this.compactMap = val; + this.bots = getBotsForCompactMap(this.bots, val); + } + + private handleConfigOptionToggleChanged = (e: Event) => { + const customEvent = e as CustomEvent<{ + labelKey: string; + checked: boolean; + }>; + const { labelKey, checked } = customEvent.detail; + + switch (labelKey) { + case "single_modal.disable_nations": + this.disableNations = checked; + break; + case "single_modal.instant_build": + this.instantBuild = checked; + break; + case "single_modal.random_spawn": + this.randomSpawn = checked; + break; + case "single_modal.infinite_gold": + this.infiniteGold = checked; + break; + case "single_modal.infinite_troops": + this.infiniteTroops = checked; + break; + case "single_modal.compact_map": + this.handleCompactMapChange(checked); + break; + default: + break; + } + }; + + private handleConfigUnitToggleChanged = (e: Event) => { + const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>; + const { unit, checked } = customEvent.detail; + this.disabledUnits = getUpdatedDisabledUnits( + this.disabledUnits, + unit, + checked, + ); + }; + + private handleBotsChange = (e: Event) => { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; if (isNaN(value) || value < 0 || value > 400) { return; } this.bots = value; - } + }; - private handleMaxTimerValueKeyDown(e: KeyboardEvent) { - if (["-", "+", "e"].includes(e.key)) { - e.preventDefault(); - } - } + private handleMaxTimerToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.maxTimer = checked; + this.maxTimerValue = toOptionalNumber(value); + }; + + private handleGoldMultiplierToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.goldMultiplier = checked; + this.goldMultiplierValue = toOptionalNumber(value); + }; + + private handleStartingGoldToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.startingGold = checked; + this.startingGoldValue = toOptionalNumber(value); + }; + + private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e"]); + }; private getEndTimerInput(): HTMLInputElement | null { return ( @@ -785,55 +535,46 @@ export class SinglePlayerModal extends BaseModal { ); } - private handleMaxTimerValueChanges(e: Event) { + private handleMaxTimerValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - input.value = input.value.replace(/[e+-]/gi, ""); - const value = parseInt(input.value); + const value = parseBoundedIntegerFromInput(input, { + min: 1, + max: 120, + stripPattern: /[e+-]/gi, + }); - // Always update state to keep UI and internal state in sync - if (isNaN(value) || value < 1 || value > 120) { - // Set to undefined for invalid/empty/out-of-range values - this.maxTimerValue = undefined; - } else { - this.maxTimerValue = value; - } - } + this.maxTimerValue = value; + }; - private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { - if (["+", "-", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["+", "-", "e", "E"]); + }; - private handleGoldMultiplierValueChanges(e: Event) { + private handleGoldMultiplierValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - const value = parseFloat(input.value); + const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 }); - if (isNaN(value) || value < 0.1 || value > 1000) { + if (value === undefined) { this.goldMultiplierValue = undefined; input.value = ""; } else { this.goldMultiplierValue = value; } - } + }; - private handleStartingGoldValueKeyDown(e: KeyboardEvent) { - if (["-", "+", "e", "E"].includes(e.key)) { - e.preventDefault(); - } - } + private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e", "E"]); + }; - private handleStartingGoldValueChanges(e: Event) { + private handleStartingGoldValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - input.value = input.value.replace(/[eE+-]/g, ""); - const value = parseInt(input.value); + const value = parseBoundedIntegerFromInput(input, { + min: 0, + max: 1000000000, + }); - if (isNaN(value) || value < 0 || value > 1000000000) { - this.startingGoldValue = undefined; - } else { - this.startingGoldValue = value; - } - } + this.startingGoldValue = value; + }; private handleGameModeSelection(value: GameMode) { this.gameMode = value; @@ -843,18 +584,6 @@ export class SinglePlayerModal extends BaseModal { this.teamCount = value; } - private getRandomMap(): GameMapType { - const maps = Object.values(GameMapType); - const randIdx = Math.floor(Math.random() * maps.length); - return maps[randIdx] as GameMapType; - } - - private toggleUnit(unit: UnitType, checked: boolean): void { - this.disabledUnits = checked - ? [...this.disabledUnits, unit] - : this.disabledUnits.filter((u) => u !== unit); - } - private async startGame() { // Validate and clamp maxTimer setting before starting let finalMaxTimerValue: number | undefined = undefined; @@ -879,7 +608,7 @@ export class SinglePlayerModal extends BaseModal { // If random map is selected, choose a random map now if (this.useRandomMap) { - this.selectedMap = this.getRandomMap(); + this.selectedMap = getRandomMapType(); } console.log( diff --git a/src/client/components/GameConfigSettings.ts b/src/client/components/GameConfigSettings.ts new file mode 100644 index 000000000..b981a89fe --- /dev/null +++ b/src/client/components/GameConfigSettings.ts @@ -0,0 +1,447 @@ +import { + LitElement, + SVGTemplateResult, + TemplateResult, + html, + nothing, + svg, +} from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { + Difficulty, + Duos, + GameMapType, + GameMode, + HumansVsNations, + Quads, + Trios, + UnitType, +} from "../../core/game/Game"; +import { TeamCountConfig } from "../../core/Schemas"; +import { translateText } from "../Utils"; +import "./Difficulties"; +import "./FluentSlider"; +import "./map/MapPicker"; + +const ACTIVE_CARD = + "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"; +const INACTIVE_CARD = + "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"; + +const DISABLED_CARD = + "w-full rounded-xl border transition-all duration-200 opacity-30 grayscale cursor-not-allowed bg-white/5 border-white/5"; + +function cardClass(active: boolean, extra = ""): string { + return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`; +} + +const CARD_LABEL_CLASS = + "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto"; + +const DIFFICULTY_OPTIONS = Object.entries(Difficulty).filter(([key]) => + isNaN(Number(key)), +) as Array<[string, Difficulty]>; +const TEAM_COUNT_OPTIONS: TeamCountConfig[] = [ + 2, + 3, + 4, + 5, + 6, + 7, + Quads, + Trios, + Duos, + HumansVsNations, +]; + +function stateTextClass(active: boolean): string { + return active ? "text-white" : "text-white/60"; +} + +function renderTextCardButton( + label: string, + active: boolean, + onClick: () => void, + cardExtraClass: string, +): TemplateResult { + return html` + + `; +} + +function renderSection( + iconSvg: SVGTemplateResult, + colorClass: string, + bgClass: string, + titleKey: string, + content: TemplateResult | TemplateResult[], + sectionClass = "space-y-6", +): TemplateResult { + return html` +
+ ${renderSectionHeader(iconSvg, colorClass, bgClass, titleKey)} ${content} +
+ `; +} + +const unitOptions: { type: UnitType; translationKey: string }[] = [ + { type: UnitType.City, translationKey: "unit_type.city" }, + { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" }, + { type: UnitType.Port, translationKey: "unit_type.port" }, + { type: UnitType.Warship, translationKey: "unit_type.warship" }, + { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" }, + { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" }, + { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" }, + { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" }, + { type: UnitType.MIRV, translationKey: "unit_type.mirv" }, + { type: UnitType.Factory, translationKey: "unit_type.factory" }, +]; + +const MAP_ICON = svg``; + +const DIFFICULTY_ICON = svg``; + +const MODE_ICON = svg``; + +const OPTIONS_ICON = svg``; + +const ENABLES_ICON = svg``; + +function renderSectionHeader( + iconSvg: SVGTemplateResult, + colorClass: string, + bgClass: string, + titleKey: string, +): TemplateResult { + return html` +
+
+ + ${iconSvg} + +
+

+ ${translateText(titleKey)} +

+
+ `; +} + +export interface ToggleOptionConfig { + labelKey: string; + checked: boolean; + hidden?: boolean; +} + +export interface GameConfigSettingsData { + map: { + selected: GameMapType; + useRandom: boolean; + randomMapDivider?: boolean; + showMedals?: boolean; + mapWins?: Map>; + }; + difficulty: { + selected: Difficulty; + disabled: boolean; + }; + gameMode: { + selected: GameMode; + }; + teamCount: { + selected: TeamCountConfig; + }; + options: { + titleKey: string; + bots: { + value: number; + labelKey: string; + disabledKey: string; + }; + toggles: ToggleOptionConfig[]; + inputCards: TemplateResult[]; + }; + unitTypes: { + titleKey: string; + disabledUnits: UnitType[]; + }; +} + +@customElement("game-config-settings") +export class GameConfigSettings extends LitElement { + @property({ attribute: false }) settings?: GameConfigSettingsData; + @property({ attribute: false }) sectionGapClass = "space-y-6"; + + createRenderRoot() { + return this; + } + + private emit(name: string, detail: T) { + this.dispatchEvent( + new CustomEvent(name, { + detail, + bubbles: true, + composed: true, + }), + ); + } + + private handleSelectMap = (map: GameMapType) => { + this.emit("map-selected", { map }); + }; + + private handleSelectRandom = () => { + this.emit("random-map-selected", {}); + }; + + private handleDifficultySelect = (difficulty: Difficulty) => { + this.emit("difficulty-selected", { difficulty }); + }; + + private handleGameModeSelect = (mode: GameMode) => { + this.emit("game-mode-selected", { mode }); + }; + + private handleTeamCountSelect = (count: TeamCountConfig) => { + this.emit("team-count-selected", { count }); + }; + + private handleOptionToggle = (toggle: ToggleOptionConfig) => { + this.emit("option-toggle-changed", { + labelKey: toggle.labelKey, + checked: !toggle.checked, + }); + }; + + private handleBotsChanged = (event: Event) => { + const customEvent = event as CustomEvent<{ value: number }>; + this.emit("bots-changed", customEvent.detail); + }; + + private handleUnitToggle = (unit: UnitType, checked: boolean) => { + this.emit("unit-toggle-changed", { unit, checked }); + }; + + private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult { + if (toggle.hidden) return html``; + + return renderTextCardButton( + translateText(toggle.labelKey), + toggle.checked, + () => this.handleOptionToggle(toggle), + "p-4 text-center", + ); + } + + private renderUnitTypeOptions(disabledUnits: UnitType[]): TemplateResult[] { + return unitOptions.map(({ type, translationKey }) => { + const isEnabled = !disabledUnits.includes(type); + return html` + + `; + }); + } + + render() { + if (!this.settings) return nothing; + const settings = this.settings; + + return html` +
+ ${renderSection( + MAP_ICON, + "text-blue-400", + "bg-blue-500/20", + "map.map", + html``, + )} + ${renderSection( + DIFFICULTY_ICON, + "text-green-400", + "bg-green-500/20", + "difficulty.difficulty", + html` +
+ ${DIFFICULTY_OPTIONS.map(([key, value]) => { + const isSelected = settings.difficulty.selected === value; + const isDisabled = settings.difficulty.disabled; + return html` + + `; + })} +
+ `, + )} + ${renderSection( + MODE_ICON, + "text-purple-400", + "bg-purple-500/20", + "host_modal.mode", + html` +
+ ${[GameMode.FFA, GameMode.Team].map((mode) => { + const isSelected = settings.gameMode.selected === mode; + return html` + + `; + })} +
+ `, + )} + ${settings.gameMode.selected === GameMode.FFA + ? nothing + : html` +
+
+ ${translateText("host_modal.team_count")} +
+
+ ${TEAM_COUNT_OPTIONS.map((o) => { + const isSelected = settings.teamCount.selected === o; + return html` + + `; + })} +
+
+ `} + ${renderSection( + OPTIONS_ICON, + "text-orange-400", + "bg-orange-500/20", + settings.options.titleKey, + html` +
+
+ +
+ + ${settings.options.toggles.map((toggle) => + this.renderOptionToggle(toggle), + )} + ${settings.options.inputCards} +
+ `, + )} + ${renderSection( + ENABLES_ICON, + "text-teal-400", + "bg-teal-500/20", + settings.unitTypes.titleKey, + html` +
+ ${this.renderUnitTypeOptions(settings.unitTypes.disabledUnits)} +
+ `, + "space-y-6 pb-6", + )} +
+ `; + } +} diff --git a/src/client/components/ToggleInputCard.ts b/src/client/components/ToggleInputCard.ts new file mode 100644 index 000000000..279366aff --- /dev/null +++ b/src/client/components/ToggleInputCard.ts @@ -0,0 +1,173 @@ +import { LitElement, PropertyValues, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateText } from "../Utils"; + +const ACTIVE_CARD = + "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"; +const INACTIVE_CARD = + "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"; +const INPUT_CLASS = + "w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1"; +const CARD_LABEL_CLASS = + "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto"; + +function cardClass(active: boolean, extra = ""): string { + return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`; +} + +@customElement("toggle-input-card") +export class ToggleInputCard extends LitElement { + @property({ attribute: false }) labelKey = ""; + @property({ type: Boolean, attribute: false }) checked = false; + @property({ attribute: false }) inputId?: string; + @property({ attribute: false }) inputType = "number"; + @property({ attribute: false }) inputMin?: number | string; + @property({ attribute: false }) inputMax?: number | string; + @property({ attribute: false }) inputStep?: number | string; + @property({ attribute: false }) inputValue?: number | string; + @property({ attribute: false }) inputAriaLabel?: string; + @property({ attribute: false }) inputPlaceholder?: string; + @property({ attribute: false }) defaultInputValue?: number | string; + @property({ attribute: false }) minValidOnEnable?: number; + @property({ attribute: false }) onToggle?: ( + checked: boolean, + value: number | string | undefined, + ) => void; + @property({ attribute: false }) onInput?: (e: Event) => void; + @property({ attribute: false }) onChange?: (e: Event) => void; + @property({ attribute: false }) onKeyDown?: (e: KeyboardEvent) => void; + + createRenderRoot() { + return this; + } + + protected updated(changedProperties: PropertyValues) { + if (!changedProperties.has("checked")) return; + const previousChecked = changedProperties.get("checked"); + if (previousChecked === false && this.checked) { + const input = this.querySelector("input"); + if (input) { + input.focus(); + input.select(); + } + } + } + + private toOptionalNumber( + value: number | string | undefined, + ): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const numeric = Number(trimmed); + return Number.isFinite(numeric) ? numeric : undefined; + } + return undefined; + } + + private resolveValueOnEnable(): number | string | undefined { + const currentValue = this.inputValue; + + if ( + currentValue === undefined || + currentValue === null || + currentValue === "" + ) { + return this.defaultInputValue; + } + + if (this.minValidOnEnable === undefined) { + return currentValue; + } + + const numericValue = this.toOptionalNumber(currentValue); + if (numericValue === undefined || numericValue < this.minValidOnEnable) { + return this.defaultInputValue; + } + + return numericValue; + } + + private emitToggle() { + const nextChecked = !this.checked; + const nextValue = nextChecked ? this.resolveValueOnEnable() : undefined; + this.onToggle?.(nextChecked, nextValue); + } + + private handleCardClick = () => { + this.emitToggle(); + }; + + render() { + return html` +
+ + + ${this.checked + ? html` +
+ +
+ ` + : nothing} +
+ `; + } +} diff --git a/src/client/components/map/MapDisplay.ts b/src/client/components/map/MapDisplay.ts index d843a9999..61241eff2 100644 --- a/src/client/components/map/MapDisplay.ts +++ b/src/client/components/map/MapDisplay.ts @@ -53,6 +53,10 @@ export class MapDisplay extends LitElement { } } + private preventImageDrag(event: DragEvent) { + event.preventDefault(); + } + render() { return html`
${this.isLoading ? html`
{ return this.mapWins?.get(mapValue) ?? new Set(); } @@ -54,7 +58,7 @@ export class MapPicker extends LitElement { return html`
this.handleMapSelection(mapValue)} - class="cursor-pointer transition-transform duration-200 active:scale-95" + class="cursor-pointer" >
diff --git a/src/client/utilities/GameConfigHelpers.ts b/src/client/utilities/GameConfigHelpers.ts new file mode 100644 index 000000000..a149dee88 --- /dev/null +++ b/src/client/utilities/GameConfigHelpers.ts @@ -0,0 +1,93 @@ +import { GameMapType, UnitType } from "../../core/game/Game"; + +export function toOptionalNumber( + value: number | string | undefined, +): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const numeric = Number(trimmed); + return Number.isFinite(numeric) ? numeric : undefined; + } + return undefined; +} + +export function preventDisallowedKeys( + e: KeyboardEvent, + disallowedKeys: string[], +): void { + if (disallowedKeys.includes(e.key)) { + e.preventDefault(); + } +} + +export function parseBoundedIntegerFromInput( + input: HTMLInputElement, + { + min, + max, + stripPattern = /[eE+-]/g, + radix = 10, + }: { + min: number; + max: number; + stripPattern?: RegExp; + radix?: number; + }, +): number | undefined { + input.value = input.value.replace(stripPattern, ""); + const value = parseInt(input.value, radix); + + if (isNaN(value) || value < min || value > max) { + return undefined; + } + + return value; +} + +export function parseBoundedFloatFromInput( + input: HTMLInputElement, + { min, max }: { min: number; max: number }, +): number | undefined { + const value = parseFloat(input.value); + + if (isNaN(value) || value < min || value > max) { + return undefined; + } + + return value; +} + +export function getBotsForCompactMap( + bots: number, + compactMapEnabled: boolean, +): number { + if (compactMapEnabled && bots === 400) { + return 100; + } + + if (!compactMapEnabled && bots === 100) { + return 400; + } + + return bots; +} + +export function getRandomMapType(): GameMapType { + const maps = Object.values(GameMapType); + const randIdx = Math.floor(Math.random() * maps.length); + return maps[randIdx] as GameMapType; +} + +export function getUpdatedDisabledUnits( + disabledUnits: UnitType[], + unit: UnitType, + checked: boolean, +): UnitType[] { + return checked + ? [...disabledUnits, unit] + : disabledUnits.filter((u) => u !== unit); +} diff --git a/src/client/utilities/RenderToggleInputCard.ts b/src/client/utilities/RenderToggleInputCard.ts deleted file mode 100644 index 44fbd437e..000000000 --- a/src/client/utilities/RenderToggleInputCard.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { TemplateResult, html, nothing } from "lit"; -import { translateText } from "../Utils"; - -export const TOGGLE_INPUT_CARD_CLASSES = { - containerActive: - "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]", - containerInactive: - "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80", - labelBase: - "text-[10px] uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto", - labelActive: "text-white", - labelInactive: "text-white/60", - input: - "w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1", -}; - -export interface ToggleInputCardInputOptions { - id?: string; - type?: string; - min?: number | string; - max?: number | string; - step?: number | string; - value?: number | string; - ariaLabel?: string; - placeholder?: string; - onInput?: (e: Event) => void; - onChange?: (e: Event) => void; - onKeyDown?: (e: KeyboardEvent) => void; - onClick?: (e: Event) => void; - className?: string; -} - -export function renderToggleInputCardInput({ - id, - type = "number", - min, - max, - step, - value, - ariaLabel, - placeholder, - onInput, - onChange, - onKeyDown, - onClick, - className = TOGGLE_INPUT_CARD_CLASSES.input, -}: ToggleInputCardInputOptions): TemplateResult { - const resolvedValue = value ?? ""; - const handleClick = onClick ?? ((e: Event) => e.stopPropagation()); - - return html` - - `; -} - -export interface ToggleInputCardRenderContext { - labelKey: string; - checked: boolean; - input?: TemplateResult; - onClick?: (e: Event) => void; - onKeyDown?: (e: KeyboardEvent) => void; - activeClassName?: string; - inactiveClassName?: string; - labelBaseClassName?: string; - labelActiveClassName?: string; - labelInactiveClassName?: string; - role?: string; - tabIndex?: number; -} - -export function renderToggleInputCard({ - labelKey, - checked, - input, - onClick, - onKeyDown, - activeClassName = TOGGLE_INPUT_CARD_CLASSES.containerActive, - inactiveClassName = TOGGLE_INPUT_CARD_CLASSES.containerInactive, - labelBaseClassName = TOGGLE_INPUT_CARD_CLASSES.labelBase, - labelActiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelActive, - labelInactiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelInactive, - role, - tabIndex, -}: ToggleInputCardRenderContext): TemplateResult { - const shouldBehaveLikeButton = Boolean(onClick ?? onKeyDown); - const resolvedRole = role ?? (shouldBehaveLikeButton ? "button" : undefined); - const resolvedTabIndex = tabIndex ?? (shouldBehaveLikeButton ? 0 : undefined); - const resolvedOnKeyDown = - onKeyDown ?? - (onClick - ? (e: KeyboardEvent) => { - if ((e.target as HTMLElement).tagName.toLowerCase() === "input") { - return; - } - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(e); - } - } - : undefined); - - return html` -
-
-
- ${checked - ? html` - - ` - : ""} -
-
- - ${checked - ? (input ?? html``) - : html`
`} - -
- ${translateText(labelKey)} -
-
- `; -} diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts deleted file mode 100644 index c04dea7f4..000000000 --- a/src/client/utilities/RenderUnitTypeOptions.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { html, TemplateResult } from "lit"; -import { UnitType } from "../../core/game/Game"; -import { translateText } from "../Utils"; - -export interface UnitTypeRenderContext { - disabledUnits: UnitType[]; - toggleUnit: (unit: UnitType, checked: boolean) => void; -} - -const unitOptions: { type: UnitType; translationKey: string }[] = [ - { type: UnitType.City, translationKey: "unit_type.city" }, - { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" }, - { type: UnitType.Port, translationKey: "unit_type.port" }, - { type: UnitType.Warship, translationKey: "unit_type.warship" }, - { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" }, - { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" }, - { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" }, - { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" }, - { type: UnitType.MIRV, translationKey: "unit_type.mirv" }, - { type: UnitType.Factory, translationKey: "unit_type.factory" }, -]; - -export function renderUnitTypeOptions({ - disabledUnits, - toggleUnit, -}: UnitTypeRenderContext): TemplateResult[] { - return unitOptions.map(({ type, translationKey }) => { - const isEnabled = !disabledUnits.includes(type); - return html` - - `; - }); -} From 6cc0ef7d148c2189442da77c7f53cc635b9595e0 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:57:18 +0100 Subject: [PATCH 4/5] =?UTF-8?q?Add=20PVP=20immunity=20to=205M=20starting?= =?UTF-8?q?=20gold=20modifier=20games=20=F0=9F=94=A7=20(#3180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Adds 30 seconds of PVP immunity to 5M starting gold modifier games. So you cannot insta-nuke other players. Because I'm sure people would be confused "I cannot attack!!!!" I added a HeadsUpMessage which informs about the PVP immunity. We already have a ImmunityTimer progress bar but I don't think its enough. image I had a second count in the HeadsUpMessage (seconds until PVP immunity is over) but it felt too busy. So I removed it. You can tell when PVP immunity is over by looking at the progress bar. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --------- Co-authored-by: Evan --- resources/lang/en.json | 3 ++- src/client/graphics/layers/HeadsUpMessage.ts | 21 +++++++++++++++++++- src/client/graphics/layers/ImmunityTimer.ts | 5 ++++- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 8 +++++++- src/core/game/GameImpl.ts | 2 +- src/core/game/GameView.ts | 6 ++++++ src/server/MapPlaylist.ts | 2 +- tests/Attack.test.ts | 6 +++--- 9 files changed, 45 insertions(+), 9 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index a29df60a2..546323c80 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -845,7 +845,8 @@ "choose_spawn": "Choose a starting location", "random_spawn": "Random spawn is enabled. Selecting starting location for you...", "singleplayer_game_paused": "Game paused", - "multiplayer_game_paused": "Game paused by Lobby Creator" + "multiplayer_game_paused": "Game paused by Lobby Creator", + "pvp_immunity_active": "PVP immunity active for {seconds}s" }, "territory_patterns": { "title": "Skins", diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 0239e2f15..a0b0937ce 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -16,6 +16,9 @@ export class HeadsUpMessage extends LitElement implements Layer { @state() private isPaused = false; + @state() + private isImmunityActive = false; + @state() private toastMessage: string | import("lit").TemplateResult | null = null; @state() @@ -79,7 +82,18 @@ export class HeadsUpMessage extends LitElement implements Layer { this.isPaused = pauseUpdate.paused; } - this.isVisible = this.game.inSpawnPhase() || this.isPaused; + const showImmunityHudDuration = 10 * 10; + const spawnEnd = this.game.config().numSpawnPhaseTurns(); + const ticksSinceSpawnEnd = this.game.ticks() - spawnEnd; + + this.isImmunityActive = + this.game.config().hasExtendedSpawnImmunity() && + !this.game.inSpawnPhase() && + this.game.isSpawnImmunityActive() && + ticksSinceSpawnEnd < showImmunityHudDuration; + + this.isVisible = + this.game.inSpawnPhase() || this.isPaused || this.isImmunityActive; this.requestUpdate(); } @@ -91,6 +105,11 @@ export class HeadsUpMessage extends LitElement implements Layer { return translateText("heads_up_message.multiplayer_game_paused"); } } + if (this.isImmunityActive) { + return translateText("heads_up_message.pvp_immunity_active", { + seconds: Math.round(this.game.config().spawnImmunityDuration() / 10), + }); + } return this.game.config().isRandomSpawn() ? translateText("heads_up_message.random_spawn") : translateText("heads_up_message.choose_spawn"); diff --git a/src/client/graphics/layers/ImmunityTimer.ts b/src/client/graphics/layers/ImmunityTimer.ts index 29f66fbac..cfb44ae2b 100644 --- a/src/client/graphics/layers/ImmunityTimer.ts +++ b/src/client/graphics/layers/ImmunityTimer.ts @@ -41,7 +41,10 @@ export class ImmunityTimer extends LitElement implements Layer { const immunityDuration = this.game.config().spawnImmunityDuration(); const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); - if (immunityDuration <= 5 * 10 || this.game.inSpawnPhase()) { + if ( + !this.game.config().hasExtendedSpawnImmunity() || + this.game.inSpawnPhase() + ) { this.setInactive(); return; } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index ac1d9ee4a..8595b3a21 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -58,6 +58,7 @@ export interface NukeMagnitude { export interface Config { spawnImmunityDuration(): Tick; + hasExtendedSpawnImmunity(): boolean; serverConfig(): ServerConfig; gameConfig(): GameConfig; theme(): Theme; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 3cb2e1af8..5a672f296 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -28,6 +28,7 @@ import { PastelThemeDark } from "./PastelThemeDark"; const DEFENSE_DEBUFF_MIDPOINT = 150_000; const DEFENSE_DEBUFF_DECAY_RATE = Math.LN2 / 50000; +const DEFAULT_SPAWN_IMMUNITY_TICKS = 5 * 10; const JwksSchema = z.object({ keys: z @@ -163,7 +164,12 @@ export class DefaultConfig implements Config { return 30 * 10; // 30 seconds } spawnImmunityDuration(): Tick { - return this._gameConfig.spawnImmunityDuration ?? 5 * 10; // default to 5 seconds + return ( + this._gameConfig.spawnImmunityDuration ?? DEFAULT_SPAWN_IMMUNITY_TICKS + ); + } + hasExtendedSpawnImmunity(): boolean { + return this.spawnImmunityDuration() > DEFAULT_SPAWN_IMMUNITY_TICKS; } gameConfig(): GameConfig { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 6dd76beff..cab7e6061 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -722,7 +722,7 @@ export class GameImpl implements Game { public isSpawnImmunityActive(): boolean { return ( this.config().numSpawnPhaseTurns() + - this.config().spawnImmunityDuration() >= + this.config().spawnImmunityDuration() > this.ticks() ); } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 65a5b74d8..d66b9361b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -813,6 +813,12 @@ export class GameView implements GameMap { inSpawnPhase(): boolean { return this.ticks() <= this._config.numSpawnPhaseTurns(); } + isSpawnImmunityActive(): boolean { + return ( + this._config.numSpawnPhaseTurns() + this._config.spawnImmunityDuration() > + this.ticks() + ); + } config(): Config { return this._config; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index ed80175c1..0abbf4ed2 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -160,7 +160,7 @@ export class MapPlaylist { gameMode: mode, playerTeams, bots: isCompact ? 100 : 400, - spawnImmunityDuration: 5 * 10, + spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10, disabledUnits: [], } satisfies GameConfig; } diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index e2bf619dc..8659a809d 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -391,17 +391,17 @@ describe("Attack immunity", () => { test("Ensure a player can't attack during all the immunity phase", async () => { // Execute a few ticks but stop right before the immunity phase is over - for (let i = 0; i < immunityPhaseTicks - 1; i++) { + for (let i = 0; i < immunityPhaseTicks - 2; i++) { game.executeNextTick(); } // Player A attacks Player B game.addExecution(new AttackExecution(null, playerA, playerB.id(), null)); - game.executeNextTick(); // ticks === immunityPhaseTicks here + game.executeNextTick(); // ticks === immunityPhaseTicks - 1 here // Attack is not possible during immunity expect(playerA.outgoingAttacks()).toHaveLength(0); // Retry after the immunity is over - game.executeNextTick(); // ticks === immunityPhaseTicks + 1 + game.executeNextTick(); // ticks === immunityPhaseTicks game.addExecution(new AttackExecution(null, playerA, playerB.id(), null)); game.executeNextTick(); // Attack is now possible right after From 07e13b3479fef50873b8cbb3a4aea3c7229819e7 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:01:08 +0100 Subject: [PATCH 5/5] Fix: remove alliances on death (#3168) ## Description: - Remove alliances on death: after death, alliances would stay active including countdown timers and (when dead player kept spectating) icons. Now remove them when player becomes inActive. - Moved code to private method within PlayerExecution + added comments in NationExecution and BotExecution for more clarity as to where removals are performed from at death - Remove renewal request from Events Display when Alliance doesn't exist anymore (after death or otherwise). - Also cleanup this.alliancesCheckedAt when alliance doesn't exist anymore. Before, old/broken alliance id's would accumulate in it during a game. - Removed now-redundant isAlive check in EventsDisplay. Both the alliances array as the isAlive are updated in the same tick from PlayerUpdates so now alliance is removed from alliances array on player death, the other.isAlive() check is no longer needed. Of course we could keep it in just to be very safe, so just let me know when you're doubtful about this. - Attack.test.ts: fix failing test. Player B dies because of the attack, meaning the alliance now gets removed. Prevent this by gving both a different, adjecent, starting tile. And to be more clear about what is needed for the test to pass, add isAlive check for both of them after the attacks. ## 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: tryout33 --- src/client/graphics/layers/EventsDisplay.ts | 13 +++++++- src/core/execution/BotExecution.ts | 1 + src/core/execution/NationExecution.ts | 1 + src/core/execution/PlayerExecution.ts | 34 +++++++++++++-------- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 6 ++++ src/core/game/PlayerImpl.ts | 4 +++ tests/Attack.test.ts | 5 ++- 8 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index b39d3f8de..31f496c05 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -251,7 +251,11 @@ export class EventsDisplay extends LitElement implements Layer { const myPlayer = this.game.myPlayer(); if (!myPlayer?.isAlive()) return; + const currentAllianceIds = new Set(); + for (const alliance of myPlayer.alliances()) { + currentAllianceIds.add(alliance.id); + if ( alliance.expiresAt > this.game.ticks() + this.game.config().allianceExtensionPromptOffset() @@ -270,7 +274,6 @@ export class EventsDisplay extends LitElement implements Layer { this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); const other = this.game.player(alliance.other) as PlayerView; - if (!other.isAlive()) continue; this.addEvent({ description: translateText("events_display.about_to_expire", { @@ -305,6 +308,13 @@ export class EventsDisplay extends LitElement implements Layer { allianceID: alliance.id, }); } + + for (const [allianceId] of this.alliancesCheckedAt) { + if (!currentAllianceIds.has(allianceId)) { + this.removeAllianceRenewalEvents(allianceId); + this.alliancesCheckedAt.delete(allianceId); + } + } } private addEvent(event: GameEvent) { @@ -530,6 +540,7 @@ export class EventsDisplay extends LitElement implements Layer { if (!myPlayer) return; this.removeAllianceRenewalEvents(update.allianceID); + this.alliancesCheckedAt.delete(update.allianceID); this.requestUpdate(); const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView; diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 85fabb34a..491bf1b21 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -39,6 +39,7 @@ export class BotExecution implements Execution { if (ticks % this.attackRate !== this.attackTick) return; if (!this.bot.isAlive()) { + //removeOnDeath is called from bot's PlayerExecution this.active = false; return; } diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 466adcdaa..45abd3729 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -144,6 +144,7 @@ export class NationExecution implements Execution { } if (!this.player.isAlive()) { + //removeOnDeath is called from nation's PlayerExecution this.active = false; return; } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index e1d80d0b4..1e9ae4a10 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -60,19 +60,7 @@ export class PlayerExecution implements Execution { } if (!this.player.isAlive()) { - // Player has no tiles, delete any remaining units and gold - const gold = this.player.gold(); - this.player.removeGold(gold); - this.player.units().forEach((u) => { - if ( - u.type() !== UnitType.AtomBomb && - u.type() !== UnitType.HydrogenBomb && - u.type() !== UnitType.MIRVWarhead && - u.type() !== UnitType.MIRV - ) { - u.delete(); - } - }); + this.removeOnDeath(); this.active = false; this.mg.stats().playerKilled(this.player, ticks); return; @@ -400,4 +388,24 @@ export class PlayerExecution implements Execution { return result; } + + private removeOnDeath(): void { + // Player (bot, human, nation) has no tiles + // Delete any remaining gold, non-nuke units and alliances + const gold = this.player.gold(); + this.player.removeGold(gold); + + this.player.units().forEach((u) => { + if ( + u.type() !== UnitType.AtomBomb && + u.type() !== UnitType.HydrogenBomb && + u.type() !== UnitType.MIRVWarhead && + u.type() !== UnitType.MIRV + ) { + u.delete(); + } + }); + + this.player.removeAllAlliances(); + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 1057e7e6b..d0a1d3f97 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -663,6 +663,7 @@ export interface Player { allianceWith(other: Player): MutableAlliance | null; canSendAllianceRequest(other: Player): boolean; breakAlliance(alliance: Alliance): void; + removeAllAlliances(): void; createAllianceRequest(recipient: Player): AllianceRequest | null; betrayals(): number; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index cab7e6061..45d77878f 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -719,6 +719,12 @@ export class GameImpl implements Game { }); } + public removeAlliancesByPlayerSilently(player: Player): void { + this.alliances_ = this.alliances_.filter( + (a) => a.requestor() !== player && a.recipient() !== player, + ); + } + public isSpawnImmunityActive(): boolean { return ( this.config().numSpawnPhaseTurns() + diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index dd72a3307..d00948d44 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -450,6 +450,10 @@ export class PlayerImpl implements Player { this.mg.breakAlliance(this, alliance); } + removeAllAlliances(): void { + this.mg.removeAlliancesByPlayerSilently(this); + } + isTraitor(): boolean { return this.getTraitorRemainingTicks() > 0; } diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 8659a809d..ff875870d 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -183,7 +183,7 @@ describe("Attack race condition with alliance requests", () => { null, "playerB_id", ); - playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 10)); + playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11)); while (game.inSpawnPhase()) { game.executeNextTick(); @@ -224,6 +224,9 @@ describe("Attack race condition with alliance requests", () => { game.executeNextTick(); } + expect(playerA.isAlive()).toBe(true); + expect(playerB.isAlive()).toBe(true); + // Player A should not be marked as traitor because the alliance was formed after the attack started expect(playerA.isTraitor()).toBe(false);