Remove SCUM server-management client path
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
Reference in New Issue
Block a user