mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
Added a public game modifier system 😮 For more variety (#2801)
## Description: Added a public game modifier system. It causes that 5% of public games are played on the compact version of the map 10% of public games have "Random Spawn" activated Percentages can easily get changed via `DefaultConfig`. We can also easily add more modifiers. Modifiers can stack, so in rare cases you will play on a compact map with random spawn 😄 More variety! ### "Compact Map" modifier implementation - With the "Compact Map" modifier the lobby max player count gets reduced to 25% and only 25% of the regular bots and only 25% of the regular nations will spawn (because the map has only 25% of its regular size) - In private lobbies and singleplayer the nation reduction happens too (When "Compact Map" is enabled). ### Restrictions - Duos/Trios/Quads team modes do not get Random Spawn (defeats the purpose) - Maps with smallest player count < 50 do not get Compact Map in team games (not enough players after the reduction to 25%). I have calculated all the possible max player counts. ### How it looks like Random Spawn modifier: <img width="528" height="183" alt="Screenshot 2026-01-06 194959" src="https://github.com/user-attachments/assets/2f729da9-80c3-4548-8205-71129da2a76a" /> Very rare case: Two modifiers at the same time and only 10 max players have been chosen from `[GameMapType.FaroeIslands]: [20, 15, 10]`. Because of the 75% reduction in player count only 3 players are allowed (3 is the minimum). I think its funny that you can play a 1v1v1 in rare occasions 😄 <img width="526" height="184" alt="Screenshot 2026-01-06 194938" src="https://github.com/user-attachments/assets/834326eb-df03-41b7-b1db-1efa3f1013b5" /> ### Funny side-effect Team games with random spawn. That will be interesting. No more "Who is better in donating troops to the frontline". Instead you have to heavily coordinate with your teammates. ## 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:
@@ -363,6 +363,10 @@
|
||||
"ffa": "Free for All",
|
||||
"teams": "Teams"
|
||||
},
|
||||
"public_game_modifier": {
|
||||
"random_spawn": "Random Spawn",
|
||||
"compact_map": "Compact Map"
|
||||
},
|
||||
"select_lang": {
|
||||
"title": "Select Language"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
UnitType,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import { getCompactMapNationCount } from "../core/game/NationCreation";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import {
|
||||
ClientInfo,
|
||||
@@ -944,6 +945,7 @@ export class HostLobbyModal extends LitElement {
|
||||
/**
|
||||
* Returns the effective nation count for display purposes.
|
||||
* In HumansVsNations mode, this equals the number of human players.
|
||||
* For compact maps, only 25% of nations are used.
|
||||
* Otherwise, it uses the manifest nation count (or 0 if nations are disabled).
|
||||
*/
|
||||
private getEffectiveNationCount(): number {
|
||||
@@ -953,7 +955,7 @@ export class HostLobbyModal extends LitElement {
|
||||
if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) {
|
||||
return this.clients.length;
|
||||
}
|
||||
return this.nationCount;
|
||||
return getCompactMapNationCount(this.nationCount, this.compactMap);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+44
-11
@@ -7,6 +7,7 @@ import {
|
||||
GameMode,
|
||||
hasUnusualThumbnailSize,
|
||||
HumansVsNations,
|
||||
PublicGameModifiers,
|
||||
Quads,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
@@ -114,6 +115,10 @@ export class PublicLobby extends LitElement {
|
||||
: `${modeLabel} ${teamDetailLabel}`;
|
||||
}
|
||||
|
||||
const modifierLabel = this.getModifierLabels(
|
||||
lobby.gameConfig.publicGameModifiers,
|
||||
);
|
||||
|
||||
const mapImageSrc = this.mapImages.get(lobby.gameID);
|
||||
const isUnusualThumbnailSize = hasUnusualThumbnailSize(
|
||||
lobby.gameConfig.gameMap,
|
||||
@@ -156,17 +161,29 @@ export class PublicLobby extends LitElement {
|
||||
.join("")}`
|
||||
: translateText("public_lobby.join")}
|
||||
</div>
|
||||
<div class="text-md font-medium text-white-400">
|
||||
<span class="text-sm text-blue-600 bg-white rounded-xs px-1 mr-1">
|
||||
${fullModeLabel}
|
||||
</span>
|
||||
<span>
|
||||
${translateText(
|
||||
`map.${lobby.gameConfig.gameMap
|
||||
.toLowerCase()
|
||||
.replace(/[\s.]+/g, "")}`,
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
class="text-md font-medium text-white-400 flex flex-wrap justify-end items-center gap-1"
|
||||
>
|
||||
<span
|
||||
class="text-sm whitespace-nowrap ${this.isLobbyHighlighted
|
||||
? "text-green-600"
|
||||
: "text-blue-600"} bg-white rounded-xs px-1"
|
||||
>${fullModeLabel}</span
|
||||
>
|
||||
${modifierLabel.map(
|
||||
(label) =>
|
||||
html`<span
|
||||
class="text-sm whitespace-nowrap ${this.isLobbyHighlighted
|
||||
? "text-green-600"
|
||||
: "text-blue-600"} bg-white rounded-xs px-1"
|
||||
>${label}</span
|
||||
>`,
|
||||
)}
|
||||
<span class="whitespace-nowrap"
|
||||
>${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -293,6 +310,22 @@ export class PublicLobby extends LitElement {
|
||||
return { label: null, isFullLabel: false };
|
||||
}
|
||||
|
||||
private getModifierLabels(
|
||||
publicGameModifiers: PublicGameModifiers | undefined,
|
||||
): string[] {
|
||||
if (!publicGameModifiers) {
|
||||
return [];
|
||||
}
|
||||
const labels: string[] = [];
|
||||
if (publicGameModifiers.isRandomSpawn) {
|
||||
labels.push(translateText("public_game_modifier.random_spawn"));
|
||||
}
|
||||
if (publicGameModifiers.isCompact) {
|
||||
labels.push(translateText("public_game_modifier.compact_map"));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private lobbyClicked(lobby: GameInfo) {
|
||||
if (this.isButtonDebounced) return;
|
||||
|
||||
|
||||
@@ -171,6 +171,12 @@ export const GameConfigSchema = z.object({
|
||||
gameType: z.enum(GameType),
|
||||
gameMode: z.enum(GameMode),
|
||||
gameMapSize: z.enum(GameMapSize),
|
||||
publicGameModifiers: z
|
||||
.object({
|
||||
isCompact: z.boolean(),
|
||||
isRandomSpawn: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
disableNations: z.boolean(),
|
||||
bots: z.number().int().min(0).max(400),
|
||||
infiniteGold: z.boolean(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Gold,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PublicGameModifiers,
|
||||
Team,
|
||||
TerraNullius,
|
||||
Tick,
|
||||
@@ -34,6 +35,7 @@ export interface ServerConfig {
|
||||
map: GameMapType,
|
||||
mode: GameMode,
|
||||
numPlayerTeams: TeamCountConfig | undefined,
|
||||
isCompactMap?: boolean,
|
||||
): number;
|
||||
numWorkers(): number;
|
||||
workerIndex(gameID: GameID): number;
|
||||
@@ -57,6 +59,8 @@ export interface ServerConfig {
|
||||
stripePublishableKey(): string;
|
||||
allowedFlares(): string[] | undefined;
|
||||
enableMatchmaking(): boolean;
|
||||
getRandomPublicGameModifiers(): PublicGameModifiers;
|
||||
supportsCompactMapForTeams(map: GameMapType): boolean;
|
||||
}
|
||||
|
||||
export interface NukeMagnitude {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
PublicGameModifiers,
|
||||
Quads,
|
||||
TerrainType,
|
||||
TerraNullius,
|
||||
@@ -176,11 +177,16 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
map: GameMapType,
|
||||
mode: GameMode,
|
||||
numPlayerTeams: TeamCountConfig | undefined,
|
||||
isCompactMap?: boolean,
|
||||
): number {
|
||||
const [l, m, s] = numPlayersConfig[map] ?? [50, 30, 20];
|
||||
const r = Math.random();
|
||||
const base = r < 0.3 ? l : r < 0.6 ? m : s;
|
||||
let p = Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, l);
|
||||
// Apply compact map 75% player reduction
|
||||
if (isCompactMap) {
|
||||
p = Math.max(3, Math.floor(p * 0.25));
|
||||
}
|
||||
if (numPlayerTeams === undefined) return p;
|
||||
switch (numPlayerTeams) {
|
||||
case Duos:
|
||||
@@ -218,6 +224,20 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
enableMatchmaking(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
getRandomPublicGameModifiers(): PublicGameModifiers {
|
||||
return {
|
||||
isRandomSpawn: Math.random() < 0.1, // 10% chance
|
||||
isCompact: Math.random() < 0.05, // 5% chance
|
||||
};
|
||||
}
|
||||
|
||||
supportsCompactMapForTeams(map: GameMapType): boolean {
|
||||
// Maps with smallest player count < 50 don't support compact map in team games
|
||||
// The smallest player count is the 3rd number in numPlayersConfig
|
||||
const [, , smallest] = numPlayersConfig[map] ?? [50, 30, 20];
|
||||
return smallest >= 50;
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultConfig implements Config {
|
||||
|
||||
@@ -193,6 +193,11 @@ export enum GameMapSize {
|
||||
Normal = "Normal",
|
||||
}
|
||||
|
||||
export interface PublicGameModifiers {
|
||||
isCompact: boolean;
|
||||
isRandomSpawn: boolean;
|
||||
}
|
||||
|
||||
export interface UnitInfo {
|
||||
cost: (game: Game, player: Player) => Gold;
|
||||
// Determines if its owner changes when its tile is conquered.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameStartInfo } from "../Schemas";
|
||||
import {
|
||||
Cell,
|
||||
GameMapSize,
|
||||
GameMode,
|
||||
GameType,
|
||||
HumansVsNations,
|
||||
@@ -14,6 +15,7 @@ import { Nation as ManifestNation } from "./TerrainMapLoader";
|
||||
/**
|
||||
* Creates the nations array for a game, handling HumansVsNations mode specially.
|
||||
* In HumansVsNations mode, the number of nations matches the number of human players to ensure fair gameplay.
|
||||
* For compact maps, only 25% of the nations are used.
|
||||
*/
|
||||
export function createNationsForGame(
|
||||
gameStart: GameStartInfo,
|
||||
@@ -31,13 +33,23 @@ export function createNationsForGame(
|
||||
new PlayerInfo(n.name, PlayerType.Nation, null, random.nextID()),
|
||||
);
|
||||
|
||||
const isCompactMap = gameStart.config.gameMapSize === GameMapSize.Compact;
|
||||
|
||||
const isHumansVsNations =
|
||||
gameStart.config.gameMode === GameMode.Team &&
|
||||
gameStart.config.playerTeams === HumansVsNations;
|
||||
|
||||
// For non-HumansVsNations modes, simply use the manifest nations
|
||||
// For compact maps, use only 25% of nations (minimum 1)
|
||||
let effectiveNations = manifestNations;
|
||||
if (isCompactMap && !isHumansVsNations) {
|
||||
const targetCount = getCompactMapNationCount(manifestNations.length, true);
|
||||
const shuffled = random.shuffleArray(manifestNations);
|
||||
effectiveNations = shuffled.slice(0, targetCount);
|
||||
}
|
||||
|
||||
// For non-HumansVsNations modes, simply use the effective nations
|
||||
if (!isHumansVsNations) {
|
||||
return manifestNations.map(toNation);
|
||||
return effectiveNations.map(toNation);
|
||||
}
|
||||
|
||||
// HumansVsNations mode: balance nation count to match human count
|
||||
@@ -71,6 +83,20 @@ export function createNationsForGame(
|
||||
return nations;
|
||||
}
|
||||
|
||||
// For compact maps, only 25% of nations are used (minimum 1).
|
||||
export function getCompactMapNationCount(
|
||||
manifestNationCount: number,
|
||||
isCompactMap: boolean,
|
||||
): number {
|
||||
if (manifestNationCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (isCompactMap) {
|
||||
return Math.max(1, Math.floor(manifestNationCount * 0.25));
|
||||
}
|
||||
return manifestNationCount;
|
||||
}
|
||||
|
||||
const PLURAL_NOUN = Symbol("plural!");
|
||||
const NOUN = Symbol("noun!");
|
||||
|
||||
|
||||
@@ -93,25 +93,43 @@ export class MapPlaylist {
|
||||
const playerTeams =
|
||||
mode === GameMode.Team ? this.getTeamCount() : undefined;
|
||||
|
||||
let { isCompact, isRandomSpawn } = config.getRandomPublicGameModifiers();
|
||||
|
||||
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
|
||||
if (
|
||||
playerTeams === Duos ||
|
||||
playerTeams === Trios ||
|
||||
playerTeams === Quads
|
||||
) {
|
||||
isRandomSpawn = false;
|
||||
}
|
||||
|
||||
// Maps with smallest player count < 50 don't support compact map in team games
|
||||
// The smallest player count is the 3rd number in numPlayersConfig
|
||||
if (mode === GameMode.Team && !config.supportsCompactMapForTeams(map)) {
|
||||
isCompact = false;
|
||||
}
|
||||
|
||||
// Create the default public game config (from your GameManager)
|
||||
return {
|
||||
donateGold: mode === GameMode.Team,
|
||||
donateTroops: mode === GameMode.Team,
|
||||
gameMap: map,
|
||||
maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams),
|
||||
maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams, isCompact),
|
||||
gameType: GameType.Public,
|
||||
gameMapSize: GameMapSize.Normal,
|
||||
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
|
||||
publicGameModifiers: { isCompact, isRandomSpawn },
|
||||
difficulty:
|
||||
playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
maxTimerValue: undefined,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
randomSpawn: isRandomSpawn,
|
||||
disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations,
|
||||
gameMode: mode,
|
||||
playerTeams,
|
||||
bots: 400,
|
||||
bots: isCompact ? 100 : 400,
|
||||
spawnImmunityDuration: 5 * 10,
|
||||
disabledUnits: [],
|
||||
} satisfies GameConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JWK } from "jose";
|
||||
import { GameEnv, ServerConfig } from "../../src/core/configuration/Config";
|
||||
import { GameMapType } from "../../src/core/game/Game";
|
||||
import { GameMapType, PublicGameModifiers } from "../../src/core/game/Game";
|
||||
import { GameID } from "../../src/core/Schemas";
|
||||
|
||||
export class TestServerConfig implements ServerConfig {
|
||||
@@ -82,4 +82,10 @@ export class TestServerConfig implements ServerConfig {
|
||||
gitCommit(): string {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getRandomPublicGameModifiers(): PublicGameModifiers {
|
||||
return { isCompact: false, isRandomSpawn: false };
|
||||
}
|
||||
supportsCompactMapForTeams(map: GameMapType): boolean {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user