@@ -452,7 +452,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
e.preventDefault()}
>
;
export type PlayerColor = z.infer;
export type Flag = z.infer;
export type GameStartInfo = z.infer;
+export type GameInfo = z.infer;
+export type PublicGames = z.infer;
+export type PublicGameInfo = z.infer;
const ClientInfoSchema = z.object({
clientID: z.string(),
@@ -145,18 +148,22 @@ const ClientInfoSchema = z.object({
export const GameInfoSchema = z.object({
gameID: z.string(),
clients: z.array(ClientInfoSchema).optional(),
- numClients: z.number().optional(),
- msUntilStart: z.number().optional(),
+ startsAt: z.number().optional(),
+ serverTime: z.number(),
gameConfig: z.lazy(() => GameConfigSchema).optional(),
});
-export interface GameInfo {
- gameID: GameID;
- clients?: ClientInfo[];
- numClients?: number;
- msUntilStart?: number;
- gameConfig?: GameConfig;
-}
+export const PublicGameInfoSchema = z.object({
+ gameID: z.string(),
+ numClients: z.number(),
+ startsAt: z.number(),
+ gameConfig: z.lazy(() => GameConfigSchema).optional(),
+});
+
+export const PublicGamesSchema = z.object({
+ serverTime: z.number(),
+ games: PublicGameInfoSchema.array(),
+});
export class LobbyInfoEvent implements GameEvent {
constructor(public lobby: GameInfo) {}
diff --git a/src/core/Util.ts b/src/core/Util.ts
index 330359021..a79dda1a8 100644
--- a/src/core/Util.ts
+++ b/src/core/Util.ts
@@ -80,13 +80,14 @@ export function calculateBoundingBox(
maxX = -Infinity,
maxY = -Infinity;
- borderTiles.forEach((tile: TileRef) => {
- const cell = gm.cell(tile);
- minX = Math.min(minX, cell.x);
- minY = Math.min(minY, cell.y);
- maxX = Math.max(maxX, cell.x);
- maxY = Math.max(maxY, cell.y);
- });
+ for (const tile of borderTiles) {
+ const x = gm.x(tile);
+ const y = gm.y(tile);
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
}
diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts
index 4f9f178f9..85fabb34a 100644
--- a/src/core/execution/BotExecution.ts
+++ b/src/core/execution/BotExecution.ts
@@ -1,7 +1,8 @@
-import { Execution, Game, Player } from "../game/Game";
+import { Execution, Game, isStructureType, Player } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
+import { DeleteUnitExecution } from "./DeleteUnitExecution";
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
export class BotExecution implements Execution {
@@ -58,6 +59,7 @@ export class BotExecution implements Execution {
}
this.acceptAllAllianceRequests();
+ this.deleteAllStructures();
this.maybeAttack();
}
@@ -80,6 +82,14 @@ export class BotExecution implements Execution {
}
}
+ private deleteAllStructures() {
+ for (const unit of this.bot.units()) {
+ if (isStructureType(unit.type()) && this.bot.canDeleteUnit()) {
+ this.mg.addExecution(new DeleteUnitExecution(this.bot, unit.id()));
+ }
+ }
+ }
+
private maybeAttack() {
if (this.attackBehavior === null) {
throw new Error("not initialized");
diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts
index c7e452e5d..e1d80d0b4 100644
--- a/src/core/execution/PlayerExecution.ts
+++ b/src/core/execution/PlayerExecution.ts
@@ -1,5 +1,5 @@
import { Config } from "../configuration/Config";
-import { Execution, Game, Player, UnitType } from "../game/Game";
+import { Cell, Execution, Game, Player, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
@@ -139,11 +139,12 @@ export class PlayerExecution implements Execution {
const largestCluster = clusters[largestIndex];
if (largestCluster === undefined) throw new Error("No clusters");
- this.player.largestClusterBoundingBox = calculateBoundingBox(
- this.mg,
+ const largestClusterBox = calculateBoundingBox(this.mg, largestCluster);
+ this.player.largestClusterBoundingBox = largestClusterBox;
+ const surroundedBy = this.surroundedBySamePlayer(
largestCluster,
+ largestClusterBox,
);
- const surroundedBy = this.surroundedBySamePlayer(largestCluster);
if (surroundedBy && !surroundedBy.isFriendly(this.player)) {
this.removeCluster(largestCluster);
}
@@ -158,7 +159,10 @@ export class PlayerExecution implements Execution {
}
}
- private surroundedBySamePlayer(cluster: Set): false | Player {
+ private surroundedBySamePlayer(
+ cluster: Set,
+ clusterBox: { min: Cell; max: Cell },
+ ): false | Player {
const enemies = new Set();
for (const tile of cluster) {
let hasUnownedNeighbor = false;
@@ -187,7 +191,6 @@ export class PlayerExecution implements Execution {
}
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
- const clusterBox = calculateBoundingBox(this.mg, cluster);
if (inscribed(enemyBox, clusterBox)) {
return enemy;
}
@@ -195,7 +198,11 @@ export class PlayerExecution implements Execution {
}
private isSurrounded(cluster: Set): boolean {
- const enemyTiles = new Set();
+ let hasEnemy = false;
+ let minX = Infinity,
+ minY = Infinity,
+ maxX = -Infinity,
+ maxY = -Infinity;
for (const tr of cluster) {
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
return false;
@@ -203,27 +210,31 @@ export class PlayerExecution implements Execution {
this.mg.forEachNeighbor(tr, (n) => {
const owner = this.mg.owner(n);
if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) {
- enemyTiles.add(n);
+ hasEnemy = true;
+ const x = this.mg.x(n);
+ const y = this.mg.y(n);
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
}
});
}
- if (enemyTiles.size === 0) {
+ if (!hasEnemy) {
return false;
}
- const enemyBox = calculateBoundingBox(this.mg, enemyTiles);
const clusterBox = calculateBoundingBox(this.mg, cluster);
+ const enemyBox = { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
return inscribed(enemyBox, clusterBox);
}
private removeCluster(cluster: Set) {
- if (
- Array.from(cluster).some(
- (t) => this.mg?.ownerID(t) !== this.player?.smallID(),
- )
- ) {
- // Other removeCluster operations could change tile owners,
- // so double check.
- return;
+ for (const t of cluster) {
+ if (this.mg?.ownerID(t) !== this.player?.smallID()) {
+ // Other removeCluster operations could change tile owners,
+ // so double check.
+ return;
+ }
}
const capturing = this.getCapturingPlayer(cluster);
diff --git a/src/core/execution/TrainStationExecution.ts b/src/core/execution/TrainStationExecution.ts
index af8a5bd3f..5f029a0cc 100644
--- a/src/core/execution/TrainStationExecution.ts
+++ b/src/core/execution/TrainStationExecution.ts
@@ -45,7 +45,9 @@ export class TrainStationExecution implements Execution {
this.active = false;
return;
}
- this.spawnTrain(this.station, ticks);
+ if (this.spawnTrains) {
+ this.spawnTrain(this.station, ticks);
+ }
}
private shouldSpawnTrain(): boolean {
@@ -69,8 +71,8 @@ export class TrainStationExecution implements Execution {
if (cluster === null) {
return;
}
- const availableForTrade = cluster.availableForTrade(this.unit.owner());
- if (availableForTrade.size === 0) {
+ const owner = this.unit.owner();
+ if (!cluster.hasAnyTradeDestination(owner)) {
return;
}
if (!this.shouldSpawnTrain()) {
@@ -79,20 +81,20 @@ export class TrainStationExecution implements Execution {
// Pick a destination randomly.
// Could be improved to pick a lucrative trip
- const destination: TrainStation =
- this.random.randFromSet(availableForTrade);
- if (destination !== station) {
- this.mg.addExecution(
- new TrainExecution(
- this.mg.railNetwork(),
- this.unit.owner(),
- station,
- destination,
- this.numCars,
- ),
- );
- this.lastSpawnTick = currentTick;
- }
+ const destination = cluster.randomTradeDestination(owner, this.random);
+ if (destination === null) return;
+ if (destination === station) return;
+
+ this.mg.addExecution(
+ new TrainExecution(
+ this.mg.railNetwork(),
+ owner,
+ station,
+ destination,
+ this.numCars,
+ ),
+ );
+ this.lastSpawnTick = currentTick;
}
activeDuringSpawnPhase(): boolean {
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 6d409fc58..6dd76beff 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -7,6 +7,7 @@ import {
import { AStarWaterHierarchical } from "../pathfinding/algorithms/AStar.WaterHierarchical";
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";
@@ -621,31 +622,46 @@ export class GameImpl implements Game {
}
private updateBorders(tile: TileRef) {
- const tiles: TileRef[] = [];
- tiles.push(tile);
- this.neighbors(tile).forEach((t) => tiles.push(t));
-
- for (const t of tiles) {
+ const updateBorderStatus = (t: TileRef) => {
if (!this.hasOwner(t)) {
- continue;
+ return;
}
+ const owner = this.owner(t) as PlayerImpl;
if (this.calcIsBorder(t)) {
- (this.owner(t) as PlayerImpl)._borderTiles.add(t);
+ owner._borderTiles.add(t);
} else {
- (this.owner(t) as PlayerImpl)._borderTiles.delete(t);
+ owner._borderTiles.delete(t);
}
- }
+ };
+
+ updateBorderStatus(tile);
+ this.forEachNeighbor(tile, updateBorderStatus);
}
private calcIsBorder(tile: TileRef): boolean {
if (!this.hasOwner(tile)) {
return false;
}
- for (const neighbor of this.neighbors(tile)) {
- const bordersEnemy = this.owner(tile) !== this.owner(neighbor);
- if (bordersEnemy) {
- return true;
- }
+ 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;
}
@@ -1097,26 +1113,48 @@ export class GameImpl implements Game {
}
}
- const gold = conquered.gold();
- this.displayMessage(
- `Conquered ${conquered.displayName()} received ${renderNumber(
+ // 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();
+
+ 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`,
- MessageType.CONQUERED_PLAYER,
- conqueror.id(),
- gold,
- );
- conqueror.addGold(gold);
- conquered.removeGold(gold);
+ {
+ gold: renderNumber(gold),
+ name: conquered.displayName(),
+ },
+ );
+ conqueror.addGold(gold);
+ conquered.removeGold(gold);
+
+ // Record stats
+ this.stats().goldWar(conqueror, conquered, gold);
+ }
+
this.addUpdate({
type: GameUpdateType.ConquestEvent,
conquerorId: conqueror.id(),
conqueredId: conquered.id(),
gold,
});
-
- // Record stats
- this.stats().goldWar(conqueror, conquered, gold);
}
}
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index fae23df8e..64cdf70c8 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -20,6 +20,7 @@ import {
ColoredTeams,
Embargo,
EmojiMessage,
+ GameMode,
Gold,
MessageType,
MutableAlliance,
@@ -29,6 +30,7 @@ import {
PlayerProfile,
PlayerType,
Relation,
+ StructureTypes,
Team,
TerraNullius,
Tick,
@@ -994,10 +996,10 @@ export class PlayerImpl implements Player {
if (!this.mg.hasOwner(targetTile)) {
return false;
}
- return this.nukeSpawn(targetTile);
+ return this.nukeSpawn(targetTile, unitType);
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
- return this.nukeSpawn(targetTile);
+ return this.nukeSpawn(targetTile, unitType);
case UnitType.MIRVWarhead:
return targetTile;
case UnitType.Port:
@@ -1024,7 +1026,7 @@ export class PlayerImpl implements Player {
}
}
- nukeSpawn(tile: TileRef): TileRef | false {
+ nukeSpawn(tile: TileRef, nukeType: UnitType): TileRef | false {
if (this.mg.isSpawnImmunityActive()) {
return false;
}
@@ -1034,6 +1036,24 @@ export class PlayerImpl implements Player {
return false;
}
}
+
+ // Prevent launching nukes that would hit teammate structures (only in team games)
+ if (
+ this.mg.config().gameConfig().gameMode === GameMode.Team &&
+ nukeType !== UnitType.MIRV
+ ) {
+ const magnitude = this.mg.config().nukeMagnitudes(nukeType);
+ const wouldHitTeammate = this.mg.anyUnitNearby(
+ tile,
+ magnitude.outer,
+ StructureTypes,
+ (unit) => unit.owner().isPlayer() && this.isOnSameTeam(unit.owner()),
+ );
+ if (wouldHitTeammate) {
+ return false;
+ }
+ }
+
// only get missilesilos that are not on cooldown and not under construction
const spawns = this.units(UnitType.MissileSilo)
.filter((silo) => {
diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts
index 9917737d9..6e9b57f0e 100644
--- a/src/core/game/TrainStation.ts
+++ b/src/core/game/TrainStation.ts
@@ -155,6 +155,12 @@ export class TrainStation {
*/
export class Cluster {
public stations: Set = new Set();
+ private tradeStations: Set = new Set();
+
+ private isTradeStation(station: TrainStation): boolean {
+ const type = station.unit.type();
+ return type === UnitType.City || type === UnitType.Port;
+ }
has(station: TrainStation) {
return this.stations.has(station);
@@ -162,11 +168,15 @@ export class Cluster {
addStation(station: TrainStation) {
this.stations.add(station);
+ if (this.isTradeStation(station)) {
+ this.tradeStations.add(station);
+ }
station.setCluster(this);
}
removeStation(station: TrainStation) {
this.stations.delete(station);
+ this.tradeStations.delete(station);
}
addStations(stations: Set) {
@@ -181,14 +191,39 @@ export class Cluster {
}
}
+ hasAnyTradeDestination(player: Player): boolean {
+ for (const station of this.tradeStations) {
+ if (station.tradeAvailable(player)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ randomTradeDestination(
+ player: Player,
+ random: PseudoRandom,
+ ): TrainStation | null {
+ let selected: TrainStation | null = null;
+ let eligibleSeen = 0;
+
+ for (const station of this.tradeStations) {
+ if (!station.tradeAvailable(player)) continue;
+ eligibleSeen++;
+
+ // Reservoir sampling: keep each eligible station with probability 1/eligibleSeen.
+ if (random.nextInt(0, eligibleSeen) === 0) {
+ selected = station;
+ }
+ }
+
+ return selected;
+ }
+
availableForTrade(player: Player): Set {
const tradingStations = new Set();
- for (const station of this.stations) {
- if (
- (station.unit.type() === UnitType.City ||
- station.unit.type() === UnitType.Port) &&
- station.tradeAvailable(player)
- ) {
+ for (const station of this.tradeStations) {
+ if (station.tradeAvailable(player)) {
tradingStations.add(station);
}
}
@@ -201,6 +236,7 @@ export class Cluster {
clear() {
this.stations.clear();
+ this.tradeStations.clear();
}
}
diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts
index d2588ae00..7b7e4358a 100644
--- a/src/server/GameManager.ts
+++ b/src/server/GameManager.ts
@@ -26,6 +26,12 @@ export class GameManager {
return this.games.get(id) ?? null;
}
+ public publicLobbies(): GameServer[] {
+ return Array.from(this.games.values()).filter(
+ (g) => g.phase() === GamePhase.Lobby && g.isPublic(),
+ );
+ }
+
joinClient(client: Client, gameID: GameID): boolean {
const game = this.games.get(gameID);
if (game) {
@@ -52,6 +58,7 @@ export class GameManager {
id: GameID,
gameConfig: GameConfig | undefined,
creatorClientID?: string,
+ startsAt?: number,
) {
const game = new GameServer(
id,
@@ -77,6 +84,7 @@ export class GameManager {
...gameConfig,
},
creatorClientID,
+ startsAt,
);
this.games.set(id, game);
return game;
diff --git a/src/server/GamePreviewBuilder.ts b/src/server/GamePreviewBuilder.ts
index a68c335c2..ea9916025 100644
--- a/src/server/GamePreviewBuilder.ts
+++ b/src/server/GamePreviewBuilder.ts
@@ -147,8 +147,7 @@ export function buildPreview(
activePlayers = countActivePlayers(players);
} else {
activePlayers =
- countActivePlayers(players) ||
- (lobby?.numClients ?? lobby?.clients?.length ?? 0);
+ countActivePlayers(players) || (lobby?.clients?.length ?? 0);
}
const map = lobby?.gameConfig?.gameMap ?? config.gameMap;
let mode = lobby?.gameConfig?.gameMode ?? config.gameMode ?? GameMode.FFA;
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index ef476f1ec..f8b183dce 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -88,6 +88,7 @@ export class GameServer {
private config: ServerConfig,
public gameConfig: GameConfig,
private lobbyCreatorID?: string,
+ private startsAt?: number,
) {
this.log = log_.child({ gameID: id });
}
@@ -506,15 +507,6 @@ export class GameServer {
return this.activeClients.length;
}
- public startTime(): number {
- if (this._startTime !== null && this._startTime > 0) {
- return this._startTime;
- } else {
- //game hasn't started yet, only works for public games
- return this.createdAt + this.config.gameCreationRate();
- }
- }
-
public prestart() {
if (this.hasStarted()) {
return;
@@ -787,8 +779,9 @@ export class GameServer {
}
}
- const msSinceCreation = now - this.createdAt;
- const lessThanLifetime = msSinceCreation < this.config.gameCreationRate();
+ // Public Games
+
+ const lessThanLifetime = Date.now() < this.startsAt!;
const notEnoughPlayers =
this.gameConfig.gameType === GameType.Public &&
this.gameConfig.maxPlayers &&
@@ -796,8 +789,7 @@ export class GameServer {
if (lessThanLifetime && notEnoughPlayers) {
return GamePhase.Lobby;
}
- const warmupOver =
- now > this.createdAt + this.config.gameCreationRate() + 30 * 1000;
+ const warmupOver = now > this.startsAt! + 30 * 1000;
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished;
}
@@ -817,15 +809,11 @@ export class GameServer {
clientID: c.clientID,
})),
gameConfig: this.gameConfig,
- msUntilStart: this.isPublic() ? this.getMsUntilStart() : undefined,
+ startsAt: this.startsAt,
+ serverTime: Date.now(),
};
}
- private getMsUntilStart(): number {
- const startTime = this.createdAt + this.config.gameCreationRate();
- return Math.max(0, startTime - Date.now());
- }
-
public isPublic(): boolean {
return this.gameConfig.gameType === GameType.Public;
}
diff --git a/src/server/IPCBridgeSchema.ts b/src/server/IPCBridgeSchema.ts
new file mode 100644
index 000000000..e6034091e
--- /dev/null
+++ b/src/server/IPCBridgeSchema.ts
@@ -0,0 +1,56 @@
+import { z } from "zod";
+import {
+ GameConfigSchema,
+ PublicGameInfoSchema,
+ PublicGamesSchema,
+} from "../core/Schemas";
+
+export type WorkerLobbyList = z.infer;
+export type WorkerReady = z.infer;
+export type MasterLobbiesBroadcast = z.infer<
+ typeof MasterLobbiesBroadcastSchema
+>;
+export type MasterCreateGame = z.infer;
+export type WorkerMessage = z.infer;
+export type MasterMessage = z.infer;
+
+// --- Worker Messages ---
+
+// Worker tells the master about its lobbies.
+const WorkerLobbyListSchema = z.object({
+ type: z.literal("lobbyList"),
+ lobbies: z.array(PublicGameInfoSchema),
+});
+
+const WorkerReadySchema = z.object({
+ type: z.literal("workerReady"),
+ workerId: z.number(),
+});
+
+export const WorkerMessageSchema = z.discriminatedUnion("type", [
+ WorkerLobbyListSchema,
+ WorkerReadySchema,
+]);
+
+// --- Master Messages ---
+
+// Broadcasts all public game info to all workers.
+// Workers need information on all public lobbies so
+// it can send it to the client.
+const MasterLobbiesBroadcastSchema = z.object({
+ type: z.literal("lobbiesBroadcast"),
+ publicGames: PublicGamesSchema,
+});
+
+// Master sends a message to worker to schedule a new public game/lobby.
+const MasterCreateGameSchema = z.object({
+ type: z.literal("createGame"),
+ gameID: z.string(),
+ gameConfig: GameConfigSchema,
+ startsAt: z.number(),
+});
+
+export const MasterMessageSchema = z.discriminatedUnion("type", [
+ MasterLobbiesBroadcastSchema,
+ MasterCreateGameSchema,
+]);
diff --git a/src/server/Master.ts b/src/server/Master.ts
index c2b131c2d..a9322d8ff 100644
--- a/src/server/Master.ts
+++ b/src/server/Master.ts
@@ -5,19 +5,16 @@ import rateLimit from "express-rate-limit";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
-import { WebSocket, WebSocketServer } from "ws";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
-import { GameInfo } from "../core/Schemas";
-import { generateID } from "../core/Util";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
-import { startPolling } from "./PollingLoop";
+import { MasterLobbyService } from "./MasterLobbyService";
import { renderHtml } from "./RenderHtml";
const config = getServerConfigFromServer();
const playlist = new MapPlaylist();
-const readyWorkers = new Set();
+let lobbyService: MasterLobbyService;
const app = express();
const server = http.createServer(app);
@@ -68,33 +65,6 @@ app.use(
}),
);
-let publicLobbiesData: { lobbies: GameInfo[] } = { lobbies: [] };
-
-const publicLobbyIDs: Set = new Set();
-const connectedClients: Set = new Set();
-
-// Broadcast lobbies to all connected clients
-function broadcastLobbies() {
- const message = JSON.stringify({
- type: "lobbies_update",
- data: publicLobbiesData,
- });
-
- const clientsToRemove: WebSocket[] = [];
-
- connectedClients.forEach((client) => {
- if (client.readyState === WebSocket.OPEN) {
- client.send(message);
- } else {
- clientsToRemove.push(client);
- }
- });
-
- clientsToRemove.forEach((client) => {
- connectedClients.delete(client);
- });
-}
-
// Start the master process
export async function startMaster() {
if (!cluster.isPrimary) {
@@ -106,36 +76,7 @@ export async function startMaster() {
log.info(`Primary ${process.pid} is running`);
log.info(`Setting up ${config.numWorkers()} workers...`);
- // Setup WebSocket server for clients
- const wss = new WebSocketServer({ server, path: "/lobbies" });
-
- wss.on("connection", (ws: WebSocket) => {
- connectedClients.add(ws);
-
- // Send current lobbies immediately (always send, even if empty)
- ws.send(
- JSON.stringify({ type: "lobbies_update", data: publicLobbiesData }),
- );
-
- ws.on("close", () => {
- connectedClients.delete(ws);
- });
-
- ws.on("error", (error) => {
- log.error(`WebSocket error:`, error);
- connectedClients.delete(ws);
- try {
- if (
- ws.readyState === WebSocket.OPEN ||
- ws.readyState === WebSocket.CONNECTING
- ) {
- ws.close(1011, "WebSocket internal error");
- }
- } catch (closeError) {
- log.error("Error while closing WebSocket after error:", closeError);
- }
- });
- });
+ lobbyService = new MasterLobbyService(config, playlist, log);
// Generate admin token for worker authentication
const ADMIN_TOKEN = crypto.randomBytes(16).toString("hex");
@@ -157,44 +98,21 @@ export async function startMaster() {
INSTANCE_ID,
});
+ lobbyService.registerWorker(i, worker);
log.info(`Started worker ${i} (PID: ${worker.process.pid})`);
}
- cluster.on("message", (worker, message) => {
- if (message.type === "WORKER_READY") {
- const workerId = message.workerId;
- readyWorkers.add(workerId);
- log.info(
- `Worker ${workerId} is ready. (${readyWorkers.size}/${config.numWorkers()} ready)`,
- );
- // Start scheduling when all workers are ready
- if (readyWorkers.size === config.numWorkers()) {
- log.info("All workers ready, starting game scheduling");
-
- const scheduleLobbies = () => {
- schedulePublicGame(playlist).catch((error) => {
- log.error("Error scheduling public game:", error);
- });
- };
-
- startPolling(async () => {
- const lobbies = await fetchLobbies();
- if (lobbies === 0) {
- scheduleLobbies();
- }
- }, 100);
- }
- }
- });
-
// Handle worker crashes
cluster.on("exit", (worker, code, signal) => {
const workerId = (worker as any).process?.env?.WORKER_ID;
- if (!workerId) {
+ if (workerId === undefined) {
log.error(`worker crashed could not find id`);
return;
}
+ const workerIdNum = parseInt(workerId);
+ lobbyService.removeWorker(workerIdNum);
+
log.warn(
`Worker ${workerId} (PID: ${worker.process.pid}) died with code: ${code} and signal: ${signal}`,
);
@@ -207,6 +125,7 @@ export async function startMaster() {
INSTANCE_ID,
});
+ lobbyService.registerWorker(workerIdNum, newWorker);
log.info(
`Restarted worker ${workerId} (New PID: ${newWorker.process.pid})`,
);
@@ -226,115 +145,6 @@ app.get("/api/env", async (req, res) => {
res.json(envConfig);
});
-// Add lobbies endpoint to list public games for this worker
-app.get("/api/public_lobbies", async (req, res) => {
- res.json(publicLobbiesData);
-});
-
-async function fetchLobbies(): Promise {
- const fetchPromises: Promise[] = [];
-
- for (const gameID of new Set(publicLobbyIDs)) {
- const controller = new AbortController();
- setTimeout(() => controller.abort(), 5000); // 5 second timeout
- const port = config.workerPort(gameID);
- const promise = fetch(`http://localhost:${port}/api/game/${gameID}`, {
- headers: { [config.adminHeader()]: config.adminToken() },
- signal: controller.signal,
- })
- .then((resp) => resp.json())
- .then((json) => {
- return json as GameInfo;
- })
- .catch((error) => {
- log.error(`Error fetching game ${gameID}:`, error);
- // Return null or a placeholder if fetch fails
- publicLobbyIDs.delete(gameID);
- return null;
- });
-
- fetchPromises.push(promise);
- }
-
- // Wait for all promises to resolve
- const results = await Promise.all(fetchPromises);
-
- // Filter out any null results from failed fetches
- const lobbyInfos: GameInfo[] = results
- .filter((result) => result !== null)
- .map((gi: GameInfo) => {
- return {
- gameID: gi.gameID,
- numClients: gi?.clients?.length ?? 0,
- gameConfig: gi.gameConfig,
- msUntilStart: gi.msUntilStart,
- } as GameInfo;
- });
-
- lobbyInfos.forEach((l) => {
- if (
- "msUntilStart" in l &&
- l.msUntilStart !== undefined &&
- l.msUntilStart <= 250
- ) {
- publicLobbyIDs.delete(l.gameID);
- return;
- }
-
- if (
- "gameConfig" in l &&
- l.gameConfig !== undefined &&
- "maxPlayers" in l.gameConfig &&
- l.gameConfig.maxPlayers !== undefined &&
- "numClients" in l &&
- l.numClients !== undefined &&
- l.gameConfig.maxPlayers <= l.numClients
- ) {
- publicLobbyIDs.delete(l.gameID);
- return;
- }
- });
-
- // Update the lobbies data
- publicLobbiesData = {
- lobbies: lobbyInfos,
- };
-
- broadcastLobbies();
-
- return publicLobbyIDs.size;
-}
-
-// Function to schedule a new public game
-async function schedulePublicGame(playlist: MapPlaylist) {
- const gameID = generateID();
- publicLobbyIDs.add(gameID);
-
- const workerPath = config.workerPath(gameID);
-
- // Send request to the worker to start the game
- try {
- const response = await fetch(
- `http://localhost:${config.workerPort(gameID)}/api/create_game/${gameID}`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- [config.adminHeader()]: config.adminToken(),
- },
- body: JSON.stringify(await playlist.gameConfig()),
- },
- );
-
- if (!response.ok) {
- throw new Error(`Failed to schedule public game: ${response.statusText}`);
- }
- } catch (error) {
- log.error(`Failed to schedule public game on worker ${workerPath}:`, error);
- throw error;
- }
-}
-
// SPA fallback route
app.get("*", async function (_req, res) {
try {
diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts
new file mode 100644
index 000000000..c82684cd9
--- /dev/null
+++ b/src/server/MasterLobbyService.ts
@@ -0,0 +1,135 @@
+import { Worker } from "cluster";
+import winston from "winston";
+import { ServerConfig } from "../core/configuration/Config";
+import { PublicGameInfo } from "../core/Schemas";
+import { generateID } from "../core/Util";
+import {
+ MasterCreateGame,
+ MasterLobbiesBroadcast,
+ WorkerMessageSchema,
+} from "./IPCBridgeSchema";
+import { logger } from "./Logger";
+import { MapPlaylist } from "./MapPlaylist";
+import { startPolling } from "./PollingLoop";
+
+export interface MasterLobbyServiceOptions {
+ config: ServerConfig;
+ playlist: MapPlaylist;
+ log: typeof logger;
+}
+
+export class MasterLobbyService {
+ private readonly workers = new Map();
+ // Worker id => the lobbies it owns.
+ private readonly workerLobbies = new Map();
+ private readonly readyWorkers = new Set();
+ private started = false;
+
+ constructor(
+ private config: ServerConfig,
+ private playlist: MapPlaylist,
+ private log: winston.Logger,
+ ) {}
+
+ registerWorker(workerId: number, worker: Worker) {
+ this.workers.set(workerId, worker);
+
+ worker.on("message", (raw: unknown) => {
+ const result = WorkerMessageSchema.safeParse(raw);
+ if (!result.success) {
+ this.log.error("Invalid IPC message from worker:", raw);
+ return;
+ }
+
+ const msg = result.data;
+ switch (msg.type) {
+ case "workerReady":
+ this.handleWorkerReady(msg.workerId);
+ break;
+ case "lobbyList":
+ this.workerLobbies.set(workerId, msg.lobbies);
+ break;
+ }
+ });
+ }
+
+ removeWorker(workerId: number) {
+ this.workers.delete(workerId);
+ this.workerLobbies.delete(workerId);
+ this.readyWorkers.delete(workerId);
+ }
+
+ private handleWorkerReady(workerId: number) {
+ this.readyWorkers.add(workerId);
+ this.log.info(
+ `Worker ${workerId} is ready. (${this.readyWorkers.size}/${this.config.numWorkers()} ready)`,
+ );
+ if (this.readyWorkers.size === this.config.numWorkers() && !this.started) {
+ this.started = true;
+ this.log.info("All workers ready, starting game scheduling");
+ startPolling(async () => this.broadcastLobbies(), 250);
+ startPolling(async () => await this.maybeScheduleLobby(), 1000);
+ }
+ }
+
+ private getAllLobbies(): PublicGameInfo[] {
+ const lobbies = Array.from(this.workerLobbies.values())
+ .flat()
+ .sort((a, b) => a.startsAt! - b.startsAt);
+ return lobbies;
+ }
+
+ private broadcastLobbies() {
+ const msg = {
+ type: "lobbiesBroadcast",
+ publicGames: {
+ serverTime: Date.now(),
+ games: this.getAllLobbies(),
+ },
+ } satisfies MasterLobbiesBroadcast;
+ for (const worker of this.workers.values()) {
+ worker.send(msg, (e) => {
+ if (e) {
+ this.log.error("Failed to send lobbies broadcast to worker:", e);
+ }
+ });
+ }
+ }
+
+ private async maybeScheduleLobby() {
+ const lobbies = this.getAllLobbies();
+ if (lobbies.length >= 2) {
+ return;
+ }
+
+ const lastStart = lobbies.reduce(
+ (max, pb) => Math.max(max, pb.startsAt),
+ Date.now(),
+ );
+
+ const gameID = generateID();
+ const workerId = this.config.workerIndex(gameID);
+
+ const gameConfig = await this.playlist.gameConfig();
+ const worker = this.workers.get(workerId);
+ if (!worker) {
+ this.log.error(`Worker ${workerId} not found`);
+ return;
+ }
+
+ worker.send(
+ {
+ type: "createGame",
+ gameID,
+ gameConfig,
+ startsAt: lastStart + this.config.gameCreationRate(),
+ } satisfies MasterCreateGame,
+ (e) => {
+ if (e) {
+ this.log.error("Failed to schedule lobby on worker:", e);
+ }
+ },
+ );
+ this.log.info(`Scheduled public game ${gameID} on worker ${workerId}`);
+ }
+}
diff --git a/src/server/Worker.ts b/src/server/Worker.ts
index e9f340c27..2d8d8dcea 100644
--- a/src/server/Worker.ts
+++ b/src/server/Worker.ts
@@ -30,6 +30,7 @@ import { MapPlaylist } from "./MapPlaylist";
import { startPolling } from "./PollingLoop";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { verifyTurnstileToken } from "./Turnstile";
+import { WorkerLobbyService } from "./WorkerLobbyService";
import { initWorkerMetrics } from "./WorkerMetrics";
const config = getServerConfigFromServer();
@@ -42,6 +43,18 @@ const playlist = new MapPlaylist(true);
export async function startWorker() {
log.info(`Worker starting...`);
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = path.dirname(__filename);
+
+ const app = express();
+ const server = http.createServer(app);
+ const wss = new WebSocketServer({ noServer: true });
+
+ const gm = new GameManager(config, log);
+
+ // Initialize lobby service (handles WebSocket upgrade routing)
+ const lobbyService = new WorkerLobbyService(server, wss, gm, log);
+
setTimeout(
() => {
startMatchmakingPolling(gm);
@@ -49,15 +62,6 @@ export async function startWorker() {
1000 + Math.random() * 2000,
);
- const __filename = fileURLToPath(import.meta.url);
- const __dirname = path.dirname(__filename);
-
- const app = express();
- const server = http.createServer(app);
- const wss = new WebSocketServer({ server });
-
- const gm = new GameManager(config, log);
-
if (config.otelEnabled()) {
initWorkerMetrics(gm);
}
@@ -459,13 +463,8 @@ export async function startWorker() {
log.info(`running on http://localhost:${PORT}`);
log.info(`Handling requests with path prefix /w${workerId}/`);
// Signal to the master process that this worker is ready
- if (process.send) {
- process.send({
- type: "WORKER_READY",
- workerId: workerId,
- });
- log.info(`signaled ready state to master`);
- }
+ lobbyService.sendReady(workerId);
+ log.info(`signaled ready state to master`);
});
// Global error handler
diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts
new file mode 100644
index 000000000..d06609294
--- /dev/null
+++ b/src/server/WorkerLobbyService.ts
@@ -0,0 +1,136 @@
+import http from "http";
+import { WebSocket, WebSocketServer } from "ws";
+import { PublicGameInfo, PublicGames } from "../core/Schemas";
+import { GameManager } from "./GameManager";
+import {
+ MasterMessageSchema,
+ WorkerLobbyList,
+ WorkerReady,
+} from "./IPCBridgeSchema";
+import { logger } from "./Logger";
+
+export class WorkerLobbyService {
+ private readonly lobbiesWss: WebSocketServer;
+ private readonly lobbyClients: Set = new Set();
+
+ constructor(
+ private readonly server: http.Server,
+ private readonly gameWss: WebSocketServer,
+ private readonly gm: GameManager,
+ private readonly log: typeof logger,
+ ) {
+ this.lobbiesWss = new WebSocketServer({ noServer: true });
+ this.setupUpgradeHandler();
+ this.setupLobbiesWebSocket();
+ this.setupIPCListener();
+ }
+
+ private setupIPCListener() {
+ process.on("message", (raw: unknown) => {
+ const result = MasterMessageSchema.safeParse(raw);
+ if (!result.success) {
+ this.log.error("Invalid IPC message from master:", raw);
+ return;
+ }
+
+ const msg = result.data;
+ switch (msg.type) {
+ case "lobbiesBroadcast":
+ // Forward message to all clients
+ this.broadcastLobbiesToClients(msg.publicGames);
+ // Update master with my lobby info
+ this.sendMyLobbiesToMaster();
+ break;
+ case "createGame":
+ if (this.gm.game(msg.gameID) !== null) {
+ this.log.warn(`Game ${msg.gameID} already exists, skipping create`);
+ return;
+ }
+ this.log.info(`Creating public game ${msg.gameID} from master`);
+ this.gm.createGame(
+ msg.gameID,
+ msg.gameConfig,
+ undefined,
+ msg.startsAt,
+ );
+ break;
+ }
+ });
+ }
+
+ sendReady(workerId: number) {
+ const msg: WorkerReady = { type: "workerReady", workerId };
+ process.send?.(msg);
+ }
+
+ private sendMyLobbiesToMaster() {
+ const lobbies = this.gm
+ .publicLobbies()
+ .map((g) => g.gameInfo())
+ .map((gi) => {
+ return {
+ gameID: gi.gameID,
+ numClients: gi.clients?.length ?? 0,
+ startsAt: gi.startsAt!,
+ gameConfig: gi.gameConfig,
+ } satisfies PublicGameInfo;
+ });
+ process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);
+ }
+
+ private setupUpgradeHandler() {
+ this.server.on("upgrade", (request, socket, head) => {
+ const pathname = request.url ?? "";
+ if (pathname === "/lobbies" || pathname.endsWith("/lobbies")) {
+ this.lobbiesWss.handleUpgrade(request, socket, head, (ws) => {
+ this.lobbiesWss.emit("connection", ws, request);
+ });
+ } else {
+ this.gameWss.handleUpgrade(request, socket, head, (ws) => {
+ this.gameWss.emit("connection", ws, request);
+ });
+ }
+ });
+ }
+
+ private setupLobbiesWebSocket() {
+ this.lobbiesWss.on("connection", (ws: WebSocket) => {
+ this.lobbyClients.add(ws);
+ ws.on("close", () => {
+ this.lobbyClients.delete(ws);
+ });
+
+ ws.on("error", (error) => {
+ this.log.error(`Lobbies WebSocket error:`, error);
+ this.lobbyClients.delete(ws);
+ try {
+ if (
+ ws.readyState === WebSocket.OPEN ||
+ ws.readyState === WebSocket.CONNECTING
+ ) {
+ ws.close(1011, "WebSocket internal error");
+ }
+ } catch (closeError) {
+ this.log.error("Error closing lobbies WebSocket:", closeError);
+ }
+ });
+ });
+ }
+
+ private broadcastLobbiesToClients(publicGames: PublicGames) {
+ const message = JSON.stringify(publicGames);
+
+ const clientsToRemove: WebSocket[] = [];
+ this.lobbyClients.forEach((client) => {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(message);
+ } else {
+ clientsToRemove.push(client);
+ }
+ });
+
+ clientsToRemove.forEach((client) => {
+ this.lobbyClients.delete(client);
+ });
+ }
+}
diff --git a/tests/AttackStats.test.ts b/tests/AttackStats.test.ts
index 10cb236c3..23e0ef98f 100644
--- a/tests/AttackStats.test.ts
+++ b/tests/AttackStats.test.ts
@@ -34,11 +34,36 @@ describe("AttackStats", () => {
test("should increase war gold stat when a player is eliminated", () => {
expect(player1.sharesBorderWith(player2)).toBeTruthy();
+ // Player2 must attack to be considered active (otherwise gold won't transfer)
+ game.addExecution(
+ new AttackExecution(1, player2, game.terraNullius().id()),
+ );
+ game.executeNextTick();
performAttack(game, player1, player2);
expectWarGoldStatIsIncreasedAfterKill(game, player1, player2);
});
+ test("should NOT increase war gold stat when a inactive player is eliminated", () => {
+ expect(player1.sharesBorderWith(player2)).toBeTruthy();
+
+ const attackerStatsBefore = game.stats().stats()[player1.clientID()!];
+ const warGoldBefore = attackerStatsBefore?.gold?.[GOLD_INDEX_WAR] ?? 0n;
+
+ performAttack(game, player1, player2);
+
+ const attackerStatsAfter = game.stats().stats()[player1.clientID()!];
+ const warGoldAfter = attackerStatsAfter?.gold?.[GOLD_INDEX_WAR] ?? 0n;
+
+ expect(warGoldAfter).toBe(warGoldBefore);
+ });
+
test("should increase war gold stat when elimination occurs via territory annexation", () => {
+ // Player2 must attack to be considered active (otherwise gold won't transfer)
+ game.addExecution(
+ new AttackExecution(1, player2, game.terraNullius().id()),
+ );
+ game.executeNextTick();
+
// Mark every tile on the map as owned by player1
for (let x = 0; x < game.map().width(); x++) {
for (let y = 0; y < game.map().height(); y++) {
diff --git a/tests/client/LobbySocket.test.ts b/tests/client/LobbySocket.test.ts
deleted file mode 100644
index 3e6e8d901..000000000
--- a/tests/client/LobbySocket.test.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { PublicLobbySocket } from "../../src/client/LobbySocket";
-
-class MockWebSocket extends EventTarget {
- static instances: MockWebSocket[] = [];
- static readonly OPEN = 1;
- static readonly CLOSED = 3;
-
- readonly url: string;
- readyState = MockWebSocket.OPEN;
-
- constructor(url: string) {
- super();
- this.url = url;
- MockWebSocket.instances.push(this);
- }
-
- addEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject,
- options?: boolean | AddEventListenerOptions,
- ): void {
- super.addEventListener(type, listener, options);
- }
-
- close(code?: number, reason?: string) {
- this.readyState = MockWebSocket.CLOSED;
- this.dispatchEvent(new CloseEvent("close", { code, reason }));
- }
-
- send(_data: unknown) {}
-}
-
-describe("PublicLobbySocket", () => {
- const originalWebSocket = globalThis.WebSocket;
- const originalFetch = globalThis.fetch;
-
- beforeEach(() => {
- MockWebSocket.instances = [];
- // @ts-expect-error assign test mock
- globalThis.WebSocket = MockWebSocket;
- });
-
- afterEach(() => {
- globalThis.WebSocket = originalWebSocket;
- globalThis.fetch = originalFetch;
- vi.useRealTimers();
- });
-
- it("delivers lobby updates from websocket messages", () => {
- const updates: unknown[][] = [];
- const socket = new PublicLobbySocket((lobbies) => updates.push(lobbies));
-
- socket.start();
- const ws = MockWebSocket.instances.at(-1);
- expect(ws?.url).toContain("/lobbies");
-
- ws?.dispatchEvent(
- new MessageEvent("message", {
- data: JSON.stringify({
- type: "lobbies_update",
- data: {
- lobbies: [
- {
- gameID: "g1",
- numClients: 1,
- gameConfig: {
- maxPlayers: 2,
- gameMode: 0,
- gameMap: "Earth",
- },
- },
- ],
- },
- }),
- }),
- );
-
- expect(updates).toHaveLength(1);
- expect((updates[0][0] as { gameID: string }).gameID).toBe("g1");
-
- socket.stop();
- });
-
- it("falls back to HTTP polling after max websocket attempts", async () => {
- vi.useFakeTimers();
-
- const fetchMock = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => ({ lobbies: [] }),
- });
-
- globalThis.fetch = fetchMock as unknown as typeof fetch;
-
- const socket = new PublicLobbySocket(() => {}, {
- maxWsAttempts: 1,
- reconnectDelay: 0,
- pollIntervalMs: 50,
- });
-
- socket.start();
- const ws = MockWebSocket.instances.at(-1);
- ws?.dispatchEvent(new CloseEvent("close"));
-
- await Promise.resolve();
- expect(fetchMock).toHaveBeenCalledTimes(1);
-
- vi.advanceTimersByTime(60);
- await Promise.resolve();
- expect(fetchMock).toHaveBeenCalledTimes(2);
-
- socket.stop();
- });
-});
diff --git a/tests/core/game/Cluster.test.ts b/tests/core/game/Cluster.test.ts
index 138e3968e..f64d73d09 100644
--- a/tests/core/game/Cluster.test.ts
+++ b/tests/core/game/Cluster.test.ts
@@ -1,9 +1,13 @@
import { vi, type Mocked } from "vitest";
+import { UnitType } from "../../../src/core/game/Game";
import { Cluster, TrainStation } from "../../../src/core/game/TrainStation";
const createMockStation = (id: string): Mocked => {
return {
id,
+ unit: {
+ type: vi.fn(() => UnitType.City),
+ } as any,
setCluster: vi.fn(),
getCluster: vi.fn(() => null),
} as any;
diff --git a/tests/core/game/RailNetwork.test.ts b/tests/core/game/RailNetwork.test.ts
index fc39db81d..70be4febd 100644
--- a/tests/core/game/RailNetwork.test.ts
+++ b/tests/core/game/RailNetwork.test.ts
@@ -1,4 +1,4 @@
-import { Unit } from "../../../src/core/game/Game";
+import { Unit, UnitType } from "../../../src/core/game/Game";
import {
RailNetworkImpl,
StationManagerImpl,
@@ -14,6 +14,7 @@ const createMockStation = (unitId: number): any => {
unit: {
id: unitId,
setTrainStation: vi.fn(),
+ type: vi.fn(() => UnitType.City),
},
tile: vi.fn(),
neighbors: vi.fn(() => []),