From 51c603aff012278ac7cde3a9c6d2aaaa41ee8705 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:31:40 +0100 Subject: [PATCH 1/3] 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. --- src/core/execution/TrainExecution.ts | 221 +++++-- src/core/execution/TrainStationExecution.ts | 4 + src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 74 +++ src/core/game/RailNetworkImpl.ts | 1 + src/core/game/TrainStation.ts | 645 +++++++++++++++++++- 6 files changed, 897 insertions(+), 49 deletions(-) diff --git a/src/core/execution/TrainExecution.ts b/src/core/execution/TrainExecution.ts index 5e165067ab..e2adfd4347 100644 --- a/src/core/execution/TrainExecution.ts +++ b/src/core/execution/TrainExecution.ts @@ -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; + } + + // Check if we've reached the destination + if (this.currentStation === this.destination) { + this.targetReached(); + return null; } - this.currentTile = leftOver; - this.saveTraversedTiles(0, leftOver); + + // 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 { diff --git a/src/core/execution/TrainStationExecution.ts b/src/core/execution/TrainStationExecution.ts index af8a5bd3f8..c3065378d4 100644 --- a/src/core/execution/TrainStationExecution.ts +++ b/src/core/execution/TrainStationExecution.ts @@ -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); } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 8ce4822b35..333ec19195 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -711,6 +711,7 @@ export interface Game extends GameMap { ): Array<{ unit: Unit; distSquared: number }>; addExecution(...exec: Execution[]): void; + recordTrainArrival(steps: number): void; displayMessage( message: string, type: MessageType, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index cccb259025..38491c4837 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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); } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index ca1a3319ed..7db749c870 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -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(); diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index 0181137c74..ab956017d9 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -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(); + + 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,14 +135,71 @@ export function createTrainStopHandlers( export class TrainStation { private readonly stopHandlers: Partial> = {}; + private random: PseudoRandom; private cluster: Cluster | null; private railroads: Set = new Set(); + // Batman routing properties - now using IDs for memory efficiency + private routingTable: Map = 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 = 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 = 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 { @@ -95,6 +213,13 @@ export class TrainStation { addRailroad(railRoad: Railroad) { this.railroads.add(railRoad); + this.routesChanged = true; // Network topology changed + + // Initialize edge metrics for new connection + const neighbor = railRoad.from === this ? railRoad.to : railRoad.from; + if (neighbor && !this.edgeMetrics.has(neighbor)) { + this.initializeEdgeMetrics(neighbor); + } } removeNeighboringRails(station: TrainStation) { @@ -112,6 +237,7 @@ export class TrainStation { railTiles, }); this.railroads.delete(toRemove); + this.routesChanged = true; // Network topology changed } } @@ -147,15 +273,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(); + 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, + 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* */ From fda1d39e2651f42add6c06010aa30a7cb6257a60 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:02:14 +0100 Subject: [PATCH 2/3] removed journey source and replaced it with recent stations during journey transmission, journeySource wasnt loop proof --- src/core/execution/TrainExecution.ts | 35 +++++++++++++--------------- src/core/game/TrainStation.ts | 13 ++++++++--- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/core/execution/TrainExecution.ts b/src/core/execution/TrainExecution.ts index e2adfd4347..4ba4d315bb 100644 --- a/src/core/execution/TrainExecution.ts +++ b/src/core/execution/TrainExecution.ts @@ -24,7 +24,6 @@ export class TrainExecution implements Execution { 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; @@ -41,12 +40,6 @@ export class TrainExecution implements Execution { 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 } @@ -58,7 +51,6 @@ export class TrainExecution implements Execution { * Share journey information with a station for organic route discovery */ public shareJourneyInfo(): { - journeySource: TrainStation | null; routeInformation: Array<{ destination: TrainStation; nextHop: TrainStation | null; @@ -78,8 +70,22 @@ export class TrainExecution implements Execution { ? 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++) { + // Find the start index for sharing journey information + // Only share information about stations visited since the last time we passed through the current station + let startIndex = 0; + const currentStation = this.recentStations[this.recentStations.length - 1]; + + // Look for the last occurrence of current station before the current visit + for (let i = this.recentStations.length - 2; i >= 0; i--) { + if (this.recentStations[i] === currentStation) { + // Found the last previous visit to this station, start sharing from after that visit + startIndex = i + 1; + break; + } + } + + // Only share routes to stations we visited since our last visit to this station (not including current) + for (let i = startIndex; 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; @@ -94,7 +100,6 @@ export class TrainExecution implements Execution { } return { - journeySource: this.journeySource, routeInformation, }; } @@ -346,14 +351,6 @@ export class TrainExecution implements Execution { throw new Error("Not initialized"); } - // 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); } diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index ab956017d9..86849df477 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -745,9 +745,16 @@ export class TrainStation { 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 + // Only process journey information if the train has visited cities/ports in its recent journey + const hasVisitedMeaningfulStations = journeyInfo.routeInformation.some( + (routeInfo) => { + const stationType = routeInfo.destination.unit.type(); + return stationType === UnitType.City || stationType === UnitType.Port; + }, + ); + + if (!hasVisitedMeaningfulStations) { + // Train hasn't visited any cities/ports in its recent journey segment, skip journey processing return; } From d6c1dc42c92dcab83e655fe0d042d5cb79e43735 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:08:26 +0100 Subject: [PATCH 3/3] Enhance train routing logic and memory management - Added search radius - Updated several properties in TrainStation class to be readonly for better immutability and clarity. - Introduced heat decay interval and factor for more flexible heat management. - pre-computed decay factors avoiding Math.pow in critical paths. - Enhance logging - Refined routing logic - removed journeyPreviousStation property - removed RecentArrivals - unbounded StationTraffic.heat -> score can now be negative --- src/core/execution/TrainExecution.ts | 11 +- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 38 +++-- src/core/game/TrainStation.ts | 203 +++++++++++++++++---------- 4 files changed, 155 insertions(+), 98 deletions(-) diff --git a/src/core/execution/TrainExecution.ts b/src/core/execution/TrainExecution.ts index 4ba4d315bb..bd89a5d135 100644 --- a/src/core/execution/TrainExecution.ts +++ b/src/core/execution/TrainExecution.ts @@ -25,13 +25,12 @@ export class TrainExecution implements Execution { private speed: number = 2; // Journey tracking for organic route discovery - simplified to immediate neighbors only 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 + private recentMemorySize: number = 50; // How many recent stations to remember constructor( private railNetwork: RailNetwork, @@ -39,9 +38,7 @@ export class TrainExecution implements Execution { private source: TrainStation, private destination: TrainStation, private numCars: number, - ) { - this.journeyPreviousStation = null; // Starting station has no previous - } + ) {} public owner(): Player { return this.player; @@ -283,6 +280,9 @@ export class TrainExecution implements Execution { // Check if we've exceeded max hops if (this.journeyHopCount >= this.maxHops) { // Give up - we've wandered too long + if (this.mg) { + this.mg.recordTrainRemovedDueToHopLimit(this.journeyHopCount); + } this.active = false; return null; } @@ -320,7 +320,6 @@ export class TrainExecution implements Execution { // 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; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 333ec19195..f842c0af2f 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -712,6 +712,7 @@ export interface Game extends GameMap { addExecution(...exec: Execution[]): void; recordTrainArrival(steps: number): void; + recordTrainRemovedDueToHopLimit(steps: number): void; displayMessage( message: string, type: MessageType, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 38491c4837..5d4e289201 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -79,10 +79,11 @@ export class GameImpl implements Game { private unitGrid: UnitGrid; // Train statistics tracking - private trainArrivalTimes: number[] = []; // timestamps of recent train arrivals - private completedTrainSteps: number[] = []; // steps of recently completed trains + private arrivalsSinceLastPrint = 0; + private completedStepsSinceLastPrint = 0; private activeTrainSteps = 0; // total steps taken by currently active trains (updated each tick) private lastStatsPrint = 0; // last time we printed stats + private hopLimitRemovalsSinceLastPrint = 0; private playerTeams: Team[]; private botTeam: Team = ColoredTeams.Bot; @@ -464,20 +465,12 @@ export class GameImpl implements Game { // Train statistics tracking methods recordTrainArrival(steps: number) { - this.trainArrivalTimes.push(this._ticks); - this.completedTrainSteps.push(steps); + this.arrivalsSinceLastPrint++; + this.completedStepsSinceLastPrint += 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, - ); - } + recordTrainRemovedDueToHopLimit(_steps: number) { + this.hopLimitRemovalsSinceLastPrint++; } getActiveTrainCount(): number { @@ -487,10 +480,8 @@ export class GameImpl implements Game { } getAverageCompletedTrainSteps(): number { - if (this.completedTrainSteps.length === 0) return 0; - - const sum = this.completedTrainSteps.reduce((a, b) => a + b, 0); - return sum / this.completedTrainSteps.length; + if (this.arrivalsSinceLastPrint === 0) return 0; + return this.completedStepsSinceLastPrint / this.arrivalsSinceLastPrint; } getAverageActiveTrainSteps(): number { @@ -502,16 +493,21 @@ export class GameImpl implements Game { } printTrainStats() { - const arrivalsLast60s = this.trainArrivalTimes.length; + const arrivalsLastInterval = this.arrivalsSinceLastPrint; const activeTrains = this.getActiveTrainCount(); const avgCompletedSteps = Math.round(this.getAverageCompletedTrainSteps() * 100) / 100; const avgActiveSteps = Math.round(this.getAverageActiveTrainSteps() * 100) / 100; + const hopLimitRemovals = this.hopLimitRemovalsSinceLastPrint; console.log( - `🚂 Trains: ${arrivalsLast60s} arrived (${avgCompletedSteps} avg steps), ${activeTrains} active (${avgActiveSteps} avg steps)`, + `🚂 Trains: ${arrivalsLastInterval} arrived (${avgCompletedSteps} avg steps), ${activeTrains} active (${avgActiveSteps} avg steps), ${hopLimitRemovals} removed (hop limit)`, ); + + this.arrivalsSinceLastPrint = 0; + this.completedStepsSinceLastPrint = 0; + this.hopLimitRemovalsSinceLastPrint = 0; } playerView(id: PlayerID): Player { diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index 86849df477..7feb2b0b64 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -62,8 +62,7 @@ export interface EdgeMetrics { */ 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) + heat: number; // Congestion heat (unbounded, decays over time) lastHeatUpdate: number; } @@ -146,8 +145,18 @@ export class TrainStation { private lastOriginatorBroadcast: number = 0; private routesChanged: boolean = false; private changedRoutes: Set = new Set(); - private maxHops: number = 20; - private routeStaleThreshold: number = 500; // ticks + + private readonly maxHops: number = 20; + private readonly routeStaleThreshold: number = 500; // ticks + private readonly trainSearchRadius = 1; // Search up to x hops away for optimal routes through neighbors + // Disabling broadcasts turns routing into local-only mode! + // Implications: + // - Stations only know routes their own trains discovered + // - No network-wide knowledge sharing (via boradcast) + // - Trains get stuck in loops more easily + // - System becomes more like individual A* pathfinding + + private readonly enableBroadcasts: boolean = false; // Enable/disable BATMAN broadcast protocol // Lazy cleanup optimization private cleanupIndex: number = 0; @@ -156,21 +165,19 @@ export class TrainStation { // Local greedy routing properties private edgeMetrics: Map = 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%) + private readonly profitSensitivity: number = 0.3; // How much profit-per-distance boosts scores + + private readonly distanceSensitivity: number = 0.2; // How much distance increases duration penalties + private readonly stationHeatSensitivity: number = 0.4; // How much station heat reduces scores + private readonly heatDecayInterval: number = 60; // How often heat decays (ticks) + private readonly heatDecayFactor: number = 1 - 0.1; // How much heat decays per time (0.95 = 5% decay) + private readonly recencyDecayFactor: number = 1 - 0.2; // How much recency penalties decay per time (0.8 = 20% decay) + private readonly maxRecencyPenalty: number = 1; // Maximum penalty for immediate revisits + + private readonly randomChoiceProbability: number = 0.1; // Probability of making random choice instead of best (0.1 = 10%) + + // Pre-computed decay factors for performance (avoid Math.pow in hot path) + private readonly recencyDecayPowers: number[]; constructor( private mg: Game, @@ -185,7 +192,6 @@ export class TrainStation { // Initialize traffic tracking this.traffic = { trainCount: 0, - recentArrivals: 0, heat: 0, lastHeatUpdate: mg.ticks(), }; @@ -200,6 +206,15 @@ export class TrainStation { lastUpdate: mg.ticks(), }); this.changedRoutes.add(this); + + // Pre-compute recency decay factors for performance + // Size matches TrainExecution.recentMemorySize (50) to avoid wasted space + this.recencyDecayPowers = new Array(50); // max hops, fixme + this.recencyDecayPowers[0] = 1.0; // stationsAgo - 1 = 0: full penalty + for (let i = 1; i < this.recencyDecayPowers.length; i++) { + this.recencyDecayPowers[i] = + this.recencyDecayPowers[i - 1] * this.recencyDecayFactor; + } } tradeAvailable(otherPlayer: Player): boolean { @@ -506,10 +521,9 @@ export class TrainStation { */ 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); + // Increase station heat (unbounded) + this.traffic.heat += 0.1; this.traffic.lastHeatUpdate = this.mg.ticks(); } @@ -538,9 +552,12 @@ export class TrainStation { // Apply graduated recency penalty based on stations ago if (stationsAgo > 0) { - const penaltyStrength = - Math.pow(this.recencyDecayFactor, stationsAgo - 1) * - this.maxRecencyPenalty; + const exponent = stationsAgo - 1; + const decayFactor = + exponent < this.recencyDecayPowers.length + ? this.recencyDecayPowers[exponent] + : Math.pow(this.recencyDecayFactor, exponent); + const penaltyStrength = decayFactor * this.maxRecencyPenalty; const recencyPenalty = 1.0 - penaltyStrength; score *= recencyPenalty; } @@ -549,7 +566,7 @@ export class TrainStation { score *= 1 - this.stationHeatSensitivity * neighborTrafficHeat; // Ensure unvisited stations get a minimum exploration score - // This prevents zero-profit unvisited stations(facttories) from being ignored + // This prevents zero-profit unvisited stations(factories) from being ignored if (stationsAgo < 0 && score <= 0) { score = 0.2; // Small positive score to encourage exploration } @@ -579,30 +596,89 @@ export class 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 + const neighbors = this.neighbors(); + + // First check: Pure exploration mode - if randomChoiceProbability triggers, pick completely random neighbor + if ( + this.random.next() < this.randomChoiceProbability && + neighbors.length > 0 + ) { + const randomIndex = this.random.nextInt(0, neighbors.length); + return neighbors[randomIndex]; + } + + // Main routing logic: Check known routes (local + distributed when enabled) + const nextHop = this.findBestRouteTo(destination, neighbors); + if (nextHop) { // With some probability, still explore instead of following known route if (this.random.next() >= this.randomChoiceProbability) { - return knownNextHop; + return nextHop; } - // Otherwise, fall through to exploration mode + // Otherwise, fall through to greedy routing } - // Second priority: Local greedy routing for exploration/unknown routes - // Trains pick highest-scoring neighbors without considering direction toward destination. + // Fallback: Local greedy routing for exploration/unknown routes + return this.chooseGreedyNeighbor(neighbors, recentStations, trainOwner); + } + + /** + * Find the best known route to destination, considering both local and distributed knowledge + */ + private findBestRouteTo( + destination: TrainStation, + neighbors: TrainStation[], + ): TrainStation | null { + // Always check current station first + const localNextHop = this.getNextHop(destination); + if (localNextHop && neighbors.includes(localNextHop)) { + return localNextHop; + } + + // If distributed routing is enabled, check neighbors for better routes + if (this.trainSearchRadius > 0) { + const routeOptions: Array<{ + neighbor: TrainStation; + totalHopCount: number; + }> = []; + + for (const neighbor of neighbors) { + const neighborRoute = neighbor.routingTable.get(destination.tile()); + if (neighborRoute && neighborRoute.hopCount <= this.trainSearchRadius) { + const timeSinceUpdate = this.mg.ticks() - neighborRoute.lastUpdate; + if (timeSinceUpdate <= this.routeStaleThreshold) { + routeOptions.push({ + neighbor, + totalHopCount: neighborRoute.hopCount + 1, // +1 for the hop to this neighbor + }); + } + } + } + + if (routeOptions.length > 0) { + // Sort by total hop count to find the shortest path + routeOptions.sort((a, b) => a.totalHopCount - b.totalHopCount); + return routeOptions[0].neighbor; + } + } + + return null; + } + + /** + * Choose neighbor using greedy routing based on profit/distance/traffic + */ + private chooseGreedyNeighbor( + neighbors: TrainStation[], + recentStations: TrainStation[], + trainOwner: Player, + ): TrainStation | null { const validNeighbors: Array<{ station: TrainStation; score: number }> = []; - // Evaluate all neighboring stations - for (const neighbor of this.neighbors()) { + for (const neighbor of 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( @@ -616,28 +692,21 @@ export class TrainStation { } 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 null; + } - return bestStation; + // 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; } /** @@ -702,21 +771,13 @@ export class TrainStation { 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% + if (timeSinceUpdate > this.heatDecayInterval) { + // Every 5 ticks + this.traffic.heat *= this.heatDecayFactor; 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);