Allow up to seven teams for players (#445)

## Description:

- Allow up to seven teams for players, and one for bots.
- Add team count selection to the single player dialog.
- Select random number of teams in server rotation.

Fixes #456

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors


![image](https://github.com/user-attachments/assets/cbc1ba82-9d06-4763-896c-abdce067a161)


![image](https://github.com/user-attachments/assets/9f82a0a5-c0bb-49b6-a714-2b13286a64ca)

## Please put your Discord username so you can be contacted if a bug or
regression is found:

fake.neo

---------

Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com>
This commit is contained in:
Scott Anderson
2025-04-15 22:02:58 -04:00
committed by GitHub
parent 1ed3d50eb1
commit 5167f67b96
16 changed files with 290 additions and 83 deletions
+1
View File
@@ -147,6 +147,7 @@
"host_modal": {
"title": "Private Lobby",
"mode": "Mode",
"team_count": "Number of Teams",
"options_title": "Options",
"bots": "Bots: ",
"bots_disabled": "Disabled",
+34
View File
@@ -23,6 +23,7 @@ export class HostLobbyModal extends LitElement {
@state() private selectedDifficulty: Difficulty = Difficulty.Medium;
@state() private disableNPCs = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: number = 2;
@state() private disableNukes: boolean = false;
@state() private bots: number = 400;
@state() private infiniteGold: boolean = false;
@@ -159,6 +160,33 @@ export class HostLobbyModal extends LitElement {
</div>
</div>
${
this.gameMode === GameMode.FFA
? ""
: html`
<!-- Team Count Selection -->
<div class="options-section">
<div class="option-title">
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
? "selected"
: ""}"
@click=${() => this.handleTeamCountSelection(o)}
>
<div class="option-card-title">${o}</div>
</div>
`,
)}
</div>
</div>
`
}
<!-- Game Options -->
<div class="options-section">
<div class="option-title">
@@ -413,6 +441,11 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private async handleTeamCountSelection(value: number) {
this.teamCount = value;
this.putGameConfig();
}
private async putGameConfig() {
const config = await getServerConfigFromClient();
const response = await fetch(
@@ -432,6 +465,7 @@ export class HostLobbyModal extends LitElement {
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
} as GameConfig),
},
);
+31
View File
@@ -30,6 +30,7 @@ export class SinglePlayerModal extends LitElement {
@state() private instantBuild: boolean = false;
@state() private useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: number = 2;
render() {
return html`
@@ -136,6 +137,31 @@ export class SinglePlayerModal extends LitElement {
</div>
</div>
${this.gameMode === GameMode.FFA
? ""
: html`
<!-- Team Count Selection -->
<div class="options-section">
<div class="option-title">
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
? "selected"
: ""}"
@click=${() => this.handleTeamCountSelection(o)}
>
<div class="option-card-title">${o}</div>
</div>
`,
)}
</div>
</div>
`}
<!-- Game Options -->
<div class="options-section">
<div class="option-title">
@@ -310,6 +336,10 @@ export class SinglePlayerModal extends LitElement {
this.gameMode = value;
}
private handleTeamCountSelection(value: number) {
this.teamCount = value;
}
private getRandomMap(): GameMapType {
const maps = Object.values(GameMapType);
const randIdx = Math.floor(Math.random() * maps.length);
@@ -361,6 +391,7 @@ export class SinglePlayerModal extends LitElement {
gameMap: this.selectedMap,
gameType: GameType.Singleplayer,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
disableNukes: this.disableNukes,
+49 -27
View File
@@ -1,13 +1,11 @@
import { blue, red } from "../../../core/configuration/Colors";
import { GameMode, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class SpawnTimer implements Layer {
private ratio = 0;
private leftColor = "rgba(0, 128, 255, 0.7)";
private rightColor = "rgba(0, 0, 0, 0.5)";
private ratios = [0];
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
constructor(
private game: GameView,
@@ -18,27 +16,35 @@ export class SpawnTimer implements Layer {
tick() {
if (this.game.inSpawnPhase()) {
this.ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns();
this.ratios[0] =
this.game.ticks() / this.game.config().numSpawnPhaseTurns();
return;
}
this.ratios = [];
this.colors = [];
if (this.game.config().gameConfig().gameMode != GameMode.Team) {
this.ratio = 0;
return;
}
const numBlueTiles = this.game
.players()
.filter((p) => p.team() == Team.Blue)
.reduce((acc, p) => acc + p.numTilesOwned(), 0);
const teamTiles: Map<Team, number> = new Map();
for (const player of this.game.players()) {
const team = player.team();
const tiles = teamTiles.get(team) ?? 0;
const sum = tiles + player.numTilesOwned();
teamTiles.set(team, sum);
}
const numRedTiles = this.game
.players()
.filter((p) => p.team() == Team.Red)
.reduce((acc, p) => acc + p.numTilesOwned(), 0);
this.ratio = numBlueTiles / (numBlueTiles + numRedTiles);
this.leftColor = blue.toRgbString();
this.rightColor = red.toRgbString();
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
if (total === 0) return;
for (const [team, count] of teamTiles) {
const ratio = count / total;
const color = theme.teamColor(team).toRgbString();
this.ratios.push(ratio);
this.colors.push(color);
}
}
shouldTransform(): boolean {
@@ -46,18 +52,34 @@ export class SpawnTimer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
if (this.ratio == 0) {
return;
}
if (this.ratios === null) return;
if (this.ratios.length === 0) return;
if (this.colors.length === 0) return;
const barHeight = 10;
const barBackgroundWidth = this.transformHandler.width();
const barWidth = this.transformHandler.width();
// Draw bar background
context.fillStyle = this.rightColor;
context.fillRect(0, 0, barBackgroundWidth, barHeight);
let x = 0;
let filledRatio = 0;
for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) {
const ratio = this.ratios[i];
const segmentWidth = barWidth * ratio;
context.fillStyle = this.leftColor;
context.fillRect(0, 0, barBackgroundWidth * this.ratio, barHeight);
context.fillStyle = this.colors[i];
context.fillRect(x, 0, segmentWidth, barHeight);
x += segmentWidth;
filledRatio += ratio;
}
}
}
function sumIterator(values: MapIterator<number>) {
// To use reduce, we'd need to allocate an array:
// return Array.from(values).reduce((sum, v) => sum + v, 0);
let total = 0;
for (const value of values) {
total += value;
}
return total;
}
+1
View File
@@ -121,6 +121,7 @@ const GameConfigSchema = z.object({
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
maxPlayers: z.number().optional(),
numPlayerTeams: z.number().optional(),
});
const SafeString = z
+5
View File
@@ -2,6 +2,11 @@ import { colord, Colord } from "colord";
export const red: Colord = colord({ r: 235, g: 53, b: 53 }); // Bright Red
export const blue: Colord = colord({ r: 41, g: 98, b: 255 }); // Royal Blue
export const teal = colord({ h: 172, s: 66, l: 50 });
export const purple = colord({ h: 271, s: 81, l: 56 });
export const yellow = colord({ h: 45, s: 93, l: 47 });
export const orange = colord({ h: 25, s: 95, l: 53 });
export const green = colord({ h: 128, s: 49, l: 50 });
export const botColor: Colord = colord({ r: 210, g: 206, b: 200 }); // Muted Beige Gray
export const territoryColors: Colord[] = [
+3
View File
@@ -7,6 +7,7 @@ import {
Gold,
Player,
PlayerInfo,
Team,
TerraNullius,
Tick,
UnitInfo,
@@ -65,6 +66,7 @@ export interface Config {
instantBuild(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
numPlayerTeams(): number;
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
@@ -122,6 +124,7 @@ export interface Config {
}
export interface Theme {
teamColor(team: Team): Colord;
territoryColor(playerInfo: PlayerView): Colord;
specialBuildingColor(playerInfo: PlayerView): Colord;
borderColor(playerInfo: PlayerView): Colord;
+3
View File
@@ -189,6 +189,9 @@ export class DefaultConfig implements Config {
defensePostDefenseBonus(): number {
return 5;
}
numPlayerTeams(): number {
return this._gameConfig.numPlayerTeams ?? 0;
}
spawnNPCs(): boolean {
return !this._gameConfig.disableNPCs;
}
+29 -8
View File
@@ -8,9 +8,14 @@ import {
blue,
botColor,
botColors,
green,
humanColors,
orange,
purple,
red,
teal,
territoryColors,
yellow,
} from "./Colors";
import { Theme } from "./Config";
@@ -36,15 +41,31 @@ export const pastelTheme = new (class implements Theme {
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
return blue;
case Team.Red:
return red;
case Team.Teal:
return teal;
case Team.Purple:
return purple;
case Team.Yellow:
return yellow;
case Team.Orange:
return orange;
case Team.Green:
return green;
case Team.Bot:
return botColor;
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
if (player.team() == Team.Bot) {
return botColor;
}
if (player.team() == Team.Red) {
return red;
}
if (player.team() == Team.Blue) {
return blue;
if (player.team() !== null) {
return this.teamColor(player.team());
}
if (player.info().playerType == PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
+29 -8
View File
@@ -8,9 +8,14 @@ import {
blue,
botColor,
botColors,
green,
humanColors,
orange,
purple,
red,
teal,
territoryColors,
yellow,
} from "./Colors";
import { Theme } from "./Config";
@@ -36,15 +41,31 @@ export const pastelThemeDark = new (class implements Theme {
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
return blue;
case Team.Red:
return red;
case Team.Teal:
return teal;
case Team.Purple:
return purple;
case Team.Yellow:
return yellow;
case Team.Orange:
return orange;
case Team.Green:
return green;
case Team.Bot:
return botColor;
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
if (player.team() == Team.Bot) {
return botColor;
}
if (player.team() == Team.Red) {
return red;
}
if (player.team() == Team.Blue) {
return blue;
if (player.team() !== null) {
return this.teamColor(player.team());
}
if (player.info().playerType == PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
+5
View File
@@ -40,6 +40,11 @@ export enum Difficulty {
export enum Team {
Red = "Red",
Blue = "Blue",
Teal = "Teal",
Purple = "Purple",
Yellow = "Yellow",
Orange = "Orange",
Green = "Green",
Bot = "Bot",
}
+12 -1
View File
@@ -99,6 +99,17 @@ export class GameImpl implements Game {
),
);
this.unitGrid = new UnitGrid(this._map);
if (_config.gameConfig().gameMode === GameMode.Team) {
const numPlayerTeams = _config.numPlayerTeams();
if (numPlayerTeams < 2) throw new Error("Too few teams!");
if (numPlayerTeams >= 3) this.playerTeams.push(Team.Teal);
if (numPlayerTeams >= 4) this.playerTeams.push(Team.Purple);
if (numPlayerTeams >= 5) this.playerTeams.push(Team.Yellow);
if (numPlayerTeams >= 6) this.playerTeams.push(Team.Orange);
if (numPlayerTeams >= 7) this.playerTeams.push(Team.Green);
if (numPlayerTeams >= 8) throw new Error("Too many teams!");
}
}
private addHumans() {
@@ -106,7 +117,7 @@ export class GameImpl implements Game {
this._humans.forEach((p) => this.addPlayer(p));
return;
}
const playerToTeam = assignTeams(this._humans);
const playerToTeam = assignTeams(this._humans, this.playerTeams);
for (const [playerInfo, team] of playerToTeam.entries()) {
if (team == "kicked") {
console.warn(`Player ${playerInfo.name} was kicked from team`);
+1 -1
View File
@@ -178,7 +178,7 @@ export class PlayerView {
return this.data.id;
}
team(): Team | null {
return this.data.team;
return this.data.team ?? null;
}
type(): PlayerType {
return this.data.playerType;
+28 -28
View File
@@ -2,10 +2,10 @@ import { PlayerInfo, Team } from "./Game";
export function assignTeams(
players: PlayerInfo[],
teams: Team[],
): Map<PlayerInfo, Team | "kicked"> {
const result = new Map<PlayerInfo, Team | "kicked">();
let redTeamCount = 0;
let blueTeamCount = 0;
const teamPlayerCount = new Map<Team, number>();
// Group players by clan
const clanGroups = new Map<string, PlayerInfo[]>();
@@ -23,7 +23,7 @@ export function assignTeams(
}
}
const maxTeamSize = Math.ceil(players.length / 2);
const maxTeamSize = Math.ceil(players.length / teams.length);
// Sort clans by size (largest first)
const sortedClans = Array.from(clanGroups.entries()).sort(
@@ -33,38 +33,38 @@ export function assignTeams(
// First, assign clan players
for (const [_, clanPlayers] of sortedClans) {
// Try to keep the clan together on the team with fewer players
if (redTeamCount <= blueTeamCount) {
// Assign to red team
for (const player of clanPlayers) {
if (redTeamCount < maxTeamSize) {
redTeamCount++;
result.set(player, Team.Red);
} else {
result.set(player, "kicked");
}
}
} else {
// Assign to blue team
for (const player of clanPlayers) {
if (blueTeamCount < maxTeamSize) {
blueTeamCount++;
result.set(player, Team.Blue);
} else {
result.set(player, "kicked");
}
let team: Team | null = null;
let teamSize = 0;
for (const t of teams) {
const p = teamPlayerCount.get(t) ?? 0;
if (team !== null && teamSize <= p) continue;
teamSize = p;
team = t;
}
for (const player of clanPlayers) {
if (teamSize < maxTeamSize) {
teamSize++;
result.set(player, team);
} else {
result.set(player, "kicked");
}
}
teamPlayerCount.set(team, teamSize);
}
// Then, assign non-clan players to balance teams
for (const player of noClanPlayers) {
if (redTeamCount <= blueTeamCount) {
redTeamCount++;
result.set(player, Team.Red);
} else {
blueTeamCount++;
result.set(player, Team.Blue);
let team: Team | null = null;
let teamSize = 0;
for (const t of teams) {
const p = teamPlayerCount.get(t) ?? 0;
if (team !== null && teamSize <= p) continue;
teamSize = p;
team = t;
}
teamPlayerCount.set(team, teamSize + 1);
result.set(player, team);
}
return result;
+6 -3
View File
@@ -237,9 +237,11 @@ async function schedulePublicGame(playlist: MapPlaylist) {
}
const gameMode = playlist.getNextGameMode();
const numPlayerTeams =
gameMode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined;
// Create the default public game config (from your GameManager)
const defaultGameConfig = {
const defaultGameConfig: GameConfig = {
gameMap: map,
maxPlayers: config.lobbyMaxPlayers(map),
gameType: GameType.Public,
@@ -249,9 +251,10 @@ async function schedulePublicGame(playlist: MapPlaylist) {
instantBuild: false,
disableNPCs: gameMode == GameMode.Team,
disableNukes: false,
gameMode: gameMode,
gameMode,
numPlayerTeams,
bots: 400,
} as GameConfig;
};
const workerPath = config.workerPath(gameID);
+53 -7
View File
@@ -1,6 +1,8 @@
import { PlayerInfo, PlayerType, Team } from "../src/core/game/Game";
import { assignTeams } from "../src/core/game/TeamAssignment";
const teams = [Team.Red, Team.Blue];
describe("assignTeams", () => {
const createPlayer = (id: string, clan?: string): PlayerInfo => {
const name = clan ? `[${clan}]Player ${id}` : `Player ${id}`;
@@ -22,7 +24,7 @@ describe("assignTeams", () => {
createPlayer("4"),
];
const result = assignTeams(players);
const result = assignTeams(players, teams);
// Check that players are assigned alternately
expect(result.get(players[0])).toEqual(Team.Red);
@@ -39,7 +41,7 @@ describe("assignTeams", () => {
createPlayer("4", "CLANB"),
];
const result = assignTeams(players);
const result = assignTeams(players, teams);
// Check that clan members are on the same team
expect(result.get(players[0])).toEqual(Team.Red);
@@ -56,7 +58,7 @@ describe("assignTeams", () => {
createPlayer("4"),
];
const result = assignTeams(players);
const result = assignTeams(players, teams);
// Check that clan members are together and non-clan players balance teams
expect(result.get(players[0])).toEqual(Team.Red);
@@ -75,7 +77,7 @@ describe("assignTeams", () => {
createPlayer("6", "CLANB"),
];
const result = assignTeams(players);
const result = assignTeams(players, teams);
// Check that players are kicked when teams are full
expect(result.get(players[0])).toEqual(Team.Red);
@@ -89,13 +91,13 @@ describe("assignTeams", () => {
});
it("should handle empty player list", () => {
const result = assignTeams([]);
const result = assignTeams([], teams);
expect(result.size).toBe(0);
});
it("should handle single player", () => {
const players = [createPlayer("1")];
const result = assignTeams(players);
const result = assignTeams(players, teams);
expect(result.get(players[0])).toEqual(Team.Red);
});
@@ -109,7 +111,7 @@ describe("assignTeams", () => {
createPlayer("6", "CLANC"),
];
const result = assignTeams(players);
const result = assignTeams(players, teams);
// Check that larger clans are assigned first
expect(result.get(players[0])).toEqual(Team.Red);
@@ -119,4 +121,48 @@ describe("assignTeams", () => {
expect(result.get(players[4])).toEqual(Team.Blue);
expect(result.get(players[5])).toEqual(Team.Blue);
});
it("should distribute players among a larger number of teams", () => {
const players = [
createPlayer("1", "CLANA"),
createPlayer("2", "CLANA"),
createPlayer("3", "CLANA"),
createPlayer("4", "CLANB"),
createPlayer("5", "CLANB"),
createPlayer("6", "CLANC"),
createPlayer("7"),
createPlayer("8"),
createPlayer("9"),
createPlayer("10"),
createPlayer("11"),
createPlayer("12"),
createPlayer("13"),
createPlayer("14"),
];
const result = assignTeams(players, [
Team.Red,
Team.Blue,
Team.Teal,
Team.Purple,
Team.Yellow,
Team.Orange,
Team.Green,
]);
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[2])).toEqual("kicked");
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[4])).toEqual(Team.Blue);
expect(result.get(players[5])).toEqual(Team.Teal);
expect(result.get(players[6])).toEqual(Team.Purple);
expect(result.get(players[7])).toEqual(Team.Yellow);
expect(result.get(players[8])).toEqual(Team.Orange);
expect(result.get(players[9])).toEqual(Team.Green);
expect(result.get(players[10])).toEqual(Team.Teal);
expect(result.get(players[11])).toEqual(Team.Purple);
expect(result.get(players[12])).toEqual(Team.Yellow);
expect(result.get(players[13])).toEqual(Team.Orange);
});
});