Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions resources/lang/en.json
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be better if it said something like "Next Game"

Copy link
Author

@DeLoWaN DeLoWaN Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was what I put initially (well it was "Up next", hence the branch name). Then I realized that the button to join the game was actually "Join next Game". This would have result into two usage of "next" and they were not referring to the same game, that might have been confusing, that's why I used another name.

I even thought of changing "Join next Game" by just "Join Game" to get rid of the "next", but I didn't had the courage to change all the translations !

Or maybe I am just overthinking this, I am happy to get it back to to "Up next" or "Next Game" if you find it better.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I prefer "up next"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I prefer "up next"

how about "Starting Soon" and then "Up Next" because I do agree having two "next"s is odd

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to change "Join next game" to "Join this lobby" or simply "Join this game"?

Copy link
Author

@DeLoWaN DeLoWaN Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it to "Join Game" and "Up next": fix displayed text

Original file line number Diff line number Diff line change
Expand Up @@ -266,14 +266,15 @@
"version_mismatch": "This game was created with a different version. Cannot join."
},
"public_lobby": {
"join": "Join next Game",
"join": "Join Game",
"waiting": "players waiting",
"teams_Duos": "of 2 (Duos)",
"teams_Trios": "of 3 (Trios)",
"teams_Quads": "of 4 (Quads)",
"teams_hvn": "Humans Vs Nations",
"teams": "{num} teams",
"players_per_team": "of {num}"
"players_per_team": "of {num}",
"up_next": "Up next"
},
"matchmaking_modal": {
"title": "Matchmaking",
Expand Down
103 changes: 78 additions & 25 deletions src/client/PublicLobby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Quads,
Trios,
} from "../core/game/Game";
import { GameID, GameInfo } from "../core/Schemas";
import { GameID, GameInfo, TeamCountConfig } from "../core/Schemas";
import { generateID } from "../core/Util";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
Expand Down Expand Up @@ -124,37 +124,44 @@ export class PublicLobby extends LitElement {
// Format time to show minutes and seconds
const timeDisplay = renderDuration(timeRemaining);

const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? (lobby.gameConfig.playerTeams ?? 0)
: null;

const maxPlayers = lobby.gameConfig.maxPlayers ?? 0;
const teamSize = this.getTeamSize(teamCount, maxPlayers);
const teamTotal = this.getTeamTotal(teamCount, teamSize, maxPlayers);
const modeLabel = this.getModeLabel(
lobby.gameConfig.gameMode,
teamCount,
teamTotal,
);
const teamDetailLabel = this.getTeamDetailLabel(
const {
modeLabel,
teamDetailLabel,
mapName: currentMapName,
} = this.getGameDisplayDetails(
lobby.gameConfig.gameMap,
lobby.gameConfig.gameMode,
teamCount,
teamTotal,
teamSize,
lobby.gameConfig.playerTeams,
lobby.gameConfig.maxPlayers,
);



const fullModeLabel = teamDetailLabel
? `${modeLabel} ${teamDetailLabel}`
: modeLabel;

const mapImageSrc = this.mapImages.get(lobby.gameID);

// Calculate details for Next Game
const {
modeLabel: nextModeLabel,
teamDetailLabel: nextTeamDetailLabel,
mapName: nextMapName,
} = this.getGameDisplayDetails(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be a bit cleaner to pass the entire game config into getGameDisplayDetails, instead of passing in 4 arguments.

lobby.nextGameConfig?.gameMap,
lobby.nextGameConfig?.gameMode,
lobby.nextGameConfig?.playerTeams,
lobby.nextGameConfig?.maxPlayers,
);

const fullNextModeLabel = nextTeamDetailLabel
? `${nextModeLabel} ${nextTeamDetailLabel}`
: nextModeLabel;
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
?disabled=${this.isButtonDebounced}
class="isolate grid h-40 grid-cols-[100%] grid-rows-[100%] place-content-stretch w-full overflow-hidden ${this
class="isolate grid h-60 grid-cols-[100%] grid-rows-[100%] place-content-stretch w-full overflow-hidden ${this
.isLobbyHighlighted
? "bg-gradient-to-r from-emerald-600 to-emerald-500"
: "bg-gradient-to-r from-red-800 to-red-700"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90 ${this
Expand Down Expand Up @@ -183,11 +190,7 @@ export class PublicLobby extends LitElement {
<span class="text-sm text-red-800 bg-white rounded-sm px-1 mr-1"
>${fullModeLabel}</span
>
<span
>${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
)}</span
>
<span>${currentMapName}</span>
</div>
</div>

Expand All @@ -197,6 +200,25 @@ export class PublicLobby extends LitElement {
</div>
<div class="text-md font-medium text-white-400">${timeDisplay}</div>
</div>
${lobby.nextGameConfig
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes the PublicLobby component too large. would it be possible to keep it the same size, and have the "up next" be a small chip on the bottom left.

? html` <div
class="col-span-full border-t border-white/20 pt-2 mt-2 flex flex-col items-end"
>
<div
class="text-xs font-semibold uppercase tracking-wide opacity-80 mb-1"
>
${translateText("public_lobby.up_next")}
</div>
<div
class="text-sm font-medium text-white-300 flex items-center justify-end gap-1"
>
<span class="text-xs text-red-800 bg-white rounded-sm px-1"
>${fullNextModeLabel}</span
>
<span>${nextMapName}</span>
</div>
</div>`
: ""}
</div>
</button>
`;
Expand Down Expand Up @@ -269,6 +291,37 @@ export class PublicLobby extends LitElement {
return null;
}

private getGameDisplayDetails(
gameMap?: string,
gameMode?: GameMode,
playerTeams?: TeamCountConfig,
maxPlayers?: number,
) {
const teamCount = gameMode === GameMode.Team ? (playerTeams ?? 0) : null;

const totalMaxPlayers = maxPlayers ?? 0;
const teamSize = this.getTeamSize(teamCount, totalMaxPlayers);
const teamTotal = this.getTeamTotal(teamCount, teamSize, totalMaxPlayers);

const modeLabel = gameMode
? this.getModeLabel(gameMode, teamCount, teamTotal)
: "";

const teamDetailLabel = gameMode
? this.getTeamDetailLabel(gameMode, teamCount, teamTotal, teamSize)
: null;

const mapName = gameMap
? translateText(`map.${gameMap.toLowerCase().replace(/[\s.]+/g, "")}`)
: "";

return {
modeLabel,
teamDetailLabel,
mapName,
};
}

private lobbyClicked(lobby: GameInfo) {
if (this.isButtonDebounced) {
return;
Expand Down
1 change: 1 addition & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export interface GameInfo {
numClients?: number;
msUntilStart?: number;
gameConfig?: GameConfig;
nextGameConfig?: GameConfig;
}
export interface ClientInfo {
clientID: ClientID;
Expand Down
18 changes: 12 additions & 6 deletions src/core/WorkerSchemas.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { z } from "zod";
import { GameConfigSchema } from "./Schemas";

export const CreateGameInputSchema = GameConfigSchema.or(
z
.object({})
.strict()
.transform((val) => undefined),
);
export const CreateGameInputSchema = z
.object({
gameConfig: GameConfigSchema,
nextGameConfig: GameConfigSchema.optional(),
})
.or(GameConfigSchema)
.or(
z
.object({})
.strict()
.transform((val) => undefined),
);
Comment on lines +9 to +15
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be possible to remove these two .or, instead just have:

.object({
gameConfig: GameConfigSchema,
nextGameConfig: GameConfigSchema.optional(),
})


export const GameInputSchema = GameConfigSchema.partial();
2 changes: 2 additions & 0 deletions src/server/GameManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class GameManager {
createGame(
id: GameID,
gameConfig: GameConfig | undefined,
nextGameConfig: GameConfig | undefined,
creatorClientID?: string,
) {
const game = new GameServer(
Expand All @@ -76,6 +77,7 @@ export class GameManager {
disabledUnits: [],
...gameConfig,
},
nextGameConfig,
creatorClientID,
);
this.games.set(id, game);
Expand Down
2 changes: 2 additions & 0 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export class GameServer {
public readonly createdAt: number,
private config: ServerConfig,
public gameConfig: GameConfig,
public nextGameConfig?: GameConfig,
private lobbyCreatorID?: string,
) {
this.log = log_.child({ gameID: id });
Expand Down Expand Up @@ -651,6 +652,7 @@ export class GameServer {
clientID: c.clientID,
})),
gameConfig: this.gameConfig,
nextGameConfig: this.nextGameConfig,
msUntilStart: this.isPublic()
? this.createdAt + this.config.gameCreationRate()
: undefined,
Expand Down
56 changes: 50 additions & 6 deletions src/server/MapPlaylist.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to modify this file at all? why not just call "gameConfig()" twice?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From delowan 3 days ago

If we do that, we will consume a map from the playlist (with the shift()), so the next map would be actually skipped.

Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
interface MapWithMode {
map: GameMapType;
mode: GameMode;
playerTeams?: TeamCountConfig;
}

const TEAM_COUNTS = [
Expand All @@ -79,13 +80,52 @@ export class MapPlaylist {

constructor(private disableTeams: boolean = false) {}

public gameConfig(): GameConfig {
const { map, mode } = this.getNextMap();
public gameConfig(): {
gameConfig: GameConfig;
nextGameConfig?: GameConfig;
} {
const nextItem = this.getNextMap();
const { map, mode } = nextItem;

this.ensurePlaylistPopulated();
const upNextItem = this.mapsPlaylist[0];
let nextPlayerTeams: TeamCountConfig | undefined;

if (upNextItem) {
if (upNextItem.mode === GameMode.Team && !upNextItem.playerTeams) {
upNextItem.playerTeams = this.getTeamCount();
}
nextPlayerTeams = upNextItem.playerTeams;
}

const playerTeams =
mode === GameMode.Team ? this.getTeamCount() : undefined;
mode === GameMode.Team
? (nextItem.playerTeams ?? this.getTeamCount())
: undefined;

const gameConfig = this.createConfig(map, mode, playerTeams);

let nextGameConfig: GameConfig | undefined;
if (upNextItem) {
nextGameConfig = this.createConfig(
upNextItem.map,
upNextItem.mode,
nextPlayerTeams,
);
}

// Create the default public game config (from your GameManager)
return {
gameConfig,
nextGameConfig,
};
}

private createConfig(
map: GameMapType,
mode: GameMode,
playerTeams?: TeamCountConfig,
): GameConfig {
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
Expand All @@ -104,24 +144,28 @@ export class MapPlaylist {
playerTeams,
bots: 400,
disabledUnits: [],
} satisfies GameConfig;
};
}

private getTeamCount(): TeamCountConfig {
return TEAM_COUNTS[Math.floor(Math.random() * TEAM_COUNTS.length)];
}

private getNextMap(): MapWithMode {
private ensurePlaylistPopulated() {
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()!;
return;
}
}
log.error("Failed to generate a valid map playlist");
}
}

private getNextMap(): MapWithMode {
this.ensurePlaylistPopulated();
// Even if it failed, playlist will be partially populated.
return this.mapsPlaylist.shift()!;
}
Comment on lines +167 to 171
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Non-null assertion may be unsafe.

Line 170 uses the non-null assertion operator (!) on shift(), which could return undefined if the playlist is empty. While the comment says the playlist will be "partially populated" even on failure, there's no explicit guarantee it's never empty.

Consider either:

  1. Removing the ! and handling the undefined case explicitly
  2. Adding a runtime check that throws a descriptive error if the playlist is empty
  3. Ensuring shuffleMapsPlaylist() always populates at least one item (with proof/assertion)
🔎 Safer alternative
 private getNextMap(): MapWithMode {
   this.ensurePlaylistPopulated();
-  // Even if it failed, playlist will be partially populated.
-  return this.mapsPlaylist.shift()!;
+  const next = this.mapsPlaylist.shift();
+  if (!next) {
+    throw new Error("Map playlist is empty - failed to generate valid playlist");
+  }
+  return next;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private getNextMap(): MapWithMode {
this.ensurePlaylistPopulated();
// Even if it failed, playlist will be partially populated.
return this.mapsPlaylist.shift()!;
}
private getNextMap(): MapWithMode {
this.ensurePlaylistPopulated();
const next = this.mapsPlaylist.shift();
if (!next) {
throw new Error("Map playlist is empty - failed to generate valid playlist");
}
return next;
}
🤖 Prompt for AI Agents
In src/server/MapPlaylist.ts around lines 167-171 the code uses a non-null
assertion on this.mapsPlaylist.shift() which can return undefined if the
playlist is empty; replace the unsafe assertion with an explicit runtime check:
after ensurePlaylistPopulated() verify this.mapsPlaylist.length > 0 and if not
throw a descriptive Error (e.g. "Maps playlist is empty after population"), then
call shift() (no !) and return the result; alternatively adjust the method
signature to return MapWithMode | undefined and propagate that change — prefer
the throw approach for a clear fail-fast behavior.

Expand Down
1 change: 1 addition & 0 deletions src/server/Master.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ async function fetchLobbies(): Promise<number> {
gameID: gi.gameID,
numClients: gi?.clients?.length ?? 0,
gameConfig: gi.gameConfig,
nextGameConfig: gi.nextGameConfig,
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
} as GameInfo;
});
Expand Down
22 changes: 17 additions & 5 deletions src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import {
ClientMessageSchema,
GameConfig,
GameID,
ID,
PartialGameRecordSchema,
Expand Down Expand Up @@ -128,9 +129,19 @@ export async function startWorker() {
return res.status(400).json({ error });
}

const gc = result.data;
const input = result.data;
let gameConfig: GameConfig | undefined;
let nextGameConfig: GameConfig | undefined;

if (input && "gameConfig" in input) {
gameConfig = input.gameConfig;
nextGameConfig = input.nextGameConfig;
} else {
gameConfig = input;
}

if (
gc?.gameType === GameType.Public &&
gameConfig?.gameType === GameType.Public &&
req.headers[config.adminHeader()] !== config.adminToken()
) {
log.warn(
Expand All @@ -149,10 +160,10 @@ export async function startWorker() {
}

// Pass creatorClientID to createGame
const game = gm.createGame(id, gc, creatorClientID);
const game = gm.createGame(id, gameConfig, nextGameConfig, creatorClientID);

log.info(
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`,
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gameConfig?.gameMode ? ` ${gameConfig.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`,
);
res.json(game.gameInfo());
});
Expand Down Expand Up @@ -556,7 +567,8 @@ async function pollLobby(gm: GameManager) {
if (data.assignment) {
// TODO: Only allow specified players to join the game.
console.log(`Creating game ${gameId}`);
const game = gm.createGame(gameId, playlist.gameConfig());
const { gameConfig, nextGameConfig } = playlist.gameConfig();
const game = gm.createGame(gameId, gameConfig, nextGameConfig);
setTimeout(() => {
// Wait a few seconds to allow clients to connect.
console.log(`Starting game ${gameId}`);
Expand Down
Loading