mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-07 11:09:34 +00:00
For v30: Add new modifiers (Hard nations and 25M Starting Gold) 🙂 (#3316)
## Description: Adds two new public game modifiers for variety and improves compact map eligibility for team games. ### New Modifiers **Hard Nations (`isHardNations`)** - We need this modifier for HvN, because medium nations are easier now (will result in a much higher human winrate) - In a discord discussion we concluded that HvN should generally be easier (higher winrate than 50%, so players are less frustated) - Thats why only 20% of HvN games have the hard nations modifier (for now) - For PvPvE enjoyers, the modifier is also active in FFA games => (Only 2.5% chance, and 1 ticket in `SPECIAL_MODIFIER_POOL`) **25M Starting Gold (`startingGoldHigh`)** - Some people in the main discord wanted this modifier, and it will result in crazy games - Rare special-only modifier (1 ticket in pool); mutually exclusive with 5M starting gold via `MUTUALLY_EXCLUSIVE_MODIFIERS` - Disables nations (they lack PVP immunity, so 25M gold doesn't work well with them) - Excluded from HumansVsNations games (since it disables nations) - Spawn immunity set to **2 minutes 30 seconds** (vs 30s for 5M gold), so people can spend the gold and prepare ### Other Changes - **Improved `supportsCompactMapForTeams`**: Replaced the hard `smallest >= 50` land-tile cutoff with a per-team-config calculation that simulates worst-case compact player count and checks every team gets at least 2 players. - **HvN spawn immunity**: Always 5 seconds in both regular and special lobbies (to get rid of a confusing PVP immunity HeadsUpMessage in 5M starting gold games) - **Regular public lobby random spawn modifier probabilty**: Reduced from 10% to 5% (Because of the new modifier, so there aren't too many modifiers in non-special-lobbies, should only occur sometimes there) - Rebalanced `SPECIAL_MODIFIER_POOL` a bit ## 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:
@@ -427,7 +427,8 @@
|
|||||||
"teams_Quads": "Quads (teams of 4)",
|
"teams_Quads": "Quads (teams of 4)",
|
||||||
"teams_Humans Vs Nations": "Humans vs Nations",
|
"teams_Humans Vs Nations": "Humans vs Nations",
|
||||||
"starting_gold": "Starting gold",
|
"starting_gold": "Starting gold",
|
||||||
"crowded": "Crowded modifier"
|
"crowded": "Crowded modifier",
|
||||||
|
"hard_nations": "Hard Nations"
|
||||||
},
|
},
|
||||||
"team_colors": {
|
"team_colors": {
|
||||||
"red": "Red",
|
"red": "Red",
|
||||||
@@ -469,7 +470,8 @@
|
|||||||
"random_spawn": "Random Spawn",
|
"random_spawn": "Random Spawn",
|
||||||
"compact_map": "Compact Map",
|
"compact_map": "Compact Map",
|
||||||
"crowded": "Crowded",
|
"crowded": "Crowded",
|
||||||
"starting_gold": "5M Starting Gold"
|
"hard_nations": "Hard Nations",
|
||||||
|
"starting_gold": "{amount}M Starting Gold"
|
||||||
},
|
},
|
||||||
"select_lang": {
|
"select_lang": {
|
||||||
"title": "Select Language"
|
"title": "Select Language"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
GameMapType,
|
GameMapType,
|
||||||
GameMode,
|
GameMode,
|
||||||
HumansVsNations,
|
HumansVsNations,
|
||||||
PublicGameModifiers,
|
|
||||||
Quads,
|
Quads,
|
||||||
Trios,
|
Trios,
|
||||||
} from "../core/game/Game";
|
} from "../core/game/Game";
|
||||||
@@ -16,7 +15,12 @@ import { PublicLobbySocket } from "./LobbySocket";
|
|||||||
import { JoinLobbyEvent } from "./Main";
|
import { JoinLobbyEvent } from "./Main";
|
||||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||||
import { getMapName, renderDuration, translateText } from "./Utils";
|
import {
|
||||||
|
getMapName,
|
||||||
|
getModifierLabels,
|
||||||
|
renderDuration,
|
||||||
|
translateText,
|
||||||
|
} from "./Utils";
|
||||||
|
|
||||||
const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]";
|
const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]";
|
||||||
|
|
||||||
@@ -198,7 +202,7 @@ export class GameModeSelector extends LitElement {
|
|||||||
|
|
||||||
const mapName = getMapName(lobby.gameConfig?.gameMap);
|
const mapName = getMapName(lobby.gameConfig?.gameMap);
|
||||||
|
|
||||||
const modifierLabels = this.getModifierLabels(
|
const modifierLabels = getModifierLabels(
|
||||||
lobby.gameConfig?.publicGameModifiers,
|
lobby.gameConfig?.publicGameModifiers,
|
||||||
);
|
);
|
||||||
// Sort by length for visual consistency (shorter labels first)
|
// Sort by length for visual consistency (shorter labels first)
|
||||||
@@ -283,16 +287,6 @@ export class GameModeSelector extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getModifierLabels(mods: PublicGameModifiers | undefined): string[] {
|
|
||||||
if (!mods) return [];
|
|
||||||
return [
|
|
||||||
mods.isRandomSpawn && translateText("public_game_modifier.random_spawn"),
|
|
||||||
mods.isCompact && translateText("public_game_modifier.compact_map"),
|
|
||||||
mods.isCrowded && translateText("public_game_modifier.crowded"),
|
|
||||||
mods.startingGold && translateText("public_game_modifier.starting_gold"),
|
|
||||||
].filter((x): x is string => !!x);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLobbyTitle(lobby: PublicGameInfo): string {
|
private getLobbyTitle(lobby: PublicGameInfo): string {
|
||||||
const config = lobby.gameConfig!;
|
const config = lobby.gameConfig!;
|
||||||
if (config.gameMode === GameMode.FFA) {
|
if (config.gameMode === GameMode.FFA) {
|
||||||
|
|||||||
+14
-1
@@ -109,6 +109,8 @@ export interface ModifierInfo {
|
|||||||
labelKey: string;
|
labelKey: string;
|
||||||
/** Translation key for badge/short label (e.g. "public_game_modifier.random_spawn") */
|
/** Translation key for badge/short label (e.g. "public_game_modifier.random_spawn") */
|
||||||
badgeKey: string;
|
badgeKey: string;
|
||||||
|
/** Parameters to pass to translateText for the badge key */
|
||||||
|
badgeParams?: Record<string, string | number>;
|
||||||
/** The raw value if applicable (e.g. startingGold amount) */
|
/** The raw value if applicable (e.g. startingGold amount) */
|
||||||
value?: number;
|
value?: number;
|
||||||
}
|
}
|
||||||
@@ -139,10 +141,19 @@ export function getActiveModifiers(
|
|||||||
badgeKey: "public_game_modifier.crowded",
|
badgeKey: "public_game_modifier.crowded",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (modifiers.isHardNations) {
|
||||||
|
result.push({
|
||||||
|
labelKey: "host_modal.hard_nations",
|
||||||
|
badgeKey: "public_game_modifier.hard_nations",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (modifiers.startingGold) {
|
if (modifiers.startingGold) {
|
||||||
result.push({
|
result.push({
|
||||||
labelKey: "host_modal.starting_gold",
|
labelKey: "host_modal.starting_gold",
|
||||||
badgeKey: "public_game_modifier.starting_gold",
|
badgeKey: "public_game_modifier.starting_gold",
|
||||||
|
badgeParams: {
|
||||||
|
amount: Math.round(modifiers.startingGold / 1_000_000),
|
||||||
|
},
|
||||||
value: modifiers.startingGold,
|
value: modifiers.startingGold,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -155,7 +166,9 @@ export function getActiveModifiers(
|
|||||||
export function getModifierLabels(
|
export function getModifierLabels(
|
||||||
modifiers: PublicGameModifiers | undefined,
|
modifiers: PublicGameModifiers | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
return getActiveModifiers(modifiers).map((m) => translateText(m.badgeKey));
|
return getActiveModifiers(modifiers).map((m) =>
|
||||||
|
translateText(m.badgeKey, m.badgeParams),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderDuration(totalSeconds: number): string {
|
export function renderDuration(totalSeconds: number): string {
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ export const GameConfigSchema = z.object({
|
|||||||
isCompact: z.boolean(),
|
isCompact: z.boolean(),
|
||||||
isRandomSpawn: z.boolean(),
|
isRandomSpawn: z.boolean(),
|
||||||
isCrowded: z.boolean(),
|
isCrowded: z.boolean(),
|
||||||
|
isHardNations: z.boolean(),
|
||||||
startingGold: z.number().int().min(0).optional(),
|
startingGold: z.number().int().min(0).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export interface PublicGameModifiers {
|
|||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
isRandomSpawn: boolean;
|
isRandomSpawn: boolean;
|
||||||
isCrowded: boolean;
|
isCrowded: boolean;
|
||||||
|
isHardNations: boolean;
|
||||||
startingGold?: number;
|
startingGold?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+174
-40
@@ -89,16 +89,33 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
|
|||||||
{ config: HumansVsNations, weight: 20 },
|
{ config: HumansVsNations, weight: 20 },
|
||||||
];
|
];
|
||||||
|
|
||||||
type ModifierKey = "isRandomSpawn" | "isCompact" | "isCrowded" | "startingGold";
|
type ModifierKey =
|
||||||
|
| "isRandomSpawn"
|
||||||
|
| "isCompact"
|
||||||
|
| "isCrowded"
|
||||||
|
| "isHardNations"
|
||||||
|
| "startingGold"
|
||||||
|
| "startingGoldHigh";
|
||||||
|
|
||||||
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
|
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
|
||||||
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
|
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
|
||||||
...Array<ModifierKey>(4).fill("isRandomSpawn"),
|
...Array<ModifierKey>(4).fill("isRandomSpawn"),
|
||||||
...Array<ModifierKey>(7).fill("isCompact"),
|
...Array<ModifierKey>(8).fill("isCompact"),
|
||||||
...Array<ModifierKey>(1).fill("isCrowded"),
|
...Array<ModifierKey>(1).fill("isCrowded"),
|
||||||
...Array<ModifierKey>(6).fill("startingGold"),
|
...Array<ModifierKey>(1).fill("isHardNations"),
|
||||||
|
...Array<ModifierKey>(8).fill("startingGold"),
|
||||||
|
...Array<ModifierKey>(1).fill("startingGoldHigh"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Modifiers that cannot be active at the same time.
|
||||||
|
const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
|
||||||
|
["startingGold", "startingGoldHigh"],
|
||||||
|
["isHardNations", "startingGoldHigh"],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Probability of hard nations modifier in HumansVsNations games.
|
||||||
|
const HARD_NATIONS_HVN_PROBABILITY = 0.2; // 20%
|
||||||
|
|
||||||
export class MapPlaylist {
|
export class MapPlaylist {
|
||||||
private playlists: Record<PublicGameType, GameMapType[]> = {
|
private playlists: Record<PublicGameType, GameMapType[]> = {
|
||||||
ffa: [],
|
ffa: [],
|
||||||
@@ -111,17 +128,15 @@ export class MapPlaylist {
|
|||||||
return this.getSpecialConfig();
|
return this.getSpecialConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: consider moving modifier to special lobby.
|
|
||||||
|
|
||||||
const mode = type === "ffa" ? GameMode.FFA : GameMode.Team;
|
const mode = type === "ffa" ? GameMode.FFA : GameMode.Team;
|
||||||
const map = this.getNextMap(type);
|
const map = this.getNextMap(type);
|
||||||
|
|
||||||
const playerTeams =
|
const playerTeams =
|
||||||
mode === GameMode.Team ? this.getTeamCount() : undefined;
|
mode === GameMode.Team ? this.getTeamCount() : undefined;
|
||||||
|
|
||||||
const modifiers = this.getRandomPublicGameModifiers();
|
const modifiers = this.getRandomPublicGameModifiers(playerTeams);
|
||||||
const { startingGold } = modifiers;
|
const { startingGold } = modifiers;
|
||||||
let { isCompact, isRandomSpawn, isCrowded } = modifiers;
|
let { isCompact, isRandomSpawn, isCrowded, isHardNations } = modifiers;
|
||||||
|
|
||||||
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
|
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
|
||||||
if (
|
if (
|
||||||
@@ -132,11 +147,16 @@ export class MapPlaylist {
|
|||||||
isRandomSpawn = false;
|
isRandomSpawn = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games
|
// Hard nations modifier only applies when nations are present
|
||||||
// (not enough players after 75% player reduction for compact maps)
|
if (mode === GameMode.Team && playerTeams !== HumansVsNations) {
|
||||||
|
isHardNations = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if compact map would leave every team with at least 2 players
|
||||||
if (
|
if (
|
||||||
|
isCompact &&
|
||||||
mode === GameMode.Team &&
|
mode === GameMode.Team &&
|
||||||
!(await this.supportsCompactMapForTeams(map))
|
!(await this.supportsCompactMapForTeams(map, playerTeams!))
|
||||||
) {
|
) {
|
||||||
isCompact = false;
|
isCompact = false;
|
||||||
}
|
}
|
||||||
@@ -167,10 +187,11 @@ export class MapPlaylist {
|
|||||||
isCompact,
|
isCompact,
|
||||||
isRandomSpawn,
|
isRandomSpawn,
|
||||||
isCrowded,
|
isCrowded,
|
||||||
|
isHardNations,
|
||||||
startingGold,
|
startingGold,
|
||||||
},
|
},
|
||||||
startingGold,
|
startingGold,
|
||||||
difficulty: Difficulty.Medium,
|
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
|
||||||
infiniteGold: false,
|
infiniteGold: false,
|
||||||
infiniteTroops: false,
|
infiniteTroops: false,
|
||||||
maxTimerValue: undefined,
|
maxTimerValue: undefined,
|
||||||
@@ -180,7 +201,10 @@ export class MapPlaylist {
|
|||||||
gameMode: mode,
|
gameMode: mode,
|
||||||
playerTeams,
|
playerTeams,
|
||||||
bots: isCompact ? 100 : 400,
|
bots: isCompact ? 100 : 400,
|
||||||
spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10,
|
spawnImmunityDuration: this.getSpawnImmunityDuration(
|
||||||
|
playerTeams,
|
||||||
|
startingGold,
|
||||||
|
),
|
||||||
disabledUnits: [],
|
disabledUnits: [],
|
||||||
} satisfies GameConfig;
|
} satisfies GameConfig;
|
||||||
}
|
}
|
||||||
@@ -190,12 +214,16 @@ export class MapPlaylist {
|
|||||||
const map = this.getNextMap("special");
|
const map = this.getNextMap("special");
|
||||||
const playerTeams =
|
const playerTeams =
|
||||||
mode === GameMode.Team ? this.getTeamCount() : undefined;
|
mode === GameMode.Team ? this.getTeamCount() : undefined;
|
||||||
const supportsCompact =
|
|
||||||
mode !== GameMode.Team || (await this.supportsCompactMapForTeams(map));
|
|
||||||
const excludedModifiers: ModifierKey[] = [];
|
const excludedModifiers: ModifierKey[] = [];
|
||||||
|
|
||||||
|
const supportsCompact =
|
||||||
|
mode !== GameMode.Team ||
|
||||||
|
(await this.supportsCompactMapForTeams(map, playerTeams!));
|
||||||
if (!supportsCompact) {
|
if (!supportsCompact) {
|
||||||
excludedModifiers.push("isCompact");
|
excludedModifiers.push("isCompact");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
playerTeams === Duos ||
|
playerTeams === Duos ||
|
||||||
playerTeams === Trios ||
|
playerTeams === Trios ||
|
||||||
@@ -204,8 +232,29 @@ export class MapPlaylist {
|
|||||||
excludedModifiers.push("isRandomSpawn");
|
excludedModifiers.push("isRandomSpawn");
|
||||||
}
|
}
|
||||||
|
|
||||||
let { isCrowded, startingGold, isCompact, isRandomSpawn } =
|
// Hard nations: excluded for non-HvN team modes (no nations present).
|
||||||
this.getRandomSpecialGameModifiers(excludedModifiers);
|
// For HumansVsNations: rolled independently (not via pool).
|
||||||
|
// For FFA: stays in the pool for normal ticket-based selection.
|
||||||
|
let hardNationsFromIndependentRoll: boolean | undefined;
|
||||||
|
let poolCountReduction = 0;
|
||||||
|
if (mode === GameMode.Team && playerTeams !== HumansVsNations) {
|
||||||
|
excludedModifiers.push("isHardNations");
|
||||||
|
} else if (playerTeams === HumansVsNations) {
|
||||||
|
excludedModifiers.push("isHardNations");
|
||||||
|
excludedModifiers.push("startingGoldHigh"); // Nations are disabled if that modifier is active
|
||||||
|
hardNationsFromIndependentRoll =
|
||||||
|
Math.random() < HARD_NATIONS_HVN_PROBABILITY;
|
||||||
|
poolCountReduction = hardNationsFromIndependentRoll ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolResult = this.getRandomSpecialGameModifiers(
|
||||||
|
excludedModifiers,
|
||||||
|
undefined,
|
||||||
|
poolCountReduction,
|
||||||
|
);
|
||||||
|
let { isCrowded, startingGold, isCompact, isRandomSpawn } = poolResult;
|
||||||
|
let isHardNations =
|
||||||
|
hardNationsFromIndependentRoll ?? poolResult.isHardNations;
|
||||||
|
|
||||||
let crowdedMaxPlayers: number | undefined;
|
let crowdedMaxPlayers: number | undefined;
|
||||||
if (isCrowded) {
|
if (isCrowded) {
|
||||||
@@ -216,10 +265,21 @@ export class MapPlaylist {
|
|||||||
// Map doesn't support crowded. Drop it and pick one replacement only
|
// Map doesn't support crowded. Drop it and pick one replacement only
|
||||||
// if it was the sole modifier, so the lobby always has at least one.
|
// if it was the sole modifier, so the lobby always has at least one.
|
||||||
isCrowded = false;
|
isCrowded = false;
|
||||||
if (!isRandomSpawn && !isCompact && startingGold === undefined) {
|
if (
|
||||||
|
!isRandomSpawn &&
|
||||||
|
!isCompact &&
|
||||||
|
!isHardNations &&
|
||||||
|
startingGold === undefined
|
||||||
|
) {
|
||||||
excludedModifiers.push("isCrowded");
|
excludedModifiers.push("isCrowded");
|
||||||
({ isRandomSpawn, isCompact, startingGold } =
|
const fallback = this.getRandomSpecialGameModifiers(
|
||||||
this.getRandomSpecialGameModifiers(excludedModifiers, 1));
|
excludedModifiers,
|
||||||
|
1,
|
||||||
|
poolCountReduction,
|
||||||
|
);
|
||||||
|
({ isRandomSpawn, isCompact, startingGold } = fallback);
|
||||||
|
isHardNations =
|
||||||
|
hardNationsFromIndependentRoll ?? fallback.isHardNations;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +290,11 @@ export class MapPlaylist {
|
|||||||
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
|
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const disableNations =
|
||||||
|
(mode === GameMode.Team && playerTeams !== HumansVsNations) ||
|
||||||
|
// Nations don't have PVP immunity, so 25M starting gold wouldn't work well with them
|
||||||
|
(startingGold !== undefined && startingGold >= 25_000_000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
donateGold: mode === GameMode.Team,
|
donateGold: mode === GameMode.Team,
|
||||||
donateTroops: mode === GameMode.Team,
|
donateTroops: mode === GameMode.Team,
|
||||||
@@ -241,20 +306,24 @@ export class MapPlaylist {
|
|||||||
isCompact,
|
isCompact,
|
||||||
isRandomSpawn,
|
isRandomSpawn,
|
||||||
isCrowded,
|
isCrowded,
|
||||||
|
isHardNations,
|
||||||
startingGold,
|
startingGold,
|
||||||
},
|
},
|
||||||
startingGold,
|
startingGold,
|
||||||
difficulty: Difficulty.Medium,
|
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
|
||||||
infiniteGold: false,
|
infiniteGold: false,
|
||||||
infiniteTroops: false,
|
infiniteTroops: false,
|
||||||
maxTimerValue: undefined,
|
maxTimerValue: undefined,
|
||||||
instantBuild: false,
|
instantBuild: false,
|
||||||
randomSpawn: isRandomSpawn,
|
randomSpawn: isRandomSpawn,
|
||||||
disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations,
|
disableNations,
|
||||||
gameMode: mode,
|
gameMode: mode,
|
||||||
playerTeams,
|
playerTeams,
|
||||||
bots: isCompact ? 100 : 400,
|
bots: isCompact ? 100 : 400,
|
||||||
spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10,
|
spawnImmunityDuration: this.getSpawnImmunityDuration(
|
||||||
|
playerTeams,
|
||||||
|
startingGold,
|
||||||
|
),
|
||||||
disabledUnits: [],
|
disabledUnits: [],
|
||||||
} satisfies GameConfig;
|
} satisfies GameConfig;
|
||||||
}
|
}
|
||||||
@@ -375,30 +444,39 @@ export class MapPlaylist {
|
|||||||
return TEAM_WEIGHTS[0].config;
|
return TEAM_WEIGHTS[0].config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRandomPublicGameModifiers(): PublicGameModifiers {
|
private getRandomPublicGameModifiers(
|
||||||
|
playerTeams?: TeamCountConfig,
|
||||||
|
): PublicGameModifiers {
|
||||||
return {
|
return {
|
||||||
isRandomSpawn: Math.random() < 0.1, // 10% chance
|
isRandomSpawn: Math.random() < 0.05, // 5% chance
|
||||||
isCompact: Math.random() < 0.05, // 5% chance
|
isCompact: Math.random() < 0.05, // 5% chance
|
||||||
isCrowded: Math.random() < 0.05, // 5% chance
|
isCrowded: Math.random() < 0.05, // 5% chance
|
||||||
startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance
|
startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance
|
||||||
|
isHardNations:
|
||||||
|
playerTeams === HumansVsNations
|
||||||
|
? Math.random() < HARD_NATIONS_HVN_PROBABILITY
|
||||||
|
: Math.random() < 0.025, // 2.5% chance
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRandomSpecialGameModifiers(
|
private getRandomSpecialGameModifiers(
|
||||||
excludedModifiers: ModifierKey[] = [],
|
excludedModifiers: ModifierKey[] = [],
|
||||||
count?: number,
|
count?: number,
|
||||||
|
countReduction: number = 0,
|
||||||
): PublicGameModifiers {
|
): PublicGameModifiers {
|
||||||
// Roll how many modifiers to pick: 30% → 1, 40% → 2, 20% → 3, 10% → 4
|
// Roll how many modifiers to pick: 30% → 1, 40% → 2, 20% → 3, 10% → 4
|
||||||
const modifierCountRoll = Math.floor(Math.random() * 10) + 1;
|
const modifierCountRoll = Math.floor(Math.random() * 10) + 1;
|
||||||
const k =
|
const k = Math.max(
|
||||||
count ??
|
0,
|
||||||
(modifierCountRoll <= 3
|
(count ??
|
||||||
? 1
|
(modifierCountRoll <= 3
|
||||||
: modifierCountRoll <= 7
|
? 1
|
||||||
? 2
|
: modifierCountRoll <= 7
|
||||||
: modifierCountRoll <= 9
|
? 2
|
||||||
? 3
|
: modifierCountRoll <= 9
|
||||||
: 4);
|
? 3
|
||||||
|
: 4)) - countReduction,
|
||||||
|
);
|
||||||
|
|
||||||
// Shuffle the pool, then pick the first k unique modifier keys.
|
// Shuffle the pool, then pick the first k unique modifier keys.
|
||||||
const pool = SPECIAL_MODIFIER_POOL.filter(
|
const pool = SPECIAL_MODIFIER_POOL.filter(
|
||||||
@@ -408,23 +486,79 @@ export class MapPlaylist {
|
|||||||
const selected = new Set<ModifierKey>();
|
const selected = new Set<ModifierKey>();
|
||||||
for (const key of pool) {
|
for (const key of pool) {
|
||||||
if (selected.size >= k) break;
|
if (selected.size >= k) break;
|
||||||
selected.add(key);
|
// Skip if a mutually exclusive modifier is already selected
|
||||||
|
const blocked = MUTUALLY_EXCLUSIVE_MODIFIERS.some(
|
||||||
|
([a, b]) =>
|
||||||
|
(key === a && selected.has(b)) || (key === b && selected.has(a)),
|
||||||
|
);
|
||||||
|
if (!blocked) selected.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isRandomSpawn: selected.has("isRandomSpawn"),
|
isRandomSpawn: selected.has("isRandomSpawn"),
|
||||||
isCompact: selected.has("isCompact"),
|
isCompact: selected.has("isCompact"),
|
||||||
isCrowded: selected.has("isCrowded"),
|
isCrowded: selected.has("isCrowded"),
|
||||||
startingGold: selected.has("startingGold") ? 5_000_000 : undefined,
|
isHardNations: selected.has("isHardNations"),
|
||||||
|
startingGold: selected.has("startingGoldHigh")
|
||||||
|
? 25_000_000
|
||||||
|
: selected.has("startingGold")
|
||||||
|
? 5_000_000
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games
|
// Check whether a compact map still gives every team at least 2 players,
|
||||||
// (not enough players after 75% player reduction for compact maps)
|
// using the worst-case player tier (smallest) from lobbyMaxPlayers.
|
||||||
private async supportsCompactMapForTeams(map: GameMapType): Promise<boolean> {
|
private async supportsCompactMapForTeams(
|
||||||
|
map: GameMapType,
|
||||||
|
playerTeams: TeamCountConfig,
|
||||||
|
): Promise<boolean> {
|
||||||
const landTiles = await getMapLandTiles(map);
|
const landTiles = await getMapLandTiles(map);
|
||||||
const [, , smallest] = this.calculateMapPlayerCounts(landTiles);
|
const [l, , s] = this.calculateMapPlayerCounts(landTiles);
|
||||||
return smallest >= 50;
|
// Worst case: smallest tier with team mode 1.5x multiplier, capped at l
|
||||||
|
let p = Math.min(Math.ceil(s * 1.5), l);
|
||||||
|
// Apply compact 75% player reduction
|
||||||
|
p = Math.max(3, Math.floor(p * 0.25));
|
||||||
|
// Apply team adjustment
|
||||||
|
p = this.adjustForTeams(p, playerTeams);
|
||||||
|
// Check at least 2 players per team
|
||||||
|
return this.playersPerTeam(p, playerTeams) >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private playersPerTeam(
|
||||||
|
adjustedPlayerCount: number,
|
||||||
|
playerTeams: TeamCountConfig,
|
||||||
|
): number {
|
||||||
|
switch (playerTeams) {
|
||||||
|
case Duos:
|
||||||
|
return Math.min(2, adjustedPlayerCount);
|
||||||
|
case Trios:
|
||||||
|
return Math.min(3, adjustedPlayerCount);
|
||||||
|
case Quads:
|
||||||
|
return Math.min(4, adjustedPlayerCount);
|
||||||
|
case HumansVsNations:
|
||||||
|
return adjustedPlayerCount; // adjustedPlayerCount is the human count
|
||||||
|
default:
|
||||||
|
return Math.floor(adjustedPlayerCount / playerTeams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralised spawn-immunity duration logic.
|
||||||
|
* - HumansVsNations: always 5s (nations can't benefit from longer PVP immunity)
|
||||||
|
* - 25M starting gold: 2:30 (extra time to compensate for high gold)
|
||||||
|
* - 5M starting gold: 30s
|
||||||
|
* - Default: 5s
|
||||||
|
*/
|
||||||
|
private getSpawnImmunityDuration(
|
||||||
|
playerTeams?: TeamCountConfig,
|
||||||
|
startingGold?: number,
|
||||||
|
): number {
|
||||||
|
if (playerTeams === HumansVsNations) return 5 * 10;
|
||||||
|
if (startingGold !== undefined && startingGold >= 25_000_000)
|
||||||
|
return 150 * 10;
|
||||||
|
if (startingGold) return 30 * 10;
|
||||||
|
return 5 * 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCrowdedMaxPlayers(
|
private async getCrowdedMaxPlayers(
|
||||||
|
|||||||
@@ -80,7 +80,12 @@ export class TestServerConfig implements ServerConfig {
|
|||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
getRandomPublicGameModifiers(): PublicGameModifiers {
|
getRandomPublicGameModifiers(): PublicGameModifiers {
|
||||||
return { isCompact: false, isRandomSpawn: false, isCrowded: false };
|
return {
|
||||||
|
isCompact: false,
|
||||||
|
isRandomSpawn: false,
|
||||||
|
isCrowded: false,
|
||||||
|
isHardNations: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
async supportsCompactMapForTeams(): Promise<boolean> {
|
async supportsCompactMapForTeams(): Promise<boolean> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
|
|||||||
Reference in New Issue
Block a user