diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index fb01edb34d..f5e1d282c0 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -13,7 +13,7 @@ import {
import { createPartialGameRecord, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
-import { PlayerActions, UnitType } from "../core/game/Game";
+import { PlayerActions, StructureTypes, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
@@ -557,7 +557,7 @@ export class ClientGameRunner {
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
- this.myPlayer.actions(tile).then((actions) => {
+ this.myPlayer.actions(tile, [UnitType.TransportShip]).then((actions) => {
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
@@ -600,7 +600,7 @@ export class ClientGameRunner {
}
private findAndUpgradeNearestBuilding(clickedTile: TileRef) {
- this.myPlayer!.actions(clickedTile).then((actions) => {
+ this.myPlayer!.actions(clickedTile, StructureTypes).then((actions) => {
const upgradeUnits: {
unitId: number;
unitType: UnitType;
@@ -653,7 +653,7 @@ export class ClientGameRunner {
this.myPlayer = myPlayer;
}
- this.myPlayer.actions(tile).then((actions) => {
+ this.myPlayer.actions(tile, [UnitType.TransportShip]).then((actions) => {
if (this.canBoatAttack(actions) !== false) {
this.sendBoatAttackIntent(tile);
}
@@ -672,7 +672,7 @@ export class ClientGameRunner {
this.myPlayer = myPlayer;
}
- this.myPlayer.actions(tile).then((actions) => {
+ this.myPlayer.actions(tile, null).then((actions) => {
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index 45d1188a3b..41e97f1752 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -1,5 +1,5 @@
import { EventBus, GameEvent } from "../core/EventBus";
-import { UnitType } from "../core/game/Game";
+import { PlayerBuildableUnitType, UnitType } from "../core/game/Game";
import { UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
import { UIState } from "./graphics/UIState";
@@ -86,7 +86,7 @@ export class ToggleStructureEvent implements GameEvent {
}
export class GhostStructureChangedEvent implements GameEvent {
- constructor(public readonly ghostStructure: UnitType | null) {}
+ constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
}
export class SwapRocketDirectionEvent implements GameEvent {
@@ -591,7 +591,7 @@ export class InputHandler {
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
}
- private setGhostStructure(ghostStructure: UnitType | null) {
+ private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) {
this.uiState.ghostStructure = ghostStructure;
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
}
diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts
index 277c91d424..c43a773f1d 100644
--- a/src/client/graphics/UIState.ts
+++ b/src/client/graphics/UIState.ts
@@ -1,9 +1,9 @@
-import { UnitType } from "../../core/game/Game";
+import { PlayerBuildableUnitType } from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
export interface UIState {
attackRatio: number;
- ghostStructure: UnitType | null;
+ ghostStructure: PlayerBuildableUnitType | null;
overlappingRailroads: number[];
ghostRailPaths: TileRef[][];
rocketDirectionUp: boolean;
diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts
index 62e9c46fc2..1218ef3f5f 100644
--- a/src/client/graphics/layers/BuildMenu.ts
+++ b/src/client/graphics/layers/BuildMenu.ts
@@ -1,11 +1,12 @@
-import { LitElement, css, html } from "lit";
+import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import {
BuildableUnit,
+ BuildMenuTypes,
Gold,
- PlayerActions,
+ PlayerBuildableUnitType,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
@@ -37,7 +38,7 @@ import samlauncherIcon from "/images/SamLauncherIconWhite.svg?url";
import shieldIcon from "/images/ShieldIconWhite.svg?url";
export interface BuildItemDisplay {
- unitType: UnitType;
+ unitType: PlayerBuildableUnitType;
icon: string;
description?: string;
key?: string;
@@ -127,7 +128,7 @@ export class BuildMenu extends LitElement implements Layer {
public eventBus: EventBus;
public uiState: UIState;
private clickedTile: TileRef;
- public playerActions: PlayerActions | null = null;
+ public playerBuildables: BuildableUnit[] | null = null;
private filteredBuildTable: BuildItemDisplay[][] = buildTable;
public transformHandler: TransformHandler;
@@ -358,12 +359,10 @@ export class BuildMenu extends LitElement implements Layer {
private _hidden = true;
public canBuildOrUpgrade(item: BuildItemDisplay): boolean {
- if (this.game?.myPlayer() === null || this.playerActions === null) {
+ if (this.game?.myPlayer() === null || this.playerBuildables === null) {
return false;
}
- const unit = this.playerActions.buildableUnits.filter(
- (u) => u.type === item.unitType,
- );
+ const unit = this.playerBuildables.filter((u) => u.type === item.unitType);
if (unit.length === 0) {
return false;
}
@@ -371,7 +370,7 @@ export class BuildMenu extends LitElement implements Layer {
}
public cost(item: BuildItemDisplay): Gold {
- for (const bu of this.playerActions?.buildableUnits ?? []) {
+ for (const bu of this.playerBuildables ?? []) {
if (bu.type === item.unitType) {
return bu.cost;
}
@@ -419,7 +418,7 @@ export class BuildMenu extends LitElement implements Layer {
(row) => html`
${row.map((item) => {
- const buildableUnit = this.playerActions?.buildableUnits.find(
+ const buildableUnit = this.playerBuildables?.find(
(bu) => bu.type === item.unitType,
);
if (buildableUnit === undefined) {
@@ -492,9 +491,9 @@ export class BuildMenu extends LitElement implements Layer {
private refresh() {
this.game
.myPlayer()
- ?.actions(this.clickedTile)
- .then((actions) => {
- this.playerActions = actions;
+ ?.buildables(this.clickedTile, BuildMenuTypes)
+ .then((buildables) => {
+ this.playerBuildables = buildables;
this.requestUpdate();
});
diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts
index 989b5aa797..c2029eceaf 100644
--- a/src/client/graphics/layers/MainRadialMenu.ts
+++ b/src/client/graphics/layers/MainRadialMenu.ts
@@ -112,7 +112,7 @@ export class MainRadialMenu extends LitElement implements Layer {
screenX: number | null = null,
screenY: number | null = null,
) {
- this.buildMenu.playerActions = actions;
+ this.buildMenu.playerBuildables = actions.buildableUnits;
const tileOwner = this.game.owner(tile);
const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts
index 341254ab87..02932ccfbf 100644
--- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts
+++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts
@@ -26,7 +26,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
private lastTrajectoryUpdate: number = 0;
private lastTargetTile: TileRef | null = null;
private currentGhostStructure: UnitType | null = null;
- // Cache spawn tile to avoid expensive player.actions() calls
+ // Cache spawn tile to avoid expensive player.buildables() calls
private cachedSpawnTile: TileRef | null = null;
constructor(
@@ -75,7 +75,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
}
/**
- * Update trajectory preview - called from tick() to cache spawn tile via expensive player.actions() call
+ * Update trajectory preview - called from tick() to cache spawn tile via expensive player.buildables() call
* This only runs when target tile changes, minimizing worker thread communication
*/
private updateTrajectoryPreview() {
@@ -138,14 +138,14 @@ export class NukeTrajectoryPreviewLayer implements Layer {
// Get buildable units to find spawn tile (expensive call - only on tick when tile changes)
player
- .actions(targetTile, [ghostStructure])
- .then((actions) => {
+ .buildables(targetTile, [ghostStructure])
+ .then((buildables) => {
// Ignore stale results if target changed
if (this.lastTargetTile !== targetTile) {
return;
}
- const buildableUnit = actions.buildableUnits.find(
+ const buildableUnit = buildables.find(
(bu) => bu.type === ghostStructure,
);
@@ -171,7 +171,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
/**
* Update trajectory path - called from renderLayer() each frame for smooth visual feedback
- * Uses cached spawn tile to avoid expensive player.actions() calls
+ * Uses cached spawn tile to avoid expensive player.buildables() calls
*/
private updateTrajectoryPath() {
const ghostStructure = this.currentGhostStructure;
diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts
index 674b83e155..ddfd6f6788 100644
--- a/src/client/graphics/layers/PlayerPanel.ts
+++ b/src/client/graphics/layers/PlayerPanel.ts
@@ -121,7 +121,7 @@ export class PlayerPanel extends LitElement implements Layer {
// Refresh actions & alliance expiry
const myPlayer = this.g.myPlayer();
if (myPlayer !== null && myPlayer.isAlive()) {
- this.actions = await myPlayer.actions(this.tile);
+ this.actions = await myPlayer.actions(this.tile, null);
if (this.actions?.interaction?.allianceExpiresAt !== undefined) {
const expiresAt = this.actions.interaction.allianceExpiresAt;
const remainingTicks = expiresAt - this.g.ticks();
diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts
index 23ea02fe4b..c03a7fbe0b 100644
--- a/src/client/graphics/layers/RadialMenuElements.ts
+++ b/src/client/graphics/layers/RadialMenuElements.ts
@@ -1,7 +1,10 @@
import { Config } from "../../../core/configuration/Config";
import {
AllPlayers,
+ BuildableAttackTypes,
+ isBuildableAttackType,
PlayerActions,
+ PlayerBuildableUnitType,
StructureTypes,
UnitType,
} from "../../../core/game/Game";
@@ -342,10 +345,14 @@ export const infoMenuElement: MenuElement = {
},
};
-function getAllEnabledUnits(myPlayer: boolean, config: Config): Set
{
- const units: Set = new Set();
+function getAllEnabledUnits(
+ myPlayer: boolean,
+ config: Config,
+): Set {
+ const units: Set =
+ new Set();
- const addIfEnabled = (unitType: UnitType) => {
+ const addIfEnabled = (unitType: PlayerBuildableUnitType) => {
if (!config.isUnitDisabled(unitType)) {
units.add(unitType);
}
@@ -354,28 +361,18 @@ function getAllEnabledUnits(myPlayer: boolean, config: Config): Set {
if (myPlayer) {
StructureTypes.forEach(addIfEnabled);
} else {
- addIfEnabled(UnitType.Warship);
- addIfEnabled(UnitType.HydrogenBomb);
- addIfEnabled(UnitType.MIRV);
- addIfEnabled(UnitType.AtomBomb);
+ BuildableAttackTypes.forEach(addIfEnabled);
}
return units;
}
-const ATTACK_UNIT_TYPES: UnitType[] = [
- UnitType.AtomBomb,
- UnitType.MIRV,
- UnitType.HydrogenBomb,
- UnitType.Warship,
-];
-
function createMenuElements(
params: MenuElementParams,
filterType: "attack" | "build",
elementIdPrefix: string,
): MenuElement[] {
- const unitTypes: Set = getAllEnabledUnits(
+ const unitTypes: Set = getAllEnabledUnits(
params.selected === params.myPlayer,
params.game.config(),
);
@@ -385,8 +382,8 @@ function createMenuElements(
(item) =>
unitTypes.has(item.unitType) &&
(filterType === "attack"
- ? ATTACK_UNIT_TYPES.includes(item.unitType)
- : !ATTACK_UNIT_TYPES.includes(item.unitType)),
+ ? isBuildableAttackType(item.unitType)
+ : !isBuildableAttackType(item.unitType)),
)
.map((item: BuildItemDisplay) => {
const canBuildOrUpgrade = params.buildMenu.canBuildOrUpgrade(item);
diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts
index 9fe6a940b1..663547170b 100644
--- a/src/client/graphics/layers/StructureDrawingUtils.ts
+++ b/src/client/graphics/layers/StructureDrawingUtils.ts
@@ -1,6 +1,10 @@
import * as PIXI from "pixi.js";
import { Theme } from "../../../core/configuration/Config";
-import { Cell, UnitType } from "../../../core/game/Game";
+import {
+ Cell,
+ PlayerBuildableUnitType,
+ UnitType,
+} from "../../../core/game/Game";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import anchorIcon from "/images/AnchorIcon.png?url";
@@ -108,7 +112,7 @@ export class SpriteFactory {
player: PlayerView,
ghostStage: PIXI.Container,
pos: { x: number; y: number },
- structureType: UnitType,
+ structureType: PlayerBuildableUnitType,
): {
container: PIXI.Container;
priceText: PIXI.BitmapText;
diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts
index 9055e47e3e..dea65df4f2 100644
--- a/src/client/graphics/layers/StructureIconsLayer.ts
+++ b/src/client/graphics/layers/StructureIconsLayer.ts
@@ -8,6 +8,7 @@ import { wouldNukeBreakAlliance } from "../../../core/execution/Util";
import {
BuildableUnit,
Cell,
+ PlayerBuildableUnitType,
PlayerID,
UnitType,
} from "../../../core/game/Game";
@@ -283,8 +284,8 @@ export class StructureIconsLayer implements Layer {
this.game
?.myPlayer()
- ?.actions(tileRef, [this.ghostUnit?.buildableUnit.type])
- .then((actions) => {
+ ?.buildables(tileRef, [this.ghostUnit?.buildableUnit.type])
+ .then((buildables) => {
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [];
this.potentialUpgrade.dotContainer.filters = [];
@@ -295,7 +296,7 @@ export class StructureIconsLayer implements Layer {
if (!this.ghostUnit) return;
- const unit = actions.buildableUnits.find(
+ const unit = buildables.find(
(u) => u.type === this.ghostUnit!.buildableUnit.type,
);
const showPrice = this.game.config().userSettings().cursorCostLabel();
@@ -434,7 +435,7 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.range?.position.set(localX, localY);
}
- private createGhostStructure(type: UnitType | null) {
+ private createGhostStructure(type: PlayerBuildableUnitType | null) {
const player = this.game.myPlayer();
if (!player) return;
if (type === null) {
diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts
index 82d530dcb5..d8219e3956 100644
--- a/src/client/graphics/layers/UnitDisplay.ts
+++ b/src/client/graphics/layers/UnitDisplay.ts
@@ -1,7 +1,13 @@
-import { LitElement, html } from "lit";
+import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
-import { Gold, PlayerActions, UnitType } from "../../../core/game/Game";
+import {
+ BuildableUnit,
+ BuildMenuTypes,
+ Gold,
+ PlayerBuildableUnitType,
+ UnitType,
+} from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import {
GhostStructureChangedEvent,
@@ -22,25 +28,12 @@ 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;
public eventBus: EventBus;
public uiState: UIState;
- private playerActions: PlayerActions | null = null;
+ private playerBuildables: BuildableUnit[] | null = null;
private keybinds: Record = {};
private _cities = 0;
private _warships = 0;
@@ -50,7 +43,7 @@ export class UnitDisplay extends LitElement implements Layer {
private _defensePost = 0;
private _samLauncher = 0;
private allDisabled = false;
- private _hoveredUnit: UnitType | null = null;
+ private _hoveredUnit: PlayerBuildableUnitType | null = null;
createRenderRoot() {
return this;
@@ -68,12 +61,12 @@ export class UnitDisplay extends LitElement implements Layer {
}
}
- this.allDisabled = BUILDABLE_UNITS.every((u) => config.isUnitDisabled(u));
+ this.allDisabled = BuildMenuTypes.every((u) => config.isUnitDisabled(u));
this.requestUpdate();
}
private cost(item: UnitType): Gold {
- for (const bu of this.playerActions?.buildableUnits ?? []) {
+ for (const bu of this.playerBuildables ?? []) {
if (bu.type === item) {
return bu.cost;
}
@@ -104,10 +97,10 @@ export class UnitDisplay extends LitElement implements Layer {
tick() {
const player = this.game?.myPlayer();
- player?.actions(undefined, BUILDABLE_UNITS).then((actions) => {
- this.playerActions = actions;
- });
if (!player) return;
+ player.buildables(undefined, BuildMenuTypes).then((buildables) => {
+ this.playerBuildables = buildables;
+ });
this._cities = player.totalUnitLevels(UnitType.City);
this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo);
this._port = player.totalUnitLevels(UnitType.Port);
@@ -221,7 +214,7 @@ export class UnitDisplay extends LitElement implements Layer {
private renderUnitItem(
icon: string,
number: number | null,
- unitType: UnitType,
+ unitType: PlayerBuildableUnitType,
structureKey: string,
hotkey: string,
) {
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 2179d2df5b..2c146c947f 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -6,6 +6,7 @@ import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
+ BuildableUnit,
Cell,
Game,
GameUpdates,
@@ -13,6 +14,7 @@ import {
Player,
PlayerActions,
PlayerBorderTiles,
+ PlayerBuildableUnitType,
PlayerID,
PlayerInfo,
PlayerProfile,
@@ -192,18 +194,30 @@ export class GameRunner {
return Math.max(0, this.turns.length - this.currTurn);
}
+ public playerBuildables(
+ playerID: PlayerID,
+ x?: number,
+ y?: number,
+ units?: readonly PlayerBuildableUnitType[],
+ ): BuildableUnit[] {
+ const player = this.game.player(playerID);
+ const tile =
+ x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
+ return player.buildableUnits(tile, units);
+ }
+
public playerActions(
playerID: PlayerID,
x?: number,
y?: number,
- units?: UnitType[],
+ units?: readonly PlayerBuildableUnitType[] | null,
): PlayerActions {
const player = this.game.player(playerID);
const tile =
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
const actions = {
- canAttack: tile !== null && units === undefined && player.canAttack(tile),
- buildableUnits: player.buildableUnits(tile, units),
+ canAttack: tile !== null && player.canAttack(tile),
+ buildableUnits: units === null ? [] : player.buildableUnits(tile, units),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
canEmbargoAll: player.canEmbargoAll(),
} as PlayerActions;
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 91f635af80..a9fde6f249 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -258,21 +258,71 @@ export enum TrainType {
Carriage = "Carriage",
}
-const _structureTypes: ReadonlySet = new Set([
+export const nukeTypes = [
+ UnitType.AtomBomb,
+ UnitType.HydrogenBomb,
+ UnitType.MIRVWarhead,
+ UnitType.MIRV,
+] as const satisfies readonly UnitType[];
+
+const _buildableAttackTypesList = [
+ UnitType.AtomBomb,
+ UnitType.HydrogenBomb,
+ UnitType.MIRV,
+ UnitType.Warship,
+] as const satisfies readonly UnitType[];
+
+export const BuildableAttackTypes = _buildableAttackTypesList;
+
+const _buildableAttackTypesSet: ReadonlySet = new Set(
+ _buildableAttackTypesList,
+);
+
+export function isBuildableAttackType(type: UnitType): boolean {
+ return _buildableAttackTypesSet.has(type);
+}
+
+const _structureTypesList = [
UnitType.City,
UnitType.DefensePost,
UnitType.SAMLauncher,
UnitType.MissileSilo,
UnitType.Port,
UnitType.Factory,
-]);
+] as const satisfies readonly UnitType[];
-export const StructureTypes: readonly UnitType[] = [..._structureTypes];
+const _structureTypesSet: ReadonlySet = new Set(_structureTypesList);
+
+export const StructureTypes = _structureTypesList;
export function isStructureType(type: UnitType): boolean {
- return _structureTypes.has(type);
+ return _structureTypesSet.has(type);
}
+const _buildMenuTypesList = [
+ ..._structureTypesList,
+ ..._buildableAttackTypesList,
+] as const satisfies readonly UnitType[];
+
+export const BuildMenuTypes = _buildMenuTypesList;
+
+const _playerBuildableTypesList = [
+ ..._buildMenuTypesList,
+ UnitType.TransportShip,
+] as const satisfies readonly UnitType[];
+
+const _playerBuildableTypesSet: ReadonlySet = new Set(
+ _playerBuildableTypesList,
+);
+
+export const PlayerBuildableTypes = _playerBuildableTypesList;
+
+export function isPlayerBuildableType(type: UnitType): boolean {
+ return _playerBuildableTypesSet.has(type);
+}
+
+export type PlayerBuildableUnitType = (typeof PlayerBuildableTypes)[number];
+
export interface OwnerComp {
owner: Player;
}
@@ -342,13 +392,6 @@ export type UnitParams = UnitParamsMap[T];
export type AllUnitParams = UnitParamsMap[keyof UnitParamsMap];
-export const nukeTypes = [
- UnitType.AtomBomb,
- UnitType.HydrogenBomb,
- UnitType.MIRVWarhead,
- UnitType.MIRV,
-] as UnitType[];
-
export enum Relation {
Hostile = 0,
Distrustful = 1,
@@ -625,8 +668,16 @@ export interface Player {
unitCount(type: UnitType): number;
unitsConstructed(type: UnitType): number;
unitsOwned(type: UnitType): number;
- buildableUnits(tile: TileRef | null, units?: UnitType[]): BuildableUnit[];
- canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
+ buildableUnits(
+ tile: TileRef | null,
+ units?: readonly PlayerBuildableUnitType[],
+ ): BuildableUnit[];
+ canBuild(
+ type: UnitType,
+ targetTile: TileRef,
+ validTiles?: TileRef[] | null,
+ knownCost?: Gold | null,
+ ): TileRef | false;
buildUnit(
type: T,
spawnTile: TileRef,
@@ -637,8 +688,12 @@ export interface Player {
// or false if it cannot be upgraded.
// 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(unit: Unit): boolean;
+ findUnitToUpgrade(
+ type: UnitType,
+ targetTile: TileRef,
+ knownCost?: Gold | null,
+ ): Unit | false;
+ canUpgradeUnit(unit: Unit, knownCost?: Gold | null): boolean;
upgradeUnit(unit: Unit): void;
captureUnit(unit: Unit): void;
@@ -847,7 +902,7 @@ export interface BuildableUnit {
canBuild: TileRef | false;
// unit id of the existing unit that can be upgraded, or false if it cannot be upgraded.
canUpgrade: number | false;
- type: UnitType;
+ type: PlayerBuildableUnitType;
cost: Gold;
overlappingRailroads: number[];
ghostRailPaths: TileRef[][];
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index f02e0e10b6..5fbb788763 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -7,6 +7,7 @@ import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
+ BuildableUnit,
Cell,
EmojiMessage,
GameUpdates,
@@ -14,6 +15,7 @@ import {
NameViewData,
PlayerActions,
PlayerBorderTiles,
+ PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
PlayerType,
@@ -403,7 +405,10 @@ export class PlayerView {
return { hasEmbargo, hasFriendly };
}
- async actions(tile?: TileRef, units?: UnitType[]): Promise {
+ async actions(
+ tile?: TileRef,
+ units?: readonly PlayerBuildableUnitType[] | null,
+ ): Promise {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
@@ -412,6 +417,18 @@ export class PlayerView {
);
}
+ async buildables(
+ tile?: TileRef,
+ units?: readonly PlayerBuildableUnitType[],
+ ): Promise {
+ return this.game.worker.playerBuildables(
+ this.id(),
+ tile && this.game.x(tile),
+ tile && this.game.y(tile),
+ units,
+ );
+ }
+
async borderTiles(): Promise {
return this.game.worker.playerBorderTiles(this.id());
}
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index e4bc3c7c9f..d3752525fa 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -26,6 +26,8 @@ import {
MessageType,
MutableAlliance,
Player,
+ PlayerBuildableTypes,
+ PlayerBuildableUnitType,
PlayerID,
PlayerInfo,
PlayerProfile,
@@ -915,7 +917,11 @@ export class PlayerImpl implements Player {
return b;
}
- public findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false {
+ public findUnitToUpgrade(type: UnitType, targetTile: TileRef, knownCost: Gold | null = null): Unit | false {
+ if (!this.mg.config().unitInfo(type).upgradable) {
+ return false;
+ }
+
const range = this.mg.config().structureMinDist();
const existing = this.mg
.nearbyUnits(targetTile, range, type, undefined, true)
@@ -924,13 +930,13 @@ export class PlayerImpl implements Player {
return false;
}
const unit = existing[0].unit;
- if (!this.canUpgradeUnit(unit)) {
+ if (!this.canUpgradeUnit(unit, knownCost)) {
return false;
}
return unit;
}
- public canUpgradeUnit(unit: Unit): boolean {
+ public canUpgradeUnit(unit: Unit, knownCost: Gold | null = null): boolean {
if (unit.isMarkedForDeletion()) {
return false;
}
@@ -944,7 +950,7 @@ export class PlayerImpl implements Player {
return false;
}
if (
- this._gold < this.mg.config().unitInfo(unit.type()).cost(this.mg, this)
+ this._gold < (knownCost ?? this.mg.config().unitInfo(unit.type()).cost(this.mg, this))
) {
return false;
}
@@ -963,54 +969,68 @@ export class PlayerImpl implements Player {
public buildableUnits(
tile: TileRef | null,
- units?: UnitType[],
+ units?: readonly PlayerBuildableUnitType[],
): BuildableUnit[] {
+ const mg = this.mg;
+ const config = mg.config();
+ const rail = mg.railNetwork();
+ const inSpawnPhase = mg.inSpawnPhase();
+
const validTiles =
tile !== null &&
- (units === undefined || units.some((u) => isStructureType(u)))
+ (units === undefined || units.some((u) => isStructureType(u))) &&
+ !inSpawnPhase
? 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);
- }
+
+ const selectedTypes =
+ units === undefined ? null : new Set(units);
+
+ const result: BuildableUnit[] = [];
+
+ for (const u of PlayerBuildableTypes) {
+ if (selectedTypes !== null && !selectedTypes.has(u)) {
+ continue;
+ }
+
+ const cost = config.unitInfo(u).cost(mg, this);
+ let canUpgrade: number | false = false;
+ let canBuild: TileRef | false = false;
+
+ if (tile !== null && !inSpawnPhase) {
+ const existingUnit = this.findUnitToUpgrade(u, tile, cost);
+ if (existingUnit !== false) {
+ canUpgrade = existingUnit.id();
}
- return {
- type: u,
- canBuild,
- canUpgrade,
- cost: this.mg.config().unitInfo(u).cost(this.mg, this),
- overlappingRailroads:
- canBuild !== false
- ? this.mg.railNetwork().overlappingRailroads(canBuild)
- : [],
- ghostRailPaths:
- canBuild !== false
- ? this.mg.railNetwork().computeGhostRailPaths(u, canBuild)
- : [],
- };
+ canBuild = this.canBuild(u, tile, validTiles, cost);
+ }
+
+ result.push({
+ type: u,
+ canBuild,
+ canUpgrade,
+ cost,
+ overlappingRailroads:
+ canBuild !== false ? rail.overlappingRailroads(canBuild) : [],
+ ghostRailPaths:
+ canBuild !== false ? rail.computeGhostRailPaths(u, canBuild) : [],
});
+ }
+
+ return result;
}
canBuild(
unitType: UnitType,
targetTile: TileRef,
validTiles: TileRef[] | null = null,
+ knownCost: Gold | null = null,
): TileRef | false {
if (this.mg.config().isUnitDisabled(unitType)) {
return false;
}
- const cost = this.mg.unitInfo(unitType).cost(this.mg, this);
+ const cost = knownCost ?? this.mg.unitInfo(unitType).cost(this.mg, this);
if (
unitType !== UnitType.MIRVWarhead &&
(!this.isAlive() || this.gold() < cost)
diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts
index aa61da3de0..c2ffd7113a 100644
--- a/src/core/worker/Worker.worker.ts
+++ b/src/core/worker/Worker.worker.ts
@@ -8,6 +8,7 @@ import {
MainThreadMessage,
PlayerActionsResultMessage,
PlayerBorderTilesResultMessage,
+ PlayerBuildablesResultMessage,
PlayerProfileResultMessage,
TransportShipSpawnResultMessage,
WorkerMessage,
@@ -107,6 +108,28 @@ ctx.addEventListener("message", async (e: MessageEvent) => {
throw error;
}
break;
+ case "player_buildables":
+ if (!gameRunner) {
+ throw new Error("Game runner not initialized");
+ }
+
+ try {
+ const buildables = (await gameRunner).playerBuildables(
+ message.playerID,
+ message.x,
+ message.y,
+ message.units,
+ );
+ sendMessage({
+ type: "player_buildables_result",
+ id: message.id,
+ result: buildables,
+ } as PlayerBuildablesResultMessage);
+ } catch (error) {
+ console.error("Failed to get buildables:", error);
+ throw error;
+ }
+ break;
case "player_profile":
if (!gameRunner) {
throw new Error("Game runner not initialized");
diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts
index a5039e9d77..7190b4ff11 100644
--- a/src/core/worker/WorkerClient.ts
+++ b/src/core/worker/WorkerClient.ts
@@ -1,10 +1,11 @@
import {
+ BuildableUnit,
Cell,
PlayerActions,
PlayerBorderTiles,
+ PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
- UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
@@ -165,7 +166,7 @@ export class WorkerClient {
playerID: PlayerID,
x?: number,
y?: number,
- units?: UnitType[],
+ units?: readonly PlayerBuildableUnitType[] | null,
): Promise {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
@@ -195,6 +196,40 @@ export class WorkerClient {
});
}
+ playerBuildables(
+ playerID: PlayerID,
+ x?: number,
+ y?: number,
+ units?: readonly PlayerBuildableUnitType[],
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.isInitialized) {
+ reject(new Error("Worker not initialized"));
+ return;
+ }
+
+ const messageId = generateID();
+
+ this.messageHandlers.set(messageId, (message) => {
+ if (
+ message.type === "player_buildables_result" &&
+ message.result !== undefined
+ ) {
+ resolve(message.result);
+ }
+ });
+
+ this.worker.postMessage({
+ type: "player_buildables",
+ id: messageId,
+ playerID,
+ x,
+ y,
+ units,
+ });
+ });
+ }
+
attackAveragePosition(
playerID: number,
attackID: string,
diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts
index 795df5497c..8476cd53c3 100644
--- a/src/core/worker/WorkerMessages.ts
+++ b/src/core/worker/WorkerMessages.ts
@@ -1,9 +1,10 @@
import {
+ BuildableUnit,
PlayerActions,
PlayerBorderTiles,
+ PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
- UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateViewData } from "../game/GameUpdates";
@@ -17,6 +18,8 @@ export type WorkerMessageType =
| "game_update"
| "player_actions"
| "player_actions_result"
+ | "player_buildables"
+ | "player_buildables_result"
| "player_profile"
| "player_profile_result"
| "player_border_tiles"
@@ -63,7 +66,7 @@ export interface PlayerActionsMessage extends BaseWorkerMessage {
playerID: PlayerID;
x?: number;
y?: number;
- units?: UnitType[];
+ units?: readonly PlayerBuildableUnitType[] | null;
}
export interface PlayerActionsResultMessage extends BaseWorkerMessage {
@@ -71,6 +74,19 @@ export interface PlayerActionsResultMessage extends BaseWorkerMessage {
result: PlayerActions;
}
+export interface PlayerBuildablesMessage extends BaseWorkerMessage {
+ type: "player_buildables";
+ playerID: PlayerID;
+ x?: number;
+ y?: number;
+ units?: readonly PlayerBuildableUnitType[];
+}
+
+export interface PlayerBuildablesResultMessage extends BaseWorkerMessage {
+ type: "player_buildables_result";
+ result: BuildableUnit[];
+}
+
export interface PlayerProfileMessage extends BaseWorkerMessage {
type: "player_profile";
playerID: number;
@@ -120,6 +136,7 @@ export type MainThreadMessage =
| InitMessage
| TurnMessage
| PlayerActionsMessage
+ | PlayerBuildablesMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage
| AttackAveragePositionMessage
@@ -130,6 +147,7 @@ export type WorkerMessage =
| InitializedMessage
| GameUpdateMessage
| PlayerActionsResultMessage
+ | PlayerBuildablesResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage