Files
OpenFrontIO/src/core/game/TrainStation.ts
T
AotumuriandGitHub d05f5e748c Fix external train trade attribution in match results (#3589)
## Description:

A player reported that income received from opponents' trains was
missing from match result logs.
Fix external train trade attribution to the station owner and add
regression tests for train income stats.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:
aotumuri
2026-04-15 02:35:32 +00:00

262 lines
6.4 KiB
TypeScript

import { TrainExecution } from "../execution/TrainExecution";
import { PseudoRandom } from "../PseudoRandom";
import { Game, Player, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType } from "./GameUpdates";
import { Railroad } from "./Railroad";
/**
* Handle train stops at various station types
*/
interface TrainStopHandler {
onStop(mg: Game, station: TrainStation, trainExecution: TrainExecution): void;
}
class TradeStationStopHandler implements TrainStopHandler {
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {
const stationOwner = station.unit.owner();
const trainOwner = trainExecution.owner();
const gold = mg
.config()
.trainGold(
rel(trainOwner, stationOwner),
trainExecution.tradeStopsVisited(),
);
// Share revenue with the station owner if it's not the current player
if (trainOwner !== stationOwner) {
stationOwner.addGold(gold, station.tile());
mg.stats().trainExternalTrade(stationOwner, gold);
}
trainOwner.addGold(gold, station.tile());
mg.stats().trainSelfTrade(trainOwner, gold);
}
}
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 TradeStationStopHandler(),
[UnitType.Port]: new TradeStationStopHandler(),
[UnitType.Factory]: new FactoryStopHandler(),
};
}
export class TrainStation {
id: number = -1; // assigned by StationManager
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
{};
private cluster: Cluster | null = null;
private railroads: Set<Railroad> = new Set();
// Quick lookup from neighboring station to connecting railroad
private railroadByNeighbor: Map<TrainStation, Railroad> = new Map();
constructor(
private mg: Game,
public unit: Unit,
) {
this.stopHandlers = createTrainStopHandlers(new PseudoRandom(mg.ticks()));
}
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);
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
this.railroadByNeighbor.set(neighbor, railRoad);
}
removeRailroad(railRoad: Railroad) {
this.railroads.delete(railRoad);
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
this.railroadByNeighbor.delete(neighbor);
}
removeNeighboringRails(station: TrainStation) {
const toRemove = [...this.railroads].find(
(r) => r.from === station || r.to === station,
);
if (toRemove) {
this.mg.addUpdate({
type: GameUpdateType.RailroadDestructionEvent,
id: toRemove.id,
});
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) {
// Properly disconnect cluster if it's already set
if (this.cluster !== null) {
this.cluster.removeStation(this);
}
this.cluster = cluster;
}
getCluster(): Cluster | null {
return this.cluster;
}
onTrainStop(trainExecution: TrainExecution) {
const type = this.unit.type();
const handler = this.stopHandlers[type];
if (handler) {
handler.onStop(this.mg, this, trainExecution);
}
}
}
/**
* Cluster of connected stations
*/
export class Cluster {
public stations: Set<TrainStation> = new Set();
private tradeStations: Set<TrainStation> = new Set();
private isTradeStation(station: TrainStation): boolean {
const type = station.unit.type();
return type === UnitType.City || type === UnitType.Port;
}
has(station: TrainStation) {
return this.stations.has(station);
}
addStation(station: TrainStation) {
this.stations.add(station);
if (this.isTradeStation(station)) {
this.tradeStations.add(station);
}
station.setCluster(this);
}
removeStation(station: TrainStation) {
this.stations.delete(station);
this.tradeStations.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);
}
}
hasAnyTradeDestination(player: Player): boolean {
for (const station of this.tradeStations) {
if (station.tradeAvailable(player)) {
return true;
}
}
return false;
}
randomTradeDestination(
player: Player,
random: PseudoRandom,
): TrainStation | null {
let selected: TrainStation | null = null;
let eligibleSeen = 0;
for (const station of this.tradeStations) {
if (!station.tradeAvailable(player)) continue;
eligibleSeen++;
// Reservoir sampling: keep each eligible station with probability 1/eligibleSeen.
if (random.nextInt(0, eligibleSeen) === 0) {
selected = station;
}
}
return selected;
}
availableForTrade(player: Player): Set<TrainStation> {
const tradingStations = new Set<TrainStation>();
for (const station of this.tradeStations) {
if (station.tradeAvailable(player)) {
tradingStations.add(station);
}
}
return tradingStations;
}
size() {
return this.stations.size;
}
clear() {
this.stations.clear();
this.tradeStations.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";
}