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
This commit is contained in:
Evan
2026-03-10 20:16:47 -07:00
committed by GitHub
parent 97767fa364
commit 3e65d08942
7 changed files with 136 additions and 17 deletions
+4 -1
View File
@@ -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;
+14 -8
View File
@@ -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 {
+9
View File
@@ -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;
}
@@ -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,
+6 -1
View File
@@ -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());
+1 -1
View File
@@ -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) }),
+95 -3
View File
@@ -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);
});
});