Merge branch 'feature/train-station-demand' into trains-borders

This commit is contained in:
scamiv
2025-11-22 17:35:04 +01:00
4 changed files with 118 additions and 18 deletions
+5
View File
@@ -144,7 +144,12 @@ export interface Config {
numPlayerPorts: number,
numPlayerTradeShips: number,
): number;
// Maximum gold a full train can earn at a level-1 station, per relation.
// Actual payout scales with station level and current passenger demand.
trainGold(rel: "self" | "team" | "ally" | "other"): Gold;
// Number of ticks it takes for an empty station to fully refill its
// passenger / demand pool (0 -> 100%).
trainGoldRefillTime(): Tick;
trainSpawnRate(numPlayerFactories: number): number;
trainStationMinRange(): number;
trainStationMaxRange(): number;
+4
View File
@@ -369,6 +369,10 @@ export class DefaultConfig implements Config {
return 10_000n;
}
}
trainGoldRefillTime(): Tick {
// Baseline: full refill in 60 ticks
return 60;
}
trainStationMinRange(): number {
return 15;
+105 -16
View File
@@ -1,7 +1,7 @@
import { TrainExecution } from "../execution/TrainExecution";
import { GraphAdapter } from "../pathfinding/SerialAStar";
import { PseudoRandom } from "../PseudoRandom";
import { Game, Player, Unit, UnitType } from "./Game";
import { Game, Gold, Player, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
import { Railroad } from "./Railroad";
@@ -58,11 +58,12 @@ export interface EdgeMetrics {
}
/**
* Station traffic and congestion data
* Station traffic data (train counts, legacy heat field kept for stats only).
* Routing decisions now use passenger demand instead of heat.
*/
export interface StationTraffic {
trainCount: number; // Current number of trains at station
heat: number; // Congestion heat (unbounded, decays over time)
heat: number; // Legacy congestion heat (no longer used for routing)
lastHeatUpdate: number;
}
@@ -85,12 +86,21 @@ class CityStopHandler implements TrainStopHandler {
): void {
const stationOwner = station.unit.owner();
const trainOwner = trainExecution.owner();
const goldBonus = mg.config().trainGold(rel(trainOwner, stationOwner));
const relation = rel(trainOwner, stationOwner);
const perLevelMax = mg.config().trainGold(relation);
const level = station.unit.level();
const maxGoldForThisTrain = perLevelMax * BigInt(level);
const payout = station.consumePassengerPool(maxGoldForThisTrain);
if (payout === 0n) {
return;
}
// Share revenue with the station owner if it's not the current player
if (trainOwner !== stationOwner) {
stationOwner.addGold(goldBonus, station.tile());
stationOwner.addGold(payout, station.tile());
}
trainOwner.addGold(goldBonus, station.tile());
trainOwner.addGold(payout, station.tile());
}
}
@@ -103,12 +113,21 @@ class PortStopHandler implements TrainStopHandler {
): void {
const stationOwner = station.unit.owner();
const trainOwner = trainExecution.owner();
const goldBonus = mg.config().trainGold(rel(trainOwner, stationOwner));
const relation = rel(trainOwner, stationOwner);
const perLevelMax = mg.config().trainGold(relation);
const level = station.unit.level();
const maxGoldForThisTrain = perLevelMax * BigInt(level);
trainOwner.addGold(goldBonus, station.tile());
const payout = station.consumePassengerPool(maxGoldForThisTrain);
if (payout === 0n) {
return;
}
// Train owner always gets the payout
trainOwner.addGold(payout, station.tile());
// Share revenue with the station owner if it's not the current player
if (trainOwner !== stationOwner) {
stationOwner.addGold(goldBonus, station.tile());
stationOwner.addGold(payout, station.tile());
}
}
}
@@ -169,7 +188,7 @@ export class TrainStation {
private readonly profitSensitivity: number = 0.3; // How much profit-per-distance boosts scores
private readonly distanceSensitivity: number = 0.2; // How much distance increases duration penalties
private readonly stationHeatSensitivity: number = 0.4; // How much station heat reduces scores
private readonly stationDemandSensitivity: number = 0.1; // How strongly passenger demand boosts scores
private readonly heatDecayInterval: number = 60; // How often heat decays (ticks)
private readonly heatDecayFactor: number = 1 - 0.1; // How much heat decays per time (0.95 = 5% decay)
private readonly recencyDecayFactor: number = 1 - 0.2; // How much recency penalties decay per time (0.8 = 20% decay)
@@ -180,6 +199,11 @@ export class TrainStation {
// Pre-computed decay factors for performance (avoid Math.pow in hot path)
private readonly recencyDecayPowers: number[];
// 01 scalar representing how "full" the station is with paying passengers.
private passengerFullness: number = 1;
// Last tick at which we updated passengerFullness.
private lastPassengerUpdateTick: number;
constructor(
private mg: Game,
public unit: Unit,
@@ -216,6 +240,10 @@ export class TrainStation {
this.recencyDecayPowers[i] =
this.recencyDecayPowers[i - 1] * this.recencyDecayFactor;
}
// Initialize passenger demand tracking
this.passengerFullness = 1;
this.lastPassengerUpdateTick = mg.ticks();
}
tradeAvailable(otherPlayer: Player): boolean {
@@ -561,7 +589,7 @@ export class TrainStation {
edge: EdgeMetrics,
stationsAgo: number, // -1 = never visited, 1 = immediate previous, 2 = 2 ago, etc.
actualProfit: number,
neighborTrafficHeat: number, // Heat factor of the neighbor station
neighborDemandScore: number, // Demand score of the neighbor station
): number {
// Base score: profit per time unit, boosted by profit-per-distance
const profitPerDistance = actualProfit / edge.distance;
@@ -582,8 +610,8 @@ export class TrainStation {
score *= recencyPenalty;
}
// Apply station heat avoidance
score *= 1 - this.stationHeatSensitivity * neighborTrafficHeat;
// Apply station demand preference (higher demand => higher score)
score *= 1 + this.stationDemandSensitivity * neighborDemandScore;
// Ensure unvisited stations get a minimum exploration score
// This prevents zero-profit unvisited stations(factories) from being ignored
@@ -700,12 +728,12 @@ export class TrainStation {
const actualProfit = this.calculateActualProfit(trainOwner, neighbor);
const stationsAgo = this.getStationsAgo(neighbor, recentStations);
const neighborTrafficHeat = neighbor.getTraffic().heat;
const neighborDemandScore = neighbor.getPassengerDemandScore();
const score = this.calculateEdgeScore(
edge,
stationsAgo,
actualProfit,
neighborTrafficHeat,
neighborDemandScore,
);
validNeighbors.push({ station: neighbor, score });
@@ -798,6 +826,63 @@ export class TrainStation {
}
}
/**
* Lazily regenerate the passenger pool based on elapsed ticks.
*/
private updatePassengerPool() {
const now = this.mg.ticks();
const dt = now - this.lastPassengerUpdateTick;
if (dt <= 0) {
return;
}
this.lastPassengerUpdateTick = now;
const refillTime = this.mg.config().trainGoldRefillTime();
if (refillTime <= 0) {
this.passengerFullness = 1;
return;
}
this.passengerFullness = Math.min(
1,
this.passengerFullness + dt / refillTime,
);
}
/**
* Public view for UI / analytics: how strong is demand right now?
*/
getPassengerDemandScore(): number {
this.updatePassengerPool();
return this.passengerFullness * this.unit.level();
}
/**
* Convert current passenger pool into an actual gold payout, then
* deplete the pool proportionally.
*/
consumePassengerPool(maxGoldForThisTrain: Gold): Gold {
this.updatePassengerPool();
const maxGoldNum = Number(maxGoldForThisTrain);
if (maxGoldNum <= 0) {
return 0n;
}
const payoutNum = Math.floor(maxGoldNum * this.passengerFullness);
const payout = BigInt(payoutNum);
if (payoutNum > 0) {
this.passengerFullness -= payoutNum / maxGoldNum;
if (this.passengerFullness < 0) {
this.passengerFullness = 0;
}
}
return payout;
}
onTrainStop(trainExecution: TrainExecution) {
// Update traffic - train has arrived
this.onTrainArrival(trainExecution);
@@ -898,7 +983,11 @@ export class TrainStationMapAdapter implements GraphAdapter<TrainStation> {
}
cost(node: TrainStation): number {
return 1;
// Favor higher-demand stations slightly by reducing their traversal cost.
const demand = node.getPassengerDemandScore(); // ~0..level
const baseCost = 1;
const alpha = 0.25; // tuning knob
return baseCost / (1 + alpha * demand);
}
position(node: TrainStation): { x: number; y: number } {
+4 -2
View File
@@ -16,8 +16,8 @@ describe("TrainStation", () => {
game = {
ticks: jest.fn().mockReturnValue(123),
config: jest.fn().mockReturnValue({
trainGold: (isFriendly: boolean) =>
isFriendly ? BigInt(1000) : BigInt(500),
trainGold: () => 1000n,
trainGoldRefillTime: () => 60,
}),
addUpdate: jest.fn(),
addExecution: jest.fn(),
@@ -30,6 +30,8 @@ describe("TrainStation", () => {
id: 1,
canTrade: jest.fn().mockReturnValue(true),
isFriendly: jest.fn().mockReturnValue(false),
isOnSameTeam: jest.fn().mockReturnValue(false),
isAlliedWith: jest.fn().mockReturnValue(false),
} as any;
unit = {