Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 168 additions & 47 deletions src/core/execution/TrainExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,17 @@ 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 hasProcessedArrival: boolean = false;
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 = 50; // How many recent stations to remember

constructor(
private railNetwork: RailNetwork,
Expand All @@ -36,27 +44,81 @@ export class TrainExecution implements Execution {
return this.player;
}

/**
* Share journey information with a station for organic route discovery
*/
public shareJourneyInfo(): {
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;

// 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;
// Distance from current station to this destination
const distance = this.recentStations.length - 1 - i;

routeInformation.push({
destination,
nextHop,
distance,
});
}

return {
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;
Expand Down Expand Up @@ -98,6 +160,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();
Expand Down Expand Up @@ -140,11 +208,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();
}
Comment on lines 210 to 212
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

activeSourceOrDestination: clarify semantics

activeSourceOrDestination returns this.source.isActive() && this.destination.isActive(). This means if either endpoint dies mid‑journey, the train is deleted.

If that’s intentional, consider renaming to bothEndpointsActive() or adding a short comment. If instead the intent is “source or destination still exists”, you probably want || instead of &&.

🤖 Prompt for AI Agents
In src/core/execution/TrainExecution.ts around lines 208 to 210, the method
activeSourceOrDestination() currently uses && which requires both endpoints to
be active; decide the intended semantics and fix accordingly: if you mean "both
endpoints must be active" rename the method to bothEndpointsActive() and add a
one-line comment clarifying that deletion occurs when either endpoint is
inactive; if you mean "either endpoint still exists" change the operator to ||,
update the method name to reflect that (e.g., sourceOrDestinationActive()), and
add/adjust a unit test to cover the case where one endpoint is inactive.


/**
Expand Down Expand Up @@ -187,49 +251,106 @@ 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;
}

// 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;
}

// 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;
}
this.currentTile = leftOver;
this.saveTraversedTiles(0, leftOver);

// 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.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;

this.currentStation.onTrainStop(this);
}

isActive(): boolean {
Expand Down
4 changes: 4 additions & 0 deletions src/core/execution/TrainStationExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 2 additions & 0 deletions src/core/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,8 @@ export interface Game extends GameMap {
): Array<{ unit: Unit; distSquared: number }>;

addExecution(...exec: Execution[]): void;
recordTrainArrival(steps: number): void;
recordTrainRemovedDueToHopLimit(steps: number): void;
displayMessage(
message: string,
type: MessageType,
Expand Down
70 changes: 70 additions & 0 deletions src/core/game/GameImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ export class GameImpl implements Game {
private updates: GameUpdates = createGameUpdatesMap();
private unitGrid: UnitGrid;

// Train statistics tracking
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;
private _railNetwork: RailNetwork = createRailNetwork(this);
Expand Down Expand Up @@ -347,12 +354,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;
}
}
});
Comment on lines 355 to 373
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

executeNextTick: fragile reliance on constructor.name and private fields

Inside the tick loop:

if (e.constructor.name === "TrainExecution") {
  this.activeTrainSteps += (e as any).journeyHopCount ?? 0;
}

This has a few problems:

  • Using constructor.name is brittle (minification, refactors, subclasses).
  • It reaches into journeyHopCount via any, coupling GameImpl to a private field of TrainExecution.
  • It runs on every tick over every execution, adding overhead.

A cleaner, more compositional approach:

  • Add a small “stats reporter” interface for executions, e.g.

    interface TrainLikeExecution {
      isTrain(): boolean;
      getJourneyHopCount(): number;
    }

    and implement it in TrainExecution, or

  • Prefer an explicit hook where TrainExecution reports its “active steps” to the game (similar to recordTrainArrival) instead of the game introspecting execution internals.

This keeps type safety and avoids using any and string name checks.


🏁 Script executed:

#!/bin/bash
# Check all Execution implementations; see if we can introduce a shared interface
rg -nP 'implements Execution' src/core/execution --type=ts -C2

Length of output: 15669


const inited: Execution[] = [];
Expand Down Expand Up @@ -381,6 +397,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;
}
Expand Down Expand Up @@ -440,6 +463,53 @@ export class GameImpl implements Game {
);
}

// Train statistics tracking methods
recordTrainArrival(steps: number) {
this.arrivalsSinceLastPrint++;
this.completedStepsSinceLastPrint += steps;
}

recordTrainRemovedDueToHopLimit(_steps: number) {
this.hopLimitRemovalsSinceLastPrint++;
}

getActiveTrainCount(): number {
return this.executions().filter(
(exec) => exec.constructor.name === "TrainExecution" && exec.isActive(),
).length;
}

getAverageCompletedTrainSteps(): number {
if (this.arrivalsSinceLastPrint === 0) return 0;
return this.completedStepsSinceLastPrint / this.arrivalsSinceLastPrint;
}

getAverageActiveTrainSteps(): number {
const activeTrains = this.getActiveTrainCount();
if (activeTrains === 0) return 0;

// Return average steps for currently active trains
return this.activeTrainSteps / activeTrains;
}

printTrainStats() {
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: ${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 {
return this.player(id);
}
Expand Down
Loading
Loading