Add trains (#1159)

## Description:

Add a rail network to handle train stations/railroad between structures.

Changes:
- `RailNetwork` is responsible for the train station graph. Use it to
connect new `TrainStations`
- A `RailRoad` connects two `TrainStation`
- No loop possible in the rail network
- Train stations handles its railroads
- Added a layer to draw the railroads under the structures

#### Clusters
- To speed up computations, each `TrainStation` references its own
cluster
- A cluster is a list of `TrainStation` connected with each other,
created by the `RailNetwork` when connecting the station
- Train stations spawn trains randomly depending on its current cluster
size
- A `TrainStation` decides randomly of the train destination by picking
one from the cluster

#### Production building:
- Added a factory which has no gameplay impact currently. _To be
discussed._

#### Train stops:
- When a train reaches a factory, it's filled with a "cargo". The loaded
trains has no impact currently. _To be discussed._
- When a train reaches a city, the player earn 10k gold
- When a train reaches a port, it sends a new tradeship if possible
- If a destination/source is destroyed, the train & railroad are deleted
too


https://github.com/user-attachments/assets/42375c17-9e04-4a42-98d0-708c81ffd609


https://github.com/user-attachments/assets/fbecdb53-a516-4df8-87fb-1f9a62c4efa0



## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

IngloriousTom

---------

Co-authored-by: Scott Anderson <scottanderson@users.noreply.github.com>
This commit is contained in:
DevelopingTom
2025-06-22 08:14:08 -07:00
committed by GitHub
co-authored by Scott Anderson
parent 0f2008a68d
commit 43397779fa
60 changed files with 2427 additions and 104 deletions
+26
View File
@@ -8,6 +8,7 @@ import {
UnitUpdate,
} from "./GameUpdates";
import { PlayerView } from "./GameView";
import { RailNetwork } from "./RailNetwork";
import { Stats } from "./Stats";
export type PlayerID = string;
@@ -149,6 +150,13 @@ export enum UnitType {
MIRV = "MIRV",
MIRVWarhead = "MIRV Warhead",
Construction = "Construction",
Train = "Train",
Factory = "Factory",
}
export enum TrainType {
Engine = "Engine",
Carriage = "Carriage",
}
const _structureTypes: ReadonlySet<UnitType> = new Set([
@@ -197,6 +205,14 @@ export interface UnitParamsMap {
lastSetSafeFromPirates?: number;
};
[UnitType.Train]: {
trainType: TrainType;
targetUnit?: Unit;
loaded?: boolean;
};
[UnitType.Factory]: {};
[UnitType.MissileSilo]: {
cooldownDuration?: number;
};
@@ -373,6 +389,13 @@ export interface Unit {
touch(): void;
hash(): number;
toUpdate(): UnitUpdate;
hasTrainStation(): boolean;
setTrainStation(trainStation: boolean): void;
// Train
trainType(): TrainType | undefined;
isLoaded(): boolean | undefined;
setLoaded(loaded: boolean): void;
// Targeting
setTargetTile(cell: TileRef | undefined): void;
@@ -634,6 +657,9 @@ export interface Game extends GameMap {
numTilesWithFallout(): number;
// Optional as it's not initialized before the end of spawn phase
stats(): Stats;
addUpdate(update: GameUpdate): void;
railNetwork(): RailNetwork;
}
export interface PlayerActions {
+9
View File
@@ -30,6 +30,8 @@ import {
import { GameMap, TileRef, TileUpdate } from "./GameMap";
import { GameUpdate, GameUpdateType } from "./GameUpdates";
import { PlayerImpl } from "./PlayerImpl";
import { RailNetwork } from "./RailNetwork";
import { createRailNetwork } from "./RailNetworkImpl";
import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
@@ -73,6 +75,7 @@ export class GameImpl implements Game {
private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue];
private botTeam: Team = ColoredTeams.Bot;
private _railNetwork: RailNetwork = createRailNetwork(this);
constructor(
private _humans: PlayerInfo[],
@@ -672,6 +675,9 @@ export class GameImpl implements Game {
}
removeUnit(u: Unit) {
this.unitGrid.removeUnit(u);
if (u.hasTrainStation()) {
this._railNetwork.removeStation(u);
}
}
nearbyUnits(
@@ -787,6 +793,9 @@ export class GameImpl implements Game {
stats(): Stats {
return this._stats;
}
railNetwork(): RailNetwork {
return this._railNetwork;
}
}
// Or a more dynamic approach that will catch new enum values:
+36 -1
View File
@@ -9,6 +9,7 @@ import {
PlayerType,
Team,
Tick,
TrainType,
UnitType,
} from "./Game";
import { TileRef, TileUpdate } from "./GameMap";
@@ -40,6 +41,8 @@ export enum GameUpdateType {
Win,
Hash,
UnitIncoming,
BonusEvent,
RailroadEvent,
}
export type GameUpdate =
@@ -56,7 +59,36 @@ export type GameUpdate =
| EmojiUpdate
| WinUpdate
| HashUpdate
| UnitIncomingUpdate;
| UnitIncomingUpdate
| BonusEventUpdate
| RailroadUpdate;
export interface BonusEventUpdate {
type: GameUpdateType.BonusEvent;
tile: TileRef;
gold: number;
workers: number;
troops: number;
}
export enum RailType {
VERTICAL,
HORIZONTAL,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT,
}
export interface RailTile {
tile: TileRef;
railType: RailType;
}
export interface RailroadUpdate {
type: GameUpdateType.RailroadEvent;
isActive: boolean;
railTiles: RailTile[];
}
export interface TileUpdateWrapper {
type: GameUpdateType.Tile;
@@ -84,6 +116,9 @@ export interface UnitUpdate {
missileTimerQueue: number[];
readyMissileCount: number;
level: number;
hasTrainStation: boolean;
trainType?: TrainType; // Only for trains
loaded?: boolean; // Only for trains
}
export interface AttackUpdate {
+10
View File
@@ -19,6 +19,7 @@ import {
TerrainType,
TerraNullius,
Tick,
TrainType,
UnitInfo,
UnitType,
} from "./Game";
@@ -124,6 +125,15 @@ export class UnitView {
level(): number {
return this.data.level;
}
hasTrainStation(): boolean {
return this.data.hasTrainStation;
}
trainType(): TrainType | undefined {
return this.data.trainType;
}
isLoaded(): boolean | undefined {
return this.data.loaded;
}
}
export class PlayerView {
+7
View File
@@ -808,10 +808,13 @@ export class PlayerImpl implements Player {
return canBuildTransportShip(this.mg, this, targetTile);
case UnitType.TradeShip:
return this.tradeShipSpawn(targetTile);
case UnitType.Train:
return this.landBasedUnitSpawn(targetTile);
case UnitType.MissileSilo:
case UnitType.DefensePost:
case UnitType.SAMLauncher:
case UnitType.City:
case UnitType.Factory:
case UnitType.Construction:
return this.landBasedStructureSpawn(targetTile, validTiles);
default:
@@ -876,6 +879,10 @@ export class PlayerImpl implements Player {
return spawns[0].tile();
}
landBasedUnitSpawn(tile: TileRef): TileRef | false {
return this.mg.isLand(tile) ? tile : false;
}
landBasedStructureSpawn(
tile: TileRef,
validTiles: TileRef[] | null = null,
+8
View File
@@ -0,0 +1,8 @@
import { Unit } from "./Game";
import { TrainStation } from "./TrainStation";
export interface RailNetwork {
connectStation(station: TrainStation): void;
removeStation(unit: Unit): void;
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
}
+222
View File
@@ -0,0 +1,222 @@
import { RailroadExecution } from "../execution/RailroadExecution";
import { PathFindResultType } from "../pathfinding/AStar";
import { MiniAStar } from "../pathfinding/MiniAStar";
import { SerialAStar } from "../pathfinding/SerialAStar";
import { Game, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { RailNetwork } from "./RailNetwork";
import { Railroad } from "./Railroad";
import { Cluster, TrainStation, TrainStationMapAdapter } from "./TrainStation";
/**
* The Stations handle their own neighbors so the graph is naturally traversable,
* but it would be expensive to look through the graph to find a station.
* This class stores the existing stations for quick access
*/
export interface StationManager {
addStation(station: TrainStation): void;
removeStation(station: TrainStation): void;
findStation(unit: Unit): TrainStation | null;
getAll(): Set<TrainStation>;
}
export class StationManagerImpl implements StationManager {
private stations: Set<TrainStation> = new Set();
addStation(station: TrainStation) {
this.stations.add(station);
}
removeStation(station: TrainStation) {
this.stations.delete(station);
}
findStation(unit: Unit): TrainStation | null {
for (const station of this.stations) {
if (station.unit === unit) return station;
}
return null;
}
getAll(): Set<TrainStation> {
return this.stations;
}
}
export interface RailPathFinderService {
findTilePath(from: TileRef, to: TileRef): TileRef[];
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
}
class RailPathFinderServiceImpl implements RailPathFinderService {
constructor(private game: Game) {}
findTilePath(from: TileRef, to: TileRef): TileRef[] {
const astar = new MiniAStar(
this.game.map(),
this.game.miniMap(),
from,
to,
5000,
20,
false,
3,
);
return astar.compute() === PathFindResultType.Completed
? astar.reconstructPath()
: [];
}
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
const stationAStar = new SerialAStar(
from,
to,
5000,
20,
new TrainStationMapAdapter(this.game),
);
return stationAStar.compute() === PathFindResultType.Completed
? stationAStar.reconstructPath()
: [];
}
}
export function createRailNetwork(game: Game): RailNetwork {
const stationManager = new StationManagerImpl();
const pathService = new RailPathFinderServiceImpl(game);
return new RailNetworkImpl(game, stationManager, pathService);
}
export class RailNetworkImpl implements RailNetwork {
constructor(
private game: Game,
private stationManager: StationManager,
private pathService: RailPathFinderService,
) {}
connectStation(station: TrainStation) {
this.stationManager.addStation(station);
this.connectToNearbyStations(station);
}
removeStation(unit: Unit): void {
const station = this.stationManager.findStation(unit);
if (!station) return;
const neighbors = station.neighbors();
this.disconnectFromNetwork(station);
this.stationManager.removeStation(station);
const cluster = station.getCluster();
if (!cluster) return;
if (neighbors.length === 1) {
cluster.removeStation(station);
} else if (neighbors.length > 1) {
for (const neighbor of neighbors) {
const stations = this.computeCluster(neighbor);
const newCluster = new Cluster();
newCluster.addStations(stations);
}
}
}
/**
* Return the intermediary stations connecting two stations
*/
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
return this.pathService.findStationsPath(from, to);
}
private connectToNearbyStations(station: TrainStation) {
const neighbors = this.game.nearbyUnits(
station.tile(),
this.game.config().trainStationMaxRange(),
[UnitType.City, UnitType.Factory, UnitType.Port],
);
const editedClusters = new Set<Cluster>();
neighbors.sort((a, b) => a.distSquared - b.distSquared);
for (const neighbor of neighbors) {
if (neighbor.unit === station.unit) continue;
const neighborStation = this.stationManager.findStation(neighbor.unit);
if (!neighborStation) continue;
const neighborCluster = neighborStation.getCluster();
if (!neighborCluster || neighborCluster.has(station)) continue;
if (
neighbor.distSquared >
this.game.config().trainStationMinRange() ** 2
) {
if (this.connect(station, neighborStation)) {
neighborCluster.addStation(station);
editedClusters.add(neighborCluster);
}
}
}
// If multiple clusters own the new station, merge them into a single cluster
if (editedClusters.size > 1) {
this.mergeClusters(editedClusters);
} else if (editedClusters.size === 0) {
// If no cluster owns the station, creates a new one for it
const newCluster = new Cluster();
newCluster.addStation(station);
}
}
private disconnectFromNetwork(station: TrainStation) {
for (const rail of station.getRailroads()) {
rail.delete(this.game);
}
station.clearRailroads();
const cluster = station.getCluster();
if (cluster !== null && cluster.size() === 1) {
this.deleteCluster(cluster);
}
}
private deleteCluster(cluster: Cluster) {
for (const station of cluster.stations) {
station.setCluster(null);
}
cluster.clear();
}
private connect(from: TrainStation, to: TrainStation) {
const path = this.pathService.findTilePath(from.tile(), to.tile());
if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) {
const railRoad = new Railroad(from, to, path);
this.game.addExecution(new RailroadExecution(railRoad));
from.addRailroad(railRoad);
to.addRailroad(railRoad);
return true;
}
return false;
}
private computeCluster(start: TrainStation): Set<TrainStation> {
const visited = new Set<TrainStation>();
const queue = [start];
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current)) continue;
visited.add(current);
for (const neighbor of current.neighbors()) {
if (!visited.has(neighbor)) queue.push(neighbor);
}
}
return visited;
}
private mergeClusters(clustersToMerge: Set<Cluster>) {
const merged = new Cluster();
for (const cluster of clustersToMerge) {
merged.merge(cluster);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Game } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
import { TrainStation } from "./TrainStation";
export class Railroad {
constructor(
public from: TrainStation,
public to: TrainStation,
public tiles: TileRef[],
) {}
delete(game: Game) {
const railTiles: RailTile[] = this.tiles.map((tile) => ({
tile,
railType: RailType.VERTICAL,
}));
game.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: false,
railTiles,
});
this.from.getRailroads().delete(this);
this.to.getRailroads().delete(this);
}
}
export function getOrientedRailroad(
from: TrainStation,
to: TrainStation,
): OrientedRailroad | null {
for (const railroad of from.getRailroads()) {
if (railroad.from === to) {
return new OrientedRailroad(railroad, false);
} else if (railroad.to === to) {
return new OrientedRailroad(railroad, true);
}
}
return null;
}
/**
* Wrap a railroad with a direction so it always starts at tiles[0]
*/
export class OrientedRailroad {
private tiles: TileRef[] = [];
constructor(
private railroad: Railroad,
private forward: boolean,
) {
this.tiles = this.forward
? this.railroad.tiles
: [...this.railroad.tiles].reverse();
}
getTiles(): TileRef[] {
return this.tiles;
}
getStart(): TrainStation {
return this.forward ? this.railroad.from : this.railroad.to;
}
getEnd(): TrainStation {
return this.forward ? this.railroad.to : this.railroad.from;
}
}
+227
View File
@@ -0,0 +1,227 @@
import { TradeShipExecution } from "../execution/TradeShipExecution";
import { TrainExecution } from "../execution/TrainExecution";
import { GraphAdapter } from "../pathfinding/SerialAStar";
import { PseudoRandom } from "../PseudoRandom";
import { Game, Player, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
import { Railroad } from "./Railroad";
/**
* Handle train stops at various station types
*/
interface TrainStopHandler {
onStop(mg: Game, station: TrainStation, trainExecution: TrainExecution): void;
}
class CityStopHandler implements TrainStopHandler {
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {
const goldBonus = mg.config().trainGold();
station.unit.owner().addGold(goldBonus);
mg.addUpdate({
type: GameUpdateType.BonusEvent,
tile: station.tile(),
gold: Number(goldBonus),
workers: 0,
troops: 0,
});
}
}
class PortStopHandler implements TrainStopHandler {
constructor(private random: PseudoRandom) {}
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {
const unit = station.unit;
const ports = unit.owner().tradingPorts(unit);
if (ports.length === 0) return;
const port = this.random.randElement(ports);
mg.addExecution(new TradeShipExecution(unit.owner(), unit, port));
}
}
class FactoryStopHandler implements TrainStopHandler {
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {
trainExecution.loadCargo();
}
}
export function createTrainStopHandlers(
random: PseudoRandom,
): Partial<Record<UnitType, TrainStopHandler>> {
return {
[UnitType.City]: new CityStopHandler(),
[UnitType.Port]: new PortStopHandler(random),
[UnitType.Factory]: new FactoryStopHandler(),
};
}
export class TrainStation {
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
{};
private cluster: Cluster | null;
private railroads: Set<Railroad> = new Set();
constructor(
private mg: Game,
public unit: Unit,
) {
this.stopHandlers = createTrainStopHandlers(new PseudoRandom(mg.ticks()));
}
tradeAvailable(otherPlayer: Player): boolean {
const player = this.unit.owner();
return otherPlayer === player || player.canTrade(otherPlayer);
}
clearRailroads() {
this.railroads.clear();
}
addRailroad(railRoad: Railroad) {
this.railroads.add(railRoad);
}
removeNeighboringRails(station: TrainStation) {
const toRemove = [...this.railroads].find(
(r) => r.from === station || r.to === station,
);
if (toRemove) {
const railTiles: RailTile[] = toRemove.tiles.map((tile) => ({
tile,
railType: RailType.VERTICAL,
}));
this.mg.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: false,
railTiles,
});
this.railroads.delete(toRemove);
}
}
neighbors(): TrainStation[] {
const neighbors: TrainStation[] = [];
for (const r of this.railroads) {
if (r.from !== this) {
neighbors.push(r.from);
} else {
neighbors.push(r.to);
}
}
return neighbors;
}
tile(): TileRef {
return this.unit.tile();
}
isActive(): boolean {
return this.unit.isActive();
}
getRailroads(): Set<Railroad> {
return this.railroads;
}
setCluster(cluster: Cluster | null) {
this.cluster = cluster;
}
getCluster(): Cluster | null {
return this.cluster;
}
onTrainStop(trainExecution: TrainExecution) {
const type = this.unit.type();
const handler = this.stopHandlers[type];
if (handler) {
handler.onStop(this.mg, this, trainExecution);
}
}
}
/**
* Make the trainstation usable with A*
*/
export class TrainStationMapAdapter implements GraphAdapter<TrainStation> {
constructor(private game: Game) {}
neighbors(node: TrainStation): TrainStation[] {
return node.neighbors();
}
cost(node: TrainStation): number {
return 1;
}
position(node: TrainStation): { x: number; y: number } {
return { x: this.game.x(node.tile()), y: this.game.y(node.tile()) };
}
isTraversable(from: TrainStation, to: TrainStation): boolean {
return true;
}
}
/**
* Cluster of connected stations
*/
export class Cluster {
public stations: Set<TrainStation> = new Set();
has(station: TrainStation) {
return this.stations.has(station);
}
addStation(station: TrainStation) {
this.stations.add(station);
station.setCluster(this);
}
removeStation(station: TrainStation) {
this.stations.delete(station);
}
addStations(stations: Set<TrainStation>) {
for (const station of stations) {
this.addStation(station);
}
}
merge(other: Cluster) {
for (const s of other.stations) {
this.addStation(s);
}
}
availableForTrade(player: Player): Set<TrainStation> {
const tradingStations = new Set<TrainStation>();
for (const station of this.stations) {
if (station.tradeAvailable(player)) {
tradingStations.add(station);
}
}
return tradingStations;
}
size() {
return this.stations.size;
}
clear() {
this.stations.clear();
}
}
+34
View File
@@ -4,6 +4,7 @@ import {
MessageType,
Player,
Tick,
TrainType,
Unit,
UnitInfo,
UnitType,
@@ -28,9 +29,12 @@ export class UnitImpl implements Unit {
private _troops: number;
private _missileTimerQueue: number[] = [];
private _readyMissileCount: number = 1;
private _hasTrainStation: boolean = false;
private _patrolTile: TileRef | undefined;
private _level: number = 1;
private _targetable: boolean = true;
private _loaded: boolean | undefined;
private _trainType: TrainType | undefined;
constructor(
private _type: UnitType,
@@ -53,6 +57,9 @@ export class UnitImpl implements Unit {
"patrolTile" in params ? (params.patrolTile ?? undefined) : undefined;
this._targetUnit =
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined;
this._loaded =
"loaded" in params ? (params.loaded ?? undefined) : undefined;
this._trainType = "trainType" in params ? params.trainType : undefined;
switch (this._type) {
case UnitType.Warship:
@@ -123,6 +130,9 @@ export class UnitImpl implements Unit {
missileTimerQueue: this._missileTimerQueue,
readyMissileCount: this._readyMissileCount,
level: this.level(),
hasTrainStation: this._hasTrainStation,
trainType: this._trainType,
loaded: this._loaded,
};
}
@@ -351,6 +361,15 @@ export class UnitImpl implements Unit {
return this._level;
}
setTrainStation(trainStation: boolean): void {
this._hasTrainStation = trainStation;
this.mg.addUpdate(this.toUpdate());
}
hasTrainStation(): boolean {
return this._hasTrainStation;
}
increaseLevel(): void {
this._level++;
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
@@ -358,4 +377,19 @@ export class UnitImpl implements Unit {
}
this.mg.addUpdate(this.toUpdate());
}
trainType(): TrainType | undefined {
return this._trainType;
}
isLoaded(): boolean | undefined {
return this._loaded;
}
setLoaded(loaded: boolean): void {
if (this._loaded !== loaded) {
this._loaded = loaded;
this.mg.addUpdate(this.toUpdate());
}
}
}