diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 1026a6d35b..4743492f83 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -231,13 +231,31 @@ const OVERTIME_DEFAULTS = { export class Config { private unitInfoCache = new Map(); + private _factoryMultCache = new Float64Array(256); + private _stationMultCache = new Float64Array(256); + constructor( private _gameConfig: GameConfig, private _userSettings: UserSettings | null, private _isReplay: boolean, public readonly listed: boolean = false, private _spectator: boolean = false, - ) {} + ) { + for (let level = 1; level < 256; level++) { + this._factoryMultCache[level] = 1 + 0.25 * (1 - pow(0.85, level - 1)); + this._stationMultCache[level] = 1.0 + 0.4 * (log(level) * Math.LOG2E); + } + } + + factoryStackMultiplier(level: number): number { + if (level < 256) return this._factoryMultCache[level]; + return 1.25; + } + + stationStackMultiplier(level: number): number { + if (level < 256) return this._stationMultCache[level]; + return 1.0 + 0.4 * (log(level) * Math.LOG2E); + } isReplay(): boolean { return this._isReplay; @@ -412,6 +430,8 @@ export class Config { rel: "self" | "team" | "ally" | "other", citiesVisited: number, player: Player | PlayerView, + sourceLevel: number = 1, + stationLevel: number = 1, ): Gold { // No penalty for the first 10 cities. citiesVisited = Math.max(0, citiesVisited - 9); @@ -429,7 +449,10 @@ export class Config { break; } const distPenalty = citiesVisited * 5_000; - const gold = Math.max(5000, baseGold - distPenalty); + let gold = Math.max(5000, baseGold - distPenalty); + gold *= + this.factoryStackMultiplier(sourceLevel) * + this.stationStackMultiplier(stationLevel); return toInt(gold * this.goldMultiplierFor(player)); } diff --git a/src/core/execution/TrainExecution.ts b/src/core/execution/TrainExecution.ts index c37aa61446..17f7afab2e 100644 --- a/src/core/execution/TrainExecution.ts +++ b/src/core/execution/TrainExecution.ts @@ -44,6 +44,10 @@ export class TrainExecution implements Execution { return this._tradeStopsVisited; } + public sourceLevel(): number { + return this.source.unit.level(); + } + init(mg: Game, ticks: number): void { this.mg = mg; const stations = this.railNetwork.findStationsPath( diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index b7c33a0a40..36a48d8c64 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -1120,7 +1120,8 @@ export class NationStructureBehavior { result.push({ tile: unit.tile(), cluster: unitToCluster.get(unit)!, - weight: selfWeight, + weight: + selfWeight * game.config().stationStackMultiplier(unit.level()), }); } } @@ -1135,7 +1136,7 @@ export class NationStructureBehavior { : player.isAlliedWith(neighbor) ? "ally" : "other"; - const weight = + const baseWeight = Number(game.config().trainGold(relType, 0, player)) / maxTradeGold; for (const unit of neighbor.units( UnitType.City, @@ -1146,7 +1147,8 @@ export class NationStructureBehavior { result.push({ tile: unit.tile(), cluster: unitToCluster.get(unit)!, - weight, + weight: + baseWeight * game.config().stationStackMultiplier(unit.level()), }); } } diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index 823127f5f8..55a87cb71c 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -26,6 +26,8 @@ class TradeStationStopHandler implements TrainStopHandler { rel(trainOwner, stationOwner), trainExecution.tradeStopsVisited(), trainOwner, + trainExecution.sourceLevel(), + station.unit.level(), ); // Share revenue with the station owner if it's not the current player if (trainOwner !== stationOwner) { diff --git a/tests/NationStructureBehavior.test.ts b/tests/NationStructureBehavior.test.ts index 4612c249e7..871e6be7c7 100644 --- a/tests/NationStructureBehavior.test.ts +++ b/tests/NationStructureBehavior.test.ts @@ -18,8 +18,8 @@ const MAX_TRADE_GOLD = Number(TRAIN_GOLD.ally); // denominator // ── Factory helpers ────────────────────────────────────────────────────────── -function makeUnit(tile: number): any { - return { tile: () => tile }; +function makeUnit(tile: number, level: number = 1): any { + return { tile: () => tile, level: () => level }; } function makeStation(unit: any, cluster: Cluster | null = null): any { @@ -30,6 +30,8 @@ function makeGame(stations: any[] = []): any { return { config: () => ({ trainGold: (rel: string, _citiesVisited: number) => TRAIN_GOLD[rel] ?? 0n, + stationStackMultiplier: (level: number) => 5 * level, + factoryStackMultiplier: (level: number) => level, }), railNetwork: () => ({ stationManager: () => ({ getAll: () => new Set(stations) }), @@ -156,27 +158,62 @@ describe("NationStructureBehavior.buildReachableStations", () => { const unit = makeUnit(10); const station = makeStation(unit, cluster); const player = makePlayer([unit], []); - const behavior = makeBehavior(makeGame([station]), player); + const game = makeGame([station]); + const behavior = makeBehavior(game, player); const result = (behavior as any).buildReachableStations(); expect(result).toHaveLength(1); expect(result[0].tile).toBe(10); expect(result[0].cluster).toBe(cluster); - expect(result[0].weight).toBeCloseTo(selfWeight); + expect(result[0].weight).toBeCloseTo( + selfWeight * game.config().stationStackMultiplier(unit.level()), + ); + }); + + it("applies the station stack multiplier to both own and neighbor units based on level", () => { + const cluster = new Cluster(); + const ownUnit = makeUnit(20, 2); + const ownStation = makeStation(ownUnit, cluster); + + const neighborUnit = makeUnit(30, 2); + const neighborStation = makeStation(neighborUnit, cluster); + const neighbor = makeNeighbor({ units: [neighborUnit], isPlayer: true }); + const player = makePlayer([ownUnit], [neighbor], { + isAlliedWith: () => true, + }); + + const game = makeGame([ownStation, neighborStation]); + const behavior = makeBehavior(game, player); + + const result = (behavior as any).buildReachableStations(); + + expect(result).toHaveLength(2); + const ownRes = result.find((r: any) => r.tile === 20); + const neighborRes = result.find((r: any) => r.tile === 30); + + expect(ownRes.weight).toBeCloseTo( + selfWeight * game.config().stationStackMultiplier(ownUnit.level()), + ); + expect(neighborRes.weight).toBeCloseTo( + allyWeight * game.config().stationStackMultiplier(neighborUnit.level()), + ); }); it("assigns null cluster when own unit is a station with no cluster", () => { const unit = makeUnit(11); const station = makeStation(unit, null); const player = makePlayer([unit], []); - const behavior = makeBehavior(makeGame([station]), player); + const game = makeGame([station]); + const behavior = makeBehavior(game, player); const result = (behavior as any).buildReachableStations(); expect(result).toHaveLength(1); expect(result[0].cluster).toBeNull(); - expect(result[0].weight).toBeCloseTo(selfWeight); + expect(result[0].weight).toBeCloseTo( + selfWeight * game.config().stationStackMultiplier(unit.level()), + ); }); it("excludes own units not registered in the station manager", () => { @@ -236,14 +273,17 @@ describe("NationStructureBehavior.buildReachableStations", () => { isOnSameTeam: () => false, isAlliedWith: () => false, }); - const behavior = makeBehavior(makeGame([station]), player); + const game = makeGame([station]); + const behavior = makeBehavior(game, player); const result = (behavior as any).buildReachableStations(); expect(result).toHaveLength(1); expect(result[0].tile).toBe(60); expect(result[0].cluster).toBe(cluster); - expect(result[0].weight).toBeCloseTo(otherWeight); + expect(result[0].weight).toBeCloseTo( + otherWeight * game.config().stationStackMultiplier(unit.level()), + ); }); it("uses 'ally' weight for allied neighbor", () => { @@ -255,12 +295,15 @@ describe("NationStructureBehavior.buildReachableStations", () => { isOnSameTeam: () => false, isAlliedWith: (n) => n === neighbor, }); - const behavior = makeBehavior(makeGame([station]), player); + const game = makeGame([station]); + const behavior = makeBehavior(game, player); const result = (behavior as any).buildReachableStations(); expect(result).toHaveLength(1); - expect(result[0].weight).toBeCloseTo(allyWeight); + expect(result[0].weight).toBeCloseTo( + allyWeight * game.config().stationStackMultiplier(unit.level()), + ); }); it("uses 'team' weight for team neighbor (team check precedes ally)", () => { @@ -272,12 +315,15 @@ describe("NationStructureBehavior.buildReachableStations", () => { isOnSameTeam: (n) => n === neighbor, isAlliedWith: () => false, }); - const behavior = makeBehavior(makeGame([station]), player); + const game = makeGame([station]); + const behavior = makeBehavior(game, player); const result = (behavior as any).buildReachableStations(); expect(result).toHaveLength(1); - expect(result[0].weight).toBeCloseTo(teamWeight); + expect(result[0].weight).toBeCloseTo( + teamWeight * game.config().stationStackMultiplier(unit.level()), + ); }); it("excludes neighbor units not registered in the station manager", () => { diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts index 99b9e46736..b083713188 100644 --- a/tests/core/game/TrainStation.test.ts +++ b/tests/core/game/TrainStation.test.ts @@ -1,5 +1,5 @@ import { GameUpdateType } from "src/core/game/GameUpdates"; -import { vi, type Mocked } from "vitest"; +import { vi } from "vitest"; import { Config } from "../../../src/core/configuration/Config"; import { TrainExecution } from "../../../src/core/execution/TrainExecution"; import { @@ -10,154 +10,154 @@ import { GameMode, GameType, Player, + PlayerType, 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"; - -vi.mock("../../../src/core/game/Game"); -vi.mock("../../../src/core/execution/TrainExecution"); -vi.mock("../../../src/core/PseudoRandom"); +import { playerInfo, setup } from "../../util/Setup"; +import { TestConfig } from "../../util/TestConfig"; + +class WiringTestConfig extends TestConfig { + factoryStackMultiplier(level: number) { + return level; + } + stationStackMultiplier(level: number) { + return level; + } +} describe("TrainStation", () => { - let game: Mocked; - let gameStats: { - trainExternalTrade: ReturnType; - trainSelfTrade: ReturnType; - }; - let unit: Mocked; - let player: Mocked; - let trainExecution: Mocked; - - beforeEach(() => { - gameStats = { - trainExternalTrade: vi.fn(), - trainSelfTrade: vi.fn(), - }; - game = { - ticks: vi.fn().mockReturnValue(123), - config: vi.fn().mockReturnValue({ - trainGold: (rel: string, _tradeStopsVisited: number) => - rel !== "other" ? BigInt(1000) : BigInt(500), - }), - addUpdate: vi.fn(), - addExecution: vi.fn(), - stats: vi.fn().mockReturnValue(gameStats), - } as any; - - player = { - addGold: vi.fn(), - addTrainGold: vi.fn(), - id: 1, - canTrade: vi.fn().mockReturnValue(true), - isAlliedWith: vi.fn().mockReturnValue(false), - isOnSameTeam: vi.fn().mockReturnValue(false), - isFriendly: vi.fn().mockReturnValue(false), - } as any; - - unit = { - owner: vi.fn().mockReturnValue(player), - level: vi.fn().mockReturnValue(1), - tile: vi.fn().mockReturnValue({ x: 0, y: 0 }), - type: vi.fn(), - isActive: vi.fn().mockReturnValue(true), - } as any; + let game: Game; + let unit: Unit; + let player: Player; + let trainExecution: TrainExecution; + + beforeEach(async () => { + game = await setup( + "plains", + {}, + [ + playerInfo("one", PlayerType.Human), + playerInfo("two", PlayerType.Human), + ], + undefined, + WiringTestConfig, + ); - trainExecution = { - loadCargo: vi.fn(), - owner: vi.fn().mockReturnValue(player), - level: vi.fn(), - tradeStopsVisited: vi.fn().mockReturnValue(0), - } as any; + player = game.player("one")!; + const tile = game.ref(5, 5); + unit = player.buildUnit(UnitType.City, tile, {}); + + const destTile = game.ref(10, 10); + const destUnit = player.buildUnit(UnitType.City, destTile, {}); + + const sourceStation = new TrainStation(game, unit); + const destStation = new TrainStation(game, destUnit); + trainExecution = new TrainExecution( + game.railNetwork(), + player, + sourceStation, + destStation, + 1, + ); }); it("handles City stop", () => { - unit.type.mockReturnValue(UnitType.City); const station = new TrainStation(game, unit); + const goldBefore = player.gold(); station.onTrainStop(trainExecution); - expect(unit.owner().addGold).toHaveBeenCalledWith(1000n, unit.tile()); + // baseGold for self is 10_000n. Stack multiplier is 1 * 1 = 1. + expect(player.gold() - goldBefore).toBe(10_000n); }); it("handles allied trade", () => { - unit.type.mockReturnValue(UnitType.City); - player.isFriendly.mockReturnValue(true); + const ally = game.player("two")!; + vi.spyOn(player, "isFriendly").mockReturnValue(true); + vi.spyOn(player, "isAlliedWith").mockReturnValue(true); + + vi.spyOn(unit, "owner").mockReturnValue(ally); + const station = new TrainStation(game, unit); + const allyGoldBefore = ally.gold(); + const playerGoldBefore = player.gold(); station.onTrainStop(trainExecution); - expect(unit.owner().addGold).toHaveBeenCalledWith(1000n, unit.tile()); - expect(trainExecution.owner().addGold).toHaveBeenCalledWith( - 1000n, - unit.tile(), - ); + // baseGold for ally is 35_000n. + expect(ally.gold() - allyGoldBefore).toBe(35_000n); + expect(player.gold() - playerGoldBefore).toBe(35_000n); }); it("records external trade on the station owner", () => { - const stationOwner = { - addGold: vi.fn(), - addTrainGold: vi.fn(), - id: 1, - canTrade: vi.fn().mockReturnValue(true), - isAlliedWith: vi.fn().mockReturnValue(false), - isOnSameTeam: vi.fn().mockReturnValue(false), - } as any; - const trainOwner = { - addGold: vi.fn(), - addTrainGold: vi.fn(), - id: 2, - canTrade: vi.fn().mockReturnValue(true), - isAlliedWith: vi.fn().mockReturnValue(false), - isOnSameTeam: vi.fn().mockReturnValue(false), - } as any; + const stationOwner = game.player("two")!; + vi.spyOn(unit, "owner").mockReturnValue(stationOwner); - unit.type.mockReturnValue(UnitType.City); - unit.owner.mockReturnValue(stationOwner); - trainExecution.owner.mockReturnValue(trainOwner); - const station = new TrainStation(game, unit); + const trainExternalTradeSpy = vi.spyOn(game.stats(), "trainExternalTrade"); + const trainSelfTradeSpy = vi.spyOn(game.stats(), "trainSelfTrade"); + const station = new TrainStation(game, unit); station.onTrainStop(trainExecution); - expect(stationOwner.addGold).toHaveBeenCalledWith(500n, unit.tile()); - expect(trainOwner.addGold).toHaveBeenCalledWith(500n, unit.tile()); - expect(stationOwner.addTrainGold).toHaveBeenCalledWith(500n); - expect(trainOwner.addTrainGold).toHaveBeenCalledWith(500n); - expect(gameStats.trainExternalTrade).toHaveBeenCalledWith( - stationOwner, - 500n, + // baseGold for other/team is 25_000n. + expect(trainExternalTradeSpy).toHaveBeenCalledWith(stationOwner, 25_000n); + expect(trainSelfTradeSpy).toHaveBeenCalledWith(player, 25_000n); + }); + + it("passes exact source and station levels through the simulation to trainGold", () => { + // 1. Create a Level 2 Factory (Source) + const factoryTile = game.ref(15, 15); + const factory = player.buildUnit(UnitType.Factory, factoryTile, {}); + factory.increaseLevel(); + expect(factory.level()).toBe(2); + + // 2. Create a Level 3 City (Destination) + const cityTile = game.ref(25, 25); + const city = player.buildUnit(UnitType.City, cityTile, {}); + city.increaseLevel(); + city.increaseLevel(); + expect(city.level()).toBe(3); + + // 3. Instantiate true TrainStations and TrainExecution + const factoryStation = new TrainStation(game, factory); + const cityStation = new TrainStation(game, city); + + const trainExec = new TrainExecution( + game.railNetwork(), + player, + factoryStation, + cityStation, + 1, ); - expect(gameStats.trainSelfTrade).toHaveBeenCalledWith(trainOwner, 500n); + + // 4. Trigger the stop and measure exact gold output + const goldBefore = player.gold(); + cityStation.onTrainStop(trainExec); + const goldEarned = player.gold() - goldBefore; + + // Wiring Verification: base 10k * factoryLevel(2) * cityLevel(3) + expect(goldEarned).toBe(60_000n); }); - 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); + it("passes tradeStopsVisited to trainGold through distance penalty", () => { + vi.spyOn(trainExecution, "tradeStopsVisited").mockReturnValue(10); // 10 cities visited = penalty of 5k (1 stop over free window) + const station = new TrainStation(game, unit); + const goldBefore = player.gold(); station.onTrainStop(trainExecution); - expect(trainGoldSpy).toHaveBeenCalledWith( - expect.any(String), - 3, - expect.anything(), - ); + // baseGold 10k - 5k penalty = 5k. + expect(player.gold() - goldBefore).toBe(5_000n); }); it("checks trade availability (same owner)", () => { - const otherUnit = { - owner: vi.fn().mockReturnValue(unit.owner()), - } as any; - const station = new TrainStation(game, unit); - const otherStation = new TrainStation(game, otherUnit); - + const otherStation = new TrainStation(game, unit); expect(station.tradeAvailable(otherStation.unit.owner())).toBe(true); }); @@ -168,14 +168,12 @@ describe("TrainStation", () => { stationA.addRailroad(railRoad); - const neighbors = stationA.neighbors(); - expect(neighbors).toContain(stationB); + expect(stationA.neighbors()).toContain(stationB); }); it("removes neighboring rail", () => { const stationA = new TrainStation(game, unit); const stationB = new TrainStation(game, unit); - const railRoad = { from: stationA, to: stationB, @@ -185,9 +183,10 @@ describe("TrainStation", () => { stationA.addRailroad(railRoad); expect(stationA.getRailroads().size).toBe(1); + const addUpdateSpy = vi.spyOn(game, "addUpdate"); stationA.removeNeighboringRails(stationB); - expect(game.addUpdate).toHaveBeenCalledWith( + expect(addUpdateSpy).toHaveBeenCalledWith( expect.objectContaining({ type: GameUpdateType.RailroadDestructionEvent, }), @@ -196,7 +195,7 @@ describe("TrainStation", () => { }); it("assigns and retrieves cluster", () => { - const cluster: Cluster = {} as Cluster; + const cluster = {} as Cluster; const station = new TrainStation(game, unit); station.setCluster(cluster); @@ -205,7 +204,7 @@ describe("TrainStation", () => { it("returns tile and active status", () => { const station = new TrainStation(game, unit); - expect(station.tile()).toEqual({ x: 0, y: 0 }); + expect(station.tile()).toEqual(unit.tile()); expect(station.isActive()).toBe(true); }); }); @@ -236,33 +235,27 @@ describe("Config.trainGold trade stop penalty", () => { }); it("returns full base gold within free window (stops 0-9)", () => { - // first 10 stops (0-9) are free — no penalty expect(config.trainGold("self", 0, mockPlayer)).toBe(10_000n); expect(config.trainGold("self", 9, mockPlayer)).toBe(10_000n); }); it("reduces gold by 5k per stop after the free window", () => { - // stop 10: effective = 10-9 = 1 -> 10k - 5k = 5k expect(config.trainGold("self", 10, mockPlayer)).toBe(5_000n); }); it("floors at 5k when penalty exceeds base gold", () => { - // stop 12: effective = 3 -> 10k - 15k -> floor at 5k expect(config.trainGold("self", 12, mockPlayer)).toBe(5_000n); }); it("floors at 5k for ally base even with heavy penalty", () => { - // ally base 35k, stop 20: effective = 11 -> penalty 55k -> floor at 5k expect(config.trainGold("ally", 20, mockPlayer)).toBe(5_000n); }); it("ally base gold reduces correctly after free window", () => { - // ally base 35k, stop 11: effective = 2 -> 35k - 10k = 25k expect(config.trainGold("ally", 11, mockPlayer)).toBe(25_000n); }); it("other/team base gold reduces correctly after free window", () => { - // other base 25k, stop 10: effective = 1 -> 25k - 5k = 20k expect(config.trainGold("other", 10, mockPlayer)).toBe(20_000n); expect(config.trainGold("team", 10, mockPlayer)).toBe(20_000n); });