diff --git a/resources/lang/en.json b/resources/lang/en.json index 082babc7c..a169de7bf 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -205,7 +205,7 @@ "max_timer": "Game length (minutes)", "max_timer_placeholder": "Mins", "max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)", - "enables_title": "Enable Settings", + "enables_title": "Disable Units", "start": "Start Game", "options_changed_no_achievements": "Custom settings – achievements disabled", "gold_multiplier": "Gold multiplier", @@ -386,7 +386,8 @@ "disabled_units": "Disabled Units", "game_length": "Game length", "pvp_immunity": "PVP immunity duration", - "starting_gold": "Starting Gold" + "starting_gold": "Starting Gold", + "host_cheats": "Host Cheats" }, "public_lobby": { "title": "Waiting for Game Start...", @@ -440,7 +441,7 @@ "compact_map": "Compact Map", "disable_alliances": "Disable alliances", "water_nukes": "Water nukes", - "enables_title": "Enable Settings", + "enables_title": "Disable Units", "player": "Player", "players": "Players", "nation_players": "Nations", @@ -463,6 +464,7 @@ "gold_multiplier_placeholder": "2.0x", "starting_gold": "Starting Gold (Millions)", "starting_gold_placeholder": "5", + "host_cheats": "Host Cheats", "leave_confirmation": "Are you sure you want to leave the lobby?" }, "team_colors": { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index c290a4242..509ab1267 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -78,6 +78,13 @@ export class HostLobbyModal extends BaseModal { @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; + @state() private hostCheatsEnabled: boolean = false; + @state() private hostCheatInfiniteGold: boolean = false; + @state() private hostCheatInfiniteTroops: boolean = false; + @state() private hostCheatGoldMultiplier: boolean = false; + @state() private hostCheatGoldMultiplierValue: number | undefined = undefined; + @state() private hostCheatStartingGold: boolean = false; + @state() private hostCheatStartingGoldValue: number | undefined = undefined; @state() private lobbyCreatorClientID: string = ""; @property({ attribute: false }) eventBus: EventBus | null = null; @@ -213,6 +220,45 @@ export class HostLobbyModal extends BaseModal { >`, ]; + const hostCheatInputCards = [ + html``, + html``, + ]; + const content = html`
@@ -304,9 +350,28 @@ export class HostLobbyModal extends BaseModal { labelKey: "host_modal.water_nukes", checked: this.waterNukes, }, + { + labelKey: "host_modal.host_cheats", + checked: this.hostCheatsEnabled, + }, ], inputCards, }, + hostCheats: { + titleKey: "host_modal.host_cheats", + visible: this.hostCheatsEnabled, + toggles: [ + { + labelKey: "host_modal.infinite_gold", + checked: this.hostCheatInfiniteGold, + }, + { + labelKey: "host_modal.infinite_troops", + checked: this.hostCheatInfiniteTroops, + }, + ], + inputCards: hostCheatInputCards, + }, unitTypes: { titleKey: "host_modal.enables_title", disabledUnits: this.disabledUnits, @@ -320,6 +385,8 @@ export class HostLobbyModal extends BaseModal { @bots-changed=${this.handleBotsChange} @nations-changed=${this.handleNationsChange} @option-toggle-changed=${this.handleConfigOptionToggleChanged} + @host-cheat-toggle-changed=${this + .handleConfigHostCheatToggleChanged} @unit-toggle-changed=${this.handleConfigUnitToggleChanged} > @@ -469,6 +536,13 @@ export class HostLobbyModal extends BaseModal { this.startingGoldValue = undefined; this.disableAlliances = false; this.waterNukes = false; + this.hostCheatsEnabled = false; + this.hostCheatInfiniteGold = false; + this.hostCheatInfiniteTroops = false; + this.hostCheatGoldMultiplier = false; + this.hostCheatGoldMultiplierValue = undefined; + this.hostCheatStartingGold = false; + this.hostCheatStartingGoldValue = undefined; this.leaveLobbyOnClose = true; } @@ -553,6 +627,31 @@ export class HostLobbyModal extends BaseModal { this.waterNukes = checked; this.putGameConfig(); break; + case "host_modal.host_cheats": + this.hostCheatsEnabled = checked; + this.putGameConfig(); + break; + default: + break; + } + }; + + private handleConfigHostCheatToggleChanged = (e: Event) => { + const customEvent = e as CustomEvent<{ + labelKey: string; + checked: boolean; + }>; + const { labelKey, checked } = customEvent.detail; + + switch (labelKey) { + case "host_modal.infinite_gold": + this.hostCheatInfiniteGold = checked; + this.putGameConfig(); + break; + case "host_modal.infinite_troops": + this.hostCheatInfiniteTroops = checked; + this.putGameConfig(); + break; default: break; } @@ -684,6 +783,61 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; + private handleHostCheatGoldMultiplierToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.hostCheatGoldMultiplier = checked; + this.hostCheatGoldMultiplierValue = toOptionalNumber(value); + this.putGameConfig(); + }; + + private handleHostCheatGoldMultiplierValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["+", "-", "e", "E"]); + }; + + private handleHostCheatGoldMultiplierValueChanges = (e: Event) => { + const input = e.target as HTMLInputElement; + const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 }); + + if (value === undefined) { + this.hostCheatGoldMultiplierValue = undefined; + input.value = ""; + } else { + this.hostCheatGoldMultiplierValue = value; + } + this.putGameConfig(); + }; + + private handleHostCheatStartingGoldToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.hostCheatStartingGold = checked; + this.hostCheatStartingGoldValue = toOptionalNumber(value); + this.putGameConfig(); + }; + + private handleHostCheatStartingGoldValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e", "E"]); + }; + + private handleHostCheatStartingGoldValueChanges = (e: Event) => { + const input = e.target as HTMLInputElement; + const value = parseBoundedFloatFromInput(input, { + min: 0.1, + max: 1000, + }); + + if (value === undefined) { + this.hostCheatStartingGoldValue = undefined; + input.value = ""; + } else { + this.hostCheatStartingGoldValue = value; + } + this.putGameConfig(); + }; + private handleRandomSpawnChange = (val: boolean) => { this.randomSpawn = val; this.putGameConfig(); @@ -814,6 +968,21 @@ export class HostLobbyModal extends BaseModal { : null, disableAlliances: this.disableAlliances || null, waterNukes: this.waterNukes ? true : null, + hostCheats: this.hostCheatsEnabled + ? { + infiniteGold: this.hostCheatInfiniteGold || undefined, + infiniteTroops: this.hostCheatInfiniteTroops || undefined, + goldMultiplier: + this.hostCheatGoldMultiplier === true + ? this.hostCheatGoldMultiplierValue + : null, + startingGold: + this.hostCheatStartingGold === true && + this.hostCheatStartingGoldValue !== undefined + ? Math.round(this.hostCheatStartingGoldValue * 1_000_000) + : null, + } + : undefined, } satisfies Partial, }, bubbles: true, diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index cca15ca24..5e06fd959 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -636,7 +636,7 @@ export class JoinLobbyModal extends BaseModal { ${cards}
` : html``} - ${this.renderDisabledUnits()} + ${this.renderDisabledUnits()} ${this.renderHostCheats()} `; } @@ -691,6 +691,64 @@ export class JoinLobbyModal extends BaseModal { `; } + private renderHostCheats(): TemplateResult { + if (!this.gameConfig?.hostCheats) { + return html``; + } + + const hc = this.gameConfig.hostCheats; + const items: TemplateResult[] = []; + + if (hc.infiniteGold) + items.push( + html` + ${translateText("host_modal.infinite_gold")} + `, + ); + if (hc.infiniteTroops) + items.push( + html` + ${translateText("host_modal.infinite_troops")} + `, + ); + if (hc.goldMultiplier) + items.push( + html` + ${translateText("host_modal.gold_multiplier")}: x${hc.goldMultiplier} + `, + ); + if (hc.startingGold) + items.push( + html` + ${translateText("private_lobby.starting_gold")}: + ${parseFloat((hc.startingGold / 1_000_000).toPrecision(12))}M + `, + ); + + if (items.length === 0) return html``; + + return html` +
+
+ ${translateText("private_lobby.host_cheats")} +
+
${items}
+
+ `; + } + // --- Lobby event handling --- private updateFromLobby(lobby: GameInfo | PublicGameInfo) { diff --git a/src/client/components/GameConfigSettings.ts b/src/client/components/GameConfigSettings.ts index d0516b620..bd34dea83 100644 --- a/src/client/components/GameConfigSettings.ts +++ b/src/client/components/GameConfigSettings.ts @@ -122,6 +122,12 @@ const OPTIONS_ICON = svg``; +const HOST_CHEATS_ICON = svg``; + const ENABLES_ICON = svg` { + this.emit("host-cheat-toggle-changed", { + labelKey: toggle.labelKey, + checked: !toggle.checked, + }); + }; + private handleUnitToggle = (unit: UnitType, checked: boolean) => { this.emit("unit-toggle-changed", { unit, checked }); }; @@ -462,6 +481,27 @@ export class GameConfigSettings extends LitElement { `, )} + ${settings.hostCheats?.visible + ? renderSection( + HOST_CHEATS_ICON, + "text-yellow-400", + "bg-yellow-500/20", + settings.hostCheats.titleKey, + html` +
+ ${settings.hostCheats.toggles.map((toggle) => + renderTextCardButton( + translateText(toggle.labelKey), + toggle.checked, + () => this.handleHostCheatToggle(toggle), + "p-4 text-center", + ), + )} + ${settings.hostCheats.inputCards} +
+ `, + ) + : nothing} ${renderSection( ENABLES_ICON, "text-teal-400", diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index a1177ec34..ffd5a3e4e 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -258,6 +258,20 @@ export const GameConfigSchema = z.object({ playerTeams: TeamCountConfigSchema.optional(), goldMultiplier: z.number().min(0.1).max(1000).nullable().optional(), startingGold: z.number().int().min(0).max(1000000000).nullable().optional(), + hostCheats: z + .object({ + infiniteGold: z.boolean().optional(), + infiniteTroops: z.boolean().optional(), + goldMultiplier: z.number().min(0.1).max(1000).nullable().optional(), + startingGold: z + .number() + .int() + .min(0) + .max(1000000000) + .nullable() + .optional(), + }) + .optional(), }); export const TeamSchema = z.string(); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 42703f6b2..92915e371 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -128,7 +128,7 @@ export interface Config { defaultDonationAmount(sender: Player): number; unitInfo(type: UnitType): UnitInfo; tradeShipShortRangeDebuff(): number; - tradeShipGold(dist: number): Gold; + tradeShipGold(dist: number, player: Player | PlayerView): Gold; tradeShipSpawnRate( tradeShipSpawnRejections: number, numTradeShips: number, @@ -136,6 +136,7 @@ export interface Config { trainGold( rel: "self" | "team" | "ally" | "other", citiesVisited: number, + player: Player | PlayerView, ): Gold; trainSpawnRate(numPlayerFactories: number): number; trainStationMinRange(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index f37f2d2b4..010d6910f 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -271,7 +271,7 @@ export class DefaultConfig implements Config { if (playerInfo.playerType === PlayerType.Bot) { return 0n; } - return BigInt(this._gameConfig.startingGold ?? 0); + return this.startingGoldFor(playerInfo); } trainSpawnRate(numPlayerFactories: number): number { @@ -282,6 +282,7 @@ export class DefaultConfig implements Config { trainGold( rel: "self" | "team" | "ally" | "other", citiesVisited: number, + player: Player | PlayerView, ): Gold { // No penalty for the first 10 cities. citiesVisited = Math.max(0, citiesVisited - 9); @@ -300,7 +301,7 @@ export class DefaultConfig implements Config { } const distPenalty = citiesVisited * 5_000; const gold = Math.max(5000, baseGold - distPenalty); - return toInt(gold * this.goldMultiplier()); + return toInt(gold * this.goldMultiplierFor(player)); } trainStationMinRange(): number { @@ -313,13 +314,12 @@ export class DefaultConfig implements Config { return 120; } - tradeShipGold(dist: number): Gold { + tradeShipGold(dist: number, player: Player | PlayerView): Gold { // Sigmoid: concave start, sharp S-curve middle, linear end - heavily punishes trades under range debuff. const debuff = this.tradeShipShortRangeDebuff(); const baseGold = 75_000 / (1 + Math.exp(-0.03 * (dist - debuff))) + 50 * dist; - const multiplier = this.goldMultiplier(); - return BigInt(Math.floor(baseGold * multiplier)); + return BigInt(Math.floor(baseGold * this.goldMultiplierFor(player))); } // Probability of trade ship spawn = 1 / tradeShipSpawnRate @@ -396,7 +396,10 @@ export class DefaultConfig implements Config { case UnitType.MIRV: info = { cost: (game: Game, player: Player) => { - if (player.type() === PlayerType.Human && this.infiniteGold()) { + if ( + player.type() === PlayerType.Human && + this.hasInfiniteGoldFor(player) + ) { return 0n; } return 25_000_000n + game.stats().numMirvsLaunched() * 15_000_000n; @@ -478,12 +481,55 @@ export class DefaultConfig implements Config { return info; } + private hasInfiniteGoldFor(player: Player | PlayerView): boolean { + if (this.infiniteGold()) return true; + const hc = this._gameConfig.hostCheats; + return (hc?.infiniteGold ?? false) && player.isLobbyCreator(); + } + + private hasInfiniteTroopsFor(player: Player | PlayerView): boolean { + if (this.infiniteTroops()) return true; + return ( + (this._gameConfig.hostCheats?.infiniteTroops ?? false) && + player.isLobbyCreator() + ); + } + + private hasInfiniteTroopsForInfo(playerInfo: PlayerInfo): boolean { + if (this.infiniteTroops()) return true; + return ( + (this._gameConfig.hostCheats?.infiniteTroops ?? false) && + playerInfo.isLobbyCreator + ); + } + + private goldMultiplierFor(player: Player | PlayerView): number { + const base = this.goldMultiplier(); + const hc = this._gameConfig.hostCheats; + if (hc?.goldMultiplier && player.isLobbyCreator()) { + return hc.goldMultiplier; + } + return base; + } + + private startingGoldFor(playerInfo: PlayerInfo): Gold { + const base = BigInt(this._gameConfig.startingGold ?? 0); + const hc = this._gameConfig.hostCheats; + if (hc?.startingGold && playerInfo.isLobbyCreator) { + return base + BigInt(hc.startingGold); + } + return base; + } + private costWrapper( costFn: (units: number) => number, ...types: UnitType[] ): (g: Game, p: Player) => bigint { return (game: Game, player: Player) => { - if (player.type() === PlayerType.Human && this.infiniteGold()) { + if ( + player.type() === PlayerType.Human && + this.hasInfiniteGoldFor(player) + ) { return 0n; } const numUnits = types.reduce( @@ -761,12 +807,12 @@ export class DefaultConfig implements Config { assertNever(this._gameConfig.difficulty); } } - return this.infiniteTroops() ? 1_000_000 : 25_000; + return this.hasInfiniteTroopsForInfo(playerInfo) ? 1_000_000 : 25_000; } maxTroops(player: Player | PlayerView): number { const maxTroops = - player.type() === PlayerType.Human && this.infiniteTroops() + player.type() === PlayerType.Human && this.hasInfiniteTroopsFor(player) ? 1_000_000_000 : 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) + player @@ -833,7 +879,7 @@ export class DefaultConfig implements Config { } goldAdditionRate(player: Player): Gold { - const multiplier = this.goldMultiplier(); + const multiplier = this.goldMultiplierFor(player); let baseRate: bigint; if (player.type() === PlayerType.Bot) { baseRate = 50n; diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index b1abdaaf3..5c9d4b484 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -168,7 +168,9 @@ export class TradeShipExecution implements Execution { private complete() { this.active = false; this.tradeShip!.delete(false); - const gold = this.mg.config().tradeShipGold(this.tilesTraveled); + const gold = this.mg + .config() + .tradeShipGold(this.tilesTraveled, this.tradeShip!.owner()); if (this.wasCaptured) { this.tradeShip!.owner().addGold(gold, this._dstPort.tile()); diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index 43ec27a8a..485d95d01 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -735,7 +735,7 @@ export class NationStructureBehavior { } const maxTradeGold = Math.max( - Number(game.config().trainGold("ally", 0)), + Number(game.config().trainGold("ally", 0, player)), 1, ); const result: Array<{ @@ -746,7 +746,7 @@ export class NationStructureBehavior { // Own structures — weighted by "self" trade gold. const selfWeight = - Number(game.config().trainGold("self", 0)) / maxTradeGold; + Number(game.config().trainGold("self", 0, player)) / maxTradeGold; for (const unit of player.units( UnitType.City, UnitType.Port, @@ -771,7 +771,8 @@ export class NationStructureBehavior { : player.isAlliedWith(neighbor) ? "ally" : "other"; - const weight = Number(game.config().trainGold(relType, 0)) / maxTradeGold; + const weight = + Number(game.config().trainGold(relType, 0, player)) / 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 66c2be111..de089d0ca 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -25,6 +25,7 @@ class TradeStationStopHandler implements TrainStopHandler { .trainGold( rel(trainOwner, stationOwner), trainExecution.tradeStopsVisited(), + trainOwner, ); // Share revenue with the station owner if it's not the current player if (trainOwner !== stationOwner) { diff --git a/src/server/ClientMsgRateLimiter.ts b/src/server/ClientMsgRateLimiter.ts index 5177eb79c..d243b6460 100644 --- a/src/server/ClientMsgRateLimiter.ts +++ b/src/server/ClientMsgRateLimiter.ts @@ -4,6 +4,7 @@ import { ClientID } from "../core/Schemas"; const INTENTS_PER_SECOND = 10; const INTENTS_PER_MINUTE = 150; const MAX_INTENT_SIZE = 500; +const MAX_CONFIG_INTENT_SIZE = 2000; const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client export type RateLimitResult = "ok" | "limit" | "kick"; @@ -16,19 +17,30 @@ interface ClientBucket { export class ClientMsgRateLimiter { private buckets = new Map(); - check(clientID: ClientID, type: string, bytes: number): RateLimitResult { + check( + clientID: ClientID, + type: string, + bytes: number, + intentType?: string, + ): RateLimitResult { const bucket = this.getOrCreate(clientID); bucket.totalBytes += bytes; if (bucket.totalBytes >= TOTAL_BYTES) return "kick"; if (type === "intent") { + // Config updates are lobby-only and not stored in turn history, + // so they can be larger than regular intents. + const maxSize = + intentType === "update_game_config" + ? MAX_CONFIG_INTENT_SIZE + : MAX_INTENT_SIZE; // Intents are stored in turn history for the duration of the game, so // oversized intents would accumulate and fill up server RAM. // Intents are also sent to all players, so it increase outgoing // data. // Intents should never be larger than MAX_INTENT_SIZE, so we assume the client is malicious. - if (bytes > MAX_INTENT_SIZE) { + if (bytes > maxSize) { return "kick"; } if ( diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 05a5bf0b1..8523a8cd6 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -175,6 +175,9 @@ export class GameServer { if (gameConfig.waterNukes !== undefined) { this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined; } + if (gameConfig.hostCheats !== undefined) { + this.gameConfig.hostCheats = gameConfig.hostCheats; + } } private isKicked(clientID: ClientID): boolean { @@ -346,10 +349,13 @@ export class GameServer { } const clientMsg = parsed.data; const bytes = Buffer.byteLength(message, "utf8"); + const intentType = + clientMsg.type === "intent" ? clientMsg.intent.type : undefined; const rateResult = this.intentRateLimiter.check( client.clientID, clientMsg.type, bytes, + intentType, ); if (rateResult === "kick") { this.log.warn(`Client rate limit exceeded, kicking`, { diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts index 703495c5f..f15d3ada1 100644 --- a/tests/core/game/TrainStation.test.ts +++ b/tests/core/game/TrainStation.test.ts @@ -139,7 +139,11 @@ describe("TrainStation", () => { station.onTrainStop(trainExecution); - expect(trainGoldSpy).toHaveBeenCalledWith(expect.any(String), 3); + expect(trainGoldSpy).toHaveBeenCalledWith( + expect.any(String), + 3, + expect.anything(), + ); }); it("checks trade availability (same owner)", () => { @@ -204,6 +208,7 @@ describe("TrainStation", () => { describe("DefaultConfig.trainGold trade stop penalty", () => { let config: DefaultConfig; + let mockPlayer: Player; beforeEach(() => { const serverConfig = new TestServerConfig(); @@ -229,37 +234,38 @@ describe("DefaultConfig.trainGold trade stop penalty", () => { new UserSettings(), false, ); + mockPlayer = { isLobbyCreator: () => false } as unknown as Player; }); 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)).toBe(10_000n); - expect(config.trainGold("self", 9)).toBe(10_000n); + 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)).toBe(5_000n); + 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)).toBe(5_000n); + 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)).toBe(5_000n); + 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)).toBe(25_000n); + 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)).toBe(20_000n); - expect(config.trainGold("team", 10)).toBe(20_000n); + expect(config.trainGold("other", 10, mockPlayer)).toBe(20_000n); + expect(config.trainGold("team", 10, mockPlayer)).toBe(20_000n); }); });