From 477704d768009fb4e70dd872ad994531274c4c02 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:07:16 +0100 Subject: [PATCH 1/2] add station passenger demand and time-based payouts - Interpret trainGold as per-level max gold and add trainGoldRefillTime with a 60-tick full refill baseline - Add per-station passenger pool with lazy tick-based refill and proportional depletion on train arrival - Make city and port train payouts depend on station level, owner relation, and current passenger demand instead of flat values - Expose getPassengerDemandScore for future logic - Update TrainStation tests for the new config and payout behavior --- src/core/configuration/Config.ts | 5 ++ src/core/configuration/DefaultConfig.ts | 4 ++ src/core/game/TrainStation.ts | 96 +++++++++++++++++++++++-- tests/core/game/TrainStation.test.ts | 6 +- 4 files changed, 102 insertions(+), 9 deletions(-) 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 9318fab25..ee81a5e8e 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"; @@ -25,12 +25,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()); } } @@ -43,12 +52,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()); } } } @@ -79,11 +97,18 @@ export class TrainStation { // Quick lookup from neighboring station to connecting railroad private railroadByNeighbor: Map = new Map(); + // 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, ) { this.stopHandlers = createTrainStopHandlers(new PseudoRandom(mg.ticks())); + this.passengerFullness = 1; + this.lastPassengerUpdateTick = mg.ticks(); } tradeAvailable(otherPlayer: Player): boolean { @@ -162,6 +187,63 @@ export class TrainStation { return this.cluster; } + /** + * 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) { const type = this.unit.type(); const handler = this.stopHandlers[type]; diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts index f99847ee2..cc4ed4ed7 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(), @@ -28,6 +28,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 = { From 40b0fe990acbb676889b781cdccd4b4a8a92efb3 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:10:07 +0100 Subject: [PATCH 2/2] =?UTF-8?q?TrainStationMapAdapter.cost(node)=20uses=20?= =?UTF-8?q?node.getPassengerDemandScore()=20so=20higher=20passenger=20dema?= =?UTF-8?q?nd=20and=20level=20=E2=86=92=20slightly=20lower=20traversal=20c?= =?UTF-8?q?ost:=20const=20demand=20=3D=20node.getPassengerDemandScore();?= =?UTF-8?q?=20return=201=20/=20(1=20+=200.25=20*=20demand);?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/game/TrainStation.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index ee81a5e8e..d20fbdab4 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -264,7 +264,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 } {