Add units filter on playeractions for performance (#3213)

## Description:

The ghost structure calls player actions each frame, which is costly
since it's checking for all possible actions.
This add a unit list filter in actions so if there are units it only
checks for buildability of those units.

Before:
![WhatsApp Image 2026-02-14 at 23 25
25](https://github.com/user-attachments/assets/beda6142-9dc7-4a9c-a702-cee3b6ea043c)
Player actions takes 20-30% of the worker

After:
<img width="825" height="342" alt="image"
src="https://github.com/user-attachments/assets/36e47547-5028-4dc9-bc42-e17df4a87200"
/>
Player actions takes 1-3% of the worker


Both performances are relevant only when a ghost structure is selected

## 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
This commit is contained in:
Vivacious Box
2026-02-15 04:54:12 +01:00
committed by GitHub
parent 1e5db18885
commit 040766d417
10 changed files with 66 additions and 45 deletions
@@ -138,7 +138,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
// Get buildable units to find spawn tile (expensive call - only on tick when tile changes)
player
.actions(targetTile)
.actions(targetTile, [ghostStructure])
.then((actions) => {
// Ignore stale results if target changed
if (this.lastTargetTile !== targetTile) {
@@ -285,7 +285,7 @@ export class StructureIconsLayer implements Layer {
this.game
?.myPlayer()
?.actions(tileRef)
?.actions(tileRef, [this.ghostUnit?.buildableUnit.type])
.then((actions) => {
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [];
+15 -12
View File
@@ -22,6 +22,19 @@ import portIcon from "/images/PortIcon.svg?url";
import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url";
import defensePostIcon from "/images/ShieldIconWhite.svg?url";
const BUILDABLE_UNITS: UnitType[] = [
UnitType.City,
UnitType.Factory,
UnitType.Port,
UnitType.DefensePost,
UnitType.MissileSilo,
UnitType.SAMLauncher,
UnitType.Warship,
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRV,
];
@customElement("unit-display")
export class UnitDisplay extends LitElement implements Layer {
public game: GameView;
@@ -55,17 +68,7 @@ export class UnitDisplay extends LitElement implements Layer {
}
}
this.allDisabled =
config.isUnitDisabled(UnitType.City) &&
config.isUnitDisabled(UnitType.Factory) &&
config.isUnitDisabled(UnitType.Port) &&
config.isUnitDisabled(UnitType.DefensePost) &&
config.isUnitDisabled(UnitType.MissileSilo) &&
config.isUnitDisabled(UnitType.SAMLauncher) &&
config.isUnitDisabled(UnitType.Warship) &&
config.isUnitDisabled(UnitType.AtomBomb) &&
config.isUnitDisabled(UnitType.HydrogenBomb) &&
config.isUnitDisabled(UnitType.MIRV);
this.allDisabled = BUILDABLE_UNITS.every((u) => config.isUnitDisabled(u));
this.requestUpdate();
}
@@ -101,7 +104,7 @@ export class UnitDisplay extends LitElement implements Layer {
tick() {
const player = this.game?.myPlayer();
player?.actions().then((actions) => {
player?.actions(undefined, BUILDABLE_UNITS).then((actions) => {
this.playerActions = actions;
});
if (!player) return;
+3 -2
View File
@@ -195,13 +195,14 @@ export class GameRunner {
playerID: PlayerID,
x?: number,
y?: number,
units?: UnitType[],
): PlayerActions {
const player = this.game.player(playerID);
const tile =
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
const actions = {
canAttack: tile !== null && player.canAttack(tile),
buildableUnits: player.buildableUnits(tile),
canAttack: tile !== null && units === undefined && player.canAttack(tile),
buildableUnits: player.buildableUnits(tile, units),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
canEmbargoAll: player.canEmbargoAll(),
} as PlayerActions;
+1 -1
View File
@@ -627,7 +627,7 @@ export interface Player {
unitCount(type: UnitType): number;
unitsConstructed(type: UnitType): number;
unitsOwned(type: UnitType): number;
buildableUnits(tile: TileRef | null): BuildableUnit[];
buildableUnits(tile: TileRef | null, units?: UnitType[]): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit<T extends UnitType>(
type: T,
+2 -1
View File
@@ -403,11 +403,12 @@ export class PlayerView {
return { hasEmbargo, hasFriendly };
}
async actions(tile?: TileRef): Promise<PlayerActions> {
async actions(tile?: TileRef, units?: UnitType[]): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
+34 -24
View File
@@ -22,6 +22,7 @@ import {
EmojiMessage,
GameMode,
Gold,
isStructureType,
MessageType,
MutableAlliance,
Player,
@@ -960,31 +961,40 @@ export class PlayerImpl implements Player {
this.recordUnitConstructed(unit.type());
}
public buildableUnits(tile: TileRef | null): BuildableUnit[] {
const validTiles = tile !== null ? this.validStructureSpawnTiles(tile) : [];
return Object.values(UnitType).map((u) => {
let canUpgrade: number | false = false;
let canBuild: TileRef | false = false;
if (!this.mg.inSpawnPhase()) {
const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile);
if (existingUnit !== false) {
canUpgrade = existingUnit.id();
public buildableUnits(
tile: TileRef | null,
units?: UnitType[],
): BuildableUnit[] {
const validTiles =
tile !== null &&
(units === undefined || units.some((u) => isStructureType(u)))
? this.validStructureSpawnTiles(tile)
: [];
return Object.values(UnitType)
.filter((u) => units === undefined || units.includes(u))
.map((u) => {
let canUpgrade: number | false = false;
let canBuild: TileRef | false = false;
if (!this.mg.inSpawnPhase()) {
const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile);
if (existingUnit !== false) {
canUpgrade = existingUnit.id();
}
if (tile !== null) {
canBuild = this.canBuild(u, tile, validTiles);
}
}
if (tile !== null) {
canBuild = this.canBuild(u, tile, validTiles);
}
}
return {
type: u,
canBuild,
canUpgrade,
cost: this.mg.config().unitInfo(u).cost(this.mg, this),
overlappingRailroads:
canBuild !== false
? this.mg.railNetwork().overlappingRailroads(canBuild)
: [],
} as BuildableUnit;
});
return {
type: u,
canBuild,
canUpgrade,
cost: this.mg.config().unitInfo(u).cost(this.mg, this),
overlappingRailroads:
canBuild !== false
? this.mg.railNetwork().overlappingRailroads(canBuild)
: [],
} as BuildableUnit;
});
}
canBuild(
+1
View File
@@ -94,6 +94,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
message.playerID,
message.x,
message.y,
message.units,
);
sendMessage({
type: "player_actions_result",
+6 -3
View File
@@ -4,6 +4,7 @@ import {
PlayerBorderTiles,
PlayerID,
PlayerProfile,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
@@ -164,6 +165,7 @@ export class WorkerClient {
playerID: PlayerID,
x?: number,
y?: number,
units?: UnitType[],
): Promise<PlayerActions> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
@@ -185,9 +187,10 @@ export class WorkerClient {
this.worker.postMessage({
type: "player_actions",
id: messageId,
playerID: playerID,
x: x,
y: y,
playerID,
x,
y,
units,
});
});
}
+2
View File
@@ -3,6 +3,7 @@ import {
PlayerBorderTiles,
PlayerID,
PlayerProfile,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateViewData } from "../game/GameUpdates";
@@ -62,6 +63,7 @@ export interface PlayerActionsMessage extends BaseWorkerMessage {
playerID: PlayerID;
x?: number;
y?: number;
units?: UnitType[];
}
export interface PlayerActionsResultMessage extends BaseWorkerMessage {