experiment: distribute part of track profits

This commit is contained in:
scamiv
2025-11-22 21:20:23 +01:00
parent 21c9594788
commit 38abed125d
5 changed files with 162 additions and 2 deletions
+24 -1
View File
@@ -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);
+4
View File
@@ -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) {
+4
View File
@@ -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;
}
+36
View File
@@ -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<TileRef, Set<Railroad>> = 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<Railroad>();
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);
+94 -1
View File
@@ -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<Player, { count: number; sampleTile: TileRef }> {
if (!this.territoryDirty && this.territoryOwners) {
return this.territoryOwners;
}
const owners = new Map<Player, { count: number; sampleTile: TileRef }>();
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;