From 2ad9c6f302a2993f89bce7d6b02d1237de844122 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 14 Oct 2025 21:50:50 -0700 Subject: [PATCH 01/24] bugfix: sending alliance to afk crashes game (#2202) ## Description: Afk players are marked as not friendly, the canSendAllianceRequest was returning true even if already allied because isFriendly was false. This was allowing alliance requests to people we were already allied with. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/core/game/PlayerImpl.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index d9ba8ba7c..75a3afcf9 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -390,6 +390,13 @@ export class PlayerImpl implements Player { if (other === this) { return false; } + if (this.isDisconnected() || other.isDisconnected()) { + // Disconnected players are marked as not-friendly even if they are allies, + // so we need to return early if either player is disconnected. + // Otherise we could end up sending an alliance request to someone + // we are already allied with. + return false; + } if (this.isFriendly(other) || !this.isAlive()) { return false; } From f552b006613c2dee52209801fe2875c64923fcc8 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 15 Oct 2025 09:46:34 -0700 Subject: [PATCH 02/24] bugfix: use FlagSchema to validate flag --- src/server/Privilege.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 402b4c3c7..fece2849e 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -1,6 +1,7 @@ import { Cosmetics } from "../core/CosmeticSchemas"; import { decodePatternData } from "../core/PatternDecoder"; import { + FlagSchema, PlayerColor, PlayerCosmeticRefs, PlayerCosmetics, @@ -42,10 +43,14 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { } } if (refs.flag) { - cosmetics.flag = cosmetics.flag = refs.flag.replace( - /[^a-z0-9-_ ()]/gi, - "", - ); + const result = FlagSchema.safeParse(refs.flag); + if (!result.success) { + return { + type: "forbidden", + reason: "invalid flag: " + result.error.message, + }; + } + cosmetics.flag = result.data; } return { type: "allowed", cosmetics }; From 144920eb53451f2652c1e2860b9b741e368a2ba7 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:49:46 +0200 Subject: [PATCH 03/24] Fix 100% owned in team stats (#2217) ## Description: When a team owns 100% of the land, it would show "1.0e2%" because of toPrecision(2). Fix this by keeping the same precision as for other numbers, but just returning 100 for 100%. It probably wasn't really noticed before since full land ownership doesn't occur much. Chances of 100% in team stats are a bit higher since fallout is now taken into account. Only a picture of the before situation: 100 percent ## 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/TeamStats.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index ba8e9dbe7..a8503c229 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -221,5 +221,6 @@ export class TeamStats extends LitElement implements Layer { function formatPercentage(value: number): string { const perc = value * 100; if (Number.isNaN(perc)) return "0%"; + if (perc === 100) return "100%"; return perc.toPrecision(2) + "%"; } From 4eaf3de5de09f60fcd8b9ebdfabb4511b8da3f6b Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Fri, 17 Oct 2025 04:58:13 +0200 Subject: [PATCH 04/24] Add destructed structure FX (#2210) ## Description: New FX on building destruction Icon level: https://github.com/user-attachments/assets/0ba5e557-a5d7-436f-8a58-2843d4c99332 Pixel art level: https://github.com/user-attachments/assets/12002df6-eb46-4853-b84f-4f81ce7c3528 ## 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: IngloriousTom --------- Co-authored-by: Evan --- resources/sprites/buildingExplosion.png | Bin 0 -> 884 bytes src/client/graphics/AnimatedSpriteLoader.ts | 10 +++++++++ src/client/graphics/fx/Fx.ts | 1 + src/client/graphics/layers/FxLayer.ts | 22 ++++++++++++++++++++ src/core/game/Game.ts | 1 + 5 files changed, 34 insertions(+) create mode 100644 resources/sprites/buildingExplosion.png diff --git a/resources/sprites/buildingExplosion.png b/resources/sprites/buildingExplosion.png new file mode 100644 index 0000000000000000000000000000000000000000..de827d4e06ba8a2cc8b854cbd9a64d2b6012c0f2 GIT binary patch literal 884 zcmV-)1B?8LP)Px&E=fc|RA_2(Ocyfe>!_SqLviNAExozMQC$^G)|_fCkdw%Y3LwRmho z=r|bFb9vx#lt=T+XTS&L@!b8y&cild_b`c@hl{Q+&p{n|J+P0hj|_RhCI|pPN}2f` zFsg(6_|5sZEgqY&yPsJ5pPn42f5`7Yey#Q8o@oLi=?}f$f$rlw9(yGHp~UCDK3Lb6 z=g{u-3gc-bL8age>1XwDfEq)pM8Rs4EpoX+5(9P$%vi7%sX`_e1t8=B03d`A*wghH zrmX-ML6sDw_gCe#?d)crp1T0619p4#BrYEI*7|XvU?0lE7qn@$0v~=)B=!w~waBPi z*nf}-Bp-+spkmy3X{Zg-d#)Y|)Ka-Cl-IKIUI<56Pfw2F{;HgTQ$efRI9N@Q&<8eG zj}XveL{_!e3;~t`{#{-(PEtwg1$0n2} zGm$1pIU+HP>cBzBN#9QjK`*N|cyy1X&-bHXb6L_kh<%pySWy*UTO+E1(|5U+D^cwn z#1e_@z(myD{Y0{*sc}Or=PmOb97|g`!EzO@UHUH5RDYjDXj-kXl?Tp)i>RUZO?}v< zKX3M~M_~nNu01L~uMLD=4X|lBZ)Kr$Or}(Vr1qjZlz2-(B~$o?Up(xsqUx(C=yXW% z^-k-jyghnii~Tm;9UO{jwL*^PJlma~R8h`Upm)josr{#mHnwi2q{HY7x=VS=R`93F zQzB=H3TNnfvUQt>&p!3*-#_B+;4mHIo9$!JFucNJ**AHZ#&0C3G3v0f6}#vk@>M9b zjN}r{ImIYL2@TGozivfUj4DIk@BNv1> = { originX: 9, originY: 9, }, + [FxType.BuildingExplosion]: { + url: buildingExplosion, + frameWidth: 17, + frameCount: 10, + frameDuration: 70, + looping: false, + originX: 8, + originY: 8, + }, [FxType.SinkingShip]: { url: sinkingShip, frameWidth: 16, diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts index 2aeb3ccf6..d4b206614 100644 --- a/src/client/graphics/fx/Fx.ts +++ b/src/client/graphics/fx/Fx.ts @@ -9,6 +9,7 @@ export enum FxType { MiniSmokeAndFire = "MiniSmokeAndFire", MiniExplosion = "MiniExplosion", UnitExplosion = "UnitExplosion", + BuildingExplosion = "BuildingExplosion", SinkingShip = "SinkingShip", Nuke = "Nuke", SAMExplosion = "SAMExplosion", diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index 027b8dd6c..e5ddf3831 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -151,6 +151,14 @@ export class FxLayer implements Layer { case UnitType.Train: this.onTrainEvent(unit); break; + case UnitType.DefensePost: + case UnitType.City: + case UnitType.Port: + case UnitType.MissileSilo: + case UnitType.SAMLauncher: + case UnitType.Factory: + this.onStructureEvent(unit); + break; } } @@ -246,6 +254,20 @@ export class FxLayer implements Layer { } } + onStructureEvent(unit: UnitView) { + if (!unit.isActive()) { + const x = this.game.x(unit.lastTile()); + const y = this.game.y(unit.lastTile()); + const explosion = new SpriteFx( + this.animatedSpriteLoader, + x, + y, + FxType.BuildingExplosion, + ); + this.allFx.push(explosion); + } + } + onNukeEvent(unit: UnitView, radius: number) { if (!unit.isActive()) { if (!unit.reachedTarget()) { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index db48e0a2f..978672084 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -202,6 +202,7 @@ const _structureTypes: ReadonlySet = new Set([ UnitType.SAMLauncher, UnitType.MissileSilo, UnitType.Port, + UnitType.Factory, ]); export function isStructureType(type: UnitType): boolean { From 19081bf21bb62dabb352761170022de411aebad6 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Fri, 17 Oct 2025 12:01:19 +0900 Subject: [PATCH 05/24] Add PlayerInfoModal (#2058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: This PR adds the Player Status display to the AccountModal. スクリーンショット 2025-10-11 6 44 25 (origin pr:https://github.com/openfrontio/OpenFrontIO/pull/1758) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: aotumuri --------- Co-authored-by: evanpelle --- resources/lang/en.json | 1 + src/client/AccountModal.ts | 126 +++++++++++++++++++++++++++++++++---- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index a8a130767..1d14125cc 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -155,6 +155,7 @@ "account_modal": { "title": "Account", "logged_in_as": "Logged in as {email}", + "fetching_account": "Fetching account information...", "logged_in_with_discord": "Logged in with Discord", "recovery_email_sent": "Recovery email sent to {email}" }, diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 93bf0ff96..01762b942 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -1,9 +1,23 @@ import { html, LitElement, TemplateResult } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { UserMeResponse } from "../core/ApiSchemas"; +import { + PlayerGame, + PlayerStatsTree, + UserMeResponse, +} from "../core/ApiSchemas"; +import "./components/baseComponents/stats/DiscordUserHeader"; +import "./components/baseComponents/stats/GameList"; +import "./components/baseComponents/stats/PlayerStatsTable"; +import "./components/baseComponents/stats/PlayerStatsTree"; import "./components/Difficulties"; import "./components/PatternButton"; -import { discordLogin, getApiBase, getUserMe, logOut } from "./jwt"; +import { + discordLogin, + fetchPlayerById, + getApiBase, + getUserMe, + logOut, +} from "./jwt"; import { isInIframe, translateText } from "./Utils"; @customElement("account-modal") @@ -14,12 +28,33 @@ export class AccountModal extends LitElement { }; @state() private email: string = ""; + @state() private isLoadingUser: boolean = false; private loggedInEmail: string | null = null; private loggedInDiscord: string | null = null; + private userMeResponse: UserMeResponse | null = null; + private playerId: string | null = null; + private statsTree: PlayerStatsTree | null = null; + private recentGames: PlayerGame[] = []; constructor() { super(); + + document.addEventListener("userMeResponse", (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail) { + this.userMeResponse = customEvent.detail as UserMeResponse; + this.playerId = this.userMeResponse?.player?.publicId; + if (this.playerId === undefined) { + this.statsTree = null; + this.recentGames = []; + } + } else { + this.statsTree = null; + this.recentGames = []; + this.requestUpdate(); + } + }); } createRenderRoot() { @@ -38,6 +73,16 @@ export class AccountModal extends LitElement { } private renderInner() { + if (this.isLoadingUser) { + return html` +
+

${translateText("account_modal.fetching_account")}

+
+
+ `; + } if (this.loggedInDiscord) { return this.renderLoggedInDiscord(); } else if (this.loggedInEmail) { @@ -47,15 +92,39 @@ export class AccountModal extends LitElement { } } + private viewGame(gameId: string): void { + this.close(); + const path = location.pathname; + const { search } = location; + const hash = `#join=${encodeURIComponent(gameId)}`; + const newUrl = `${path}${search}${hash}`; + + history.pushState({ join: gameId }, "", newUrl); + window.dispatchEvent(new HashChangeEvent("hashchange")); + } + private renderLoggedInDiscord() { return html`
-
-

+

+

Logged in with Discord as ${this.loggedInDiscord}

+ ${this.logoutButton()} +
+
+ + +
+ this.viewGame(id)} + >
- ${this.logoutButton()}
`; } @@ -208,13 +277,30 @@ export class AccountModal extends LitElement { discordLogin(); } - public async open() { - const userMe = await getUserMe(); - if (userMe) { - this.loggedInEmail = userMe.user.email ?? null; - this.loggedInDiscord = userMe.user.discord?.global_name ?? null; - } + public open() { this.modalEl?.open(); + this.isLoadingUser = true; + + void getUserMe() + .then((userMe) => { + if (userMe) { + this.loggedInEmail = userMe.user.email ?? null; + this.loggedInDiscord = userMe.user.discord?.global_name ?? null; + if (this.playerId) { + this.loadFromApi(this.playerId); + } + } else { + this.loggedInEmail = null; + this.loggedInDiscord = null; + } + this.isLoadingUser = false; + this.requestUpdate(); + }) + .catch((err) => { + console.warn("Failed to fetch user info in AccountModal.open():", err); + this.isLoadingUser = false; + this.requestUpdate(); + }); this.requestUpdate(); } @@ -228,6 +314,24 @@ export class AccountModal extends LitElement { // Refresh the page after logout to update the UI state window.location.reload(); } + + private async loadFromApi(playerId: string): Promise { + try { + const data = await fetchPlayerById(playerId); + if (!data) { + this.requestUpdate(); + return; + } + + this.recentGames = data.games; + this.statsTree = data.stats; + + this.requestUpdate(); + } catch (err) { + console.warn("Failed to load player data:", err); + this.requestUpdate(); + } + } } @customElement("account-button") From 06de3f1e8f9534288a11a186246653df4eaae4cd Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Sat, 18 Oct 2025 06:27:47 +0900 Subject: [PATCH 06/24] mls (v4.8) (#2218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: This is the final MLS for v26. Starting from the next PR, we’ll move on to v27. mls for v26 Version identifier within MLS: v4.7 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: Aotumuri --- resources/lang/sl.json | 78 +++++++++++++++++++++++++++++++++++---- resources/lang/zh-CN.json | 78 +++++++++++++++++++++++++++++++++++---- 2 files changed, 142 insertions(+), 14 deletions(-) diff --git a/resources/lang/sl.json b/resources/lang/sl.json index c6c4cb02e..b786336ae 100644 --- a/resources/lang/sl.json +++ b/resources/lang/sl.json @@ -130,9 +130,8 @@ "disable_nations": "Deaktiviraj nacije", "instant_build": "Instantna gradnja", "infinite_gold": "Neomejeno zlata", - "donate_gold": "Doniraj zlato", "infinite_troops": "Neomejene enote", - "donate_troops": "Doniraj enote", + "compact_map": "Mini mapa", "disable_nukes": "Deaktiviraj bombe", "enables_title": "Aktiviraj nastavitve", "start": "Začni igro" @@ -177,8 +176,10 @@ "halkidiki": "Halkidiki", "straitofgibraltar": "Gibraltarska ožina", "italia": "Italija", + "japan": "Japonska", "yenisei": "Jenisej", - "pluto": "Pluto" + "pluto": "Pluto", + "montreal": "Montreal" }, "map_categories": { "continental": "Celinska", @@ -196,8 +197,9 @@ "join_lobby": "Pridruži se sobi", "checking": "Preverjanje sobe...", "not_found": "Sobe ni mogoče najti. Prosimo preverite ID in poskusite znova.", - "error": "Prišlo je do napake. Prosimo, poskusite znova.", - "joined_waiting": "Uspešna pridružitev! Čakanje na začetek igre..." + "error": "Prišlo je do napake. Poskusite znova ali se obrnite na podporo.", + "joined_waiting": "Uspešna pridružitev! Čakanje na začetek igre...", + "version_mismatch": "Ta soba je bila ustvarjena z drugo verzijo igre. Napaka pridružitve." }, "public_lobby": { "join": "Pridruži se naslednji igri", @@ -227,6 +229,7 @@ "donate_gold": "Doniraj zlato", "infinite_troops": "Neomejene enote", "donate_troops": "Doniraj enote", + "compact_map": "Mini mapa", "enables_title": "Aktiviraj nastavitve", "player": "Igralec", "players": "Igralci", @@ -314,6 +317,8 @@ "territory_patterns_desc": "Izberite, ali želite v igri prikazati vzorce ozemlja", "performance_overlay_label": "Prekrivni sloj zmogljivosti", "performance_overlay_desc": "Preklopi prekrivni sloj zmogljivosti. Ko je omogočen, bo prikazan prekrivni sloj zmogljivosti. Med igro pritisnite shift-D za preklop.", + "performance_overlay_enabled": "Prekrivanje zmogljivosti je omogočeno", + "performance_overlay_disabled": "Prekrivanje zmogljivosti je onemogočeno", "easter_writing_speed_label": "Množitelj hitrosti pisanja", "easter_writing_speed_desc": "Prilagodite hitrost pretvarjanja, da programirate (x1–x100)", "easter_bug_count_label": "Število hroščev", @@ -494,7 +499,8 @@ "nation": "Narod", "player": "Igralec", "team": "Ekipa", - "d_troops": "Obrambne enote", + "alliance_timeout": "Zavezništvo poteče čez", + "troops": "Enote", "a_troops": "Napadalne enote", "gold": "Zlato", "ports": "Luka", @@ -585,7 +591,7 @@ "choose_spawn": "Izberite začetno lokacijo" }, "territory_patterns": { - "title": "Izberite vzorec ozemlja", + "title": "Izberi poslikavo območja", "purchase": "Nakup", "blocked": { "login": "Za dostop do tega vzorca morate biti prijavljeni.", @@ -644,5 +650,63 @@ "radial_menu": { "delete_unit_title": "Pobriši enoto", "delete_unit_description": "Pritisni za odstranitev najbližjih enot" + }, + "discord_user_header": { + "avatar_alt": "Avatar" + }, + "player_stats_table": { + "building_stats": "Statistika zgradbe", + "ship_arrivals": "Prihajajoče ladje", + "nuke_stats": "Statistika bomb", + "player_metrics": "Metrika igralca", + "building": "Zgradba", + "ship_type": "Vrsta ladje", + "weapon": "Orožje", + "built": "Zgrajeno", + "destroyed": "Uničeno", + "captured": "Ujeto", + "lost": "Zgubljeno", + "hits": "Zadetki", + "launched": "Izstreljeno", + "landed": "Pristanek", + "sent": "Poslano", + "arrived": "Prišlo", + "attack": "Napad", + "received": "Prejeto", + "cancelled": "Prekinjeno", + "count": "Število", + "gold": "Zlato", + "workers": "Delavci", + "war": "Vojna", + "trade": "Trgovanje", + "steal": "Kraja", + "unit": { + "city": "Mesto", + "port": "Luka", + "defp": "Obrambni stolp", + "saml": "SAM Izstreljevalec", + "silo": "Izstreljevalec raket", + "wshp": "Bojna ladja", + "fact": "Tovarna", + "trade": "Trgovska ladja", + "trans": "Transportna ladja", + "abomb": "Atomska bomba", + "hbomb": "Vodikova bomba", + "mirv": "MIRV", + "mirvw": "MIRV bojna glava" + } + }, + "game_list": { + "recent_games": "Nedavne igre", + "game_id": "ID sobe", + "mode": "Način", + "mode_ffa": "Prosto za vse", + "mode_team": "Ekipe", + "view": "Poglej", + "details": "Detajli", + "started": "Začeto", + "map": "Mapa", + "difficulty": "Težavnost", + "type": "Tip" } } diff --git a/resources/lang/zh-CN.json b/resources/lang/zh-CN.json index 2e748efa2..4b038a59c 100644 --- a/resources/lang/zh-CN.json +++ b/resources/lang/zh-CN.json @@ -130,9 +130,8 @@ "disable_nations": "禁用国家", "instant_build": "立即建造", "infinite_gold": "无限黄金", - "donate_gold": "捐赠金币", "infinite_troops": "无限军队", - "donate_troops": "捐赠军队", + "compact_map": "小地图", "disable_nukes": "禁用核弹", "enables_title": "启用设置", "start": "开始游戏" @@ -177,8 +176,10 @@ "halkidiki": "哈尔基季基", "straitofgibraltar": "直布罗陀海峡", "italia": "意大利", + "japan": "日本", "yenisei": "叶尼塞河", - "pluto": "冥王星" + "pluto": "冥王星", + "montreal": "蒙特利尔" }, "map_categories": { "continental": "大陆", @@ -196,8 +197,9 @@ "join_lobby": "加入房间", "checking": "正在确认房间...", "not_found": "找不到房间。请检查 ID 然后重试。", - "error": "发生了错误,请再试一次。", - "joined_waiting": "加入成功!正在等待游戏开始..." + "error": "发生错误。请再试一次或联系支持人员。", + "joined_waiting": "加入成功!正在等待游戏开始...", + "version_mismatch": "这场游戏基于另一个版本,无法加入。" }, "public_lobby": { "join": "加入下一场游戏", @@ -227,6 +229,7 @@ "donate_gold": "捐赠金币", "infinite_troops": "无限军队", "donate_troops": "捐赠军队", + "compact_map": "小地图", "enables_title": "启用设置", "player": "玩家", "players": "玩家", @@ -314,6 +317,8 @@ "territory_patterns_desc": "选择是否在游戏中显示领土皮肤", "performance_overlay_label": "性能叠层", "performance_overlay_desc": "切换性能叠层。启用后将显示性能叠层。在游戏过程中按下 Shift+D 可进行切换。", + "performance_overlay_enabled": "已启用性能叠层", + "performance_overlay_disabled": "已禁用性能叠层", "easter_writing_speed_label": "写入速度乘数", "easter_writing_speed_desc": "调节你“假装写代码”的速度 (x1–x100)", "easter_bug_count_label": "Bug 计数", @@ -494,7 +499,8 @@ "nation": "国家", "player": "玩家", "team": "队伍", - "d_troops": "防守军队", + "alliance_timeout": "结盟剩余时长", + "troops": "军队", "a_troops": "进攻军队", "gold": "黄金", "ports": "港口", @@ -541,7 +547,7 @@ }, "relation": { "hostile": "敌对", - "distrustful": "不可信", + "distrustful": "可疑", "neutral": "中立", "friendly": "友好", "default": "默认" @@ -644,5 +650,63 @@ "radial_menu": { "delete_unit_title": "删除单位", "delete_unit_description": "点击删除最近的单位" + }, + "discord_user_header": { + "avatar_alt": "头像" + }, + "player_stats_table": { + "building_stats": "建筑统计", + "ship_arrivals": "船只抵达", + "nuke_stats": "核弹统计", + "player_metrics": "玩家指标", + "building": "建筑", + "ship_type": "船只类型", + "weapon": "武器", + "built": "已建造", + "destroyed": "已摧毁", + "captured": "已捕获", + "lost": "损失", + "hits": "击中", + "launched": "已发射", + "landed": "已登陆", + "sent": "已发送", + "arrived": "已抵达", + "attack": "攻击", + "received": "已收到", + "cancelled": "已取消", + "count": "总计", + "gold": "黄金", + "workers": "工人", + "war": "战争", + "trade": "交易", + "steal": "偷窃", + "unit": { + "city": "城市", + "port": "港口", + "defp": "防守据点", + "saml": "防空塔", + "silo": "导弹发射井", + "wshp": "军舰", + "fact": "工厂", + "trade": "贸易船", + "trans": "运输船", + "abomb": "原子弹", + "hbomb": "氢弹", + "mirv": "MIRV", + "mirvw": "MIRV 弹头" + } + }, + "game_list": { + "recent_games": "近期对局", + "game_id": "游戏 ID", + "mode": "模式", + "mode_ffa": "混战", + "mode_team": "团队", + "view": "视图", + "details": "详细信息", + "started": "已开始", + "map": "地图", + "difficulty": "难度", + "type": "类型" } } From d5174d02ecb83b0e0a20a3ee903bff0eddc22f2b Mon Sep 17 00:00:00 2001 From: Duwibi <86431918+Duwibi@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:48:49 +0300 Subject: [PATCH 07/24] Readd Translation Discord link (#2255) ## Description: Updated translation instructions and added new Discord link. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] 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: DISCORD_USERNAME Nikola123 --------- Co-authored-by: Evan Co-authored-by: Loymdayddaud <145969603+TheGiraffe3@users.noreply.github.com> --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index db90be6cb..868aa8120 100644 --- a/README.md +++ b/README.md @@ -151,11 +151,14 @@ Contributions are welcome! Please feel free to submit a Pull Request. Translators are welcome! Please feel free to help translate into your language. How to help? -1. Request to join the dev [Discord](https://discord.gg/K9zernJB5z) (in the application form, say you want to help translate) -1. Go to the project's Crowdin translation page: [https://crowdin.com/project/openfront-mls](https://crowdin.com/project/openfront-mls) -1. Login if you already have an account/ Sign up if you don't have one -1. Select the language you want to translate in/ If your language isn't on the list, click the "Request New Language" button and enter the language you want added there. -1. Translate the strings +1. Join the translation [Discord](https://discord.gg/3zZzacjWFr) +2. Go to the project's Crowdin translation page: [https://crowdin.com/project/openfront-mls](https://crowdin.com/project/openfront-mls) +3. Login if you already have an account / Sign up if you don't have one +4. Join the project +5. Select the language you want to translate in. If your language isn't on the list, click the "Request New Language" button and enter the language you want added there. +6. Translate the strings + +Feel free to ask questions in the translation Discord server! ### Project Governance From 19597a37d979be8dfdbc98aa2fd33b85c007f75a Mon Sep 17 00:00:00 2001 From: MaxHT0x <113981080+MaxHT0x@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:49:33 +0300 Subject: [PATCH 08/24] Fix slow radial menu animation for mobile players (#2201) (#2248) ## Description: Closes #2201 Remove CSS transition from SVG element that was causing the radial menu to animate from its previous position to the new clicked position. This was creating delays of up to 2 seconds for mobile players, making the game unplayable on mobile devices. The viewport clamping functionality (from PR #1817) is preserved - the menu now appears instantly at the correct clamped position instead of animating to it. I wasn't sure if this should only apply for mobile players, but making it so sounded too much trouble for what it's worth so its removed entirely ## 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: stackk. --- src/client/graphics/layers/RadialMenu.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 84444afd3..2633f1cbd 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -149,10 +149,6 @@ export class RadialMenu implements Layer { .style("position", "absolute") .style("top", "50%") .style("left", "50%") - .style( - "transition", - `top ${this.config.menuTransitionDuration}ms ease, left ${this.config.menuTransitionDuration}ms ease`, - ) .style("transform", "translate(-50%, -50%)") .style("pointer-events", "all") .on("click", (event) => this.hideRadialMenu()); From 373e3ef44abe21b4f415dd82d22cd9f6da0fb019 Mon Sep 17 00:00:00 2001 From: Abdallah Bahrawi <140177728+abdallahbahrawi1@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:55:54 +0300 Subject: [PATCH 09/24] fix Request icon stuck for nations (#2234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: fixes https://github.com/openfrontio/OpenFrontIO/issues/1955 Describe the PR. Bots previously created requests directly, skipping timeout cleanup and causing a stuck “request sent” icon when unhandled. Using AllianceRequestExecution makes bots follow the same lifecycle as humans, so requests expire correctly. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] 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: abodcraft1 --------- Co-authored-by: Evan --- src/core/execution/FakeHumanExecution.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index edeab7417..3b8a7e94d 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -17,6 +17,7 @@ import { TileRef, euclDistFN } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util"; +import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution"; import { ConstructionExecution } from "./ConstructionExecution"; import { EmojiExecution } from "./EmojiExecution"; import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; @@ -221,7 +222,9 @@ export class FakeHumanExecution implements Execution { if (this.random.chance(20)) { const toAlly = this.random.randElement(enemies); if (this.player.canSendAllianceRequest(toAlly)) { - this.player.createAllianceRequest(toAlly); + this.mg.addExecution( + new AllianceRequestExecution(this.player, toAlly.id()), + ); } } From fb3d2e2c6fbd91274aba95d83e2ca958d3fe4625 Mon Sep 17 00:00:00 2001 From: Abdallah Bahrawi <140177728+abdallahbahrawi1@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:05:14 +0300 Subject: [PATCH 10/24] Polished Player Panel (#2235) ## Description: - Polished Player Panel UI. - Made alliance list unstructured. - Removed blur background. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] 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: abodcraft1 --------- Co-authored-by: Evan --- src/client/graphics/layers/PlayerPanel.ts | 245 +++++++++--------- .../graphics/layers/SendResourceModal.ts | 31 +-- 2 files changed, 138 insertions(+), 138 deletions(-) diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 3af01a0ec..5ff2640a9 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -193,6 +193,7 @@ export class PlayerPanel extends LitElement implements Layer { private closeSend = () => { this.sendTarget = null; + this.sendMode = "none"; }; private confirmSend = ( @@ -418,10 +419,15 @@ export class PlayerPanel extends LitElement implements Layer { }} />` : ""} -

