From f8f8ba4788892835569b8ef45f5e73d1c69379b7 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Tue, 21 Jul 2026 10:02:11 +0100 Subject: [PATCH] feat(server): per-intent-type rate limits for social/diplomatic intents The existing ClientMsgRateLimiter enforces a single global budget (10/sec, 150/min) across all intent types, so a scripted quick-chat loop or emoji/embargo spam stays under the ceiling by consuming the shared budget in short bursts. Add tighter per-intent-type token buckets for social/diplomatic actions that no human issues in rapid succession (quick_chat, emoji, allianceRequest, embargo, embargo_all), layered under the global limit. The intent sub-type is now passed through from GameServer. Combat and economy intents (attack, boat, move_warship, build_unit, ...) keep only the global limit, so legitimate high-tempo play is unaffected. Exceeding a per-type cap drops the single message ("limit"), never kicks, so a rare lag burst costs at most one dropped social action. Co-Authored-By: Claude Opus 4.8 --- src/server/ClientMsgRateLimiter.ts | 79 ++++++++++++++++++++++- src/server/GameServer.ts | 1 + tests/server/ClientMsgRateLimiter.test.ts | 48 ++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/server/ClientMsgRateLimiter.ts b/src/server/ClientMsgRateLimiter.ts index 96b408092f..991f46be4f 100644 --- a/src/server/ClientMsgRateLimiter.ts +++ b/src/server/ClientMsgRateLimiter.ts @@ -7,16 +7,52 @@ const MAX_INTENT_SIZE = 2000; const TOTAL_BYTES = 5 * 1024 * 1024; // 5MB per client export type RateLimitResult = "ok" | "limit" | "kick"; +// Per-intent-type caps for social/diplomatic actions that a human never +// legitimately issues in rapid succession. They sit *under* the global intent +// limit above: a spammy category (e.g. a scripted quick-chat loop) is throttled +// on its own without shrinking the budget available for normal high-tempo play +// like attacks, boats, or warship micro. +// +// Exceeding a per-type cap drops the single message ("limit"), it never kicks, +// so a rare burst from lag costs at most one dropped social action rather than a +// disconnect. +const PER_INTENT_LIMITS: Record< + string, + { perSecond: number; perMinute: number } +> = { + quick_chat: { perSecond: 4, perMinute: 40 }, + emoji: { perSecond: 4, perMinute: 40 }, + allianceRequest: { perSecond: 3, perMinute: 30 }, + embargo: { perSecond: 5, perMinute: 40 }, + embargo_all: { perSecond: 2, perMinute: 20 }, +}; + +interface TypeBucket { + perSecond: RateLimiter; + perMinute: RateLimiter; +} + interface ClientBucket { perSecond: RateLimiter; perMinute: RateLimiter; + perIntentType: Map; totalBytes: number; } export class ClientMsgRateLimiter { private buckets = new Map(); - check(clientID: ClientID, type: string, bytes: number): RateLimitResult { + /** + * @param intentType When `type === "intent"`, the intent's own sub-type + * (e.g. "quick_chat"). Used to apply per-intent-type caps on top of the + * global intent limit. Optional so non-intent callers are unaffected. + */ + check( + clientID: ClientID, + type: string, + bytes: number, + intentType?: string, + ): RateLimitResult { const bucket = this.getOrCreate(clientID); bucket.totalBytes += bytes; @@ -37,6 +73,23 @@ export class ClientMsgRateLimiter { ) { return "limit"; } + // Tighter per-type cap for spammy social/diplomatic intents. + if (intentType !== undefined) { + const limits = PER_INTENT_LIMITS[intentType]; + if (limits !== undefined) { + const typeBucket = this.getOrCreateTypeBucket( + bucket, + intentType, + limits, + ); + if ( + !typeBucket.perSecond.tryRemoveTokens(1) || + !typeBucket.perMinute.tryRemoveTokens(1) + ) { + return "limit"; + } + } + } } return "ok"; @@ -56,9 +109,33 @@ export class ClientMsgRateLimiter { tokensPerInterval: INTENTS_PER_MINUTE, interval: "minute", }), + perIntentType: new Map(), totalBytes: 0, }; this.buckets.set(clientID, bucket); return bucket; } + + private getOrCreateTypeBucket( + bucket: ClientBucket, + intentType: string, + limits: { perSecond: number; perMinute: number }, + ): TypeBucket { + const existing = bucket.perIntentType.get(intentType); + if (existing) { + return existing; + } + const typeBucket: TypeBucket = { + perSecond: new RateLimiter({ + tokensPerInterval: limits.perSecond, + interval: "second", + }), + perMinute: new RateLimiter({ + tokensPerInterval: limits.perMinute, + interval: "minute", + }), + }; + bucket.perIntentType.set(intentType, typeBucket); + return typeBucket; + } } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index dca022bde4..35e482ea0d 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -699,6 +699,7 @@ export class GameServer { client.clientID, clientMsg.type, bytes, + clientMsg.type === "intent" ? clientMsg.intent.type : undefined, ); if (rateResult === "kick") { this.log.warn(`Client rate limit exceeded, kicking`, { diff --git a/tests/server/ClientMsgRateLimiter.test.ts b/tests/server/ClientMsgRateLimiter.test.ts index a5f5483d66..4311236bf5 100644 --- a/tests/server/ClientMsgRateLimiter.test.ts +++ b/tests/server/ClientMsgRateLimiter.test.ts @@ -40,6 +40,54 @@ describe("ClientMsgRateLimiter", () => { }); }); + describe("per-intent-type limits", () => { + it("limits a spammy social intent below the global limit", () => { + const limiter = new ClientMsgRateLimiter(); + // quick_chat is capped at 4/sec, well under the global 10/sec. + for (let i = 0; i < 4; i++) { + expect(limiter.check(CLIENT_A, "intent", SMALL, "quick_chat")).toBe( + "ok", + ); + } + expect(limiter.check(CLIENT_A, "intent", SMALL, "quick_chat")).toBe( + "limit", + ); + }); + + it("a throttled type does not block other intent types", () => { + const limiter = new ClientMsgRateLimiter(); + // Exhaust the quick_chat per-type bucket. + for (let i = 0; i < 4; i++) { + limiter.check(CLIENT_A, "intent", SMALL, "quick_chat"); + } + expect(limiter.check(CLIENT_A, "intent", SMALL, "quick_chat")).toBe( + "limit", + ); + // A different intent type still has global budget left. + expect(limiter.check(CLIENT_A, "intent", SMALL, "attack")).toBe("ok"); + }); + + it("does not apply a per-type cap to unlisted intent types", () => { + const limiter = new ClientMsgRateLimiter(); + // "attack" has no per-type cap, so only the global 10/sec applies. + for (let i = 0; i < 10; i++) { + expect(limiter.check(CLIENT_A, "intent", SMALL, "attack")).toBe("ok"); + } + expect(limiter.check(CLIENT_A, "intent", SMALL, "attack")).toBe("limit"); + }); + + it("per-type buckets are isolated per client", () => { + const limiter = new ClientMsgRateLimiter(); + for (let i = 0; i < 4; i++) { + limiter.check(CLIENT_A, "intent", SMALL, "quick_chat"); + } + expect(limiter.check(CLIENT_A, "intent", SMALL, "quick_chat")).toBe( + "limit", + ); + expect(limiter.check(CLIENT_B, "intent", SMALL, "quick_chat")).toBe("ok"); + }); + }); + describe("non-intent messages", () => { it("does not rate-limit non-intent messages", () => { const limiter = new ClientMsgRateLimiter();