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:
scamiv
2025-11-18 12:31:40 +01:00
parent 930a79e31c
commit 56b7f7aa7b
6 changed files with 905 additions and 51 deletions
+173 -48
View File
@@ -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);
}
+1
View File
@@ -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,
+74
View File
@@ -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);
}
+1
View File
@@ -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();
+652 -3
View File
@@ -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*
*/