From 3e65d08942c5357c7071301ae8e424d5d475562a Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 10 Mar 2026 20:16:47 -0700 Subject: [PATCH] reduce train gold after each city (#3400) ## Description: Now that cities snap to existing rails, it's possible to line up dozens of cities in a row, producing way too much gold. This PR reduces the gold after each stop to prevent that. Gold only starts decreasing after the 3rd city. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/core/configuration/Config.ts | 5 +- src/core/configuration/DefaultConfig.ts | 22 +++-- src/core/execution/TrainExecution.ts | 9 ++ .../nation/NationStructureBehavior.ts | 10 +- src/core/game/TrainStation.ts | 7 +- tests/NationStructureBehavior.test.ts | 2 +- tests/core/game/TrainStation.test.ts | 98 ++++++++++++++++++- 7 files changed, 136 insertions(+), 17 deletions(-) diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 5a7a90c30..990da255b 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -132,7 +132,10 @@ export interface Config { tradeShipSpawnRejections: number, numTradeShips: number, ): number; - trainGold(rel: "self" | "team" | "ally" | "other"): Gold; + trainGold( + rel: "self" | "team" | "ally" | "other", + citiesVisited: number, + ): Gold; trainSpawnRate(numPlayerFactories: number): number; trainStationMinRange(): number; trainStationMaxRange(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 76083858f..4ad8b2e33 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -20,7 +20,7 @@ import { PlayerView } from "../game/GameView"; import { UserSettings } from "../game/UserSettings"; import { GameConfig, GameID, TeamCountConfig } from "../Schemas"; import { NukeType } from "../StatsSchemas"; -import { assertNever, sigmoid, simpleHash, within } from "../Util"; +import { assertNever, sigmoid, simpleHash, toInt, within } from "../Util"; import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config"; import { Env } from "./Env"; import { PastelTheme } from "./PastelTheme"; @@ -273,22 +273,28 @@ export class DefaultConfig implements Config { // expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories) return (numPlayerFactories + 10) * 18; } - trainGold(rel: "self" | "team" | "ally" | "other"): Gold { - const multiplier = this.goldMultiplier(); - let baseGold: bigint; + trainGold( + rel: "self" | "team" | "ally" | "other", + citiesVisited: number, + ): Gold { + // No penalty for the first 3 cities. + citiesVisited = Math.max(0, citiesVisited - 2); + let baseGold: number; switch (rel) { case "ally": - baseGold = 35_000n; + baseGold = 35_000; break; case "team": case "other": - baseGold = 25_000n; + baseGold = 25_000; break; case "self": - baseGold = 10_000n; + baseGold = 10_000; break; } - return BigInt(Math.floor(Number(baseGold) * multiplier)); + const distPenalty = citiesVisited * 5_000; + const gold = Math.max(5000, baseGold - distPenalty); + return toInt(gold * this.goldMultiplier()); } trainStationMinRange(): number { diff --git a/src/core/execution/TrainExecution.ts b/src/core/execution/TrainExecution.ts index 949c6dffc..4bf0b860d 100644 --- a/src/core/execution/TrainExecution.ts +++ b/src/core/execution/TrainExecution.ts @@ -24,6 +24,7 @@ export class TrainExecution implements Execution { private stations: TrainStation[] = []; private currentRailroad: OrientedRailroad | null = null; private speed: number = 2; + private _tradeStopsVisited: number = 0; constructor( private railNetwork: RailNetwork, @@ -37,6 +38,10 @@ export class TrainExecution implements Execution { return this.player; } + public tradeStopsVisited(): number { + return this._tradeStopsVisited; + } + init(mg: Game, ticks: number): void { this.mg = mg; const stations = this.railNetwork.findStationsPath( @@ -261,6 +266,10 @@ export class TrainExecution implements Execution { throw new Error("Not initialized"); } this.stations[1].onTrainStop(this); + const stationType = this.stations[1].unit.type(); + if (stationType === UnitType.City || stationType === UnitType.Port) { + this._tradeStopsVisited++; + } return; } diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index 1e9478803..fe9698abd 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -697,7 +697,10 @@ export class NationStructureBehavior { unitToCluster.set(station.unit, station.getCluster()); } - const maxTradeGold = Math.max(Number(game.config().trainGold("ally")), 1); + const maxTradeGold = Math.max( + Number(game.config().trainGold("ally", 0)), + 1, + ); const result: Array<{ tile: TileRef; cluster: Cluster | null; @@ -705,7 +708,8 @@ export class NationStructureBehavior { }> = []; // Own structures — weighted by "self" trade gold. - const selfWeight = Number(game.config().trainGold("self")) / maxTradeGold; + const selfWeight = + Number(game.config().trainGold("self", 0)) / maxTradeGold; for (const unit of player.units( UnitType.City, UnitType.Port, @@ -730,7 +734,7 @@ export class NationStructureBehavior { : player.isAlliedWith(neighbor) ? "ally" : "other"; - const weight = Number(game.config().trainGold(relType)) / maxTradeGold; + const weight = Number(game.config().trainGold(relType, 0)) / maxTradeGold; for (const unit of neighbor.units( UnitType.City, UnitType.Port, diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index e2b687a6f..8f64a0c69 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -20,7 +20,12 @@ class TradeStationStopHandler implements TrainStopHandler { ): void { const stationOwner = station.unit.owner(); const trainOwner = trainExecution.owner(); - const gold = mg.config().trainGold(rel(trainOwner, stationOwner)); + const gold = mg + .config() + .trainGold( + rel(trainOwner, stationOwner), + trainExecution.tradeStopsVisited(), + ); // Share revenue with the station owner if it's not the current player if (trainOwner !== stationOwner) { stationOwner.addGold(gold, station.tile()); diff --git a/tests/NationStructureBehavior.test.ts b/tests/NationStructureBehavior.test.ts index e9862c49b..7f0632c40 100644 --- a/tests/NationStructureBehavior.test.ts +++ b/tests/NationStructureBehavior.test.ts @@ -28,7 +28,7 @@ function makeStation(unit: any, cluster: Cluster | null = null): any { function makeGame(stations: any[] = []): any { return { config: () => ({ - trainGold: (rel: string) => TRAIN_GOLD[rel] ?? 0n, + trainGold: (rel: string, _citiesVisited: number) => TRAIN_GOLD[rel] ?? 0n, }), railNetwork: () => ({ stationManager: () => ({ getAll: () => new Set(stations) }), diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts index ba6329145..65ba4c98a 100644 --- a/tests/core/game/TrainStation.test.ts +++ b/tests/core/game/TrainStation.test.ts @@ -1,8 +1,22 @@ import { GameUpdateType } from "src/core/game/GameUpdates"; import { vi, type Mocked } from "vitest"; +import { DefaultConfig } from "../../../src/core/configuration/DefaultConfig"; import { TrainExecution } from "../../../src/core/execution/TrainExecution"; -import { Game, Player, Unit, UnitType } from "../../../src/core/game/Game"; +import { + Difficulty, + Game, + GameMapSize, + GameMapType, + GameMode, + GameType, + Player, + Unit, + UnitType, +} from "../../../src/core/game/Game"; import { Cluster, TrainStation } from "../../../src/core/game/TrainStation"; +import { UserSettings } from "../../../src/core/game/UserSettings"; +import { GameConfig } from "../../../src/core/Schemas"; +import { TestServerConfig } from "../../util/TestServerConfig"; vi.mock("../../../src/core/game/Game"); vi.mock("../../../src/core/execution/TrainExecution"); @@ -18,8 +32,8 @@ describe("TrainStation", () => { game = { ticks: vi.fn().mockReturnValue(123), config: vi.fn().mockReturnValue({ - trainGold: (isFriendly: boolean) => - isFriendly ? BigInt(1000) : BigInt(500), + trainGold: (rel: string, _tradeStopsVisited: number) => + rel !== "other" ? BigInt(1000) : BigInt(500), }), addUpdate: vi.fn(), addExecution: vi.fn(), @@ -48,6 +62,7 @@ describe("TrainStation", () => { loadCargo: vi.fn(), owner: vi.fn().mockReturnValue(player), level: vi.fn(), + tradeStopsVisited: vi.fn().mockReturnValue(0), } as any; }); @@ -74,6 +89,20 @@ describe("TrainStation", () => { ); }); + it("passes tradeStopsVisited to trainGold", () => { + unit.type.mockReturnValue(UnitType.City); + const trainGoldSpy = vi.fn().mockReturnValue(500n); + (game.config as any).mockReturnValue({ + trainGold: trainGoldSpy, + }); + (trainExecution as any).tradeStopsVisited = vi.fn().mockReturnValue(3); + const station = new TrainStation(game, unit); + + station.onTrainStop(trainExecution); + + expect(trainGoldSpy).toHaveBeenCalledWith(expect.any(String), 3); + }); + it("checks trade availability (same owner)", () => { const otherUnit = { owner: vi.fn().mockReturnValue(unit.owner()), @@ -133,3 +162,66 @@ describe("TrainStation", () => { expect(station.isActive()).toBe(true); }); }); + +describe("DefaultConfig.trainGold trade stop penalty", () => { + let config: DefaultConfig; + + beforeEach(() => { + const serverConfig = new TestServerConfig(); + const gameConfig: GameConfig = { + gameMap: GameMapType.Asia, + gameMapSize: GameMapSize.Normal, + gameMode: GameMode.FFA, + gameType: GameType.Singleplayer, + difficulty: Difficulty.Medium, + nations: "default", + donateGold: false, + donateTroops: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + disableNavMesh: false, + randomSpawn: false, + }; + config = new DefaultConfig( + serverConfig, + gameConfig, + new UserSettings(), + false, + ); + }); + + it("returns full base gold within free window (stops 0-2)", () => { + // first 3 stops (0, 1, 2) are free — no penalty + expect(config.trainGold("self", 0)).toBe(10_000n); + expect(config.trainGold("self", 1)).toBe(10_000n); + expect(config.trainGold("self", 2)).toBe(10_000n); + }); + + it("reduces gold by 5k per stop after the free window", () => { + // stop 3: effective = 3-2 = 1 -> 10k - 5k = 5k + expect(config.trainGold("self", 3)).toBe(5_000n); + }); + + it("floors at 5k when penalty exceeds base gold", () => { + // stop 5: effective = 3 -> 10k - 15k -> floor at 5k + expect(config.trainGold("self", 5)).toBe(5_000n); + }); + + it("floors at 5k for ally base even with heavy penalty", () => { + // ally base 35k, stop 20: effective = 18 -> penalty 90k -> floor at 5k + expect(config.trainGold("ally", 20)).toBe(5_000n); + }); + + it("ally base gold reduces correctly after free window", () => { + // ally base 35k, stop 4: effective = 2 -> 35k - 10k = 25k + expect(config.trainGold("ally", 4)).toBe(25_000n); + }); + + it("other/team base gold reduces correctly after free window", () => { + // other base 25k, stop 3: effective = 1 -> 25k - 5k = 20k + expect(config.trainGold("other", 3)).toBe(20_000n); + expect(config.trainGold("team", 3)).toBe(20_000n); + }); +});