mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 22:03:24 +00:00
33810e41c5
## Description:
This PR optimizes how the rail network looks up railroads connecting two
stations by introducing an O(1) neighbor→railroad map on `TrainStation`.
It also updates `getOrientedRailroad` and railroad deletion to use this
new API, avoiding repeated linear scans over all railroads attached to a
station.
### What changed
- **TrainStation neighbor→railroad index**
- Added `railroadByNeighbor: Map<TrainStation, Railroad>` to
`TrainStation` for quick edge lookup.
- Kept `railroads: Set<Railroad>` for iteration and existing APIs.
- Updated lifecycle methods to keep both data structures in sync:
- `addRailroad(railRoad: Railroad)` now:
- Adds to `railroads`.
- Computes the neighbor station (`railRoad.from === this ? railRoad.to :
railRoad.from`).
- Stores the mapping in `railroadByNeighbor`.
- `removeRailroad(railRoad: Railroad)` now:
- Removes from `railroads`.
- Removes the corresponding entry from `railroadByNeighbor`.
- `clearRailroads()` now clears both `railroads` and
`railroadByNeighbor`.
- Added `getRailroadTo(station: TrainStation): Railroad | null` to
retrieve the connecting railroad in O(1).
- **Use the new API in `TrainStation` and `Railroad`**
- `TrainStation.removeNeighboringRails(station)` now calls
`removeRailroad(toRemove)` instead of manually deleting from the set,
ensuring the map stays in sync.
- `Railroad.delete(game)` now calls `from.removeRailroad(this)` and
`to.removeRailroad(this)` instead of mutating the sets directly.
- **Refactor `getOrientedRailroad` to use O(1) lookup**
- Replaced a linear scan over `from.getRailroads()` with a direct
lookup:
```ts
export function getOrientedRailroad(
from: TrainStation,
to: TrainStation,
): OrientedRailroad | null {
const railroad = from.getRailroadTo(to);
if (!railroad) return null;
// If tiles are stored from -> to, we go forward when railroad.to === to
const forward = railroad.to === to;
return new OrientedRailroad(railroad, forward);
}
```
- Behavior is preserved:
- `getRailroadTo` returns the same `Railroad` instance that was
previously found by scanning `getRailroads()`.
- Direction (`forward` vs reversed) is still derived from the
`Railroad.from` / `.to` fields in the same way as before.
### Motivation
- `getOrientedRailroad` and upcoming logic both need to resolve “the
railroad between station A and station B” frequently.
- The old pattern (`for (const railroad of from.getRailroads()) { ...
}`) was:
- O(degree) per lookup,
- Repeated in multiple places,
- Harder to maintain as more features (like fare-based costs) touch this
code.
- Centralizing edge lookup in a dedicated `railroadByNeighbor` map makes
this:
- **O(1)** per lookup,
- Less error-prone (one source of truth),
- Easier to reuse from new systems (e.g. train pathfinding, fare-aware
logic).
### Impact / Risk
- **Public behavior:** No functional change in how railroads are
created, deleted, or oriented; only the lookup mechanism changed.
- **Internal invariants:** Correctness relies on:
- All railroad creations using `addRailroad` on both endpoints (already
true via `RailNetworkImpl.connect`).
- All removals (`Railroad.delete`,
`TrainStation.removeNeighboringRails`, `disconnectFromNetwork`) using
`removeRailroad` / `clearRailroads`, which this PR updates.
- **Tests:** Existing `TrainStation` tests still pass; they exercise
`addRailroad`, `removeNeighboringRails`, and `getRailroads()`, which
continue to behave the same from the outside.
## 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:
DISCORD_USERNAME
266 lines
6.5 KiB
TypeScript
266 lines
6.5 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";
|
|
|
|
/**
|
|
* 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 cluster: Cluster | 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) {
|
|
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;
|
|
}
|
|
|
|
onTrainStop(trainExecution: TrainExecution) {
|
|
const type = this.unit.type();
|
|
const handler = this.stopHandlers[type];
|
|
if (handler) {
|
|
handler.onStop(this.mg, this, trainExecution);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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";
|
|
}
|