From 2ec12f0a3ab1f4e416a47d93c78de7fe915c8624 Mon Sep 17 00:00:00 2001
From: Mattia Migliorini
Date: Fri, 20 Mar 2026 18:56:36 +0100
Subject: [PATCH 1/5] Auto-reject alliance request when transport ship is sent
to target player (#3477)
## Description:
When a player sends a transport ship toward another player's territory,
any pending alliance request from the target is now automatically
rejected.
This mirrors the behavior already in place for direct attacks,
preventing a player from exploiting a pending alliance request while
launching a naval invasion.
## 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:
deshack_82603
---
src/core/execution/TransportShipExecution.ts | 20 ++++++++
tests/Attack.test.ts | 48 ++++++++++++++++++++
2 files changed, 68 insertions(+)
diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts
index 3504e691c..e24a9ac3b 100644
--- a/src/core/execution/TransportShipExecution.ts
+++ b/src/core/execution/TransportShipExecution.ts
@@ -4,6 +4,7 @@ import {
Game,
MessageType,
Player,
+ PlayerType,
TerraNullius,
Unit,
UnitType,
@@ -76,6 +77,16 @@ export class TransportShipExecution implements Execution {
return;
}
+ if (this.target.isPlayer()) {
+ const targetPlayer = this.target as Player;
+ if (
+ targetPlayer.type() !== PlayerType.Bot &&
+ this.attacker.type() !== PlayerType.Bot
+ ) {
+ this.rejectIncomingAllianceRequests(targetPlayer);
+ }
+ }
+
if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) {
this.active = false;
return;
@@ -290,4 +301,13 @@ export class TransportShipExecution implements Execution {
isActive(): boolean {
return this.active;
}
+
+ private rejectIncomingAllianceRequests(target: Player) {
+ const request = this.attacker
+ .incomingAllianceRequests()
+ .find((ar) => ar.requestor() === target);
+ if (request !== undefined) {
+ request.reject();
+ }
+ }
}
diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts
index dbf05b7c2..5f741559f 100644
--- a/tests/Attack.test.ts
+++ b/tests/Attack.test.ts
@@ -333,6 +333,54 @@ describe("Attack race condition with alliance requests", () => {
});
});
+describe("Transport ship alliance rejection", () => {
+ beforeEach(async () => {
+ game = await setup("ocean_and_land", {
+ infiniteGold: true,
+ instantBuild: true,
+ infiniteTroops: true,
+ });
+
+ const playerAInfo = new PlayerInfo(
+ "playerA",
+ PlayerType.Human,
+ null,
+ "playerA_id",
+ );
+ // close to the water to send boats
+ playerA = addPlayerToGame(playerAInfo, game, game.ref(7, 0));
+
+ const playerBInfo = new PlayerInfo(
+ "playerB",
+ PlayerType.Human,
+ null,
+ "playerB_id",
+ );
+ playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15));
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+ });
+
+ test("Should cancel alliance requests if the recipient sends a transport ship", async () => {
+ // Player A sends alliance request to Player B
+ const allianceRequest = playerA.createAllianceRequest(playerB);
+ expect(allianceRequest).not.toBeNull();
+ expect(playerB.incomingAllianceRequests()).toHaveLength(1);
+
+ // Player B sends a transport ship toward Player A's territory
+ game.addExecution(new TransportShipExecution(playerB, game.ref(7, 0), 0));
+
+ // Execute a tick to process the transport ship launch
+ game.executeNextTick();
+
+ // Alliance request should be rejected since player B sent a naval invasion
+ expect(playerA.outgoingAllianceRequests()).toHaveLength(0);
+ expect(playerB.incomingAllianceRequests()).toHaveLength(0);
+ });
+});
+
describe("Attack immunity", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {
From 54a63ac4814407c42d69ad8c2f0782c4a1b65c1e Mon Sep 17 00:00:00 2001
From: VariableVince <24507472+VariableVince@users.noreply.github.com>
Date: Fri, 20 Mar 2026 20:26:35 +0100
Subject: [PATCH 2/5] Could help decrease icons disappearing: Update PixiJS
(#3478)
## Description:
Please put in next v30 update if possible.
Upgrade [PixiJS from 8.11.0 to their newest version
8.17.1](https://github.com/pixijs/pixijs/releases) and pixi-filters too.
This could help decrease occurance of this issue:
https://github.com/openfrontio/OpenFrontIO/issues/2147
Checked "Behavior Change" in their release notes and we aren't touched
by those. But some of the fixes, especially GC/memory leak, may help in
reducing "dissappearing structure icons". Update to destroy() now using
the new unload() could help too; we call destroy() directly on
ghoststructures and its possibly called elsewhere under the surface too.
## 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
---
package-lock.json | 65 ++++++++++++++++++++++++++---------------------
package.json | 4 +--
2 files changed, 38 insertions(+), 31 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 59350bf82..d3ad3cf89 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -85,8 +85,8 @@
"lit": "^3.3.1",
"lit-markdown": "^1.3.2",
"mrmime": "^2.0.0",
- "pixi-filters": "^6.1.4",
- "pixi.js": "^8.11.0",
+ "pixi-filters": "^6.1.5",
+ "pixi.js": "^8.17.1",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-sh": "^0.17.4",
@@ -4198,13 +4198,6 @@
"@types/node": "*"
}
},
- "node_modules/@types/css-font-loading-module": {
- "version": "0.0.12",
- "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
- "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -5219,16 +5212,16 @@
}
},
"node_modules/@webgpu/types": {
- "version": "0.1.61",
- "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.61.tgz",
- "integrity": "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==",
+ "version": "0.1.69",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
+ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
- "version": "0.8.10",
- "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
- "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
+ "version": "0.8.11",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
+ "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7108,9 +7101,9 @@
}
},
"node_modules/earcut": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz",
- "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
+ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"dev": true,
"license": "ISC"
},
@@ -10277,9 +10270,9 @@
}
},
"node_modules/pixi-filters": {
- "version": "6.1.4",
- "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.4.tgz",
- "integrity": "sha512-6QdkhR8hZ/jXyV7GZG8R0UKkRy9jPeZsOnHaQiKSFEe4tGJ4PfUG90vaC9eyi7g+YKxhKLpNOXu6tmO1+R2tpQ==",
+ "version": "6.1.5",
+ "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.5.tgz",
+ "integrity": "sha512-Ewb/J+kxAbaNN+0/ATJbglAJG+skGJfh7BIDP3ILIDdD6wWk1p0pGa25pVf1T8hGBOQSUNVAmwwJBwkj+cyLLA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10290,22 +10283,26 @@
}
},
"node_modules/pixi.js": {
- "version": "8.11.0",
- "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.11.0.tgz",
- "integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.17.1.tgz",
+ "integrity": "sha512-OB4TpZHrP5RYy+7FqmFrAc0IHRhfOoNIfF4sVeinvK3aG1r2pYrSMneJAKi9+WvGKC70Dj7GEpZ2OZGB6o/xdg==",
"dev": true,
"license": "MIT",
+ "workspaces": [
+ "examples",
+ "playground"
+ ],
"dependencies": {
"@pixi/colord": "^2.9.6",
- "@types/css-font-loading-module": "^0.0.12",
"@types/earcut": "^3.0.0",
- "@webgpu/types": "^0.1.40",
- "@xmldom/xmldom": "^0.8.10",
- "earcut": "^3.0.1",
+ "@webgpu/types": "^0.1.69",
+ "@xmldom/xmldom": "^0.8.11",
+ "earcut": "^3.0.2",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
- "parse-svg-path": "^0.1.2"
+ "parse-svg-path": "^0.1.2",
+ "tiny-lru": "^11.4.7"
},
"funding": {
"type": "opencollective",
@@ -11629,6 +11626,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tiny-lru": {
+ "version": "11.4.7",
+ "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
+ "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
diff --git a/package.json b/package.json
index fe1e43329..bba7b5d9f 100644
--- a/package.json
+++ b/package.json
@@ -69,8 +69,8 @@
"lit": "^3.3.1",
"lit-markdown": "^1.3.2",
"mrmime": "^2.0.0",
- "pixi-filters": "^6.1.4",
- "pixi.js": "^8.11.0",
+ "pixi-filters": "^6.1.5",
+ "pixi.js": "^8.17.1",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-sh": "^0.17.4",
From c29fca477b8fd1663f98d07b6eda22dbbb09b71f Mon Sep 17 00:00:00 2001
From: VariableVince <24507472+VariableVince@users.noreply.github.com>
Date: Fri, 20 Mar 2026 22:03:06 +0100
Subject: [PATCH 3/5] Fix(HelpModal): small updates and fixes (#3473)
## Description:
- fix info icon spacing
- update multiple texts to reflect current state, rewrote
"ui_playeroverlay_desc" further for better readability
- add text for the options menu, and change their order to reflect
current button order
- add missing "Stop trading" icon, is PNG so lazy load
- remove uneccesary lazy loading for an SVG icon (rest of the SVGs
aren't lazy loaded either)
Didn't touch the rest although more incremental updates are needed
following UI and other changes.
Before:
After:
## 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
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
resources/lang/en.json | 7 ++++---
src/client/HelpModal.ts | 21 +++++++++------------
2 files changed, 13 insertions(+), 15 deletions(-)
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 56468b246..cf3dfe2ce 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -121,12 +121,13 @@
"ui_options": "Options",
"ui_options_desc": "The following elements can be found inside:",
"ui_playeroverlay": "Player info overlay",
- "ui_playeroverlay_desc": "When you hover over a country, the Player info overlay is displayed under Options. It shows the type of player: Human, Nation, or Tribe. A Nation's attitude towards you, ranging from Hostile to Friendly. And defending troops, gold, plus the number of Warships and various buildings the player has.",
+ "ui_playeroverlay_desc": "When you hover over a country, the Player info overlay appears. It shows the player type (Human, Nation, or Tribe), a Nation's attitude toward you (Hostile to Friendly), defending troops, gold, and the number of Warships and buildings they have.",
"ui_wilderness": "Wilderness",
- "option_pause": "Pause/Unpause the game - Only available in single player mode.",
+ "option_pause": "Pause/Unpause the game - Unavailable in public games.",
+ "option_speed": "Speed - Adjust the game speed. Unavailable in public games.",
"option_timer": "Timer - Time passed since the start of the game.",
"option_exit": "Exit button.",
- "option_settings": "Settings - Open the settings menu. Inside you can toggle the Alternate view, Emojis, Dark Mode, Ninja (anonymous/random names mode), and action on left click.",
+ "option_settings": "Settings - Open the settings menu. Inside you can toggle things like Alternate view, Emojis, Dark Mode, Hidden names, action on left click and more.",
"radial_title": "Radial menu",
"radial_desc": "Right clicking (or touch on mobile) opens the Radial menu. Right click outside it to close it. From the menu you can:",
"radial_build": "Open the Build menu.",
diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts
index d70accd7e..b66336d9d 100644
--- a/src/client/HelpModal.ts
+++ b/src/client/HelpModal.ts
@@ -624,10 +624,11 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.ui_options_desc")}
- - ${translateText("help_modal.option_pause")}
- ${translateText("help_modal.option_timer")}
- - ${translateText("help_modal.option_exit")}
+ - ${translateText("help_modal.option_speed")}
+ - ${translateText("help_modal.option_pause")}
- ${translateText("help_modal.option_settings")}
+ - ${translateText("help_modal.option_exit")}
@@ -718,8 +719,7 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.radial_info")}
@@ -848,14 +848,11 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.info_emoji")}
-
-

-
+
${translateText("help_modal.info_trade")}
From 13df5cf324734703310233a9fd306812a3a21f6c Mon Sep 17 00:00:00 2001
From: VariableVince <24507472+VariableVince@users.noreply.github.com>
Date: Fri, 20 Mar 2026 23:34:19 +0100
Subject: [PATCH 4/5] Perf/Cleanup/Fix(NameLayer): 40% better performance
(#3475)
## Description:
TL;DR: NameLayer cleanup+ fix + about 40% faster. The potential move of
NameLayer to canvas is stalled so this is a welcome improvement until
then imo.
- It was previously attempted to move NameLayer from HTML to canvas. But
currently that work is stalled so it might take awhile. Therefore
optimizations to NameLayer are useful to merge in the meantime. Also
there's a fix in this PR (see point below) and some cleanup. Overall it
would probably be better to base future changes on this better version
of NameLayer.
Messages about attempt on Feb 6 and reference to having done that
attempt on March 3:
https://discord.com/channels/1359946986937258015/1381293863712591872/1469117172767784981
https://discord.com/channels/1359946986937258015/1381293863712591872/1469401090385641573
https://discord.com/channels/1359946986937258015/1381293863712591872/1469435973522686127
https://discord.com/channels/1359946986937258015/1359946989046989063/1478213329079242752
- Fix: TL;DR: Remove redundant comparison that unintentionally didn't
work and always resolved to true. Leading to scale always being
recalculated. It is now still always recalculated because otherwise name
may be too big for the territory for several seconds, which looks buggy.
(More on this: In renderPlayerInfo(), it cached render.location in
oldLocation. Then put new Cell() in render.location. Later on it would
strictly compare render.location against oldLocation, to decide if scale
should be changed. Which would always be true because render.location
would have a new Object (long ago they were compared non-strictly with
==, later on strictly when those checks were updated in the entire repo
to ===). With this comparison always returning true (even if
render.location x and y did not actually change), the scale would always
be updated by updating render.element.style.transform.
After the fix (removing the non-working comparison which always resolved
to true), scale updates happen at same frequency as before. I have not
kept a similar check like "positionChanged". Because in testing,
player/tribe name would be scaled as too big for their territory size
for several seconds. This felt buggy. Cause for this is two delays
sometimes overlap resulting in several seconds of delay before scale is
recalculated after name position changed: time in Namelayer per render
refresh inside renderLayer (renderRefreshRate 500ms) plus the waiting
time in PlayerExecution per recalculation of largestClusterBoundingBox
(every 20 ticks). I ultimately decided that it should not wait for
"positionChanged" and just be updated every 500ms (renderRefreshRate)
just like unintentionally happened before.)
- Remove redundantly re-adding the name, when a player name doesn't
change anyway. Only adding it when creating the element is enough
- Remove dead code for Shield
- Cache DOM lookups
- Use textContext instead of innerHTML for nameSpan
- Only transform container if it has updates
- Remove currently unused Canvas. Also from public renderLayer().
Layer.ts expects renderLayer(context: CanvasRenderingContext2) so i
could put it back, but it isn't needed per se and i think it makes more
clear that NameLayer doesn't use Canvas currently.
- Remove two unneeded/outdated comments, update others
- Move setting render.fontSize and render.fontColor after early return
- Pass baseSize to updateElementVisibility so as to not calculate it
twice
- Cache render.player.nameLocation() to re-use
BEFORE:

AFTER:

## 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/NameLayer.ts | 199 +++++++++---------------
1 file changed, 75 insertions(+), 124 deletions(-)
diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts
index ac0b973f9..ed5184a39 100644
--- a/src/client/graphics/layers/NameLayer.ts
+++ b/src/client/graphics/layers/NameLayer.ts
@@ -6,7 +6,7 @@ import { Cell } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent } from "../../InputHandler";
-import { createCanvas, renderNumber, renderTroops } from "../../Utils";
+import { renderTroops } from "../../Utils";
import {
computeAllianceClipPath,
createAllianceProgressIcon,
@@ -16,11 +16,22 @@ import {
} from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
-import shieldIcon from "/images/ShieldIconBlack.svg?url";
+
+const PLAYER_NAME = "player-name";
+const PLAYER_NAME_SPAN = "player-name-span";
+const PLAYER_TROOPS = "player-troops";
+const PLAYER_ICONS = "player-icons";
+const PLAYER_FLAG = "player-flag";
class RenderInfo {
public icons: Map = new Map(); // Track icon elements
+ public nameDiv: HTMLDivElement;
+ public nameSpan: HTMLSpanElement | null;
+ public troopsDiv: HTMLDivElement;
+ public flagDiv: HTMLDivElement | null;
+ public iconsDiv: HTMLDivElement;
+
constructor(
public player: PlayerView,
public lastRenderCalc: number,
@@ -28,39 +39,41 @@ class RenderInfo {
public fontSize: number,
public fontColor: string,
public element: HTMLElement,
- ) {}
+ ) {
+ // Traverse the DOM once, upon creation
+ this.nameDiv = element.querySelector(`.${PLAYER_NAME}`) as HTMLDivElement;
+ this.nameSpan = element.querySelector(
+ `.${PLAYER_NAME_SPAN}`,
+ ) as HTMLSpanElement | null;
+ this.troopsDiv = element.querySelector(
+ `.${PLAYER_TROOPS}`,
+ ) as HTMLDivElement;
+ this.flagDiv = element.querySelector(
+ `.${PLAYER_FLAG}`,
+ ) as HTMLDivElement | null;
+ this.iconsDiv = element.querySelector(`.${PLAYER_ICONS}`) as HTMLDivElement;
+ }
}
export class NameLayer implements Layer {
- private canvas: HTMLCanvasElement;
private lastChecked = 0;
private renderCheckRate = 100;
private renderRefreshRate = 500;
private rand = new PseudoRandom(10);
private renders: RenderInfo[] = [];
private seenPlayers: Set = new Set();
- private shieldIconImage: HTMLImageElement;
private container: HTMLDivElement;
private theme: Theme = this.game.config().theme();
private userSettings: UserSettings = new UserSettings();
private isVisible: boolean = true;
private firstPlace: PlayerView | null = null;
+ private lastContainerTransform: string = "";
constructor(
private game: GameView,
private transformHandler: TransformHandler,
private eventBus: EventBus,
- ) {
- this.shieldIconImage = new Image();
- this.shieldIconImage.src = shieldIcon;
- this.shieldIconImage = new Image();
- this.shieldIconImage.src = shieldIcon;
- }
-
- resizeCanvas() {
- this.canvas.width = window.innerWidth;
- this.canvas.height = window.innerHeight;
- }
+ ) {}
shouldTransform(): boolean {
return false;
@@ -71,10 +84,6 @@ export class NameLayer implements Layer {
}
public init() {
- this.canvas = createCanvas();
- window.addEventListener("resize", () => this.resizeCanvas());
- this.resizeCanvas();
-
this.container = document.createElement("div");
this.container.style.position = "fixed";
this.container.style.left = "50%";
@@ -109,12 +118,13 @@ export class NameLayer implements Layer {
}
}
- private updateElementVisibility(render: RenderInfo) {
+ private updateElementVisibility(render: RenderInfo, baseSize?: number) {
if (!render.player.nameLocation() || !render.player.isAlive()) {
return;
}
- const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size));
+ baseSize =
+ baseSize ?? Math.max(1, Math.floor(render.player.nameLocation().size));
const size = this.transformHandler.scale * baseSize;
const isOnScreen = render.location
? this.transformHandler.isOnScreen(render.location)
@@ -160,7 +170,7 @@ export class NameLayer implements Layer {
}
}
- public renderLayer(mainContex: CanvasRenderingContext2D) {
+ public renderLayer() {
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
);
@@ -168,7 +178,11 @@ export class NameLayer implements Layer {
screenPosOld.x - window.innerWidth / 2,
screenPosOld.y - window.innerHeight / 2,
);
- this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
+ const newTransform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
+ if (this.lastContainerTransform !== newTransform) {
+ this.container.style.transform = newTransform;
+ this.lastContainerTransform = newTransform;
+ }
const now = Date.now();
if (now > this.lastChecked + this.renderCheckRate) {
@@ -177,14 +191,6 @@ export class NameLayer implements Layer {
this.renderPlayerInfo(render);
}
}
-
- mainContex.drawImage(
- this.canvas,
- 0,
- 0,
- mainContex.canvas.width,
- mainContex.canvas.height,
- );
}
private createPlayerElement(player: PlayerView): HTMLDivElement {
@@ -196,7 +202,7 @@ export class NameLayer implements Layer {
element.style.gap = "0px";
const iconsDiv = document.createElement("div");
- iconsDiv.classList.add("player-icons");
+ iconsDiv.classList.add(PLAYER_ICONS);
iconsDiv.style.display = "flex";
iconsDiv.style.gap = "4px";
iconsDiv.style.justifyContent = "center";
@@ -207,7 +213,7 @@ export class NameLayer implements Layer {
const nameDiv = document.createElement("div");
const applyFlagStyles = (element: HTMLElement): void => {
- element.classList.add("player-flag");
+ element.classList.add(PLAYER_FLAG);
element.style.opacity = "0.8";
element.style.zIndex = "1";
element.style.aspectRatio = "3/4";
@@ -227,7 +233,7 @@ export class NameLayer implements Layer {
nameDiv.appendChild(flagImg);
}
}
- nameDiv.classList.add("player-name");
+ nameDiv.classList.add(PLAYER_NAME);
nameDiv.style.color = this.theme.textColor(player);
nameDiv.style.fontFamily = this.theme.font();
nameDiv.style.whiteSpace = "nowrap";
@@ -238,13 +244,13 @@ export class NameLayer implements Layer {
nameDiv.style.alignItems = "center";
const nameSpan = document.createElement("span");
- nameSpan.className = "player-name-span";
+ nameSpan.className = PLAYER_NAME_SPAN;
nameSpan.textContent = player.displayName();
nameDiv.appendChild(nameSpan);
element.appendChild(nameDiv);
const troopsDiv = document.createElement("div");
- troopsDiv.classList.add("player-troops");
+ troopsDiv.classList.add(PLAYER_TROOPS);
troopsDiv.setAttribute("translate", "no");
troopsDiv.textContent = renderTroops(player.troops());
troopsDiv.style.color = this.theme.textColor(player);
@@ -253,33 +259,6 @@ export class NameLayer implements Layer {
troopsDiv.style.marginTop = "-5%";
element.appendChild(troopsDiv);
- // TODO: Remove the shield icon.
- /* eslint-disable no-constant-condition */
- if (false) {
- const shieldDiv = document.createElement("div");
- shieldDiv.classList.add("player-shield");
- shieldDiv.style.zIndex = "3";
- shieldDiv.style.marginTop = "-5%";
- shieldDiv.style.display = "flex";
- shieldDiv.style.alignItems = "center";
- shieldDiv.style.gap = "0px";
- const shieldImg = document.createElement("img");
- shieldImg.src = this.shieldIconImage.src;
- shieldImg.style.width = "16px";
- shieldImg.style.height = "16px";
-
- const shieldSpan = document.createElement("span");
- shieldSpan.textContent = "0";
- shieldSpan.style.color = "black";
- shieldSpan.style.fontSize = "10px";
- shieldSpan.style.marginTop = "-2px";
-
- shieldDiv.appendChild(shieldImg);
- shieldDiv.appendChild(shieldSpan);
- element.appendChild(shieldDiv);
- }
- /* eslint-enable no-constant-condition */
-
// Start off invisible so it doesn't flash at 0,0
element.style.display = "none";
@@ -297,26 +276,27 @@ export class NameLayer implements Layer {
return;
}
- const oldLocation = render.location;
- render.location = new Cell(
- render.player.nameLocation().x,
- render.player.nameLocation().y,
- );
+ // Update location and size, show or hide dependent on those
+ const nameLocation = render.player.nameLocation();
+ const newX = nameLocation.x;
+ const newY = nameLocation.y;
- // Calculate base size and scale
- const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size));
- render.fontSize = Math.max(4, Math.floor(baseSize * 0.4));
- render.fontColor = this.theme.textColor(render.player);
+ if (
+ !render.location ||
+ render.location.x !== newX ||
+ render.location.y !== newY
+ ) {
+ render.location = new Cell(newX, newY);
+ }
- // Update element visibility (handles Ctrl key, size, and screen position)
- this.updateElementVisibility(render);
+ const baseSize = Math.max(1, Math.floor(nameLocation.size));
+ this.updateElementVisibility(render, baseSize);
- // If element is hidden, don't continue with rendering
if (render.element.style.display === "none") {
return;
}
- // Throttle updates
+ // Throttle further updates
const now = Date.now();
if (now - render.lastRenderCalc <= this.renderRefreshRate) {
return;
@@ -324,50 +304,20 @@ export class NameLayer implements Layer {
render.lastRenderCalc = now + this.rand.nextInt(0, 100);
// Update text sizes
- const nameDiv = render.element.querySelector(
- ".player-name",
- ) as HTMLDivElement;
- const flagDiv = render.element.querySelector(
- ".player-flag",
- ) as HTMLDivElement;
- const troopsDiv = render.element.querySelector(
- ".player-troops",
- ) as HTMLDivElement;
- nameDiv.style.fontSize = `${render.fontSize}px`;
- nameDiv.style.lineHeight = `${render.fontSize}px`;
- nameDiv.style.color = render.fontColor;
- const span = nameDiv.querySelector(".player-name-span");
- if (span) {
- span.textContent = render.player.displayName();
- }
- if (flagDiv) {
- flagDiv.style.height = `${render.fontSize}px`;
- }
- troopsDiv.style.fontSize = `${render.fontSize}px`;
- troopsDiv.style.color = render.fontColor;
- troopsDiv.textContent = renderTroops(render.player.troops());
+ render.fontSize = Math.max(4, Math.floor(baseSize * 0.4));
+ render.fontColor = this.theme.textColor(render.player);
- const density = renderNumber(
- render.player.troops() / render.player.numTilesOwned(),
- );
- const shieldDiv: HTMLDivElement | null =
- render.element.querySelector(".player-shield");
- const shieldImg = shieldDiv?.querySelector("img");
- const shieldNumber = shieldDiv?.querySelector("span");
- if (shieldImg) {
- shieldImg.style.width = `${render.fontSize * 0.8}px`;
- shieldImg.style.height = `${render.fontSize * 0.8}px`;
- }
- if (shieldNumber) {
- shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`;
- shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`;
- shieldNumber.textContent = density;
+ render.nameDiv.style.fontSize = `${render.fontSize}px`;
+ render.nameDiv.style.lineHeight = `${render.fontSize}px`;
+ render.nameDiv.style.color = render.fontColor;
+ if (render.flagDiv) {
+ render.flagDiv.style.height = `${render.fontSize}px`;
}
+ render.troopsDiv.style.fontSize = `${render.fontSize}px`;
+ render.troopsDiv.style.color = render.fontColor;
+ render.troopsDiv.textContent = renderTroops(render.player.troops());
// Handle icons
- const iconsDiv = render.element.querySelector(
- ".player-icons",
- ) as HTMLDivElement;
const iconSize = Math.min(render.fontSize * 1.5, 48);
// Compute which icons should be shown for this player using shared logic
@@ -399,7 +349,7 @@ export class NameLayer implements Layer {
emojiDiv.style.position = "absolute";
emojiDiv.style.top = "50%";
emojiDiv.style.transform = "translateY(-50%)";
- iconsDiv.appendChild(emojiDiv);
+ render.iconsDiv.appendChild(emojiDiv);
render.icons.set(icon.id, emojiDiv);
}
@@ -436,7 +386,7 @@ export class NameLayer implements Layer {
hasExtensionRequest,
this.userSettings.darkMode(),
);
- iconsDiv.appendChild(allianceWrapper);
+ render.iconsDiv.appendChild(allianceWrapper);
render.icons.set(icon.id, allianceWrapper);
} else {
// Update existing alliance icon
@@ -476,7 +426,7 @@ export class NameLayer implements Layer {
if (!imgElement) {
imgElement = this.createIconElement(icon.src, iconSize, icon.center);
- iconsDiv.appendChild(imgElement);
+ render.iconsDiv.appendChild(imgElement);
render.icons.set(icon.id, imgElement);
}
@@ -519,10 +469,11 @@ export class NameLayer implements Layer {
}
// Position element with scale
- if (render.location && render.location !== oldLocation) {
- const scale = Math.min(baseSize * 0.25, 3);
- render.element.style.transform = `translate(${render.location.x}px, ${render.location.y}px) translate(-50%, -50%) scale(${scale})`;
- }
+ // Even when positionChanged is false: Scale update otherwise sometimes only happens after seconds which looks buggy.
+ // Because of sometimes overlapping delays of 20 ticks for nameLocation() (largestClusterBoundingBox in PlayerExecution)
+ // and the 500ms renderRefreshRate in NameLayer.
+ const scale = Math.min(baseSize * 0.25, 3);
+ render.element.style.transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`;
}
private createIconElement(
From bf09b9c9be4768164fd46d5864e56c0d593392f1 Mon Sep 17 00:00:00 2001
From: FloPinguin <25036848+FloPinguin@users.noreply.github.com>
Date: Sat, 21 Mar 2026 04:43:35 +0100
Subject: [PATCH 5/5] =?UTF-8?q?Improve=20JoinLobbyModal=20=E2=9C=A8=20(#34?=
=?UTF-8?q?82)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description:
Perviously, JoinLobbyModal did not show settings like "Infinite gold" or
"Instant Build" or changed tribe count.
Now it does. Only if the setting differs from the default. I tested a
lot of scenarios, I also thought of the public game modifiers.
And we show a small map image now.
Public game with lots of modifiers:
A private game with lots of custom settings:
A private game with disabled units:
Regular public FFA (No modifiers):
This PR also includes a fix for UsernameInput:
This PR also fixes the default private lobby difficulty in GameManager
## Please complete the following:
- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
## Please put your Discord username so you can be contacted if a bug or
regression is found:
FloPinguin
---
resources/lang/en.json | 6 +-
src/client/JoinLobbyModal.ts | 240 +++++++++++++++++++++++++++++------
src/client/UsernameInput.ts | 24 ++++
src/server/GameManager.ts | 2 +-
4 files changed, 228 insertions(+), 44 deletions(-)
diff --git a/resources/lang/en.json b/resources/lang/en.json
index cf3dfe2ce..fabdc614b 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -368,7 +368,10 @@
"error": "An error occurred. Please try again or contact support.",
"joined_waiting": "Lobby joined! Waiting for host to start...",
"version_mismatch": "This game was created with a different version. Cannot join.",
- "disabled_units": "Disabled Units"
+ "disabled_units": "Disabled Units",
+ "game_length": "Game length",
+ "pvp_immunity": "PVP immunity duration",
+ "starting_gold": "Starting Gold"
},
"public_lobby": {
"title": "Waiting for Game Start...",
@@ -406,7 +409,6 @@
"title": "Create Private Lobby",
"mode": "Mode",
"team_count": "Number of Teams",
- "team_type": "Team Type",
"options_title": "Options",
"bots": "Tribes: ",
"bots_disabled": "Disabled",
diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts
index 4bea53d43..3d6ff86c9 100644
--- a/src/client/JoinLobbyModal.ts
+++ b/src/client/JoinLobbyModal.ts
@@ -2,13 +2,10 @@ import { html, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import {
calculateServerTimeOffset,
- getActiveModifiers,
- getGameModeLabel,
getMapName,
getSecondsUntilServerTimestamp,
getServerNow,
renderDuration,
- renderNumber,
translateText,
} from "../client/Utils";
import { EventBus } from "../core/EventBus";
@@ -22,11 +19,18 @@ import {
PublicGameInfo,
} from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
-import { GameMode, GameType, HumansVsNations } from "../core/game/Game";
+import {
+ Difficulty,
+ GameMapSize,
+ GameMode,
+ GameType,
+ HumansVsNations,
+} from "../core/game/Game";
import { getApiBase } from "./Api";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
+import { normaliseMapKey } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/LobbyConfigItem";
@@ -426,45 +430,197 @@ export class JoinLobbyModal extends BaseModal {
const c = this.gameConfig;
const mapName = getMapName(c.gameMap);
- const modeName = getGameModeLabel(c);
- const modifiers = getActiveModifiers(c.publicGameModifiers);
+ const normalizedMap = normaliseMapKey(c.gameMap);
+ const thumbnailUrl = `/maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`;
+ const isTeam = c.gameMode === GameMode.Team;
+
+ let modeSubtitle: string;
+ if (!isTeam) {
+ modeSubtitle = translateText("game_mode.ffa");
+ } else if (c.playerTeams === HumansVsNations) {
+ modeSubtitle = translateText("host_modal.teams_Humans Vs Nations");
+ } else if (typeof c.playerTeams === "string") {
+ modeSubtitle = translateText("host_modal.teams_" + c.playerTeams);
+ } else if (typeof c.playerTeams === "number") {
+ modeSubtitle = translateText("public_lobby.teams", {
+ num: c.playerTeams,
+ });
+ } else {
+ modeSubtitle = translateText("game_mode.ffa");
+ }
+
+ const pm = c.publicGameModifiers;
+ const cards: TemplateResult[] = [];
+ if (pm?.isCrowded)
+ cards.push(
+ html``,
+ );
+ if (
+ pm?.isHardNations ||
+ (c.gameType === GameType.Private && c.difficulty !== Difficulty.Easy)
+ )
+ cards.push(
+ html``,
+ );
+ if (c.infiniteTroops)
+ cards.push(
+ html``,
+ );
+ if (c.infiniteGold)
+ cards.push(
+ html``,
+ );
+ if (c.instantBuild)
+ cards.push(
+ html``,
+ );
+ if (c.randomSpawn)
+ cards.push(
+ html``,
+ );
+ if (c.maxTimerValue)
+ cards.push(
+ html``,
+ );
+ if (
+ c.spawnImmunityDuration &&
+ Math.round(c.spawnImmunityDuration / 10) !== 5
+ ) {
+ const totalSeconds = Math.round(c.spawnImmunityDuration / 10);
+ const immunityValue =
+ totalSeconds < 60
+ ? `${totalSeconds}s`
+ : totalSeconds % 60 > 0
+ ? `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`
+ : `${Math.floor(totalSeconds / 60)} min`;
+ cards.push(
+ html``,
+ );
+ }
+ if (c.startingGold)
+ cards.push(
+ html``,
+ );
+ if (c.goldMultiplier)
+ cards.push(
+ html``,
+ );
+ if (c.disableAlliances)
+ cards.push(
+ html``,
+ );
+ if ((isTeam && !c.donateGold) || (!isTeam && c.donateGold))
+ cards.push(
+ html``,
+ );
+ if ((isTeam && !c.donateTroops) || (!isTeam && c.donateTroops))
+ cards.push(
+ html``,
+ );
+ const isCompact =
+ c.gameMapSize === GameMapSize.Compact || c.publicGameModifiers?.isCompact;
+ if (isCompact)
+ cards.push(
+ html``,
+ );
+ {
+ const defaultBots = isCompact ? 100 : 400;
+ if (c.bots !== defaultBots)
+ cards.push(
+ html``,
+ );
+ }
+ {
+ const defaultNations = isCompact
+ ? Math.max(0, Math.floor(this.nationCount * 0.25))
+ : this.nationCount;
+ if (typeof c.nations === "number" && c.nations !== defaultNations)
+ cards.push(
+ html``,
+ );
+ }
+ if (c.nations === "disabled" && !(c.gameType === GameType.Public && isTeam))
+ cards.push(
+ html``,
+ );
return html`
-
-
-
- ${modifiers.map(
- (m) => html`
-
- `,
- )}
- ${c.gameMode !== GameMode.FFA &&
- c.playerTeams &&
- c.playerTeams !== HumansVsNations
- ? html`
-
- `
- : html``}
+
+

{
+ (e.target as HTMLImageElement).style.display = "none";
+ }}
+ />
+
+ ${mapName}
+ ${modeSubtitle}
+
+ ${cards.length > 0
+ ? html`
+ ${cards}
+
`
+ : html``}
${this.renderDisabledUnits()}
`;
}
@@ -495,7 +651,9 @@ export class JoinLobbyModal extends BaseModal {
};
return html`
-
+
diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts
index cb1030705..69082ebea 100644
--- a/src/client/UsernameInput.ts
+++ b/src/client/UsernameInput.ts
@@ -13,6 +13,12 @@ import {
} from "../core/validations/username";
import { crazyGamesSDK } from "./CrazyGamesSDK";
+interface LangSelectorLike {
+ currentLang?: string;
+ translations?: Record;
+ defaultTranslations?: Record;
+}
+
const usernameKey: string = "username";
const clanTagKey: string = "clanTag";
@@ -23,6 +29,7 @@ export class UsernameInput extends LitElement {
@property({ type: String }) validationError: string = "";
private _isValid: boolean = true;
+ private _lastValidatedLang: string | null = null;
// Remove static styles since we're using Tailwind
@@ -60,6 +67,23 @@ export class UsernameInput extends LitElement {
});
}
+ protected updated(): void {
+ // Re-validate when translations become available or language changes,
+ // since initial validation may run before translations are loaded.
+ if (this.validationError) {
+ const langSelector = document.querySelector(
+ "lang-selector",
+ );
+ const lang = langSelector?.currentLang;
+ const hasTranslations =
+ langSelector?.translations ?? langSelector?.defaultTranslations;
+ if (hasTranslations && lang && lang !== this._lastValidatedLang) {
+ this._lastValidatedLang = lang;
+ this.validateAndStore();
+ }
+ }
+ }
+
private loadStoredUsername() {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts
index 11e2a87b9..ceecc8526 100644
--- a/src/server/GameManager.ts
+++ b/src/server/GameManager.ts
@@ -71,7 +71,7 @@ export class GameManager {
gameMap: GameMapType.World,
gameType: GameType.Private,
gameMapSize: GameMapSize.Normal,
- difficulty: Difficulty.Medium,
+ difficulty: Difficulty.Easy,
nations: "default",
infiniteGold: false,
infiniteTroops: false,