diff --git a/src/core/execution/TrainExecution.ts b/src/core/execution/TrainExecution.ts index 44efb7b73..6840b4542 100644 --- a/src/core/execution/TrainExecution.ts +++ b/src/core/execution/TrainExecution.ts @@ -117,7 +117,30 @@ export class TrainExecution implements Execution { tiles.length > 0 ? tiles[Math.floor(tiles.length / 2)] : railroad.getStart().tile(); - this.player.addGold(-fare, midTile); + let netFare = fare; + + // Optimization: if the train owner is also the sole territory owner along this railroad, + // they would immediately get back the full 20% share. In that case, just charge the net + // 80% fare and skip the distribution step. + let shouldDistributeShare = true; + if ( + this.mg && + fare > 0n && + rail.isSoleTerritoryOwner(this.mg, this.player) + ) { + const profitShare = fare / 5n; // 20% + netFare = fare - profitShare; + shouldDistributeShare = false; + } + + // Charge fare (possibly reduced by owner share optimization) to the train owner + this.player.addGold(-netFare, midTile); + + // Share 20% of the fare with territory owners along the railroad, + // proportional to the number of tiles they own under this track. + if (shouldDistributeShare && this.mg && fare > 0n) { + rail.distributeFareShare(this.mg, fare); + } // Update client-side coloring when fare changes significantly if (this.mg !== null) { rail.updateFare(this.mg); diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 92c41bcd7..6631aa37b 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -606,6 +606,8 @@ export class GameImpl implements Game { type: GameUpdateType.Tile, update: this.toTileUpdate(tile), }); + // Notify rail network so it can invalidate cached territory ownership + this._railNetwork.onTileOwnerChanged(tile); } relinquish(tile: TileRef) { @@ -627,6 +629,8 @@ export class GameImpl implements Game { type: GameUpdateType.Tile, update: this.toTileUpdate(tile), }); + // Notify rail network so it can invalidate cached territory ownership + this._railNetwork.onTileOwnerChanged(tile); } private updateBorders(tile: TileRef) { diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index 404c062be..1f44b0077 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -1,8 +1,12 @@ import { Unit } from "./Game"; +import { TileRef } from "./GameMap"; import { TrainStation } from "./TrainStation"; export interface RailNetwork { connectStation(station: TrainStation): void; removeStation(unit: Unit): void; findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; + // Notify the rail network that the owner of a tile has changed, + // so any railroads crossing that tile can update cached territory ownership. + onTileOwnerChanged(tile: TileRef): void; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 7db749c87..51f77a257 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -89,6 +89,9 @@ export function createRailNetwork(game: Game): RailNetwork { export class RailNetworkImpl implements RailNetwork { private maxConnectionDistance: number = 4; + // Index which railroads cross which tiles so we can quickly invalidate + // cached territory ownership on conquests. + private railsByTile: Map> = new Map(); constructor( private game: Game, @@ -96,6 +99,28 @@ export class RailNetworkImpl implements RailNetwork { private pathService: RailPathFinderService, ) {} + private registerRailroad(railRoad: Railroad) { + for (const tile of railRoad.tiles) { + let set = this.railsByTile.get(tile); + if (!set) { + set = new Set(); + this.railsByTile.set(tile, set); + } + set.add(railRoad); + } + } + + private unregisterRailroad(railRoad: Railroad) { + for (const tile of railRoad.tiles) { + const set = this.railsByTile.get(tile); + if (!set) continue; + set.delete(railRoad); + if (set.size === 0) { + this.railsByTile.delete(tile); + } + } + } + connectStation(station: TrainStation) { this.stationManager.addStation(station); this.connectToNearbyStations(station); @@ -124,6 +149,15 @@ export class RailNetworkImpl implements RailNetwork { station.unit.setTrainStation(false); } + onTileOwnerChanged(tile: TileRef): void { + const rails = this.railsByTile.get(tile); + if (!rails) return; + + for (const rail of rails) { + rail.markTerritoryDirty(); + } + } + /** * Return the intermediary stations connecting two stations */ @@ -180,6 +214,7 @@ export class RailNetworkImpl implements RailNetwork { private disconnectFromNetwork(station: TrainStation) { for (const rail of station.getRailroads()) { + this.unregisterRailroad(rail); rail.delete(this.game); } station.clearRailroads(); @@ -200,6 +235,7 @@ export class RailNetworkImpl implements RailNetwork { const path = this.pathService.findTilePath(from.tile(), to.tile()); if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) { const railRoad = new Railroad(from, to, path); + this.registerRailroad(railRoad); this.game.addExecution(new RailroadExecution(railRoad)); from.addRailroad(railRoad); to.addRailroad(railRoad); diff --git a/src/core/game/Railroad.ts b/src/core/game/Railroad.ts index 294b32554..f85e9b2ac 100644 --- a/src/core/game/Railroad.ts +++ b/src/core/game/Railroad.ts @@ -1,4 +1,4 @@ -import { Game, Tick } from "./Game"; +import { Game, Player, Tick } from "./Game"; import { TileRef } from "./GameMap"; import { GameUpdateType, RailTile, RailType } from "./GameUpdates"; import { TrainStation } from "./TrainStation"; @@ -13,6 +13,12 @@ export class Railroad { private railTiles: RailTile[] | null = null; // Last fare used for client-side coloring private lastFare: bigint | null = null; + // Cached territory ownership along this railroad: which players own how many tiles. + private territoryOwners: Map< + Player, + { count: number; sampleTile: TileRef } + > | null = null; + private territoryDirty: boolean = true; constructor( public from: TrainStation, @@ -44,6 +50,93 @@ export class Railroad { this.updateCongestionEma(currentTick); } + /** + * Mark cached territory ownership as dirty; should be called when any tile owner + * along this railroad changes. + */ + markTerritoryDirty(): void { + this.territoryDirty = true; + } + + /** + * Lazily (re)compute which players own tiles under this railroad. + */ + private ensureTerritoryOwners( + game: Game, + ): Map { + if (!this.territoryDirty && this.territoryOwners) { + return this.territoryOwners; + } + + const owners = new Map(); + + for (const tile of this.tiles) { + const ownerOrNull = game.owner(tile); + if (ownerOrNull && ownerOrNull.isPlayer()) { + const owner = ownerOrNull as Player; + const existing = owners.get(owner); + if (existing) { + existing.count += 1; + } else { + owners.set(owner, { count: 1, sampleTile: tile }); + } + } + } + + this.territoryOwners = owners; + this.territoryDirty = false; + return owners; + } + + /** + * Distribute a 20% share of the given fare to territory owners along this railroad, + * proportional to the number of tiles they own under the track. + */ + distributeFareShare(game: Game, fare: bigint): void { + if (fare <= 0n) return; + + const profitShare = fare / 5n; // 20% + if (profitShare <= 0n) return; + + const owners = this.ensureTerritoryOwners(game); + if (owners.size === 0) return; + + let totalTiles = 0; + owners.forEach((entry) => { + totalTiles += entry.count; + }); + if (totalTiles <= 0) return; + + const totalTilesBig = BigInt(totalTiles); + let distributed = 0n; + + const entries = Array.from(owners.entries()); + entries.forEach(([owner, { count, sampleTile }], index) => { + let share: bigint; + if (index === entries.length - 1) { + // Last owner gets the remaining share to avoid rounding loss. + share = profitShare - distributed; + } else { + share = (profitShare * BigInt(count)) / totalTilesBig; + distributed += share; + } + if (share > 0n) { + owner.addGold(share, sampleTile); + } + }); + } + + /** + * Return true if there is exactly one territory owner along this railroad + * and that owner is the given player. + */ + isSoleTerritoryOwner(game: Game, player: Player): boolean { + const owners = this.ensureTerritoryOwners(game); + if (owners.size !== 1) return false; + const [onlyOwner] = owners.keys(); + return onlyOwner === player; + } + private updateCongestionEma(currentTick: Tick): void { if (this.lastCongestionTick === null) { this.lastCongestionTick = currentTick;