Files
OpenFrontIO/src/core/game/Game.ts
T
Evan bca980f572 Shrink the per-tick worker → main update payload by ~90% (#4244)
Stacked on #4243 (the `perf:client` harness) — first step of fixing the
every-100ms main-thread stutter: make the per-tick burst small before
spreading what remains across frames.

## Problem

The harness showed the main-thread burst was dominated by
`structuredClone` of the `updates` object, and the clone was dominated
by two kinds of per-tick churn that re-sent object payloads every tick:

- `gold` / `troops` / `tilesOwned` change for nearly every alive player
every tick → ~278 partial `PlayerUpdate` objects per tick (world/400
bots), ~508 on giantworldmap.
- Attack troop counts tick down every tick → whole
`outgoingAttacks`/`incomingAttacks` arrays re-cloned for every fighting
player every tick.
- `playerNameViewData` (an all-players record) was cloned every tick but
only recomputed every 30 ticks.

## Change

Three additions to the worker → main protocol (all transferable,
zero-clone):

1. **`packedPlayerUpdates`** — `[smallID, tilesOwned, gold, troops]`
float64 quads for players whose stats changed. These fields no longer
appear in `PlayerUpdate` diffs (first emissions still carry the full
snapshot). Gold is exact in a float64 (game values ≪ 2^53).
2. **`packedAttackUpdates`** — `[ownerSmallID, direction, index,
troops]` quads. Attack arrays are only resent when
membership/order/retreating changes — which is exactly the condition
that keeps the patch indexes valid (a tick either resends an array or
patches it, never both).
3. **`playerNameViewData` is now optional** — attached only on
placement-rebuild ticks (spawn ticks, first ticks, every 30th, spawn
end). The client keeps the last applied values; dead players' name
placements freeze at death (matching the previous effective behavior).

On the client, `GameView.populateFrame` now also rebuilds `names` /
`relationMatrix` / `allianceClusters` only when their inputs changed
that tick — field presence on a partial `PlayerUpdate` marks them dirty.
(`playerStatus`, nuke telegraphs, and attack rings still recompute every
tick; they're tick- or unit-dependent.)

## Results (perf:client, this machine; low-end devices ~5–20× slower)

Default run (world, 400 bots, 1800 ticks):

| stage | before | after |
|---|---|---|
| clone (serialize+deserialize) | 1.02ms | **0.09ms** |
| GameView.update | 0.62ms | **0.29ms** |
| WebGLFrameBuilder.update | 0.04ms | 0.04ms |
| **TOTAL burst mean** | **1.67ms** | **0.42ms** |
| TOTAL p99 / max | 3.47 / 10.3ms | **1.21 / 3.92ms** |

giantworldmap/600t: 2.54 → 0.68ms mean. Player update objects: 278 → 6.5
per tick (world), 508 → 12 (giant). The remaining burst is mostly tile
apply + per-tick derivations — the part that frame-spreading (next step)
addresses.

## Verification

- **Sim final hash unchanged** on all three reference configs
(`5607618202213430`, `29309648281599524`, `39945089450032050`) — no
simulation behavior change.
- **View hash unchanged** on all three configs (`942106e9`, `a3aae227`,
`cbaaf265`) — the rendered view state is provably identical
tick-for-tick, including the name-freeze semantics.
- New tests: `tests/PackedPlayerUpdates.test.ts` (drain + GameRunner
cadence), packed-channel and freeze-at-death cases in
`tests/client/view/GameView.test.ts`, `packAttackTroopDeltas` unit tests
and updated diff contract in `tests/GameUpdateUtils.test.ts` /
`tests/PlayerUpdateDiff.test.ts`.
- `npm test` (1490 tests), `eslint`, `prettier`, `tsc --noEmit` all
pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:50:56 -07:00

940 lines
24 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;
};
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,
}
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;
modifyHealth(delta: number, attacker?: Player): 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;
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
units(...types: 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) to avoid creating arrays
forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void;
// 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;
units(...types: 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 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;
}