This commit is contained in:
Scott Anderson
2025-06-24 17:40:59 -04:00
parent 260f5f7d28
commit 070fed8a53
7 changed files with 123 additions and 119 deletions
+76 -66
View File
@@ -101,81 +101,91 @@ export class PublicLobby extends LitElement {
render() {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
if (!lobby?.gameConfig) {
return;
}
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
const timeRemaining = Math.max(0, Math.floor((start - Date.now()) / 1000));
const elements = this.lobbies.map((lobby) => {
if (!lobby?.gameConfig) {
return;
}
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
const timeRemaining = Math.max(
0,
Math.floor((start - Date.now()) / 1000),
);
// Format time to show minutes and seconds
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
const timeDisplay = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
// Format time to show minutes and seconds
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
const timeDisplay =
minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? lobby.gameConfig.playerTeams || 0
: null;
const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? lobby.gameConfig.playerTeams || 0
: null;
const mapImageSrc = this.mapImages.get(lobby.gameID);
const mapImageSrc = this.mapImages.get(lobby.gameID);
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
.isLobbyHighlighted
? "bg-gradient-to-r from-green-600 to-green-500"
: "bg-gradient-to-r from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90 ${this
.isButtonDebounced
? "opacity-70 cursor-not-allowed"
: ""}"
>
${mapImageSrc
? html`<img
src="${mapImageSrc}"
alt="${lobby.gameConfig.gameMap}"
class="place-self-start col-span-full row-span-full h-full -z-10"
style="mask-image: linear-gradient(to left, transparent, #fff)"
/>`
: html`<div
class="place-self-start col-span-full row-span-full h-full -z-10 bg-gray-300"
></div>`}
<div
class="flex flex-col justify-between h-full col-span-full row-span-full p-4 md:p-6 text-right z-0"
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
.isLobbyHighlighted
? "bg-gradient-to-r from-green-600 to-green-500"
: "bg-gradient-to-r from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90 ${this
.isButtonDebounced
? "opacity-70 cursor-not-allowed"
: ""}"
>
<div>
<div class="text-lg md:text-2xl font-semibold">
${translateText("public_lobby.join")}
${mapImageSrc
? html`<img
src="${mapImageSrc}"
alt="${lobby.gameConfig.gameMap}"
class="place-self-start col-span-full row-span-full h-full -z-10"
style="mask-image: linear-gradient(to left, transparent, #fff)"
/>`
: html`<div
class="place-self-start col-span-full row-span-full h-full -z-10 bg-gray-300"
></div>`}
<div
class="flex flex-col justify-between h-full col-span-full row-span-full p-4 md:p-6 text-right z-0"
>
<div>
<div class="text-lg md:text-2xl font-semibold">
${translateText("public_lobby.join")}
</div>
<div class="text-md font-medium text-blue-100">
<span
class="text-sm ${this.isLobbyHighlighted
? "text-green-600"
: "text-blue-600"} bg-white rounded-sm px-1"
>
${lobby.gameConfig.gameMode === GameMode.Team
? translateText("public_lobby.teams", {
num: teamCount ?? 0,
})
: translateText("game_mode.ffa")}</span
>
<span
>${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
)}</span
>
</div>
</div>
<div class="text-md font-medium text-blue-100">
<span
class="text-sm ${this.isLobbyHighlighted
? "text-green-600"
: "text-blue-600"} bg-white rounded-sm px-1"
>
${lobby.gameConfig.gameMode === GameMode.Team
? translateText("public_lobby.teams", { num: teamCount ?? 0 })
: translateText("game_mode.ffa")}</span
>
<span
>${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
)}</span
>
</div>
</div>
<div>
<div class="text-md font-medium text-blue-100">
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
<div>
<div class="text-md font-medium text-blue-100">
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
</div>
<div class="text-md font-medium text-blue-100">
${timeDisplay}
</div>
</div>
<div class="text-md font-medium text-blue-100">${timeDisplay}</div>
</div>
</div>
</button>
`;
</button>
`;
});
return elements;
}
leaveLobby() {
+1 -4
View File
@@ -196,10 +196,7 @@
class="w-[20%] md:w-[15%] component-hideable"
></news-button>
</div>
<div></div>
<div>
<public-lobby class="block"></public-lobby>
</div>
<public-lobby class="block"></public-lobby>
<div class="container__row container__row--equal">
<o-button
id="host-lobby-button"
+7 -1
View File
@@ -1,11 +1,17 @@
import { z } from "zod/v4";
import { GameConfigSchema } from "./Schemas";
export const CreateGameInputSchema = GameConfigSchema.or(
export const CreateGameConfigSchema = GameConfigSchema.or(
z
.object({})
.strict()
.transform((val) => undefined),
);
export const CreateGameInputSchema = z.object({
config: CreateGameConfigSchema,
startTime: z.number().optional(),
});
export type CreateGameInput = z.infer<typeof CreateGameInputSchema>;
export const GameInputSchema = GameConfigSchema.partial();
+6 -2
View File
@@ -28,8 +28,12 @@ export class GameManager {
return false;
}
createGame(id: GameID, gameConfig: GameConfig | undefined) {
const game = new GameServer(id, this.log, Date.now(), this.config, {
createGame(
id: GameID,
gameConfig: GameConfig | undefined,
startTime: number,
) {
const game = new GameServer(id, this.log, startTime, this.config, {
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
+9 -34
View File
@@ -44,7 +44,7 @@ export class GameServer {
// Used for record record keeping
private allClients: Map<ClientID, Client> = new Map();
private _hasStarted = false;
private _startTime: number | null = null;
private readonly createdAt = Date.now();
private endTurnIntervalID;
@@ -64,7 +64,7 @@ export class GameServer {
constructor(
public readonly id: string,
readonly log_: Logger,
public readonly createdAt: number,
private _startTime: number,
private config: ServerConfig,
public gameConfig: GameConfig,
) {
@@ -278,12 +278,7 @@ export class GameServer {
}
public startTime(): number {
if (this._startTime !== null && this._startTime > 0) {
return this._startTime;
} else {
//game hasn't started yet, only works for public games
return this.createdAt + this.config.gameCreationRate();
}
return this._startTime;
}
public prestart() {
@@ -478,36 +473,16 @@ export class GameServer {
const noRecentPings = now > this.lastPingUpdate + 20 * 1000;
const noActive = this.activeClients.length === 0;
if (this.gameConfig.gameType !== GameType.Public) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
this.log.info("private game complete", {
gameID: this.id,
});
return GamePhase.Finished;
} else {
return GamePhase.Active;
}
} else {
return GamePhase.Lobby;
}
}
const msSinceCreation = now - this.createdAt;
const lessThanLifetime = msSinceCreation < this.config.gameCreationRate();
const notEnoughPlayers =
this.gameConfig.gameType === GameType.Public &&
this.gameConfig.maxPlayers &&
this.activeClients.length < this.gameConfig.maxPlayers;
if (lessThanLifetime && notEnoughPlayers) {
if (!this._hasStarted) {
return GamePhase.Lobby;
}
const warmupOver =
now > this.createdAt + this.config.gameCreationRate() + 30 * 1000;
if (noActive && warmupOver && noRecentPings) {
if (noActive && noRecentPings) {
this.log.info("game complete", {
type: this.gameConfig.gameType,
gameID: this.id,
});
return GamePhase.Finished;
}
return GamePhase.Active;
}
+20 -10
View File
@@ -7,6 +7,7 @@ import { fileURLToPath } from "url";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameInfo, PublicLobbies } from "../core/Schemas";
import { generateID } from "../core/Util";
import { CreateGameInput } from "../core/WorkerSchemas";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
@@ -61,8 +62,6 @@ app.use(
let publicLobbiesJsonStr = "";
const publicLobbyIDs: Set<string> = new Set();
// Start the master process
export async function startMaster() {
if (!cluster.isPrimary) {
@@ -103,7 +102,7 @@ export async function startMaster() {
setInterval(
() =>
fetchLobbies().then((lobbies) => {
if (lobbies === 0) {
if (lobbies < 3) {
scheduleLobbies();
}
}),
@@ -193,10 +192,12 @@ app.post(
}),
);
const publicLobbies: Map<string, number> = new Map();
async function fetchLobbies(): Promise<number> {
const fetchPromises: Promise<GameInfo | null>[] = [];
for (const gameID of new Set(publicLobbyIDs)) {
for (const [gameID] of publicLobbies) {
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // 5 second timeout
const port = config.workerPort(gameID);
@@ -211,7 +212,7 @@ async function fetchLobbies(): Promise<number> {
.catch((error) => {
log.error(`Error fetching game ${gameID}:`, error);
// Return null or a placeholder if fetch fails
publicLobbyIDs.delete(gameID);
publicLobbies.delete(gameID);
return null;
});
@@ -239,7 +240,7 @@ async function fetchLobbies(): Promise<number> {
l.msUntilStart !== undefined &&
l.msUntilStart <= 250
) {
publicLobbyIDs.delete(l.gameID);
publicLobbies.delete(l.gameID);
return;
}
@@ -252,7 +253,7 @@ async function fetchLobbies(): Promise<number> {
l.numClients !== undefined &&
l.gameConfig.maxPlayers <= l.numClients
) {
publicLobbyIDs.delete(l.gameID);
publicLobbies.delete(l.gameID);
return;
}
});
@@ -262,16 +263,22 @@ async function fetchLobbies(): Promise<number> {
lobbies: lobbyInfos,
} satisfies PublicLobbies);
return publicLobbyIDs.size;
return publicLobbies.size;
}
// Function to schedule a new public game
async function schedulePublicGame(playlist: MapPlaylist) {
const gameID = generateID();
publicLobbyIDs.add(gameID);
const workerPath = config.workerPath(gameID);
let lastGameCreatedTime = Date.now() - config.gameCreationRate();
for (const value of publicLobbies.values()) {
lastGameCreatedTime = Math.max(value, lastGameCreatedTime);
}
const createdAt = lastGameCreatedTime + config.gameCreationRate();
publicLobbies.set(gameID, createdAt);
// Send request to the worker to start the game
try {
const response = await fetch(
@@ -282,7 +289,10 @@ async function schedulePublicGame(playlist: MapPlaylist) {
"Content-Type": "application/json",
[config.adminHeader()]: config.adminToken(),
},
body: JSON.stringify(playlist.gameConfig()),
body: JSON.stringify({
config: playlist.gameConfig(),
createdAt,
} satisfies CreateGameInput),
},
);
+4 -2
View File
@@ -100,7 +100,7 @@ export function startWorker() {
return res.status(400).json({ error });
}
const gc = result.data;
const gc = result.data.config;
if (
gc?.gameType === GameType.Public &&
req.headers[config.adminHeader()] !== config.adminToken()
@@ -120,7 +120,9 @@ export function startWorker() {
return res.status(400).json({ error: "Worker, game id mismatch" });
}
const game = gm.createGame(id, gc);
const startTime =
result.data.startTime ?? Date.now() + config.gameCreationRate();
const game = gm.createGame(id, gc, startTime);
log.info(
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,