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
+6
View File
@@ -90,6 +90,8 @@ import type {
ServerLifecycleResponse,
ServerConfigWriteApprovalRequest,
ServerConfigWriteDispatchResponse,
SourceRCONCommandRequest,
SourceRCONCommandResponse,
ServerInstanceListResponse,
ServerDeletionRequest,
ServerInstanceUpdateRequest,
@@ -565,6 +567,10 @@ export class PlatformApiClient {
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
}
async sendSourceRCONCommand(serverInstanceId: string, request: SourceRCONCommandRequest): Promise<SourceRCONCommandResponse> {
return this.request<SourceRCONCommandResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/rcon/commands`, { method: "POST", body: request });
}
async getServerConfig(id: string): Promise<ServerConfigResponse> {
return this.request<ServerConfigResponse>(`/server-instances/${encodeURIComponent(id)}/config`);
}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client";
import type { SourceRCONCommandRequest } from "./types";
describe("PlatformApiClient Source RCON command dispatch", () => {
afterEach(() => vi.unstubAllGlobals());
it("posts the typed server-scoped request and projects only safe queue state", async () => {
const request: SourceRCONCommandRequest = {
kind: "chat",
chatType: 4,
message: "Maintenance complete",
targetSteamId: "76561198000000001",
idempotencyKey: "web:source-rcon:chat:server-1:12"
};
const calls: Array<{ url: string; method: string; body?: unknown; authorization: string | null }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : undefined,
authorization: new Headers(init?.headers).get("Authorization")
});
return new Response(JSON.stringify({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" }), {
status: 202,
headers: { "Content-Type": "application/json" }
});
}));
const response = await new PlatformApiClient("/api/v1", () => "operator-session").sendSourceRCONCommand("server/1", request);
expect(calls).toEqual([{
url: "/api/v1/server-instances/server%2F1/rcon/commands",
method: "POST",
body: request,
authorization: "Bearer operator-session"
}]);
expect(response).toEqual({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" });
for (const forbidden of ["command", "password", "host", "configRef", "response"]) {
expect(Object.keys(response)).not.toContain(forbidden);
}
});
});
+20
View File
@@ -485,6 +485,26 @@ export interface ServerLifecycleResponse {
job: JobResponse;
}
export type SourceRCONCommandKind = "chat" | "command";
export interface SourceRCONCommandRequest {
kind: SourceRCONCommandKind;
chatType?: number;
message?: string;
targetSteamId?: string;
command?: string;
idempotencyKey: string;
}
// The Platform response intentionally excludes the command, local listener
// details, credentials, and Source RCON response body.
export interface SourceRCONCommandResponse {
jobId: string;
serverInstanceId: string;
status: JobState;
message: string;
}
export interface RunCapacityResponse {
maxJobs: number;
runningJobs: number;
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import sourceRCONCommandPanelSource from "./SourceRCONCommandPanel.tsx?raw";
describe("SourceRCONCommandPanel", () => {
it("uses the typed dispatch API without confirmation, transcript, or connection fields", () => {
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
expect(sourceRCONCommandPanelSource).toContain("sourceRCONChatRequest");
expect(sourceRCONCommandPanelSource).toContain("sourceRCONRawCommandRequest");
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
expect(sourceRCONCommandPanelSource).not.toContain("transcript");
expect(sourceRCONCommandPanelSource).not.toContain("history");
expect(sourceRCONCommandPanelSource).not.toContain("password");
expect(sourceRCONCommandPanelSource).not.toContain("host");
});
});
@@ -0,0 +1,107 @@
import { type FormEvent, useState } from "react";
import { platformApiClient } from "../api/client";
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
import { ResultBadge } from "./StateViews";
interface SourceRCONCommandPanelProps {
serverId: string;
pluginId: string;
}
type DispatchState = { status: "pending" | "succeeded" | "failed"; label: string } | null;
export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommandPanelProps) {
const [chatType, setChatType] = useState(4);
const [chatMessage, setChatMessage] = useState("");
const [targetSteamId, setTargetSteamId] = useState("");
const [rawCommand, setRawCommand] = useState("");
const [pending, setPending] = useState<"chat" | "command" | null>(null);
const [dispatch, setDispatch] = useState<DispatchState>(null);
if (pluginId !== "game.scum") {
return null;
}
async function sendChat(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending("chat");
setDispatch({ status: "pending", label: "正在提交聊天消息" });
try {
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONChatRequest(serverId, { chatType, message: chatMessage, targetSteamId }));
setChatMessage("");
setTargetSteamId("");
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
} catch (error) {
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "聊天消息提交失败" });
} finally {
setPending(null);
}
}
async function sendRawCommand(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending("command");
setDispatch({ status: "pending", label: "正在提交原始管理员指令" });
try {
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, rawCommand));
setRawCommand("");
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
} catch (error) {
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "原始管理员指令提交失败" });
} finally {
setPending(null);
}
}
return (
<article className="console-panel" aria-label="SCUM Source RCON controls">
<div className="panel-header">
<div>
<h2>SCUM </h2>
<p className="page-status"></p>
</div>
{dispatch && <ResultBadge status={dispatch.status} label={dispatch.label} />}
</div>
<div className="operations-command-grid">
<section className="console-module" aria-label="SCUM chat command">
<div className="panel-header"><h2></h2></div>
<form className="provider-form" onSubmit={(event) => void sendChat(event)}>
<div className="form-grid">
<label>
<select value={chatType} onChange={(event) => setChatType(Number(event.target.value))} disabled={pending !== null}>
{[0, 1, 2, 3, 4, 5, 6, 7].map((value) => <option key={value} value={value}> {value}</option>)}
</select>
</label>
<label>
SteamID64
<input value={targetSteamId} inputMode="numeric" maxLength={17} onChange={(event) => setTargetSteamId(event.target.value)} disabled={pending !== null} placeholder="留空为广播" />
</label>
</div>
<label>
<textarea value={chatMessage} maxLength={1024} rows={3} onChange={(event) => setChatMessage(event.target.value)} disabled={pending !== null} placeholder="输入单行聊天内容" />
</label>
<div className="action-strip"><button type="submit" className="primary-command" disabled={pending !== null || !chatMessage.trim()}>{pending === "chat" ? "提交中…" : "发送聊天"}</button></div>
</form>
</section>
<section className="console-module" aria-label="SCUM raw administrator command">
<div className="panel-header"><h2></h2></div>
<form className="provider-form" onSubmit={(event) => void sendRawCommand(event)}>
<label>
<textarea value={rawCommand} maxLength={4000} rows={5} onChange={(event) => setRawCommand(event.target.value)} disabled={pending !== null} placeholder="例如 SetTime 12" />
</label>
<p className="page-status"> SCUM</p>
<div className="action-strip"><button type="submit" className="icon-command" disabled={pending !== null || !rawCommand.trim()}>{pending === "command" ? "提交中…" : "发送指令"}</button></div>
</form>
</section>
</div>
</article>
);
}
function sourceRCONDispatchLabel(jobId: string, status: string): string {
return `${status === "queued" ? "排队" : "提交"} · 任务 ${jobId}`;
}
@@ -4,6 +4,7 @@ import { configDiffViewFromPreview } from "./ServerDetailPage";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import clientManagerLifecyclePanelSource from "../components/ClientManagerLifecyclePanel.tsx?raw";
import runtimeDLLExtensionsPanelSource from "../components/RuntimeDLLExtensionsPanel.tsx?raw";
import sourceRCONCommandPanelSource from "../components/SourceRCONCommandPanel.tsx?raw";
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types";
@@ -115,6 +116,18 @@ describe("ServerDetailPage config write approval", () => {
}
});
it("adds direct SCUM chat and raw commands through the one-time typed RCON API", () => {
expect(serverDetailPageSource).toContain("SourceRCONCommandPanel");
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
expect(sourceRCONCommandPanelSource).toContain("不保留聊天或指令记录");
expect(sourceRCONCommandPanelSource).toContain("不会显示执行回包");
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
for (const forbidden of ["password", "host", "transcript", "history"]) {
expect(sourceRCONCommandPanelSource).not.toContain(forbidden);
}
});
it("loads the dependency catalog only after runtime actions expose dependency operations", () => {
const runtimeDistributionSectionSource = serverDetailPageSource.split("function RuntimeDistributionSection")[1]?.split("function RuntimeBindingFields")[0] ?? "";
expect(runtimeDistributionSectionSource).toContain('action.key === "dependencies-check" || action.key === "dependencies-install"');
+2
View File
@@ -30,6 +30,7 @@ import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecycl
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
import { RuntimeDLLExtensionsPanel } from "../components/RuntimeDLLExtensionsPanel";
import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel";
import {
RuntimeTaskProgressDialog,
runtimeBuildStages,
@@ -295,6 +296,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
/>
)}
{section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{section === "overview" && (
<RuntimeDistributionSection
instance={instance.data}
+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))}`;
}