diff --git a/index.html b/index.html index 7c22c1357..f6a07f4ed 100644 --- a/index.html +++ b/index.html @@ -269,7 +269,7 @@
`} ${this.renderSmallActionCard( translateText("main.join"), this.openJoinLobby, @@ -204,11 +207,13 @@ export class GameModeSelector extends LitElement { this.openHostLobby, "bg-slate-600 hover:bg-slate-500 active:bg-slate-700", )} - ${this.renderSmallActionCard( - translateText("mode_selector.ranked_title"), - this.openRankedMenu, - "bg-slate-600 hover:bg-slate-500 active:bg-slate-700", - )} + ${!crazyGamesSDK.isOnCrazyGames() + ? this.renderSmallActionCard( + translateText("mode_selector.ranked_title"), + this.openRankedMenu, + "bg-slate-600 hover:bg-slate-500 active:bg-slate-700", + ) + : html``} ${this.renderSmallActionCard( translateText("main.join"), this.openJoinLobby, diff --git a/src/client/components/BaseModal.ts b/src/client/components/BaseModal.ts index 8efc8e258..b73eb9470 100644 --- a/src/client/components/BaseModal.ts +++ b/src/client/components/BaseModal.ts @@ -115,6 +115,7 @@ export abstract class BaseModal extends LitElement { * Subclasses can override onOpen() for custom behavior. */ public open(): void { + if (this.isModalOpen) return; this.registerEscapeHandler(); this.onOpen(); diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index d2d4c4bae..719272804 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -208,7 +208,7 @@ export class ControlPanel extends LitElement implements Layer { const { greenPercent, orangePercent } = this.calculateTroopBar(); return html`
${greenPercent > 0 @@ -225,7 +225,7 @@ export class ControlPanel extends LitElement implements Layer { : ""}
@@ -261,10 +261,10 @@ export class ControlPanel extends LitElement implements Layer { private renderDesktop() { return html` -
+
${this.renderDesktopTroopBar()}
@@ -300,9 +300,9 @@ export class ControlPanel extends LitElement implements Layer {
-
+
this.handleRatioSliderInput(e)} @pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)} - class="flex-1 h-2 accent-blue-500 cursor-pointer" + class="flex-1 h-1.5 accent-blue-500 cursor-pointer" />
`; @@ -384,7 +384,7 @@ export class ControlPanel extends LitElement implements Layer { return html`
e.preventDefault()} > diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index e23d4d609..9f49f6f26 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -288,7 +288,10 @@ export class NameLayer implements Layer { } renderPlayerInfo(render: RenderInfo) { - if (!render.player.nameLocation() || !render.player.isAlive()) { + if (!render.player.nameLocation()) { + return; + } + if (!render.player.isAlive()) { this.renders = this.renders.filter((r) => r !== render); render.element.remove(); return; diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index d1cab6db8..d936f921e 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -318,12 +318,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer { const playerTeam = getTranslatedPlayerTeamLabel(player.team()); return html` -
+
@@ -402,7 +402,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { >`} ${this.renderPlayerNameIcons(player)} ${allianceHtml ?? ""}
-
+
${this.displayUnitCount(player, UnitType.City, cityIcon)} ${this.displayUnitCount(player, UnitType.Factory, factoryIcon)} ${this.displayUnitCount(player, UnitType.Port, portIcon)} @@ -440,7 +440,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { return html`
${greenPercent > 0 @@ -518,13 +518,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer { return html`
this.hide()} @contextmenu=${(e: MouseEvent) => e.preventDefault()} >
${this.player !== null ? this.renderPlayerInfo(this.player) : ""} ${this.unit !== null ? this.renderUnitInfo(this.unit) : ""} diff --git a/src/core/configuration/Colors.ts b/src/core/configuration/Colors.ts index 37caba0f3..81b45e622 100644 --- a/src/core/configuration/Colors.ts +++ b/src/core/configuration/Colors.ts @@ -31,8 +31,8 @@ function generateTeamColors(baseColor: Colord): Colord[] { return Array.from({ length: colorCount }, (_, index) => { if (index === 0) return baseColor; - // Spread hues evenly across ±12° band using golden angle within that range - const hueShift = ((index * goldenAngle) % 24) - 12; + // Spread hues evenly across ±6° band using golden angle within that range + const hueShift = ((index * goldenAngle) % 12) - 6; const h = (lch.h + hueShift + 360) % 360; // Chroma oscillates ±10% around the base to add variety without washing out diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index d43030bea..871837f15 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -271,14 +271,14 @@ export class DefaultConfig implements Config { trainSpawnRate(numPlayerFactories: number): number { // hyperbolic decay, midpoint at 10 factories // expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories) - return (numPlayerFactories + 10) * 18; + return (numPlayerFactories + 10) * 15; } trainGold( rel: "self" | "team" | "ally" | "other", citiesVisited: number, ): Gold { - // No penalty for the first 5 cities. - citiesVisited = Math.max(0, citiesVisited - 5); + // No penalty for the first 10 cities. + citiesVisited = Math.max(0, citiesVisited - 9); let baseGold: number; switch (rel) { case "ally": @@ -311,7 +311,7 @@ export class DefaultConfig implements Config { // Sigmoid: concave start, sharp S-curve middle, linear end - heavily punishes trades under range debuff. const debuff = this.tradeShipShortRangeDebuff(); const baseGold = - 50_000 / (1 + Math.exp(-0.03 * (dist - debuff))) + 50 * dist; + 75_000 / (1 + Math.exp(-0.03 * (dist - debuff))) + 50 * dist; const multiplier = this.goldMultiplier(); return BigInt(Math.floor(baseGold * multiplier)); } diff --git a/src/server/ClientMsgRateLimiter.ts b/src/server/ClientMsgRateLimiter.ts new file mode 100644 index 000000000..986149da9 --- /dev/null +++ b/src/server/ClientMsgRateLimiter.ts @@ -0,0 +1,72 @@ +import { RateLimiter } from "limiter"; +import { ClientID } from "../core/Schemas"; + +const INTENTS_PER_SECOND = 10; +const INTENTS_PER_MINUTE = 150; +const MAX_BYTES_PER_MINUTE = 25 * 1024; // 25KB/min per client +const MAX_INTENT_BYTES = 500; // intents are stored in turns, keep them small +export type RateLimitResult = "ok" | "limit" | "kick"; + +// Allow 3 winner messages per client since a player can rejoin and resend. +const MAX_WINNER_MSGS = 3; + +interface ClientBucket { + perSecond: RateLimiter; + perMinute: RateLimiter; + bytesPerMinute: RateLimiter; + winnerMsgCount: number; +} + +export class ClientMsgRateLimiter { + private buckets = new Map(); + + check(clientID: ClientID, type: string, bytes: number): RateLimitResult { + const bucket = this.getOrCreate(clientID); + + // Winner message contains stats for all players and can be large (100s of KB). + // It bypasses the byte rate limit but is strictly limited to one per client. + if (type === "winner") { + if (bucket.winnerMsgCount >= MAX_WINNER_MSGS) return "kick"; + bucket.winnerMsgCount++; + return "ok"; + } + + // Intents are stored in turn history for the duration of the game, so + // oversized intents would accumulate and fill up server RAM. + if (type === "intent" && bytes > MAX_INTENT_BYTES) return "kick"; + + if (!bucket.bytesPerMinute.tryRemoveTokens(bytes)) return "kick"; + + if ( + !bucket.perSecond.tryRemoveTokens(1) || + !bucket.perMinute.tryRemoveTokens(1) + ) + return "limit"; + + return "ok"; + } + + private getOrCreate(clientID: ClientID): ClientBucket { + const existing = this.buckets.get(clientID); + if (existing) { + return existing; + } + const bucket = { + perSecond: new RateLimiter({ + tokensPerInterval: INTENTS_PER_SECOND, + interval: "second", + }), + perMinute: new RateLimiter({ + tokensPerInterval: INTENTS_PER_MINUTE, + interval: "minute", + }), + bytesPerMinute: new RateLimiter({ + tokensPerInterval: MAX_BYTES_PER_MINUTE, + interval: "minute", + }), + winnerMsgCount: 0, + }; + this.buckets.set(clientID, bucket); + return bucket; + } +} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 3902f90a2..49305432e 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -26,6 +26,7 @@ import { import { createPartialGameRecord, getClanTag } from "../core/Util"; import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; +import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter"; export enum GamePhase { Lobby = "LOBBY", Active = "ACTIVE", @@ -34,10 +35,14 @@ export enum GamePhase { const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session"; const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator"; +const KICK_REASON_TOO_MUCH_DATA = "kick_reason.too_much_data"; +const KICK_REASON_INVALID_MESSAGE = "kick_reason.invalid_message"; export class GameServer { private sentDesyncMessageClients = new Set(); + private intentRateLimiter = new ClientMsgRateLimiter(); + private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds @@ -51,6 +56,7 @@ export class GameServer { private clientsDisconnectedStatus: Map = new Map(); private _hasStarted = false; private _startTime: number | null = null; + private hasReachedMaxPlayerCount: boolean = false; private endTurnIntervalID: ReturnType | undefined; @@ -247,6 +253,10 @@ export class GameServer { this.addListeners(client); this.startLobbyInfoBroadcast(); + if (this.activeClients.length >= (this.gameConfig.maxPlayers ?? Infinity)) { + this.hasReachedMaxPlayerCount = true; + } + // In case a client joined the game late and missed the start message. if (this._hasStarted) { this.sendStartGameMsg(client.ws, 0); @@ -306,22 +316,48 @@ export class GameServer { client.ws.removeAllListeners("message"); client.ws.on("message", async (message: string) => { try { - const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); - if (!parsed.success) { - const error = z.prettifyError(parsed.error); - this.log.warn(`Failed to parse client message ${error}`, { + let json: unknown; + try { + json = JSON.parse(message); + } catch (e) { + this.log.warn(`Failed to parse client message JSON, kicking`, { clientID: client.clientID, + error: String(e), }); - client.ws.send( - JSON.stringify({ - type: "error", - error, - message: `Server could not parse message from client: ${message}`, - } satisfies ServerErrorMessage), - ); + this.kickClient(client.clientID, KICK_REASON_INVALID_MESSAGE); + return; + } + const parsed = ClientMessageSchema.safeParse(json); + if (!parsed.success) { + this.log.warn(`Failed to parse client message, kicking`, { + clientID: client.clientID, + error: z.prettifyError(parsed.error), + }); + this.kickClient(client.clientID, KICK_REASON_INVALID_MESSAGE); return; } const clientMsg = parsed.data; + const bytes = Buffer.byteLength(message, "utf8"); + const rateResult = this.intentRateLimiter.check( + client.clientID, + clientMsg.type, + bytes, + ); + if (rateResult === "kick") { + this.log.warn(`Client rate limit exceeded, kicking`, { + clientID: client.clientID, + type: clientMsg.type, + }); + this.kickClient(client.clientID, KICK_REASON_TOO_MUCH_DATA); + return; + } + if (rateResult === "limit") { + this.log.warn(`Client message rate limit exceeded, dropping`, { + clientID: client.clientID, + type: clientMsg.type, + }); + return; + } switch (clientMsg.type) { case "rejoin": { // Client is already connected, no auth required, send start game message if game has started @@ -813,11 +849,11 @@ export class GameServer { // Public Games const lessThanLifetime = this.startsAt ? Date.now() < this.startsAt : true; - const notEnoughPlayers = - this.gameConfig.gameType === GameType.Public && - this.gameConfig.maxPlayers && - this.activeClients.length < this.gameConfig.maxPlayers; - if (lessThanLifetime && notEnoughPlayers) { + if ( + lessThanLifetime && + !this.hasStarted() && + !this.hasReachedMaxPlayerCount + ) { return GamePhase.Lobby; } const warmupOver = now > this.startsAt! + 30 * 1000; diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index 17a41a285..9285b8a91 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -75,7 +75,7 @@ export class MasterLobbyService { if (this.readyWorkers.size === this.config.numWorkers() && !this.started) { this.started = true; this.log.info("All workers ready, starting game scheduling"); - startPolling(async () => this.broadcastLobbies(), 250); + startPolling(async () => this.broadcastLobbies(), 500); startPolling(async () => await this.maybeScheduleLobby(), 1000); } } @@ -117,10 +117,14 @@ export class MasterLobbyService { games: this.getAllLobbies(), }, } satisfies MasterLobbiesBroadcast; - for (const worker of this.workers.values()) { + for (const [workerId, worker] of this.workers.entries()) { worker.send(msg, (e) => { if (e) { - this.log.error("Failed to send lobbies broadcast to worker:", e); + this.log.error( + `Failed to send lobbies broadcast to worker ${workerId}, killing worker:`, + e, + ); + worker.kill(); } }); } @@ -131,12 +135,13 @@ export class MasterLobbyService { for (const type of Object.keys(lobbiesByType) as PublicGameType[]) { const lobbies = lobbiesByType[type]; - if (lobbies.length >= 2) { - continue; - } + + // Always ensure the next lobby has a timer, even if we already have 2+ + // lobbies. This prevents a race where two lobbies are created before + // either receives a startsAt (IPC round-trip delay), leaving both stuck + // without a countdown. const nextLobby = lobbies[0]; if (nextLobby && nextLobby.startsAt === undefined) { - // The previous game has started, so we need to set the timer on the next game. this.sendMessageToWorker({ type: "updateLobby", gameID: nextLobby.gameID, @@ -144,6 +149,10 @@ export class MasterLobbyService { }); } + if (lobbies.length >= 2) { + continue; + } + this.sendMessageToWorker({ type: "createGame", gameID: generateID(), @@ -162,7 +171,11 @@ export class MasterLobbyService { } worker.send(msg, (e) => { if (e) { - this.log.error("Failed to send message to worker:", e); + this.log.error( + `Failed to send message to worker ${workerId}, killing worker:`, + e, + ); + worker.kill(); } }); } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 2a2a0e46b..8e19c3474 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -48,7 +48,10 @@ export async function startWorker() { const app = express(); app.use(express.json({ limit: "5mb" })); const server = http.createServer(app); - const wss = new WebSocketServer({ noServer: true }); + const wss = new WebSocketServer({ + noServer: true, + maxPayload: 2 * 1024 * 1024, + }); const gm = new GameManager(config, log); diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 2bbd50e08..ab8852968 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -19,7 +19,10 @@ export class WorkerLobbyService { private readonly gm: GameManager, private readonly log: typeof logger, ) { - this.lobbiesWss = new WebSocketServer({ noServer: true }); + this.lobbiesWss = new WebSocketServer({ + noServer: true, + maxPayload: 256 * 1024, + }); this.setupUpgradeHandler(); this.setupLobbiesWebSocket(); this.setupIPCListener(); @@ -109,6 +112,9 @@ export class WorkerLobbyService { private setupLobbiesWebSocket() { this.lobbiesWss.on("connection", (ws: WebSocket) => { this.lobbyClients.add(ws); + ws.on("message", () => { + ws.terminate(); + }); ws.on("close", () => { this.lobbyClients.delete(ws); }); diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts index e04167594..03b84d8ca 100644 --- a/tests/core/game/TrainStation.test.ts +++ b/tests/core/game/TrainStation.test.ts @@ -192,35 +192,35 @@ describe("DefaultConfig.trainGold trade stop penalty", () => { ); }); - it("returns full base gold within free window (stops 0-5)", () => { - // first 6 stops (0-5) are free — no penalty + it("returns full base gold within free window (stops 0-9)", () => { + // first 10 stops (0-9) are free — no penalty expect(config.trainGold("self", 0)).toBe(10_000n); - expect(config.trainGold("self", 5)).toBe(10_000n); + expect(config.trainGold("self", 9)).toBe(10_000n); }); it("reduces gold by 5k per stop after the free window", () => { - // stop 6: effective = 6-5 = 1 -> 10k - 5k = 5k - expect(config.trainGold("self", 6)).toBe(5_000n); + // stop 10: effective = 10-9 = 1 -> 10k - 5k = 5k + expect(config.trainGold("self", 10)).toBe(5_000n); }); it("floors at 5k when penalty exceeds base gold", () => { - // stop 8: effective = 3 -> 10k - 15k -> floor at 5k - expect(config.trainGold("self", 8)).toBe(5_000n); + // stop 12: effective = 3 -> 10k - 15k -> floor at 5k + expect(config.trainGold("self", 12)).toBe(5_000n); }); it("floors at 5k for ally base even with heavy penalty", () => { - // ally base 35k, stop 20: effective = 15 -> penalty 75k -> floor at 5k + // ally base 35k, stop 20: effective = 11 -> penalty 55k -> floor at 5k expect(config.trainGold("ally", 20)).toBe(5_000n); }); it("ally base gold reduces correctly after free window", () => { - // ally base 35k, stop 7: effective = 2 -> 35k - 10k = 25k - expect(config.trainGold("ally", 7)).toBe(25_000n); + // ally base 35k, stop 11: effective = 2 -> 35k - 10k = 25k + expect(config.trainGold("ally", 11)).toBe(25_000n); }); it("other/team base gold reduces correctly after free window", () => { - // other base 25k, stop 6: effective = 1 -> 25k - 5k = 20k - expect(config.trainGold("other", 6)).toBe(20_000n); - expect(config.trainGold("team", 6)).toBe(20_000n); + // other base 25k, stop 10: effective = 1 -> 25k - 5k = 20k + expect(config.trainGold("other", 10)).toBe(20_000n); + expect(config.trainGold("team", 10)).toBe(20_000n); }); }); diff --git a/tests/server/ClientMsgRateLimiter.test.ts b/tests/server/ClientMsgRateLimiter.test.ts new file mode 100644 index 000000000..263464485 --- /dev/null +++ b/tests/server/ClientMsgRateLimiter.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { ClientMsgRateLimiter } from "../../src/server/ClientMsgRateLimiter"; + +const CLIENT_A = "clientA" as any; +const CLIENT_B = "clientB" as any; + +const SMALL = 100; +const LARGE = 501; // over MAX_INTENT_BYTES + +describe("ClientMsgRateLimiter", () => { + describe("intent messages", () => { + it("allows intents within limits", () => { + const limiter = new ClientMsgRateLimiter(); + expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok"); + }); + + it("kicks on oversized intent", () => { + const limiter = new ClientMsgRateLimiter(); + expect(limiter.check(CLIENT_A, "intent", LARGE)).toBe("kick"); + }); + + it("limits when per-second count exceeded", () => { + const limiter = new ClientMsgRateLimiter(); + for (let i = 0; i < 10; i++) { + expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok"); + } + expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("limit"); + }); + + it("rate limits are per client", () => { + const limiter = new ClientMsgRateLimiter(); + for (let i = 0; i < 10; i++) { + limiter.check(CLIENT_A, "intent", SMALL); + } + expect(limiter.check(CLIENT_B, "intent", SMALL)).toBe("ok"); + }); + }); + + describe("winner messages", () => { + it("allows first winner message", () => { + const limiter = new ClientMsgRateLimiter(); + expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok"); + }); + + it("allows up to 3 winner messages", () => { + const limiter = new ClientMsgRateLimiter(); + expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok"); + expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok"); + expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok"); + expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("kick"); + }); + + it("winner does not consume intent rate limit", () => { + const limiter = new ClientMsgRateLimiter(); + limiter.check(CLIENT_A, "winner", 50000); + expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok"); + }); + }); + + describe("other messages", () => { + it("applies rate limiting to other message types", () => { + const limiter = new ClientMsgRateLimiter(); + for (let i = 0; i < 10; i++) { + expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok"); + } + expect(limiter.check(CLIENT_A, "ping", 50)).toBe("limit"); + }); + }); +});