diff --git a/src/core/execution/TrainExecution.ts b/src/core/execution/TrainExecution.ts index 4ba4d315b..bd89a5d13 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 8ba8ef728..75c2b7a21 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -713,6 +713,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 4a7e3700a..92c41bcd7 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 7bb64353d..68960c0fd 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; } @@ -148,8 +147,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; @@ -158,21 +167,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, @@ -187,7 +194,6 @@ export class TrainStation { // Initialize traffic tracking this.traffic = { trainCount: 0, - recentArrivals: 0, heat: 0, lastHeatUpdate: mg.ticks(), }; @@ -202,6 +208,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 { @@ -527,10 +542,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(); } @@ -559,9 +573,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; } @@ -570,7 +587,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 } @@ -600,30 +617,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 - // With some probability, still explore instead of following known route - if (this.random.next() >= this.randomChoiceProbability) { - return knownNextHop; - } - // Otherwise, fall through to exploration mode + 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]; } - // Second priority: Local greedy routing for exploration/unknown routes - // Trains pick highest-scoring neighbors without considering direction toward destination. + // 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 nextHop; + } + // Otherwise, fall through to greedy routing + } + + // 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( @@ -637,28 +713,21 @@ export class TrainStation { } if (validNeighbors.length === 0) { - return null; // No valid neighbors + return null; } - // 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; + // 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; - } + for (const { station, score } of validNeighbors) { + if (score > bestScore) { + bestScore = score; + bestStation = station; } - - return bestStation; } + + return bestStation; } /** @@ -723,21 +792,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);