mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 21:23:27 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user