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
This commit is contained in:
scamiv
2025-11-18 15:08:26 +01:00
parent 73826e0a5d
commit 27f2c7b7a3
4 changed files with 155 additions and 98 deletions
+5 -6
View File
@@ -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;
+1
View File
@@ -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,
+17 -21
View File
@@ -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 {
+132 -71
View File
@@ -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<TrainStation> = 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<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%)
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);