Skip to content
Open
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
3 changes: 2 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,8 @@
"admin": "Kicked by an admin",
"duplicate_session": "Kicked from game (you may have been playing on another tab)",
"host_left": "The host has left the lobby.",
"lobby_creator": "Kicked by lobby creator"
"lobby_creator": "Kicked by lobby creator",
"too_much_data": "Kicked for sending too much data"
},
"lang": {
"en": "English",
Expand Down
37 changes: 37 additions & 0 deletions src/client/PacedSender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Sends queued work at a fixed minimum spacing.
*
* The server drops intents past its per-second budget, so a burst of batches
* has to be spread out rather than emitted all at once.
*/
export class PacedSender {
private readonly queue: (() => boolean)[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;

constructor(private readonly intervalMs: number) {}

push(send: () => boolean): void {
this.queue.push(send);
if (this.timer === null) this.next();
}

clear(): void {
this.queue.length = 0;
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}

private next(): void {
const send = this.queue[0];
if (send === undefined) {
this.timer = null;
return;
}
// Keep the item queued if it could not be sent, so a reconnect does not
// lose it. leaveGame() clears the queue when the game is over.
if (send()) this.queue.shift();
this.timer = setTimeout(() => this.next(), this.intervalMs);
}
}
52 changes: 39 additions & 13 deletions src/client/Transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { TileRef } from "../core/game/GameMap";
import {
AllPlayersStats,
batchMoveWarshipUnitIds,
ClientHashMessage,
ClientIntentMessage,
ClientJoinMessage,
Expand All @@ -32,9 +33,15 @@ import { getPlayToken } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { showInGameAlert } from "./InGameModal";
import { LocalServer } from "./LocalServer";
import { PacedSender } from "./PacedSender";
import { translateText } from "./Utils";
import { PlayerView } from "./view";

// Spacing between batched move_warship intents. The server allows 10 intents
// per second across all types; this stays clear of that with room for the
// player's other actions.
const WARSHIP_BATCH_INTERVAL_MS = 150;

export class PauseGameIntentEvent implements GameEvent {
constructor(public readonly paused: boolean) {}
}
Expand Down Expand Up @@ -202,6 +209,10 @@ export class Transport {
private onmessage: (msg: ServerMessage) => void;

private pingInterval: number | null = null;
private readonly warshipBatches: PacedSender;
// The server discards intents until it has processed our join, and an open
// socket says nothing about that. Its first message does.
private joined = false;
public readonly isLocal: boolean;

constructor(
Expand All @@ -213,6 +224,10 @@ export class Transport {
this.isLocal =
lobbyConfig.gameRecord !== undefined ||
lobbyConfig.gameStartInfo?.config.gameType === GameType.Singleplayer;
// The local server has no rate limiter, so only remote games need spacing.
this.warshipBatches = new PacedSender(
this.isLocal ? 0 : WARSHIP_BATCH_INTERVAL_MS,
);

this.eventBus.on(SendAllianceRequestIntentEvent, (e) =>
this.onSendAllianceRequest(e),
Expand Down Expand Up @@ -351,6 +366,7 @@ export class Transport {
// the desktop app://openfront origin), not window.location.host.
const workerPath = ClientEnv.workerPath(this.lobbyConfig.gameID);
this.socket = new WebSocket(`${ClientEnv.serverWsBase()}/${workerPath}`);
this.joined = false;
this.onconnect = onconnect;
this.onmessage = onmessage;
this.socket.onopen = () => {
Expand All @@ -371,6 +387,7 @@ export class Transport {
onconnect();
};
this.socket.onmessage = (event: MessageEvent) => {
this.joined = true;
try {
const parsed = JSON.parse(event.data);
const result = ServerMessageSchema.safeParse(parsed);
Expand Down Expand Up @@ -441,6 +458,7 @@ export class Transport {
}

leaveGame() {
this.warshipBatches.clear();
if (this.isLocal) {
this.localServer.endGame();
return;
Expand Down Expand Up @@ -649,11 +667,15 @@ export class Transport {
}

private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
this.sendIntent({
type: "move_warship",
unitIds: event.unitIds,
tile: event.tile,
});
for (const unitIds of batchMoveWarshipUnitIds(event.unitIds, event.tile)) {
Comment thread
bighurdan-cell marked this conversation as resolved.
this.warshipBatches.push(() =>
this.sendIntent({
Comment thread
bighurdan-cell marked this conversation as resolved.
type: "move_warship",
unitIds,
tile: event.tile,
}),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private onSendDeleteUnitIntent(event: SendDeleteUnitIntentEvent) {
Expand Down Expand Up @@ -681,20 +703,24 @@ export class Transport {
this.sendIntent({ type: "toggle_game_start_timer" });
}

private sendIntent(intent: Intent) {
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
private sendIntent(intent: Intent): boolean {
if (
this.isLocal ||
(this.joined && this.socket?.readyState === WebSocket.OPEN)
) {
const msg = {
type: "intent",
intent: intent,
} satisfies ClientIntentMessage;
this.sendMsg(msg);
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket?.readyState,
);
console.log("attempting reconnect");
return true;
}
console.log(
"WebSocket is not open. Current state:",
this.socket?.readyState,
);
console.log("attempting reconnect");
return false;
}

private sendMsg(msg: ClientMessage) {
Expand Down
27 changes: 26 additions & 1 deletion src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,10 +534,35 @@ export const CancelBoatIntentSchema = z.object({

export const MoveWarshipIntentSchema = z.object({
type: z.literal("move_warship"),
unitIds: z.array(z.number().int()).nonempty(),
unitIds: z.array(z.number().int()).nonempty().max(1000),
tile: z.number(),
});

// Client messages larger than this get the client kicked by ClientMsgRateLimiter.
export const MAX_INTENT_SIZE = 2000;

export function batchMoveWarshipUnitIds(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this funciton shouldn't be in Schemas.ts

unitIds: readonly number[],
tile: number,
): number[][] {
const overhead = JSON.stringify({
type: "intent",
intent: { type: "move_warship", unitIds: [], tile },
} satisfies ClientIntentMessage).length;
const batches: number[][] = [];
let size = overhead;
for (const unitId of unitIds) {
const unitIdSize = String(unitId).length + 1;
if (batches.length === 0 || size + unitIdSize > MAX_INTENT_SIZE) {
batches.push([]);
size = overhead;
}
batches[batches.length - 1].push(unitId);
size += unitIdSize;
}
return batches;
}

export const DeleteUnitIntentSchema = z.object({
type: z.literal("delete_unit"),
unitId: z.number(),
Expand Down
3 changes: 1 addition & 2 deletions src/server/ClientMsgRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { RateLimiter } from "limiter";
import { ClientID } from "../core/Schemas";
import { ClientID, MAX_INTENT_SIZE } from "../core/Schemas";

const INTENTS_PER_SECOND = 10;
const INTENTS_PER_MINUTE = 150;
const MAX_INTENT_SIZE = 2000;
const TOTAL_BYTES = 5 * 1024 * 1024; // 5MB per client
export type RateLimitResult = "ok" | "limit" | "kick";

Expand Down
56 changes: 56 additions & 0 deletions tests/MoveWarshipIntentBatching.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
batchMoveWarshipUnitIds,
ClientIntentMessage,
ClientMessageSchema,
MAX_INTENT_SIZE,
} from "../src/core/Schemas";
import { replacer } from "../src/core/Util";

const TILE = 250_000;

function frame(unitIds: number[], tile: number): string {
return JSON.stringify(
{
type: "intent",
intent: { type: "move_warship", unitIds, tile },
} satisfies ClientIntentMessage,
replacer,
);
}

describe("batchMoveWarshipUnitIds", () => {
test("sends a small fleet as a single intent", () => {
const unitIds = [10000, 10003, 10006];
expect(batchMoveWarshipUnitIds(unitIds, TILE)).toEqual([unitIds]);
});

test("returns no batches for an empty selection", () => {
expect(batchMoveWarshipUnitIds([], TILE)).toEqual([]);
});

test.each([1, 400, 4000])(
"keeps every batch under the server cap (%i warships)",
(count) => {
const unitIds = Array.from({ length: count }, (_, i) => 900_000 + i * 3);
const batches = batchMoveWarshipUnitIds(unitIds, TILE);

for (const batch of batches) {
expect(batch.length).toBeGreaterThan(0);
expect(
Buffer.byteLength(frame(batch, TILE), "utf8"),
).toBeLessThanOrEqual(MAX_INTENT_SIZE);
}
expect(batches.flat()).toEqual(unitIds);
},
);

test("every batch is a valid client intent message", () => {
const unitIds = Array.from({ length: 450 }, (_, i) => 10_000 + i * 3);

for (const batch of batchMoveWarshipUnitIds(unitIds, TILE)) {
expect(
ClientMessageSchema.safeParse(JSON.parse(frame(batch, TILE))).success,
).toBe(true);
}
});
});
Loading
Loading