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