mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-13 05:13:53 +00:00
9e9c608053
## Summary Round 2 of GC-churn reduction, attacking the next tier of allocation sources found by the profiling harness from #4494. All changes are **behavior-preserving** — the simulation is bit-identical (final hash unchanged on three seeded runs). ### Changes | Site | Change | Churn target | |---|---|---| | `Player.units()` / `Game.units()` | Rest parameter → fixed-arity + array overloads (`units()`, `units(types[])`, `units(t1, t2?, t3?)`). The rest array was allocated on **every call** of one of the hottest functions in the sim. Spread call sites (`units(...Structures.types)`) now pass the array directly. `GameImpl.units()` builds one flat array instead of `Array.from().flatMap()` per-player intermediates. | ~18 GB | | `PlayerExecution` cluster flood fill | Results are plain `TileRef[]` in mark order instead of `Set<TileRef>` — the generation-stamped visited array already deduplicates, and consumers only iterate/measure. DFS stack reused across fills. | ~3.7 GB | | `SpatialQuery.bfsNearest` | Fused generation-stamped BFS with per-game scratch buffers (`WeakMap`-keyed, same pattern as `PlayerExecution`) instead of materializing a `Set` of the entire search area per query. Identical traversal and tie-breaking. | ~2.2 GB | | `NationWarshipBehavior` ship tracking | Single-pass loops instead of `filter().forEach()`; dropped defensive `Array.from(set)` copies (deleting the current entry while iterating a `Set` is well-defined). | ~1.4 GB | ### Results (Giant World Map, 400 bots, 12,000 ticks ≈ 20 game-min, seed `perf-default`) | Metric | Before | After | vs. pre-#4494 | |---|---|---|---| | Sampled allocations (incl. collected) | 59.2 GB | **37.8 GB (−36%)** | 97.7 GB (**−61%**) | | GC count / total pause | 1,076 / 1,830 ms | 772 / 1,442 ms | 1,682 / 3,313 ms | | Ticks/sec | 73 | **82** | 66 (+24%) | | Mean / p99 tick | 13.6 / 39.2 ms | 12.2 / 36.0 ms | 15.2 / 49.9 ms | `units()` no longer appears in the top-30 allocator list at all. The remaining leaders (possible round 3): the minimap pathfinding `Cell` pipeline (~8.5 GB), `diffPlayerUpdate`/`toFullUpdate` per-tick serialization (~4.6 GB), and iterator allocations (~3.3 GB). ## Determinism Final game-state hash unchanged on all three reference runs: - Giant World Map 12,000 ticks: `57830793797434300` ✓ - Giant World Map 2,000 ticks: `55125379638382860` ✓ - World 1,800 ticks: `32337437717390864` ✓ ## Test plan - [x] Full suite green (1,905 tests), including updated `units()` semantics tests (array overload, snapshot isolation, insertion order) - [x] Hash equality on 3 seeded headless runs (2 maps) - [x] Before/after 20-min GC benchmarks on the same commit base 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
980 lines
26 KiB
TypeScript
980 lines
26 KiB
TypeScript
import { Config } from "../configuration/Config";
|
||
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
|
||
import { PathFinder } from "../pathfinding/types";
|
||
import { AllPlayersStats, ClientID } from "../Schemas";
|
||
import { formatPlayerDisplayName } from "../Util";
|
||
import { GameMap, TileRef } from "./GameMap";
|
||
import {
|
||
GameUpdate,
|
||
GameUpdateType,
|
||
PlayerUpdate,
|
||
UnitUpdate,
|
||
} from "./GameUpdates";
|
||
import { MotionPlanRecord } from "./MotionPlans";
|
||
import { RailNetwork } from "./RailNetwork";
|
||
import { Stats } from "./Stats";
|
||
import { UnitPredicate } from "./UnitGrid";
|
||
|
||
function isEnumValue<T extends Record<string, string | number>>(
|
||
enumObj: T,
|
||
value: unknown,
|
||
): value is T[keyof T] {
|
||
return Object.values(enumObj).includes(value as T[keyof T]);
|
||
}
|
||
|
||
export type PlayerID = string;
|
||
export type Tick = number;
|
||
export type Gold = bigint;
|
||
|
||
export type WarshipState = {
|
||
state: "patrolling" | "retreating" | "docked";
|
||
patrolTile?: TileRef;
|
||
retreatPort?: TileRef;
|
||
isInCombat?: boolean;
|
||
lastCombatTick: number;
|
||
// Veterancy level (0–max) plus a shared integer progress meter fed by
|
||
// transport kills and trade captures (see UnitImpl.addVeterancyProgress).
|
||
veterancy: number;
|
||
veterancyProgress: number;
|
||
};
|
||
|
||
export type TransportShipState = {
|
||
isRetreating: boolean;
|
||
troops: number;
|
||
};
|
||
|
||
export const AllPlayers = "AllPlayers" as const;
|
||
|
||
// export type GameUpdates = Record<GameUpdateType, GameUpdate[]>;
|
||
// Create a type that maps GameUpdateType to its corresponding update type
|
||
type UpdateTypeMap<T extends GameUpdateType> = Extract<GameUpdate, { type: T }>;
|
||
|
||
// Then use it to create the record type
|
||
export type GameUpdates = {
|
||
[K in GameUpdateType]: UpdateTypeMap<K>[];
|
||
};
|
||
|
||
export interface MapPos {
|
||
x: number;
|
||
y: number;
|
||
}
|
||
|
||
export enum Difficulty {
|
||
Easy = "Easy",
|
||
Medium = "Medium",
|
||
Hard = "Hard",
|
||
Impossible = "Impossible",
|
||
}
|
||
export const isDifficulty = (value: unknown): value is Difficulty =>
|
||
isEnumValue(Difficulty, value);
|
||
|
||
export type Team = string;
|
||
|
||
export interface SpawnArea {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
export type TeamGameSpawnAreas = Record<string, SpawnArea[]>;
|
||
|
||
export const Duos = "Duos" as const;
|
||
export const Trios = "Trios" as const;
|
||
export const Quads = "Quads" as const;
|
||
export const HumansVsNations = "Humans Vs Nations" as const;
|
||
|
||
export const ColoredTeams: Record<string, Team> = {
|
||
Red: "Red",
|
||
Blue: "Blue",
|
||
Teal: "Teal",
|
||
Purple: "Purple",
|
||
Yellow: "Yellow",
|
||
Orange: "Orange",
|
||
Green: "Green",
|
||
Bot: "Bot",
|
||
Humans: "Humans",
|
||
Nations: "Nations",
|
||
} as const;
|
||
|
||
// GameMapType and the maps list are generated from
|
||
// map-generator/assets/maps/<map>/info.json by the map-generator
|
||
// (`npm run gen-maps`).
|
||
export {
|
||
GameMapType,
|
||
mapCategoryOrder,
|
||
maps,
|
||
type GameMapName,
|
||
type MapCategory,
|
||
type MapInfo,
|
||
} from "./Maps.gen";
|
||
|
||
export enum GameType {
|
||
Singleplayer = "Singleplayer",
|
||
Public = "Public",
|
||
Private = "Private",
|
||
}
|
||
export const isGameType = (value: unknown): value is GameType =>
|
||
isEnumValue(GameType, value);
|
||
|
||
export enum GameMode {
|
||
FFA = "Free For All",
|
||
Team = "Team",
|
||
}
|
||
|
||
export enum RankedType {
|
||
OneVOne = "1v1",
|
||
}
|
||
|
||
export const isGameMode = (value: unknown): value is GameMode =>
|
||
isEnumValue(GameMode, value);
|
||
|
||
export enum GameMapSize {
|
||
Compact = "Compact",
|
||
Normal = "Normal",
|
||
}
|
||
|
||
export interface PublicGameModifiers {
|
||
isCompact?: boolean;
|
||
isRandomSpawn?: boolean;
|
||
isCrowded?: boolean;
|
||
isHardNations?: boolean;
|
||
startingGold?: number;
|
||
goldMultiplier?: number;
|
||
isAlliancesDisabled?: boolean;
|
||
isPortsDisabled?: boolean;
|
||
isNukesDisabled?: boolean;
|
||
isSAMsDisabled?: boolean;
|
||
isPeaceTime?: boolean;
|
||
isWaterNukes?: boolean;
|
||
}
|
||
|
||
export interface UnitInfo {
|
||
cost: (game: Game, player: Player) => Gold;
|
||
maxHealth?: number;
|
||
damage?: number;
|
||
constructionDuration?: number;
|
||
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",
|
||
Shell = "Shell",
|
||
SAMMissile = "SAMMissile",
|
||
Port = "Port",
|
||
AtomBomb = "Atom Bomb",
|
||
HydrogenBomb = "Hydrogen Bomb",
|
||
TradeShip = "Trade Ship",
|
||
MissileSilo = "Missile Silo",
|
||
DefensePost = "Defense Post",
|
||
SAMLauncher = "SAM Launcher",
|
||
City = "City",
|
||
MIRV = "MIRV",
|
||
MIRVWarhead = "MIRV Warhead",
|
||
Train = "Train",
|
||
Factory = "Factory",
|
||
}
|
||
|
||
export enum TrainType {
|
||
Engine = "Engine",
|
||
TailEngine = "TailEngine",
|
||
Carriage = "Carriage",
|
||
}
|
||
|
||
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 BuildMenus = unitTypeGroup([
|
||
...Structures.types,
|
||
...BuildableAttacks.types,
|
||
] as const);
|
||
|
||
export const PlayerBuildable = unitTypeGroup([
|
||
...BuildMenus.types,
|
||
UnitType.TransportShip,
|
||
] as const);
|
||
|
||
export type PlayerBuildableUnitType = (typeof PlayerBuildable.types)[number];
|
||
|
||
export interface OwnerComp {
|
||
owner: Player;
|
||
}
|
||
|
||
export type TrajectoryTile = {
|
||
tile: TileRef;
|
||
targetable: boolean;
|
||
};
|
||
export interface UnitParamsMap {
|
||
[UnitType.TransportShip]: {
|
||
troops?: number;
|
||
targetTile?: TileRef;
|
||
};
|
||
|
||
[UnitType.Warship]: {
|
||
patrolTile: TileRef;
|
||
};
|
||
|
||
[UnitType.Shell]: Record<string, never>;
|
||
|
||
[UnitType.SAMMissile]: Record<string, never>;
|
||
|
||
[UnitType.Port]: Record<string, never>;
|
||
|
||
[UnitType.AtomBomb]: {
|
||
targetTile?: number;
|
||
trajectory: TrajectoryTile[];
|
||
};
|
||
|
||
[UnitType.HydrogenBomb]: {
|
||
targetTile?: number;
|
||
trajectory: TrajectoryTile[];
|
||
};
|
||
|
||
[UnitType.TradeShip]: {
|
||
targetUnit: Unit;
|
||
lastSetSafeFromPirates?: number;
|
||
};
|
||
|
||
[UnitType.Train]: {
|
||
trainType: TrainType;
|
||
targetUnit?: Unit;
|
||
loaded?: boolean;
|
||
};
|
||
|
||
[UnitType.Factory]: Record<string, never>;
|
||
|
||
[UnitType.MissileSilo]: Record<string, never>;
|
||
|
||
[UnitType.DefensePost]: Record<string, never>;
|
||
|
||
[UnitType.SAMLauncher]: Record<string, never>;
|
||
|
||
[UnitType.City]: Record<string, never>;
|
||
|
||
[UnitType.MIRV]: {
|
||
targetTile?: number;
|
||
};
|
||
|
||
[UnitType.MIRVWarhead]: {
|
||
targetTile?: number;
|
||
};
|
||
}
|
||
|
||
// Type helper to get params type for a specific unit type
|
||
export type UnitParams<T extends UnitType> = UnitParamsMap[T];
|
||
|
||
export type AllUnitParams = UnitParamsMap[keyof UnitParamsMap];
|
||
|
||
export enum Relation {
|
||
Hostile = 0,
|
||
Distrustful = 1,
|
||
Neutral = 2,
|
||
Friendly = 3,
|
||
}
|
||
|
||
export class Nation {
|
||
constructor(
|
||
public readonly spawnCell: Cell | undefined,
|
||
public readonly playerInfo: PlayerInfo,
|
||
) {}
|
||
}
|
||
|
||
export class Cell {
|
||
public index: number;
|
||
|
||
private strRepr: string;
|
||
|
||
constructor(
|
||
public readonly x: number,
|
||
public readonly y: number,
|
||
) {
|
||
this.strRepr = `Cell[${this.x},${this.y}]`;
|
||
}
|
||
|
||
pos(): MapPos {
|
||
return {
|
||
x: this.x,
|
||
y: this.y,
|
||
};
|
||
}
|
||
|
||
toString(): string {
|
||
return this.strRepr;
|
||
}
|
||
}
|
||
|
||
export enum TerrainType {
|
||
Plains,
|
||
Highland,
|
||
Mountain,
|
||
Ocean,
|
||
Impassable,
|
||
}
|
||
|
||
export enum PlayerType {
|
||
Bot = "BOT",
|
||
Human = "HUMAN",
|
||
Nation = "NATION",
|
||
}
|
||
|
||
export interface Execution {
|
||
isActive(): boolean;
|
||
activeDuringSpawnPhase(): boolean;
|
||
init(mg: Game, ticks: number): void;
|
||
tick(ticks: number): void;
|
||
}
|
||
|
||
export interface Attack {
|
||
id(): string;
|
||
retreating(): boolean;
|
||
retreated(): boolean;
|
||
orderRetreat(): void;
|
||
executeRetreat(): void;
|
||
target(): Player | TerraNullius;
|
||
attacker(): Player;
|
||
troops(): number;
|
||
setTroops(troops: number): void;
|
||
isActive(): boolean;
|
||
delete(): void;
|
||
// The tile the attack originated from, mostly used for boat attacks.
|
||
sourceTile(): TileRef | null;
|
||
addBorderTile(tile: TileRef): void;
|
||
removeBorderTile(tile: TileRef): void;
|
||
clearBorder(): void;
|
||
borderSize(): number;
|
||
clusteredPositions(): TileRef[];
|
||
}
|
||
|
||
export interface AllianceRequest {
|
||
accept(): void;
|
||
reject(): void;
|
||
requestor(): Player;
|
||
recipient(): Player;
|
||
createdAt(): Tick;
|
||
status(): "pending" | "accepted" | "rejected";
|
||
}
|
||
|
||
export interface Alliance {
|
||
requestor(): Player;
|
||
recipient(): Player;
|
||
createdAt(): Tick;
|
||
expiresAt(): Tick;
|
||
other(player: Player): Player;
|
||
}
|
||
|
||
export interface MutableAlliance extends Alliance {
|
||
expire(): void;
|
||
other(player: Player): Player;
|
||
bothAgreedToExtend(): boolean;
|
||
addExtensionRequest(player: Player): void;
|
||
id(): number;
|
||
extend(): void;
|
||
onlyOneAgreedToExtend(): boolean;
|
||
|
||
agreedToExtend(player: Player): boolean;
|
||
}
|
||
|
||
export class PlayerInfo {
|
||
public readonly displayName: string;
|
||
|
||
constructor(
|
||
public readonly name: string,
|
||
public readonly playerType: PlayerType,
|
||
// null if tribe.
|
||
public readonly clientID: ClientID | null,
|
||
// TODO: make player id the small id
|
||
public readonly id: PlayerID,
|
||
public readonly isLobbyCreator: boolean = false,
|
||
public readonly clanTag: string | null = null,
|
||
public readonly friends: ClientID[] = [],
|
||
) {
|
||
this.displayName = formatPlayerDisplayName(this.name, this.clanTag);
|
||
}
|
||
}
|
||
|
||
export function isUnit(unit: unknown): unit is Unit {
|
||
return (
|
||
unit &&
|
||
typeof unit === "object" &&
|
||
"isUnit" in unit &&
|
||
typeof unit.isUnit === "function" &&
|
||
unit.isUnit()
|
||
);
|
||
}
|
||
|
||
export interface Unit {
|
||
isUnit(): this is Unit;
|
||
|
||
// Common properties.
|
||
id(): number;
|
||
type(): UnitType;
|
||
owner(): Player;
|
||
info(): UnitInfo;
|
||
isMarkedForDeletion(): boolean;
|
||
markForDeletion(): void;
|
||
isOverdueDeletion(): boolean;
|
||
delete(displayMessage?: boolean, destroyer?: Player): void;
|
||
tile(): TileRef;
|
||
lastTile(): TileRef;
|
||
move(tile: TileRef): void;
|
||
isActive(): boolean;
|
||
setOwner(owner: Player): void;
|
||
touch(): void;
|
||
hash(): number;
|
||
toUpdate(): UnitUpdate;
|
||
hasTrainStation(): boolean;
|
||
setTrainStation(trainStation: boolean): void;
|
||
wasDestroyedByEnemy(): boolean;
|
||
destroyer(): Player | undefined;
|
||
|
||
// Train
|
||
trainType(): TrainType | undefined;
|
||
isLoaded(): boolean | undefined;
|
||
setLoaded(loaded: boolean): void;
|
||
|
||
// Targeting
|
||
setTargetTile(cell: TileRef | undefined): void;
|
||
targetTile(): TileRef | undefined;
|
||
setTrajectoryIndex(i: number): void;
|
||
trajectoryIndex(): number;
|
||
trajectory(): TrajectoryTile[];
|
||
setTargetUnit(unit: Unit | undefined): void;
|
||
targetUnit(): Unit | undefined;
|
||
setTargetedBySAM(targeted: boolean): void;
|
||
targetedBySAM(): boolean;
|
||
setReachedTarget(): void;
|
||
reachedTarget(): boolean;
|
||
isTargetable(): boolean;
|
||
setTargetable(targetable: boolean): void;
|
||
|
||
// Health
|
||
hasHealth(): boolean;
|
||
warshipState(): WarshipState;
|
||
updateWarshipState(update: Partial<WarshipState>): void;
|
||
transportShipState(): TransportShipState;
|
||
updateTransportShipState(update: Partial<TransportShipState>): void;
|
||
health(): number;
|
||
/** Effective max health, including any warship veterancy bonus. */
|
||
maxHealth(): number;
|
||
modifyHealth(delta: number, attacker?: Player): void;
|
||
|
||
// Warship veterancy
|
||
/** Current veterancy level from warshipState (0 for non-warships). */
|
||
veterancy(): number;
|
||
/** Record this warship destroying an enemy unit (drives veterancy gain). */
|
||
recordKill(targetType: UnitType): void;
|
||
/** Record this warship capturing a trade ship (drives veterancy gain). */
|
||
recordTradeCapture(): void;
|
||
|
||
// Troops
|
||
setTroops(troops: number): void;
|
||
troops(): number;
|
||
|
||
// --- UNIT SPECIFIC ---
|
||
|
||
// SAMs & Missile Silos
|
||
launch(): void;
|
||
reloadMissile(): void;
|
||
isInCooldown(): boolean;
|
||
missileTimerQueue(): number[];
|
||
|
||
// Trade Ships
|
||
setSafeFromPirates(): void; // Only for trade ships
|
||
isSafeFromPirates(): boolean; // Only for trade ships
|
||
|
||
// Construction phase on structures
|
||
isUnderConstruction(): boolean;
|
||
setUnderConstruction(underConstruction: boolean): void;
|
||
|
||
// Upgradable Structures
|
||
level(): number;
|
||
increaseLevel(): void;
|
||
decreaseLevel(destroyer?: Player): void;
|
||
}
|
||
|
||
export interface TerraNullius {
|
||
isPlayer(): false;
|
||
id(): null;
|
||
clientID(): ClientID;
|
||
smallID(): number;
|
||
}
|
||
|
||
export interface Embargo {
|
||
createdAt: Tick;
|
||
isTemporary: boolean;
|
||
target: Player;
|
||
}
|
||
|
||
export interface Player {
|
||
// Basic Info
|
||
smallID(): number;
|
||
info(): PlayerInfo;
|
||
name(): string;
|
||
displayName(): string;
|
||
clientID(): ClientID | null;
|
||
id(): PlayerID;
|
||
type(): PlayerType;
|
||
isPlayer(): this is Player;
|
||
toString(): string;
|
||
isLobbyCreator(): boolean;
|
||
|
||
// State & Properties
|
||
isAlive(): boolean;
|
||
isTraitor(): boolean;
|
||
markTraitor(): void;
|
||
// Doomsday Clock (anti-stall): marked when below the rising territory bar.
|
||
inDoomsdayClock(): boolean;
|
||
doomsdayClockTicks(): number;
|
||
enterDoomsdayClock(): void;
|
||
clearDoomsdayClock(): void;
|
||
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
|
||
lastTileChange(): Tick;
|
||
|
||
isDisconnected(): boolean;
|
||
markDisconnected(isDisconnected: boolean): void;
|
||
|
||
hasSpawned(): boolean;
|
||
setSpawnTile(spawnTile: TileRef): void;
|
||
spawnTile(): TileRef | undefined;
|
||
|
||
// Territory
|
||
tiles(): ReadonlySet<TileRef>;
|
||
borderTiles(): ReadonlySet<TileRef>;
|
||
numTilesOwned(): number;
|
||
conquer(tile: TileRef): void;
|
||
relinquish(tile: TileRef): void;
|
||
|
||
// Resources & Troops
|
||
gold(): Gold;
|
||
addGold(toAdd: Gold, tile?: TileRef): void;
|
||
removeGold(toRemove: Gold): Gold;
|
||
troops(): number;
|
||
setTroops(troops: number): void;
|
||
addTroops(troops: number): void;
|
||
removeTroops(troops: number): number;
|
||
|
||
// Units
|
||
// Fixed-arity + array overloads instead of a rest parameter: the rest array
|
||
// would be allocated on every call, and this is one of the hottest calls in
|
||
// the simulation. With no arguments the player's live unit array is
|
||
// returned — do not mutate it; typed queries return a fresh snapshot array.
|
||
units(): Unit[];
|
||
units(types: readonly UnitType[]): Unit[];
|
||
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
|
||
unitCount(type: UnitType): number;
|
||
unitsConstructed(type: UnitType): number;
|
||
unitsOwned(type: UnitType): number;
|
||
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,
|
||
params: UnitParams<T>,
|
||
): Unit;
|
||
|
||
// Returns the existing unit that can be upgraded,
|
||
// 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;
|
||
upgradeUnit(unit: Unit): void;
|
||
captureUnit(unit: Unit): void;
|
||
|
||
// Relations & Diplomacy
|
||
nearby(): (Player | TerraNullius)[];
|
||
sharesBorderWith(other: Player | TerraNullius): boolean;
|
||
relation(other: Player): Relation;
|
||
allRelationsSorted(): { player: Player; relation: Relation }[];
|
||
updateRelation(other: Player, delta: number): void;
|
||
decayRelations(): void;
|
||
isOnSameTeam(other: Player): boolean;
|
||
// Either allied or on same team.
|
||
isFriendly(other: Player, treatAFKFriendly?: boolean): boolean;
|
||
team(): Team | null;
|
||
incomingAllianceRequests(): AllianceRequest[];
|
||
outgoingAllianceRequests(): AllianceRequest[];
|
||
alliances(): MutableAlliance[];
|
||
expiredAlliances(): Alliance[];
|
||
allies(): Player[];
|
||
isAlliedWith(other: Player): boolean;
|
||
allianceWith(other: Player): MutableAlliance | null;
|
||
allianceInfo(other: Player): AllianceInfo | null;
|
||
canSendAllianceRequest(other: Player): boolean;
|
||
breakAlliance(alliance: Alliance): void;
|
||
removeAllAlliances(): void;
|
||
createAllianceRequest(recipient: Player): AllianceRequest | null;
|
||
betrayals(): number;
|
||
|
||
// Targeting
|
||
canTarget(other: Player): boolean;
|
||
target(other: Player): void;
|
||
targets(): Player[];
|
||
transitiveTargets(): Player[];
|
||
|
||
// Communication
|
||
canSendEmoji(recipient: Player | typeof AllPlayers): boolean;
|
||
outgoingEmojis(): EmojiMessage[];
|
||
sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void;
|
||
canSendQuickChat(recipient: Player): boolean;
|
||
recordQuickChat(recipient: Player): void;
|
||
|
||
// Donation
|
||
canDonateGold(recipient: Player): boolean;
|
||
canDonateTroops(recipient: Player): boolean;
|
||
donateTroops(recipient: Player, troops: number): boolean;
|
||
donateGold(recipient: Player, gold: Gold): boolean;
|
||
canDeleteUnit(): boolean;
|
||
recordDeleteUnit(): void;
|
||
canEmbargoAll(): boolean;
|
||
recordEmbargoAll(): void;
|
||
|
||
// Embargo
|
||
hasEmbargoAgainst(other: Player): boolean;
|
||
tradingPartners(): Player[];
|
||
addEmbargo(other: Player, isTemporary: boolean): void;
|
||
getEmbargoes(): Embargo[];
|
||
stopEmbargo(other: Player): void;
|
||
endTemporaryEmbargo(other: Player): void;
|
||
canTrade(other: Player): boolean;
|
||
|
||
// Attacking.
|
||
canAttack(tile: TileRef): boolean;
|
||
canAttackPlayer(player: Player, treatAFKFriendly?: boolean): boolean;
|
||
isImmune(): boolean;
|
||
|
||
createAttack(
|
||
target: Player | TerraNullius,
|
||
troops: number,
|
||
sourceTile: TileRef | null,
|
||
border: Set<number>,
|
||
): Attack;
|
||
outgoingAttacks(): Attack[];
|
||
incomingAttacks(): Attack[];
|
||
orderRetreat(attackID: string): void;
|
||
executeRetreat(attackID: string): void;
|
||
|
||
// Misc
|
||
toUpdate(
|
||
statsOut?: number[],
|
||
attackTroopsOut?: number[],
|
||
): PlayerUpdate | null;
|
||
playerProfile(): PlayerProfile;
|
||
// WARNING: this operation is expensive.
|
||
bestTransportShipSpawn(tile: TileRef): TileRef | false;
|
||
}
|
||
|
||
export interface Game extends GameMap {
|
||
// Map & Dimensions
|
||
isOnMap(cell: Cell): boolean;
|
||
width(): number;
|
||
height(): number;
|
||
map(): GameMap;
|
||
miniMap(): GameMap;
|
||
forEachTile(fn: (tile: TileRef) => void): void;
|
||
// Zero-allocation neighbor iteration (cardinal only), in the same N, S, W, E
|
||
// order as neighbors().
|
||
forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void;
|
||
// Writes the cardinal neighbors of ref into out (same N, S, W, E order as
|
||
// neighbors()) and returns the count. Reuse out across calls to avoid
|
||
// allocation.
|
||
neighbors4(ref: TileRef, out: TileRef[]): number;
|
||
// Zero-allocation neighbor iteration for performance-critical cluster calculation
|
||
// Alternative to neighborsWithDiag() that returns arrays
|
||
// Avoids creating intermediate arrays and uses a callback for better performance
|
||
forEachNeighborWithDiag(
|
||
tile: TileRef,
|
||
callback: (neighbor: TileRef) => void,
|
||
): void;
|
||
|
||
// Player Management
|
||
player(id: PlayerID): Player;
|
||
players(): Player[];
|
||
allPlayers(): Player[];
|
||
playerByClientID(id: ClientID): Player | null;
|
||
playerBySmallID(id: number): Player | TerraNullius;
|
||
hasPlayer(id: PlayerID): boolean;
|
||
addPlayer(playerInfo: PlayerInfo): Player;
|
||
terraNullius(): TerraNullius;
|
||
owner(ref: TileRef): Player | TerraNullius;
|
||
|
||
teams(): Team[];
|
||
teamSpawnArea(team: Team): SpawnArea | undefined;
|
||
|
||
// Alliances
|
||
expireAlliance(alliance: Alliance): void;
|
||
|
||
// Immunity timer
|
||
isSpawnImmunityActive(): boolean;
|
||
isNationSpawnImmunityActive(): boolean;
|
||
elapsedGameSeconds(): number;
|
||
|
||
// Game State
|
||
ticks(): Tick;
|
||
inSpawnPhase(): boolean;
|
||
endSpawnPhase(): void;
|
||
executeNextTick(): GameUpdates;
|
||
drainPackedTileUpdates(): Uint32Array;
|
||
recordMotionPlan(record: MotionPlanRecord): void;
|
||
drainPackedMotionPlans(): Uint32Array | null;
|
||
drainPackedPlayerUpdates(): Float64Array | null;
|
||
drainPackedAttackUpdates(): Float64Array | null;
|
||
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
|
||
getWinner(): Player | Team | null;
|
||
config(): Config;
|
||
isPaused(): boolean;
|
||
setPaused(paused: boolean): void;
|
||
|
||
// Units
|
||
unit(id: number): Unit | undefined;
|
||
// See Player.units() for why this is not a rest parameter.
|
||
units(): Unit[];
|
||
units(types: readonly UnitType[]): Unit[];
|
||
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
|
||
unitCount(type: UnitType): number;
|
||
unitInfo(type: UnitType): UnitInfo;
|
||
hasUnitNearby(
|
||
tile: TileRef,
|
||
searchRange: number,
|
||
type: UnitType,
|
||
playerId?: PlayerID,
|
||
includeUnderConstruction?: boolean,
|
||
): boolean;
|
||
anyUnitNearby(
|
||
tile: TileRef,
|
||
searchRange: number,
|
||
types: readonly UnitType[],
|
||
predicate: (unit: Unit) => boolean,
|
||
playerId?: PlayerID,
|
||
includeUnderConstruction?: boolean,
|
||
): boolean;
|
||
nearbyUnits(
|
||
tile: TileRef,
|
||
searchRange: number,
|
||
types: UnitType | readonly UnitType[],
|
||
predicate?: UnitPredicate,
|
||
includeUnderConstruction?: boolean,
|
||
): Array<{ unit: Unit; distSquared: number }>;
|
||
|
||
addExecution(...exec: Execution[]): void;
|
||
displayMessage(
|
||
message: string,
|
||
type: MessageType,
|
||
playerID: PlayerID | null,
|
||
goldAmount?: bigint,
|
||
params?: Record<string, string | number>,
|
||
unitID?: number,
|
||
focusPlayerID?: PlayerID,
|
||
): void;
|
||
displayIncomingUnit(
|
||
unitID: number,
|
||
message: string,
|
||
type: MessageType,
|
||
playerID: PlayerID | null,
|
||
): void;
|
||
|
||
displayChat(
|
||
message: string,
|
||
category: string,
|
||
target: PlayerID | undefined,
|
||
playerID: PlayerID | null,
|
||
isFrom: boolean,
|
||
recipient: string,
|
||
): void;
|
||
|
||
// Nations
|
||
nations(): Nation[];
|
||
|
||
numTilesWithFallout(): number;
|
||
stats(): Stats;
|
||
|
||
addUpdate(update: GameUpdate): void;
|
||
railNetwork(): RailNetwork;
|
||
conquerPlayer(conqueror: Player, conquered: Player): void;
|
||
miniWaterHPA(): PathFinder<number> | null;
|
||
miniWaterGraph(): AbstractGraph | null;
|
||
getWaterComponent(tile: TileRef): number | null;
|
||
hasWaterComponent(tile: TileRef, component: number): boolean;
|
||
/**
|
||
* Returns the approximate number of water tiles in the component
|
||
* containing `tile`, or null if the tile has no water component. Useful for
|
||
* filtering tiny water bodies (e.g. preventing AI port placement on ponds).
|
||
*/
|
||
getWaterComponentSize(tile: TileRef): number | null;
|
||
/**
|
||
* Returns the set of water components that `player` shares with at least one
|
||
* valid trade partner (cached). Used by nation AI for port-placement
|
||
* heuristics. `null` means no usable water body for ports.
|
||
*/
|
||
sharedWaterComponents(player: Player): Set<number> | null;
|
||
/** Incremented each time the water navigation graph is rebuilt (e.g. after nuke terrain change). */
|
||
waterGraphVersion(): number;
|
||
|
||
/** Queue a land tile for conversion to water (batched every few ticks). Tile must be unowned. */
|
||
queueWaterConversion(tile: TileRef): void;
|
||
}
|
||
|
||
export interface PlayerActions {
|
||
canAttack: boolean;
|
||
buildableUnits: BuildableUnit[];
|
||
canSendEmojiAllPlayers: boolean;
|
||
canEmbargoAll?: boolean;
|
||
interaction?: PlayerInteraction;
|
||
}
|
||
|
||
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: PlayerBuildableUnitType;
|
||
cost: Gold;
|
||
overlappingRailroads: TileRef[];
|
||
ghostRailPaths: TileRef[][];
|
||
}
|
||
|
||
export interface PlayerProfile {
|
||
relations: Record<number, Relation>;
|
||
alliances: number[];
|
||
}
|
||
|
||
export interface PlayerBorderTiles {
|
||
borderTiles: ReadonlySet<TileRef>;
|
||
}
|
||
|
||
export interface AllianceInfo {
|
||
expiresAt: Tick;
|
||
inExtensionWindow: boolean;
|
||
myPlayerAgreedToExtend: boolean;
|
||
otherAgreedToExtend: boolean;
|
||
canExtend: boolean;
|
||
}
|
||
|
||
export interface PlayerInteraction {
|
||
sharedBorder: boolean;
|
||
canSendEmoji: boolean;
|
||
canSendAllianceRequest: boolean;
|
||
canBreakAlliance: boolean;
|
||
canTarget: boolean;
|
||
canDonateGold: boolean;
|
||
canDonateTroops: boolean;
|
||
canEmbargo: boolean;
|
||
allianceInfo?: AllianceInfo;
|
||
}
|
||
|
||
export interface EmojiMessage {
|
||
message: string;
|
||
senderID: number;
|
||
recipientID: number | typeof AllPlayers;
|
||
createdAt: Tick;
|
||
}
|
||
|
||
export enum MessageType {
|
||
ATTACK_FAILED,
|
||
ATTACK_CANCELLED,
|
||
ATTACK_REQUEST,
|
||
CONQUERED_PLAYER,
|
||
MIRV_INBOUND,
|
||
NUKE_INBOUND,
|
||
NUKE_DETONATED,
|
||
HYDROGEN_BOMB_INBOUND,
|
||
NAVAL_INVASION_INBOUND,
|
||
SAM_MISS,
|
||
SAM_HIT,
|
||
CAPTURED_ENEMY_UNIT,
|
||
UNIT_DESTROYED,
|
||
ALLIANCE_ACCEPTED,
|
||
ALLIANCE_REJECTED,
|
||
ALLIANCE_REQUEST,
|
||
ALLIANCE_BROKEN,
|
||
ALLIANCE_EXPIRED,
|
||
DONATION_SENT,
|
||
DONATION_RECEIVED,
|
||
CHAT,
|
||
RENEW_ALLIANCE,
|
||
}
|
||
|
||
// Message categories used for filtering events in the EventsDisplay
|
||
export enum MessageCategory {
|
||
ATTACK = "ATTACK",
|
||
NUKE = "NUKE",
|
||
ALLIANCE = "ALLIANCE",
|
||
TRADE = "TRADE",
|
||
CHAT = "CHAT",
|
||
}
|
||
|
||
// Ensures that all message types are included in a category
|
||
export const MESSAGE_TYPE_CATEGORIES: Record<MessageType, MessageCategory> = {
|
||
[MessageType.ATTACK_FAILED]: MessageCategory.ATTACK,
|
||
[MessageType.ATTACK_CANCELLED]: MessageCategory.ATTACK,
|
||
[MessageType.ATTACK_REQUEST]: MessageCategory.ATTACK,
|
||
[MessageType.CONQUERED_PLAYER]: MessageCategory.ATTACK,
|
||
[MessageType.MIRV_INBOUND]: MessageCategory.NUKE,
|
||
[MessageType.NUKE_INBOUND]: MessageCategory.NUKE,
|
||
[MessageType.NUKE_DETONATED]: MessageCategory.NUKE,
|
||
[MessageType.HYDROGEN_BOMB_INBOUND]: MessageCategory.NUKE,
|
||
[MessageType.NAVAL_INVASION_INBOUND]: MessageCategory.ATTACK,
|
||
[MessageType.SAM_MISS]: MessageCategory.ATTACK,
|
||
[MessageType.SAM_HIT]: MessageCategory.ATTACK,
|
||
[MessageType.CAPTURED_ENEMY_UNIT]: MessageCategory.ATTACK,
|
||
[MessageType.UNIT_DESTROYED]: MessageCategory.ATTACK,
|
||
[MessageType.ALLIANCE_ACCEPTED]: MessageCategory.ALLIANCE,
|
||
[MessageType.ALLIANCE_REJECTED]: MessageCategory.ALLIANCE,
|
||
[MessageType.ALLIANCE_REQUEST]: MessageCategory.ALLIANCE,
|
||
[MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE,
|
||
[MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE,
|
||
[MessageType.RENEW_ALLIANCE]: MessageCategory.ALLIANCE,
|
||
[MessageType.DONATION_SENT]: MessageCategory.TRADE,
|
||
[MessageType.DONATION_RECEIVED]: MessageCategory.TRADE,
|
||
[MessageType.CHAT]: MessageCategory.CHAT,
|
||
} as const;
|
||
|
||
/**
|
||
* Get the category of a message type
|
||
*/
|
||
export function getMessageCategory(messageType: MessageType): MessageCategory {
|
||
return MESSAGE_TYPE_CATEGORIES[messageType];
|
||
}
|
||
|
||
export interface NameViewData {
|
||
x: number;
|
||
y: number;
|
||
size: number;
|
||
}
|