Lobby Gold Options (Starting Gold, Gold Multiplier) 💰 (#2915)

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

<img width="357" height="140" alt="image"
src="https://github.com/user-attachments/assets/72acc15c-e788-4e04-8590-ac72dd9657c7"
/>


## 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-01-16 19:19:41 +01:00
committed by GitHub
parent b2ba37e0ab
commit fb910cbff5
11 changed files with 488 additions and 14 deletions
+12 -3
View File
@@ -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...",
@@ -379,7 +383,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",
@@ -409,7 +417,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"
+204
View File
@@ -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")}
</div>
</div>
<!-- Gold Multiplier -->
<div
role="button"
tabindex="0"
@click=${this.createToggleHandlers(
() => 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"}"
>
<div class="flex items-center justify-center w-full mt-1">
<div
class="w-5 h-5 rounded border flex items-center justify-center transition-colors ${this
.goldMultiplier
? "bg-blue-500 border-blue-500"
: "border-white/20 bg-white/5"}"
>
${this.goldMultiplier
? html`<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3 text-white"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>`
: ""}
</div>
</div>
${this.goldMultiplier
? html`<input
type="number"
id="gold-multiplier-value"
min="0.1"
max="1000"
step="any"
value=${this.goldMultiplierValue ?? ""}
class="w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1"
aria-label=${translateText(
"single_modal.gold_multiplier",
)}
@change=${this.handleGoldMultiplierValueChanges}
@keydown=${this.handleGoldMultiplierValueKeyDown}
placeholder=${translateText(
"single_modal.gold_multiplier_placeholder",
)}
/>`
: html`<div
class="h-[2px] w-4 bg-white/10 rounded my-3"
></div>`}
<div
class="text-[10px] uppercase font-bold text-white/60 tracking-wider text-center w-full leading-tight break-words hyphens-auto"
>
${translateText("single_modal.gold_multiplier")}
</div>
</div>
<!-- Starting Gold -->
<div
role="button"
tabindex="0"
@click=${this.createToggleHandlers(
() => 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"}"
>
<div class="flex items-center justify-center w-full mt-1">
<div
class="w-5 h-5 rounded border flex items-center justify-center transition-colors ${this
.startingGold
? "bg-blue-500 border-blue-500"
: "border-white/20 bg-white/5"}"
>
${this.startingGold
? html`<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3 text-white"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>`
: ""}
</div>
</div>
${this.startingGold
? html`<input
type="number"
id="starting-gold-value"
min="0"
max="1000000000"
step="100000"
.value=${String(this.startingGoldValue ?? "")}
class="w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1"
aria-label=${translateText(
"single_modal.starting_gold",
)}
@input=${this.handleStartingGoldValueChanges}
@keydown=${this.handleStartingGoldValueKeyDown}
placeholder=${translateText(
"single_modal.starting_gold_placeholder",
)}
/>`
: html`<div
class="h-[2px] w-4 bg-white/10 rounded my-3"
></div>`}
<div
class="text-[10px] uppercase font-bold text-white/60 tracking-wider text-center w-full leading-tight break-words hyphens-auto"
>
${translateText("single_modal.starting_gold")}
</div>
</div>
</div>
</div>
@@ -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<GameConfig>,
},
bubbles: true,
+3
View File
@@ -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;
}
+224
View File
@@ -52,6 +52,10 @@ export class SinglePlayerModal extends BaseModal {
@state() private showAchievements: boolean = false;
@state() private mapWins: Map<GameMapType, Set<Difficulty>> = 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")}
</div>
</div>
<!-- Gold Multiplier -->
<div
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"}"
@click=${(e: Event) => {
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);
}
}}
>
<div class="flex items-center justify-center w-full mt-1">
<div
class="w-5 h-5 rounded border flex items-center justify-center transition-colors ${this
.goldMultiplier
? "bg-blue-500 border-blue-500"
: "border-white/20 bg-white/5"}"
>
${this.goldMultiplier
? html`<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3 text-white"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>`
: ""}
</div>
</div>
${this.goldMultiplier
? html`<input
type="number"
id="gold-multiplier-value"
min="0.1"
max="1000"
step="any"
value=${this.goldMultiplierValue ?? ""}
class="w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1"
aria-label=${translateText(
"single_modal.gold_multiplier",
)}
@change=${this.handleGoldMultiplierValueChanges}
@keydown=${this.handleGoldMultiplierValueKeyDown}
placeholder=${translateText(
"single_modal.gold_multiplier_placeholder",
)}
/>`
: html`<div
class="h-[2px] w-4 bg-white/10 rounded my-3"
></div>`}
<div
class="text-[10px] uppercase font-bold text-white/60 tracking-wider text-center w-full leading-tight break-words hyphens-auto"
>
${translateText("single_modal.gold_multiplier")}
</div>
</div>
<!-- Starting Gold -->
<div
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"}"
@click=${(e: Event) => {
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);
}
}}
>
<div class="flex items-center justify-center w-full mt-1">
<div
class="w-5 h-5 rounded border flex items-center justify-center transition-colors ${this
.startingGold
? "bg-blue-500 border-blue-500"
: "border-white/20 bg-white/5"}"
>
${this.startingGold
? html`<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3 text-white"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>`
: ""}
</div>
</div>
${this.startingGold
? html`<input
type="number"
id="starting-gold-value"
min="0"
max="1000000000"
step="100000"
.value=${String(this.startingGoldValue ?? "")}
class="w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1"
aria-label=${translateText(
"single_modal.starting_gold",
)}
@input=${this.handleStartingGoldValueChanges}
@keydown=${this.handleStartingGoldValueKeyDown}
placeholder=${translateText(
"single_modal.starting_gold_placeholder",
)}
/>`
: html`<div
class="h-[2px] w-4 bg-white/10 rounded my-3"
></div>`}
<div
class="text-[10px] uppercase font-bold text-white/60 tracking-wider text-center w-full leading-tight break-words hyphens-auto"
>
${translateText("single_modal.starting_gold")}
</div>
</div>
</div>
</div>
@@ -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
},
+3
View File
@@ -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();
+2
View File
@@ -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;
+26 -6
View File
@@ -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 {
+1
View File
@@ -211,6 +211,7 @@ export enum GameMapSize {
export interface PublicGameModifiers {
isCompact: boolean;
isRandomSpawn: boolean;
startingGold?: number;
}
export interface UnitInfo {
+1 -1
View File
@@ -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));
}
+6 -2
View File
@@ -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) {
+6 -2
View File
@@ -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
};
}