Add Nations Vs Players Game Mode (#2233)

## Description:
Fixes: #676 
This PR adds Players Vs Nations as a game mode in the menu.

For this change I have added two mutually exclusive option for this
mode:
1. Match number of nations to number of players who have joined
2. Set the number of nations to a fixed value

### Screenshots:
#### Options in Single player mode
<img width="1025" height="790" alt="image"
src="https://github.com/user-attachments/assets/c0685ea5-94f5-43c7-a9e5-390835fc94e9"
/>
<img width="1005" height="795" alt="image"
src="https://github.com/user-attachments/assets/dddba015-a424-40dd-a0fe-2571fd7b0fba"
/>

#### Options in lobby mode
<img width="1015" height="888" alt="image"
src="https://github.com/user-attachments/assets/45bc865b-c6a8-4b6a-9062-4eb499c1ea36"
/>

#### Example gameplay (1 Human Vs 90 Nations)
<img width="1888" height="912" alt="image"
src="https://github.com/user-attachments/assets/38faec75-171f-4358-a3be-93630cca1587"
/>


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

saphereye

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
Adarsh Das
2025-10-29 05:10:30 +05:30
committed by GitHub
parent 02bdaaa2c6
commit 0789f0d7f8
10 changed files with 154 additions and 53 deletions
+5
View File
@@ -139,6 +139,7 @@
"options_title": "Options",
"bots": "Bots: ",
"bots_disabled": "Disabled",
"nations": "Nations: ",
"disable_nations": "Disable Nations",
"instant_build": "Instant build",
"infinite_gold": "Infinite gold",
@@ -146,6 +147,7 @@
"compact_map": "Mini Map",
"max_timer": "Game length (minutes)",
"disable_nukes": "Disable Nukes",
"automatic_difficulty": "Automatic Difficulty",
"enables_title": "Enable Settings",
"start": "Start Game"
},
@@ -222,6 +224,7 @@
"teams_Duos": "Duos (teams of 2)",
"teams_Trios": "Trios (teams of 3)",
"teams_Quads": "Quads (teams of 4)",
"teams_hvn": "Humans Vs Nations",
"teams": "{num} teams"
},
"matchmaking_modal": {
@@ -244,6 +247,7 @@
"options_title": "Options",
"bots": "Bots: ",
"bots_disabled": "Disabled",
"nations": "Nations: ",
"disable_nations": "Disable Nations",
"max_timer": "Game length (minutes)",
"instant_build": "Instant build",
@@ -252,6 +256,7 @@
"infinite_troops": "Infinite troops",
"donate_troops": "Donate troops",
"compact_map": "Mini Map",
"automatic_difficulty": "Automatic Difficulty",
"enables_title": "Enable Settings",
"player": "Player",
"players": "Players",
+65 -33
View File
@@ -9,6 +9,7 @@ import {
GameMapSize,
GameMapType,
GameMode,
HumansVsNations,
Quads,
Trios,
UnitType,
@@ -284,7 +285,18 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7, Quads, Trios, Duos].map(
${[
2,
3,
4,
5,
6,
7,
Quads,
Trios,
Duos,
HumansVsNations,
].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -294,7 +306,9 @@ export class HostLobbyModal extends LitElement {
>
<div class="option-card-title">
${typeof o === "string"
? translateText(`public_lobby.teams_${o}`)
? o === HumansVsNations
? translateText("public_lobby.teams_hvn")
: translateText(`public_lobby.teams_${o}`)
: translateText("public_lobby.teams", {
num: o,
})}
@@ -313,42 +327,53 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.options_title")}
</div>
<div class="option-cards">
<label for="bots-count" class="option-card">
<input
type="range"
id="bots-count"
min="0"
max="400"
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${String(this.bots)}"
/>
<div class="option-card-title">
<span>${translateText("host_modal.bots")}</span>${
this.bots === 0
? translateText("host_modal.bots_disabled")
: this.bots
}
</div>
</label>
<label
for="disable-npcs"
class="option-card ${this.disableNPCs ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<label for="bots-count" class="option-card">
<input
type="checkbox"
id="disable-npcs"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
type="range"
id="bots-count"
min="0"
max="400"
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${String(this.bots)}"
/>
<div class="option-card-title">
${translateText("host_modal.disable_nations")}
<span>${translateText("host_modal.bots")}</span>${
this.bots === 0
? translateText("host_modal.bots_disabled")
: this.bots
}
</div>
</label>
${
!(
this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
)
? html`
<label
for="disable-npcs"
class="option-card ${this.disableNPCs
? "selected"
: ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="disable-npcs"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">
${translateText("host_modal.disable_nations")}
</div>
</label>
`
: ""
}
<label
for="instant-build"
class="option-card ${this.instantBuild ? "selected" : ""}"
@@ -718,7 +743,6 @@ export class HostLobbyModal extends LitElement {
? GameMapSize.Compact
: GameMapSize.Normal,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
donateGold: this.donateGold,
@@ -728,6 +752,14 @@ export class HostLobbyModal extends LitElement {
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
...(this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
? {
disableNPCs: false,
}
: {
disableNPCs: this.disableNPCs,
}),
maxTimerValue:
this.maxTimer === true ? this.maxTimerValue : undefined,
} satisfies Partial<GameConfig>),
+47 -18
View File
@@ -9,6 +9,7 @@ import {
GameMapType,
GameMode,
GameType,
HumansVsNations,
Quads,
Trios,
UnitType,
@@ -195,7 +196,18 @@ export class SinglePlayerModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7, Quads, Trios, Duos].map(
${[
2,
3,
4,
5,
6,
7,
Quads,
Trios,
Duos,
HumansVsNations,
].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -205,7 +217,9 @@ export class SinglePlayerModal extends LitElement {
>
<div class="option-card-title">
${typeof o === "string"
? translateText(`public_lobby.teams_${o}`)
? o === HumansVsNations
? translateText("public_lobby.teams_hvn")
: translateText(`public_lobby.teams_${o}`)
: translateText(`public_lobby.teams`, { num: o })}
</div>
</div>
@@ -240,21 +254,29 @@ export class SinglePlayerModal extends LitElement {
</div>
</label>
<label
for="singleplayer-modal-disable-npcs"
class="option-card ${this.disableNPCs ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-disable-npcs"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">
${translateText("single_modal.disable_nations")}
</div>
</label>
${!(
this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
)
? html`
<label
for="singleplayer-modal-disable-npcs"
class="option-card ${this.disableNPCs ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-disable-npcs"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">
${translateText("single_modal.disable_nations")}
</div>
</label>
`
: ""}
<label
for="singleplayer-modal-instant-build"
class="option-card ${this.instantBuild ? "selected" : ""}"
@@ -534,7 +556,6 @@ export class SinglePlayerModal extends LitElement {
gameMode: this.gameMode,
playerTeams: this.teamCount,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
maxTimerValue: this.maxTimer ? this.maxTimerValue : undefined,
bots: this.bots,
infiniteGold: this.infiniteGold,
@@ -545,6 +566,14 @@ export class SinglePlayerModal extends LitElement {
disabledUnits: this.disabledUnits
.map((u) => Object.values(UnitType).find((ut) => ut === u))
.filter((ut): ut is UnitType => ut !== undefined),
...(this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
? {
disableNPCs: false,
}
: {
disableNPCs: this.disableNPCs,
}),
},
},
} satisfies JoinLobbyEvent,
+2
View File
@@ -14,6 +14,7 @@ import {
GameMapType,
GameMode,
GameType,
HumansVsNations,
Quads,
Trios,
UnitType,
@@ -149,6 +150,7 @@ const TeamCountConfigSchema = z.union([
z.literal(Duos),
z.literal(Trios),
z.literal(Quads),
z.literal(HumansVsNations),
]);
export type TeamCountConfig = z.infer<typeof TeamCountConfigSchema>;
+4
View File
@@ -47,6 +47,10 @@ export class ColorAllocator {
return greenTeamColors;
case ColoredTeams.Bot:
return botTeamColors;
case ColoredTeams.Humans:
return blueTeamColors;
case ColoredTeams.Nations:
return redTeamColors;
default:
return [this.assignColor(team)];
}
+4
View File
@@ -8,6 +8,7 @@ import {
GameMode,
GameType,
Gold,
HumansVsNations,
Player,
PlayerInfo,
PlayerType,
@@ -195,6 +196,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
case Quads:
p -= p % 4;
break;
case HumansVsNations:
// For HumansVsNations, return the base team player count
break;
default:
p -= p % numPlayerTeams;
break;
+3
View File
@@ -53,6 +53,7 @@ export type Team = string;
export const Duos = "Duos" as const;
export const Trios = "Trios" as const;
export const Quads = "Quads" as const;
export const HumansVsNations = "Humans Vs Nations" as const;
export const ColoredTeams: Record<string, Team> = {
Red: "Red",
@@ -63,6 +64,8 @@ export const ColoredTeams: Record<string, Team> = {
Orange: "Orange",
Green: "Green",
Bot: "Bot",
Humans: "Humans",
Nations: "Nations",
} as const;
export enum GameMapType {
+19 -1
View File
@@ -15,6 +15,7 @@ import {
Game,
GameMode,
GameUpdates,
HumansVsNations,
MessageType,
MutableAlliance,
Nation,
@@ -105,6 +106,13 @@ export class GameImpl implements Game {
private populateTeams() {
let numPlayerTeams = this._config.playerTeams();
// HumansVsNations mode always has exactly 2 teams
if (numPlayerTeams === HumansVsNations) {
this.playerTeams = [ColoredTeams.Humans, ColoredTeams.Nations];
return;
}
if (typeof numPlayerTeams !== "number") {
const players = this._humans.length + this._nations.length;
switch (numPlayerTeams) {
@@ -139,11 +147,21 @@ export class GameImpl implements Game {
}
private addPlayers() {
if (this.config().gameConfig().gameMode !== GameMode.Team) {
if (this.config().gameConfig().gameMode === GameMode.FFA) {
this._humans.forEach((p) => this.addPlayer(p));
this._nations.forEach((n) => this.addPlayer(n.playerInfo));
return;
}
if (this._config.playerTeams() === HumansVsNations) {
this._humans.forEach((p) => this.addPlayer(p, ColoredTeams.Humans));
this._nations.forEach((n) =>
this.addPlayer(n.playerInfo, ColoredTeams.Nations),
);
return;
}
// Team mode
const allPlayers = [
...this._humans,
...this._nations.map((n) => n.playerInfo),
+3 -1
View File
@@ -7,6 +7,7 @@ import {
GameMapType,
GameMode,
GameType,
HumansVsNations,
Quads,
Trios,
} from "../core/game/Game";
@@ -67,6 +68,7 @@ const TEAM_COUNTS = [
Duos,
Trios,
Quads,
HumansVsNations,
] as const satisfies TeamCountConfig[];
export class MapPlaylist {
@@ -93,7 +95,7 @@ export class MapPlaylist {
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
disableNPCs: mode === GameMode.Team,
disableNPCs: mode === GameMode.Team && playerTeams !== HumansVsNations,
gameMode: mode,
playerTeams,
bots: 400,
+2
View File
@@ -90,6 +90,8 @@ describe("ColorAllocator", () => {
expect(allocator.assignTeamColor(ColoredTeams.Orange)).toEqual(orange);
expect(allocator.assignTeamColor(ColoredTeams.Green)).toEqual(green);
expect(allocator.assignTeamColor(ColoredTeams.Bot)).toEqual(botColor);
expect(allocator.assignTeamColor(ColoredTeams.Humans)).toEqual(blue);
expect(allocator.assignTeamColor(ColoredTeams.Nations)).toEqual(red);
});
test("assignTeamPlayerColor always returns the same color for the same playerID", () => {