Remove SCUM server-management client path

This commit is contained in:
npc0-hue
2026-09-02 17:56:49 +08:00
parent 6e614d3fa3
commit a82f1ff01a
52 changed files with 245 additions and 5131 deletions
+21 -206
View File
@@ -26,9 +26,7 @@ import {
parseBridgeExecutionResponse,
parseAIInvocationResponse,
type GameClientBridgeQueryTemplateDeclaration,
type GameClientBridgeCompanionDeclaration,
type GamePluginManifest,
type RuntimeClientManagerProfile,
type PluginLifecycleActionDeclaration,
type PluginBridgeContext
} from "../sdk/index.js";
@@ -74,16 +72,6 @@ function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unkno
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function listProductionGoFiles(directory: string): string[] {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listProductionGoFiles(target);
}
return entry.isFile() && entry.name.endsWith(".go") && !entry.name.endsWith("_test.go") ? [target] : [];
});
}
function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManifest, fixtureDir: string) => void): string[] {
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-bridge-manifest-"));
try {
@@ -129,8 +117,8 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife
}
}
function validateTemporaryScumCompanionManifest(mutate: (manifest: Record<string, any>, fixtureDir: string) => void): string[] {
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-companion-manifest-"));
function validateTemporaryScumManifest(mutate: (manifest: Record<string, any>, fixtureDir: string) => void): string[] {
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-manifest-"));
try {
fs.cpSync(path.join(pluginsRoot, "examples/scum-server-plugin"), fixtureDir, { recursive: true });
const manifestPath = path.join(fixtureDir, "manifest.json");
@@ -209,18 +197,18 @@ describe("plugin manifest validation", () => {
});
it("rejects lifecycle executable assets missing from the manifest seed declaration", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
const errors = validateTemporaryScumManifest((manifest) => {
manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-start.cmd");
});
expect(errors.some((error) => error.includes("lifecycleAction.start.executableKey") && error.includes("manifest.assetFiles"))).toBe(true);
});
it("rejects unsupported or unsafe inline create-field declarations", () => {
const malformed = validateTemporaryScumCompanionManifest((manifest) => {
const malformed = validateTemporaryScumManifest((manifest) => {
manifest.server.createFields[0].type = "path";
});
expect(malformed.some((error) => error.includes("createFields") && error.includes("type"))).toBe(true);
const unsafe = validateTemporaryScumCompanionManifest((manifest) => {
const unsafe = validateTemporaryScumManifest((manifest) => {
manifest.server.createFields[0].defaultValue = "/srv/hidden-server";
});
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
@@ -244,167 +232,6 @@ describe("plugin manifest validation", () => {
]));
});
it("defines a generated SCUM companion config without inline proof or session material", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
gameClientBridge: { companion?: GameClientBridgeCompanionDeclaration };
};
const companion = manifest.gameClientBridge.companion;
expect(companion).toMatchObject({
profileKey: "scum-client-manager",
configTemplateKey: "client-config",
configFormat: "yaml",
platformBaseUrlSource: "run-control",
registrationProof: "hmac-sha256",
proofMaterialSource: "component-package",
proofMaterialEnv: "SCUM_COMPONENT_PROOF",
sessionMode: "component-session",
tlsPolicy: "verify-system-roots",
heartbeatIntervalSeconds: 30,
requestTimeoutSeconds: 15
});
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, companion!.configSchemaRef), "utf8"));
const example = JSON.parse(fs.readFileSync(path.join(pluginDir, "schemas/companion/config.generated.example.json"), "utf8"));
const validate = new Ajv2020({ strict: false, validateFormats: false }).compile(schema);
expect(validate(example), JSON.stringify(validate.errors)).toBe(true);
expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i);
expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } });
});
it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => {
const policyErrors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.gameClientBridge.companion.tlsPolicy = "skip-verification";
});
expect(policyErrors.some((error) => error.includes("tlsPolicy") || error.includes("secure component registration/session/TLS policy"))).toBe(true);
const materialErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
example.proof.sessionToken = "inline-session-material";
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(materialErrors.some((error) => error.includes("inline proof/session material") || error.includes("additional properties"))).toBe(true);
const environmentErrors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.gameClientBridge.companion.proofMaterialEnv = "LD_PRELOAD";
});
expect(environmentErrors.some((error) => error.includes("proofMaterialEnv"))).toBe(true);
});
it("rejects generated SCUM companion configs with incomplete capabilities or unsafe Platform URLs", () => {
const capabilityErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
example.capabilities = ["component.register", "component.heartbeat", "component.health", "game-client.bridge"];
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(capabilityErrors.some((error) => error.includes("capabilities"))).toBe(true);
for (const unsafeURL of ["https://user:raw-token@example.test?session=raw-token#fragment", "https://?missing-host"]) {
const urlErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
example.platform.baseUrl = unsafeURL;
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(urlErrors.some((error) => error.includes("platform.baseUrl"))).toBe(true);
}
});
it("cross-validates generated SCUM companion config against its declaration and runtime profile", () => {
const proofErrors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.gameClientBridge.companion.proofMaterialEnv = "OTHER_COMPONENT_PROOF";
});
expect(proofErrors.some((error) => error.includes("proof.materialEnv") && error.includes("proofMaterialEnv"))).toBe(true);
const profileErrors = validateTemporaryScumCompanionManifest((manifest) => {
const manager = manifest.runtimeProfiles.clientManagers.find((candidate: Record<string, unknown>) => candidate.key === "scum-client-manager");
manager.health.requiredCapabilities = manager.health.requiredCapabilities.filter((capability: string) => capability !== "logs.stream");
});
expect(profileErrors.some((error) => error.includes("capabilities") && error.includes("requiredCapabilities"))).toBe(true);
const sessionErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
example.session.mode = "legacy-shared-token";
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(sessionErrors.some((error) => error.includes("session"))).toBe(true);
});
it("rejects unsafe optional fields declared only by the SCUM companion config schema", () => {
const schemaErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json");
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
schema.properties.hostPath = { type: "string", minLength: 1, maxLength: 200 };
writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema);
});
expect(schemaErrors.some((error) => error.includes("hostPath") && error.includes("raw host path"))).toBe(true);
});
it("rejects legacy companion endpoints, insecure TLS flags, and credential keys", () => {
const cases = [
{
name: "legacy shared-token endpoint",
mutate(schema: Record<string, any>, _example: Record<string, any>): void {
schema.description = "legacy /api/v1/scum-clients/commands endpoint";
}
},
{
name: "insecure TLS flag",
mutate(schema: Record<string, any>, _example: Record<string, any>): void {
schema.description = "InsecureSkipVerify";
}
},
{
name: "inline credential key",
mutate(schema: Record<string, any>, example: Record<string, any>): void {
schema.properties.credential = { type: "string", minLength: 1, maxLength: 200 };
example.credential = "legacy-shared-value";
}
}
];
for (const testCase of cases) {
const errors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json");
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")) as Record<string, any>;
const example = JSON.parse(fs.readFileSync(examplePath, "utf8")) as Record<string, any>;
testCase.mutate(schema, example);
writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema);
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(errors, testCase.name).toContain(
"manifest.gameClientBridge.companion.configSchemaRef: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material"
);
}
});
it("keeps the production SCUM companion free of unsafe legacy transport and updater patterns", () => {
const companionDir = path.join(pluginsRoot, "examples/scum-server-plugin/companion");
const forbiddenPatterns = [
{ name: "legacy shared-token endpoint", pattern: /\/api\/v1\/scum-clients\//i },
{ name: "legacy shared credential", pattern: /\b(?:SCUMClientCredential|scum_client_credential)\b/i },
{ name: "disabled TLS verification", pattern: /\bInsecureSkipVerify\s*:\s*true\b/ },
{ name: "arbitrary process or shell execution", pattern: /(?:\b(?:os\/exec|exec\.Command(?:Context)?|os\.StartProcess|syscall\.Exec)\b|\b(?:bash|zsh|powershell|pwsh|cmd(?:\.exe)?)\s+-[a-z/])/i },
{ name: "direct socket transport", pattern: /(?:\bnet\.(?:Dial|DialTimeout)\s*\(|\b(?:tcp|unix|ws):\/\/)/i },
{ name: "arbitrary URL self-update or download", pattern: /(?:\b(?:self_?update|update_?url|download_?url|updater)\b|\bhttp\.(?:Get|DefaultClient\.Get)\s*\(|\b(?:curl|wget)\b)/i }
];
const productionFiles = listProductionGoFiles(companionDir);
expect(productionFiles.length).toBeGreaterThan(0);
for (const file of productionFiles) {
const source = fs.readFileSync(file, "utf8");
const relativeFile = path.relative(companionDir, file);
for (const forbidden of forbiddenPatterns) {
const match = source.match(forbidden.pattern);
expect(match ? `${relativeFile}: ${match[0]}` : null, forbidden.name).toBeNull();
}
}
});
it("covers the SCUM 4.1 bridge and lifecycle declarations", () => {
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
@@ -450,7 +277,6 @@ describe("plugin manifest validation", () => {
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
"companion.diagnostics",
"player.lookup",
"reward.deliver",
"player.notify",
@@ -459,7 +285,8 @@ describe("plugin manifest validation", () => {
"restart.prepare",
"maintenance.prepare"
]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined();
expect(manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.vehicles")?.projections).toEqual(expect.arrayContaining([
expect.objectContaining({ collection: "scum_trade_goods", mergeExisting: true, fixedValues: expect.objectContaining({ catalogType: "vehicle", type: "21", typeName: "其他载具" }) })
]));
@@ -473,8 +300,8 @@ describe("plugin manifest validation", () => {
expect(manifest.fileWorkspace?.directories.map((directory) => `${directory.key}:${directory.scope}`)).toEqual(expect.arrayContaining(["scum-config:config", "scum-logs:logs"]));
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"]));
expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "game-port", "query-port", "max-players", "welcome-message"]));
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events", "scum-client-events"]));
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")).toBeUndefined();
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events"]));
});
it("declares bounded and permissioned SCUM bridge commands", () => {
@@ -492,7 +319,6 @@ describe("plugin manifest validation", () => {
};
};
const expected = {
"companion.diagnostics": { permission: "server.game-client.read" },
"player.lookup": { permission: "server.game-client.read" },
"reward.deliver": { permission: "server.game-client.command" },
"player.notify": { permission: "server.game-client.command" },
@@ -559,7 +385,7 @@ describe("plugin manifest validation", () => {
pages: Array<{ pageKey: string; snapshotTypes?: string[] }>;
};
};
const expectedTypes = ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"];
const expectedTypes = ["online.sessions", "players", "squads", "vehicles", "flags"];
const snapshotsByType = new Map(manifest.gameClientBridge.snapshots.map((snapshot) => [snapshot.type, snapshot]));
expect([...snapshotsByType.keys()]).toEqual(expect.arrayContaining(expectedTypes));
@@ -639,7 +465,7 @@ describe("plugin manifest validation", () => {
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database");
expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" });
expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]));
expect(manifest.runtimeProfiles?.dataTargets).toEqual(expect.arrayContaining([expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot", sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", workspaceKey: "databases/scum-database" })]));
expect(manifest.runtimeProfiles?.dataTargets).toBeUndefined();
for (const key of expectedKeys) {
const template = templatesByKey.get(key)!;
expect(template.engine).toBe("sqlite");
@@ -703,22 +529,11 @@ describe("plugin manifest validation", () => {
});
it("aligns the SCUM Client Manager declaration with the real Go bootstrap", () => {
it("does not declare a SCUM client-manager profile", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
runtimeProfiles?: { clientManagers?: Array<{
key: string;
build?: { workspaceRef?: string; entryRef?: string };
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
deployment?: { arguments?: string[] };
health?: { intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number };
}> };
runtimeProfiles?: { clientManagers?: unknown[] };
};
const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager");
expect(manager?.build).toMatchObject({ entryRef: "main.go" });
expect(manager?.build).not.toHaveProperty("workspaceRef");
expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]);
expect(manager?.deployment?.arguments).toBeUndefined();
expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 });
expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined();
});
it("accepts the Minecraft server plugin manifest", () => {
@@ -953,7 +768,7 @@ describe("plugin SDK", () => {
it("builds safe game-client bridge requests without component transport material", () => {
const request = createGameClientBridgeQueueRequest({
profileKey: "scum-client",
profileKey: "example-client",
commandType: "diagnostic.ping",
payload: { message: "hello" },
idempotencyKey: "diagnostic-1",
@@ -1302,18 +1117,18 @@ describe("plugin SDK", () => {
action: "logs.backfill.request",
payload: { sourceKey: "chat-log", limit: "500" }
});
expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "scum-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({
expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "example-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({
action: "client-manager.request",
payload: { operation: "generate", profileKey: "scum-client-manager" }
payload: { operation: "generate", profileKey: "example-client-manager" }
});
expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "scum-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret");
expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "scum-client-manager", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: 2, idempotencyKey: "idem-deploy" })).toMatchObject({
expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "example-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret");
expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "example-client-manager", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: 2, idempotencyKey: "idem-deploy" })).toMatchObject({
action: "client-manager.request",
payload: { operation: "deploy", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: "2" }
});
expect(parseClientManagerLifecycleStatus({
installationId: "cm-install-1",
profileKey: "scum-client-manager",
profileKey: "example-client-manager",
status: "online",
phase: "healthy",
targetOS: "windows",
@@ -1326,7 +1141,7 @@ describe("plugin SDK", () => {
})).toMatchObject({ installationId: "cm-install-1", deploymentGeneration: 2, actions: ["stop", "restart", "update", "uninstall"] });
expect(parseClientManagerLifecycleStatus({
installationId: "cm-install-1",
profileKey: "scum-client-manager",
profileKey: "example-client-manager",
status: "online",
deploymentGeneration: "2",
actions: "stop",
+24 -45
View File
@@ -18,7 +18,7 @@ const surfaceData: SCUMSurfaceData = {
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, lastLoginIp: "203.0.113.7", position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }],
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }],
members: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", displayName: "Mira", squadId: "squad-1", rank: "Leader", score: 42, lastLoginAt: "2026-08-10T00:00:00Z", freshness: { status: "fresh" } }],
events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }],
events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", rconCommand: "#start_event event-1", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }],
eventProduces: [{ _recordKey: "event-1:produce-1", id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }],
eventRuns: [{ id: "run-1", eventId: "event-1", status: "running", startedAt: "2026-08-10T00:00:00Z", summary: "Round 1" }],
nativeEventRounds: [{ eventRecordId: "native-1", eventId: "native-event", state: "active", startTime: "2026-08-10T00:00:00Z", enemyKills: 2 }],
@@ -93,17 +93,14 @@ describe("SCUM plugin feature module", () => {
expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` });
});
it("merges player snapshots only by stable identifiers and ignores name-only online sessions", async () => {
it("reads player records only from plugin-owned collections", async () => {
const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } });
const gameClient = gameClientActions();
gameClient.snapshots.mockResolvedValue({ items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] });
const data = await loadSCUMSurface({ pluginData, gameClient }, "players");
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "vehicles"]);
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:00:00Z" });
const data = await loadSCUMSurface({ pluginData }, "players");
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", displayName: "Mira", online: false });
const sameName = mergePlayerSnapshots([{ steamId: "steam-2", displayName: "Noah", online: false }], { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { players: [{ playerId: "steam-3", playerName: "Noah", status: "online" }] } }] });
expect(sameName).toHaveLength(2);
expect(sameName.find((player) => player.steamId === "steam-2")).toMatchObject({ online: false });
expect(dataClientSource).not.toContain('type: "online.sessions"');
expect(dataClientSource).not.toContain("scum-client-manager");
});
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
@@ -131,14 +128,13 @@ describe("SCUM plugin feature module", () => {
it("persists event produces, event runs, and gift resets in plugin-owned collections", async () => {
const pluginData = pluginDataActions();
const gameClient = gameClientActions();
const actions: SCUMWorkspaceActions = { pluginData, gameClient };
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
const actions: SCUMWorkspaceActions = { pluginData, dispatch };
await saveEventProduce(actions, { id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 });
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventProduces, "event-1:produce-1", expect.objectContaining({ eventId: "event-1", tradeGoodsId: "goods-1" }));
await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces);
expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({
eventType: "range", class: 1, placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2,
produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }]
await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ action: "remote.access.request", payload: expect.objectContaining({
capability: "remote.run.rcon.command", "input.command": "#start_event event-1"
}) }));
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventRuns, expect.any(String), expect.objectContaining({ eventId: "event-1", status: "queued", produces: surfaceData.eventProduces }));
await resetGiftClaim(actions, { _recordKey: "claim-1" });
@@ -147,38 +143,31 @@ describe("SCUM plugin feature module", () => {
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.pendingGifts, "pending-1", expect.objectContaining({ status: "pending", receivedAt: null }));
});
it("queues gift and event commands through the host-compatible generic gameClient bridge", async () => {
it("queues gift and event commands through platform-mediated SCUM RCON", async () => {
const pluginData = pluginDataActions();
const gameClient = gameClientActions();
const actions: SCUMWorkspaceActions = { pluginData, gameClient };
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
const actions: SCUMWorkspaceActions = { pluginData, dispatch };
await queueGiftDelivery(actions, { ...surfaceData.gifts[0], operations: ["#announce Starter pack", "#SetFamePoints 250"] }, surfaceData.players[0]);
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "reward.deliver", payload: expect.objectContaining({ playerId: "steam-1", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], operations: ["#announce Starter pack", "#SetFamePoints 250"] }) }));
expect(dispatch.mock.calls.map(([request]) => request.payload?.["input.command"])).toEqual(["#SpawnItem BP_Cash_01 2", "#announce Starter pack", "#SetFamePoints 250"]);
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, expect.any(String), expect.objectContaining({ giftCode: "starter-pack", playerId: "steam-1", status: "queued" }));
await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces);
expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "event.start", payload: expect.objectContaining({ eventId: "event-1", eventType: "range", class: 1, placard: "Event starting", percent: 75, produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }] }) }));
expect(Object.keys(gameClient).sort()).toEqual(["get", "list", "queue", "snapshots"]);
await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-1" }) }));
});
it("defaults activity class to range and strips collection metadata from queued produces", async () => {
const pluginData = pluginDataActions();
const gameClient = gameClientActions();
await startEvent({ pluginData, gameClient }, { id: "event-default", name: "Default Event" }, [{
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
await startEvent({ pluginData, dispatch }, { id: "event-default", name: "Default Event", rconCommand: "#start_event event-default" }, [{
_recordKey: "event-default:produce-1", id: "produce-1", eventId: "event-default", updatedAt: "2026-08-10T00:00:00Z",
tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300
}]);
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({
eventType: "range", class: 1, npc: 0, item: 0, zombie: 0, animal: 0,
produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300 }]
}) }));
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-default" }) }));
});
it("keeps positive event duration and counts above the removed arbitrary limits", async () => {
const gameClient = gameClientActions();
await startEvent({ pluginData: pluginDataActions(), gameClient }, { id: "event-large", name: "Large Event", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]);
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({
durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004,
produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]
}) }));
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
await startEvent({ pluginData: pluginDataActions(), dispatch }, { id: "event-large", name: "Large Event", rconCommand: "#start_event event-large", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]);
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-large" }) }));
});
it("renders searchable user management from real collection values", () => {
@@ -241,7 +230,7 @@ describe("SCUM plugin feature module", () => {
it("renders activity definitions, status filters, runs, and records", () => {
const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" });
expect(view.inputs.map((input) => input.label)).toContain("活动状态");
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "RCON 命令", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
expect(view.texts).toContain("Friday Range");
expect(view.texts).toContain("running");
expect(view.texts).toContain("最近活动记录");
@@ -318,16 +307,6 @@ function pluginDataActions(overrides: Partial<{ list: (collection: string, key?:
};
}
function gameClientActions() {
const queue = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["queue"]>(async () => ({ id: "command-1", state: "pending" }));
const get = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["get"]>(async () => ({ id: "command-1", state: "pending" }));
const list = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["list"]>(async () => ({ items: [], count: 0 }));
const snapshots = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["snapshots"]>(async () => ({ items: [], count: 0 }));
return {
queue, get, list, snapshots
};
}
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; playerSearch?: string } = {}) {
const nodes: string[] = [];
const texts: string[] = [];
@@ -355,7 +334,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
return [value, () => undefined];
}
};
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) };
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) };
renderPluginPage(react, {
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },