From b3c59da29d5f729e42b673e15bb8223d70222d75 Mon Sep 17 00:00:00 2001 From: evan Date: Tue, 6 May 2025 19:44:18 -0700 Subject: [PATCH 01/35] validate persistent id on client reconnect, validate client message ClientID and intent ClientID match to prevent spoofing --- src/server/GameServer.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index e3e60f972..c655a1cf4 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -126,11 +126,17 @@ export class GameServer { (c) => c.clientID == client.clientID, ); if (existing != null) { + if (client.persistentID != existing.persistentID) { + console.warn( + `client ${client.clientID} cannot rejoin game, persistent id mismatch: exist pid: ${existing.persistentID}, new pid: ${client.persistentID}`, + ); + return; + } existing.ws.removeAllListeners("message"); + this.activeClients = this.activeClients.filter( + (c) => c.clientID != client.clientID, + ); } - this.activeClients = this.activeClients.filter( - (c) => c.clientID != client.clientID, - ); this.activeClients.push(client); client.lastPing = Date.now(); @@ -164,14 +170,20 @@ export class GameServer { clientMsg.persistentID = null; if (clientMsg.type == "intent") { - if (clientMsg.gameID == this.id) { - this.addIntent(clientMsg.intent); - } else { + if (clientMsg.gameID != this.id) { this.log.warn("client sent to wrong game", { clientID: clientMsg.clientID, persistentID: clientMsg.persistentID, }); + return; } + if (clientMsg.intent.clientID != clientMsg.clientID) { + this.log.warn( + `client id mismatch, client message: ${clientMsg.clientID}, intent client id ${clientMsg.intent.clientID}`, + ); + return; + } + this.addIntent(clientMsg.intent); } if (clientMsg.type == "ping") { this.lastPingUpdate = Date.now(); From cddcc681dde4809734699e85a06d5ff90845c9a4 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Wed, 7 May 2025 22:14:54 +0900 Subject: [PATCH 02/35] Added custom disable settings (#593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: I will write an issue later as I don't have time. ![スクリーンショット 2025-04-23 22 04 24](https://github.com/user-attachments/assets/77754140-eee9-46bd-a98f-a3abec35ca6a) スクリーンショット 2025-04-23 22 04 40 スクリーンショット 2025-04-23 22 04 47 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --- resources/lang/en.json | 14 +++- resources/lang/ja.json | 15 ++++- src/client/HostLobbyModal.ts | 81 ++++++++++++++++++++---- src/client/SinglePlayerModal.ts | 73 ++++++++++++++++----- src/client/graphics/layers/BuildMenu.ts | 18 +----- src/core/Schemas.ts | 3 +- src/core/configuration/Config.ts | 2 +- src/core/configuration/DefaultConfig.ts | 5 +- src/core/execution/FakeHumanExecution.ts | 4 +- src/core/game/PlayerImpl.ts | 21 +++--- src/server/GameManager.ts | 2 +- src/server/GameServer.ts | 8 ++- src/server/Worker.ts | 2 +- 13 files changed, 177 insertions(+), 71 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 62a7153d7..871b516bd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -101,6 +101,7 @@ "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", "disable_nukes": "Disable Nukes", + "enables_title": "Enable Settings", "start": "Start Game" }, "map": { @@ -167,7 +168,7 @@ "instant_build": "Instant build", "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", - "disable_nukes": "Disable Nukes", + "enables_title": "Enable Settings", "player": "Player", "players": "Players", "waiting": "Waiting for players...", @@ -191,6 +192,17 @@ "select_lang": { "title": "Select Language" }, + "unit_type": { + "city": "City", + "defense_post": "Defense Post", + "port": "Port", + "warship": "Warship", + "missile_silo": "Missile Silo", + "sam_launcher": "SAM Launcher", + "atom_bomb": "Atom Bomb", + "hydrogen_bomb": "Hydrogen Bomb", + "mirv": "MIRV" + }, "user_setting": { "title": "User Settings", "tab_basic": "Basic Settings", diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 98e89edbd..06eb8d9c3 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -97,7 +97,7 @@ "instant_build": "即時建設", "infinite_gold": "資金無限", "infinite_troops": "兵士無限", - "disable_nukes": "核兵器使用禁止", + "enables_title": "有効化設定", "start": "ゲーム開始" }, "map": { @@ -158,7 +158,7 @@ "instant_build": "実在する国家を無効化", "infinite_gold": "資金無限", "infinite_troops": "兵士無限", - "disable_nukes": "核兵器使用禁止", + "enables_title": "有効化設定", "player": "プレイヤー", "players": "プレイヤー", "waiting": "他のプレイヤーの参加を待っています...", @@ -182,6 +182,17 @@ "select_lang": { "title": "言語を選択" }, + "unit_type": { + "city": "都市", + "defense_post": "防衛ポスト", + "port": "港", + "warship": "戦艦", + "missile_silo": "ミサイル格納庫", + "sam_launcher": "SAMランチャー", + "atom_bomb": "原子爆弾", + "hydrogen_bomb": "水素爆弾", + "mirv": "MIRV" + }, "user_setting": { "title": "ユーザー設定", "tab_basic": "基本設定", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 86b6197b5..0f86f05b8 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -9,6 +9,7 @@ import { Duos, GameMapType, GameMode, + UnitType, mapCategories, } from "../core/game/Game"; import { GameConfig, GameInfo } from "../core/Schemas"; @@ -39,6 +40,7 @@ export class HostLobbyModal extends LitElement { @state() private copySuccess = false; @state() private players: string[] = []; @state() private useRandomMap: boolean = false; + @state() private disabledUnits: string[] = []; private playersInterval = null; // Add a new timer for debouncing bot changes @@ -302,21 +304,72 @@ export class HostLobbyModal extends LitElement { - + - +
+
+ ${translateText("single_modal.enables_title")} +
+
+ ${[ + [UnitType.City, "unit_type.city"], + [UnitType.DefensePost, "unit_type.defense_post"], + [UnitType.Port, "unit_type.port"], + [UnitType.Warship, "unit_type.warship"], + [UnitType.MissileSilo, "unit_type.missile_silo"], + [UnitType.SAMLauncher, "unit_type.sam_launcher"], + [UnitType.AtomBomb, "unit_type.atom_bomb"], + [UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"], + [UnitType.MIRV, "unit_type.mirv"], + ].map( + ([unitType, translationKey]) => html` + + `, + )}
@@ -419,6 +461,7 @@ export class SinglePlayerModal extends LitElement { infiniteGold: this.infiniteGold, infiniteTroops: this.infiniteTroops, instantBuild: this.instantBuild, + disabledUnits: this.disabledUnits, }, }, } as JoinLobbyEvent, diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index ed3a477d8..a4105c46f 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -409,21 +409,9 @@ export class BuildMenu extends LitElement implements Layer { } private getBuildableUnits(): BuildItemDisplay[][] { - if (this.game?.config()?.disableNukes()) { - return buildTable.map((row) => - row.filter( - (item) => - ![ - UnitType.AtomBomb, - UnitType.MIRV, - UnitType.HydrogenBomb, - UnitType.MissileSilo, - UnitType.SAMLauncher, - ].includes(item.unitType), - ), - ); - } - return buildTable; + return buildTable.map((row) => + row.filter((item) => !this.game?.config()?.isUnitDisabled(item.unitType)), + ); } get isVisible() { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index b08fcfcfb..fba00a877 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -116,12 +116,13 @@ const GameConfigSchema = z.object({ gameType: z.nativeEnum(GameType), gameMode: z.nativeEnum(GameMode), disableNPCs: z.boolean(), - disableNukes: z.boolean(), bots: z.number().int().min(0).max(400), infiniteGold: z.boolean(), infiniteTroops: z.boolean(), instantBuild: z.boolean(), maxPlayers: z.number().optional(), + numPlayerTeams: z.number().optional(), + disabledUnits: z.array(z.nativeEnum(UnitType)).optional(), playerTeams: z.union([z.number().optional(), z.literal(Duos)]), }); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 1442dd814..9f55f8b52 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -66,7 +66,7 @@ export interface Config { percentageTilesOwnedToWin(): number; numBots(): number; spawnNPCs(): boolean; - disableNukes(): boolean; + isUnitDisabled(unitType: UnitType): boolean; bots(): number; infiniteGold(): boolean; infiniteTroops(): boolean; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index dfbca1d77..660820fd8 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -231,9 +231,10 @@ export class DefaultConfig implements Config { return !this._gameConfig.disableNPCs; } - disableNukes(): boolean { - return this._gameConfig.disableNukes; + isUnitDisabled(unitType: UnitType): boolean { + return this._gameConfig.disabledUnits?.includes(unitType) ?? false; } + bots(): number { return this._gameConfig.bots; } diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 9870fe662..358164931 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -421,9 +421,7 @@ export class FakeHumanExecution implements Execution { if (this.maybeSpawnWarship()) { return; } - if (!this.mg.config().disableNukes()) { - this.maybeSpawnStructure(UnitType.MissileSilo, 1); - } + this.maybeSpawnStructure(UnitType.MissileSilo, 1); } private maybeSpawnStructure(type: UnitType, maxNum: number) { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index b2046cd40..55972adc5 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -709,6 +709,12 @@ export class PlayerImpl implements Player { spawnTile: TileRef, unitSpecificInfos: UnitSpecificInfos = {}, ): UnitImpl { + if (this.mg.config().isUnitDisabled(type)) { + throw new Error( + `Attempted to build disabled unit ${type} at tile ${spawnTile} by player ${this.name()}`, + ); + } + const cost = this.mg.unitInfo(type).cost(this); const b = new UnitImpl( type, @@ -746,19 +752,8 @@ export class PlayerImpl implements Player { targetTile: TileRef, validTiles: TileRef[] | null = null, ): TileRef | false { - // prevent the building of nukes and nuke related buildings - if (this.mg.config().disableNukes()) { - if ( - unitType === UnitType.MissileSilo || - unitType === UnitType.MIRV || - unitType === UnitType.AtomBomb || - unitType === UnitType.HydrogenBomb || - unitType === UnitType.SAMLauncher || - unitType === UnitType.SAMMissile || - unitType === UnitType.MIRVWarhead - ) { - return false; - } + if (this.mg.config().isUnitDisabled(unitType)) { + return false; } const cost = this.mg.unitInfo(unitType).cost(this); diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 2a4e196c5..c2a890e9d 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -34,12 +34,12 @@ export class GameManager { gameType: GameType.Private, difficulty: Difficulty.Medium, disableNPCs: false, - disableNukes: false, infiniteGold: false, infiniteTroops: false, instantBuild: false, gameMode: GameMode.FFA, bots: 400, + disabledUnits: [], ...gameConfig, }); this.games.set(id, game); diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index c655a1cf4..df95296f9 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -77,9 +77,6 @@ export class GameServer { if (gameConfig.disableNPCs != null) { this.gameConfig.disableNPCs = gameConfig.disableNPCs; } - if (gameConfig.disableNukes != null) { - this.gameConfig.disableNukes = gameConfig.disableNukes; - } if (gameConfig.bots != null) { this.gameConfig.bots = gameConfig.bots; } @@ -95,6 +92,11 @@ export class GameServer { if (gameConfig.gameMode != null) { this.gameConfig.gameMode = gameConfig.gameMode; } + + if (gameConfig.disabledUnits != null) { + this.gameConfig.disabledUnits = gameConfig.disabledUnits; + } + if (gameConfig.playerTeams != null) { this.gameConfig.playerTeams = gameConfig.playerTeams; } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 3307d5629..880dca263 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -162,7 +162,7 @@ export function startWorker() { instantBuild: req.body.instantBuild, bots: req.body.bots, disableNPCs: req.body.disableNPCs, - disableNukes: req.body.disableNukes, + disabledUnits: req.body.disabledUnits, gameMode: req.body.gameMode, playerTeams: req.body.playerTeams, }); From e6ec9382f7bf18ac240f60b2d3ad97c71b55c3c9 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 7 May 2025 09:15:51 -0400 Subject: [PATCH 03/35] Update deployment script to wait for deployment to come online (#667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: - Wait up to five minutes for the deployment to come online - Re-add the `Notify PR 🚀` step, disabled on the main repo for now ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- .github/workflows/deploy.yml | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b72f2431c..3eb00042d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -39,7 +39,7 @@ jobs: # Don't deploy on push if this is a fork if: ${{ github.event_name == 'workflow_dispatch' || github.repository == 'openfrontio/OpenFrontIO' }} # Use different logic based on event type - name: Deploy to ${{ + name: ${{ github.event_name == 'push' && (github.ref_name == 'main' && 'openfront.dev' || format('{0}.openfront.dev', github.ref_name)) @@ -116,6 +116,37 @@ jobs: echo "::group::deploy.sh" ./deploy.sh "$ENV" "$HOST" "$SUBDOMAIN" echo "::endgroup::" + - name: Wait for deployment to start + run: | + echo "::group::Wait for deployment to start" + set -euxo pipefail + while [ "$(curl -s https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do + if [ "$SECONDS" -ge 300 ]; then + echo "Timeout: deployment did not start within 5 minutes" + exit 1 + fi + sleep 10 + done + echo "::endgroup::" + - name: Notify PR 🚀 + if: ${{ success() && github.event_name == 'push' + && github.repository != 'openfrontio/OpenFrontIO' + }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euxo pipefail + if [ -z "$GH_TOKEN" ]; then + echo "No GH_TOKEN found, skipping" + exit 0; + fi + echo "Checking for open PR from $GITHUB_HEAD_REF..." + pr_url=$(gh pr list --head "$GITHUB_HEAD_REF" --state open --json url -q '.[0].url') + if [ -z "$pr_url" ]; then + echo "No open PR found for branch $GITHUB_HEAD_REF" + exit 0; + fi + gh pr comment "$pr_url" --body "🚀 Deployed ${GITHUB_SHA} to [$FQDN](https://$FQDN)." - name: Update deployment status ✅ if: success() run: | From 77409096c06b9f35eb1f96d1d94d7e5587295ebf Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Wed, 7 May 2025 17:53:36 +0200 Subject: [PATCH 04/35] smoothed ship spawn rate function (#553) ## Description: Fixes #552 ## Please complete the following: - [ X] I have added screenshots for all UI updates - [ X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [X ] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: Discord Name: 1brucben --- src/core/configuration/DefaultConfig.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 660820fd8..e8c3e9881 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -251,12 +251,7 @@ export class DefaultConfig implements Config { return 10000 + 150 * Math.pow(dist, 1.1); } tradeShipSpawnRate(numberOfPorts: number): number { - if (numberOfPorts <= 3) return 18; - if (numberOfPorts <= 5) return 25; - if (numberOfPorts <= 8) return 35; - if (numberOfPorts <= 10) return 40; - if (numberOfPorts <= 12) return 45; - return 50; + return Math.round(10 * Math.pow(numberOfPorts, 0.6)); } unitInfo(type: UnitType): UnitInfo { From a62bbbc6b16f9c528a72bc761a96f126337b5145 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Thu, 8 May 2025 00:55:01 +0900 Subject: [PATCH 05/35] Add anonymous name toggle to user settings (#661) ## Description: Added a new toggle to user settings I deleted some leftover files that were no longer in use in order to eliminate errors. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --- resources/lang/en.json | 2 ++ resources/lang/ja.json | 2 ++ src/client/Main.ts | 10 ---------- src/client/RandomNameButton.ts | 30 ------------------------------ src/client/UserSettingModal.ts | 18 ++++++++++++++++++ 5 files changed, 22 insertions(+), 40 deletions(-) delete mode 100644 src/client/RandomNameButton.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 871b516bd..10a2fcf42 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -211,6 +211,8 @@ "dark_mode_desc": "Toggle the site’s appearance between light and dark themes", "emojis_label": "😊 Emojis", "emojis_desc": "Toggle whether emojis are shown in game", + "anonymous_names_label": "🥷 Hidden Names", + "anonymous_names_desc": "Hide real player names with random ones on your screen.", "left_click_label": "🖱️ Left Click to Open Menu", "left_click_desc": "When ON, left-click opens menu and sword button attacks. When OFF, left-click attacks directly.", "attack_ratio_label": "⚔️ Attack Ratio", diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 06eb8d9c3..a0dc8051b 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -201,6 +201,8 @@ "dark_mode_desc": "ダークモードを切り替えます。", "emojis_label": "😊 絵文字", "emojis_desc": "ゲーム内での絵文字を表示します。", + "anonymous_names_label": "🥷 表示名を隠す", + "anonymous_names_desc": "実際のプレイヤー名を隠し、自分の画面ではランダムな名前で表示します。", "left_click_label": "🖱️ 左クリックでメニューを開く", "left_click_desc": "オンにすると左クリックでメニューを開き、剣ボタンで攻撃します。オフにすると右クリックで直接攻撃します。", "attack_ratio_label": "⚔️ 攻撃比率", diff --git a/src/client/Main.ts b/src/client/Main.ts index eb6bb3631..31a2a9ab4 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -21,8 +21,6 @@ import { LangSelector } from "./LangSelector"; import { LanguageModal } from "./LanguageModal"; import "./PublicLobby"; import { PublicLobby } from "./PublicLobby"; -import "./RandomNameButton"; -import { RandomNameButton } from "./RandomNameButton"; import { SinglePlayerModal } from "./SinglePlayerModal"; import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; @@ -50,7 +48,6 @@ class Client { private usernameInput: UsernameInput | null = null; private flagInput: FlagInput | null = null; private darkModeButton: DarkModeButton | null = null; - private randomNameButton: RandomNameButton | null = null; private joinModal: JoinPrivateLobbyModal; private publicLobby: PublicLobby; @@ -85,13 +82,6 @@ class Client { consolex.warn("Dark mode button element not found"); } - this.randomNameButton = document.querySelector( - "random-name-button", - ) as RandomNameButton; - if (!this.randomNameButton) { - consolex.warn("Random name button element not found"); - } - const loginDiscordButton = document.getElementById( "login-discord", ) as OButton; diff --git a/src/client/RandomNameButton.ts b/src/client/RandomNameButton.ts deleted file mode 100644 index 4617c784f..000000000 --- a/src/client/RandomNameButton.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { LitElement, html } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import { UserSettings } from "../core/game/UserSettings"; - -@customElement("random-name-button") -export class RandomNameButton extends LitElement { - private userSettings: UserSettings = new UserSettings(); - @state() private randomName: boolean = this.userSettings.anonymousNames(); - - createRenderRoot() { - return this; - } - - toggleRandomName() { - this.userSettings.toggleRandomName(); - this.randomName = this.userSettings.anonymousNames(); - } - - render() { - return html` - - `; - } -} diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index eddba88b7..45d911905 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -102,6 +102,15 @@ export class UserSettingModal extends LitElement { console.log("🤡 Emojis:", enabled ? "ON" : "OFF"); } + private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) { + const enabled = e.detail?.checked; + if (typeof enabled !== "boolean") return; + + this.userSettings.set("settings.anonymousNames", enabled); + + console.log("🙈 Anonymous Names:", enabled ? "ON" : "OFF"); + } + private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) { const enabled = e.detail?.checked; if (typeof enabled !== "boolean") return; @@ -226,6 +235,15 @@ export class UserSettingModal extends LitElement { @change=${this.toggleLeftClickOpensMenu} > + + + Date: Wed, 7 May 2025 14:11:18 -0400 Subject: [PATCH 06/35] Remove clientID, persistentID, gameID from ClientBaseMessageSchema, TurnSchema (#666) ## Description: Remove the devils `clientID`, `persistentID`, and `gameID` from `ClientBaseMessageSchema`, as well as `gameID` from `TurnSchema`. These values are already known through the `Client` object that is hoisted into the relevant handler. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/client/ClientGameRunner.ts | 1 - src/client/LocalServer.ts | 1 - src/core/Schemas.ts | 4 ---- src/server/GameServer.ts | 41 +++++++++------------------------- 4 files changed, 10 insertions(+), 37 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 1b8aefe22..941e07a46 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -275,7 +275,6 @@ export class ClientGameRunner { while (turn.turnNumber - 1 > this.turnsSeen) { this.worker.sendTurn({ turnNumber: this.turnsSeen, - gameID: turn.gameID, intents: [], }); this.turnsSeen++; diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 8248a1603..42a777129 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -127,7 +127,6 @@ export class LocalServer { } const pastTurn: Turn = { turnNumber: this.turns.length, - gameID: this.lobbyConfig.gameStartInfo.gameID, intents: this.intents, }; this.turns.push(pastTurn); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index fba00a877..61f3d1eea 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -290,7 +290,6 @@ const IntentSchema = z.union([ export const TurnSchema = z.object({ turnNumber: z.number(), - gameID: ID, intents: z.array(IntentSchema), // The hash of the game state at the end of the turn. hash: z.number().nullable().optional(), @@ -357,9 +356,6 @@ export const ServerMessageSchema = z.union([ const ClientBaseMessageSchema = z.object({ type: z.enum(["winner", "join", "intent", "ping", "log", "hash"]), - clientID: ID, - persistentID: SafeString.nullable(), // WARNING: persistent id is private. - gameID: ID, }); export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({ diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index df95296f9..49830cfef 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -128,10 +128,14 @@ export class GameServer { (c) => c.clientID == client.clientID, ); if (existing != null) { - if (client.persistentID != existing.persistentID) { - console.warn( - `client ${client.clientID} cannot rejoin game, persistent id mismatch: exist pid: ${existing.persistentID}, new pid: ${client.persistentID}`, - ); + if (client.persistentID !== existing.persistentID) { + this.log.error("persistent ids do not match", { + clientID: client.clientID, + clientIP: client.ip, + clientPersistentID: client.persistentID, + existingIP: existing.ip, + existingPersistentID: existing.persistentID, + }); return; } existing.ws.removeAllListeners("message"); @@ -154,34 +158,10 @@ export class GameServer { } catch (error) { throw Error(`error parsing schema for ${client.ip}`); } - if (this.allClients.has(clientMsg.clientID)) { - const client = this.allClients.get(clientMsg.clientID); - if (client.persistentID != clientMsg.persistentID) { - this.log.warn( - `Client ID ${clientMsg.clientID} sent incorrect id ${clientMsg.persistentID}, does not match persistent id ${client.persistentID}`, - { - clientID: clientMsg.clientID, - persistentID: clientMsg.persistentID, - }, - ); - return; - } - } - - // Clear out persistent id to make sure it doesn't get sent to other clients. - clientMsg.persistentID = null; - if (clientMsg.type == "intent") { - if (clientMsg.gameID != this.id) { - this.log.warn("client sent to wrong game", { - clientID: clientMsg.clientID, - persistentID: clientMsg.persistentID, - }); - return; - } - if (clientMsg.intent.clientID != clientMsg.clientID) { + if (clientMsg.intent.clientID != client.clientID) { this.log.warn( - `client id mismatch, client message: ${clientMsg.clientID}, intent client id ${clientMsg.intent.clientID}`, + `client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`, ); return; } @@ -335,7 +315,6 @@ export class GameServer { private endTurn() { const pastTurn: Turn = { turnNumber: this.turns.length, - gameID: this.id, intents: this.intents, }; this.turns.push(pastTurn); From 5de469e3124270daa4a0bf0196f02c9fc3dfb010 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 7 May 2025 22:15:48 -0400 Subject: [PATCH 07/35] Update workflow files (#675) ## Description: - Split the CI workflow in to separate build and test jobs, to improve visibility on PRs when the build passes but tests fail. - Add emoji labels to various jobs and steps - Add build and startup time to job summary ![image](https://github.com/user-attachments/assets/187e99d2-20de-4220-b1a4-11f4d2e72647) ![image](https://github.com/user-attachments/assets/83f9a9fc-c118-415a-92ca-faca48bac31e) ![image](https://github.com/user-attachments/assets/b8061a28-7d5d-4f93-99c6-673f3ca84e30) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- .github/workflows/ci.yml | 23 ++++++++++++++++------- .github/workflows/deploy.yml | 18 ++++++++++-------- .github/workflows/eslint.yml | 4 ++-- .github/workflows/prettier.yml | 4 ++-- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 076493044..8e3954117 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: 🧪 CI on: [push, pull_request] jobs: build: @@ -12,13 +12,22 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - - name: Setup npm - run: npm install - - name: Run tests - run: npm test - - name: Build - run: npm run build-prod + - run: npm ci + - run: npm run build-prod - uses: actions/upload-artifact@v4 with: path: out/index.html retention-days: 1 + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: false + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3eb00042d..836b8ec33 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -61,7 +61,7 @@ jobs: SUBDOMAIN: ${{ github.event_name == 'push' && github.ref_name || inputs.target_subdomain || 'main' }} steps: - uses: actions/checkout@v4 - - name: Update deployment status + - name: 📝 Update deployment status env: FQDN: ${{ env.SUBDOMAIN && format('{0}.{1}', env.SUBDOMAIN, env.DOMAIN) || env.DOMAIN || 'openfront.dev' }} run: | @@ -71,12 +71,12 @@ jobs: Deploying from $GITHUB_REF to $FQDN EOF - - name: Log in to Docker Hub + - name: 🔗 Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Create SSH private key + - name: 🔑 Create SSH private key env: SERVER_HOST_EU: ${{ secrets.SERVER_HOST_EU }} SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }} @@ -90,7 +90,7 @@ jobs: test -n "$SERVER_HOST_US" && ssh-keyscan -H "$SERVER_HOST_US" >> ~/.ssh/known_hosts test -n "$SERVER_HOST_EU" && ssh-keyscan -H "$SERVER_HOST_EU" >> ~/.ssh/known_hosts chmod 600 ~/.ssh/id_rsa - - name: Deploy + - name: 🚢 Deploy env: ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }} CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} @@ -115,8 +115,9 @@ jobs: run: | echo "::group::deploy.sh" ./deploy.sh "$ENV" "$HOST" "$SUBDOMAIN" + echo "Deployment created in ${SECONDS} seconds" >> $GITHUB_STEP_SUMMARY echo "::endgroup::" - - name: Wait for deployment to start + - name: ⏳ Wait for deployment to start run: | echo "::group::Wait for deployment to start" set -euxo pipefail @@ -127,8 +128,9 @@ jobs: fi sleep 10 done + echo "Deployment started in ${SECONDS} seconds" >> $GITHUB_STEP_SUMMARY echo "::endgroup::" - - name: Notify PR 🚀 + - name: 🚀 Notify PR if: ${{ success() && github.event_name == 'push' && github.repository != 'openfrontio/OpenFrontIO' }} @@ -147,7 +149,7 @@ jobs: exit 0; fi gh pr comment "$pr_url" --body "🚀 Deployed ${GITHUB_SHA} to [$FQDN](https://$FQDN)." - - name: Update deployment status ✅ + - name: ✅ Update deployment status if: success() run: | cat <> $GITHUB_STEP_SUMMARY @@ -155,7 +157,7 @@ jobs: Deployed from $GITHUB_REF to $FQDN EOF - - name: Update deployment status ❌ + - name: ❌ Update deployment status if: failure() run: | cat <> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 3cd72b90e..836b8284d 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -1,4 +1,4 @@ -name: ESLint Check +name: 🔍 ESLint on: pull_request: @@ -6,7 +6,7 @@ on: branches: [main] jobs: - eslint: + check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 530ef7fcf..315b8031b 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -1,4 +1,4 @@ -name: Prettier Check +name: 🎨 Prettier on: pull_request: @@ -6,7 +6,7 @@ on: branches: [main] jobs: - prettier: + check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 5ddc25897f0a7707711a3d5ba6df5d5ca170e8a3 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Fri, 9 May 2025 01:00:25 +0900 Subject: [PATCH 08/35] Add quick chat (#412) ## Description: Fixes #480 ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [ ] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: --- resources/QuickChat.json | 224 +++++++++++++++ resources/images/ChatIconWhite.svg | 13 + src/client/Main.ts | 6 + src/client/Transport.ts | 20 ++ src/client/graphics/GameRenderer.ts | 18 ++ src/client/graphics/layers/ChatDisplay.ts | 193 +++++++++++++ src/client/graphics/layers/ChatModal.ts | 292 ++++++++++++++++++++ src/client/graphics/layers/EventsDisplay.ts | 2 + src/client/graphics/layers/PlayerPanel.ts | 19 ++ src/client/index.html | 8 + src/client/styles.css | 1 + src/client/styles/modal/chat.css | 94 +++++++ src/core/Schemas.ts | 17 ++ src/core/execution/ExecutionManager.ts | 8 + src/core/execution/QuickChatExecution.ts | 98 +++++++ src/core/game/Game.ts | 1 + 16 files changed, 1014 insertions(+) create mode 100644 resources/QuickChat.json create mode 100644 resources/images/ChatIconWhite.svg create mode 100644 src/client/graphics/layers/ChatDisplay.ts create mode 100644 src/client/graphics/layers/ChatModal.ts create mode 100644 src/client/styles/modal/chat.css create mode 100644 src/core/execution/QuickChatExecution.ts diff --git a/resources/QuickChat.json b/resources/QuickChat.json new file mode 100644 index 000000000..82777e696 --- /dev/null +++ b/resources/QuickChat.json @@ -0,0 +1,224 @@ +{ + "help": [ + { + "key": "troops", + "text": "Please give me troops!", + "requiresPlayer": false + }, + { + "key": "gold", + "text": "Please give me gold!", + "requiresPlayer": false + }, + { + "key": "no_attack", + "text": "Please don't attack me!", + "requiresPlayer": false + }, + { + "key": "sorry_attack", + "text": "Sorry, I didn’t mean to attack.", + "requiresPlayer": false + }, + { + "key": "alliance", + "text": "Alliance?", + "requiresPlayer": false + }, + { + "key": "help_defend", + "text": "Help me defend against [P1]!", + "requiresPlayer": true + }, + { + "key": "team_up", + "text": "Let’s team up against [P1]!", + "requiresPlayer": true + } + ], + "attack": [ + { + "key": "attack", + "text": "Attack [P1]!", + "requiresPlayer": true + }, + { + "key": "mirv", + "text": "Launch a MIRV at [P1]!", + "requiresPlayer": true + }, + { + "key": "focus", + "text": "Focus fire on [P1]!", + "requiresPlayer": true + }, + { + "key": "finish", + "text": "Let's finish off [P1]!", + "requiresPlayer": true + } + ], + "defend": [ + { + "key": "defend", + "text": "Defend [P1]!", + "requiresPlayer": true + }, + { + "key": "dont_attack", + "text": "Don’t attack [P1]!", + "requiresPlayer": true + }, + { + "key": "ally", + "text": "[P1] is my ally!", + "requiresPlayer": true + } + ], + "greet": [ + { + "key": "hello", + "text": "Hello!", + "requiresPlayer": false + }, + { + "key": "good_luck", + "text": "Good luck!", + "requiresPlayer": false + }, + { + "key": "have_fun", + "text": "Have fun!", + "requiresPlayer": false + }, + { + "key": "gg", + "text": "GG!", + "requiresPlayer": false + }, + { + "key": "nice_to_meet", + "text": "Nice to meet you!", + "requiresPlayer": false + }, + { + "key": "well_played", + "text": "Well played!", + "requiresPlayer": false + }, + { + "key": "hi_again", + "text": "Hi again!", + "requiresPlayer": false + }, + { + "key": "bye", + "text": "Bye!", + "requiresPlayer": false + }, + { + "key": "thanks", + "text": "Thanks!", + "requiresPlayer": false + }, + { + "key": "oops", + "text": "Oops, wrong button!", + "requiresPlayer": false + }, + { + "key": "trust_me", + "text": "You can trust me. Promise!", + "requiresPlayer": false + }, + { + "key": "trust_broken", + "text": "I trusted you...", + "requiresPlayer": false + } + ], + "misc": [ + { + "key": "go", + "text": "Let’s go!", + "requiresPlayer": false + }, + { + "key": "strategy", + "text": "Nice strategy!", + "requiresPlayer": false + }, + { + "key": "fun", + "text": "This game is fun!", + "requiresPlayer": false + }, + { + "key": "pr", + "text": "When will my PR finally get merged...?", + "requiresPlayer": false + } + ], + "warnings": [ + { + "key": "strong", + "text": "[P1] is strong.", + "requiresPlayer": true + }, + { + "key": "weak", + "text": "[P1] is weak.", + "requiresPlayer": true + }, + { + "key": "mirv_soon", + "text": "[P1] can launch a MIRV soon!", + "requiresPlayer": true + }, + { + "key": "number1_warning", + "text": "The #1 player will win soon unless we team up!", + "requiresPlayer": false + }, + { + "key": "stalemate", + "text": "Let's make peace. This is a stalemate, we will both lose.", + "requiresPlayer": false + }, + { + "key": "has_allies", + "text": "[P1] has many allies.", + "requiresPlayer": true + }, + { + "key": "no_allies", + "text": "[P1] has no allies.", + "requiresPlayer": true + }, + { + "key": "betrayed", + "text": "[P1] betrayed their ally!", + "requiresPlayer": true + }, + { + "key": "getting_big", + "text": "[P1] is growing too fast!", + "requiresPlayer": true + }, + { + "key": "danger_base", + "text": "[P1] is unprotected!", + "requiresPlayer": true + }, + { + "key": "saving_for_mirv", + "text": "[P1] is saving up to launch a MIRV.", + "requiresPlayer": true + }, + { + "key": "mirv_ready", + "text": "[P1] has enough gold to launch a MIRV!", + "requiresPlayer": true + } + ] +} diff --git a/resources/images/ChatIconWhite.svg b/resources/images/ChatIconWhite.svg new file mode 100644 index 000000000..56c7d8e5b --- /dev/null +++ b/resources/images/ChatIconWhite.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/client/Main.ts b/src/client/Main.ts index 31a2a9ab4..a8fddd2e5 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -122,6 +122,12 @@ class Client { } }); + // const ctModal = document.querySelector("chat-modal") as ChatModal; + // ctModal instanceof ChatModal; + // document.getElementById("chat-button").addEventListener("click", () => { + // ctModal.open(); + // }); + const hlpModal = document.querySelector("help-modal") as HelpModal; hlpModal instanceof HelpModal; document.getElementById("help-button").addEventListener("click", () => { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index a9ee93fe9..ad2a13dd5 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -108,6 +108,15 @@ export class SendDonateTroopsIntentEvent implements GameEvent { ) {} } +export class SendQuickChatEvent implements GameEvent { + constructor( + public readonly sender: PlayerView, + public readonly recipient: PlayerView, + public readonly quickChatKey: string, + public readonly variables: { [key: string]: string }, + ) {} +} + export class SendEmbargoIntentEvent implements GameEvent { constructor( public readonly sender: PlayerView, @@ -196,6 +205,7 @@ export class Transport { this.eventBus.on(SendDonateTroopsIntentEvent, (e) => this.onSendDonateTroopIntent(e), ); + this.eventBus.on(SendQuickChatEvent, (e) => this.onSendQuickChatIntent(e)); this.eventBus.on(SendEmbargoIntentEvent, (e) => this.onSendEmbargoIntent(e), ); @@ -458,6 +468,16 @@ export class Transport { }); } + private onSendQuickChatIntent(event: SendQuickChatEvent) { + this.sendIntent({ + type: "quick_chat", + clientID: this.lobbyConfig.clientID, + recipient: event.recipient.id(), + quickChatKey: event.quickChatKey, + variables: event.variables, + }); + } + private onSendEmbargoIntent(event: SendEmbargoIntentEvent) { this.sendIntent({ type: "embargo", diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 9c92a3d0e..17f8bc30f 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -7,6 +7,8 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; import { BuildMenu } from "./layers/BuildMenu"; +import { ChatDisplay } from "./layers/ChatDisplay"; +import { ChatModal } from "./layers/ChatModal"; import { ControlPanel } from "./layers/ControlPanel"; import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; @@ -87,6 +89,14 @@ export function createRenderer( eventsDisplay.game = game; eventsDisplay.clientID = clientID; + const chatDisplay = document.querySelector("chat-display") as ChatDisplay; + if (!(chatDisplay instanceof ChatDisplay)) { + consolex.error("chat display not found"); + } + chatDisplay.eventBus = eventBus; + chatDisplay.game = game; + chatDisplay.clientID = clientID; + const playerInfo = document.querySelector( "player-info-overlay", ) as PlayerInfoOverlay; @@ -126,6 +136,13 @@ export function createRenderer( playerPanel.eventBus = eventBus; playerPanel.emojiTable = emojiTable; + const chatModal = document.querySelector("chat-modal") as ChatModal; + if (!(chatModal instanceof ChatModal)) { + console.error("chat modal not found"); + } + chatModal.g = game; + chatModal.eventBus = eventBus; + const multiTabModal = document.querySelector( "multi-tab-modal", ) as MultiTabModal; @@ -142,6 +159,7 @@ export function createRenderer( new UILayer(game, eventBus, clientID, transformHandler), new NameLayer(game, transformHandler, clientID), eventsDisplay, + chatDisplay, buildMenu, new RadialMenu( eventBus, diff --git a/src/client/graphics/layers/ChatDisplay.ts b/src/client/graphics/layers/ChatDisplay.ts new file mode 100644 index 000000000..847dd35d0 --- /dev/null +++ b/src/client/graphics/layers/ChatDisplay.ts @@ -0,0 +1,193 @@ +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { DirectiveResult } from "lit/directive.js"; +import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js"; +import { EventBus } from "../../../core/EventBus"; +import { MessageType } from "../../../core/game/Game"; +import { + DisplayMessageUpdate, + GameUpdateType, +} from "../../../core/game/GameUpdates"; +import { GameView } from "../../../core/game/GameView"; +import { ClientID } from "../../../core/Schemas"; +import { onlyImages } from "../../../core/Util"; +import { Layer } from "./Layer"; + +interface ChatEvent { + description: string; + unsafeDescription?: boolean; + createdAt: number; + highlight?: boolean; +} + +@customElement("chat-display") +export class ChatDisplay extends LitElement implements Layer { + public eventBus: EventBus; + public game: GameView; + public clientID: ClientID; + + private active: boolean = false; + + private updateMap = new Map([ + [GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)], + ]); + + @state() private _hidden: boolean = false; + @state() private newEvents: number = 0; + @state() private chatEvents: ChatEvent[] = []; + + private toggleHidden() { + this._hidden = !this._hidden; + if (this._hidden) { + this.newEvents = 0; + } + this.requestUpdate(); + } + + private addEvent(event: ChatEvent) { + this.chatEvents = [...this.chatEvents, event]; + if (this._hidden) { + this.newEvents++; + } + this.requestUpdate(); + } + + private removeEvent(index: number) { + this.chatEvents = [ + ...this.chatEvents.slice(0, index), + ...this.chatEvents.slice(index + 1), + ]; + } + + onDisplayMessageEvent(event: DisplayMessageUpdate) { + if (event.messageType !== MessageType.CHAT) return; + const myPlayer = this.game.playerByClientID(this.clientID); + if ( + event.playerID != null && + (!myPlayer || myPlayer.smallID() !== event.playerID) + ) { + return; + } + + this.addEvent({ + description: event.message, + createdAt: this.game.ticks(), + highlight: true, + unsafeDescription: true, + }); + } + + init() {} + + tick() { + // this.active = true; + const updates = this.game.updatesSinceLastTick(); + const messages = updates[GameUpdateType.DisplayEvent] as + | DisplayMessageUpdate[] + | undefined; + + if (messages) { + for (const msg of messages) { + if (msg.messageType === MessageType.CHAT) { + const myPlayer = this.game.playerByClientID(this.clientID); + if ( + msg.playerID != null && + (!myPlayer || myPlayer.smallID() !== msg.playerID) + ) { + continue; + } + + this.chatEvents = [ + ...this.chatEvents, + { + description: msg.message, + unsafeDescription: true, + createdAt: this.game.ticks(), + }, + ]; + } + } + } + + if (this.chatEvents.length > 100) { + this.chatEvents = this.chatEvents.slice(-100); + } + + this.requestUpdate(); + } + + private getChatContent( + chat: ChatEvent, + ): string | DirectiveResult { + return chat.unsafeDescription + ? unsafeHTML(onlyImages(chat.description)) + : chat.description; + } + + render() { + if (!this.active) { + return html``; + } + return html` +
+
+
+ +
+ + + + + + ${this.chatEvents.map( + (chat) => html` + + + + `, + )} + +
+ ${this.getChatContent(chat)} +
+
+
+ `; + } + + createRenderRoot() { + return this; + } +} diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts new file mode 100644 index 000000000..4d4447877 --- /dev/null +++ b/src/client/graphics/layers/ChatModal.ts @@ -0,0 +1,292 @@ +import { LitElement, html } from "lit"; +import { customElement, query } from "lit/decorators.js"; + +import { PlayerType } from "../../../core/game/Game"; +import { GameView, PlayerView } from "../../../core/game/GameView"; + +import quickChatData from "../../../../resources/QuickChat.json"; +import { EventBus } from "../../../core/EventBus"; +import { SendQuickChatEvent } from "../../Transport"; + +type QuickChatPhrase = { + key: string; + text: string; + requiresPlayer: boolean; +}; + +type QuickChatPhrases = Record; + +const quickChatPhrases: QuickChatPhrases = quickChatData; + +@customElement("chat-modal") +export class ChatModal extends LitElement { + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + createRenderRoot() { + return this; + } + + private players: string[] = []; + + private playerSearchQuery: string = ""; + private previewText: string | null = null; + private requiresPlayerSelection: boolean = false; + private selectedCategory: string | null = null; + private selectedPhraseText: string | null = null; + private selectedPlayer: string | null = null; + private selectedPhraseTemplate: string | null = null; + private selectedQuickChatKey: string | null = null; + + private recipient: PlayerView; + private sender: PlayerView; + public eventBus: EventBus; + + public g: GameView; + + quickChatPhrases: Record< + string, + Array<{ text: string; requiresPlayer: boolean }> + > = { + help: [{ text: "Please give me troops!", requiresPlayer: false }], + attack: [{ text: "Attack [P1]!", requiresPlayer: true }], + defend: [{ text: "Defend [P1]!", requiresPlayer: true }], + greet: [{ text: "Hello!", requiresPlayer: false }], + misc: [{ text: "Let's go!", requiresPlayer: false }], + }; + + private categories = [ + { id: "help", name: "Help" }, + { id: "attack", name: "Attack" }, + { id: "defend", name: "Defend" }, + { id: "greet", name: "Greetings" }, + { id: "misc", name: "Miscellaneous" }, + { id: "warnings", name: "Warnings" }, + ]; + + private getPhrasesForCategory(categoryId: string) { + return quickChatPhrases[categoryId] ?? []; + } + + render() { + const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b)); + + const filteredPlayers = sortedPlayers.filter((player) => + player.toLowerCase().includes(this.playerSearchQuery), + ); + + const otherPlayers = sortedPlayers.filter( + (player) => !player.toLowerCase().includes(this.playerSearchQuery), + ); + + const displayPlayers = [...filteredPlayers, ...otherPlayers]; + return html` + +
+
+
Category
+ ${this.categories.map( + (category) => html` + + `, + )} +
+ + ${this.selectedCategory + ? html` +
+
Phrase
+
+ ${this.getPhrasesForCategory(this.selectedCategory).map( + (phrase) => html` + + `, + )} +
+
+ ` + : null} + ${this.requiresPlayerSelection || this.selectedPlayer + ? html` +
+
Player
+ + + +
+ ${this.getSortedFilteredPlayers().map( + (player) => html` + + `, + )} +
+
+ ` + : null} +
+ +
+ ${this.previewText || "Build your message..."} +
+
+ +
+
+ `; + } + + private selectCategory(categoryId: string) { + this.selectedCategory = categoryId; + this.selectedPhraseText = null; + this.previewText = null; + this.requiresPlayerSelection = false; + this.selectedPlayer = null; + this.requestUpdate(); + } + + private selectPhrase(phrase: QuickChatPhrase) { + this.selectedPhraseTemplate = phrase.text; + this.selectedPhraseText = phrase.text; + this.selectedQuickChatKey = this.getFullQuickChatKey( + this.selectedCategory!, + phrase.key, + ); + this.previewText = phrase.text; + this.requiresPlayerSelection = phrase.requiresPlayer; + this.selectedPlayer = null; + this.requestUpdate(); + } + + private renderPhrasePreview(phrase: { text: string }) { + return phrase.text.replace("[P1]", "___"); // 仮表示 + } + + private selectPlayer(player: string) { + if (this.previewText) { + this.previewText = this.selectedPhraseTemplate.replace("[P1]", player); + this.selectedPlayer = player; + this.requiresPlayerSelection = false; + this.requestUpdate(); + } + } + + private sendChatMessage() { + console.log("Sent message:", this.previewText); + console.log("Sender:", this.sender); + console.log("Recipient:", this.recipient); + console.log("Key:", this.selectedQuickChatKey); + + if (this.sender && this.recipient && this.selectedQuickChatKey) { + const variables = this.selectedPlayer ? { P1: this.selectedPlayer } : {}; + + this.eventBus.emit( + new SendQuickChatEvent( + this.sender, + this.recipient, + this.selectedQuickChatKey, + variables, + ), + ); + } + + this.previewText = null; + this.selectedCategory = null; + this.requiresPlayerSelection = false; + this.close(); + + this.requestUpdate(); + } + + private onPlayerSearchInput(e: Event) { + const target = e.target as HTMLInputElement; + this.playerSearchQuery = target.value.toLowerCase(); + this.requestUpdate(); + } + + private getSortedFilteredPlayers(): string[] { + const sorted = [...this.players].sort((a, b) => a.localeCompare(b)); + const filtered = sorted.filter((p) => + p.toLowerCase().includes(this.playerSearchQuery), + ); + const others = sorted.filter( + (p) => !p.toLowerCase().includes(this.playerSearchQuery), + ); + return [...filtered, ...others]; + } + + private getFullQuickChatKey(category: string, phraseKey: string): string { + return `${category}.${phraseKey}`; + } + + public open(sender?: PlayerView, recipient?: PlayerView) { + if (sender && recipient) { + console.log("Sent message:", recipient); + console.log("Sent message:", sender); + const alivePlayerNames = this.g + .players() + .filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot)) + .map((p) => p.data.name); + + console.log("Alive player names:", alivePlayerNames); + this.players = alivePlayerNames; + this.recipient = recipient; + this.sender = sender; + } + this.modalEl?.open(); + } + + public close() { + this.selectedCategory = null; + this.selectedPhraseText = null; + this.previewText = null; + this.requiresPlayerSelection = false; + this.selectedPlayer = null; + this.modalEl?.close(); + } + + public setRecipient(value: PlayerView) { + this.recipient = value; + } + + public setSender(value: PlayerView) { + this.sender = value; + } +} diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 48f812c3f..95fd89c54 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -395,6 +395,8 @@ export class EventsDisplay extends LitElement implements Layer { return "text-green-300"; case MessageType.INFO: return "text-gray-200"; + case MessageType.CHAT: + return "text-gray-200"; case MessageType.WARN: return "text-yellow-300"; case MessageType.ERROR: diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 371b45989..bc4977322 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; +import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg"; import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg"; import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg"; @@ -27,6 +28,7 @@ import { SendTargetPlayerIntentEvent, } from "../../Transport"; import { renderNumber, renderTroops } from "../../Utils"; +import { ChatModal } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; @@ -139,6 +141,11 @@ export class PlayerPanel extends LitElement implements Layer { }); } + private handleChat(e: Event, sender: PlayerView, other: PlayerView) { + this.ctModal.open(sender, other); + this.hide(); + } + private handleTargetClick(e: Event, other: PlayerView) { e.stopPropagation(); this.eventBus.emit(new SendTargetPlayerIntentEvent(other.id())); @@ -149,8 +156,12 @@ export class PlayerPanel extends LitElement implements Layer { return this; } + private ctModal; + init() { this.eventBus.on(MouseUpEvent, (e: MouseEvent) => this.hide()); + + this.ctModal = document.querySelector("chat-modal") as ChatModal; } async tick() { @@ -295,6 +306,14 @@ export class PlayerPanel extends LitElement implements Layer {
+ ${canTarget ? html`
+
@@ -372,6 +379,7 @@ +
; @@ -50,6 +52,7 @@ export type TargetTroopRatioIntent = z.infer< >; export type BuildUnitIntent = z.infer; export type MoveWarshipIntent = z.infer; +export type QuickChatIntent = z.infer; export type Turn = z.infer; export type GameConfig = z.infer; @@ -270,6 +273,19 @@ export const MoveWarshipIntentSchema = BaseIntentSchema.extend({ tile: z.number(), }); +export const QuickChatKeySchema = z.enum( + Object.entries(quickChatData).flatMap(([category, entries]) => + entries.map((entry) => `${category}.${entry.key}`), + ) as [string, ...string[]], +); + +export const QuickChatIntentSchema = BaseIntentSchema.extend({ + type: z.literal("quick_chat"), + recipient: ID, + quickChatKey: QuickChatKeySchema, + variables: z.record(SafeString).optional(), +}); + const IntentSchema = z.union([ AttackIntentSchema, CancelAttackIntentSchema, @@ -286,6 +302,7 @@ const IntentSchema = z.union([ BuildUnitIntentSchema, EmbargoIntentSchema, MoveWarshipIntentSchema, + QuickChatIntentSchema, ]); export const TurnSchema = z.object({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 73bfb97e9..15c3c3241 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -15,6 +15,7 @@ import { EmojiExecution } from "./EmojiExecution"; import { FakeHumanExecution } from "./FakeHumanExecution"; import { MoveWarshipExecution } from "./MoveWarshipExecution"; import { NoOpExecution } from "./NoOpExecution"; +import { QuickChatExecution } from "./QuickChatExecution"; import { RetreatExecution } from "./RetreatExecution"; import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution"; import { SpawnExecution } from "./SpawnExecution"; @@ -108,6 +109,13 @@ export class Executor { this.mg.ref(intent.x, intent.y), intent.unit, ); + case "quick_chat": + return new QuickChatExecution( + playerID, + intent.recipient, + intent.quickChatKey, + intent.variables ?? {}, + ); default: throw new Error(`intent type ${intent} not found`); } diff --git a/src/core/execution/QuickChatExecution.ts b/src/core/execution/QuickChatExecution.ts new file mode 100644 index 000000000..919979997 --- /dev/null +++ b/src/core/execution/QuickChatExecution.ts @@ -0,0 +1,98 @@ +import quickChatData from "../../../resources/QuickChat.json"; +import { consolex } from "../Consolex"; +import { Execution, Game, MessageType, Player, PlayerID } from "../game/Game"; + +export class QuickChatExecution implements Execution { + private sender: Player; + private recipient: Player; + private mg: Game; + + private active = true; + + constructor( + private senderID: PlayerID, + private recipientID: PlayerID, + private quickChatKey: string, + private variables: Record, + ) {} + + init(mg: Game, ticks: number): void { + this.mg = mg; + if (!mg.hasPlayer(this.senderID)) { + consolex.warn(`QuickChatExecution: sender ${this.senderID} not found`); + this.active = false; + return; + } + if (!mg.hasPlayer(this.recipientID)) { + consolex.warn( + `QuickChatExecution: recipient ${this.recipientID} not found`, + ); + this.active = false; + return; + } + + this.sender = mg.player(this.senderID); + this.recipient = mg.player(this.recipientID); + } + + tick(ticks: number): void { + const message = this.getMessageFromKey(this.quickChatKey, this.variables); + + this.mg.displayMessage( + `${this.sender.name()}: ${message}`, + MessageType.CHAT, + this.recipient.id(), + ); + + this.mg.displayMessage( + `You sent to ${this.recipient.name()}: ${message}`, + MessageType.CHAT, + this.sender.id(), + ); + + consolex.log( + `[QuickChat] ${this.sender.name} → ${this.recipient.name}: ${message}`, + ); + + this.active = false; + } + + owner(): Player { + return this.sender; + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } + + private getMessageFromKey( + fullKey: string, + vars: Record, + ): string { + // Key for translation + const [category, key] = fullKey.split("."); + const phrases = quickChatData[category]; + + if (!phrases) { + consolex.warn(`QuickChat: Unknown category '${category}'`); + return `[${fullKey}]`; + } + + const phraseObj = phrases.find((p) => p.key === key); + if (!phraseObj) { + consolex.warn( + `QuickChat: Key '${key}' not found in category '${category}'`, + ); + return `[${fullKey}]`; + } + + return phraseObj.text.replace( + /\[(\w+)\]/g, + (_, p1) => vars[p1] || `[${p1}]`, + ); + } +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 17cc19e63..492dd8489 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -557,6 +557,7 @@ export enum MessageType { INFO, WARN, ERROR, + CHAT, } export interface NameViewData { From 9d8a2a2b41d9c5dcf0a7c673676c3ecc83a4e80c Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Thu, 8 May 2025 15:55:03 -0400 Subject: [PATCH 09/35] bugfix: Joining game fails (#680) ## Description: Restore the necessary fields to the join message. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/Schemas.ts | 19 +++++++++---------- src/server/Worker.ts | 6 ++++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 392ce7f99..8f202d9d0 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -371,42 +371,41 @@ export const ServerMessageSchema = z.union([ // Client -const ClientBaseMessageSchema = z.object({ - type: z.enum(["winner", "join", "intent", "ping", "log", "hash"]), -}); - -export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({ +export const ClientSendWinnerSchema = z.object({ type: z.literal("winner"), winner: z.union([ID, TeamSchema]).nullable(), allPlayersStats: AllPlayersStatsSchema, winnerType: z.enum(["player", "team"]), }); -export const ClientHashSchema = ClientBaseMessageSchema.extend({ +export const ClientHashSchema = z.object({ type: z.literal("hash"), hash: z.number(), turnNumber: z.number(), }); -export const ClientLogMessageSchema = ClientBaseMessageSchema.extend({ +export const ClientLogMessageSchema = z.object({ type: z.literal("log"), severity: z.nativeEnum(LogSeverity), log: ID, persistentID: SafeString, }); -export const ClientPingMessageSchema = ClientBaseMessageSchema.extend({ +export const ClientPingMessageSchema = z.object({ type: z.literal("ping"), }); -export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({ +export const ClientIntentMessageSchema = z.object({ type: z.literal("intent"), intent: IntentSchema, }); // WARNING: never send this message to clients. -export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({ +export const ClientJoinMessageSchema = z.object({ type: z.literal("join"), + clientID: ID, + persistentID: SafeString, // WARNING: PII + gameID: ID, lastTurn: z.number(), // The last turn the client saw. username: SafeString, flag: SafeString.nullable().optional(), diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 880dca263..0ce554f16 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -7,7 +7,7 @@ import { WebSocket, WebSocketServer } from "ws"; import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; -import { GameConfig, GameRecord } from "../core/Schemas"; +import { ClientMessageSchema, GameConfig, GameRecord } from "../core/Schemas"; import { archive, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; @@ -263,7 +263,9 @@ export function startWorker() { try { // Process WebSocket messages as in your original code // Parse and handle client messages - const clientMsg = JSON.parse(message.toString()); + const clientMsg = ClientMessageSchema.parse( + JSON.parse(message.toString()), + ); if (clientMsg.type == "join") { // Verify this worker should handle this game From c58ac10835f54d02c77e44c0f26adbd2d9ea7480 Mon Sep 17 00:00:00 2001 From: evan Date: Thu, 8 May 2025 13:14:38 -0700 Subject: [PATCH 10/35] add news button for update release notes --- resources/images/Megaphone.svg | 93 +++++++++++++++++++++++++++++ src/client/Main.ts | 20 +++++++ src/client/NewsModal.ts | 65 ++++++++++++++++++++ src/client/components/NewsButton.ts | 64 ++++++++++++++++++++ src/client/index.html | 2 + 5 files changed, 244 insertions(+) create mode 100644 resources/images/Megaphone.svg create mode 100644 src/client/NewsModal.ts create mode 100644 src/client/components/NewsButton.ts diff --git a/resources/images/Megaphone.svg b/resources/images/Megaphone.svg new file mode 100644 index 000000000..372c915ed --- /dev/null +++ b/resources/images/Megaphone.svg @@ -0,0 +1,93 @@ + +Created by Jacopo Bonaccifrom the Noun Project diff --git a/src/client/Main.ts b/src/client/Main.ts index a8fddd2e5..9fc9e0f2c 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -19,6 +19,7 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal"; import "./LangSelector"; import { LangSelector } from "./LangSelector"; import { LanguageModal } from "./LanguageModal"; +import { NewsModal } from "./NewsModal"; import "./PublicLobby"; import { PublicLobby } from "./PublicLobby"; import { SinglePlayerModal } from "./SinglePlayerModal"; @@ -26,6 +27,8 @@ import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; import { UsernameInput } from "./UsernameInput"; import { generateCryptoRandomUUID } from "./Utils"; +import "./components/NewsButton"; +import { NewsButton } from "./components/NewsButton"; import "./components/baseComponents/Button"; import { OButton } from "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; @@ -57,6 +60,23 @@ class Client { constructor() {} initialize(): void { + const newsModal = document.querySelector("news-modal") as NewsModal; + if (!newsModal) { + consolex.warn("News modal element not found"); + } else { + consolex.log("News modal element found"); + } + newsModal instanceof NewsModal; + const newsButton = document.querySelector("news-button") as NewsButton; + if (!newsButton) { + consolex.warn("News button element not found"); + } else { + consolex.log("News button element found"); + } + + // Comment out to show news button. + newsButton.hidden = true; + const langSelector = document.querySelector( "lang-selector", ) as LangSelector; diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts new file mode 100644 index 000000000..94c8116bf --- /dev/null +++ b/src/client/NewsModal.ts @@ -0,0 +1,65 @@ +import { LitElement, css, html } from "lit"; +import { customElement, query } from "lit/decorators.js"; +import { translateText } from "../client/Utils"; +import "./components/baseComponents/Button"; +import "./components/baseComponents/Modal"; + +@customElement("news-modal") +export class NewsModal extends LitElement { + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + static styles = css` + .news-container { + max-height: 60vh; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .news-content { + color: #ddd; + line-height: 1.5; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 1rem; + } + `; + + render() { + return html` + +
+
+
+
INSERT NEWS HERE
+
+
+
+ + +
+ `; + } + + public open() { + this.requestUpdate(); + this.modalEl?.open(); + } + + private close() { + this.modalEl?.close(); + } + + createRenderRoot() { + return this; // light DOM + } +} diff --git a/src/client/components/NewsButton.ts b/src/client/components/NewsButton.ts new file mode 100644 index 000000000..2fda8e1ac --- /dev/null +++ b/src/client/components/NewsButton.ts @@ -0,0 +1,64 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import megaphone from "../../../resources/images/Megaphone.svg"; +import { NewsModal } from "../NewsModal"; +import { translateText } from "../Utils"; + +@customElement("news-button") +export class NewsButton extends LitElement { + @property({ type: Boolean }) + hidden = false; + + static styles = css` + .news-button { + opacity: 0.75; + transition: opacity 0.2s ease; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; + border: none; + background: none; + cursor: pointer; + } + + .news-button:hover { + opacity: 1; + } + + .news-button img { + width: 24px; + height: 24px; + display: block; + margin-left: 12px; + } + + .hidden { + display: none !important; + } + `; + + private handleClick() { + const newsModal = document.querySelector("news-modal") as NewsModal; + if (newsModal) { + newsModal.open(); + } + } + + render() { + return html` +
+ +
+ `; + } + + createRenderRoot() { + return this; + } +} diff --git a/src/client/index.html b/src/client/index.html index 16c537c6e..897c3538f 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -230,6 +230,7 @@
+
@@ -382,6 +383,7 @@ +