Files
OpenFrontIO/src/core/game/GameImpl.ts
T
2026-05-27 14:29:19 +02:00

1325 lines
34 KiB
TypeScript

import { renderNumber } from "../../client/Utils";
import { Config } from "../configuration/Config";
import { SharedWaterCache } from "../execution/nation/SharedWaterCache";
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { ATTACK_INDEX_SENT } from "../StatsSchemas";
import { simpleHash } from "../Util";
import { AllianceImpl } from "./AllianceImpl";
import { AllianceRequestImpl } from "./AllianceRequestImpl";
import {
Alliance,
AllianceRequest,
Cell,
ColoredTeams,
Duos,
EmojiMessage,
Execution,
Game,
GameMode,
GameUpdates,
HumansVsNations,
MessageType,
MutableAlliance,
Nation,
Player,
PlayerID,
PlayerInfo,
PlayerType,
Quads,
SpawnArea,
Team,
TeamGameSpawnAreas,
TerrainType,
TerraNullius,
Trios,
Unit,
UnitInfo,
UnitType,
} from "./Game";
import { GameMap, TileRef } from "./GameMap";
import { GameUpdate, GameUpdateType } from "./GameUpdates";
import { UnitView } from "./GameView";
import { MotionPlanRecord, packMotionPlans } from "./MotionPlans";
import { PlayerImpl } from "./PlayerImpl";
import { RailNetwork } from "./RailNetwork";
import { createRailNetwork } from "./RailNetworkImpl";
import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid, UnitPredicate } from "./UnitGrid";
import { WaterManager } from "./WaterManager";
export function createGame(
humans: PlayerInfo[],
nations: Nation[],
gameMap: GameMap,
miniGameMap: GameMap,
config: Config,
teamGameSpawnAreas?: TeamGameSpawnAreas,
): Game {
const stats = new StatsImpl();
return new GameImpl(
humans,
nations,
gameMap,
miniGameMap,
config,
stats,
teamGameSpawnAreas,
);
}
export type CellString = string;
export class GameImpl implements Game {
private _ticks = 0;
private unInitExecs: Execution[] = [];
_players: Map<PlayerID, PlayerImpl> = new Map<PlayerID, PlayerImpl>();
_playersBySmallID: Player[] = [];
private execs: Execution[] = [];
private _width: number;
private _height: number;
_terraNullius: TerraNulliusImpl;
allianceRequests: AllianceRequestImpl[] = [];
alliances_: AllianceImpl[] = [];
private nextPlayerID = 1;
private _nextUnitID = 1;
private updates: GameUpdates = createGameUpdatesMap();
private tileUpdatePairs: number[] = [];
private motionPlanRecords: MotionPlanRecord[] = [];
private planDrivenUnitIds = new Set<number>();
private unitGrid: UnitGrid;
private _unitMap = new Map<number, Unit>();
private playerTeams: Team[] = [];
private botTeam: Team = ColoredTeams.Bot;
private _railNetwork: RailNetwork = createRailNetwork(this);
// Used to assign unique IDs to each new alliance
private nextAllianceID: number = 0;
private _isPaused: boolean = false;
private _winner: Player | Team | null = null;
private _waterManager: WaterManager;
private _sharedWaterCache: SharedWaterCache;
private _teamGameSpawnAreas: TeamGameSpawnAreas | undefined;
constructor(
private _humans: PlayerInfo[],
private _nations: Nation[],
private _map: GameMap,
private miniGameMap: GameMap,
private _config: Config,
private _stats: Stats,
teamGameSpawnAreas?: TeamGameSpawnAreas,
) {
const constructorStart = performance.now();
this._teamGameSpawnAreas = teamGameSpawnAreas;
this._terraNullius = new TerraNulliusImpl();
this._width = _map.width();
this._height = _map.height();
this.unitGrid = new UnitGrid(this._map);
this._waterManager = new WaterManager(
this._map,
this.miniGameMap,
_config.disableNavMesh(),
);
this._sharedWaterCache = new SharedWaterCache(this);
if (_config.gameConfig().gameMode === GameMode.Team) {
this.populateTeams();
}
this.addPlayers();
console.log(
`[GameImpl] Constructor total: ${(performance.now() - constructorStart).toFixed(0)}ms`,
);
}
private populateTeams() {
let numPlayerTeams = this._config.playerTeams();
// HumansVsNations mode always has exactly 2 teams
if (numPlayerTeams === HumansVsNations) {
this.playerTeams = [ColoredTeams.Humans, ColoredTeams.Nations];
return;
}
if (typeof numPlayerTeams !== "number") {
const players = this._humans.length + this._nations.length;
switch (numPlayerTeams) {
case Duos:
numPlayerTeams = Math.ceil(players / 2);
break;
case Trios:
numPlayerTeams = Math.ceil(players / 3);
break;
case Quads:
numPlayerTeams = Math.ceil(players / 4);
break;
default:
throw new Error(`Unknown TeamCountConfig ${numPlayerTeams}`);
}
}
if (numPlayerTeams < 2) {
throw new Error(`Too few teams: ${numPlayerTeams}`);
} else if (numPlayerTeams < 8) {
this.playerTeams = [ColoredTeams.Red, ColoredTeams.Blue];
if (numPlayerTeams >= 3) this.playerTeams.push(ColoredTeams.Yellow);
if (numPlayerTeams >= 4) this.playerTeams.push(ColoredTeams.Green);
if (numPlayerTeams >= 5) this.playerTeams.push(ColoredTeams.Purple);
if (numPlayerTeams >= 6) this.playerTeams.push(ColoredTeams.Orange);
if (numPlayerTeams >= 7) this.playerTeams.push(ColoredTeams.Teal);
} else {
this.playerTeams = [];
for (let i = 1; i <= numPlayerTeams; i++) {
this.playerTeams.push(`Team ${i}`);
}
}
}
private addPlayers() {
if (this.config().gameConfig().gameMode === GameMode.FFA) {
this._humans.forEach((p) => this.addPlayer(p));
this._nations.forEach((n) => this.addPlayer(n.playerInfo));
return;
}
if (this._config.playerTeams() === HumansVsNations) {
this._humans.forEach((p) => this.addPlayer(p, ColoredTeams.Humans));
this._nations.forEach((n) =>
this.addPlayer(n.playerInfo, ColoredTeams.Nations),
);
return;
}
// Team mode
const allPlayers = [
...this._humans,
...this._nations.map((n) => n.playerInfo),
];
const playerToTeam = assignTeams(allPlayers, this.playerTeams);
for (const [playerInfo, team] of playerToTeam.entries()) {
if (team === "kicked") {
console.warn(`Player ${playerInfo.name} was kicked from team`);
continue;
}
this.addPlayer(playerInfo, team);
}
}
isOnEdgeOfMap(ref: TileRef): boolean {
return this._map.isOnEdgeOfMap(ref);
}
owner(ref: TileRef): Player | TerraNullius {
return this.playerBySmallID(this.ownerID(ref));
}
alliances(): MutableAlliance[] {
return this.alliances_;
}
playerBySmallID(id: number): Player | TerraNullius {
if (id === 0) {
return this.terraNullius();
}
return this._playersBySmallID[id - 1];
}
map(): GameMap {
return this._map;
}
miniMap(): GameMap {
return this.miniGameMap;
}
addUpdate(update: GameUpdate) {
(this.updates[update.type] as GameUpdate[]).push(update);
}
nextUnitID(): number {
const old = this._nextUnitID;
this._nextUnitID++;
return old;
}
setFallout(tile: TileRef, value: boolean) {
if (value && this.hasOwner(tile)) {
throw Error(`cannot set fallout, tile ${tile} has owner`);
}
if (this._map.hasFallout(tile)) {
return;
}
this._map.setFallout(tile, value);
this.recordTileUpdate(tile);
}
setWater(tile: TileRef): void {
if (!this.isLand(tile)) return;
if (this.hasOwner(tile)) {
throw Error(`cannot set water, tile ${tile} has owner`);
}
// Clear fallout if present (water tiles shouldn't have fallout)
if (this._map.hasFallout(tile)) {
this._map.setFallout(tile, false);
}
this._map.setWater(tile);
this.recordTileUpdate(tile);
}
queueWaterConversion(tile: TileRef): void {
if (!this.isLand(tile)) return;
if (this.hasOwner(tile)) {
throw Error(`cannot queue water conversion, tile ${tile} has owner`);
}
if (!this._config.waterNukes()) {
this.setFallout(tile, true);
return;
}
this._waterManager.queueTile(tile);
}
unit(id: number): Unit | undefined {
return this._unitMap.get(id);
}
units(...types: UnitType[]): Unit[] {
return Array.from(this._players.values()).flatMap((p) => p.units(...types));
}
unitCount(type: UnitType): number {
let total = 0;
for (const player of this._players.values()) {
total += player.unitCount(type);
}
return total;
}
unitInfo(type: UnitType): UnitInfo {
return this.config().unitInfo(type);
}
nations(): Nation[] {
return this._nations;
}
createAllianceRequest(
requestor: Player,
recipient: Player,
): AllianceRequest | null {
if (requestor.isAlliedWith(recipient)) {
console.log("cannot request alliance, already allied");
return null;
}
if (
recipient
.incomingAllianceRequests()
.find((ar) => ar.requestor() === requestor) !== undefined
) {
console.log(`duplicate alliance request from ${requestor.name()}`);
return null;
}
const correspondingReq = requestor
.incomingAllianceRequests()
.find((ar) => ar.requestor() === recipient);
if (correspondingReq !== undefined) {
console.log(`got corresponding alliance requests, accepting`);
correspondingReq.accept();
return null;
}
const ar = new AllianceRequestImpl(requestor, recipient, this._ticks, this);
this.allianceRequests.push(ar);
this.addUpdate(ar.toUpdate());
return ar;
}
acceptAllianceRequest(request: AllianceRequestImpl) {
this.allianceRequests = this.allianceRequests.filter(
(ar) => ar !== request,
);
const requestor = request.requestor();
const recipient = request.recipient();
const existing = requestor.allianceWith(recipient);
if (existing) {
throw new Error(
`cannot accept alliance request, already allied with ${recipient.name()}`,
);
}
// Create and register the new alliance
const alliance = new AllianceImpl(
this,
requestor as PlayerImpl,
recipient as PlayerImpl,
this._ticks,
this.nextAllianceID++,
);
this.alliances_.push(alliance);
(request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push(
request,
);
this.addUpdate({
type: GameUpdateType.AllianceRequestReply,
request: request.toUpdate(),
accepted: true,
});
}
rejectAllianceRequest(request: AllianceRequestImpl) {
this.allianceRequests = this.allianceRequests.filter(
(ar) => ar !== request,
);
(request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push(
request,
);
this.addUpdate({
type: GameUpdateType.AllianceRequestReply,
request: request.toUpdate(),
accepted: false,
});
}
hasPlayer(id: PlayerID): boolean {
return this._players.has(id);
}
config(): Config {
return this._config;
}
isPaused(): boolean {
return this._isPaused;
}
setPaused(paused: boolean): void {
this._isPaused = paused;
this.addUpdate({ type: GameUpdateType.GamePaused, paused });
}
inSpawnPhase(): boolean {
return this._ticks <= this.config().numSpawnPhaseTurns();
}
ticks(): number {
return this._ticks;
}
executeNextTick(): GameUpdates {
this.updates = createGameUpdatesMap();
this.tileUpdatePairs.length = 0;
this.execs.forEach((e) => {
if (
(!this.inSpawnPhase() || e.activeDuringSpawnPhase()) &&
e.isActive()
) {
e.tick(this._ticks);
}
});
const inited: Execution[] = [];
const unInited: Execution[] = [];
this.unInitExecs.forEach((e) => {
if (!this.inSpawnPhase() || e.activeDuringSpawnPhase()) {
e.init(this, this._ticks);
inited.push(e);
} else {
unInited.push(e);
}
});
this.removeInactiveExecutions();
this.execs.push(...inited);
this.unInitExecs = unInited;
for (const player of this._players.values()) {
// Players change each to so always add them
this.addUpdate(player.toUpdate());
}
if (this.ticks() % 10 === 0) {
this.addUpdate({
type: GameUpdateType.Hash,
tick: this.ticks(),
hash: this.hash(),
});
}
// Flush pending water conversions + throttled graph rebuild
const waterChangedTiles = this._waterManager.tick(this._ticks);
for (const tile of waterChangedTiles) {
this.recordTileUpdate(tile);
}
this._ticks++;
return this.updates;
}
private recordTileUpdate(tile: TileRef): void {
// Low 16 bits: tile state, bits 16-23: terrain byte
this.tileUpdatePairs.push(
tile,
(this._map.tileState(tile) & 0xffff) |
(this._map.terrainByte(tile) << 16),
);
}
drainPackedTileUpdates(): Uint32Array {
const pairs = this.tileUpdatePairs;
const packed = new Uint32Array(pairs.length);
for (let i = 0; i < pairs.length; i++) {
packed[i] = pairs[i];
}
pairs.length = 0;
return packed;
}
recordMotionPlan(record: MotionPlanRecord): void {
switch (record.kind) {
case "grid_segments":
this.planDrivenUnitIds.add(record.unitId);
break;
case "train":
this.planDrivenUnitIds.add(record.engineUnitId);
for (const unitId of record.carUnitIds) {
this.planDrivenUnitIds.add(unitId);
}
break;
}
this.motionPlanRecords.push(record);
}
private isUnitPlanDriven(unitId: number): boolean {
return this.planDrivenUnitIds.has(unitId);
}
maybeAddUnitUpdate(unit: Unit): void {
if (!this.isUnitPlanDriven(unit.id())) {
this.addUpdate(unit.toUpdate());
}
}
onUnitMoved(unit: Unit): void {
this.updateUnitTile(unit);
this.maybeAddUnitUpdate(unit);
}
drainPackedMotionPlans(): Uint32Array | null {
const records = this.motionPlanRecords;
if (records.length === 0) {
return null;
}
const packed = packMotionPlans(records);
records.length = 0;
return packed;
}
private hash(): number {
let hash = 1;
this._players.forEach((p) => {
hash += p.hash();
});
return hash;
}
terraNullius(): TerraNullius {
return this._terraNullius;
}
removeInactiveExecutions(): void {
const activeExecs: Execution[] = [];
for (const exec of this.execs) {
if (this.inSpawnPhase()) {
if (exec.activeDuringSpawnPhase()) {
if (exec.isActive()) {
activeExecs.push(exec);
}
} else {
activeExecs.push(exec);
}
} else {
if (exec.isActive()) {
activeExecs.push(exec);
}
}
}
this.execs = activeExecs;
}
players(): Player[] {
return Array.from(this._players.values()).filter((p) => p.isAlive());
}
allPlayers(): Player[] {
return Array.from(this._players.values());
}
executions(): Execution[] {
return [...this.execs, ...this.unInitExecs];
}
addExecution(...exec: Execution[]) {
this.unInitExecs.push(...exec);
}
removeExecution(exec: Execution) {
this.execs = this.execs.filter((execution) => execution !== exec);
this.unInitExecs = this.unInitExecs.filter(
(execution) => execution !== exec,
);
}
playerView(id: PlayerID): Player {
return this.player(id);
}
addPlayer(playerInfo: PlayerInfo, team: Team | null = null): Player {
const player = new PlayerImpl(
this,
this.nextPlayerID,
playerInfo,
this.config().startManpower(playerInfo),
team ?? this.maybeAssignTeam(playerInfo),
);
this._playersBySmallID.push(player);
this.nextPlayerID++;
this._players.set(playerInfo.id, player);
return player;
}
private maybeAssignTeam(player: PlayerInfo): Team | null {
if (this._config.gameConfig().gameMode !== GameMode.Team) {
return null;
}
if (player.playerType === PlayerType.Bot) {
return this.botTeam;
}
const rand = simpleHash(player.id);
return this.playerTeams[rand % this.playerTeams.length];
}
player(id: PlayerID): Player {
const player = this._players.get(id);
if (player === undefined) {
throw new Error(`Player with id ${id} not found`);
}
return player;
}
playerByClientID(id: ClientID): Player | null {
for (const [, player] of this._players) {
if (player.clientID() === id) {
return player;
}
}
return null;
}
isOnMap(cell: Cell): boolean {
return (
cell.x >= 0 &&
cell.x < this._width &&
cell.y >= 0 &&
cell.y < this._height
);
}
neighborsWithDiag(tile: TileRef): TileRef[] {
const x = this.x(tile);
const y = this.y(tile);
const ns: TileRef[] = [];
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
if (dx === 0 && dy === 0) continue; // Skip the center tile
const newX = x + dx;
const newY = y + dy;
if (
newX >= 0 &&
newX < this._width &&
newY >= 0 &&
newY < this._height
) {
ns.push(this._map.ref(newX, newY));
}
}
}
return ns;
}
// Zero-allocation neighbor iteration for performance-critical code
forEachNeighborWithDiag(
tile: TileRef,
callback: (neighbor: TileRef) => void,
): void {
const x = this.x(tile);
const y = this.y(tile);
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
if (dx === 0 && dy === 0) continue; // Skip the center tile
const newX = x + dx;
const newY = y + dy;
if (
newX >= 0 &&
newX < this._width &&
newY >= 0 &&
newY < this._height
) {
callback(this._map.ref(newX, newY));
}
}
}
}
conquer(owner: PlayerImpl, tile: TileRef): void {
if (!this.isLand(tile)) {
throw Error(`cannot conquer water`);
}
const previousOwner = this.owner(tile) as TerraNullius | PlayerImpl;
if (previousOwner.isPlayer()) {
previousOwner._lastTileChange = this._ticks;
previousOwner._tiles.delete(tile);
previousOwner._borderTiles.delete(tile);
}
this._map.setOwnerID(tile, owner.smallID());
owner._tiles.add(tile);
owner._lastTileChange = this._ticks;
this.updateBorders(tile);
this._map.setFallout(tile, false);
this.updateDefendedStateForTileChange(tile, owner);
this.recordTileUpdate(tile);
}
relinquish(tile: TileRef) {
if (!this.hasOwner(tile)) {
throw new Error(`Cannot relinquish tile because it is unowned`);
}
if (this.isWater(tile)) {
throw new Error("Cannot relinquish water");
}
const previousOwner = this.owner(tile) as PlayerImpl;
previousOwner._lastTileChange = this._ticks;
previousOwner._tiles.delete(tile);
previousOwner._borderTiles.delete(tile);
this._map.setOwnerID(tile, 0);
this.updateBorders(tile);
if (this._map.isDefended(tile)) {
this._map.setDefended(tile, false);
}
this.recordTileUpdate(tile);
}
private updateBorders(tile: TileRef) {
const updateBorderStatus = (t: TileRef) => {
if (!this.hasOwner(t)) {
return;
}
const owner = this.owner(t) as PlayerImpl;
if (this.calcIsBorder(t)) {
owner._borderTiles.add(t);
} else {
owner._borderTiles.delete(t);
}
};
updateBorderStatus(tile);
this.forEachNeighbor(tile, updateBorderStatus);
}
private calcIsBorder(tile: TileRef): boolean {
if (!this.hasOwner(tile)) {
return false;
}
const ownerId = this.ownerID(tile);
const x = this.x(tile);
const y = this.y(tile);
if (x > 0 && this.ownerID(this._map.ref(x - 1, y)) !== ownerId) {
return true;
}
if (
x + 1 < this._width &&
this.ownerID(this._map.ref(x + 1, y)) !== ownerId
) {
return true;
}
if (y > 0 && this.ownerID(this._map.ref(x, y - 1)) !== ownerId) {
return true;
}
if (
y + 1 < this._height &&
this.ownerID(this._map.ref(x, y + 1)) !== ownerId
) {
return true;
}
return false;
}
target(targeter: Player, target: Player) {
this.addUpdate({
type: GameUpdateType.TargetPlayer,
playerID: targeter.smallID(),
targetID: target.smallID(),
});
}
public breakAlliance(breaker: Player, alliance: MutableAlliance) {
let other: Player;
if (alliance.requestor() === breaker) {
other = alliance.recipient();
} else {
other = alliance.requestor();
}
if (!breaker.isAlliedWith(other)) {
throw new Error(
`${breaker} not allied with ${other}, cannot break alliance`,
);
}
if (!other.isTraitor() && !other.isDisconnected()) {
breaker.markTraitor();
}
this.alliances_ = this.alliances_.filter((a) => a !== alliance);
this.addUpdate({
type: GameUpdateType.BrokeAlliance,
traitorID: breaker.smallID(),
betrayedID: other.smallID(),
allianceID: alliance.id(),
});
}
public expireAlliance(alliance: Alliance) {
const p1Set = new Set(alliance.recipient().alliances());
const alliances = alliance
.requestor()
.alliances()
.filter((a) => p1Set.has(a));
if (alliances.length !== 1) {
throw new Error(
`cannot expire alliance: must have exactly one alliance, have ${alliances.length}`,
);
}
this.alliances_ = this.alliances_.filter((a) => a !== alliances[0]);
this.addUpdate({
type: GameUpdateType.AllianceExpired,
player1ID: alliance.requestor().smallID(),
player2ID: alliance.recipient().smallID(),
});
}
public removeAlliancesByPlayerSilently(player: Player): void {
this.alliances_ = this.alliances_.filter(
(a) => a.requestor() !== player && a.recipient() !== player,
);
}
public isSpawnImmunityActive(): boolean {
return (
this.config().numSpawnPhaseTurns() +
this.config().spawnImmunityDuration() >
this.ticks()
);
}
public isNationSpawnImmunityActive(): boolean {
return (
this.config().numSpawnPhaseTurns() +
this.config().nationSpawnImmunityDuration() >
this.ticks()
);
}
sendEmojiUpdate(msg: EmojiMessage): void {
this.addUpdate({
type: GameUpdateType.Emoji,
emoji: msg,
});
}
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void {
this._winner = winner;
this.addUpdate({
type: GameUpdateType.Win,
winner: this.makeWinner(winner),
allPlayersStats,
});
}
getWinner(): Player | Team | null {
return this._winner;
}
private makeWinner(winner: string | Player): Winner | undefined {
if (typeof winner === "string") {
return [
"team",
winner,
...this.players()
.filter((p) => p.team() === winner && p.clientID() !== null)
.map((p) => p.clientID()!),
];
} else {
const clientId = winner.clientID();
if (clientId === null) {
return ["nation", winner.name()];
}
return [
"player",
clientId,
// TODO: Assists (vote for peace)
];
}
}
teams(): Team[] {
if (this._config.gameConfig().gameMode !== GameMode.Team) {
return [];
}
return [this.botTeam, ...this.playerTeams];
}
teamSpawnArea(team: Team): SpawnArea | undefined {
if (!this._teamGameSpawnAreas) {
return undefined;
}
const numTeams = this.playerTeams.length;
const areas = this._teamGameSpawnAreas[String(numTeams)];
if (!areas) {
return undefined;
}
const teamIndex = this.playerTeams.indexOf(team);
if (teamIndex < 0 || teamIndex >= areas.length) {
return undefined;
}
return areas[teamIndex];
}
displayMessage(
message: string,
type: MessageType,
playerID: PlayerID | null,
goldAmount?: bigint,
params?: Record<string, string | number>,
): void {
let id: number | null = null;
if (playerID !== null) {
id = this.player(playerID).smallID();
}
this.addUpdate({
type: GameUpdateType.DisplayEvent,
messageType: type,
message: message,
playerID: id,
goldAmount: goldAmount,
params: params,
});
}
displayChat(
message: string,
category: string,
target: PlayerID | undefined,
playerID: PlayerID | null,
isFrom: boolean,
recipient: string,
): void {
let id: number | null = null;
if (playerID !== null) {
id = this.player(playerID).smallID();
}
this.addUpdate({
type: GameUpdateType.DisplayChatEvent,
key: message,
category: category,
target: target,
playerID: id,
isFrom,
recipient: recipient,
});
}
displayIncomingUnit(
unitID: number,
message: string,
type: MessageType,
playerID: PlayerID,
): void {
const id = this.player(playerID).smallID();
this.addUpdate({
type: GameUpdateType.UnitIncoming,
unitID: unitID,
message: message,
messageType: type,
playerID: id,
});
}
addUnit(u: Unit) {
this.unitGrid.addUnit(u);
this._unitMap.set(u.id(), u);
}
removeUnit(u: Unit) {
this.unitGrid.removeUnit(u);
this._unitMap.delete(u.id());
this.planDrivenUnitIds.delete(u.id());
if (u.hasTrainStation()) {
this._railNetwork.removeStation(u);
}
}
updateUnitTile(u: Unit) {
if (u.type() === UnitType.DefensePost) {
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
}
this.unitGrid.updateUnitCell(u);
}
refreshDefensePostDefendedState(u: Unit) {
if (u.type() === UnitType.DefensePost) {
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
}
}
hasUnitNearby(
tile: TileRef,
searchRange: number,
type: UnitType,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
) {
return this.unitGrid.hasUnitNearby(
tile,
searchRange,
type,
playerId,
includeUnderConstruction,
);
}
anyUnitNearby(
tile: TileRef,
searchRange: number,
types: readonly UnitType[],
predicate: (unit: Unit) => boolean,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
): boolean {
return this.unitGrid.anyUnitNearby(
tile,
searchRange,
types,
predicate as (unit: Unit | UnitView) => boolean,
playerId,
includeUnderConstruction,
);
}
nearbyUnits(
tile: TileRef,
searchRange: number,
types: UnitType | readonly UnitType[],
predicate?: UnitPredicate,
includeUnderConstruction?: boolean,
): Array<{ unit: Unit; distSquared: number }> {
return this.unitGrid.nearbyUnits(
tile,
searchRange,
types,
predicate,
includeUnderConstruction,
) as Array<{
unit: Unit;
distSquared: number;
}>;
}
ref(x: number, y: number): TileRef {
return this._map.ref(x, y);
}
isValidRef(ref: TileRef): boolean {
return this._map.isValidRef(ref);
}
x(ref: TileRef): number {
return this._map.x(ref);
}
y(ref: TileRef): number {
return this._map.y(ref);
}
cell(ref: TileRef): Cell {
return this._map.cell(ref);
}
width(): number {
return this._map.width();
}
height(): number {
return this._map.height();
}
numLandTiles(): number {
return this._map.numLandTiles();
}
isValidCoord(x: number, y: number): boolean {
return this._map.isValidCoord(x, y);
}
isLand(ref: TileRef): boolean {
return this._map.isLand(ref);
}
isOceanShore(ref: TileRef): boolean {
return this._map.isOceanShore(ref);
}
isOcean(ref: TileRef): boolean {
return this._map.isOcean(ref);
}
isShoreline(ref: TileRef): boolean {
return this._map.isShoreline(ref);
}
magnitude(ref: TileRef): number {
return this._map.magnitude(ref);
}
terrainByte(ref: TileRef): number {
return this._map.terrainByte(ref);
}
setShorelineBit(ref: TileRef): void {
this._map.setShorelineBit(ref);
}
clearShorelineBit(ref: TileRef): void {
this._map.clearShorelineBit(ref);
}
setOcean(ref: TileRef): void {
this._map.setOcean(ref);
}
setMagnitude(ref: TileRef, value: number): void {
this._map.setMagnitude(ref, value);
}
ownerID(ref: TileRef): number {
return this._map.ownerID(ref);
}
hasOwner(ref: TileRef): boolean {
return this._map.hasOwner(ref);
}
setOwnerID(ref: TileRef, playerId: number): void {
return this._map.setOwnerID(ref, playerId);
}
hasFallout(ref: TileRef): boolean {
return this._map.hasFallout(ref);
}
isDefended(ref: TileRef): boolean {
return this._map.isDefended(ref);
}
setDefended(ref: TileRef, value: boolean): void {
this._map.setDefended(ref, value);
}
isBorder(ref: TileRef): boolean {
return this._map.isBorder(ref);
}
neighbors(ref: TileRef): TileRef[] {
return this._map.neighbors(ref);
}
// Zero-allocation neighbor iteration (cardinal only)
forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void {
const x = this.x(tile);
const y = this.y(tile);
if (x > 0) callback(this._map.ref(x - 1, y));
if (x + 1 < this._width) callback(this._map.ref(x + 1, y));
if (y > 0) callback(this._map.ref(x, y - 1));
if (y + 1 < this._height) callback(this._map.ref(x, y + 1));
}
isWater(ref: TileRef): boolean {
return this._map.isWater(ref);
}
isLake(ref: TileRef): boolean {
return this._map.isLake(ref);
}
isShore(ref: TileRef): boolean {
return this._map.isShore(ref);
}
cost(ref: TileRef): number {
return this._map.cost(ref);
}
terrainType(ref: TileRef): TerrainType {
return this._map.terrainType(ref);
}
forEachTile(fn: (tile: TileRef) => void): void {
return this._map.forEachTile(fn);
}
manhattanDist(c1: TileRef, c2: TileRef): number {
return this._map.manhattanDist(c1, c2);
}
euclideanDistSquared(c1: TileRef, c2: TileRef): number {
return this._map.euclideanDistSquared(c1, c2);
}
circleSearch(
tile: TileRef,
radius: number,
filter?: (tile: TileRef, d2: number) => boolean,
): Set<TileRef> {
return this._map.circleSearch(tile, radius, filter);
}
bfs(
tile: TileRef,
filter: (gm: GameMap, tile: TileRef) => boolean,
): Set<TileRef> {
return this._map.bfs(tile, filter);
}
tileState(tile: TileRef): number {
return this._map.tileState(tile);
}
updateTile(tile: TileRef, state: number): boolean {
return this._map.updateTile(tile, state);
}
tileStateView(): Uint16Array {
return this._map.tileStateView();
}
terrainDataView(): Uint8Array {
return this._map.terrainDataView();
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
}
stats(): Stats {
return this._stats;
}
railNetwork(): RailNetwork {
return this._railNetwork;
}
miniWaterHPA(): PathFinder<number> | null {
return this._waterManager.miniWaterHPA();
}
miniWaterGraph(): AbstractGraph | null {
return this._waterManager.miniWaterGraph();
}
waterGraphVersion(): number {
return this._waterManager.waterGraphVersion();
}
getWaterComponent(tile: TileRef): number | null {
return this._waterManager.getWaterComponent(tile);
}
hasWaterComponent(tile: TileRef, component: number): boolean {
return this._waterManager.hasWaterComponent(tile, component);
}
sharedWaterComponents(player: Player): Set<number> | null {
return this._sharedWaterCache.get(player);
}
conquerPlayer(conqueror: Player, conquered: Player) {
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
const ships = conquered
.units()
.filter(
(u) =>
u.type() === UnitType.Warship ||
u.type() === UnitType.TransportShip,
);
for (const ship of ships) {
conqueror.captureUnit(ship);
}
}
// Don't transfer gold when the conquered player didn't play (never attacked anyone)
// This is especially important when starting gold is enabled
const stats = this._stats.getPlayerStats(conquered);
const attacksSent = stats?.attacks?.[ATTACK_INDEX_SENT] ?? 0n;
const skipGoldTransfer =
attacksSent === 0n && conquered.type() === PlayerType.Human;
const gold = skipGoldTransfer ? 0n : conquered.gold();
const goldCaptured = skipGoldTransfer
? 0n
: this._config.conquerGoldAmount(conquered);
if (skipGoldTransfer) {
this.displayMessage(
"events_display.conquered_no_gold",
MessageType.CONQUERED_PLAYER,
conqueror.id(),
undefined,
{
name: conquered.displayName(),
},
);
} else {
this.displayMessage(
"events_display.received_gold_from_conquest",
MessageType.CONQUERED_PLAYER,
conqueror.id(),
gold,
{
gold: renderNumber(goldCaptured),
name: conquered.displayName(),
},
);
conqueror.addGold(goldCaptured);
conquered.removeGold(gold);
// Record stats
this.stats().goldWar(conqueror, conquered, goldCaptured);
}
this.addUpdate({
type: GameUpdateType.ConquestEvent,
conquerorId: conqueror.id(),
conqueredId: conquered.id(),
gold: goldCaptured,
});
}
private updateDefendedStateForDefensePost(
center: TileRef,
owner: PlayerImpl,
) {
const range = this.config().defensePostRange();
const rangeSq = range * range;
for (const tile of owner._borderTiles) {
if (this._map.euclideanDistSquared(center, tile) <= rangeSq) {
const wasDefended = this._map.isDefended(tile);
const isDefended = this.unitGrid.hasUnitNearby(
tile,
range,
UnitType.DefensePost,
owner.id(),
);
if (wasDefended !== isDefended) {
this._map.setDefended(tile, isDefended);
this.recordTileUpdate(tile);
}
}
}
}
private updateDefendedStateForTileChange(tile: TileRef, owner: PlayerImpl) {
const wasDefended = this._map.isDefended(tile);
const isDefended = this.unitGrid.hasUnitNearby(
tile,
this.config().defensePostRange(),
UnitType.DefensePost,
owner.id(),
);
if (wasDefended !== isDefended) {
this._map.setDefended(tile, isDefended);
}
if (
this.unitGrid.hasUnitNearby(tile, 0, UnitType.DefensePost, owner.id())
) {
this.updateDefendedStateForDefensePost(tile, owner);
}
}
}
// Or a more dynamic approach that will catch new enum values:
const createGameUpdatesMap = (): GameUpdates => {
const map = {} as GameUpdates;
Object.values(GameUpdateType)
.filter((key) => !isNaN(Number(key))) // Filter out reverse mappings
.forEach((key) => {
map[key as GameUpdateType] = [];
});
return map;
};