diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f1f1b03c6..375bc1e35 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -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; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 066a3e9ac..808ed67ba 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -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; diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index 3975ac48e..1ded80076 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -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[]; + // 0–1 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 { } 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 } { diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts index 987614f4d..6dc03e113 100644 --- a/tests/core/game/TrainStation.test.ts +++ b/tests/core/game/TrainStation.test.ts @@ -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 = {