From 9821e8e0411184037f650d86fca1101209f6a1c0 Mon Sep 17 00:00:00 2001
From: FloPinguin <25036848+FloPinguin@users.noreply.github.com>
Date: Thu, 16 Apr 2026 00:20:08 +0200
Subject: [PATCH] =?UTF-8?q?Add=20host=20cheats=20for=20streamers=20(Specif?=
=?UTF-8?q?ically=20Enzo)=20=E2=AD=90=20(#3671)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description:
- Adds a "Host Cheats" toggle in the private lobby options section that
reveals a dedicated section with four host-only cheats: infinite gold,
infinite troops, gold multiplier, and starting gold
- Only the lobby creator receives the cheat effects in-game (checked via
`isLobbyCreator` in DefaultConfig)
- Joining players see active host cheats displayed as yellow badges in
the lobby UI
- Adds `hostCheats` optional object to `GameConfigSchema` and wires it
through the server config update whitelist
- Raises the intent size limit for `update_game_config` messages
(lobby-only, not stored in turn history) to prevent rate-limiter kicks
(I always got too-much-data-kicked after selecting "host cheats" lol)
## 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:
FloPinguin
---
resources/lang/en.json | 8 +-
src/client/HostLobbyModal.ts | 169 ++++++++++++++++++
src/client/JoinLobbyModal.ts | 60 ++++++-
src/client/components/GameConfigSettings.ts | 40 +++++
src/core/Schemas.ts | 14 ++
src/core/configuration/Config.ts | 3 +-
src/core/configuration/DefaultConfig.ts | 66 +++++--
src/core/execution/TradeShipExecution.ts | 4 +-
.../nation/NationStructureBehavior.ts | 7 +-
src/core/game/TrainStation.ts | 1 +
src/server/ClientMsgRateLimiter.ts | 16 +-
src/server/GameServer.ts | 6 +
tests/core/game/TrainStation.test.ts | 24 ++-
13 files changed, 388 insertions(+), 30 deletions(-)
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);
});
});