Add host cheats for streamers (Specifically Enzo) (#3671)

## 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)

<img width="861" height="525" alt="image"
src="https://github.com/user-attachments/assets/51e51ec4-c2e8-46ca-b258-11a93487964f"
/>


<img width="933" height="825" alt="image"
src="https://github.com/user-attachments/assets/5acbd38d-2097-42e1-ba78-0fb17d6afe82"
/>

## 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
This commit is contained in:
FloPinguin
2026-04-16 00:20:08 +02:00
committed by GitHub
parent f32994fbc7
commit 9821e8e041
13 changed files with 388 additions and 30 deletions
+5 -3
View File
@@ -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": {
+169
View File
@@ -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 {
></toggle-input-card>`,
];
const hostCheatInputCards = [
html`<toggle-input-card
.labelKey=${"host_modal.gold_multiplier"}
.checked=${this.hostCheatGoldMultiplier}
.inputId=${"host-cheat-gold-multiplier-value"}
.inputMin=${0.1}
.inputMax=${1000}
.inputStep=${"any"}
.inputValue=${this.hostCheatGoldMultiplierValue}
.inputAriaLabel=${translateText("host_modal.gold_multiplier")}
.inputPlaceholder=${translateText(
"host_modal.gold_multiplier_placeholder",
)}
.defaultInputValue=${2}
.minValidOnEnable=${0.1}
.onToggle=${this.handleHostCheatGoldMultiplierToggle}
.onChange=${this.handleHostCheatGoldMultiplierValueChanges}
.onKeyDown=${this.handleHostCheatGoldMultiplierValueKeyDown}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"host_modal.starting_gold"}
.checked=${this.hostCheatStartingGold}
.inputId=${"host-cheat-starting-gold-value"}
.inputMin=${0.1}
.inputMax=${1000}
.inputStep=${"any"}
.inputValue=${this.hostCheatStartingGoldValue}
.inputAriaLabel=${translateText("host_modal.starting_gold")}
.inputPlaceholder=${translateText(
"host_modal.starting_gold_placeholder",
)}
.defaultInputValue=${5}
.minValidOnEnable=${0.1}
.onToggle=${this.handleHostCheatStartingGoldToggle}
.onChange=${this.handleHostCheatStartingGoldValueChanges}
.onKeyDown=${this.handleHostCheatStartingGoldValueKeyDown}
></toggle-input-card>`,
];
const content = html`
<div class="${this.modalContainerClass}">
<!-- Header -->
@@ -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}
></game-config-settings>
@@ -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<GameConfig>,
},
bubbles: true,
+59 -1
View File
@@ -636,7 +636,7 @@ export class JoinLobbyModal extends BaseModal {
${cards}
</div>`
: 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`<span
class="px-2 py-1 bg-yellow-500/20 text-yellow-200 text-xs rounded font-bold border border-yellow-500/30"
>
${translateText("host_modal.infinite_gold")}
</span>`,
);
if (hc.infiniteTroops)
items.push(
html`<span
class="px-2 py-1 bg-yellow-500/20 text-yellow-200 text-xs rounded font-bold border border-yellow-500/30"
>
${translateText("host_modal.infinite_troops")}
</span>`,
);
if (hc.goldMultiplier)
items.push(
html`<span
class="px-2 py-1 bg-yellow-500/20 text-yellow-200 text-xs rounded font-bold border border-yellow-500/30"
>
${translateText("host_modal.gold_multiplier")}: x${hc.goldMultiplier}
</span>`,
);
if (hc.startingGold)
items.push(
html`<span
class="px-2 py-1 bg-yellow-500/20 text-yellow-200 text-xs rounded font-bold border border-yellow-500/30"
>
${translateText("private_lobby.starting_gold")}:
${parseFloat((hc.startingGold / 1_000_000).toPrecision(12))}M
</span>`,
);
if (items.length === 0) return html``;
return html`
<div
class="mt-4 mb-6 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
>
<div
class="text-xs font-bold text-yellow-400 uppercase tracking-widest mb-2"
>
${translateText("private_lobby.host_cheats")}
</div>
<div class="flex flex-wrap gap-2">${items}</div>
</div>
`;
}
// --- Lobby event handling ---
private updateFromLobby(lobby: GameInfo | PublicGameInfo) {
@@ -122,6 +122,12 @@ const OPTIONS_ICON = svg`<path
clip-rule="evenodd"
/>`;
const HOST_CHEATS_ICON = svg`<path
fill-rule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
clip-rule="evenodd"
/>`;
const ENABLES_ICON = svg`<path
fill-rule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm0 8.625a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25zM15.375 12a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zM7.5 10.875a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25z"
@@ -196,6 +202,12 @@ export interface GameConfigSettingsData {
toggles: ToggleOptionConfig[];
inputCards: TemplateResult[];
};
hostCheats?: {
titleKey: string;
visible: boolean;
toggles: ToggleOptionConfig[];
inputCards: TemplateResult[];
};
unitTypes: {
titleKey: string;
disabledUnits: UnitType[];
@@ -258,6 +270,13 @@ export class GameConfigSettings extends LitElement {
this.emit("nations-changed", customEvent.detail);
};
private handleHostCheatToggle = (toggle: ToggleOptionConfig) => {
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 {
</div>
`,
)}
${settings.hostCheats?.visible
? renderSection(
HOST_CHEATS_ICON,
"text-yellow-400",
"bg-yellow-500/20",
settings.hostCheats.titleKey,
html`
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
${settings.hostCheats.toggles.map((toggle) =>
renderTextCardButton(
translateText(toggle.labelKey),
toggle.checked,
() => this.handleHostCheatToggle(toggle),
"p-4 text-center",
),
)}
${settings.hostCheats.inputCards}
</div>
`,
)
: nothing}
${renderSection(
ENABLES_ICON,
"text-teal-400",
+14
View File
@@ -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();
+2 -1
View File
@@ -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;
+56 -10
View File
@@ -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;
+3 -1
View File
@@ -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());
@@ -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,
+1
View File
@@ -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) {
+14 -2
View File
@@ -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<ClientID, ClientBucket>();
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 (
+6
View File
@@ -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`, {
+15 -9
View File
@@ -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);
});
});