mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 22:45:22 +00:00
feat: Implement local train routing with discovery and greedy path selection
Replace fixed pathfinding with dynamic routing system featuring: - Local greedy routing: Trains evaluate neighbors based on profit potential, traffic congestion, distance, and recent history - Exploration capability: 10% randomness prevents suboptimal but discovers new routes - Congestion avoidance: Trains naturally spread to less busy stations - Loop prevention: Memory of recent visits prevents getting stuck - Adaptive behavior: System responds to changing network conditions - Enhanced journey tracking: Share complete route information instead of just start position Includes BATMAN-style routing protocol (currently disabled) for future network-wide knowledge distribution.
This commit is contained in:
@@ -20,9 +20,19 @@ export class TrainExecution implements Execution {
|
||||
private currentTile: number = 0;
|
||||
private spacing = 2;
|
||||
private usedTiles: TileRef[] = []; // used for cars behind
|
||||
private stations: TrainStation[] = [];
|
||||
private currentRailroad: OrientedRailroad | null = null;
|
||||
private currentStation: TrainStation | null = null;
|
||||
private speed: number = 2;
|
||||
// Journey tracking for organic route discovery - simplified to immediate neighbors only
|
||||
private journeySource: TrainStation | null;
|
||||
private hasProcessedArrival: boolean = false;
|
||||
private journeyPreviousStation: TrainStation | null = null; // Immediate previous station
|
||||
private journeyHopCount: number = 0;
|
||||
|
||||
// Local greedy routing properties
|
||||
private recentStations: TrainStation[] = []; // Recently visited stations (for loop prevention)
|
||||
private maxHops: number = 50; // Maximum hops before giving up
|
||||
private recentMemorySize: number = 30; // How many recent stations to remember
|
||||
|
||||
constructor(
|
||||
private railNetwork: RailNetwork,
|
||||
@@ -30,33 +40,83 @@ export class TrainExecution implements Execution {
|
||||
private source: TrainStation,
|
||||
private destination: TrainStation,
|
||||
private numCars: number,
|
||||
) {}
|
||||
) {
|
||||
// Initialize journey tracking - journeySource is the first city/port visited
|
||||
const sourceType = source.unit.type();
|
||||
this.journeySource =
|
||||
sourceType === UnitType.City || sourceType === UnitType.Port
|
||||
? source
|
||||
: null;
|
||||
this.journeyPreviousStation = null; // Starting station has no previous
|
||||
}
|
||||
|
||||
public owner(): Player {
|
||||
return this.player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share journey information with a station for organic route discovery
|
||||
*/
|
||||
public shareJourneyInfo(): {
|
||||
journeySource: TrainStation | null;
|
||||
routeInformation: Array<{
|
||||
destination: TrainStation;
|
||||
nextHop: TrainStation | null;
|
||||
distance: number;
|
||||
}>;
|
||||
} {
|
||||
const routeInformation: Array<{
|
||||
destination: TrainStation;
|
||||
nextHop: TrainStation | null;
|
||||
distance: number;
|
||||
}> = [];
|
||||
|
||||
// Derive routing info from recentStations array
|
||||
// recentStations = [oldest, ..., previous, current]
|
||||
const immediatePrevious =
|
||||
this.recentStations.length > 1
|
||||
? this.recentStations[this.recentStations.length - 2]
|
||||
: null;
|
||||
|
||||
// Only share routes to stations we visited (not the current station we're at)
|
||||
for (let i = 0; i < this.recentStations.length - 1; i++) {
|
||||
const destination = this.recentStations[i];
|
||||
// For reverse routing: to reach any destination, go through the station we came from
|
||||
const nextHop = immediatePrevious;
|
||||
// Distance from current station to this destination
|
||||
const distance = this.recentStations.length - 1 - i;
|
||||
|
||||
routeInformation.push({
|
||||
destination,
|
||||
nextHop,
|
||||
distance,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
journeySource: this.journeySource,
|
||||
routeInformation,
|
||||
};
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
const stations = this.railNetwork.findStationsPath(
|
||||
this.source,
|
||||
this.destination,
|
||||
);
|
||||
if (!stations || stations.length <= 1) {
|
||||
|
||||
// Validate that source and destination are active
|
||||
if (!this.source.isActive() || !this.destination.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.stations = stations;
|
||||
const railroad = getOrientedRailroad(this.stations[0], this.stations[1]);
|
||||
if (railroad) {
|
||||
this.currentRailroad = railroad;
|
||||
} else {
|
||||
// If source and destination are the same, we're already there
|
||||
if (this.source === this.destination) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const spawn = this.player.canBuild(UnitType.Train, this.stations[0].tile());
|
||||
this.currentStation = this.source;
|
||||
|
||||
const spawn = this.player.canBuild(UnitType.Train, this.source.tile());
|
||||
if (spawn === false) {
|
||||
console.warn(`cannot build train`);
|
||||
this.active = false;
|
||||
@@ -98,6 +158,12 @@ export class TrainExecution implements Execution {
|
||||
if (this.train === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record train arrival statistics
|
||||
if (this.mg) {
|
||||
this.mg.recordTrainArrival(this.journeyHopCount);
|
||||
}
|
||||
|
||||
this.train.setReachedTarget();
|
||||
this.cars.forEach((car: Unit) => {
|
||||
car.setReachedTarget();
|
||||
@@ -140,11 +206,7 @@ export class TrainExecution implements Execution {
|
||||
}
|
||||
|
||||
private activeSourceOrDestination(): boolean {
|
||||
return (
|
||||
this.stations.length > 1 &&
|
||||
this.stations[1].isActive() &&
|
||||
this.stations[0].isActive()
|
||||
);
|
||||
return this.source.isActive() && this.destination.isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,49 +249,112 @@ export class TrainExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
private nextStation() {
|
||||
if (this.stations.length > 2) {
|
||||
this.stations.shift();
|
||||
const railRoad = getOrientedRailroad(this.stations[0], this.stations[1]);
|
||||
if (railRoad) {
|
||||
this.currentRailroad = railRoad;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private isAtStation(): boolean {
|
||||
if (!this.train || !this.currentStation || !this.mg) return false;
|
||||
|
||||
private canTradeWithDestination() {
|
||||
// Check if train is at the current station's tile
|
||||
const trainTile = this.train.tile();
|
||||
return (
|
||||
this.stations.length > 1 && this.stations[1].tradeAvailable(this.player)
|
||||
this.mg.x(trainTile) === this.mg.x(this.currentStation.tile()) &&
|
||||
this.mg.y(trainTile) === this.mg.y(this.currentStation.tile())
|
||||
);
|
||||
}
|
||||
|
||||
private getNextTile(): TileRef | null {
|
||||
if (this.currentRailroad === null || !this.canTradeWithDestination()) {
|
||||
return null;
|
||||
}
|
||||
this.saveTraversedTiles(this.currentTile, this.speed);
|
||||
this.currentTile = this.currentTile + this.speed;
|
||||
const leftOver = this.currentTile - this.currentRailroad.getTiles().length;
|
||||
if (leftOver >= 0) {
|
||||
// Station reached, pick the next station
|
||||
this.stationReached();
|
||||
if (!this.nextStation()) {
|
||||
return null; // Destination reached (or no valid connection)
|
||||
// If we're at a station, decide where to go next
|
||||
if (this.isAtStation()) {
|
||||
// Process arrival if we haven't already for this station visit
|
||||
if (!this.hasProcessedArrival) {
|
||||
this.stationReached(); // Handle arrival at current station
|
||||
this.hasProcessedArrival = true;
|
||||
}
|
||||
this.currentTile = leftOver;
|
||||
this.saveTraversedTiles(0, leftOver);
|
||||
|
||||
// Check if we've reached the destination
|
||||
if (this.currentStation === this.destination) {
|
||||
this.targetReached();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we've exceeded max hops
|
||||
if (this.journeyHopCount >= this.maxHops) {
|
||||
// Give up - we've wandered too long
|
||||
this.active = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use local greedy routing to choose next station
|
||||
const nextHop = this.currentStation!.chooseNextStation(
|
||||
this.destination,
|
||||
this.recentStations,
|
||||
this.player,
|
||||
);
|
||||
|
||||
if (!nextHop) {
|
||||
// No good options available - stay and wait
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get railroad to next hop
|
||||
const railroad = getOrientedRailroad(this.currentStation!, nextHop);
|
||||
if (!railroad) {
|
||||
return null; // No direct connection
|
||||
}
|
||||
|
||||
// Reset arrival flag since we're departing
|
||||
this.hasProcessedArrival = false;
|
||||
|
||||
// Notify current station that train is departing
|
||||
this.currentStation!.onTrainDepartureFromStation(this);
|
||||
|
||||
// Update recent stations memory for loop prevention
|
||||
this.recentStations.push(nextHop);
|
||||
if (this.recentStations.length > this.recentMemorySize) {
|
||||
this.recentStations.shift(); // Remove oldest
|
||||
}
|
||||
|
||||
// Update journey tracking - remember where we came from BEFORE changing currentStation
|
||||
// This should happen after arrival processing but before departure
|
||||
this.journeyHopCount++;
|
||||
this.journeyPreviousStation = this.currentStation;
|
||||
|
||||
this.currentStation = nextHop;
|
||||
this.currentRailroad = railroad;
|
||||
this.currentTile = 0;
|
||||
}
|
||||
return this.currentRailroad.getTiles()[this.currentTile];
|
||||
|
||||
// Follow current railroad
|
||||
if (
|
||||
this.currentRailroad &&
|
||||
this.currentTile < this.currentRailroad.getTiles().length
|
||||
) {
|
||||
this.saveTraversedTiles(this.currentTile, this.speed);
|
||||
this.currentTile += this.speed;
|
||||
|
||||
if (this.currentTile >= this.currentRailroad.getTiles().length) {
|
||||
// We've reached the next station
|
||||
this.currentTile = this.currentRailroad.getTiles().length - 1;
|
||||
}
|
||||
|
||||
return this.currentRailroad.getTiles()[this.currentTile];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private stationReached() {
|
||||
if (this.mg === null || this.player === null) {
|
||||
if (this.mg === null || this.player === null || !this.currentStation) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
this.stations[1].onTrainStop(this);
|
||||
return;
|
||||
|
||||
// Set journeySource to first city/port visited (if not already set)
|
||||
if (this.journeySource === null) {
|
||||
const stationType = this.currentStation.unit.type();
|
||||
if (stationType === UnitType.City || stationType === UnitType.Port) {
|
||||
this.journeySource = this.currentStation;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentStation.onTrainStop(this);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
|
||||
@@ -45,6 +45,10 @@ export class TrainStationExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle periodic routing broadcasts
|
||||
this.station.tick();
|
||||
|
||||
this.spawnTrain(this.station, ticks);
|
||||
}
|
||||
|
||||
|
||||
@@ -712,6 +712,7 @@ export interface Game extends GameMap {
|
||||
): Array<{ unit: Unit; distSquared: number }>;
|
||||
|
||||
addExecution(...exec: Execution[]): void;
|
||||
recordTrainArrival(steps: number): void;
|
||||
displayMessage(
|
||||
message: string,
|
||||
type: MessageType,
|
||||
|
||||
@@ -78,6 +78,12 @@ export class GameImpl implements Game {
|
||||
private updates: GameUpdates = createGameUpdatesMap();
|
||||
private unitGrid: UnitGrid;
|
||||
|
||||
// Train statistics tracking
|
||||
private trainArrivalTimes: number[] = []; // timestamps of recent train arrivals
|
||||
private completedTrainSteps: number[] = []; // steps of recently completed trains
|
||||
private activeTrainSteps = 0; // total steps taken by currently active trains (updated each tick)
|
||||
private lastStatsPrint = 0; // last time we printed stats
|
||||
|
||||
private playerTeams: Team[];
|
||||
private botTeam: Team = ColoredTeams.Bot;
|
||||
private _railNetwork: RailNetwork = createRailNetwork(this);
|
||||
@@ -347,12 +353,21 @@ export class GameImpl implements Game {
|
||||
|
||||
executeNextTick(): GameUpdates {
|
||||
this.updates = createGameUpdatesMap();
|
||||
|
||||
// Reset active train steps counter for this tick
|
||||
this.activeTrainSteps = 0;
|
||||
|
||||
this.execs.forEach((e) => {
|
||||
if (
|
||||
(!this.inSpawnPhase() || e.activeDuringSpawnPhase()) &&
|
||||
e.isActive()
|
||||
) {
|
||||
e.tick(this._ticks);
|
||||
|
||||
// Track steps for active trains
|
||||
if (e.constructor.name === "TrainExecution") {
|
||||
this.activeTrainSteps += (e as any).journeyHopCount ?? 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
const inited: Execution[] = [];
|
||||
@@ -381,6 +396,13 @@ export class GameImpl implements Game {
|
||||
hash: this.hash(),
|
||||
});
|
||||
}
|
||||
|
||||
// Print train statistics every 60 ticks (~60 seconds)
|
||||
if (this._ticks - this.lastStatsPrint >= 60) {
|
||||
this.printTrainStats();
|
||||
this.lastStatsPrint = this._ticks;
|
||||
}
|
||||
|
||||
this._ticks++;
|
||||
return this.updates;
|
||||
}
|
||||
@@ -440,6 +462,58 @@ export class GameImpl implements Game {
|
||||
);
|
||||
}
|
||||
|
||||
// Train statistics tracking methods
|
||||
recordTrainArrival(steps: number) {
|
||||
this.trainArrivalTimes.push(this._ticks);
|
||||
this.completedTrainSteps.push(steps);
|
||||
|
||||
// Clean up old data (keep only last 60 seconds)
|
||||
const cutoffTime = this._ticks - 60;
|
||||
this.trainArrivalTimes = this.trainArrivalTimes.filter(
|
||||
(time) => time > cutoffTime,
|
||||
);
|
||||
// Keep same number of completed train steps as arrival times
|
||||
if (this.completedTrainSteps.length > this.trainArrivalTimes.length) {
|
||||
this.completedTrainSteps = this.completedTrainSteps.slice(
|
||||
-this.trainArrivalTimes.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveTrainCount(): number {
|
||||
return this.executions().filter(
|
||||
(exec) => exec.constructor.name === "TrainExecution" && exec.isActive(),
|
||||
).length;
|
||||
}
|
||||
|
||||
getAverageCompletedTrainSteps(): number {
|
||||
if (this.completedTrainSteps.length === 0) return 0;
|
||||
|
||||
const sum = this.completedTrainSteps.reduce((a, b) => a + b, 0);
|
||||
return sum / this.completedTrainSteps.length;
|
||||
}
|
||||
|
||||
getAverageActiveTrainSteps(): number {
|
||||
const activeTrains = this.getActiveTrainCount();
|
||||
if (activeTrains === 0) return 0;
|
||||
|
||||
// Return average steps for currently active trains
|
||||
return this.activeTrainSteps / activeTrains;
|
||||
}
|
||||
|
||||
printTrainStats() {
|
||||
const arrivalsLast60s = this.trainArrivalTimes.length;
|
||||
const activeTrains = this.getActiveTrainCount();
|
||||
const avgCompletedSteps =
|
||||
Math.round(this.getAverageCompletedTrainSteps() * 100) / 100;
|
||||
const avgActiveSteps =
|
||||
Math.round(this.getAverageActiveTrainSteps() * 100) / 100;
|
||||
|
||||
console.log(
|
||||
`🚂 Trains: ${arrivalsLast60s} arrived (${avgCompletedSteps} avg steps), ${activeTrains} active (${avgActiveSteps} avg steps)`,
|
||||
);
|
||||
}
|
||||
|
||||
playerView(id: PlayerID): Player {
|
||||
return this.player(id);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
|
||||
const neighbors = station.neighbors();
|
||||
this.disconnectFromNetwork(station);
|
||||
station.onStationRemoved();
|
||||
this.stationManager.removeStation(station);
|
||||
|
||||
const cluster = station.getCluster();
|
||||
|
||||
@@ -6,6 +6,67 @@ import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
|
||||
import { Railroad } from "./Railroad";
|
||||
|
||||
/**
|
||||
* Simple station lookup by tile ID for routing
|
||||
*/
|
||||
class StationLookup {
|
||||
private static stations = new Map<TileRef, TrainStation>();
|
||||
|
||||
static register(station: TrainStation): void {
|
||||
this.stations.set(station.tile(), station);
|
||||
}
|
||||
|
||||
static getStation(tile: TileRef): TrainStation | null {
|
||||
return this.stations.get(tile) ?? null;
|
||||
}
|
||||
|
||||
static unregister(station: TrainStation): void {
|
||||
this.stations.delete(station.tile());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight routing entry using station IDs for memory efficiency
|
||||
*/
|
||||
export interface RoutingEntry {
|
||||
destinationId: number;
|
||||
nextHopId: number;
|
||||
hopCount: number;
|
||||
sequenceNumber: number;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy interface for backward compatibility (deprecated)
|
||||
*/
|
||||
export interface RoutingEntryFull {
|
||||
destination: TrainStation;
|
||||
nextHop: TrainStation;
|
||||
hopCount: number;
|
||||
sequenceNumber: number;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge metrics for local greedy routing
|
||||
*/
|
||||
export interface EdgeMetrics {
|
||||
toStation: TrainStation;
|
||||
baseDuration: number; // Base travel time/cost to this station
|
||||
distance: number; // Physical distance (affects duration)
|
||||
lastUpdated: number; // When metrics were last updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Station traffic and congestion data
|
||||
*/
|
||||
export interface StationTraffic {
|
||||
trainCount: number; // Current number of trains at station
|
||||
recentArrivals: number; // Trains arrived in last N ticks
|
||||
heat: number; // Congestion heat (0-1, decays over time)
|
||||
lastHeatUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle train stops at various station types
|
||||
*/
|
||||
@@ -74,16 +135,73 @@ export function createTrainStopHandlers(
|
||||
export class TrainStation {
|
||||
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
|
||||
{};
|
||||
private random: PseudoRandom;
|
||||
private cluster: Cluster | null;
|
||||
private railroads: Set<Railroad> = new Set();
|
||||
// Quick lookup from neighboring station to connecting railroad
|
||||
private railroadByNeighbor: Map<TrainStation, Railroad> = new Map();
|
||||
|
||||
// Batman routing properties - now using IDs for memory efficiency
|
||||
private routingTable: Map<number, RoutingEntry> = new Map();
|
||||
private sequenceNumber: number = 0;
|
||||
private originatorInterval: number = 1000; // ticks between broadcasts (increased 10x)
|
||||
private lastOriginatorBroadcast: number = 0;
|
||||
private routesChanged: boolean = false;
|
||||
private changedRoutes: Set<TrainStation> = new Set();
|
||||
private maxHops: number = 20;
|
||||
private routeStaleThreshold: number = 500; // ticks
|
||||
|
||||
// Lazy cleanup optimization
|
||||
private cleanupIndex: number = 0;
|
||||
private readonly routesToCheckPerTick = 3; // Check only 3 routes per tick
|
||||
|
||||
// Local greedy routing properties
|
||||
private edgeMetrics: Map<TrainStation, EdgeMetrics> = new Map();
|
||||
private traffic: StationTraffic;
|
||||
private profitSensitivity: number = 0.3; // How much profit-per-distance boosts scores
|
||||
private distanceSensitivity: number = 0.2; // How much distance increases duration penalties
|
||||
private stationHeatSensitivity: number = 0.4; // How much station heat reduces scores
|
||||
private recencyDecayFactor: number = 0.1; // Exponential decay rate for recency penalties
|
||||
private maxRecencyPenalty: number = 1; // Maximum penalty for immediate revisits
|
||||
// Disabling broadcasts turns routing into local-only mode!
|
||||
// Implications:
|
||||
// - Stations only know routes their own trains discovered
|
||||
// - No network-wide knowledge sharing (BATMAN protocol disabled)
|
||||
// - Trains get stuck in loops more easily
|
||||
// - Route discovery becomes slower and less efficient
|
||||
// - System becomes more like individual A* pathfinding
|
||||
// - Lower memory usage but higher train congestion
|
||||
private enableBroadcasts: boolean = false; // Enable/disable BATMAN broadcast protocol
|
||||
private randomChoiceProbability: number = 0.1; // Probability of making random choice instead of best (0.1 = 10%)
|
||||
|
||||
constructor(
|
||||
private mg: Game,
|
||||
public unit: Unit,
|
||||
) {
|
||||
this.stopHandlers = createTrainStopHandlers(new PseudoRandom(mg.ticks()));
|
||||
this.random = new PseudoRandom(mg.ticks() + this.tile());
|
||||
|
||||
// Register station for lookup
|
||||
StationLookup.register(this);
|
||||
|
||||
// Initialize traffic tracking
|
||||
this.traffic = {
|
||||
trainCount: 0,
|
||||
recentArrivals: 0,
|
||||
heat: 0,
|
||||
lastHeatUpdate: mg.ticks(),
|
||||
};
|
||||
|
||||
// Initialize self-route using tile as ID
|
||||
const stationTile = this.tile();
|
||||
this.routingTable.set(stationTile, {
|
||||
destinationId: stationTile,
|
||||
nextHopId: stationTile,
|
||||
hopCount: 0,
|
||||
sequenceNumber: this.sequenceNumber,
|
||||
lastUpdate: mg.ticks(),
|
||||
});
|
||||
this.changedRoutes.add(this);
|
||||
}
|
||||
|
||||
tradeAvailable(otherPlayer: Player): boolean {
|
||||
@@ -98,14 +216,28 @@ export class TrainStation {
|
||||
|
||||
addRailroad(railRoad: Railroad) {
|
||||
this.railroads.add(railRoad);
|
||||
this.routesChanged = true; // Network topology changed
|
||||
|
||||
// Determine neighboring station and maintain quick lookup
|
||||
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
|
||||
this.railroadByNeighbor.set(neighbor, railRoad);
|
||||
if (neighbor) {
|
||||
this.railroadByNeighbor.set(neighbor, railRoad);
|
||||
|
||||
// Initialize edge metrics for new connection if not present
|
||||
if (!this.edgeMetrics.has(neighbor)) {
|
||||
this.initializeEdgeMetrics(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeRailroad(railRoad: Railroad) {
|
||||
this.railroads.delete(railRoad);
|
||||
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
|
||||
this.railroadByNeighbor.delete(neighbor);
|
||||
if (neighbor) {
|
||||
this.railroadByNeighbor.delete(neighbor);
|
||||
this.edgeMetrics.delete(neighbor);
|
||||
}
|
||||
this.routesChanged = true; // Network topology changed
|
||||
}
|
||||
|
||||
removeNeighboringRails(station: TrainStation) {
|
||||
@@ -162,15 +294,532 @@ export class TrainStation {
|
||||
return this.cluster;
|
||||
}
|
||||
|
||||
// ===== BATMAN ROUTING METHODS =====
|
||||
|
||||
/**
|
||||
* Get the next hop toward a destination using routing table
|
||||
*/
|
||||
getNextHop(destination: TrainStation): TrainStation | null {
|
||||
const destTile = destination.tile();
|
||||
const route = this.routingTable.get(destTile);
|
||||
|
||||
if (route && route.hopCount <= this.maxHops) {
|
||||
const timeSinceUpdate = this.mg.ticks() - route.lastUpdate;
|
||||
if (timeSinceUpdate <= this.routeStaleThreshold) {
|
||||
return StationLookup.getStation(route.nextHopId);
|
||||
}
|
||||
}
|
||||
|
||||
// No valid route - routes will be learned organically as trains explore
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast originator message with changed routes only
|
||||
*/
|
||||
broadcastOriginatorMessage(): void {
|
||||
this.sequenceNumber++;
|
||||
this.cleanupStaleRoutes();
|
||||
|
||||
// Create a map of only changed routes using tile IDs
|
||||
const changedRoutesMap = new Map<number, RoutingEntry>();
|
||||
for (const dest of this.changedRoutes) {
|
||||
const destTile = dest.tile();
|
||||
const route = this.routingTable.get(destTile);
|
||||
if (route) {
|
||||
changedRoutesMap.set(destTile, route);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear changed routes after broadcasting
|
||||
this.changedRoutes.clear();
|
||||
|
||||
// Send only changed routes to all neighbors
|
||||
for (const neighbor of this.neighbors()) {
|
||||
neighbor.receiveOriginatorMessage(
|
||||
this,
|
||||
changedRoutesMap,
|
||||
this.sequenceNumber,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive and process originator message from another station
|
||||
*/
|
||||
receiveOriginatorMessage(
|
||||
originator: TrainStation,
|
||||
originatorTable: Map<number, RoutingEntry>,
|
||||
originatorSeq: number,
|
||||
): void {
|
||||
const currentTime = this.mg.ticks();
|
||||
let routesWereUpdated = false;
|
||||
|
||||
// Get originator tile
|
||||
const originatorTile = originator.tile();
|
||||
|
||||
// Only process if this is a newer sequence number than what we have for originator
|
||||
const existingSeq =
|
||||
this.routingTable.get(originatorTile)?.sequenceNumber ?? 0;
|
||||
if (originatorSeq <= existingSeq) {
|
||||
return; // Stale message
|
||||
}
|
||||
|
||||
// Update route to originator itself
|
||||
this.routingTable.set(originatorTile, {
|
||||
destinationId: originatorTile,
|
||||
nextHopId: originatorTile, // Direct neighbor
|
||||
hopCount: 1,
|
||||
sequenceNumber: originatorSeq,
|
||||
lastUpdate: currentTime,
|
||||
});
|
||||
this.changedRoutes.add(originator);
|
||||
routesWereUpdated = true;
|
||||
|
||||
// Process each route from originator
|
||||
for (const [destId, route] of originatorTable) {
|
||||
const newHopCount = route.hopCount + 1;
|
||||
|
||||
// Skip if hop count would be too high
|
||||
if (newHopCount > this.maxHops) continue;
|
||||
|
||||
const existingRoute = this.routingTable.get(destId);
|
||||
|
||||
// Update if: no existing route, better hop count, or same hop count but newer sequence
|
||||
const shouldUpdate =
|
||||
!existingRoute ||
|
||||
newHopCount < existingRoute.hopCount ||
|
||||
(newHopCount === existingRoute.hopCount &&
|
||||
originatorSeq > existingRoute.sequenceNumber);
|
||||
|
||||
if (shouldUpdate) {
|
||||
this.routingTable.set(destId, {
|
||||
destinationId: destId,
|
||||
nextHopId: originatorTile, // Next hop is the station we received this from
|
||||
hopCount: newHopCount,
|
||||
sequenceNumber: originatorSeq,
|
||||
lastUpdate: currentTime,
|
||||
});
|
||||
|
||||
// Mark destination station as changed
|
||||
const destStation = StationLookup.getStation(destId);
|
||||
if (destStation) {
|
||||
this.changedRoutes.add(destStation);
|
||||
}
|
||||
routesWereUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If routes were updated, we should eventually broadcast our changes
|
||||
if (routesWereUpdated) {
|
||||
this.routesChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale routes - lazy implementation for scalability
|
||||
* Only checks a few routes per tick instead of all routes
|
||||
*/
|
||||
private cleanupStaleRoutes(): void {
|
||||
const currentTime = this.mg.ticks();
|
||||
|
||||
// Convert map to array for indexed access
|
||||
const routeEntries = Array.from(this.routingTable.entries());
|
||||
|
||||
if (routeEntries.length === 0) {
|
||||
this.cleanupIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check only a few routes per tick (round-robin)
|
||||
const routesChecked = Math.min(
|
||||
this.routesToCheckPerTick,
|
||||
routeEntries.length,
|
||||
);
|
||||
|
||||
for (let i = 0; i < routesChecked; i++) {
|
||||
const index = (this.cleanupIndex + i) % routeEntries.length;
|
||||
const [destId, route] = routeEntries[index];
|
||||
|
||||
if (currentTime - route.lastUpdate > this.routeStaleThreshold) {
|
||||
this.routingTable.delete(destId);
|
||||
// Mark destination station as changed for potential rebroadcast
|
||||
const destStation = StationLookup.getStation(destId);
|
||||
if (destStation) {
|
||||
this.changedRoutes.add(destStation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update index for next cleanup cycle
|
||||
this.cleanupIndex =
|
||||
(this.cleanupIndex + routesChecked) % routeEntries.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic tick for routing maintenance - event-driven broadcasting
|
||||
*/
|
||||
tick(): void {
|
||||
// Update traffic metrics
|
||||
this.updateTraffic();
|
||||
|
||||
const timeSinceLastBroadcast =
|
||||
this.mg.ticks() - this.lastOriginatorBroadcast;
|
||||
|
||||
// Broadcast if routes changed OR if it's been too long since last broadcast
|
||||
|
||||
if (
|
||||
this.enableBroadcasts &&
|
||||
(this.routesChanged || timeSinceLastBroadcast >= this.originatorInterval)
|
||||
) {
|
||||
this.broadcastOriginatorMessage();
|
||||
this.routesChanged = false; // Reset the flag after broadcasting
|
||||
this.lastOriginatorBroadcast = this.mg.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== LOCAL GREEDY ROUTING METHODS =====
|
||||
|
||||
/**
|
||||
* Initialize edge metrics for a neighboring station
|
||||
*/
|
||||
private initializeEdgeMetrics(neighborStation: TrainStation): void {
|
||||
const distance = this.calculateDistance(neighborStation);
|
||||
const baseDuration = Math.max(1, Math.floor(distance / 2)); // Rough duration estimate
|
||||
|
||||
this.edgeMetrics.set(neighborStation, {
|
||||
toStation: neighborStation,
|
||||
baseDuration,
|
||||
distance,
|
||||
lastUpdated: this.mg.ticks(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate physical distance to another station
|
||||
*/
|
||||
private calculateDistance(other: TrainStation): number {
|
||||
const dx = Math.abs(this.mg.x(this.tile()) - this.mg.x(other.tile()));
|
||||
const dy = Math.abs(this.mg.y(this.tile()) - this.mg.y(other.tile()));
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate actual profit for a train owner traveling to another station
|
||||
* Uses the game's actual trainGold configuration based on relationship
|
||||
*/
|
||||
private calculateActualProfit(
|
||||
trainOwner: Player,
|
||||
other: TrainStation,
|
||||
): number {
|
||||
const stationOwner = other.unit.owner();
|
||||
const relationship = rel(trainOwner, stationOwner);
|
||||
|
||||
// Use actual game values from config
|
||||
const goldValue = this.mg.config().trainGold(relationship);
|
||||
|
||||
// Convert BigInt to number for scoring calculations
|
||||
return Number(goldValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update traffic when a train arrives
|
||||
*/
|
||||
onTrainArrival(trainExecution: TrainExecution): void {
|
||||
this.traffic.trainCount++;
|
||||
this.traffic.recentArrivals++;
|
||||
|
||||
// Increase station heat
|
||||
this.traffic.heat = Math.min(1.0, this.traffic.heat + 0.1);
|
||||
this.traffic.lastHeatUpdate = this.mg.ticks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update traffic when a train departs
|
||||
*/
|
||||
onTrainDeparture(trainExecution: TrainExecution): void {
|
||||
this.traffic.trainCount = Math.max(0, this.traffic.trainCount - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate edge score for local greedy routing with graduated recency penalties
|
||||
*/
|
||||
private calculateEdgeScore(
|
||||
edge: EdgeMetrics,
|
||||
stationsAgo: number, // -1 = never visited, 1 = immediate previous, 2 = 2 ago, etc.
|
||||
actualProfit: number,
|
||||
neighborTrafficHeat: number, // Heat factor of the neighbor station
|
||||
): number {
|
||||
// Base score: profit per time unit, boosted by profit-per-distance
|
||||
const profitPerDistance = actualProfit / edge.distance;
|
||||
let score =
|
||||
(actualProfit /
|
||||
(edge.baseDuration * (1 + this.distanceSensitivity * edge.distance))) *
|
||||
(1 + this.profitSensitivity * profitPerDistance);
|
||||
|
||||
// Apply graduated recency penalty based on stations ago
|
||||
if (stationsAgo > 0) {
|
||||
const penaltyStrength =
|
||||
Math.pow(this.recencyDecayFactor, stationsAgo - 1) *
|
||||
this.maxRecencyPenalty;
|
||||
const recencyPenalty = 1.0 - penaltyStrength;
|
||||
score *= recencyPenalty;
|
||||
}
|
||||
|
||||
// Apply station heat avoidance
|
||||
score *= 1 - this.stationHeatSensitivity * neighborTrafficHeat;
|
||||
|
||||
// Ensure unvisited stations get a minimum exploration score
|
||||
// This prevents zero-profit unvisited stations(facttories) from being ignored
|
||||
if (stationsAgo < 0 && score <= 0) {
|
||||
score = 0.2; // Small positive score to encourage exploration
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate how many stations ago a station was visited
|
||||
*/
|
||||
private getStationsAgo(
|
||||
station: TrainStation,
|
||||
recentStations: TrainStation[],
|
||||
): number {
|
||||
const index = recentStations.lastIndexOf(station);
|
||||
if (index === -1) return -1; // Never visited in recent memory
|
||||
|
||||
// Distance from end: 0 = current, 1 = immediate previous, 2 = 2 ago, etc.
|
||||
return recentStations.length - 1 - index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose next station using hybrid routing: prioritize known routes, fall back to greedy routing
|
||||
*/
|
||||
chooseNextStation(
|
||||
destination: TrainStation,
|
||||
recentStations: TrainStation[],
|
||||
trainOwner: Player,
|
||||
): TrainStation | null {
|
||||
// First priority: Check if we have a known route to the destination
|
||||
const knownNextHop = this.getNextHop(destination);
|
||||
if (knownNextHop && this.neighbors().includes(knownNextHop)) {
|
||||
// We have a known route and the next hop is a valid neighbor
|
||||
// With some probability, still explore instead of following known route
|
||||
if (this.random.next() >= this.randomChoiceProbability) {
|
||||
return knownNextHop;
|
||||
}
|
||||
// Otherwise, fall through to exploration mode
|
||||
}
|
||||
|
||||
// Second priority: Local greedy routing for exploration/unknown routes
|
||||
// Trains pick highest-scoring neighbors without considering direction toward destination.
|
||||
const validNeighbors: Array<{ station: TrainStation; score: number }> = [];
|
||||
|
||||
// Evaluate all neighboring stations
|
||||
for (const neighbor of this.neighbors()) {
|
||||
const edge = this.edgeMetrics.get(neighbor);
|
||||
if (!edge) continue;
|
||||
|
||||
// Calculate actual profit based on train owner's relationship with station
|
||||
const actualProfit = this.calculateActualProfit(trainOwner, neighbor);
|
||||
|
||||
// Calculate how many stations ago this neighbor was visited
|
||||
const stationsAgo = this.getStationsAgo(neighbor, recentStations);
|
||||
const neighborTrafficHeat = neighbor.getTraffic().heat;
|
||||
const score = this.calculateEdgeScore(
|
||||
edge,
|
||||
stationsAgo,
|
||||
actualProfit,
|
||||
neighborTrafficHeat,
|
||||
);
|
||||
|
||||
validNeighbors.push({ station: neighbor, score });
|
||||
}
|
||||
|
||||
if (validNeighbors.length === 0) {
|
||||
return null; // No valid neighbors
|
||||
}
|
||||
|
||||
// With some probability, make a random choice instead of the best
|
||||
if (this.random.next() < this.randomChoiceProbability) {
|
||||
// Random choice: pick any valid neighbor uniformly
|
||||
const randomIndex = this.random.nextInt(0, validNeighbors.length);
|
||||
return validNeighbors[randomIndex].station;
|
||||
} else {
|
||||
// Best choice: pick the highest scoring neighbor
|
||||
let bestStation: TrainStation | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const { station, score } of validNeighbors) {
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestStation = station;
|
||||
}
|
||||
}
|
||||
|
||||
return bestStation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all references to this station when it's being removed
|
||||
*/
|
||||
onStationRemoved(): void {
|
||||
const stationTile = this.tile();
|
||||
|
||||
// Remove from StationLookup
|
||||
StationLookup.unregister(this);
|
||||
|
||||
// Remove all routing table entries that reference this station
|
||||
for (const [destTile, route] of this.routingTable) {
|
||||
if (route.nextHopId === stationTile) {
|
||||
// This route goes through the station being removed
|
||||
this.routingTable.delete(destTile);
|
||||
this.changedRoutes.add(this); // Mark for rebroadcast if broadcasts enabled
|
||||
}
|
||||
}
|
||||
|
||||
// Remove edge metrics for this station
|
||||
this.edgeMetrics.clear(); // Remove all edges from this station
|
||||
|
||||
// Remove from changed routes
|
||||
this.changedRoutes.delete(this);
|
||||
|
||||
// Clear routing table
|
||||
this.routingTable.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up references to another station that has been removed
|
||||
*/
|
||||
onOtherStationRemoved(removedStation: TrainStation): void {
|
||||
const removedTile = removedStation.tile();
|
||||
|
||||
// Remove routing table entries that reference the removed station
|
||||
for (const [destTile, route] of this.routingTable) {
|
||||
if (route.nextHopId === removedTile) {
|
||||
// This route goes through the removed station
|
||||
this.routingTable.delete(destTile);
|
||||
this.changedRoutes.add(this); // Mark for rebroadcast if broadcasts enabled
|
||||
}
|
||||
}
|
||||
|
||||
// Remove edge metrics to/from the removed station
|
||||
this.edgeMetrics.delete(removedStation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current traffic information
|
||||
*/
|
||||
getTraffic(): StationTraffic {
|
||||
return { ...this.traffic };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update traffic metrics periodically
|
||||
*/
|
||||
private updateTraffic(): void {
|
||||
const currentTime = this.mg.ticks();
|
||||
const timeSinceUpdate = currentTime - this.traffic.lastHeatUpdate;
|
||||
|
||||
// Decay heat over time
|
||||
if (timeSinceUpdate > 50) {
|
||||
// Every 50 ticks
|
||||
this.traffic.heat *= 0.95; // Decay heat by 5%
|
||||
this.traffic.lastHeatUpdate = currentTime;
|
||||
|
||||
// Reset recent arrivals periodically
|
||||
if (timeSinceUpdate > 200) {
|
||||
this.traffic.recentArrivals = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== END LOCAL GREEDY ROUTING METHODS =====
|
||||
// ===== END BATMAN ROUTING METHODS =====
|
||||
|
||||
onTrainStop(trainExecution: TrainExecution) {
|
||||
// Update traffic - train has arrived
|
||||
this.onTrainArrival(trainExecution);
|
||||
|
||||
// Process journey information for organic route discovery
|
||||
this.processJourneyInformation(trainExecution);
|
||||
|
||||
// Handle normal station behavior (gold rewards, etc.)
|
||||
const type = this.unit.type();
|
||||
const handler = this.stopHandlers[type];
|
||||
if (handler) {
|
||||
handler.onStop(this.mg, this, trainExecution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a train departs from this station
|
||||
*/
|
||||
onTrainDepartureFromStation(trainExecution: TrainExecution): void {
|
||||
this.onTrainDeparture(trainExecution);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process journey information from a train to update routing tables organically
|
||||
*/
|
||||
private processJourneyInformation(trainExecution: TrainExecution): void {
|
||||
const journeyInfo = trainExecution.shareJourneyInfo();
|
||||
|
||||
// Only process journey information if the train has established a journey source (visited a city/port)
|
||||
if (!journeyInfo.journeySource) {
|
||||
// Train hasn't visited a city/port yet, skip journey processing
|
||||
return;
|
||||
}
|
||||
|
||||
// Process routing information for each destination the train knows how to reach
|
||||
for (const routeInfo of journeyInfo.routeInformation) {
|
||||
const { destination, nextHop, distance } = routeInfo;
|
||||
|
||||
// Store reverse route: if a train reached destination D via nextHop N,
|
||||
// then to get to D from here, go through N first
|
||||
if (nextHop && nextHop !== this) {
|
||||
this.updateReverseRouteFromJourney(destination, nextHop, distance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routing table with reverse route: when a train reached a destination,
|
||||
* store the destination, next hop to reach it, and distance
|
||||
**/
|
||||
private updateReverseRouteFromJourney(
|
||||
destination: TrainStation,
|
||||
nextHop: TrainStation,
|
||||
distance: number,
|
||||
): void {
|
||||
if (destination === this) return; // Don't store route to self
|
||||
|
||||
const currentTime = this.mg.ticks();
|
||||
const destinationTile = destination.tile();
|
||||
const existingRoute = this.routingTable.get(destinationTile);
|
||||
|
||||
// Only update if this is a better route or we don't have one
|
||||
const shouldUpdate =
|
||||
!existingRoute ||
|
||||
distance < existingRoute.hopCount ||
|
||||
(distance === existingRoute.hopCount &&
|
||||
currentTime - existingRoute.lastUpdate > this.routeStaleThreshold / 2);
|
||||
|
||||
if (shouldUpdate) {
|
||||
this.routingTable.set(destinationTile, {
|
||||
destinationId: destinationTile,
|
||||
nextHopId: nextHop.tile(),
|
||||
hopCount: distance,
|
||||
sequenceNumber: this.sequenceNumber,
|
||||
lastUpdate: currentTime,
|
||||
});
|
||||
|
||||
this.changedRoutes.add(destination);
|
||||
this.routesChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Make the trainstation usable with A*
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user