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
This commit is contained in:
scamiv
2025-11-22 17:07:16 +01:00
parent 930a79e31c
commit 477704d768
4 changed files with 102 additions and 9 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;
+89 -7
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";
@@ -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<TrainStation, Railroad> = new Map();
// 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,
) {
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];
+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(),
@@ -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 = {