From 4e8454f3ccdc49d70e0bb300127c6b305780916d Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:19:41 +0100 Subject: [PATCH] =?UTF-8?q?Lobby=20Gold=20Options=20(Starting=20Gold,=20Go?= =?UTF-8?q?ld=20Multiplier)=20=F0=9F=92=B0=20(#2915)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: We might want to add this to v29 to have a third possible public game modifier from the beginning on 😄 Would be fun - Add starting gold option (0 to 1_000_000_000 allowed, also applies to nations) - Add gold multiplier option (0.1 to 1000 allowed, also applies to nations and bots) - Add third public game modifier (3% chance of starting with 5M gold) - Why 5M? It's enough gold to massively change the game start but not enough to insta-hydro someone (launcher + hydro is 6M) image ## 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 | 15 +- src/client/HostLobbyModal.ts | 204 +++++++++++++++++++++ src/client/PublicLobby.ts | 3 + src/client/SinglePlayerModal.ts | 224 ++++++++++++++++++++++++ src/core/Schemas.ts | 3 + src/core/configuration/Config.ts | 2 + src/core/configuration/DefaultConfig.ts | 32 +++- src/core/game/Game.ts | 1 + src/core/game/PlayerImpl.ts | 2 +- src/server/GameServer.ts | 8 +- src/server/MapPlaylist.ts | 8 +- 11 files changed, 488 insertions(+), 14 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index d3c3d4e62..6027fc282 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -171,7 +171,11 @@ "max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", - "start": "Start Game" + "start": "Start Game", + "gold_multiplier": "Gold multiplier", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Starting gold", + "starting_gold_placeholder": "5000000" }, "token_login_modal": { "title": "Logging in...", @@ -381,7 +385,11 @@ "teams_Duos": "Duos (teams of 2)", "teams_Trios": "Trios (teams of 3)", "teams_Quads": "Quads (teams of 4)", - "teams_Humans Vs Nations": "Humans vs Nations" + "teams_Humans Vs Nations": "Humans vs Nations", + "gold_multiplier": "Gold multiplier", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Starting gold", + "starting_gold_placeholder": "5000000" }, "team_colors": { "red": "Red", @@ -411,7 +419,8 @@ }, "public_game_modifier": { "random_spawn": "Random Spawn", - "compact_map": "Compact Map" + "compact_map": "Compact Map", + "starting_gold": "5M Starting Gold" }, "select_lang": { "title": "Select Language" diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 5abdb60a9..e7d709c12 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -60,6 +60,10 @@ export class HostLobbyModal extends BaseModal { @state() private instantBuild: boolean = false; @state() private randomSpawn: boolean = false; @state() private compactMap: boolean = false; + @state() private goldMultiplier: boolean = false; + @state() private goldMultiplierValue: number | undefined = undefined; + @state() private startingGold: boolean = false; + @state() private startingGoldValue: number | undefined = undefined; @state() private lobbyId = ""; @state() private copySuccess = false; @state() private lobbyUrlSuffix = ""; @@ -739,6 +743,158 @@ export class HostLobbyModal extends BaseModal { ${translateText("host_modal.player_immunity_duration")} + + +
this.goldMultiplier, + (val) => (this.goldMultiplier = val), + () => this.goldMultiplierValue, + (val) => (this.goldMultiplierValue = val), + 2, + ).click} + @keydown=${this.createToggleHandlers( + () => this.goldMultiplier, + (val) => (this.goldMultiplier = val), + () => this.goldMultiplierValue, + (val) => (this.goldMultiplierValue = val), + 2, + ).keydown} + class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this + .goldMultiplier + ? "bg-blue-500/20 border-blue-500/50" + : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}" + > +
+
+ ${this.goldMultiplier + ? html` + + ` + : ""} +
+
+ + ${this.goldMultiplier + ? html`` + : html`
`} + +
+ ${translateText("single_modal.gold_multiplier")} +
+
+ + +
this.startingGold, + (val) => (this.startingGold = val), + () => this.startingGoldValue, + (val) => (this.startingGoldValue = val), + 5000000, + ).click} + @keydown=${this.createToggleHandlers( + () => this.startingGold, + (val) => (this.startingGold = val), + () => this.startingGoldValue, + (val) => (this.startingGoldValue = val), + 5000000, + ).keydown} + class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this + .startingGold + ? "bg-blue-500/20 border-blue-500/50" + : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}" + > +
+
+ ${this.startingGold + ? html` + + ` + : ""} +
+
+ + ${this.startingGold + ? html`` + : html`
`} + +
+ ${translateText("single_modal.starting_gold")} +
+
@@ -968,6 +1124,10 @@ export class HostLobbyModal extends BaseModal { this.lobbyCreatorClientID = ""; this.lobbyIdVisible = true; this.nationCount = 0; + this.goldMultiplier = false; + this.goldMultiplierValue = undefined; + this.startingGold = false; + this.startingGoldValue = undefined; this.leaveLobbyOnClose = true; } @@ -1036,6 +1196,44 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); } + private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { + if (["+", "-", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleGoldMultiplierValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + const value = parseFloat(input.value); + + if (isNaN(value) || value < 0.1 || value > 1000) { + this.goldMultiplierValue = undefined; + input.value = ""; + } else { + this.goldMultiplierValue = value; + } + this.putGameConfig(); + } + + private handleStartingGoldValueKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleStartingGoldValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value); + + if (isNaN(value) || value < 0 || value > 1000000000) { + this.startingGoldValue = undefined; + } else { + this.startingGoldValue = value; + } + this.putGameConfig(); + } + private handleRandomSpawnChange = (val: boolean) => { this.randomSpawn = val; this.putGameConfig(); @@ -1151,6 +1349,12 @@ export class HostLobbyModal extends BaseModal { }), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : undefined, + goldMultiplier: + this.goldMultiplier === true + ? this.goldMultiplierValue + : undefined, + startingGold: + this.startingGold === true ? this.startingGoldValue : undefined, } satisfies Partial, }, bubbles: true, diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index c7516804d..4c895ab8f 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -374,6 +374,9 @@ export class PublicLobby extends LitElement { if (publicGameModifiers.isCompact) { labels.push(translateText("public_game_modifier.compact_map")); } + if (publicGameModifiers.startingGold) { + labels.push(translateText("public_game_modifier.starting_gold")); + } return labels; } diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 12c805751..c34dfe268 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -52,6 +52,10 @@ export class SinglePlayerModal extends BaseModal { @state() private showAchievements: boolean = false; @state() private mapWins: Map> = new Map(); @state() private userMeResponse: UserMeResponse | false = false; + @state() private goldMultiplier: boolean = false; + @state() private goldMultiplierValue: number | undefined = undefined; + @state() private startingGold: boolean = false; + @state() private startingGoldValue: number | undefined = undefined; @state() private disabledUnits: UnitType[] = []; @@ -601,6 +605,180 @@ export class SinglePlayerModal extends BaseModal { ${translateText("single_modal.max_timer")} + + +
{ + if ( + (e.target as HTMLElement).tagName.toLowerCase() === + "input" + ) + return; + this.goldMultiplier = !this.goldMultiplier; + if (!this.goldMultiplier) { + this.goldMultiplierValue = undefined; + } else { + if ( + !this.goldMultiplierValue || + this.goldMultiplierValue <= 0 + ) { + this.goldMultiplierValue = 2; + } + setTimeout(() => { + const input = this.renderRoot.querySelector( + "#gold-multiplier-value", + ) as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + }, 0); + } + }} + > +
+
+ ${this.goldMultiplier + ? html` + + ` + : ""} +
+
+ + ${this.goldMultiplier + ? html`` + : html`
`} + +
+ ${translateText("single_modal.gold_multiplier")} +
+
+ + +
{ + if ( + (e.target as HTMLElement).tagName.toLowerCase() === + "input" + ) + return; + this.startingGold = !this.startingGold; + if (!this.startingGold) { + this.startingGoldValue = undefined; + } else { + if ( + !this.startingGoldValue || + this.startingGoldValue < 0 + ) { + this.startingGoldValue = 5000000; + } + setTimeout(() => { + const input = this.renderRoot.querySelector( + "#starting-gold-value", + ) as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + }, 0); + } + }} + > +
+
+ ${this.startingGold + ? html` + + ` + : ""} +
+
+ + ${this.startingGold + ? html`` + : html`
`} + +
+ ${translateText("single_modal.starting_gold")} +
+
@@ -714,6 +892,10 @@ export class SinglePlayerModal extends BaseModal { this.randomSpawn = false; this.teamCount = 2; this.disabledUnits = []; + this.goldMultiplier = false; + this.goldMultiplierValue = undefined; + this.startingGold = false; + this.startingGoldValue = undefined; } private handleSelectRandomMap() { @@ -767,6 +949,42 @@ export class SinglePlayerModal extends BaseModal { } } + private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { + if (["+", "-", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleGoldMultiplierValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + const value = parseFloat(input.value); + + if (isNaN(value) || value < 0.1 || value > 1000) { + this.goldMultiplierValue = undefined; + input.value = ""; + } else { + this.goldMultiplierValue = value; + } + } + + private handleStartingGoldValueKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleStartingGoldValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value); + + if (isNaN(value) || value < 0 || value > 1000000000) { + this.startingGoldValue = undefined; + } else { + this.startingGoldValue = value; + } + } + private handleGameModeSelection(value: GameMode) { this.gameMode = value; } @@ -888,6 +1106,12 @@ export class SinglePlayerModal extends BaseModal { : { disableNations: this.disableNations, }), + ...(this.goldMultiplier && this.goldMultiplierValue + ? { goldMultiplier: this.goldMultiplierValue } + : {}), + ...(this.startingGold && this.startingGoldValue !== undefined + ? { startingGold: this.startingGoldValue } + : {}), }, lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP }, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 10a1a84b2..28362063f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -190,6 +190,7 @@ export const GameConfigSchema = z.object({ .object({ isCompact: z.boolean(), isRandomSpawn: z.boolean(), + startingGold: z.number().int().min(0).optional(), }) .optional(), disableNations: z.boolean(), @@ -204,6 +205,8 @@ export const GameConfigSchema = z.object({ spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks disabledUnits: z.enum(UnitType).array().optional(), playerTeams: TeamCountConfigSchema.optional(), + goldMultiplier: z.number().min(0.1).max(1000).optional(), + startingGold: z.number().int().min(0).max(1000000000).optional(), }); export const TeamSchema = z.string(); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 4c9fce54d..ac1d9ee4a 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -76,6 +76,8 @@ export interface Config { numSpawnPhaseTurns(): number; userSettings(): UserSettings; playerTeams(): TeamCountConfig; + goldMultiplier(): number; + startingGold(playerInfo: PlayerInfo): Gold; startManpower(playerInfo: PlayerInfo): number; troopIncreaseRate(player: Player | PlayerView): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 7311cb60c..36057bdad 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -245,6 +245,15 @@ export class DefaultConfig implements Config { donateTroops(): boolean { return this._gameConfig.donateTroops; } + goldMultiplier(): number { + return this._gameConfig.goldMultiplier ?? 1; + } + startingGold(playerInfo: PlayerInfo): Gold { + if (playerInfo.playerType === PlayerType.Bot) { + return 0n; + } + return BigInt(this._gameConfig.startingGold ?? 0); + } trainSpawnRate(numPlayerFactories: number): number { // hyperbolic decay, midpoint at 10 factories @@ -252,15 +261,21 @@ export class DefaultConfig implements Config { return (numPlayerFactories + 10) * 18; } trainGold(rel: "self" | "team" | "ally" | "other"): Gold { + const multiplier = this.goldMultiplier(); + let baseGold: bigint; switch (rel) { case "ally": - return 35_000n; + baseGold = 35_000n; + break; case "team": case "other": - return 25_000n; + baseGold = 25_000n; + break; case "self": - return 10_000n; + baseGold = 10_000n; + break; } + return BigInt(Math.floor(Number(baseGold) * multiplier)); } trainStationMinRange(): number { @@ -281,7 +296,8 @@ export class DefaultConfig implements Config { const numPortBonus = numPorts - 1; // Hyperbolic decay, midpoint at 5 ports, 3x bonus max. const bonus = 1 + 2 * (numPortBonus / (numPortBonus + 5)); - return BigInt(Math.floor(baseGold * bonus)); + const multiplier = this.goldMultiplier(); + return BigInt(Math.floor(baseGold * bonus * multiplier)); } // Probability of trade ship spawn = 1 / tradeShipSpawnRate @@ -791,10 +807,14 @@ export class DefaultConfig implements Config { } goldAdditionRate(player: Player): Gold { + const multiplier = this.goldMultiplier(); + let baseRate: bigint; if (player.type() === PlayerType.Bot) { - return 50n; + baseRate = 50n; + } else { + baseRate = 100n; } - return 100n; + return BigInt(Math.floor(Number(baseRate) * multiplier)); } nukeMagnitudes(unitType: UnitType): NukeMagnitude { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 4ead6efe5..fdfff12d8 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -211,6 +211,7 @@ export enum GameMapSize { export interface PublicGameModifiers { isCompact: boolean; isRandomSpawn: boolean; + startingGold?: number; } export interface UnitInfo { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 3b773576e..e09360acc 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -112,7 +112,7 @@ export class PlayerImpl implements Player { ) { this._name = playerInfo.name; this._troops = toInt(startTroops); - this._gold = 0n; + this._gold = mg.config().startingGold(playerInfo); this._displayName = this._name; this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id)); } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 068253920..1f685a72e 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -127,14 +127,18 @@ export class GameServer { if (gameConfig.gameMode !== undefined) { this.gameConfig.gameMode = gameConfig.gameMode; } - if (gameConfig.disabledUnits !== undefined) { this.gameConfig.disabledUnits = gameConfig.disabledUnits; } - if (gameConfig.playerTeams !== undefined) { this.gameConfig.playerTeams = gameConfig.playerTeams; } + if (gameConfig.goldMultiplier !== undefined) { + this.gameConfig.goldMultiplier = gameConfig.goldMultiplier; + } + if (gameConfig.startingGold !== undefined) { + this.gameConfig.startingGold = gameConfig.startingGold; + } } public joinClient(client: Client) { diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index a9ba0e78d..beadd0bec 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -94,7 +94,9 @@ export class MapPlaylist { const playerTeams = mode === GameMode.Team ? this.getTeamCount() : undefined; - let { isCompact, isRandomSpawn } = this.getRandomPublicGameModifiers(); + const modifiers = this.getRandomPublicGameModifiers(); + const { startingGold } = modifiers; + let { isCompact, isRandomSpawn } = modifiers; // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose) if ( @@ -122,7 +124,8 @@ export class MapPlaylist { maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact), gameType: GameType.Public, gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, - publicGameModifiers: { isCompact, isRandomSpawn }, + publicGameModifiers: { isCompact, isRandomSpawn, startingGold }, + startingGold, difficulty: playerTeams === HumansVsNations ? Difficulty.Impossible @@ -198,6 +201,7 @@ export class MapPlaylist { return { isRandomSpawn: Math.random() < 0.1, // 10% chance isCompact: Math.random() < 0.05, // 5% chance + startingGold: Math.random() < 0.03 ? 5_000_000 : undefined, // 3% chance }; }