mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-22 11:37:12 +00:00
- 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
983 lines
29 KiB
TypeScript
983 lines
29 KiB
TypeScript
import { TrainExecution } from "../execution/TrainExecution";
|
|
import { GraphAdapter } from "../pathfinding/SerialAStar";
|
|
import { PseudoRandom } from "../PseudoRandom";
|
|
import { Game, Player, Unit, UnitType } from "./Game";
|
|
import { TileRef } from "./GameMap";
|
|
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
|
|
import { Railroad } from "./Railroad";
|
|
|
|
/**
|
|
* Simple station lookup by tile ID for routing
|
|
*/
|
|
class StationLookup {
|
|
private static stations = new Map<TileRef, TrainStation>();
|
|
|
|
static register(station: TrainStation): void {
|
|
this.stations.set(station.tile(), station);
|
|
}
|
|
|
|
static getStation(tile: TileRef): TrainStation | null {
|
|
return this.stations.get(tile) ?? null;
|
|
}
|
|
|
|
static unregister(station: TrainStation): void {
|
|
this.stations.delete(station.tile());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lightweight routing entry using station IDs for memory efficiency
|
|
*/
|
|
export interface RoutingEntry {
|
|
destinationId: number;
|
|
nextHopId: number;
|
|
hopCount: number;
|
|
sequenceNumber: number;
|
|
lastUpdate: number;
|
|
}
|
|
|
|
/**
|
|
* Legacy interface for backward compatibility (deprecated)
|
|
*/
|
|
export interface RoutingEntryFull {
|
|
destination: TrainStation;
|
|
nextHop: TrainStation;
|
|
hopCount: number;
|
|
sequenceNumber: number;
|
|
lastUpdate: number;
|
|
}
|
|
|
|
/**
|
|
* Edge metrics for local greedy routing
|
|
*/
|
|
export interface EdgeMetrics {
|
|
toStation: TrainStation;
|
|
baseDuration: number; // Base travel time/cost to this station
|
|
distance: number; // Physical distance (affects duration)
|
|
lastUpdated: number; // When metrics were last updated
|
|
}
|
|
|
|
/**
|
|
* Station traffic and congestion data
|
|
*/
|
|
export interface StationTraffic {
|
|
trainCount: number; // Current number of trains at station
|
|
heat: number; // Congestion heat (unbounded, decays over time)
|
|
lastHeatUpdate: number;
|
|
}
|
|
|
|
/**
|
|
* Handle train stops at various station types
|
|
*/
|
|
interface TrainStopHandler {
|
|
onStop(mg: Game, station: TrainStation, trainExecution: TrainExecution): void;
|
|
}
|
|
|
|
/**
|
|
* All stop handlers share the same logic for the time being
|
|
* Behavior to be defined
|
|
*/
|
|
class CityStopHandler implements TrainStopHandler {
|
|
onStop(
|
|
mg: Game,
|
|
station: TrainStation,
|
|
trainExecution: TrainExecution,
|
|
): void {
|
|
const stationOwner = station.unit.owner();
|
|
const trainOwner = trainExecution.owner();
|
|
const goldBonus = mg.config().trainGold(rel(trainOwner, stationOwner));
|
|
// Share revenue with the station owner if it's not the current player
|
|
if (trainOwner !== stationOwner) {
|
|
stationOwner.addGold(goldBonus, station.tile());
|
|
}
|
|
trainOwner.addGold(goldBonus, station.tile());
|
|
}
|
|
}
|
|
|
|
class PortStopHandler implements TrainStopHandler {
|
|
constructor(private random: PseudoRandom) {}
|
|
onStop(
|
|
mg: Game,
|
|
station: TrainStation,
|
|
trainExecution: TrainExecution,
|
|
): void {
|
|
const stationOwner = station.unit.owner();
|
|
const trainOwner = trainExecution.owner();
|
|
const goldBonus = mg.config().trainGold(rel(trainOwner, stationOwner));
|
|
|
|
trainOwner.addGold(goldBonus, station.tile());
|
|
// Share revenue with the station owner if it's not the current player
|
|
if (trainOwner !== stationOwner) {
|
|
stationOwner.addGold(goldBonus, station.tile());
|
|
}
|
|
}
|
|
}
|
|
|
|
class FactoryStopHandler implements TrainStopHandler {
|
|
onStop(
|
|
mg: Game,
|
|
station: TrainStation,
|
|
trainExecution: TrainExecution,
|
|
): void {}
|
|
}
|
|
|
|
export function createTrainStopHandlers(
|
|
random: PseudoRandom,
|
|
): Partial<Record<UnitType, TrainStopHandler>> {
|
|
return {
|
|
[UnitType.City]: new CityStopHandler(),
|
|
[UnitType.Port]: new PortStopHandler(random),
|
|
[UnitType.Factory]: new FactoryStopHandler(),
|
|
};
|
|
}
|
|
|
|
export class TrainStation {
|
|
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
|
|
{};
|
|
private random: PseudoRandom;
|
|
private cluster: Cluster | null;
|
|
private railroads: Set<Railroad> = new Set();
|
|
// Quick lookup from neighboring station to connecting railroad
|
|
private railroadByNeighbor: Map<TrainStation, Railroad> = new Map();
|
|
|
|
// Batman routing properties - now using IDs for memory efficiency
|
|
private routingTable: Map<number, RoutingEntry> = new Map();
|
|
private sequenceNumber: number = 0;
|
|
private originatorInterval: number = 1000; // ticks between broadcasts (increased 10x)
|
|
private lastOriginatorBroadcast: number = 0;
|
|
private routesChanged: boolean = false;
|
|
private changedRoutes: Set<TrainStation> = new Set();
|
|
|
|
private 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;
|
|
private readonly routesToCheckPerTick = 3; // Check only 3 routes per tick
|
|
|
|
// Local greedy routing properties
|
|
private edgeMetrics: Map<TrainStation, EdgeMetrics> = new Map();
|
|
private traffic: StationTraffic;
|
|
private 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,
|
|
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,
|
|
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);
|
|
|
|
// 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 {
|
|
const player = this.unit.owner();
|
|
return otherPlayer === player || player.canTrade(otherPlayer);
|
|
}
|
|
|
|
clearRailroads() {
|
|
this.railroads.clear();
|
|
this.railroadByNeighbor.clear();
|
|
}
|
|
|
|
addRailroad(railRoad: Railroad) {
|
|
this.railroads.add(railRoad);
|
|
this.routesChanged = true; // Network topology changed
|
|
|
|
// Determine neighboring station and maintain quick lookup
|
|
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
|
|
if (neighbor) {
|
|
this.railroadByNeighbor.set(neighbor, railRoad);
|
|
|
|
// Initialize edge metrics for new connection if not present
|
|
if (!this.edgeMetrics.has(neighbor)) {
|
|
this.initializeEdgeMetrics(neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
removeRailroad(railRoad: Railroad) {
|
|
this.railroads.delete(railRoad);
|
|
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
|
|
if (neighbor) {
|
|
this.railroadByNeighbor.delete(neighbor);
|
|
this.edgeMetrics.delete(neighbor);
|
|
}
|
|
this.routesChanged = true; // Network topology changed
|
|
}
|
|
|
|
removeNeighboringRails(station: TrainStation) {
|
|
const toRemove = [...this.railroads].find(
|
|
(r) => r.from === station || r.to === station,
|
|
);
|
|
if (toRemove) {
|
|
const railTiles: RailTile[] = toRemove.tiles.map((tile) => ({
|
|
tile,
|
|
railType: RailType.VERTICAL,
|
|
}));
|
|
this.mg.addUpdate({
|
|
type: GameUpdateType.RailroadEvent,
|
|
isActive: false,
|
|
railTiles,
|
|
});
|
|
this.removeRailroad(toRemove);
|
|
}
|
|
}
|
|
|
|
neighbors(): TrainStation[] {
|
|
const neighbors: TrainStation[] = [];
|
|
for (const r of this.railroads) {
|
|
if (r.from !== this) {
|
|
neighbors.push(r.from);
|
|
} else {
|
|
neighbors.push(r.to);
|
|
}
|
|
}
|
|
return neighbors;
|
|
}
|
|
|
|
tile(): TileRef {
|
|
return this.unit.tile();
|
|
}
|
|
|
|
isActive(): boolean {
|
|
return this.unit.isActive();
|
|
}
|
|
|
|
getRailroads(): Set<Railroad> {
|
|
return this.railroads;
|
|
}
|
|
|
|
getRailroadTo(station: TrainStation): Railroad | null {
|
|
return this.railroadByNeighbor.get(station) ?? null;
|
|
}
|
|
|
|
setCluster(cluster: Cluster | null) {
|
|
this.cluster = cluster;
|
|
}
|
|
|
|
getCluster(): Cluster | null {
|
|
return this.cluster;
|
|
}
|
|
|
|
// ===== BATMAN ROUTING METHODS =====
|
|
|
|
/**
|
|
* Get the next hop toward a destination using routing table
|
|
*/
|
|
getNextHop(destination: TrainStation): TrainStation | null {
|
|
const destTile = destination.tile();
|
|
const route = this.routingTable.get(destTile);
|
|
|
|
if (route && route.hopCount <= this.maxHops) {
|
|
const timeSinceUpdate = this.mg.ticks() - route.lastUpdate;
|
|
if (timeSinceUpdate <= this.routeStaleThreshold) {
|
|
return StationLookup.getStation(route.nextHopId);
|
|
}
|
|
}
|
|
|
|
// No valid route - routes will be learned organically as trains explore
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Broadcast originator message with changed routes only
|
|
*/
|
|
broadcastOriginatorMessage(): void {
|
|
this.sequenceNumber++;
|
|
this.cleanupStaleRoutes();
|
|
|
|
// Create a map of only changed routes using tile IDs
|
|
const changedRoutesMap = new Map<number, RoutingEntry>();
|
|
for (const dest of this.changedRoutes) {
|
|
const destTile = dest.tile();
|
|
const route = this.routingTable.get(destTile);
|
|
if (route) {
|
|
changedRoutesMap.set(destTile, route);
|
|
}
|
|
}
|
|
|
|
// Clear changed routes after broadcasting
|
|
this.changedRoutes.clear();
|
|
|
|
// Send only changed routes to all neighbors
|
|
for (const neighbor of this.neighbors()) {
|
|
neighbor.receiveOriginatorMessage(
|
|
this,
|
|
changedRoutesMap,
|
|
this.sequenceNumber,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Receive and process originator message from another station
|
|
*/
|
|
receiveOriginatorMessage(
|
|
originator: TrainStation,
|
|
originatorTable: Map<number, RoutingEntry>,
|
|
originatorSeq: number,
|
|
): void {
|
|
const currentTime = this.mg.ticks();
|
|
let routesWereUpdated = false;
|
|
|
|
// Get originator tile
|
|
const originatorTile = originator.tile();
|
|
|
|
// Only process if this is a newer sequence number than what we have for originator
|
|
const existingSeq =
|
|
this.routingTable.get(originatorTile)?.sequenceNumber ?? 0;
|
|
if (originatorSeq <= existingSeq) {
|
|
return; // Stale message
|
|
}
|
|
|
|
// Update route to originator itself
|
|
this.routingTable.set(originatorTile, {
|
|
destinationId: originatorTile,
|
|
nextHopId: originatorTile, // Direct neighbor
|
|
hopCount: 1,
|
|
sequenceNumber: originatorSeq,
|
|
lastUpdate: currentTime,
|
|
});
|
|
this.changedRoutes.add(originator);
|
|
routesWereUpdated = true;
|
|
|
|
// Process each route from originator
|
|
for (const [destId, route] of originatorTable) {
|
|
const newHopCount = route.hopCount + 1;
|
|
|
|
// Skip if hop count would be too high
|
|
if (newHopCount > this.maxHops) continue;
|
|
|
|
const existingRoute = this.routingTable.get(destId);
|
|
|
|
// Update if: no existing route, better hop count, or same hop count but newer sequence
|
|
const shouldUpdate =
|
|
!existingRoute ||
|
|
newHopCount < existingRoute.hopCount ||
|
|
(newHopCount === existingRoute.hopCount &&
|
|
originatorSeq > existingRoute.sequenceNumber);
|
|
|
|
if (shouldUpdate) {
|
|
this.routingTable.set(destId, {
|
|
destinationId: destId,
|
|
nextHopId: originatorTile, // Next hop is the station we received this from
|
|
hopCount: newHopCount,
|
|
sequenceNumber: originatorSeq,
|
|
lastUpdate: currentTime,
|
|
});
|
|
|
|
// Mark destination station as changed
|
|
const destStation = StationLookup.getStation(destId);
|
|
if (destStation) {
|
|
this.changedRoutes.add(destStation);
|
|
}
|
|
routesWereUpdated = true;
|
|
}
|
|
}
|
|
|
|
// If routes were updated, we should eventually broadcast our changes
|
|
if (routesWereUpdated) {
|
|
this.routesChanged = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up stale routes - lazy implementation for scalability
|
|
* Only checks a few routes per tick instead of all routes
|
|
*/
|
|
private cleanupStaleRoutes(): void {
|
|
const currentTime = this.mg.ticks();
|
|
|
|
// Convert map to array for indexed access
|
|
const routeEntries = Array.from(this.routingTable.entries());
|
|
|
|
if (routeEntries.length === 0) {
|
|
this.cleanupIndex = 0;
|
|
return;
|
|
}
|
|
|
|
// Check only a few routes per tick (round-robin)
|
|
const routesChecked = Math.min(
|
|
this.routesToCheckPerTick,
|
|
routeEntries.length,
|
|
);
|
|
|
|
for (let i = 0; i < routesChecked; i++) {
|
|
const index = (this.cleanupIndex + i) % routeEntries.length;
|
|
const [destId, route] = routeEntries[index];
|
|
|
|
if (currentTime - route.lastUpdate > this.routeStaleThreshold) {
|
|
this.routingTable.delete(destId);
|
|
// Mark destination station as changed for potential rebroadcast
|
|
const destStation = StationLookup.getStation(destId);
|
|
if (destStation) {
|
|
this.changedRoutes.add(destStation);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update index for next cleanup cycle
|
|
this.cleanupIndex =
|
|
(this.cleanupIndex + routesChecked) % routeEntries.length;
|
|
}
|
|
|
|
/**
|
|
* Periodic tick for routing maintenance - event-driven broadcasting
|
|
*/
|
|
tick(): void {
|
|
// Update traffic metrics
|
|
this.updateTraffic();
|
|
|
|
const timeSinceLastBroadcast =
|
|
this.mg.ticks() - this.lastOriginatorBroadcast;
|
|
|
|
// Broadcast if routes changed OR if it's been too long since last broadcast
|
|
|
|
if (
|
|
this.enableBroadcasts &&
|
|
(this.routesChanged || timeSinceLastBroadcast >= this.originatorInterval)
|
|
) {
|
|
this.broadcastOriginatorMessage();
|
|
this.routesChanged = false; // Reset the flag after broadcasting
|
|
this.lastOriginatorBroadcast = this.mg.ticks();
|
|
}
|
|
}
|
|
|
|
// ===== LOCAL GREEDY ROUTING METHODS =====
|
|
|
|
/**
|
|
* Initialize edge metrics for a neighboring station
|
|
*/
|
|
private initializeEdgeMetrics(neighborStation: TrainStation): void {
|
|
const distance = this.calculateDistance(neighborStation);
|
|
const baseDuration = Math.max(1, Math.floor(distance / 2)); // Rough duration estimate
|
|
|
|
this.edgeMetrics.set(neighborStation, {
|
|
toStation: neighborStation,
|
|
baseDuration,
|
|
distance,
|
|
lastUpdated: this.mg.ticks(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate physical distance to another station
|
|
*/
|
|
private calculateDistance(other: TrainStation): number {
|
|
const dx = Math.abs(this.mg.x(this.tile()) - this.mg.x(other.tile()));
|
|
const dy = Math.abs(this.mg.y(this.tile()) - this.mg.y(other.tile()));
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
/**
|
|
* Calculate actual profit for a train owner traveling to another station
|
|
* Uses the game's actual trainGold configuration based on relationship
|
|
*/
|
|
private calculateActualProfit(
|
|
trainOwner: Player,
|
|
other: TrainStation,
|
|
): number {
|
|
const stationOwner = other.unit.owner();
|
|
const relationship = rel(trainOwner, stationOwner);
|
|
|
|
// Use actual game values from config
|
|
const goldValue = this.mg.config().trainGold(relationship);
|
|
|
|
// Convert BigInt to number for scoring calculations
|
|
return Number(goldValue);
|
|
}
|
|
|
|
/**
|
|
* Update traffic when a train arrives
|
|
*/
|
|
onTrainArrival(trainExecution: TrainExecution): void {
|
|
this.traffic.trainCount++;
|
|
|
|
// Increase station heat (unbounded)
|
|
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 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;
|
|
}
|
|
|
|
// Apply station heat avoidance
|
|
score *= 1 - this.stationHeatSensitivity * neighborTrafficHeat;
|
|
|
|
// Ensure unvisited stations get a minimum exploration score
|
|
// This prevents zero-profit unvisited stations(factories) 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 {
|
|
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 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 }> = [];
|
|
|
|
for (const neighbor of neighbors) {
|
|
const edge = this.edgeMetrics.get(neighbor);
|
|
if (!edge) continue;
|
|
|
|
const actualProfit = this.calculateActualProfit(trainOwner, neighbor);
|
|
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;
|
|
}
|
|
|
|
// 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 > this.heatDecayInterval) {
|
|
// Every 5 ticks
|
|
this.traffic.heat *= this.heatDecayFactor;
|
|
this.traffic.lastHeatUpdate = currentTime;
|
|
}
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
// 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*
|
|
*/
|
|
export class TrainStationMapAdapter implements GraphAdapter<TrainStation> {
|
|
constructor(private game: Game) {}
|
|
|
|
neighbors(node: TrainStation): TrainStation[] {
|
|
return node.neighbors();
|
|
}
|
|
|
|
cost(node: TrainStation): number {
|
|
return 1;
|
|
}
|
|
|
|
position(node: TrainStation): { x: number; y: number } {
|
|
return { x: this.game.x(node.tile()), y: this.game.y(node.tile()) };
|
|
}
|
|
|
|
isTraversable(from: TrainStation, to: TrainStation): boolean {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cluster of connected stations
|
|
*/
|
|
export class Cluster {
|
|
public stations: Set<TrainStation> = new Set();
|
|
|
|
has(station: TrainStation) {
|
|
return this.stations.has(station);
|
|
}
|
|
|
|
addStation(station: TrainStation) {
|
|
this.stations.add(station);
|
|
station.setCluster(this);
|
|
}
|
|
|
|
removeStation(station: TrainStation) {
|
|
this.stations.delete(station);
|
|
}
|
|
|
|
addStations(stations: Set<TrainStation>) {
|
|
for (const station of stations) {
|
|
this.addStation(station);
|
|
}
|
|
}
|
|
|
|
merge(other: Cluster) {
|
|
for (const s of other.stations) {
|
|
this.addStation(s);
|
|
}
|
|
}
|
|
|
|
availableForTrade(player: Player): Set<TrainStation> {
|
|
const tradingStations = new Set<TrainStation>();
|
|
for (const station of this.stations) {
|
|
if (
|
|
(station.unit.type() === UnitType.City ||
|
|
station.unit.type() === UnitType.Port) &&
|
|
station.tradeAvailable(player)
|
|
) {
|
|
tradingStations.add(station);
|
|
}
|
|
}
|
|
return tradingStations;
|
|
}
|
|
|
|
size() {
|
|
return this.stations.size;
|
|
}
|
|
|
|
clear() {
|
|
this.stations.clear();
|
|
}
|
|
}
|
|
|
|
function rel(
|
|
player: Player,
|
|
other: Player,
|
|
): "self" | "team" | "ally" | "other" {
|
|
if (player === other) {
|
|
return "self";
|
|
}
|
|
if (player.isOnSameTeam(other)) {
|
|
return "team";
|
|
}
|
|
if (player.isAlliedWith(other)) {
|
|
return "ally";
|
|
}
|
|
return "other";
|
|
}
|