implement duos (#630)

Implement Duos team mode.
Also assign teams to nations at the start instead of assigning them
randomly on spawn. This gives more consistent team sizes

- [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
regression is found:

<DISCORD USERNAME>

Co-authored-by: evan <openfrontio@gmail.com>
This commit is contained in:
evanpelle
2025-04-30 13:47:35 -07:00
committed by evan
parent 54fbf94d90
commit 92b5827636
21 changed files with 341 additions and 282 deletions
+6 -5
View File
@@ -6,6 +6,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
mapCategories,
@@ -28,7 +29,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 teamCount: number | typeof Duos = 2;
@state() private disableNukes: boolean = false;
@state() private bots: number = 400;
@state() private infiniteGold: boolean = false;
@@ -194,7 +195,7 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
${[Duos, 2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -465,8 +466,8 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private async handleTeamCountSelection(value: number) {
this.teamCount = value;
private async handleTeamCountSelection(value: number | typeof Duos) {
this.teamCount = value === Duos ? Duos : Number(value);
this.putGameConfig();
}
@@ -489,7 +490,7 @@ export class HostLobbyModal extends LitElement {
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
playerTeams: this.teamCount,
} as GameConfig),
},
);
+1 -1
View File
@@ -96,7 +96,7 @@ export class PublicLobby extends LitElement {
const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? lobby.gameConfig.numPlayerTeams || 0
? lobby.gameConfig.playerTeams || 0
: null;
return html`
+6 -5
View File
@@ -5,6 +5,7 @@ import { translateText } from "../client/Utils";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
GameType,
@@ -36,7 +37,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;
@state() private teamCount: number | typeof Duos = 2;
render() {
return html`
@@ -165,7 +166,7 @@ export class SinglePlayerModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
${["Duos", 2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -355,8 +356,8 @@ export class SinglePlayerModal extends LitElement {
this.gameMode = value;
}
private handleTeamCountSelection(value: number) {
this.teamCount = value;
private handleTeamCountSelection(value: number | string) {
this.teamCount = value === "Duos" ? Duos : Number(value);
}
private getRandomMap(): GameMapType {
@@ -410,7 +411,7 @@ export class SinglePlayerModal extends LitElement {
gameMap: this.selectedMap,
gameType: GameType.Singleplayer,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
playerTeams: this.teamCount,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
disableNukes: this.disableNukes,
+41 -16
View File
@@ -4,9 +4,11 @@ import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Cell,
Game,
GameUpdates,
NameViewData,
Nation,
Player,
PlayerActions,
PlayerBorderTiles,
@@ -23,8 +25,9 @@ import {
GameUpdateViewData,
} from "./game/GameUpdates";
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { PseudoRandom } from "./PseudoRandom";
import { ClientID, GameStartInfo, Turn } from "./Schemas";
import { sanitize } from "./Util";
import { sanitize, simpleHash } from "./Util";
import { fixProfaneUsername } from "./validations/username";
export async function createGameRunner(
@@ -34,26 +37,48 @@ export async function createGameRunner(
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(gameStart.config.gameMap);
const game = createGame(
gameStart.players.map(
(p) =>
new PlayerInfo(
p.flag,
p.clientID == clientID
? sanitize(p.username)
: fixProfaneUsername(sanitize(p.username)),
PlayerType.Human,
p.clientID,
p.playerID,
),
),
const random = new PseudoRandom(simpleHash(gameStart.gameID));
const humans = gameStart.players.map(
(p) =>
new PlayerInfo(
p.flag,
p.clientID == clientID
? sanitize(p.username)
: fixProfaneUsername(sanitize(p.username)),
PlayerType.Human,
p.clientID,
p.playerID,
),
);
const nations = gameStart.config.disableNPCs
? []
: gameMap.nationMap.nations.map(
(n) =>
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
new PlayerInfo(
n.flag || "",
n.name,
PlayerType.FakeHuman,
null,
random.nextID(),
),
),
);
const game: Game = createGame(
humans,
nations,
gameMap.gameMap,
gameMap.miniGameMap,
gameMap.nationMap,
config,
);
const gr = new GameRunner(
game as Game,
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
);
+6 -7
View File
@@ -2,11 +2,11 @@ import { z } from "zod";
import {
AllPlayers,
Difficulty,
Duos,
GameMapType,
GameMode,
GameType,
PlayerType,
Team,
UnitType,
} from "./game/Game";
import { flattenedEmojiTable } from "./Util";
@@ -122,9 +122,11 @@ const GameConfigSchema = z.object({
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
maxPlayers: z.number().optional(),
numPlayerTeams: z.number().optional(),
playerTeams: z.union([z.number().optional(), z.literal(Duos)]),
});
export const TeamSchema = z.string();
const SafeString = z
.string()
.regex(
@@ -361,7 +363,7 @@ const ClientBaseMessageSchema = z.object({
export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
type: z.literal("winner"),
winner: ID.or(z.nativeEnum(Team)).nullable(),
winner: z.union([ID, TeamSchema]).nullable(),
allPlayersStats: AllPlayersStatsSchema,
winnerType: z.enum(["player", "team"]),
});
@@ -422,10 +424,7 @@ export const GameRecordSchema = z.object({
date: SafeString,
num_turns: z.number(),
turns: z.array(TurnSchema),
winner: z
.union([ID, z.nativeEnum(Team)])
.nullable()
.optional(),
winner: z.union([ID, SafeString]).nullable().optional(),
winnerType: z.enum(["player", "team"]).nullable().optional(),
allPlayersStats: z.record(ID, PlayerStatsSchema),
version: z.enum(["v0.0.1"]),
+2 -1
View File
@@ -2,6 +2,7 @@ import { Colord } from "colord";
import { GameConfig, GameID } from "../Schemas";
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
@@ -72,7 +73,7 @@ export interface Config {
instantBuild(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
numPlayerTeams(): number;
playerTeams(): number | typeof Duos;
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
+5 -2
View File
@@ -1,5 +1,6 @@
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
@@ -217,12 +218,14 @@ export class DefaultConfig implements Config {
defensePostDefenseBonus(): number {
return 5;
}
numPlayerTeams(): number {
return this._gameConfig.numPlayerTeams ?? 0;
playerTeams(): number | typeof Duos {
return this._gameConfig.playerTeams ?? 0;
}
spawnNPCs(): boolean {
return !this._gameConfig.disableNPCs;
}
disableNukes(): boolean {
return this._gameConfig.disableNukes;
}
+11 -10
View File
@@ -1,7 +1,7 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
@@ -43,24 +43,25 @@ export const pastelTheme = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
+11 -10
View File
@@ -1,7 +1,7 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
@@ -43,24 +43,25 @@ export const pastelThemeDark = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
+2 -14
View File
@@ -1,4 +1,4 @@
import { Execution, Game, PlayerInfo, PlayerType } from "../game/Game";
import { Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
@@ -120,19 +120,7 @@ export class Executor {
fakeHumanExecutions(): Execution[] {
const execs = [];
for (const nation of this.mg.nations()) {
execs.push(
new FakeHumanExecution(
this.gameID,
new PlayerInfo(
nation.flag || "",
nation.name,
PlayerType.FakeHuman,
null,
this.random.nextID(),
nation,
),
),
);
execs.push(new FakeHumanExecution(this.gameID, nation));
}
return execs;
}
+9 -7
View File
@@ -4,9 +4,9 @@ import {
Difficulty,
Execution,
Game,
Nation,
Player,
PlayerID,
PlayerInfo,
PlayerType,
Relation,
TerrainType,
@@ -42,10 +42,10 @@ export class FakeHumanExecution implements Execution {
constructor(
gameID: GameID,
private playerInfo: PlayerInfo,
private nation: Nation,
) {
this.random = new PseudoRandom(
simpleHash(playerInfo.id) + simpleHash(gameID),
simpleHash(nation.playerInfo.id) + simpleHash(gameID),
);
this.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e));
}
@@ -102,15 +102,17 @@ export class FakeHumanExecution implements Execution {
if (ticks % this.random.nextInt(5, 30) == 0) {
const rl = this.randomLand();
if (rl == null) {
consolex.warn(`cannot spawn ${this.playerInfo.name}`);
consolex.warn(`cannot spawn ${this.nation.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.playerInfo, rl));
this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl));
}
return;
}
if (this.player == null) {
this.player = this.mg.players().find((p) => p.id() == this.playerInfo.id);
this.player = this.mg
.players()
.find((p) => p.id() == this.nation.playerInfo.id);
if (this.player == null) {
return;
}
@@ -552,7 +554,7 @@ export class FakeHumanExecution implements Execution {
let tries = 0;
while (tries < 50) {
tries++;
const cell = this.playerInfo.nation.cell;
const cell = this.nation.spawnCell;
const x = this.random.nextInt(cell.x - delta, cell.x + delta);
const y = this.random.nextInt(cell.y - delta, cell.y + delta);
if (!this.mg.isValidCoord(x, y)) {
+9 -2
View File
@@ -1,5 +1,12 @@
import { GameEvent } from "../EventBus";
import { Execution, Game, GameMode, Player, Team } from "../game/Game";
import {
ColoredTeams,
Execution,
Game,
GameMode,
Player,
Team,
} from "../game/Game";
export class WinEvent implements GameEvent {
constructor(public readonly winner: Player) {}
@@ -66,7 +73,7 @@ export class WinCheckExecution implements Execution {
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
const percentage = (max[1] / numTilesWithoutFallout) * 100;
if (percentage > this.mg.config().percentageTilesOwnedToWin()) {
if (max[0] == Team.Bot) return;
if (max[0] == ColoredTeams.Bot) return;
this.mg.setWinner(max[0], this.mg.stats().stats());
console.log(`${max[0]} has won the game`);
this.active = false;
+16 -13
View File
@@ -37,16 +37,20 @@ export enum Difficulty {
Impossible = "Impossible",
}
export enum Team {
Red = "Red",
Blue = "Blue",
Teal = "Teal",
Purple = "Purple",
Yellow = "Yellow",
Orange = "Orange",
Green = "Green",
Bot = "Bot",
}
export type Team = string;
export const Duos = "Duos" as const;
export const ColoredTeams: Record<string, Team> = {
Red: "Red",
Blue: "Blue",
Teal: "Teal",
Purple: "Purple",
Yellow: "Yellow",
Orange: "Orange",
Green: "Green",
Bot: "Bot",
} as const;
export enum GameMapType {
World = "World",
@@ -152,10 +156,9 @@ export enum Relation {
export class Nation {
constructor(
public readonly flag: string,
public readonly name: string,
public readonly cell: Cell,
public readonly spawnCell: Cell,
public readonly strength: number,
public readonly playerInfo: PlayerInfo,
) {}
}
+37 -27
View File
@@ -8,6 +8,8 @@ import {
Alliance,
AllianceRequest,
Cell,
ColoredTeams,
Duos,
EmojiMessage,
Execution,
Game,
@@ -32,19 +34,18 @@ import { PlayerImpl } from "./PlayerImpl";
import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
import { NationMap } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid } from "./UnitGrid";
import { UnitImpl } from "./UnitImpl";
export function createGame(
humans: PlayerInfo[],
nations: Nation[],
gameMap: GameMap,
miniGameMap: GameMap,
nationMap: NationMap,
config: Config,
): Game {
return new GameImpl(humans, gameMap, miniGameMap, nationMap, config);
return new GameImpl(humans, nations, gameMap, miniGameMap, config);
}
export type CellString = string;
@@ -54,8 +55,6 @@ export class GameImpl implements Game {
private unInitExecs: Execution[] = [];
private nations_: Nation[] = [];
_players: Map<PlayerID, PlayerImpl> = new Map<PlayerID, PlayerImpl>();
_playersBySmallID = [];
@@ -75,51 +74,62 @@ export class GameImpl implements Game {
private _stats: StatsImpl = new StatsImpl();
private playerTeams: Team[] = [Team.Red, Team.Blue];
private botTeam: Team = Team.Bot;
private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue];
private botTeam: Team = ColoredTeams.Bot;
constructor(
private _humans: PlayerInfo[],
private _nations: Nation[],
private _map: GameMap,
private miniGameMap: GameMap,
nationMap: NationMap,
private _config: Config,
) {
this._terraNullius = new TerraNulliusImpl();
this._width = _map.width();
this._height = _map.height();
this.nations_ = nationMap.nations.map(
(n) =>
new Nation(
n.flag || "",
n.name,
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
),
);
this.unitGrid = new UnitGrid(this._map);
if (_config.gameConfig().gameMode === GameMode.Team) {
const numPlayerTeams = _config.numPlayerTeams();
this.populateTeams();
}
this.addPlayers();
}
private populateTeams() {
if (this._config.playerTeams() === Duos) {
this.playerTeams = [];
const numTeams = Math.ceil(
(this._humans.length + this._nations.length) / 2,
);
for (let i = 0; i < numTeams; i++) {
this.playerTeams.push("Team " + (i + 1));
}
} else {
const numPlayerTeams = this._config.playerTeams() as number;
if (numPlayerTeams < 2)
throw new Error(`Too few teams: ${numPlayerTeams}`);
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 >= 3) this.playerTeams.push(ColoredTeams.Teal);
if (numPlayerTeams >= 4) this.playerTeams.push(ColoredTeams.Purple);
if (numPlayerTeams >= 5) this.playerTeams.push(ColoredTeams.Yellow);
if (numPlayerTeams >= 6) this.playerTeams.push(ColoredTeams.Orange);
if (numPlayerTeams >= 7) this.playerTeams.push(ColoredTeams.Green);
if (numPlayerTeams >= 8)
throw new Error(`Too many teams: ${numPlayerTeams}`);
}
this.addHumans();
}
private addHumans() {
private addPlayers() {
if (this.config().gameConfig().gameMode != GameMode.Team) {
this._humans.forEach((p) => this.addPlayer(p));
this._nations.forEach((n) => this.addPlayer(n.playerInfo));
return;
}
const playerToTeam = assignTeams(this._humans, this.playerTeams);
const isDuos = this.config().gameConfig().playerTeams === Duos;
const allPlayers = [
...this._humans,
...this._nations.map((n) => n.playerInfo),
];
const playerToTeam = assignTeams(allPlayers, this.playerTeams);
for (const [playerInfo, team] of playerToTeam.entries()) {
if (team == "kicked") {
console.warn(`Player ${playerInfo.name} was kicked from team`);
@@ -180,7 +190,7 @@ export class GameImpl implements Game {
return this.config().unitInfo(type);
}
nations(): Nation[] {
return this.nations_;
return this._nations;
}
createAllianceRequest(requestor: Player, recipient: Player): AllianceRequest {
+2 -1
View File
@@ -20,6 +20,7 @@ import {
Attack,
BuildableUnit,
Cell,
ColoredTeams,
EmojiMessage,
Gold,
MessageType,
@@ -606,7 +607,7 @@ export class PlayerImpl implements Player {
if (this.team() == null || other.team() == null) {
return false;
}
if (this.team() == Team.Bot || other.team() == Team.Bot) {
if (this.team() == ColoredTeams.Bot || other.team() == ColoredTeams.Bot) {
return false;
}
return this._team == other.team();
+2 -2
View File
@@ -95,8 +95,8 @@ export class GameServer {
if (gameConfig.gameMode != null) {
this.gameConfig.gameMode = gameConfig.gameMode;
}
if (gameConfig.numPlayerTeams != null) {
this.gameConfig.numPlayerTeams = gameConfig.numPlayerTeams;
if (gameConfig.playerTeams != null) {
this.gameConfig.playerTeams = gameConfig.playerTeams;
}
}
+94 -108
View File
@@ -1,133 +1,119 @@
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
import { GameMapType, GameMode } from "../core/game/Game";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameConfig } from "../core/Schemas";
import { logger } from "./Logger";
const log = logger.child({});
const config = getServerConfigFromServer();
const frequency = {
World: 3,
Europe: 2,
Africa: 2,
Australia: 1,
NorthAmerica: 1,
Britannia: 1,
GatewayToTheAtlantic: 1,
Iceland: 1,
SouthAmerica: 1,
KnownWorld: 1,
DeglaciatedAntarctica: 1,
EuropeClassic: 1,
Mena: 1,
Pangaea: 1,
Asia: 1,
Mars: 1,
BetweenTwoSeas: 1,
Japan: 1,
BlackSea: 1,
FaroeIslands: 1,
};
interface MapWithMode {
map: GameMapType;
mode: GameMode;
enum PlaylistType {
BigMaps,
SmallMaps,
}
const random = new PseudoRandom(123);
export class MapPlaylist {
private mapsPlaylist: MapWithMode[] = [];
private gameModeRotation = [GameMode.FFA, GameMode.FFA, GameMode.Team];
private currentGameModeIndex = 0;
public gameConfig(): GameConfig {
const { map, mode } = this.getNextMap();
private mapsPlaylistBig: GameMapType[] = [];
private mapsPlaylistSmall: GameMapType[] = [];
private currentPlaylistCounter = 0;
const numPlayerTeams =
mode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined;
// Create the default public game config (from your GameManager)
return {
gameMap: map,
maxPlayers: config.lobbyMaxPlayers(map, mode),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: mode == GameMode.Team,
disableNukes: false,
gameMode: mode,
numPlayerTeams: numPlayerTeams,
bots: 400,
} as GameConfig;
// Get the next map in rotation
public getNextMap(): GameMapType {
const playlistType: PlaylistType = this.getNextPlaylistType();
const mapsPlaylist: GameMapType[] = this.getNextMapsPlayList(playlistType);
return mapsPlaylist.shift()!;
}
private getNextMap(): MapWithMode {
if (this.mapsPlaylist.length === 0) {
const numAttempts = 10000;
for (let i = 0; i < numAttempts; i++) {
if (this.shuffleMapsPlaylist()) {
log.info(`Generated map playlist in ${i} attempts`);
return this.mapsPlaylist.shift()!;
public getNextGameMode(): GameMode {
const nextGameMode = this.gameModeRotation[this.currentGameModeIndex];
this.currentGameModeIndex =
(this.currentGameModeIndex + 1) % this.gameModeRotation.length;
return nextGameMode;
}
private getNextMapsPlayList(playlistType: PlaylistType): GameMapType[] {
switch (playlistType) {
case PlaylistType.BigMaps:
if (!(this.mapsPlaylistBig.length > 0)) {
this.fillMapsPlaylist(playlistType, this.mapsPlaylistBig);
}
}
log.error("Failed to generate a valid map playlist");
return this.mapsPlaylistBig;
case PlaylistType.SmallMaps:
if (!(this.mapsPlaylistSmall.length > 0)) {
this.fillMapsPlaylist(playlistType, this.mapsPlaylistSmall);
}
return this.mapsPlaylistSmall;
}
// Even if it failed, playlist will be partially populated.
return this.mapsPlaylist.shift()!;
}
private shuffleMapsPlaylist(): boolean {
const maps: GameMapType[] = [];
private fillMapsPlaylist(
playlistType: PlaylistType,
mapsPlaylist: GameMapType[],
): void {
const frequency = this.getFrequency(playlistType);
Object.keys(GameMapType).forEach((key) => {
for (let i = 0; i < parseInt(frequency[key]); i++) {
maps.push(GameMapType[key]);
let count = parseInt(frequency[key]);
while (count > 0) {
mapsPlaylist.push(GameMapType[key]);
count--;
}
});
do {
random.shuffleArray(mapsPlaylist);
} while (!this.allNonConsecutive(mapsPlaylist));
}
const rand = new PseudoRandom(Date.now());
// Specifically controls how the playlists rotate.
private getNextPlaylistType(): PlaylistType {
switch (this.currentPlaylistCounter) {
case 0:
case 1:
this.currentPlaylistCounter++;
return PlaylistType.BigMaps;
case 2:
this.currentPlaylistCounter = 0;
return PlaylistType.SmallMaps;
}
}
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
const ffa3: GameMapType[] = rand.shuffleArray([...maps]);
const team: GameMapType[] = rand.shuffleArray([...maps]);
private getFrequency(playlistType: PlaylistType) {
switch (playlistType) {
// Big Maps are those larger than ~2.5 mil pixels
case PlaylistType.BigMaps:
return {
Europe: 2,
NorthAmerica: 1,
Africa: 2,
Britannia: 1,
GatewayToTheAtlantic: 2,
Australia: 2,
Iceland: 2,
SouthAmerica: 1,
KnownWorld: 2,
};
case PlaylistType.SmallMaps:
return {
World: 4,
EuropeClassic: 3,
Mena: 2,
Pangaea: 1,
Asia: 1,
Mars: 1,
BetweenTwoSeas: 2,
Japan: 2,
BlackSea: 1,
FaroeIslands: 2,
};
}
}
this.mapsPlaylist = [];
for (let i = 0; i < maps.length; i++) {
if (!this.addNextMap(this.mapsPlaylist, ffa1, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
// Check for consecutive duplicates in the maps array
private allNonConsecutive(maps: GameMapType[]): boolean {
for (let i = 0; i < maps.length - 1; i++) {
if (maps[i] === maps[i + 1]) {
return false;
}
}
return true;
}
private addNextMap(
playlist: MapWithMode[],
nextEls: GameMapType[],
mode: GameMode,
): boolean {
const nonConsecutiveNum = 5;
const lastEls = playlist
.slice(playlist.length - nonConsecutiveNum)
.map((m) => m.map);
for (let i = 0; i < nextEls.length; i++) {
const next = nextEls[i];
if (lastEls.includes(next)) {
continue;
}
nextEls.splice(i, 1);
playlist.push({ map: next, mode: mode });
return true;
}
return false;
}
}
+33 -2
View File
@@ -5,7 +5,8 @@ import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameInfo } from "../core/Schemas";
import { Difficulty, GameMode, GameType } from "../core/game/Game";
import { GameConfig, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { logger } from "./Logger";
@@ -213,10 +214,40 @@ async function fetchLobbies(): Promise<number> {
return publicLobbyIDs.size;
}
let lastGameMode: GameMode = GameMode.FFA;
// Function to schedule a new public game
async function schedulePublicGame(playlist: MapPlaylist) {
const gameID = generateID();
const map = playlist.getNextMap();
publicLobbyIDs.add(gameID);
if (lastGameMode == GameMode.FFA) {
lastGameMode = GameMode.Team;
} else {
lastGameMode = GameMode.FFA;
}
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: GameConfig = {
gameMap: map,
maxPlayers: config.lobbyMaxPlayers(map, gameMode),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: gameMode == GameMode.Team,
disableNukes: false,
gameMode,
playerTeams: numPlayerTeams,
bots: 400,
};
const workerPath = config.workerPath(gameID);
// Send request to the worker to start the game
@@ -230,7 +261,7 @@ async function schedulePublicGame(playlist: MapPlaylist) {
[config.adminHeader()]: config.adminToken(),
},
body: JSON.stringify({
gameConfig: playlist.gameConfig(),
gameConfig: defaultGameConfig,
}),
},
);
+1 -1
View File
@@ -164,7 +164,7 @@ export function startWorker() {
disableNPCs: req.body.disableNPCs,
disableNukes: req.body.disableNukes,
gameMode: req.body.gameMode,
numPlayerTeams: req.body.numPlayerTeams,
playerTeams: req.body.playerTeams,
});
res.status(200).json({ success: true });
}),
+46 -46
View File
@@ -1,7 +1,7 @@
import { PlayerInfo, PlayerType, Team } from "../src/core/game/Game";
import { ColoredTeams, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { assignTeams } from "../src/core/game/TeamAssignment";
const teams = [Team.Red, Team.Blue];
const teams = [ColoredTeams.Red, ColoredTeams.Blue];
describe("assignTeams", () => {
const createPlayer = (id: string, clan?: string): PlayerInfo => {
@@ -27,10 +27,10 @@ describe("assignTeams", () => {
const result = assignTeams(players, teams);
// Check that players are assigned alternately
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Blue);
expect(result.get(players[2])).toEqual(Team.Red);
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Blue);
expect(result.get(players[2])).toEqual(ColoredTeams.Red);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
});
it("should keep clan members together on the same team", () => {
@@ -44,10 +44,10 @@ describe("assignTeams", () => {
const result = assignTeams(players, teams);
// Check that clan members are on the same team
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[2])).toEqual(Team.Blue);
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Blue);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
});
it("should handle mixed clan and non-clan players", () => {
@@ -61,10 +61,10 @@ describe("assignTeams", () => {
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);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[2])).toEqual(Team.Blue);
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Blue);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
});
it("should kick players when teams are full", () => {
@@ -80,14 +80,14 @@ describe("assignTeams", () => {
const result = assignTeams(players, teams);
// Check that players are kicked when teams are full
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[2])).toEqual(Team.Red);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Red);
expect(result.get(players[3])).toEqual("kicked");
expect(result.get(players[4])).toEqual(Team.Blue);
expect(result.get(players[5])).toEqual(Team.Blue);
expect(result.get(players[4])).toEqual(ColoredTeams.Blue);
expect(result.get(players[5])).toEqual(ColoredTeams.Blue);
});
it("should handle empty player list", () => {
@@ -98,7 +98,7 @@ describe("assignTeams", () => {
it("should handle single player", () => {
const players = [createPlayer("1")];
const result = assignTeams(players, teams);
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
});
it("should handle multiple clans with different sizes", () => {
@@ -114,12 +114,12 @@ describe("assignTeams", () => {
const result = assignTeams(players, teams);
// Check that larger clans are assigned first
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[2])).toEqual(Team.Red);
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[4])).toEqual(Team.Blue);
expect(result.get(players[5])).toEqual(Team.Blue);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Red);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
expect(result.get(players[4])).toEqual(ColoredTeams.Blue);
expect(result.get(players[5])).toEqual(ColoredTeams.Blue);
});
it("should distribute players among a larger number of teams", () => {
@@ -141,28 +141,28 @@ describe("assignTeams", () => {
];
const result = assignTeams(players, [
Team.Red,
Team.Blue,
Team.Teal,
Team.Purple,
Team.Yellow,
Team.Orange,
Team.Green,
ColoredTeams.Red,
ColoredTeams.Blue,
ColoredTeams.Teal,
ColoredTeams.Purple,
ColoredTeams.Yellow,
ColoredTeams.Orange,
ColoredTeams.Green,
]);
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.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);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
expect(result.get(players[4])).toEqual(ColoredTeams.Blue);
expect(result.get(players[5])).toEqual(ColoredTeams.Teal);
expect(result.get(players[6])).toEqual(ColoredTeams.Purple);
expect(result.get(players[7])).toEqual(ColoredTeams.Yellow);
expect(result.get(players[8])).toEqual(ColoredTeams.Orange);
expect(result.get(players[9])).toEqual(ColoredTeams.Green);
expect(result.get(players[10])).toEqual(ColoredTeams.Teal);
expect(result.get(players[11])).toEqual(ColoredTeams.Purple);
expect(result.get(players[12])).toEqual(ColoredTeams.Yellow);
expect(result.get(players[13])).toEqual(ColoredTeams.Orange);
});
});
+1 -2
View File
@@ -18,7 +18,6 @@ export async function setup(mapName: string, _gameConfig: GameConfig = {}) {
const miniGameMap = await genTerrainFromBin(
String.fromCharCode.apply(null, miniMap),
);
const nationMap = { nations: [] };
// Configure the game
const serverConfig = new TestServerConfig();
@@ -36,5 +35,5 @@ export async function setup(mapName: string, _gameConfig: GameConfig = {}) {
const config = new TestConfig(serverConfig, gameConfig, new UserSettings());
// Create and return the game
return createGame([], gameMap, miniGameMap, nationMap, config); // TODO: !!!
return createGame([], [], gameMap, miniGameMap, config);
}