Add SCUM Source RCON transport

This commit is contained in:
npc0-hue
2026-07-23 10:55:31 +08:00
parent ec96c22e4c
commit 742f96ea02
33 changed files with 1297 additions and 19 deletions
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "./sourceRcon";
describe("Source RCON browser request schemas", () => {
it("builds a bounded typed chat request without connection material", () => {
expect(sourceRCONChatRequest("server-1", { chatType: 4, message: "hello", targetSteamId: "76561198000000001" }, 12)).toEqual({
kind: "chat",
chatType: 4,
message: "hello",
targetSteamId: "76561198000000001",
idempotencyKey: "web:source-rcon:chat:server-1:12"
});
});
it("rejects framed text and formats raw commands without chat fields", () => {
expect(() => sourceRCONChatRequest("server-1", { chatType: 2, message: "line one\nline two" }, 13)).toThrow("受限的单行文本");
expect(() => sourceRCONChatRequest("server-1", { chatType: 8, message: "hello" }, 13)).toThrow("聊天类型");
expect(sourceRCONRawCommandRequest("server-1", " SetTime 12 ", 14)).toEqual({
kind: "command",
command: "SetTime 12",
idempotencyKey: "web:source-rcon:command:server-1:14"
});
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { SourceRCONCommandRequest } from "../api/types";
const maxChatBytes = 1024;
const maxCommandBytes = 4000;
const steamID64 = /^[0-9]{17}$/;
export interface SourceRCONChatDraft {
chatType: number;
message: string;
targetSteamId?: string;
}
export function sourceRCONChatRequest(serverInstanceId: string, draft: SourceRCONChatDraft, sequence = Date.now()): SourceRCONCommandRequest {
const message = validateSourceRCONText(draft.message, maxChatBytes, "聊天内容");
if (!Number.isInteger(draft.chatType) || draft.chatType < 0 || draft.chatType > 7) {
throw new Error("聊天类型必须在 0 到 7 之间。");
}
const targetSteamId = draft.targetSteamId?.trim() ?? "";
if (targetSteamId && !steamID64.test(targetSteamId)) {
throw new Error("目标 SteamID64 必须为 17 位数字。");
}
return {
kind: "chat",
chatType: draft.chatType,
message,
targetSteamId: targetSteamId || undefined,
idempotencyKey: sourceRCONIdempotencyKey("chat", serverInstanceId, sequence)
};
}
export function sourceRCONRawCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): SourceRCONCommandRequest {
return {
kind: "command",
command: validateSourceRCONText(command, maxCommandBytes, "原始指令"),
idempotencyKey: sourceRCONIdempotencyKey("command", serverInstanceId, sequence)
};
}
function validateSourceRCONText(value: string, maxBytes: number, label: string): string {
const normalized = value.trim();
if (!normalized || new TextEncoder().encode(normalized).byteLength > maxBytes || /[\u0000\r\n]/.test(normalized)) {
throw new Error(`${label}必须是受限的单行文本。`);
}
return normalized;
}
function sourceRCONIdempotencyKey(kind: "chat" | "command", serverInstanceId: string, sequence: number): string {
return `web:source-rcon:${kind}:${serverInstanceId.trim()}:${Math.max(0, Math.floor(sequence))}`;
}