- ${other.name()} -

+
+

+ ${other.name()} +

+
${chip ? html`
💰 - + ${renderNumber(other.gold() || 0)} - ${translateText("player_panel.gold")} + ${translateText("player_panel.gold")}
🛡️ - + ${renderTroops(other.troops() || 0)} - ${translateText("player_panel.troops")} + ${translateText("player_panel.troops")}
@@ -476,32 +482,34 @@ export class PlayerPanel extends LitElement implements Layer { private renderStats(other: PlayerView, my: PlayerView) { return html` -
+
${translateText("player_panel.betrayals")}
-
+
${other.data.betrayals ?? 0}
-
+
${translateText("player_panel.trading")}
-
+
${other.hasEmbargoAgainst(my) - ? html`${translateText("player_panel.stopped")}` - : html`${translateText("player_panel.active")}`}
@@ -512,60 +520,57 @@ export class PlayerPanel extends LitElement implements Layer { private renderAlliances(other: PlayerView) { const allies = other.allies(); + const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" }); + const alliesSorted = [...allies].sort((a, b) => + nameCollator.compare(a.name(), b.name()), + ); + return html` -
- +
-
+
${translateText("player_panel.alliances")}
${allies.length}
-
-
+
    - ${allies.length > 0 - ? allies.map((p) => { - const color = p.territoryColor().toHex(); - return html` -
    + ${translateText("common.none")} + ` + : alliesSorted.map( + (p) => + html`
  • - - - - - ${p.name()} - -
  • - `; - }) - : html` -
    - ${translateText("common.none")} -
    - `} -
+ ${p.name()} + `, + )} +
`; @@ -580,7 +585,7 @@ export class PlayerPanel extends LitElement implements Layer {
${this.allianceExpiryText} +
${actionButton({ onClick: (e: MouseEvent) => this.handleChat(e, my, other), @@ -657,6 +662,7 @@ export class PlayerPanel extends LitElement implements Layer { }) : ""}
+
${other !== my @@ -754,80 +760,85 @@ export class PlayerPanel extends LitElement implements Layer {
e.preventDefault()} @wheel=${(e: MouseEvent) => e.stopPropagation()} @click=${() => this.hide()} >
e.stopPropagation()} > -
- - - +
+
- -
${this.renderIdentityRow(other, my)}
+ + - ${this.sendTarget - ? html` - - ` - : ""} +
+ +
${this.renderIdentityRow(other, my)}
- + ${this.sendTarget + ? html` + + ` + : ""} - - ${this.renderResources(other)} + - + + ${this.renderResources(other)} - - ${this.renderStats(other, my)} + - + + ${this.renderStats(other, my)} - - ${this.renderAlliances(other)} + - - ${this.renderAllianceExpiry()} + + ${this.renderAlliances(other)} - + + ${this.renderAllianceExpiry()} - - ${this.renderActions(my, other)} + + + + ${this.renderActions(my, other)} +
diff --git a/src/client/graphics/layers/SendResourceModal.ts b/src/client/graphics/layers/SendResourceModal.ts index c3de5319a..5e7b3a1ed 100644 --- a/src/client/graphics/layers/SendResourceModal.ts +++ b/src/client/graphics/layers/SendResourceModal.ts @@ -244,19 +244,22 @@ export class SendResourceModal extends LitElement { private renderHeader() { const name = this.target?.name?.() ?? ""; return html` -
+

${this.heading ?? this.i18n.title(name)}

+
`; @@ -264,7 +267,6 @@ export class SendResourceModal extends LitElement { private renderAvailable() { const total = this.getTotalNumber(); - const cap = this.getCapacityLeft(); return html`
@@ -277,21 +279,6 @@ export class SendResourceModal extends LitElement { ${this.i18n.availableChip()} ${this.format(total)} - - ${cap !== null - ? html` - - - ${this.i18n.cap()} - ${this.format(cap)} - - ` - : html``}
`; @@ -554,9 +541,11 @@ export class SendResourceModal extends LitElement { const allowed = this.limitAmount(this.sendAmount); return html` -
+
this.closeModal()} >
From 4673808dc08bb933610735902d7657e8ebe385ea Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:06:23 +0200 Subject: [PATCH 11/24] Fix annex surrounded main cluster disconnected player (#2241) ## Description: When a main cluster is fully surrounded, the surrounding player is able to attack them (based on AttackLogic in DefaultConfig). But so far wasn't able to annex them. Fix: turned around an isFriendly check in PlayerExecution. This way if this.player is disconnected, they can get annexed, allied/team mate or not. This also means that in the edge case of surrounding player going AFK, the enclosed main cluster can attack the disconnected surrounding player and maybe fight it's way out of being enclosed. Meant as hotfix for v26. Reported here: https://discord.com/channels/1284581928254701718/1429252618995105923/1429252618995105923 ## 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/core/execution/PlayerExecution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 66e10c854..bc2491a72 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -118,7 +118,7 @@ export class PlayerExecution implements Execution { if (main === undefined) throw new Error("No clusters"); this.player.largestClusterBoundingBox = calculateBoundingBox(this.mg, main); const surroundedBy = this.surroundedBySamePlayer(main); - if (surroundedBy && !this.player.isFriendly(surroundedBy)) { + if (surroundedBy && !surroundedBy.isFriendly(this.player)) { this.removeCluster(main); } From dddf54be0b75fd055fac1d0507709de69620919d Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Tue, 21 Oct 2025 19:07:14 +0200 Subject: [PATCH 12/24] Add deletion duration and indicators (#2216) ## Description: Adds a timer before self deleting units Adds a loading bar under deleting units Adds a timer in radial menu for clarity purposes ![deletecd](https://github.com/user-attachments/assets/613bf742-ef90-42b5-a258-b928daae6aaa) ## 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: Mr.Box --------- Co-authored-by: Evan --- src/client/graphics/layers/RadialMenu.ts | 25 ++++++++ .../graphics/layers/RadialMenuElements.ts | 8 ++- .../graphics/layers/StructureDrawingUtils.ts | 58 ++++++++++++++----- .../graphics/layers/StructureIconsLayer.ts | 11 ++++ src/client/graphics/layers/UILayer.ts | 31 ++++++++-- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 3 + src/core/execution/DeleteUnitExecution.ts | 34 +++++++---- .../execution/UpgradeStructureExecution.ts | 2 +- src/core/game/Game.ts | 5 +- src/core/game/GameUpdates.ts | 1 + src/core/game/GameView.ts | 15 +++-- src/core/game/PlayerImpl.ts | 13 +++-- src/core/game/UnitImpl.ts | 27 +++++++++ tests/DeleteUnitExecution.test.ts | 38 +++++++++--- tests/util/TestConfig.ts | 4 ++ 16 files changed, 225 insertions(+), 51 deletions(-) diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 2633f1cbd..1b5981bb1 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -550,6 +550,20 @@ export class RadialMenu implements Layer { .attr("x", arc.centroid(d)[0] - this.config.iconSize / 2) .attr("y", arc.centroid(d)[1] - this.config.iconSize / 2) .attr("opacity", disabled ? 0.5 : 1); + + if (this.params && d.data.cooldown?.(this.params)) { + const cooldown = Math.ceil(d.data.cooldown?.(this.params)); + content + .append("text") + .attr("class", `cooldown-text`) + .text(cooldown + "s") + .attr("fill", "white") + .attr("opacity", disabled ? 0.5 : 1) + .attr("font-size", "14px") + .attr("font-weight", "bold") + .attr("x", arc.centroid(d)[0] - this.config.iconSize / 4) + .attr("y", arc.centroid(d)[1] + this.config.iconSize / 2 + 7); + } } this.menuIcons.set(contentId, content as any); @@ -994,6 +1008,17 @@ export class RadialMenu implements Layer { if (!imageElement.empty()) { imageElement.attr("opacity", disabled ? 0.5 : 1); } + + // Update cooldown text if applicable + const cooldownElement = icon.select(".cooldown-text"); + if (this.params && !cooldownElement.empty() && item.cooldown) { + const cooldown = Math.ceil(item.cooldown(this.params)); + if (cooldown <= 0) { + cooldownElement.remove(); + } else { + cooldownElement.text(cooldown + "s"); + } + } } } }); diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 9e1fe21ef..ae4c29d9d 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -51,6 +51,7 @@ export interface MenuElement { tooltipItems?: TooltipItem[]; tooltipKeys?: TooltipKey[]; + cooldown?: (params: MenuElementParams) => number; disabled: (params: MenuElementParams) => boolean; action?: (params: MenuElementParams) => void; // For leaf items that perform actions subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus @@ -425,6 +426,7 @@ export const attackMenuElement: MenuElement = { export const deleteUnitElement: MenuElement = { id: Slot.Delete, name: "delete", + cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(), disabled: (params: MenuElementParams) => { const tileOwner = params.game.owner(params.tile); const isLand = params.game.isLand(params.tile); @@ -441,7 +443,7 @@ export const deleteUnitElement: MenuElement = { return true; } - if (!params.myPlayer.canDeleteUnit()) { + if (params.myPlayer.deleteUnitCooldown() > 0) { return true; } @@ -450,8 +452,10 @@ export const deleteUnitElement: MenuElement = { .units() .filter( (unit) => + unit.constructionType() === undefined && + unit.markedForDeletion() === false && params.game.manhattanDist(unit.tile(), params.tile) <= - DELETE_SELECTION_RADIUS, + DELETE_SELECTION_RADIUS, ); return myUnits.length === 0; diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts index a3693dc74..a8a7ef930 100644 --- a/src/client/graphics/layers/StructureDrawingUtils.ts +++ b/src/client/graphics/layers/StructureDrawingUtils.ts @@ -99,10 +99,7 @@ export class SpriteFactory { private invalidateTextureCache(unitType: UnitType) { for (const key of Array.from(this.textureCache.keys())) { - if ( - key.endsWith(`-${unitType}-icon`) || - key === `construction-${unitType}-icon` - ) { + if (key.includes(`-${unitType}`)) { this.textureCache.delete(key); } } @@ -115,7 +112,13 @@ export class SpriteFactory { structureType: UnitType, ): PIXI.Container { const parentContainer = new PIXI.Container(); - const texture = this.createTexture(structureType, player, false, true); + const texture = this.createTexture( + structureType, + player, + false, + false, + true, + ); const sprite = new PIXI.Sprite(texture); sprite.anchor.set(0.5); sprite.alpha = 0.5; @@ -139,6 +142,7 @@ export class SpriteFactory { const worldPos = new Cell(this.game.x(tile), this.game.y(tile)); const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos); + const isMarkedForDeletion = unit.markedForDeletion() !== false; const isConstruction = unit.type() === UnitType.Construction; const constructionType = unit.constructionType(); const structureType = isConstruction ? constructionType! : unit.type(); @@ -156,6 +160,7 @@ export class SpriteFactory { structureType, unit.owner(), isConstruction, + isMarkedForDeletion, type === "icon", ); const sprite = new PIXI.Sprite(texture); @@ -202,19 +207,30 @@ export class SpriteFactory { type: UnitType, owner: PlayerView, isConstruction: boolean, + isMarkedForDeletion: boolean, renderIcon: boolean, ): PIXI.Texture { - const cacheKey = isConstruction - ? `construction-${type}` + (renderIcon ? "-icon" : "") - : `${this.theme.territoryColor(owner).toRgbString()}-${type}` + - (renderIcon ? "-icon" : ""); + const cacheKeyBase = isConstruction + ? `construction-${type}` + : `${this.theme.territoryColor(owner).toRgbString()}-${type}`; + const cacheKey = + cacheKeyBase + + (renderIcon ? "-icon" : "") + + (isMarkedForDeletion ? "-deleted" : ""); if (this.textureCache.has(cacheKey)) { return this.textureCache.get(cacheKey)!; } const shape = STRUCTURE_SHAPES[type]; const texture = shape - ? this.createIcon(owner, type, isConstruction, shape, renderIcon) + ? this.createIcon( + owner, + type, + isConstruction, + isMarkedForDeletion, + shape, + renderIcon, + ) : PIXI.Texture.EMPTY; this.textureCache.set(cacheKey, texture); return texture; @@ -224,6 +240,7 @@ export class SpriteFactory { owner: PlayerView, structureType: UnitType, isConstruction: boolean, + isMarkedForDeletion: boolean, shape: string, renderIcon: boolean, ): PIXI.Texture { @@ -370,11 +387,8 @@ export class SpriteFactory { } const structureInfo = this.structuresInfos.get(structureType); - if (!structureInfo?.image) { - return PIXI.Texture.from(structureCanvas); - } - if (renderIcon) { + if (structureInfo?.image && renderIcon) { const SHAPE_OFFSETS = { triangle: [6, 11], square: [5, 5], @@ -390,6 +404,22 @@ export class SpriteFactory { offsetY, ); } + + if (isMarkedForDeletion) { + context.save(); + context.strokeStyle = "rgba(255, 64, 64, 0.95)"; + context.lineWidth = Math.max(2, Math.round(iconSize * 0.12)); + context.lineCap = "round"; + const padding = Math.max(2, iconSize * 0.12); + context.beginPath(); + context.moveTo(padding, padding); + context.lineTo(iconSize - padding, iconSize - padding); + context.moveTo(iconSize - padding, padding); + context.lineTo(padding, iconSize - padding); + context.stroke(); + context.restore(); + } + return PIXI.Texture.from(structureCanvas); } diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 4f86c8070..009316f69 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -417,6 +417,7 @@ export class StructureIconsLayer implements Layer { const render = this.findRenderByUnit(unitView); if (render) { this.checkForConstructionState(render, unitView); + this.checkForDeletionState(render, unitView); this.checkForOwnershipChange(render, unitView); this.checkForLevelChange(render, unitView); } @@ -466,6 +467,16 @@ export class StructureIconsLayer implements Layer { } } + private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) { + if (unit.markedForDeletion() !== false) { + render.iconContainer?.destroy(); + render.dotContainer?.destroy(); + render.iconContainer = this.createIconSprite(unit); + render.dotContainer = this.createDotSprite(unit); + this.modifyVisibility(render); + } + } + private checkForConstructionState( render: StructureRenderInfo, unit: UnitView, diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 5bda48ef6..43092a642 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -117,11 +117,18 @@ export class UILayer implements Layer { this.drawHealthBar(unit); break; } + case UnitType.City: + case UnitType.Factory: + case UnitType.DefensePost: + case UnitType.Port: case UnitType.MissileSilo: - this.createLoadingBar(unit); - break; case UnitType.SAMLauncher: - this.createLoadingBar(unit); + if ( + unit.markedForDeletion() !== false || + unit.missileReadinesss() < 1 + ) { + this.createLoadingBar(unit); + } break; default: return; @@ -329,12 +336,28 @@ export class UILayer implements Layer { } case UnitType.MissileSilo: case UnitType.SAMLauncher: - return unit.missileReadinesss(); + return !unit.markedForDeletion() + ? unit.missileReadinesss() + : this.deletionProgress(this.game, unit); + case UnitType.City: + case UnitType.Factory: + case UnitType.Port: + case UnitType.DefensePost: + return this.deletionProgress(this.game, unit); default: return 1; } } + private deletionProgress(game: GameView, unit: UnitView): number { + const deleteAt = unit.markedForDeletion(); + if (deleteAt === false) return 1; + return Math.max( + 0, + (deleteAt - game.ticks()) / game.config().deletionMarkDuration(), + ); + } + public createLoadingBar(unit: UnitView) { if (!this.context) { return; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index baea027af..9811680d8 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -130,6 +130,7 @@ export interface Config { emojiMessageCooldown(): Tick; emojiMessageDuration(): Tick; donateCooldown(): Tick; + deletionMarkDuration(): Tick; deleteUnitCooldown(): Tick; defaultDonationAmount(sender: Player): number; unitInfo(type: UnitType): UnitInfo; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 2a4388d11..ba5ccec6e 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -569,6 +569,9 @@ export class DefaultConfig implements Config { donateCooldown(): Tick { return 10 * 10; } + deletionMarkDuration(): Tick { + return 15 * 10; + } deleteUnitCooldown(): Tick { return 5 * 10; } diff --git a/src/core/execution/DeleteUnitExecution.ts b/src/core/execution/DeleteUnitExecution.ts index 424130eea..9ba68ee50 100644 --- a/src/core/execution/DeleteUnitExecution.ts +++ b/src/core/execution/DeleteUnitExecution.ts @@ -1,8 +1,9 @@ -import { Execution, Game, MessageType, Player } from "../game/Game"; +import { Execution, Game, MessageType, Player, Unit } from "../game/Game"; export class DeleteUnitExecution implements Execution { private active: boolean = true; private mg: Game; + private unit: Unit | null = null; constructor( private player: Player, @@ -33,6 +34,7 @@ export class DeleteUnitExecution implements Execution { this.active = false; return; } + this.unit = unit; const tileOwner = mg.owner(unit.tile()); if (!tileOwner.isPlayer() || tileOwner.id() !== this.player.id()) { @@ -61,19 +63,29 @@ export class DeleteUnitExecution implements Execution { return; } - unit.delete(false); this.player.recordDeleteUnit(); - - this.mg.displayMessage( - `events_display.unit_voluntarily_deleted`, - MessageType.UNIT_DESTROYED, - this.player.id(), - ); - - this.active = false; + unit.markForDeletion(); } - tick(ticks: number) {} + tick(ticks: number) { + if (!this.active || !this.unit) { + return; + } + if (!this.unit.isActive()) { + this.active = false; + return; + } + if (this.unit.isOverdueDeletion()) { + this.unit.delete(false); + + this.mg.displayMessage( + `events_display.unit_voluntarily_deleted`, + MessageType.UNIT_DESTROYED, + this.player.id(), + ); + this.active = false; + } + } isActive(): boolean { return this.active; diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts index 1d83f1c77..b0d575a30 100644 --- a/src/core/execution/UpgradeStructureExecution.ts +++ b/src/core/execution/UpgradeStructureExecution.ts @@ -19,7 +19,7 @@ export class UpgradeStructureExecution implements Execution { return; } - if (!this.player.canUpgradeUnit(this.structure.type())) { + if (!this.player.canUpgradeUnit(this.structure)) { console.warn( `[UpgradeStructureExecution] unit type ${this.structure.type()} cannot be upgraded`, ); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index db48e0a2f..2e490b1ef 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -432,6 +432,9 @@ export interface Unit { type(): UnitType; owner(): Player; info(): UnitInfo; + isMarkedForDeletion(): boolean; + markForDeletion(): void; + isOverdueDeletion(): boolean; delete(displayMessage?: boolean, destroyer?: Player): void; tile(): TileRef; lastTile(): TileRef; @@ -573,7 +576,7 @@ export interface Player { // New units of the same type can upgrade existing units. // e.g. if a place a new city here, can it upgrade an existing city? findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false; - canUpgradeUnit(unitType: UnitType): boolean; + canUpgradeUnit(unit: Unit): boolean; upgradeUnit(unit: Unit): void; captureUnit(unit: Unit): void; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index f117e210a..922212923 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -123,6 +123,7 @@ export interface UnitUpdate { reachedTarget: boolean; retreating: boolean; targetable: boolean; + markedForDeletion: number | false; targetUnitId?: number; // Only for trade ships targetTile?: TileRef; // Only for nukes health?: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d6ed3730d..ccceacef9 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -87,6 +87,10 @@ export class UnitView { return this.data.targetable; } + markedForDeletion(): number | false { + return this.data.markedForDeletion; + } + type(): UnitType { return this.data.unitType; } @@ -430,10 +434,13 @@ export class PlayerView { return this.data.lastDeleteUnitTick; } - canDeleteUnit(): boolean { + deleteUnitCooldown(): number { return ( - this.game.ticks() + 1 - this.lastDeleteUnitTick() >= - this.game.config().deleteUnitCooldown() + Math.max( + 0, + this.game.config().deleteUnitCooldown() - + (this.game.ticks() + 1 - this.lastDeleteUnitTick()), + ) / 10 ); } } @@ -573,7 +580,7 @@ export class GameView implements GameMap { tile: TileRef, searchRange: number, type: UnitType, - playerId: PlayerID, + playerId?: PlayerID, ) { return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId); } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index d9ba8ba7c..fb531f94e 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -853,20 +853,23 @@ export class PlayerImpl implements Player { return false; } const unit = existing[0].unit; - if (!this.canUpgradeUnit(unit.type())) { + if (!this.canUpgradeUnit(unit)) { return false; } return unit; } - public canUpgradeUnit(unitType: UnitType): boolean { - if (!this.mg.config().unitInfo(unitType).upgradable) { + public canUpgradeUnit(unit: Unit): boolean { + if (unit.isMarkedForDeletion()) { return false; } - if (this.mg.config().isUnitDisabled(unitType)) { + if (!this.mg.config().unitInfo(unit.type()).upgradable) { return false; } - if (this._gold < this.mg.config().unitInfo(unitType).cost(this)) { + if (this.mg.config().isUnitDisabled(unit.type())) { + return false; + } + if (this._gold < this.mg.config().unitInfo(unit.type()).cost(this)) { return false; } return true; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index fc1eab97a..917d0af67 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -39,6 +39,7 @@ export class UnitImpl implements Unit { // Nuke only private _trajectoryIndex: number = 0; private _trajectory: TrajectoryTile[]; + private _deletionAt: number | null = null; constructor( private _type: UnitType, @@ -126,6 +127,7 @@ export class UnitImpl implements Unit { reachedTarget: this._reachedTarget, retreating: this._retreating, pos: this._tile, + markedForDeletion: this._deletionAt ?? false, targetable: this._targetable, lastPos: this._lastTile, health: this.hasHealth() ? Number(this._health) : undefined, @@ -182,6 +184,7 @@ export class UnitImpl implements Unit { } setOwner(newOwner: PlayerImpl): void { + this.clearPendingDeletion(); switch (this._type) { case UnitType.Warship: case UnitType.Port: @@ -221,6 +224,30 @@ export class UnitImpl implements Unit { } } + clearPendingDeletion(): void { + this._deletionAt = null; + } + + isMarkedForDeletion(): boolean { + return this._deletionAt !== null; + } + + markForDeletion(): void { + if (!this.isActive()) { + return; + } + this._deletionAt = + this.mg.ticks() + this.mg.config().deletionMarkDuration(); + this.mg.addUpdate(this.toUpdate()); + } + + isOverdueDeletion(): boolean { + if (!this.isActive()) { + return false; + } + return this._deletionAt !== null && this.mg.ticks() - this._deletionAt > 0; + } + delete(displayMessage?: boolean, destroyer?: Player): void { if (!this.isActive()) { throw new Error(`cannot delete ${this} not active`); diff --git a/tests/DeleteUnitExecution.test.ts b/tests/DeleteUnitExecution.test.ts index c8486a1fe..a931c140b 100644 --- a/tests/DeleteUnitExecution.test.ts +++ b/tests/DeleteUnitExecution.test.ts @@ -10,6 +10,7 @@ import { } from "../src/core/game/Game"; import { TileRef } from "../src/core/game/GameMap"; import { setup } from "./util/Setup"; +import { executeTicks } from "./util/utils"; describe("DeleteUnitExecution Security Tests", () => { let game: Game; @@ -79,6 +80,7 @@ describe("DeleteUnitExecution Security Tests", () => { execution.init(game, 0); expect(execution.isActive()).toBe(false); + expect(enemyUnit.isMarkedForDeletion()).toBe(false); }); it("should prevent deleting units on enemy territory", () => { @@ -90,6 +92,7 @@ describe("DeleteUnitExecution Security Tests", () => { execution.init(game, 0); expect(execution.isActive()).toBe(false); + expect(unit.isMarkedForDeletion()).toBe(false); } }); @@ -100,15 +103,7 @@ describe("DeleteUnitExecution Security Tests", () => { execution.init(game, 0); expect(execution.isActive()).toBe(false); - }); - - it("should allow deleting the last city (suicide)", () => { - jest.spyOn(game, "inSpawnPhase").mockReturnValue(false); - - const execution = new DeleteUnitExecution(player, unit.id()); - execution.init(game, 0); - - expect(unit.isActive()).toBe(false); + expect(unit.isMarkedForDeletion()).toBe(false); }); it("should allow deleting units when all conditions are met", () => { @@ -117,7 +112,32 @@ describe("DeleteUnitExecution Security Tests", () => { const execution = new DeleteUnitExecution(player, unit.id()); execution.init(game, 0); + expect(unit.isMarkedForDeletion()).toBe(true); + }); + + it("should delete after deletion delay", () => { + jest.spyOn(game, "inSpawnPhase").mockReturnValue(false); + + const execution = new DeleteUnitExecution(player, unit.id()); + game.addExecution(execution); + + game.executeNextTick(); + expect(unit.isMarkedForDeletion()).toBe(true); + expect(unit.isOverdueDeletion()).toBe(false); + executeTicks(game, game.config().deletionMarkDuration() + 1); expect(unit.isActive()).toBe(false); }); + + it("should reset deletion if captured", () => { + jest.spyOn(game, "inSpawnPhase").mockReturnValue(false); + + const execution = new DeleteUnitExecution(player, unit.id()); + game.addExecution(execution); + game.executeNextTick(); + expect(unit.isMarkedForDeletion()).toBe(true); + unit.setOwner(enemyPlayer); + expect(unit.isMarkedForDeletion()).toBe(false); + expect(unit.isActive()).toBe(true); + }); }); }); diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts index f0b10ac37..252f10bdd 100644 --- a/tests/util/TestConfig.ts +++ b/tests/util/TestConfig.ts @@ -46,6 +46,10 @@ export class TestConfig extends DefaultConfig { return 20; } + deletionMarkDuration(): number { + return 5; + } + defaultSamRange(): number { return 20; } From 4ada4c737574bafb1effe849ae4a8e4357de77f0 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 21 Oct 2025 14:08:07 -0700 Subject: [PATCH 13/24] feature: basic matchmaking (#2227) ## Description: Implement a basic matchmaking modal that connects to the api service and waits for a game id. It then waits until the game starts and connects to it. Workers use long polling to check in with the matchmaking server and receive player assignments. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- resources/lang/en.json | 6 + src/client/Main.ts | 16 +- src/client/Matchmaking.ts | 193 ++++++++++++++++++++++++ src/client/index.html | 3 + src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 3 + src/server/MapPlaylist.ts | 8 +- src/server/Master.ts | 5 - src/server/Worker.ts | 91 ++++++++++- tests/util/TestServerConfig.ts | 3 + 10 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 src/client/Matchmaking.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index cecb533a4..e3a47ef60 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -222,6 +222,12 @@ "teams_Quads": "Quads (teams of 4)", "teams": "{num} teams" }, + "matchmaking_modal": { + "title": "Matchmaking", + "connecting": "Connecting to matchmaking server...", + "searching": "Searching for game...", + "waiting_for_game": "Waiting for game to start..." + }, "username": { "enter_username": "Enter your username", "not_string": "Username must be a string.", diff --git a/src/client/Main.ts b/src/client/Main.ts index 2d61e78b6..81ff4550b 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -22,6 +22,8 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal"; import "./LangSelector"; import { LangSelector } from "./LangSelector"; import { LanguageModal } from "./LanguageModal"; +import "./Matchmaking"; +import { MatchmakingModal } from "./Matchmaking"; import { NewsModal } from "./NewsModal"; import "./PublicLobby"; import { PublicLobby } from "./PublicLobby"; @@ -100,6 +102,7 @@ class Client { private userSettings: UserSettings = new UserSettings(); private patternsModal: TerritoryPatternsModal; private tokenLoginModal: TokenLoginModal; + private matchmakingModal: MatchmakingModal; private gutterAds: GutterAds; @@ -256,6 +259,16 @@ class Client { console.warn("Token login modal element not found"); } + this.matchmakingModal = document.querySelector( + "matchmaking-modal", + ) as MatchmakingModal; + if ( + !this.matchmakingModal || + !(this.matchmakingModal instanceof MatchmakingModal) + ) { + console.warn("Matchmaking modal element not found"); + } + const onUserMe = async (userMeResponse: UserMeResponse | false) => { document.dispatchEvent( new CustomEvent("userMeResponse", { @@ -598,6 +611,7 @@ class Client { "flag-input-modal", "account-button", "token-login", + "matchmaking-modal", ].forEach((tag) => { const modal = document.querySelector(tag) as HTMLElement & { close?: () => void; @@ -697,7 +711,7 @@ document.addEventListener("DOMContentLoaded", () => { }); // WARNING: DO NOT EXPOSE THIS ID -function getPlayToken(): string { +export function getPlayToken(): string { const result = isLoggedIn(); if (result !== false) return result.token; return getPersistentIDFromCookie(); diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts new file mode 100644 index 000000000..e9118f790 --- /dev/null +++ b/src/client/Matchmaking.ts @@ -0,0 +1,193 @@ +import { html, LitElement } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { generateID } from "../core/Util"; +import "./components/Difficulties"; +import "./components/PatternButton"; +import { getPlayToken, JoinLobbyEvent } from "./Main"; +import { translateText } from "./Utils"; + +@customElement("matchmaking-modal") +export class MatchmakingModal extends LitElement { + private gameCheckInterval: ReturnType | null = null; + private connected = false; + @state() private socket: WebSocket | null = null; + + @state() private gameID: string | null = null; + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + constructor() { + super(); + } + + createRenderRoot() { + return this; + } + + render() { + return html` + + ${this.renderInner()} + + `; + } + + private renderInner() { + if (!this.connected) { + return html`${translateText("matchmaking_modal.connecting")}`; + } + if (this.gameID === null) { + return html`${translateText("matchmaking_modal.searching")}`; + } else { + return html`${translateText("matchmaking_modal.waiting_for_game")}`; + } + } + + private async connect() { + const config = await getServerConfigFromClient(); + + this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`); + this.socket.onopen = () => { + console.log("Connected to matchmaking server"); + setTimeout(() => { + // Set a delay so the user can see the "connecting" message, + // otherwise the "searching" message will be shown immediately. + this.connected = true; + this.requestUpdate(); + }, 1000); + this.socket?.send( + JSON.stringify({ + type: "auth", + playToken: getPlayToken(), + }), + ); + }; + this.socket.onmessage = (event) => { + console.log(event.data); + const data = JSON.parse(event.data); + if (data.type === "match-assignment") { + this.socket?.close(); + console.log(`matchmaking: got game ID: ${data.gameId}`); + this.gameID = data.gameId; + } + }; + this.socket.onerror = (event: ErrorEvent) => { + console.error("WebSocket error occurred:", event); + }; + this.socket.onclose = (event) => { + console.log("Matchmaking server closed connection"); + }; + } + + public close() { + this.connected = false; + this.socket?.close(); + this.modalEl?.close(); + if (this.gameCheckInterval) { + clearInterval(this.gameCheckInterval); + this.gameCheckInterval = null; + } + } + + public async open() { + this.modalEl?.open(); + this.requestUpdate(); + this.connect(); + this.gameCheckInterval = setInterval(() => this.checkGame(), 3000); + } + + private async checkGame() { + if (this.gameID === null) { + return; + } + const config = await getServerConfigFromClient(); + const url = `/${config.workerPath(this.gameID)}/api/game/${this.gameID}/exists`; + + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + const gameInfo = await response.json(); + + if (response.status !== 200) { + console.error(`Error checking game ${this.gameID}: ${response.status}`); + return; + } + + if (!gameInfo.exists) { + console.info(`Game ${this.gameID} does not exist or hasn't started yet`); + return; + } + + if (this.gameCheckInterval) { + clearInterval(this.gameCheckInterval); + this.gameCheckInterval = null; + } + + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: this.gameID, + clientID: generateID(), + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + } +} + +@customElement("matchmaking-button") +export class MatchmakingButton extends LitElement { + @query("matchmaking-modal") private matchmakingModal: MatchmakingModal; + @state() private matchmakingEnabled = false; + + constructor() { + super(); + } + + async connectedCallback() { + super.connectedCallback(); + const config = await getServerConfigFromClient(); + this.matchmakingEnabled = config.enableMatchmaking(); + } + + createRenderRoot() { + return this; + } + + render() { + if (!this.matchmakingEnabled) { + return html``; + } + + return html` +
+ +
+ + `; + } + + private open() { + this.matchmakingModal?.open(); + } + + public close() { + this.matchmakingModal?.close(); + this.requestUpdate(); + } +} diff --git a/src/client/index.html b/src/client/index.html index f103179ed..7a765995c 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -221,6 +221,9 @@
+
+ +
{ - return new Promise((resolve) => setTimeout(resolve, ms)); -} - // SPA fallback route app.get("*", function (req, res) { res.sendFile(path.join(__dirname, "../../static/index.html")); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 9406f4fe3..212f1bcf0 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -11,11 +11,12 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { ClientMessageSchema, + GameID, ID, PartialGameRecordSchema, ServerErrorMessage, } from "../core/Schemas"; -import { replacer } from "../core/Util"; +import { generateID, replacer } from "../core/Util"; import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; @@ -23,6 +24,7 @@ import { GameManager } from "./GameManager"; import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; +import { MapPlaylist } from "./MapPlaylist"; import { PrivilegeRefresher } from "./PrivilegeRefresher"; import { initWorkerMetrics } from "./WorkerMetrics"; @@ -30,11 +32,24 @@ const config = getServerConfigFromServer(); const workerId = parseInt(process.env.WORKER_ID ?? "0"); const log = logger.child({ comp: `w_${workerId}` }); +const playlist = new MapPlaylist(true); // Worker setup export async function startWorker() { log.info(`Worker starting...`); + if (config.enableMatchmaking()) { + log.info("Starting matchmaking"); + setTimeout( + () => { + pollLobby(gm); + }, + 1000 + Math.random() * 2000, + ); + } else { + log.info("Matchmaking disabled"); + } + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -450,3 +465,77 @@ export async function startWorker() { log.error(`unhandled rejection at:`, promise, "reason:", reason); }); } + +async function pollLobby(gm: GameManager) { + try { + const url = `${config.jwtIssuer() + "/matchmaking/checkin"}`; + const gameId = generateGameIdForWorker(); + if (gameId === null) { + log.warn(`Failed to generate game ID for worker ${workerId}`); + return; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 20000); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": config.apiKey(), + }, + body: JSON.stringify({ + id: workerId, + gameId: gameId, + ccu: gm.activeClients(), + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + log.warn( + `Failed to poll lobby: ${response.status} ${response.statusText}`, + ); + return; + } + + const data = await response.json(); + log.info(`Lobby poll successful:`, data); + + if (data.assignment) { + // TODO: Only allow specified players to join the game. + console.log(`Creating game ${gameId}`); + const game = gm.createGame(gameId, playlist.gameConfig()); + setTimeout(() => { + // Wait a few seconds to allow clients to connect. + console.log(`Starting game ${gameId}`); + game.start(); + }, 5000); + } + } catch (error) { + log.error(`Error polling lobby:`, error); + } finally { + setTimeout( + () => { + pollLobby(gm); + }, + 5000 + Math.random() * 1000, + ); + } +} + +// TODO: This is a hack to generate a game ID for the worker. +// It should be replaced with a more robust solution. +function generateGameIdForWorker(): GameID | null { + let attempts = 1000; + while (attempts > 0) { + const gameId = generateID(); + if (workerId === config.workerIndex(gameId)) { + return gameId; + } + attempts--; + } + log.warn(`Failed to generate game ID for worker ${workerId}`); + return null; +} diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 6488dc99b..6f20fa1cd 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + enableMatchmaking(): boolean { + throw new Error("Method not implemented."); + } apiKey(): string { throw new Error("Method not implemented."); } From 0f09bd3aa949fdf29f059b863dd4f9b1cf67e52c Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 21 Oct 2025 16:17:33 -0700 Subject: [PATCH 14/24] bugfix: check if modal is not null before checking if it contains isModalOpen --- src/client/Main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 81ff4550b..17df5a4c2 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -619,7 +619,7 @@ class Client { }; if (modal?.close) { modal.close(); - } else if ("isModalOpen" in modal) { + } else if (modal && "isModalOpen" in modal) { modal.isModalOpen = false; } }); From 8e278a5fd860df6bc58cbc90515c444f5054f1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Mart=C3=ADnek?= <96337712+Michal-Martinek@users.noreply.github.com> Date: Thu, 23 Oct 2025 04:07:40 +0200 Subject: [PATCH 15/24] Made leaderboard entries bold for teammates (#2221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Closes #2185. Made leaderboard entries **bold** all your for **teamates**. Before it was only bold for your player. _Your player should still be bold even when is undefined._ Didn't add any tests - not sure if there is any testing for these type of things. ### collapsed leaderboard list Snímek obrazovky 2025-10-22 182432 ### expanded leaderboard Snímek obrazovky 2025-10-22 182539 ### player not yet spawned Snímek obrazovky 2025-10-22 183858 ## 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 - [ ] 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: michal7952 --- src/client/graphics/layers/Leaderboard.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 30e0539d5..8d6a32d6e 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -14,6 +14,7 @@ interface Entry { gold: string; troops: string; isMyPlayer: boolean; + isOnSameTeam: boolean; player: PlayerView; } @@ -115,6 +116,7 @@ export class Leaderboard extends LitElement implements Layer { gold: renderNumber(player.gold()), troops: renderNumber(troops), isMyPlayer: player === myPlayer, + isOnSameTeam: player === myPlayer || player.isOnSameTeam(myPlayer!), player: player, }; }); @@ -143,6 +145,7 @@ export class Leaderboard extends LitElement implements Layer { gold: renderNumber(myPlayer.gold()), troops: renderNumber(myPlayerTroops), isMyPlayer: true, + isOnSameTeam: true, player: myPlayer, }); } @@ -225,7 +228,7 @@ export class Leaderboard extends LitElement implements Layer { (p) => p.player.id(), (player) => html`
this.handleRowClickPlayer(player.player)} From dd9ad7472f68a681a2fba141d6e2c43b370ae8cd Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 23 Oct 2025 15:02:13 -0700 Subject: [PATCH 16/24] update header ads (#2266) ## Description: 1. Remove SpawnAds and replace it with AdTimer which will delete the in-game ad after the first minute. 2. remove login blocker UI, we don't use it anymore 3. convert TerritoryPatternsModal & GutterAds to use event based when checking for flares 4. remove window.PageOS.session.newPageView(); because it was throwing an exception 5. Convert SpawnTimer to a lit element to give it a higher z-index to stay above the header ad ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/Cosmetics.ts | 5 +- src/client/GutterAds.ts | 32 +++++- src/client/Main.ts | 113 +------------------ src/client/TerritoryPatternsModal.ts | 22 +++- src/client/components/PatternButton.ts | 1 - src/client/graphics/GameRenderer.ts | 19 ++-- src/client/graphics/layers/AdTimer.ts | 28 +++++ src/client/graphics/layers/SpawnAd.ts | 135 ----------------------- src/client/graphics/layers/SpawnTimer.ts | 74 +++++++++---- src/client/graphics/layers/WinModal.ts | 7 +- src/client/index.html | 9 +- 11 files changed, 153 insertions(+), 292 deletions(-) create mode 100644 src/client/graphics/layers/AdTimer.ts delete mode 100644 src/client/graphics/layers/SpawnAd.ts diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index c0a501d8b..8eeb1f74d 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -74,10 +74,11 @@ export async function fetchCosmetics(): Promise { export function patternRelationship( pattern: Pattern, colorPalette: { name: string; isArchived?: boolean } | null, - userMeResponse: UserMeResponse | null, + userMeResponse: UserMeResponse | false, affiliateCode: string | null, ): "owned" | "purchasable" | "blocked" { - const flares = userMeResponse?.player.flares ?? []; + const flares = + userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); if (flares.includes("pattern:*")) { return "owned"; } diff --git a/src/client/GutterAds.ts b/src/client/GutterAds.ts index b47f2939b..caa33623a 100644 --- a/src/client/GutterAds.ts +++ b/src/client/GutterAds.ts @@ -1,5 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { UserMeResponse } from "../core/ApiSchemas"; import { isInIframe } from "./Utils"; const LEFT_FUSE = "gutter-ad-container-left"; @@ -17,6 +18,31 @@ export class GutterAds extends LitElement { return this; } + private readonly boundUserMeHandler = (event: Event) => + this.onUserMe((event as CustomEvent).detail); + + connectedCallback() { + super.connectedCallback(); + document.addEventListener( + "userMeResponse", + this.boundUserMeHandler as EventListener, + ); + } + + private onUserMe(userMeResponse: UserMeResponse | false): void { + const flares = + userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); + const hasFlare = flares.some((flare) => flare.startsWith("pattern:")); + if (hasFlare) { + console.log("No ads because you have patterns"); + window.enableAds = false; + } else { + console.log("No flares, showing ads"); + this.show(); + window.enableAds = true; + } + } + private isScreenLargeEnough(): boolean { return window.innerWidth >= MIN_SCREEN_WIDTH; } @@ -52,6 +78,10 @@ export class GutterAds extends LitElement { this.isVisible = false; console.log("hiding GutterAds"); this.destroyAds(); + document.removeEventListener( + "userMeResponse", + this.boundUserMeHandler as EventListener, + ); this.requestUpdate(); } @@ -95,7 +125,7 @@ export class GutterAds extends LitElement { disconnectedCallback() { super.disconnectedCallback(); - this.destroyAds(); + this.hide(); } render() { diff --git a/src/client/Main.ts b/src/client/Main.ts index 2d61e78b6..e6297b63c 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -2,7 +2,6 @@ import version from "../../resources/version.txt"; import { UserMeResponse } from "../core/ApiSchemas"; import { EventBus } from "../core/EventBus"; import { GameRecord, GameStartInfo, ID } from "../core/Schemas"; -import { ServerConfig } from "../core/configuration/Config"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; @@ -36,17 +35,17 @@ import { generateCryptoRandomUUID, incrementGamesPlayed, isInIframe, - translateText, } from "./Utils"; import "./components/NewsButton"; import { NewsButton } from "./components/NewsButton"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; -import { discordLogin, getUserMe, isLoggedIn } from "./jwt"; +import { getUserMe, isLoggedIn } from "./jwt"; import "./styles.css"; declare global { interface Window { + enableAds: boolean; PageOS: { session: { newPageView: () => void; @@ -57,6 +56,7 @@ declare global { destroyZone: (id: string) => void; pageInit: (options?: any) => void; que: Array<() => void>; + destroySticky: () => void; }; ramp: { que: Array<() => void>; @@ -265,98 +265,12 @@ class Client { }), ); - const config = await getServerConfigFromClient(); - if (!hasAllowedFlare(userMeResponse, config)) { - if (userMeResponse === false) { - // Login is required - document.body.innerHTML = ` -
-
-

${translateText("auth.login_required")}

-

${translateText("auth.redirecting")}

-
-
-
-
-
-
- - `; - setTimeout(discordLogin, 5000); - } else { - // Unauthorized - document.body.innerHTML = ` -
-
-

${translateText("auth.not_authorized")}

-

${translateText("auth.contact_admin")}

-
-
-
- `; - } - return; - } else if (userMeResponse === false) { - // Not logged in - this.patternsModal.onUserMe(null); - } else { + if (userMeResponse !== false) { // Authorized console.log( `Your player ID is ${userMeResponse.player.publicId}\n` + "Sharing this ID will allow others to view your game history and stats.", ); - this.patternsModal.onUserMe(userMeResponse); - const flares = (userMeResponse.player.flares ?? []).filter((flare) => - flare.startsWith("pattern:"), - ); - if (flares.length > 0) { - console.log("Hiding gutter ads because you have patterns"); - this.gutterAds.hide(); - } } }; @@ -628,12 +542,6 @@ class Client { this.publicLobby.stop(); incrementGamesPlayed(); - try { - window.PageOS.session.newPageView(); - } catch (e) { - console.error("Error calling newPageView", e); - } - document.querySelectorAll(".ad").forEach((ad) => { (ad as HTMLElement).style.display = "none"; }); @@ -675,7 +583,6 @@ class Client { window.fusetag.pageInit({ blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"], }); - this.gutterAds.show(); }); return true; } else { @@ -735,15 +642,3 @@ function getPersistentIDFromCookie(): string { return newID; } - -function hasAllowedFlare( - userMeResponse: UserMeResponse | false, - config: ServerConfig, -) { - const allowed = config.allowedFlares(); - if (allowed === undefined) return true; - if (userMeResponse === false) return false; - const flares = userMeResponse.player.flares; - if (flares === undefined) return false; - return allowed.length === 0 || allowed.some((f) => flares.includes(f)); -} diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index baf75e7be..f13faf670 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -37,14 +37,24 @@ export class TerritoryPatternsModal extends LitElement { private affiliateCode: string | null = null; - private userMeResponse: UserMeResponse | null = null; + private userMeResponse: UserMeResponse | false = false; constructor() { super(); } - async onUserMe(userMeResponse: UserMeResponse | null) { - if (userMeResponse === null) { + connectedCallback() { + super.connectedCallback(); + document.addEventListener( + "userMeResponse", + (event: CustomEvent) => { + this.onUserMe(event.detail); + }, + ); + } + + async onUserMe(userMeResponse: UserMeResponse | false) { + if (userMeResponse === false) { this.userSettings.setSelectedPatternName(undefined); this.selectedPattern = null; this.selectedColor = null; @@ -136,7 +146,11 @@ export class TerritoryPatternsModal extends LitElement { } private renderColorSwatchGrid(): TemplateResult { - const hexCodes = (this.userMeResponse?.player.flares ?? []) + const hexCodes = ( + this.userMeResponse === false + ? [] + : (this.userMeResponse.player.flares ?? []) + ) .filter((flare) => flare.startsWith("color:")) .map((flare) => "#" + flare.split(":")[1]); return html` diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index 1b7146b13..9c35347e1 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -137,7 +137,6 @@ export function renderPatternPreview( width: number, height: number, ): TemplateResult { - console.log("renderPatternPreview", pattern); if (pattern === null) { return renderBlankPreview(width, height); } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 4491dede9..8b8080dcb 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -5,6 +5,7 @@ import { GameStartingModal } from "../GameStartingModal"; import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; +import { AdTimer } from "./layers/AdTimer"; import { AlertFrame } from "./layers/AlertFrame"; import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; @@ -27,7 +28,6 @@ import { PlayerPanel } from "./layers/PlayerPanel"; import { RailroadLayer } from "./layers/RailroadLayer"; import { ReplayPanel } from "./layers/ReplayPanel"; import { SettingsModal } from "./layers/SettingsModal"; -import { SpawnAd } from "./layers/SpawnAd"; import { SpawnTimer } from "./layers/SpawnTimer"; import { StructureIconsLayer } from "./layers/StructureIconsLayer"; import { StructureLayer } from "./layers/StructureLayer"; @@ -209,18 +209,19 @@ export function createRenderer( fpsDisplay.eventBus = eventBus; fpsDisplay.userSettings = userSettings; - const spawnAd = document.querySelector("spawn-ad") as SpawnAd; - if (!(spawnAd instanceof SpawnAd)) { - console.error("spawn ad not found"); - } - spawnAd.g = game; - const alertFrame = document.querySelector("alert-frame") as AlertFrame; if (!(alertFrame instanceof AlertFrame)) { console.error("alert frame not found"); } alertFrame.game = game; + const spawnTimer = document.querySelector("spawn-timer") as SpawnTimer; + if (!(spawnTimer instanceof SpawnTimer)) { + console.error("spawn timer not found"); + } + spawnTimer.game = game; + spawnTimer.transformHandler = transformHandler; + // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -246,7 +247,7 @@ export function createRenderer( uiState, playerPanel, ), - new SpawnTimer(game, transformHandler), + spawnTimer, leaderboard, gameLeftSidebar, unitDisplay, @@ -260,7 +261,7 @@ export function createRenderer( playerPanel, headsUpMessage, multiTabModal, - spawnAd, + new AdTimer(game), alertFrame, fpsDisplay, ]; diff --git a/src/client/graphics/layers/AdTimer.ts b/src/client/graphics/layers/AdTimer.ts new file mode 100644 index 000000000..4184e6a43 --- /dev/null +++ b/src/client/graphics/layers/AdTimer.ts @@ -0,0 +1,28 @@ +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +const AD_SHOW_TICKS = 60 * 10; // 1 minute + +export class AdTimer implements Layer { + private isHidden: boolean = false; + + constructor(private g: GameView) {} + + init() {} + + public async tick() { + if (this.isHidden) { + return; + } + + const gameTicks = this.g.ticks() - this.g.config().numSpawnPhaseTurns(); + if (gameTicks > AD_SHOW_TICKS) { + console.log("destroying sticky ads"); + window.fusetag?.que?.push(() => { + window.fusetag?.destroySticky?.(); + }); + this.isHidden = true; + return; + } + } +} diff --git a/src/client/graphics/layers/SpawnAd.ts b/src/client/graphics/layers/SpawnAd.ts deleted file mode 100644 index f8f39294f..000000000 --- a/src/client/graphics/layers/SpawnAd.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { LitElement, css, html } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import { translateText } from "../../../client/Utils"; -import { GameView } from "../../../core/game/GameView"; -import { getGamesPlayed } from "../../Utils"; -import { Layer } from "./Layer"; - -const AD_TYPE = "bottom_rail"; -const AD_CONTAINER_ID = "bottom-rail-ad-container"; - -@customElement("spawn-ad") -export class SpawnAd extends LitElement implements Layer { - public g: GameView; - - @state() - private isVisible: boolean = false; - - @state() - private adLoaded: boolean = false; - - private gamesPlayed: number = 0; - - // Override createRenderRoot to disable shadow DOM - createRenderRoot() { - return this; - } - - static styles = css``; - - constructor() { - super(); - } - - init() { - this.gamesPlayed = getGamesPlayed(); - } - - public show(): void { - this.isVisible = true; - this.loadAd(); - this.requestUpdate(); - } - - public hide(): void { - // Destroy the ad when hiding - this.destroyAd(); - this.isVisible = false; - this.adLoaded = false; - this.requestUpdate(); - } - - public async tick() { - if ( - !this.isVisible && - this.g.inSpawnPhase() && - this.g.ticks() > 10 && - this.gamesPlayed > 5 - ) { - console.log("not showing spawn ad"); - // this.show(); - } - if (this.isVisible && !this.g.inSpawnPhase()) { - console.log("hiding bottom left ad"); - this.hide(); - } - } - - private loadAd(): void { - if (!window.ramp) { - console.warn("Playwire RAMP not available"); - return; - } - if (this.adLoaded) { - console.log("Ad already loaded, skipping"); - return; - } - try { - window.ramp.que.push(() => { - window.ramp.spaAddAds([ - { - type: AD_TYPE, - selectorId: AD_CONTAINER_ID, - }, - ]); - this.adLoaded = true; - console.log("Playwire ad loaded:", AD_TYPE); - }); - } catch (error) { - console.error("Failed to load Playwire ad:", error); - } - } - - private destroyAd(): void { - if (!window.ramp || !this.adLoaded) { - return; - } - try { - window.ramp.que.push(() => { - window.ramp.destroyUnits("all"); - console.log("Playwire spawn ad destroyed"); - }); - } catch (error) { - console.error("Failed to destroy Playwire ad:", error); - } - } - - disconnectedCallback() { - super.disconnectedCallback(); - // Clean up ad when component is removed - this.destroyAd(); - } - - render() { - if (!this.isVisible) { - return html``; - } - - return html` -
-
- ${!this.adLoaded - ? html`${translateText("spawn_ad.loading")}` - : ""} -
-
- `; - } -} diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts index da96c89a1..393cf96d4 100644 --- a/src/client/graphics/layers/SpawnTimer.ts +++ b/src/client/graphics/layers/SpawnTimer.ts @@ -1,18 +1,34 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; import { GameMode, Team } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; -export class SpawnTimer implements Layer { +@customElement("spawn-timer") +export class SpawnTimer extends LitElement implements Layer { + public game: GameView; + public transformHandler: TransformHandler; + private ratios = [0]; private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"]; - constructor( - private game: GameView, - private transformHandler: TransformHandler, - ) {} + private isVisible = false; - init() {} + createRenderRoot() { + this.style.position = "fixed"; + this.style.top = "0"; + this.style.left = "0"; + this.style.width = "100%"; + this.style.height = "7px"; + this.style.zIndex = "1000"; + this.style.pointerEvents = "none"; + return this; + } + + init() { + this.isVisible = true; + } tick() { if (this.game.inSpawnPhase()) { @@ -21,6 +37,7 @@ export class SpawnTimer implements Layer { this.game.ticks() / this.game.config().numSpawnPhaseTurns(), ]; this.colors = ["rgba(0, 128, 255, 0.7)"]; + this.requestUpdate(); return; } @@ -28,6 +45,7 @@ export class SpawnTimer implements Layer { this.colors = []; if (this.game.config().gameConfig().gameMode !== GameMode.Team) { + this.requestUpdate(); return; } @@ -41,44 +59,52 @@ export class SpawnTimer implements Layer { const theme = this.game.config().theme(); const total = sumIterator(teamTiles.values()); - if (total === 0) return; + if (total === 0) { + this.requestUpdate(); + return; + } for (const [team, count] of teamTiles) { const ratio = count / total; this.ratios.push(ratio); this.colors.push(theme.teamColor(team).toRgbString()); } + this.requestUpdate(); } shouldTransform(): boolean { return false; } - renderLayer(context: CanvasRenderingContext2D) { - if (this.ratios.length === 0 || this.colors.length === 0) return; + render() { + if (!this.isVisible) { + return html``; + } - const barHeight = 10; - const barWidth = this.transformHandler.width(); + if (this.ratios.length === 0 || this.colors.length === 0) { + return html``; + } if ( !this.game.inSpawnPhase() && this.game.config().gameConfig().gameMode !== GameMode.Team ) { - return; + return html``; } - let x = 0; - let filledRatio = 0; - for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) { - const ratio = this.ratios[i] ?? 1 - filledRatio; - const segmentWidth = barWidth * ratio; - - context.fillStyle = this.colors[i]; - context.fillRect(x, 0, segmentWidth, barHeight); - - x += segmentWidth; - filledRatio += ratio; - } + return html` +
+ ${this.ratios.map((ratio, i) => { + const color = this.colors[i] || "rgba(0, 0, 0, 0.5)"; + return html` +
+ `; + })} +
+ `; } } diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 809eb5465..3e042afe1 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -132,12 +132,7 @@ export class WinModal extends LitElement implements Layer { for (const pattern of Object.values(patterns?.patterns ?? {})) { for (const colorPalette of pattern.colorPalettes ?? []) { if ( - patternRelationship( - pattern, - colorPalette, - me !== false ? me : null, - null, - ) === "purchasable" + patternRelationship(pattern, colorPalette, me, null) === "purchasable" ) { const palette = patterns?.colorPalettes?.[colorPalette.name]; if (palette) { diff --git a/src/client/index.html b/src/client/index.html index 2dcf835e7..ae46d19e8 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -97,6 +97,13 @@ src="https://cdn.fuseplatform.net/publift/tags/2/4121/fuse.js" > + +