mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
Re-Enable HumansVsNations 🎉 (#2689)
## Description: **HumansVsNations is back!** The original PR had an issue: only the nations listed in the map’s `manifest.json` were being spawned, which resulted in completely unbalanced games. What did I change with this PR? - The number of humans and nations is now always the same. - If a map contains too many nations, we take a random subset. - If a map doesn’t contain enough nations, we dynamically add additional ones. These get random spawn locations, and their names are taken from the new name generator `NationNames.ts`. The name generator was taken from the closed PR #2245 (idea from @VariableVince). These changes apply to private lobbies and singleplayer as well. In singleplayer, you now simply play a 1-vs-1 against a nation. For public lobbies, we use 50% of the regular team-game player count. The remaining 50% are nations. We are also using the Hard difficulty for HumansVsNations. At the moment, it’s important that nations cheat a little because humans can donate troops, whereas nations cannot, at least not yet. In the future, we may make that work. We might need to adjust the difficulty or do fine-tuning depending on the humans’ win rate in production. Ideally, we want a ~50% win rate; otherwise, the mode may become boring. Over time, humans will likely develop strategies that nations can’t counter, in which case we’ll need to improve the nation AI. Here is a screenshot showing that the number of nations now matches the number of humans in the private lobby UI: <img width="806" height="304" alt="Screenshot 2025-12-25 004023" src="https://github.com/user-attachments/assets/cb4ac6f6-13cc-452c-8cc5-7a500670d7f2" /> The `PuplicLobby` display was a bit bugged for HumansVsNations: <img width="532" height="191" alt="Screenshot 2025-12-23 221832" src="https://github.com/user-attachments/assets/3950bcd9-0072-4c28-b1a0-83c0a24e9b8e" /> So I fixed it to look like this; <img width="532" height="195" alt="Screenshot 2025-12-23 224127" src="https://github.com/user-attachments/assets/690fc554-b607-4c8a-8b22-0c2912ee671a" /> ## 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 --------- Co-authored-by: iamlewis <lewismmmm@gmail.com>
This commit is contained in:
@@ -274,7 +274,8 @@
|
||||
"teams_Duos": "of 2 (Duos)",
|
||||
"teams_Trios": "of 3 (Trios)",
|
||||
"teams_Quads": "of 4 (Quads)",
|
||||
"teams_hvn": "Humans Vs Nations",
|
||||
"teams_hvn": "Humans vs Nations",
|
||||
"teams_hvn_detailed": "{num} Humans vs {num} Nations",
|
||||
"teams": "{num} teams",
|
||||
"players_per_team": "of {num}"
|
||||
},
|
||||
|
||||
@@ -551,9 +551,9 @@ export class HostLobbyModal extends LitElement {
|
||||
: translateText("host_modal.players")
|
||||
}
|
||||
<span style="margin: 0 8px;">•</span>
|
||||
${this.disableNations ? 0 : this.nationCount}
|
||||
${this.getEffectiveNationCount()}
|
||||
${
|
||||
this.nationCount === 1
|
||||
this.getEffectiveNationCount() === 1
|
||||
? translateText("host_modal.nation_player")
|
||||
: translateText("host_modal.nation_players")
|
||||
}
|
||||
@@ -564,7 +564,7 @@ export class HostLobbyModal extends LitElement {
|
||||
.clients=${this.clients}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.teamCount=${this.teamCount}
|
||||
.nationCount=${this.disableNations ? 0 : this.nationCount}
|
||||
.nationCount=${this.getEffectiveNationCount()}
|
||||
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
|
||||
></lobby-team-view>
|
||||
</div>
|
||||
@@ -876,6 +876,21 @@ export class HostLobbyModal extends LitElement {
|
||||
this.nationCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective nation count for display purposes.
|
||||
* In HumansVsNations mode, this equals the number of human players.
|
||||
* Otherwise, it uses the manifest nation count (or 0 if nations are disabled).
|
||||
*/
|
||||
private getEffectiveNationCount(): number {
|
||||
if (this.disableNations) {
|
||||
return 0;
|
||||
}
|
||||
if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) {
|
||||
return this.clients.length;
|
||||
}
|
||||
return this.nationCount;
|
||||
}
|
||||
}
|
||||
|
||||
async function createLobby(creatorClientID: string): Promise<GameInfo> {
|
||||
|
||||
@@ -135,6 +135,7 @@ export class PublicLobby extends LitElement {
|
||||
lobby.gameConfig.gameMode,
|
||||
teamCount,
|
||||
teamTotal,
|
||||
teamSize,
|
||||
);
|
||||
const teamDetailLabel = this.getTeamDetailLabel(
|
||||
lobby.gameConfig.gameMode,
|
||||
@@ -241,7 +242,7 @@ export class PublicLobby extends LitElement {
|
||||
if (teamCount === Duos) return 2;
|
||||
if (teamCount === Trios) return 3;
|
||||
if (teamCount === Quads) return 4;
|
||||
if (teamCount === HumansVsNations) return Math.floor(maxPlayers / 2);
|
||||
if (teamCount === HumansVsNations) return maxPlayers;
|
||||
return undefined;
|
||||
}
|
||||
if (typeof teamCount === "number" && teamCount > 0) {
|
||||
@@ -265,10 +266,13 @@ export class PublicLobby extends LitElement {
|
||||
gameMode: GameMode,
|
||||
teamCount: number | string | null,
|
||||
teamTotal: number | undefined,
|
||||
teamSize: number | undefined,
|
||||
): string {
|
||||
if (gameMode !== GameMode.Team) return translateText("game_mode.ffa");
|
||||
if (teamCount === HumansVsNations)
|
||||
return translateText("public_lobby.teams_hvn");
|
||||
if (teamCount === HumansVsNations && teamSize !== undefined)
|
||||
return translateText("public_lobby.teams_hvn_detailed", {
|
||||
num: teamSize,
|
||||
});
|
||||
const totalTeams =
|
||||
teamTotal ?? (typeof teamCount === "number" ? teamCount : 0);
|
||||
return translateText("public_lobby.teams", { num: totalTeams });
|
||||
@@ -282,7 +286,11 @@ export class PublicLobby extends LitElement {
|
||||
): string | null {
|
||||
if (gameMode !== GameMode.Team) return null;
|
||||
|
||||
if (typeof teamCount === "string" && teamCount !== HumansVsNations) {
|
||||
if (typeof teamCount === "string" && teamCount === HumansVsNations) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof teamCount === "string") {
|
||||
const teamKey = `public_lobby.teams_${teamCount}`;
|
||||
const maybeTranslated = translateText(teamKey);
|
||||
if (maybeTranslated !== teamKey) return maybeTranslated;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Game,
|
||||
GameUpdates,
|
||||
NameViewData,
|
||||
Nation,
|
||||
Player,
|
||||
PlayerActions,
|
||||
PlayerBorderTiles,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
GameUpdateType,
|
||||
GameUpdateViewData,
|
||||
} from "./game/GameUpdates";
|
||||
import { createNationsForGame } from "./game/NationUtils";
|
||||
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
||||
import { PseudoRandom } from "./PseudoRandom";
|
||||
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
||||
@@ -56,14 +56,11 @@ export async function createGameRunner(
|
||||
);
|
||||
});
|
||||
|
||||
const nations = gameStart.config.disableNations
|
||||
? []
|
||||
: gameMap.nations.map(
|
||||
(n) =>
|
||||
new Nation(
|
||||
new Cell(n.coordinates[0], n.coordinates[1]),
|
||||
new PlayerInfo(n.name, PlayerType.Nation, null, random.nextID()),
|
||||
),
|
||||
const nations = createNationsForGame(
|
||||
gameStart,
|
||||
gameMap.nations,
|
||||
humans.length,
|
||||
random,
|
||||
);
|
||||
|
||||
const game: Game = createGame(
|
||||
|
||||
@@ -187,7 +187,8 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
p -= p % 4;
|
||||
break;
|
||||
case HumansVsNations:
|
||||
// For HumansVsNations, return the base team player count
|
||||
// Half the slots are for humans, the other half will get filled with nations
|
||||
p = Math.floor(p / 2);
|
||||
break;
|
||||
default:
|
||||
p -= p % numPlayerTeams;
|
||||
|
||||
@@ -116,7 +116,15 @@ export class NationExecution implements Execution {
|
||||
}
|
||||
|
||||
if (this.mg.inSpawnPhase()) {
|
||||
// select a tile near the position defined in the map manifest for the current nation
|
||||
// Place nations without a spawn cell (Dynamically created for HumansVsNations) randomly by SpawnExecution
|
||||
if (this.nation.spawnCell === undefined) {
|
||||
this.mg.addExecution(
|
||||
new SpawnExecution(this.gameID, this.nation.playerInfo),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Select a tile near the position defined in the map manifest
|
||||
const rl = this.randomSpawnLand();
|
||||
|
||||
if (rl === null) {
|
||||
@@ -194,6 +202,8 @@ export class NationExecution implements Execution {
|
||||
}
|
||||
|
||||
private randomSpawnLand(): TileRef | null {
|
||||
if (this.nation.spawnCell === undefined) throw new Error("not initialized");
|
||||
|
||||
const delta = 25;
|
||||
let tries = 0;
|
||||
while (tries < 50) {
|
||||
|
||||
@@ -312,7 +312,7 @@ export enum Relation {
|
||||
|
||||
export class Nation {
|
||||
constructor(
|
||||
public readonly spawnCell: Cell,
|
||||
public readonly spawnCell: Cell | undefined,
|
||||
public readonly playerInfo: PlayerInfo,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameStartInfo } from "../Schemas";
|
||||
import {
|
||||
Cell,
|
||||
GameMode,
|
||||
GameType,
|
||||
HumansVsNations,
|
||||
Nation,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
} from "./Game";
|
||||
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.
|
||||
*/
|
||||
export function createNationsForGame(
|
||||
gameStart: GameStartInfo,
|
||||
manifestNations: ManifestNation[],
|
||||
numHumans: number,
|
||||
random: PseudoRandom,
|
||||
): Nation[] {
|
||||
if (gameStart.config.disableNations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const toNation = (n: ManifestNation): Nation =>
|
||||
new Nation(
|
||||
new Cell(n.coordinates[0], n.coordinates[1]),
|
||||
new PlayerInfo(n.name, PlayerType.Nation, null, random.nextID()),
|
||||
);
|
||||
|
||||
const isHumansVsNations =
|
||||
gameStart.config.gameMode === GameMode.Team &&
|
||||
gameStart.config.playerTeams === HumansVsNations;
|
||||
|
||||
// For non-HumansVsNations modes, simply use the manifest nations
|
||||
if (!isHumansVsNations) {
|
||||
return manifestNations.map(toNation);
|
||||
}
|
||||
|
||||
// HumansVsNations mode: balance nation count to match human count
|
||||
const isSingleplayer = gameStart.config.gameType === GameType.Singleplayer;
|
||||
const targetNationCount = isSingleplayer ? 1 : numHumans;
|
||||
|
||||
if (targetNationCount === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If we have enough manifest nations, use a subset
|
||||
if (manifestNations.length >= targetNationCount) {
|
||||
// Shuffle manifest nations to add variety
|
||||
const shuffled = random.shuffleArray(manifestNations);
|
||||
return shuffled.slice(0, targetNationCount).map(toNation);
|
||||
}
|
||||
|
||||
// If we need more nations than defined in manifest, create additional ones
|
||||
const nations: Nation[] = manifestNations.map(toNation);
|
||||
const additionalCount = targetNationCount - manifestNations.length;
|
||||
for (let i = 0; i < additionalCount; i++) {
|
||||
const name = generateNationName(random);
|
||||
nations.push(
|
||||
new Nation(
|
||||
undefined,
|
||||
new PlayerInfo(name, PlayerType.Nation, null, random.nextID()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return nations;
|
||||
}
|
||||
|
||||
const PLURAL_NOUN = Symbol("plural!");
|
||||
const NOUN = Symbol("noun!");
|
||||
|
||||
type NameTemplate = (string | typeof PLURAL_NOUN | typeof NOUN)[];
|
||||
|
||||
const NAME_TEMPLATES: NameTemplate[] = [
|
||||
["World Famous", NOUN],
|
||||
["Famous", PLURAL_NOUN],
|
||||
["Comically Large", NOUN],
|
||||
["Comically Small", NOUN],
|
||||
["Massive", PLURAL_NOUN],
|
||||
["Friendly", NOUN],
|
||||
["Evil", NOUN],
|
||||
["Malicious", NOUN],
|
||||
["Spiteful", NOUN],
|
||||
["Suspicious", NOUN],
|
||||
["Canonically Evil", NOUN],
|
||||
["Limited Edition", NOUN],
|
||||
["Patent Pending", NOUN],
|
||||
["Patented", NOUN],
|
||||
["Space", NOUN],
|
||||
["Defend The", PLURAL_NOUN],
|
||||
["Anarchist", NOUN],
|
||||
["Republic of", PLURAL_NOUN],
|
||||
["Slippery", NOUN],
|
||||
["Wealthy", PLURAL_NOUN],
|
||||
["Certified", NOUN],
|
||||
["Dr", NOUN],
|
||||
["Runaway", NOUN],
|
||||
["Chrome", NOUN],
|
||||
["All New", NOUN],
|
||||
["Top Shelf", PLURAL_NOUN],
|
||||
["Invading", PLURAL_NOUN],
|
||||
["Loyal To", PLURAL_NOUN],
|
||||
["United States of", NOUN],
|
||||
["United States of", PLURAL_NOUN],
|
||||
["Flowing Rivers of", NOUN],
|
||||
["House of", PLURAL_NOUN],
|
||||
["Certified Organic", NOUN],
|
||||
["Unregulated", NOUN],
|
||||
|
||||
[NOUN, "For Hire"],
|
||||
[PLURAL_NOUN, "That Bite"],
|
||||
[PLURAL_NOUN, "Are Opps"],
|
||||
[NOUN, "Hotel"],
|
||||
[PLURAL_NOUN, "The Movie"],
|
||||
[NOUN, "Corporation"],
|
||||
[PLURAL_NOUN, "Inc"],
|
||||
[NOUN, "Democracy"],
|
||||
[NOUN, "Network"],
|
||||
[NOUN, "Railway"],
|
||||
[NOUN, "Congress"],
|
||||
[NOUN, "Alliance"],
|
||||
[NOUN, "Island"],
|
||||
[NOUN, "Kingdom"],
|
||||
[NOUN, "Empire"],
|
||||
[NOUN, "Dynasty"],
|
||||
[NOUN, "Cartel"],
|
||||
[NOUN, "Cabal"],
|
||||
[NOUN, "Land"],
|
||||
[NOUN, "Oligarchy"],
|
||||
[NOUN, "Nationalist"],
|
||||
[NOUN, "State"],
|
||||
[NOUN, "Duchy"],
|
||||
[NOUN, "Ocean"],
|
||||
|
||||
["Alternate", NOUN, "Universe"],
|
||||
["Famous", NOUN, "Collection"],
|
||||
["Supersonic", NOUN, "Spaceship"],
|
||||
["Secret", NOUN, "Agenda"],
|
||||
["Ballistic", NOUN, "Missile"],
|
||||
["The", PLURAL_NOUN, "are SPIES"],
|
||||
["Traveling", NOUN, "Circus"],
|
||||
["The", PLURAL_NOUN, "Lied"],
|
||||
["Sacred", NOUN, "Knowledge"],
|
||||
["Quantum", NOUN, "Computer"],
|
||||
["Hadron", NOUN, "Collider"],
|
||||
["Large", NOUN, "Obliterator"],
|
||||
["Interstellar", NOUN, "Cabal"],
|
||||
["Interstellar", NOUN, "Army"],
|
||||
["Interstellar", NOUN, "Pirates"],
|
||||
["Interstellar", NOUN, "Dynasty"],
|
||||
["Interstellar", NOUN, "Clan"],
|
||||
["Galactic", NOUN, "Smugglers"],
|
||||
["Galactic", NOUN, "Cabal"],
|
||||
["Galactic", NOUN, "Alliance"],
|
||||
["Galactic", NOUN, "Empire"],
|
||||
["Galactic", NOUN, "Army"],
|
||||
["Galactic", NOUN, "Crown"],
|
||||
["Galactic", NOUN, "Pirates"],
|
||||
["Galactic", NOUN, "Dynasty"],
|
||||
["Galactic", NOUN, "Clan"],
|
||||
["Alien", NOUN, "Army"],
|
||||
["Alien", NOUN, "Cabal"],
|
||||
["Alien", NOUN, "Alliance"],
|
||||
["Alien", NOUN, "Empire"],
|
||||
["Alien", NOUN, "Pirates"],
|
||||
["Alien", NOUN, "Clan"],
|
||||
["Grand", NOUN, "Empire"],
|
||||
["Grand", NOUN, "Dynasty"],
|
||||
["Grand", NOUN, "Army"],
|
||||
["Grand", NOUN, "Cabal"],
|
||||
["Grand", NOUN, "Alliance"],
|
||||
["Royal", NOUN, "Army"],
|
||||
["Royal", NOUN, "Cabal"],
|
||||
["Royal", NOUN, "Empire"],
|
||||
["Royal", NOUN, "Dynasty"],
|
||||
["Holy", NOUN, "Dynasty"],
|
||||
["Holy", NOUN, "Empire"],
|
||||
["Holy", NOUN, "Alliance"],
|
||||
["Eternal", NOUN, "Empire"],
|
||||
["Eternal", NOUN, "Cabal"],
|
||||
["Eternal", NOUN, "Alliance"],
|
||||
["Eternal", NOUN, "Dynasty"],
|
||||
["Invading", NOUN, "Cabal"],
|
||||
["Invading", NOUN, "Empire"],
|
||||
["Invading", NOUN, "Alliance"],
|
||||
["Immortal", NOUN, "Pirates"],
|
||||
["Shadow", NOUN, "Cabal"],
|
||||
["Secret", NOUN, "Dynasty"],
|
||||
["The Great", NOUN, "Army"],
|
||||
["The", NOUN, "Matrix"],
|
||||
];
|
||||
|
||||
const NOUNS = [
|
||||
"Snail",
|
||||
"Cow",
|
||||
"Giraffe",
|
||||
"Donkey",
|
||||
"Horse",
|
||||
"Mushroom",
|
||||
"Salad",
|
||||
"Kitten",
|
||||
"Fork",
|
||||
"Apple",
|
||||
"Pancake",
|
||||
"Tree",
|
||||
"Fern",
|
||||
"Seashell",
|
||||
"Turtle",
|
||||
"Casserole",
|
||||
"Gnome",
|
||||
"Frog",
|
||||
"Cheese",
|
||||
"Mold",
|
||||
"Clown",
|
||||
"Boat",
|
||||
"Robot",
|
||||
"Millionaire",
|
||||
"Billionaire",
|
||||
"Pigeon",
|
||||
"Fish",
|
||||
"Bumblebee",
|
||||
"Jelly",
|
||||
"Wizard",
|
||||
"Worm",
|
||||
"Rat",
|
||||
"Pumpkin",
|
||||
"Zombie",
|
||||
"Grass",
|
||||
"Bear",
|
||||
"Skunk",
|
||||
"Sandwich",
|
||||
"Butter",
|
||||
"Soda",
|
||||
"Pickle",
|
||||
"Potato",
|
||||
"Book",
|
||||
"Friend",
|
||||
"Feather",
|
||||
"Flower",
|
||||
"Oil",
|
||||
"Train",
|
||||
"Fan",
|
||||
"Salmon",
|
||||
"Cod",
|
||||
"Sink",
|
||||
"Villain",
|
||||
"Bug",
|
||||
"Car",
|
||||
"Soup",
|
||||
"Puppy",
|
||||
"Rock",
|
||||
"Stick",
|
||||
"Succulent",
|
||||
"Nerd",
|
||||
"Mercenary",
|
||||
"Ninja",
|
||||
"Burger",
|
||||
"Tomato",
|
||||
"Penguin",
|
||||
];
|
||||
|
||||
function generateNationName(random: PseudoRandom): string {
|
||||
const template = NAME_TEMPLATES[random.nextInt(0, NAME_TEMPLATES.length)];
|
||||
const noun = NOUNS[random.nextInt(0, NOUNS.length)];
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
for (const part of template) {
|
||||
if (part === PLURAL_NOUN) {
|
||||
result.push(pluralize(noun));
|
||||
} else if (part === NOUN) {
|
||||
result.push(noun);
|
||||
} else {
|
||||
result.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join(" ");
|
||||
}
|
||||
|
||||
// Words from NOUNS that need irregular "-oes" plural
|
||||
const O_TO_OES = new Set(["Potato", "Tomato"]);
|
||||
|
||||
function pluralize(noun: string): string {
|
||||
if (
|
||||
noun.endsWith("s") ||
|
||||
noun.endsWith("ch") ||
|
||||
noun.endsWith("sh") ||
|
||||
noun.endsWith("x") ||
|
||||
noun.endsWith("z")
|
||||
) {
|
||||
return `${noun}es`;
|
||||
}
|
||||
if (noun.endsWith("y") && !"aeiou".includes(noun[noun.length - 2])) {
|
||||
return `${noun.slice(0, -1)}ies`;
|
||||
}
|
||||
if (O_TO_OES.has(noun)) {
|
||||
return `${noun}es`;
|
||||
}
|
||||
return `${noun}s`;
|
||||
}
|
||||
@@ -74,6 +74,7 @@ const TEAM_COUNTS = [
|
||||
Duos,
|
||||
Trios,
|
||||
Quads,
|
||||
HumansVsNations,
|
||||
] as const satisfies TeamCountConfig[];
|
||||
|
||||
export class MapPlaylist {
|
||||
@@ -95,7 +96,8 @@ export class MapPlaylist {
|
||||
maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams),
|
||||
gameType: GameType.Public,
|
||||
gameMapSize: GameMapSize.Normal,
|
||||
difficulty: Difficulty.Easy,
|
||||
difficulty:
|
||||
playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
maxTimerValue: undefined,
|
||||
|
||||
Reference in New Issue
Block a user