${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");
+ });
+ });
+});