Fix/Perf/Refactor: playerActions and buildableUnits, their callers and related types (#3220)

## Description:

TL;DR: it's faster.

buildableUnits is called via PlayerView.actions from UnitDisplay (each
tick without TileRef), BuildMenu (each tick when open), MainRadialMenu
(each tick when open), PlayerPanel (each tick when open),
StructureIconsLayer (when placing a building from build bar),
NukeTrajectoryPreviewLayer (when placing nuke, on tick when tile
changes), ClientGameRunner (on click to attack/auto-boat or hotkey B or
G).

After https://github.com/openfrontio/OpenFrontIO/pull/3213 got merged,
the change with largest impact in
https://github.com/openfrontio/OpenFrontIO/pull/3193 was done in such a
different way that a new PR was needed

The idea in 3193 was to not always ask for Transport Ship from
buildableUnits. In such a way that very little extra data was send to
the worker. This had the biggest impact on performance (the idea was
months older btw, see
https://github.com/openfrontio/OpenFrontIO/pull/2295). Now, we do it the
other way around, by telling buildableUnits all unit types we want. Or
we want them all (undefined). The downside is more data is send in the
worker message. The upside is we have more options and can add more in
this PR.

This PR implements some of the leftovers in 3193 on top of 3213 and adds
further improvements.

(Some unrelated refactor/perf changes where moved out of this PR and
into already merged
https://github.com/openfrontio/OpenFrontIO/pull/3233,
https://github.com/openfrontio/OpenFrontIO/pull/3234,
https://github.com/openfrontio/OpenFrontIO/pull/3235,
https://github.com/openfrontio/OpenFrontIO/pull/3236,
https://github.com/openfrontio/OpenFrontIO/pull/3237,
https://github.com/openfrontio/OpenFrontIO/pull/3238,
https://github.com/openfrontio/OpenFrontIO/pull/3239)

- **GameRunner**, **WorkerMessages**: _playerActions_ and
_PlayerActionsMessage ._ Option to ask for no buildable units (null). It
now has 3 modes: get all actions and all buildings (units undefined),
get all actions and no buildings (units null), or get all actions and
specific building (units contains Unit Types).

- **GameRunner**: _playerActions_. fixes wrong assumption in PR 3213:
that only if units was undefined, we have to know canAttack.
ClientGameRunner wants to know both, in case of a click on non-bordering
land, to decide if it should auto-boat using a Transport Ship. So units
is not undefined (we only ask for Transport Ship now which has a
positive effect on performance for each click/tap) but we need canAttack
still.
Solved by removing the unit === undefined check before _canAttack_ in
_playerActions_.

- **GameRunner**, **GameView**, **WorkerClient**, **WorkerMessages**,
**Worker.worker**: added _playerBuildables_ / _buildables_ next to
existing _playerActions_ / _actions_. With above solved, there was still
no option to only get buildable units when the actions are not needed.
While **StructureIconsLayer**, **NukeTrajectoryPreviewLayer**,
**BuildMenu** and **UnitDisplay** need only that. To not make
playerActions more convoluted with more params or so, i've added a new
function _playerBuildables_ in **GameView** to only get buildable units
(**GameRunner** _playerBuildables_). _playerBuildables_ has 2 modes: get
all buildings (units undefined) or get specific buildings (units
contains Unit Types). Also update some comments that mentioned .actions
in **NukeTrajectoryPreviewLayer**.

- **ClientGameRunner**, **PlayerPanel**, **BuildMenu**, **UnitDisplay**,
**StructureIconsLayer** and **NukeTrajectoryPreviewLayer**: Since PR
3213, **StructureIconsLayer** and **NukeTrajectoryPreviewLayer** ask for
specific types of units from **GameView** _actions_ (**GameRunner**
playerActions). Now have the other files do the same. For example
**BuildMenu** asks for the new _BuildMenuTypes_ when it calls
._buildables_ and **ClientGameRunner** asks for UnitType.TransportShip
when sending a boat

- **ClientGameRunner**: canBoatAttack now accepts BuildableUnit[]
instead of PlayerActions so we can send it either actions.buildableUnits
or just buildables. Have functions call myPlayer.buildables(tileRef,
[UnitType.TransportShip]) when we only need a buildable unit and no
actions. Or myPlayer.actions(tileRef, null) when we need actions but no
buildable units. Or myPlayer.actions(tileRef, [UnitType.TransportShip])
when we need both actions, like canAttack, and a buildable unit. Then if
needed send either actions.buildableUnits or buildables to to
_canAutoBoat_ / _canBoatAttack_.

- **MainRadialMenu**: needs all player buildable unit types including
Transport Ship, so the _actions_ call argument for unit types can stay
undefined (unchanged) there.

- **MainRadialMenu**: now that **BuildMenu** uses _playerBuildables_
instead of _playerActions_, we must put data in
_this.buildMenu.playerBuildables_. And since we're not putting the
(unneeded) full _actions_ in there anymore, we can now put only the
needed and expected _actions._buildableUnits_ in it.

- **Game**, **PlayerImpl**, **StructureIconsLayer**: Typesafety and some
added perf: new type _PlayerBuildableUnitType_ (see also the below point
for how it is formed). So callers of _buildableUnits_ can never ask for
the wrong type like e.g. UnitType.Train because it doesn't return data
for that type. This type is now used in **PlayerImpl**, **BuildMenu**,
**RadialMenuElements**, **StructureDrawingUtils** and **UnitDisplay**
for that reason. And **InputHandler**, **StructureIconsLayer** and
**UIState** (little more on that in point below).

- **InputHandler**, **StructureIconsLayer**, **UIState**: In order to
make type safety work for GhostUnit.buildableUnit.type too (line ~217 of
StructureIconsLayer), changed type of interface _BuildableUnit_ to
_PlayerBuildableType_. Which is only more accurate. Same for and
this.structures and uiState.ghostStructure and with the latter,
_renderUnitItem_ in **UnitDisplay** and _setGhostStructure_ in
**InputHandler**. All Structures are of PlayerBuildableType (there are
even some in PlayerBuildables that aren't Structures, but it is much
more confined than UnitType).

- **Game**: Typesafety and some added perf: added _BuildMenus_ and
_BuildableAttacks_ in the same fashion that the existing StructureTypes
was already used (simplified it a bit too, with it renamed
_StructureTypes_ to _Structures_ and removed _isStructureType_). They
can be used with .types or .has(). _BuildableAttacks_.has() is used in
**RadialMenuElements**. _BuildableAttacks_ and existing _Structures_ now
make up _BuildMenus_. Which is used in **BuildMenu**,
**StructureIconsLayer** and **UnitDisplay**. Then _BuildMenus_ together
with UnitType.TransportShip make up the _PlayerBuildables_. Which is
used in **PlayerImpl** _buildableUnits_ (see point below). And with
_PlayerBuildableUnits_ we get the new _PlayerBuildableUnitType_ (see
above point on Game / PlayerImpl).

- **RadialMenuElements**: replace non-central ATTACK_UNIT_TYPES in
**RadialMenuElements** with centralized _BuildableAttackTypes_ too. Use
_PlayerBuildableUnitType_ for more type safety (can't by mistake add
UnitType.Train to its build menu). Make use of _BuildableAttackTypes_
instead of adding items hardcoded line by line in _getAllEnabledUnits_,
just like we already did since PR 3239 with _StructureTypes_. And use
_BuildableAttacks.types_ in the same fashion that existing
_isStructureTypes_ (now Structures.types) was already used elsewhere.

- **PlayerImpl**: _buildableUnits_ 
-- would do Object.values(UnitTypes) on every call. Now for better perf
directly loop over player buildable units by using _PlayerBuildables_
(see above point). In this way we also exclude MIRVWarhead, TradeShip,
Train, SamMissile and Shell so there are less unit types to loop through
by default. Since a player doesn't build those by themselves, they are
only build by Executions which use _canBuild_ directly and not
_buildableUnits_.
-- for more performance, do for loop instead of using .map and .filter,
no intermediate array needed nor callback overhead. We just loop over
the given units (which if undefined will contain _PlayerBuildables_).
Also pre-allocate the results array to get the most out of it, even if
V8 might already be very good at this.
-- cache config, railNetwork and inSpawnPhase so they can be re-used
inside the for loop.
-- cache cost inside the loop
-- it would check twice for tile!==null to decide to call
findUnitToUpgrade and canBuild. Now once.
-- eliminated double/triple checks for the same thing. It called
_findUnitToUpgrade_ (and with that _canUpgradeUnit_) and then _canBuild_
which both check if player has enough gold for the cost of the unit
type. And they both check if the unit type is disabled. Now we call
private functions _canBuildUnitType_, _canUpgradeUnitType_ to first do
checks on unit type level for early returns, and
_findExistingUnitToUpgrade_ to find existing unit without doing anything
extra. in a specific order to check everything only once. The public
functions _findUnitToUpgrade_ and _canBuild_ have an unchanged
functionality and we don't call them from _buildableUnits_ anymore.
-- would get _overlappingRailRoads_ and _computeGhostRailPaths_ when
canBuild was true. But this data is only meant for
**StructureIconsLayer** and it logically only uses it when placing a new
unit, not when upgrading one. Which is also commented on line 351 of
**StructureIconsLayer**. So, we now only get overlapping railroads and
ghost rails if we're not hovering to upgrade an existing unit.

- **PlayerImpl**: _findUnitToUpgrade_: unchanged functionality, but have
it call new private function _findExistingUnitToUpgrade_ to find
existing unit.

- **PlayerImpl**: _canBuild_: unchanged functionality, but have it call
new private function _canBuildUnitType_ to do the checks it first did
itself. And then new private function _canSpawnUnitType_ for the rest of
the checks. This way we can call _canBuildUnitType_ and
_canSpawnUnitType_ from _buildableUnits_ in a specific order to prevent
double/triple checks.

- **PlayerImpl**: _canBuildUnitType_: new private function to be shared
by _buildableUnits_, _canBuild_ and _canUpgradeUnit_ to be able do unit
type level checks in a specific order to prevent double/triple checks.
Via parameter knownCost, _buildableUnits_ can send it the cost it
already fetched so that it doesn't have to be fetched again. For caller
_canUpgradeUnit_, the isAlive() check (which was previously only done in
canBuild) is new but harmless, maybe even better to have also check
isAlive() on upgrade now that Nations are also upgrading which might
prevent some edge case bugs.

- **PlayerImpl**: _canUpgradeUnitType_: new private function to be
shared by _buildableUnits_ and _canUpgradeUnit_ to be able do unit type
level checks in a specific order to prevent double/triple checks.

- **PlayerImpl**: _canSpawnUnitType_: new private function to be shared
by _buildableUnits_ and _canBuildUnit_ to be able do unit type level
checks in a specific order to prevent double/triple checks.

- **PlayerImpl**: _findExistingUnitToUpgrade_: new private function to
be shared by _buildableUnits_ and _findUnitToUpgrade_ to be able do unit
level checks in a specific order to prevent double/triple checks.

- **PlayerImpl**: _isUnitValidToUpgrade_: new private function to be
shared by _buildableUnits_ and _canUpgradeUnit_ to be able do unit level
checks in a specific order to prevent double/triple checks.

- **PlayerImpl.test.ts**: because of the isAlive() check in which is new
for _canUpgradeUnit_ (see above at _canBuildUnitType_), the tests needed
to have the players be alive at the start, in order to pass.

- **BuildMenu**: use .find instead of .filter in canBuildOrUpgrade, a
function we already needed to change. This is faster and prevents an
allocation.


**PERFORMANCE**
As for calling ._buildables_ instead of unnecessarily getting
._actions_, there is an obvious win because there's less to send
calculate and recieve.

Also asking for only the needed buildings helps a lot (especially if
TradeShip isn't needed, see the difference in benchmark in original
#3193).

But the real-world impact is hard to measure. gave it a try in #3193 and
those results should be even better now.

Now testing only _buildableUnits_ performance in a synthetic benchmark,
we get these results. This is after other performance improvments so the
base is already better than it was in original #3193:

**BEFORE** (only buildableUnits itself)
<img width="602" height="96" alt="image"
src="https://github.com/user-attachments/assets/7770c0fa-a35e-42fc-90de-1de83242ec23"
/>

**AFTER** (only buildableUnits itself)
<img width="603" height="91" alt="image"
src="https://github.com/user-attachments/assets/a1578382-7010-4160-937c-7117bad18beb"
/>


## 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>
This commit is contained in:
VariableVince
2026-03-04 20:32:45 +01:00
committed by GitHub
parent 2a0b0f890d
commit e137fcaa6c
27 changed files with 391 additions and 213 deletions
+20 -20
View File
@@ -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 { BuildableUnit, Structures, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
@@ -548,7 +548,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(
@@ -556,7 +556,7 @@ export class ClientGameRunner {
this.myPlayer!.troops() * this.renderer.uiState.attackRatio,
),
);
} else if (this.canAutoBoat(actions, tile)) {
} else if (this.canAutoBoat(actions.buildableUnits, tile)) {
this.sendBoatAttackIntent(tile);
}
});
@@ -591,7 +591,7 @@ export class ClientGameRunner {
}
private findAndUpgradeNearestBuilding(clickedTile: TileRef) {
this.myPlayer!.actions(clickedTile).then((actions) => {
this.myPlayer!.actions(clickedTile, Structures.types).then((actions) => {
const upgradeUnits: {
unitId: number;
unitType: UnitType;
@@ -644,15 +644,17 @@ export class ClientGameRunner {
this.myPlayer = myPlayer;
}
this.myPlayer.actions(tile).then((actions) => {
if (this.canBoatAttack(actions) === false) {
console.warn(
"Boat attack triggered but can't send Transport Ship to tile",
);
return;
}
this.sendBoatAttackIntent(tile);
});
this.myPlayer
.buildables(tile, [UnitType.TransportShip])
.then((buildables) => {
if (this.canBoatAttack(buildables) !== false) {
this.sendBoatAttackIntent(tile);
} else {
console.warn(
"Boat attack triggered but can't send Transport Ship to tile",
);
}
});
}
private doGroundAttackUnderCursor(): void {
@@ -667,7 +669,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(
@@ -696,10 +698,8 @@ export class ClientGameRunner {
return this.gameView.ref(cell.x, cell.y);
}
private canBoatAttack(actions: PlayerActions): false | TileRef {
const bu = actions.buildableUnits.find(
(bu) => bu.type === UnitType.TransportShip,
);
private canBoatAttack(buildables: BuildableUnit[]): false | TileRef {
const bu = buildables.find((bu) => bu.type === UnitType.TransportShip);
return bu?.canBuild ?? false;
}
@@ -714,10 +714,10 @@ export class ClientGameRunner {
);
}
private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean {
private canAutoBoat(buildables: BuildableUnit[], tile: TileRef): boolean {
if (!this.gameView.isLand(tile)) return false;
const canBuild = this.canBoatAttack(actions);
const canBuild = this.canBoatAttack(buildables);
if (canBuild === false) return false;
// TODO: Global enable flag
+6 -4
View File
@@ -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";
@@ -83,11 +83,13 @@ export class RefreshGraphicsEvent implements GameEvent {}
export class TogglePerformanceOverlayEvent implements GameEvent {}
export class ToggleStructureEvent implements GameEvent {
constructor(public readonly structureTypes: UnitType[] | null) {}
constructor(
public readonly structureTypes: PlayerBuildableUnitType[] | null,
) {}
}
export class GhostStructureChangedEvent implements GameEvent {
constructor(public readonly ghostStructure: UnitType | null) {}
constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
}
export class SwapRocketDirectionEvent implements GameEvent {
@@ -609,7 +611,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));
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { AllPlayers, nukeTypes } from "../../core/game/Game";
import { AllPlayers, Nukes } from "../../core/game/Game";
import { GameView, PlayerView } from "../../core/game/GameView";
import allianceIcon from "/images/AllianceIcon.svg?url";
import allianceIconFaded from "/images/AllianceIconFaded.svg?url";
@@ -134,7 +134,7 @@ export function getPlayerIcons(
}
// Nuke icon (different color depending on whether the local player is the target)
const nukesSentByOtherPlayer = game.units(...nukeTypes).filter((unit) => {
const nukesSentByOtherPlayer = game.units(...Nukes.types).filter((unit) => {
const isSendingNuke = player.id() === unit.owner().id();
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
return isSendingNuke && notMyPlayer && unit.isActive();
+2 -2
View File
@@ -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;
+13 -17
View File
@@ -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,
BuildMenus,
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,20 +359,15 @@ 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,
);
if (unit.length === 0) {
return false;
}
return unit[0].canBuild !== false || unit[0].canUpgrade !== false;
const unit = this.playerBuildables.find((u) => u.type === item.unitType);
return unit ? unit.canBuild !== false || unit.canUpgrade !== false : false;
}
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 +415,7 @@ export class BuildMenu extends LitElement implements Layer {
(row) => html`
<div class="build-row">
${row.map((item) => {
const buildableUnit = this.playerActions?.buildableUnits.find(
const buildableUnit = this.playerBuildables?.find(
(bu) => bu.type === item.unitType,
);
if (buildableUnit === undefined) {
@@ -492,9 +488,9 @@ export class BuildMenu extends LitElement implements Layer {
private refresh() {
this.game
.myPlayer()
?.actions(this.clickedTile)
.then((actions) => {
this.playerActions = actions;
?.buildables(this.clickedTile, BuildMenus.types)
.then((buildables) => {
this.playerBuildables = buildables;
this.requestUpdate();
});
+1 -1
View File
@@ -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;
@@ -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;
+1 -1
View File
@@ -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?.allianceInfo?.expiresAt !== undefined) {
const expiresAt = this.actions.interaction.allianceInfo.expiresAt;
const remainingTicks = expiresAt - this.g.ticks();
@@ -1,8 +1,10 @@
import { Config } from "../../../core/configuration/Config";
import {
AllPlayers,
BuildableAttacks,
PlayerActions,
StructureTypes,
PlayerBuildableUnitType,
Structures,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
@@ -376,40 +378,34 @@ export const infoMenuElement: MenuElement = {
},
};
function getAllEnabledUnits(myPlayer: boolean, config: Config): Set<UnitType> {
const units: Set<UnitType> = new Set<UnitType>();
function getAllEnabledUnits(
myPlayer: boolean,
config: Config,
): Set<PlayerBuildableUnitType> {
const units: Set<PlayerBuildableUnitType> =
new Set<PlayerBuildableUnitType>();
const addIfEnabled = (unitType: UnitType) => {
const addIfEnabled = (unitType: PlayerBuildableUnitType) => {
if (!config.isUnitDisabled(unitType)) {
units.add(unitType);
}
};
if (myPlayer) {
StructureTypes.forEach(addIfEnabled);
Structures.types.forEach(addIfEnabled);
} else {
addIfEnabled(UnitType.Warship);
addIfEnabled(UnitType.HydrogenBomb);
addIfEnabled(UnitType.MIRV);
addIfEnabled(UnitType.AtomBomb);
BuildableAttacks.types.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<UnitType> = getAllEnabledUnits(
const unitTypes: Set<PlayerBuildableUnitType> = getAllEnabledUnits(
params.selected === params.myPlayer,
params.game.config(),
);
@@ -419,8 +415,8 @@ function createMenuElements(
(item) =>
unitTypes.has(item.unitType) &&
(filterType === "attack"
? ATTACK_UNIT_TYPES.includes(item.unitType)
: !ATTACK_UNIT_TYPES.includes(item.unitType)),
? BuildableAttacks.has(item.unitType)
: !BuildableAttacks.has(item.unitType)),
)
.map((item: BuildItemDisplay) => {
const canBuildOrUpgrade = params.buildMenu.canBuildOrUpgrade(item);
@@ -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;
@@ -8,7 +8,9 @@ import { wouldNukeBreakAlliance } from "../../../core/execution/Util";
import {
BuildableUnit,
Cell,
PlayerBuildableUnitType,
PlayerID,
Structures,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
@@ -84,14 +86,10 @@ export class StructureIconsLayer implements Layer {
private readonly mousePos = { x: 0, y: 0 };
private renderSprites = true;
private factory: SpriteFactory;
private readonly structures: Map<UnitType, { visible: boolean }> = new Map([
[UnitType.City, { visible: true }],
[UnitType.Factory, { visible: true }],
[UnitType.DefensePost, { visible: true }],
[UnitType.Port, { visible: true }],
[UnitType.MissileSilo, { visible: true }],
[UnitType.SAMLauncher, { visible: true }],
]);
private readonly structures: Map<
PlayerBuildableUnitType,
{ visible: boolean }
> = new Map(Structures.types.map((type) => [type, { visible: true }]));
private lastGhostQueryAt: number;
private visibilityStateDirty = true;
private hasHiddenStructure = false;
@@ -299,8 +297,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 = [];
@@ -311,7 +309,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();
@@ -453,7 +451,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) {
@@ -559,7 +557,9 @@ export class StructureIconsLayer implements Layer {
}
}
private toggleStructures(toggleStructureType: UnitType[] | null): void {
private toggleStructures(
toggleStructureType: PlayerBuildableUnitType[] | null,
): void {
for (const [structureType, infos] of this.structures) {
infos.visible =
toggleStructureType?.indexOf(structureType) !== -1 ||
@@ -602,7 +602,9 @@ export class StructureIconsLayer implements Layer {
this.checkForOwnershipChange(render, unitView);
this.checkForLevelChange(render, unitView);
}
} else if (this.structures.has(unitView.type())) {
} else if (
this.structures.has(unitView.type() as PlayerBuildableUnitType)
) {
this.addNewStructure(unitView);
}
}
@@ -621,7 +623,7 @@ export class StructureIconsLayer implements Layer {
private modifyVisibility(render: StructureRenderInfo) {
this.refreshVisibilityStateCache();
const structureType = render.unit.type();
const structureType = render.unit.type() as PlayerBuildableUnitType;
const structureInfos = this.structures.get(structureType);
if (structureInfos) {
+16 -23
View File
@@ -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,
BuildMenus,
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<string, { value: string; key: string }> = {};
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 = BuildMenus.types.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, BuildMenus.types).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,
) {
+17 -3
View File
@@ -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,
@@ -189,18 +191,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;
+2 -2
View File
@@ -1,4 +1,4 @@
import { Execution, Game, isStructureType, Player } from "../game/Game";
import { Execution, Game, Player, Structures } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
@@ -85,7 +85,7 @@ export class BotExecution implements Execution {
private deleteAllStructures() {
for (const unit of this.bot.units()) {
if (isStructureType(unit.type()) && this.bot.canDeleteUnit()) {
if (Structures.has(unit.type()) && this.bot.canDeleteUnit()) {
this.mg.addExecution(new DeleteUnitExecution(this.bot, unit.id()));
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
import {
Execution,
Game,
isStructureType,
MessageType,
Player,
Structures,
TerraNullius,
TrajectoryTile,
Unit,
@@ -346,7 +346,7 @@ export class NukeExecution implements Execution {
private redrawBuildings(range: number) {
const rangeSquared = range * range;
for (const unit of this.mg.units()) {
if (isStructureType(unit.type())) {
if (Structures.has(unit.type())) {
if (
this.mg.euclideanDistSquared(this.dst, unit.tile()) < rangeSquared
) {
+2 -2
View File
@@ -3,8 +3,8 @@ import {
Cell,
Execution,
Game,
isStructureType,
Player,
Structures,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
@@ -42,7 +42,7 @@ export class PlayerExecution implements Execution {
tick(ticks: number) {
this.player.decayRelations();
for (const u of this.player.units()) {
if (!isStructureType(u.type())) {
if (!Structures.has(u.type())) {
continue;
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { NukeMagnitude } from "../configuration/Config";
import { Game, Player, StructureTypes } from "../game/Game";
import { Game, Player, Structures } from "../game/Game";
import { euclDistFN, GameMap, TileRef } from "../game/GameMap";
import { GameView } from "../game/GameView";
@@ -60,7 +60,7 @@ export function wouldNukeBreakAlliance(
const wouldDestroyAlliedStructure = game.anyUnitNearby(
targetTile,
magnitude.outer,
StructureTypes,
Structures.types,
(unit) =>
unit.owner().isPlayer() && allySmallIds.has(unit.owner().smallID()),
);
@@ -119,7 +119,7 @@ export function listNukeBreakAlliance(
// Also check if any allied structures would be destroyed
game
.nearbyUnits(targetTile, magnitude.outer, StructureTypes)
.nearbyUnits(targetTile, magnitude.outer, Structures.types)
.forEach(({ unit }) =>
playersToBreakAllianceWith.add(unit.owner().smallID()),
);
@@ -5,7 +5,7 @@ import {
Player,
PlayerType,
Relation,
StructureTypes,
Structures,
Unit,
UnitType,
} from "../../game/Game";
@@ -356,7 +356,7 @@ export class NationStructureBehavior {
private getTotalStructureDensity(): number {
const tilesOwned = this.player.numTilesOwned();
return tilesOwned > 0
? this.player.units(...StructureTypes).length / tilesOwned
? this.player.units(...Structures.types).length / tilesOwned
: 0; //ignoring levels for structures
}
+4 -4
View File
@@ -3,11 +3,11 @@ import {
Game,
GameMode,
HumansVsNations,
isStructureType,
Player,
PlayerID,
PlayerType,
Relation,
Structures,
TerraNullius,
UnitType,
} from "../../game/Game";
@@ -361,7 +361,7 @@ export class AiAttackBehavior {
n.isPlayer() &&
n.type() === PlayerType.Bot &&
!this.player.isFriendly(n) &&
n.units().some((u) => isStructureType(u.type())),
n.units().some((u) => Structures.has(u.type())),
);
}
@@ -419,7 +419,7 @@ export class AiAttackBehavior {
const density = (p: Player) => p.troops() / p.numTilesOwned();
const ownsStructures = (p: Player) =>
p.units().some((u) => isStructureType(u.type()));
p.units().some((u) => Structures.has(u.type()));
const sortedBots = bots.slice().sort((a, b) => {
const aHasStructures = ownsStructures(a);
const bHasStructures = ownsStructures(b);
@@ -743,7 +743,7 @@ export class AiAttackBehavior {
const botWithStructures =
target.isPlayer() &&
target.type() === PlayerType.Bot &&
target.units().some((u) => isStructureType(u.type()));
target.units().some((u) => Structures.has(u.type()));
// Use the expand ratio when attacking a bot that owns structures — we need to
// recapture those structures ASAP, even before reaching the normal reserve.
const useReserve = target.isPlayer() && !botWithStructures;
+45 -16
View File
@@ -252,6 +252,15 @@ export interface UnitInfo {
upgradable?: boolean;
}
function unitTypeGroup<T extends readonly UnitType[]>(types: T) {
return {
types,
has(type: UnitType): type is T[number] {
return (types as readonly UnitType[]).includes(type);
},
};
}
export enum UnitType {
TransportShip = "Transport",
Warship = "Warship",
@@ -277,20 +286,40 @@ export enum TrainType {
Carriage = "Carriage",
}
const _structureTypes: ReadonlySet<UnitType> = new Set([
export const Nukes = unitTypeGroup([
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRVWarhead,
UnitType.MIRV,
] as const);
export const BuildableAttacks = unitTypeGroup([
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRV,
UnitType.Warship,
] as const);
export const Structures = unitTypeGroup([
UnitType.City,
UnitType.DefensePost,
UnitType.SAMLauncher,
UnitType.MissileSilo,
UnitType.Port,
UnitType.Factory,
]);
] as const);
export const StructureTypes: readonly UnitType[] = [..._structureTypes];
export const BuildMenus = unitTypeGroup([
...Structures.types,
...BuildableAttacks.types,
] as const);
export function isStructureType(type: UnitType): boolean {
return _structureTypes.has(type);
}
export const PlayerBuildable = unitTypeGroup([
...BuildMenus.types,
UnitType.TransportShip,
] as const);
export type PlayerBuildableUnitType = (typeof PlayerBuildable.types)[number];
export interface OwnerComp {
owner: Player;
@@ -361,13 +390,6 @@ export type UnitParams<T extends UnitType> = 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,
@@ -646,8 +668,15 @@ 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,
): TileRef | false;
buildUnit<T extends UnitType>(
type: T,
spawnTile: TileRef,
@@ -873,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[][];
+18 -1
View File
@@ -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,
@@ -415,7 +417,10 @@ export class PlayerView {
return { hasEmbargo, hasFriendly };
}
async actions(tile?: TileRef, units?: UnitType[]): Promise<PlayerActions> {
async actions(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[] | null,
): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
@@ -424,6 +429,18 @@ export class PlayerView {
);
}
async buildables(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[],
): Promise<BuildableUnit[]> {
return this.game.worker.playerBuildables(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async borderTiles(): Promise<PlayerBorderTiles> {
return this.game.worker.playerBorderTiles(this.id());
}
+105 -57
View File
@@ -23,16 +23,17 @@ import {
EmojiMessage,
GameMode,
Gold,
isStructureType,
MessageType,
MutableAlliance,
Player,
PlayerBuildable,
PlayerBuildableUnitType,
PlayerID,
PlayerInfo,
PlayerProfile,
PlayerType,
Relation,
StructureTypes,
Structures,
Team,
TerraNullius,
Tick,
@@ -988,6 +989,17 @@ export class PlayerImpl implements Player {
}
public findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false {
const unit = this.findExistingUnitToUpgrade(type, targetTile);
if (unit === false || !this.canUpgradeUnit(unit)) {
return false;
}
return unit;
}
private findExistingUnitToUpgrade(
type: UnitType,
targetTile: TileRef,
): Unit | false {
const range = this.mg.config().structureMinDist();
const existing = this.mg
.nearbyUnits(targetTile, range, type, undefined, true)
@@ -995,29 +1007,35 @@ export class PlayerImpl implements Player {
if (existing.length === 0) {
return false;
}
const unit = existing[0].unit;
if (!this.canUpgradeUnit(unit)) {
return false;
}
return unit;
return existing[0].unit;
}
public canUpgradeUnit(unit: Unit): boolean {
if (unit.isMarkedForDeletion()) {
private canBuildUnitType(
unitType: UnitType,
knownCost: Gold | null = null,
): boolean {
if (this.mg.config().isUnitDisabled(unitType)) {
return false;
}
const cost = knownCost ?? this.mg.unitInfo(unitType).cost(this.mg, this);
if (this._gold < cost) {
return false;
}
if (unitType !== UnitType.MIRVWarhead && !this.isAlive()) {
return false;
}
return true;
}
private canUpgradeUnitType(unitType: UnitType): boolean {
return Boolean(this.mg.config().unitInfo(unitType).upgradable);
}
private isUnitValidToUpgrade(unit: Unit): boolean {
if (unit.isUnderConstruction()) {
return false;
}
if (!this.mg.config().unitInfo(unit.type()).upgradable) {
return false;
}
if (this.mg.config().isUnitDisabled(unit.type())) {
return false;
}
if (
this._gold < this.mg.config().unitInfo(unit.type()).cost(this.mg, this)
) {
if (unit.isMarkedForDeletion()) {
return false;
}
if (unit.owner() !== this) {
@@ -1026,6 +1044,19 @@ export class PlayerImpl implements Player {
return true;
}
public canUpgradeUnit(unit: Unit): boolean {
if (!this.canUpgradeUnitType(unit.type())) {
return false;
}
if (!this.canBuildUnitType(unit.type())) {
return false;
}
if (!this.isUnitValidToUpgrade(unit)) {
return false;
}
return true;
}
upgradeUnit(unit: Unit) {
const cost = this.mg.unitInfo(unit.type()).cost(this.mg, this);
this.removeGold(cost);
@@ -1035,42 +1066,58 @@ export class PlayerImpl implements Player {
public buildableUnits(
tile: TileRef | null,
units?: UnitType[],
units: readonly PlayerBuildableUnitType[] = PlayerBuildable.types,
): 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)))
tile !== null && units.some((u) => Structures.has(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) {
const len = units.length;
const result = new Array<BuildableUnit>(len);
for (let i = 0; i < len; i++) {
const u = units[i];
const cost = config.unitInfo(u).cost(mg, this);
let canUpgrade: number | false = false;
let canBuild: TileRef | false = false;
if (tile !== null && this.canBuildUnitType(u, cost) && !inSpawnPhase) {
if (this.canUpgradeUnitType(u)) {
const existingUnit = this.findExistingUnitToUpgrade(u, tile);
if (
existingUnit !== false &&
this.isUnitValidToUpgrade(existingUnit)
) {
canUpgrade = existingUnit.id();
}
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)
: [],
ghostRailPaths:
canBuild !== false
? this.mg.railNetwork().computeGhostRailPaths(u, canBuild)
: [],
};
});
canBuild = this.canSpawnUnitType(u, tile, validTiles);
}
const buildNew = canBuild !== false && canUpgrade === false;
result[i] = {
type: u,
canBuild,
canUpgrade,
cost,
overlappingRailroads: buildNew
? rail.overlappingRailroads(canBuild as TileRef)
: [],
ghostRailPaths: buildNew
? rail.computeGhostRailPaths(u, canBuild as TileRef)
: [],
};
}
return result;
}
canBuild(
@@ -1078,17 +1125,18 @@ export class PlayerImpl implements Player {
targetTile: TileRef,
validTiles: TileRef[] | null = null,
): TileRef | false {
if (this.mg.config().isUnitDisabled(unitType)) {
if (!this.canBuildUnitType(unitType)) {
return false;
}
const cost = this.mg.unitInfo(unitType).cost(this.mg, this);
if (
unitType !== UnitType.MIRVWarhead &&
(!this.isAlive() || this.gold() < cost)
) {
return false;
}
return this.canSpawnUnitType(unitType, targetTile, validTiles);
}
private canSpawnUnitType(
unitType: UnitType,
targetTile: TileRef,
validTiles: TileRef[] | null,
): TileRef | false {
switch (unitType) {
case UnitType.MIRV:
if (!this.mg.hasOwner(targetTile)) {
@@ -1144,7 +1192,7 @@ export class PlayerImpl implements Player {
const wouldHitTeammate = this.mg.anyUnitNearby(
tile,
magnitude.outer,
StructureTypes,
Structures.types,
(unit) => unit.owner().isPlayer() && this.isOnSameTeam(unit.owner()),
);
if (wouldHitTeammate) {
@@ -1227,7 +1275,7 @@ export class PlayerImpl implements Player {
const nearbyUnits = this.mg.nearbyUnits(
tile,
searchRadius * 2,
StructureTypes,
Structures.types,
undefined,
true,
);
+2 -4
View File
@@ -149,9 +149,7 @@ export class UnitGrid {
);
const rangeSquared = searchRange * searchRange;
// `Array.isArray` does not reliably narrow `readonly T[]` in TS, so use a
// cheap runtime check that narrows correctly for our string-backed UnitType.
if (typeof types !== "string") {
if (Array.isArray(types)) {
for (let cy = startGridY; cy <= endGridY; cy++) {
for (let cx = startGridX; cx <= endGridX; cx++) {
const cell = this.grid[cy][cx];
@@ -182,7 +180,7 @@ export class UnitGrid {
const type = types;
for (let cy = startGridY; cy <= endGridY; cy++) {
for (let cx = startGridX; cx <= endGridX; cx++) {
const unitSet = this.grid[cy][cx].get(type);
const unitSet = this.grid[cy][cx].get(type as UnitType);
if (unitSet === undefined) continue;
for (const unit of unitSet) {
if (!unit.isActive()) continue;
+23
View File
@@ -8,6 +8,7 @@ import {
MainThreadMessage,
PlayerActionsResultMessage,
PlayerBorderTilesResultMessage,
PlayerBuildablesResultMessage,
PlayerProfileResultMessage,
TransportShipSpawnResultMessage,
WorkerMessage,
@@ -184,6 +185,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
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");
+37 -2
View File
@@ -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";
@@ -166,7 +167,7 @@ export class WorkerClient {
playerID: PlayerID,
x?: number,
y?: number,
units?: UnitType[],
units?: readonly PlayerBuildableUnitType[] | null,
): Promise<PlayerActions> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
@@ -196,6 +197,40 @@ export class WorkerClient {
});
}
playerBuildables(
playerID: PlayerID,
x?: number,
y?: number,
units?: readonly PlayerBuildableUnitType[],
): Promise<BuildableUnit[]> {
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,
+20 -2
View File
@@ -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_batch"
| "player_actions"
| "player_actions_result"
| "player_buildables"
| "player_buildables_result"
| "player_profile"
| "player_profile_result"
| "player_border_tiles"
@@ -64,7 +67,7 @@ export interface PlayerActionsMessage extends BaseWorkerMessage {
playerID: PlayerID;
x?: number;
y?: number;
units?: UnitType[];
units?: readonly PlayerBuildableUnitType[] | null;
}
export interface PlayerActionsResultMessage extends BaseWorkerMessage {
@@ -72,6 +75,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
@@ -131,6 +148,7 @@ export type WorkerMessage =
| GameUpdateMessage
| GameUpdateBatchMessage
| PlayerActionsResultMessage
| PlayerBuildablesResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage
+4 -1
View File
@@ -29,9 +29,12 @@ describe("PlayerImpl", () => {
}
player = game.player("player_id");
player.addGold(BigInt(1000000));
other = game.player("other_id");
player.conquer(game.ref(0, 0));
other.conquer(game.ref(50, 50));
player.addGold(BigInt(1000000));
game.config().structureMinDist = () => 10;
});