mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:00:44 +00:00
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:
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user