diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 575977f9a4..7de98bc85f 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -12,7 +12,11 @@ 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, + TransportShipFilter, + UnitType, +} from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { GameMapLoader } from "../core/game/GameMapLoader"; import { @@ -475,13 +479,12 @@ export class ClientGameRunner { if (myPlayer === null) return; this.myPlayer = myPlayer; } - this.myPlayer.actions(tile).then((actions) => { - if (this.myPlayer === null) return; + this.myPlayer.actions(tile, TransportShipFilter.Only).then((actions) => { if (actions.canAttack) { this.eventBus.emit( new SendAttackIntentEvent( this.gameView.owner(tile).id(), - this.myPlayer.troops() * this.renderer.uiState.attackRatio, + this.myPlayer!.troops() * this.renderer.uiState.attackRatio, ), ); } else if (this.canAutoBoat(actions, tile)) { @@ -572,7 +575,7 @@ export class ClientGameRunner { this.myPlayer = myPlayer; } - this.myPlayer.actions(tile).then((actions) => { + this.myPlayer.actions(tile, TransportShipFilter.Only).then((actions) => { if (this.canBoatAttack(actions) !== false) { this.sendBoatAttackIntent(tile); } diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 1458e7affd..6259c0b563 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -10,6 +10,7 @@ import { Cell, PlayerActions, PlayerID, + TransportShipFilter, UnitType, } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; @@ -251,7 +252,7 @@ export class StructureIconsLayer implements Layer { this.game ?.myPlayer() - ?.actions(tileRef) + ?.actions(tileRef, TransportShipFilter.Exclude) .then((actions) => { if (this.potentialUpgrade) { this.potentialUpgrade.iconContainer.filters = []; diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts index 55b7d7ccb4..36eea1a345 100644 --- a/src/client/graphics/layers/UnitDisplay.ts +++ b/src/client/graphics/layers/UnitDisplay.ts @@ -11,7 +11,12 @@ import portIcon from "../../../../resources/images/PortIcon.svg"; import samLauncherIcon from "../../../../resources/images/SamLauncherIconWhite.svg"; import defensePostIcon from "../../../../resources/images/ShieldIconWhite.svg"; import { EventBus } from "../../../core/EventBus"; -import { Gold, PlayerActions, UnitType } from "../../../core/game/Game"; +import { + Gold, + PlayerActions, + TransportShipFilter, + UnitType, +} from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { GhostStructureChangedEvent, @@ -100,7 +105,7 @@ export class UnitDisplay extends LitElement implements Layer { tick() { const player = this.game?.myPlayer(); - player?.actions().then((actions) => { + player?.actions(undefined, TransportShipFilter.Exclude).then((actions) => { this.playerActions = actions; }); if (!player) return; diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 6a8a4042f3..263ba75fd5 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -17,6 +17,7 @@ import { PlayerInfo, PlayerProfile, PlayerType, + TransportShipFilter, } from "./game/Game"; import { createGame } from "./game/GameImpl"; import { TileRef } from "./game/GameMap"; @@ -193,13 +194,14 @@ export class GameRunner { playerID: PlayerID, x?: number, y?: number, + transportShipFilter?: TransportShipFilter, ): 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), + buildableUnits: player.buildableUnits(tile, transportShipFilter), canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers), canEmbargoAll: player.canEmbargoAll(), } as PlayerActions; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 0cd4b07c53..9086cc9bba 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -564,7 +564,10 @@ export interface Player { unitCount(type: UnitType): number; unitsConstructed(type: UnitType): number; unitsOwned(type: UnitType): number; - buildableUnits(tile: TileRef | null): BuildableUnit[]; + buildableUnits( + tile: TileRef | null, + transportShipFilter?: TransportShipFilter, + ): BuildableUnit[]; canBuild(type: UnitType, targetTile: TileRef): TileRef | false; buildUnit( type: T, @@ -866,3 +869,9 @@ export interface NameViewData { y: number; size: number; } + +// Filter for buildableUnits +export enum TransportShipFilter { + Exclude = "exclude", + Only = "only", +} diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 63ce987de5..6045b378bc 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -22,6 +22,7 @@ import { TerraNullius, Tick, TrainType, + TransportShipFilter, UnitInfo, UnitType, } from "./Game"; @@ -287,11 +288,15 @@ export class PlayerView { : this._defendedBorderColors.dark; } - async actions(tile?: TileRef): Promise { + async actions( + tile?: TileRef, + transportShipFilter?: TransportShipFilter, + ): Promise { return this.game.worker.playerInteraction( this.id(), tile && this.game.x(tile), tile && this.game.y(tile), + transportShipFilter, ); } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index c32c1f6f4e..fd48435930 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -33,6 +33,7 @@ import { Team, TerraNullius, Tick, + TransportShipFilter, Unit, UnitParams, UnitType, @@ -883,7 +884,15 @@ export class PlayerImpl implements Player { return b; } - public findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false { + public findUnitToUpgrade( + type: UnitType, + targetTile: TileRef, + skipUnitTypeCheck: boolean = false, + ): Unit | false { + if (!this.canUpgradeUnitType(type, skipUnitTypeCheck)) { + return false; + } + const range = this.mg.config().structureMinDist(); const existing = this.mg .nearbyUnits(targetTile, range, type, undefined, true) @@ -892,29 +901,53 @@ export class PlayerImpl implements Player { return false; } const unit = existing[0].unit; - if (!this.canUpgradeUnit(unit)) { + if (!this.canUpgradeUnit(unit, true)) { return false; } + return unit; } - public canUpgradeUnit(unit: Unit): boolean { + public canUpgradeUnit( + unit: Unit, + skipUnitTypeCheck: boolean = false, + ): boolean { if (unit.isMarkedForDeletion()) { return false; } if (unit.isUnderConstruction()) { return false; } - if (!this.mg.config().unitInfo(unit.type()).upgradable) { + if (unit.owner() !== this) { + return false; + } + if (!skipUnitTypeCheck && !this.canUpgradeUnitType(unit.type())) { + return false; + } + return true; + } + + private canUpgradeUnitType( + unitType: UnitType, + skipUnitTypeCheck: boolean = false, + ): boolean { + if (!this.mg.config().unitInfo(unitType).upgradable) { return false; } - if (this.mg.config().isUnitDisabled(unit.type())) { + if (!skipUnitTypeCheck && !this.canBuildUnitType(unitType)) { return false; } - if (this._gold < this.mg.config().unitInfo(unit.type()).cost(this)) { + return true; + } + + private canBuildUnitType(unitType: UnitType): boolean { + if (this.mg.config().isUnitDisabled(unitType)) { return false; } - if (unit.owner() !== this) { + if (this._gold < this.mg.config().unitInfo(unitType).cost(this)) { + return false; + } + if (!this.isAlive()) { return false; } return true; @@ -927,41 +960,65 @@ 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) => { + public buildableUnits( + tile: TileRef | null, + transportShipFilter?: TransportShipFilter, + ): BuildableUnit[] { + const notInSpawnPhase = !this.mg.inSpawnPhase(); + let foundShip = false; + const result: BuildableUnit[] = []; + + const validTiles = + tile !== null && transportShipFilter !== TransportShipFilter.Only + ? this.validStructureSpawnTiles(tile) + : []; + + for (const u of Object.values(UnitType)) { + if ( + u === UnitType.TransportShip && + transportShipFilter === TransportShipFilter.Exclude + ) { + continue; + } + + if (transportShipFilter === TransportShipFilter.Only) { + if (foundShip) break; + + if (u !== UnitType.TransportShip) continue; + else foundShip = true; + } + + let canBuild: TileRef | false = false; let canUpgrade: number | false = false; - if (!this.mg.inSpawnPhase()) { - const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile); - if (existingUnit !== false) { - canUpgrade = existingUnit.id(); - } + + if (tile !== null && this.canBuildUnitType(u) && notInSpawnPhase) { + canBuild = this.canBuild(u, tile, validTiles, true); + + const existingUnit = this.findUnitToUpgrade(u, tile, true); + canUpgrade = existingUnit !== false ? existingUnit.id() : false; } - return { + + result.push({ type: u, - canBuild: - this.mg.inSpawnPhase() || tile === null - ? false - : this.canBuild(u, tile, validTiles), + canBuild: canBuild, canUpgrade: canUpgrade, cost: this.mg.config().unitInfo(u).cost(this), - } as BuildableUnit; - }); + } as BuildableUnit); + } + + return result; } canBuild( unitType: UnitType, targetTile: TileRef, validTiles: TileRef[] | null = null, + skipUnitTypeCheck: boolean = false, ): TileRef | false { - if (this.mg.config().isUnitDisabled(unitType)) { + if (!skipUnitTypeCheck && !this.canBuildUnitType(unitType)) { return false; } - const cost = this.mg.unitInfo(unitType).cost(this); - if (!this.isAlive() || this.gold() < cost) { - return false; - } switch (unitType) { case UnitType.MIRV: if (!this.mg.hasOwner(targetTile)) { diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index a0af53526a..72ca0f6b02 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -2,6 +2,30 @@ import { PathFindResultType } from "../pathfinding/AStar"; import { MiniAStar } from "../pathfinding/MiniAStar"; import { Game, Player, UnitType } from "./Game"; import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap"; +interface CacheEntry { + tick: number; + value: T; +} +const transportTileCache = new Map>(); + +function getTileCacheKey(tile: TileRef, tick: number): string { + return `transport_${tile}_${tick}`; +} + +let lastCleanupTick = 0; + +function cleanupCache(currentTick: number): void { + if (currentTick < lastCleanupTick + 20) { + return; + } + lastCleanupTick = currentTick; + + for (const [key, entry] of transportTileCache.entries()) { + if (entry.tick < currentTick) { + transportTileCache.delete(key); + } + } +} export function canBuildTransportShip( game: Game, @@ -14,11 +38,6 @@ export function canBuildTransportShip( return false; } - const dst = targetTransportTile(game, tile); - if (dst === null) { - return false; - } - const other = game.owner(tile); if (other === player) { return false; @@ -27,6 +46,11 @@ export function canBuildTransportShip( return false; } + const dst = targetTransportTile(game, tile); + if (dst === null) { + return false; + } + if (game.isOceanShore(dst)) { let myPlayerBordersOcean = false; for (const bt of player.borderTiles()) { @@ -36,19 +60,7 @@ export function canBuildTransportShip( } } - let otherPlayerBordersOcean = false; - if (!game.hasOwner(tile)) { - otherPlayerBordersOcean = true; - } else { - for (const bt of (other as Player).borderTiles()) { - if (game.isOceanShore(bt)) { - otherPlayerBordersOcean = true; - break; - } - } - } - - if (myPlayerBordersOcean && otherPlayerBordersOcean) { + if (myPlayerBordersOcean) { return transportShipSpawn(game, player, dst); } else { return false; @@ -93,23 +105,15 @@ function transportShipSpawn( return spawn; } -export function sourceDstOceanShore( - gm: Game, - src: Player, - tile: TileRef, -): [TileRef | null, TileRef | null] { - const dst = gm.owner(tile); - const srcTile = closestShoreFromPlayer(gm, src, tile); - let dstTile: TileRef | null = null; - if (dst.isPlayer()) { - dstTile = closestShoreFromPlayer(gm, dst as Player, tile); - } else { - dstTile = closestShoreTN(gm, tile, 50); +export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null { + const currentTick = gm.ticks(); + const key = getTileCacheKey(tile, currentTick); + + const cached = transportTileCache.get(key); + if (cached?.tick === currentTick) { + return cached.value; } - return [srcTile, dstTile]; -} -export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null { const dst = gm.playerBySmallID(gm.ownerID(tile)); let dstTile: TileRef | null = null; if (dst.isPlayer()) { @@ -117,6 +121,10 @@ export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null { } else { dstTile = closestShoreTN(gm, tile, 50); } + + transportTileCache.set(key, { tick: currentTick, value: dstTile }); + cleanupCache(currentTick); + return dstTile; } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 1014968fb2..8260a6fa82 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -83,6 +83,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { message.playerID, message.x, message.y, + message.transportShipFilter, ); sendMessage({ type: "player_actions_result", diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index bde436f398..3d71349a1a 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -4,6 +4,7 @@ import { PlayerBorderTiles, PlayerID, PlayerProfile, + TransportShipFilter, } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; @@ -162,6 +163,7 @@ export class WorkerClient { playerID: PlayerID, x?: number, y?: number, + transportShipFilter?: TransportShipFilter, ): Promise { return new Promise((resolve, reject) => { if (!this.isInitialized) { @@ -186,6 +188,7 @@ export class WorkerClient { playerID: playerID, x: x, y: y, + transportShipFilter: transportShipFilter, }); }); } diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index a8d30e9b1f..d0e2037e4d 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -3,6 +3,7 @@ import { PlayerBorderTiles, PlayerID, PlayerProfile, + TransportShipFilter, } 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; + transportShipFilter?: TransportShipFilter; } export interface PlayerActionsResultMessage extends BaseWorkerMessage { diff --git a/tests/PlayerImpl.test.ts b/tests/PlayerImpl.test.ts index 71d022f228..678cfbe2c8 100644 --- a/tests/PlayerImpl.test.ts +++ b/tests/PlayerImpl.test.ts @@ -1,3 +1,4 @@ +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; import { Game, Player, @@ -24,6 +25,11 @@ describe("PlayerImpl", () => { ], ); + game.addExecution( + new SpawnExecution(game.player("player_id").info(), game.ref(0, 0)), + new SpawnExecution(game.player("other_id").info(), game.ref(20, 20)), + ); + while (game.inSpawnPhase()) { game.executeNextTick(); }