Integrate SCUM real ops workflows
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
type GameClientBridgeOperationTemplateDeclaration,
|
||||
type GameClientBridgeProtectedRequestDeclaration,
|
||||
type GameClientBridgeCompanionDeclaration,
|
||||
type GamePluginManifest,
|
||||
@@ -54,6 +55,7 @@ type MutableBridgeManifest = {
|
||||
commands: Array<Record<string, unknown>>;
|
||||
snapshots: Array<Record<string, unknown>>;
|
||||
queryTemplates?: Array<Record<string, unknown>>;
|
||||
operationTemplates?: Array<Record<string, unknown>>;
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
pages: Array<Record<string, unknown>>;
|
||||
@@ -92,30 +94,44 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife
|
||||
fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true });
|
||||
const manifestPath = path.join(fixtureDir, "manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableBridgeManifest;
|
||||
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query"];
|
||||
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"];
|
||||
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query"], databaseEngines: ["sqlite"] };
|
||||
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query", "remote.run.protected.rcon", "remote.run.protected.sql"];
|
||||
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read", "server.game-client.maintenance"];
|
||||
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query", "remote.run.protected.rcon", "remote.run.protected.sql"], databaseEngines: ["sqlite"] };
|
||||
manifest.runtimeProfiles = {
|
||||
transportProfiles: [{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] }]
|
||||
transportProfiles: [
|
||||
{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] },
|
||||
{ key: "scum-rcon", kind: "rcon", targetKey: "scum-rcon", capabilities: ["remote.run.protected.rcon"] },
|
||||
{ key: "scum-mutation-db", kind: "sqlite", targetKey: "scum-mutation-db", capabilities: ["remote.run.protected.sql"] }
|
||||
]
|
||||
};
|
||||
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
||||
if (overviewPage) {
|
||||
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.remote.access"];
|
||||
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access"];
|
||||
overviewPage.bridgeActions = [...(overviewPage.bridgeActions ?? []), "remote.access.request"];
|
||||
}
|
||||
manifest.gameClientBridge = {
|
||||
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
|
||||
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
|
||||
queryTemplates: [{ key: "player.by-id", title: "Find player by ID", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-by-id.parameters.schema.json", resultSchemaRef: "schemas/bridge/player-by-id.result.schema.json", maxRows: 1, timeoutSeconds: 10 }],
|
||||
operationTemplates: [
|
||||
{ key: "player.fame.set", title: "Set player fame", permission: "server.game-client.command", approvalLevel: "operator", kind: "rcon", transportKey: "scum-rcon", targetKey: "scum-rcon", payloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-fame-set.result.schema.json", confirmationSchemaRef: "schemas/bridge/player-fame-set.confirmation.schema.json", timeoutSeconds: 60, maxPayloadBytes: 2048, safety: { requiresApproval: true, requiresConfirmation: true } },
|
||||
{ key: "player.attribute.855.set", title: "Set player attribute 855", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", kind: "sqlite-mutation", transportKey: "scum-mutation-db", targetKey: "scum-mutation-db", payloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", confirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", timeoutSeconds: 120, maxPayloadBytes: 4096, maxRowsAffected: 1, mutation: { fieldKey: "855", tableKey: "prisoner", identityKey: "user_profile_id", valueKey: "value", confirmationQueryKey: "player.by-id", allowedValueType: "integer", minValue: 0, maxValue: 100000 }, safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true } }
|
||||
],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "overview", commandTypes: ["announcement.send"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"] }]
|
||||
pages: [{ pageKey: "overview", commandTypes: ["announcement.send"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"], operationKeys: ["player.fame.set", "player.attribute.855.set"] }]
|
||||
};
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-result.schema.json", bridgeObjectSchema({ accepted: { type: "boolean" } }, ["accepted"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 100, items: bridgeObjectSchema({ id: { type: "string", minLength: 1, maxLength: 80 } }, ["id"]) } }, ["players"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 1, items: bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]) } }, ["players"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, fame: { type: "integer", minimum: 0, maximum: 2147483647 } }, ["playerId", "fame"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["queued", "succeeded", "failed", "unknown"] } }, ["outcome"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, fame: { type: "integer" } }, ["playerId", "fame"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, before: { type: "number" }, after: { type: "number" }, safetyWindow: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId", "before", "after", "safetyWindow"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["succeeded", "failed", "unknown"] }, rowsAffected: { type: "integer", minimum: 0, maximum: 1 } }, ["outcome", "rowsAffected"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, value: { type: "number" } }, ["playerId", "value"]));
|
||||
mutate?.(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
@@ -174,19 +190,16 @@ describe("plugin manifest validation", () => {
|
||||
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares bounded protected SQL and management request surfaces", () => {
|
||||
it("removes raw protected SQL and management request command surfaces", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; resultSchemaRef?: string; protectedRequest?: { kind: string; textField: string; transportKey: string; targetKey: string } }> } };
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; protectedRequest?: { kind: string } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
|
||||
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
|
||||
expect(commands.map((command) => command.protectedRequest?.kind)).toEqual(expect.arrayContaining(["sql", "rcon", "program"]));
|
||||
for (const command of commands) {
|
||||
expect(command.protectedRequest?.textField).toBe("requestText");
|
||||
expect(command.protectedRequest?.transportKey).toBe(command.protectedRequest?.targetKey);
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command.payloadSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command.resultSchemaRef!), "utf8"));
|
||||
expect(payload).toMatchObject({ additionalProperties: false, required: ["requestText"] });
|
||||
expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } });
|
||||
}
|
||||
expect(commands).toEqual([]);
|
||||
expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"]));
|
||||
expect(manifest.gameClientBridge.queryTemplates.map((query) => query.key)).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
expect(manifest.gameClientBridge.operationTemplates.map((operation) => operation.key)).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver", "player.attribute.855.set"]));
|
||||
expect(manifest.gameClientBridge.operationTemplates.find((operation) => operation.key === "player.attribute.855.set")?.kind).toBe("sqlite-mutation");
|
||||
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
|
||||
@@ -257,7 +270,7 @@ describe("plugin manifest validation", () => {
|
||||
expect(local?.capabilities).not.toContain("remote.run.rcon.command");
|
||||
expect(local?.transportKeys).not.toContain("rcon");
|
||||
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: ["remote.run.protected.sql"] }),
|
||||
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.protected.sql"]) }),
|
||||
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }),
|
||||
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
|
||||
]));
|
||||
@@ -439,7 +452,7 @@ describe("plugin manifest validation", () => {
|
||||
maxPayloadBytes: number;
|
||||
}>;
|
||||
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[] }>;
|
||||
fileWorkspace?: {
|
||||
@@ -478,25 +491,22 @@ describe("plugin manifest validation", () => {
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toContain("files-config");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config")?.commandTypes).toEqual(expect.arrayContaining([
|
||||
"announcement.send",
|
||||
"companion.diagnostics",
|
||||
"player.lookup",
|
||||
"reward.deliver",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare"
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.operationKeys).toEqual(expect.arrayContaining([
|
||||
"player.fame.set",
|
||||
"player.currency.normal.set",
|
||||
"player.currency.gold.set",
|
||||
"player.notify",
|
||||
"player.attribute.855.set"
|
||||
]));
|
||||
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["files-config", "players", "squads", "live-map", "gifts"]));
|
||||
expect(manifest.pages.find((page) => page.key === "files-config")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
||||
expect(manifest.pages.map((page) => page.key)).not.toContain("files-config");
|
||||
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config");
|
||||
expect(manifest.fileWorkspace?.directories.map((directory) => directory.key)).toEqual(["scum-config", "scum-logs"]);
|
||||
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-game-config", "scum-engine-config", "scum-game-user-settings", "scum-admin-log", "scum-chat-log", "scum-kill-log", "scum-login-log", "scum-server-log"]));
|
||||
expect(manifest.fileWorkspace?.configFields.every((field) => field.fileKey === "scum-server-settings")).toBe(true);
|
||||
expect(manifest.fileWorkspace).toBeUndefined();
|
||||
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-client-events"]));
|
||||
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-client-events"]));
|
||||
});
|
||||
|
||||
it("declares bounded and permissioned SCUM bridge commands", () => {
|
||||
@@ -601,17 +611,22 @@ describe("plugin manifest validation", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
|
||||
expect(operationsPage?.snapshotTypes).toEqual(expect.arrayContaining(expectedTypes));
|
||||
const pageSnapshotTypes = manifest.gameClientBridge.pages.flatMap((page) => page.snapshotTypes ?? []);
|
||||
expect(pageSnapshotTypes).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "online.sessions"]));
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
|
||||
});
|
||||
|
||||
it("does not declare direct database query templates", () => {
|
||||
it("declares typed SCUM.db query templates without browser-visible SQL", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
capabilities: string[];
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
gameClientBridge: {
|
||||
queryTemplates: Array<{
|
||||
key: string;
|
||||
title?: string;
|
||||
permission: string;
|
||||
engine: string;
|
||||
transportKey: string;
|
||||
@@ -626,12 +641,72 @@ describe("plugin manifest validation", () => {
|
||||
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
||||
};
|
||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
|
||||
const operationsPluginPage = manifest.pages.find((page) => page.key === "files-config");
|
||||
expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]);
|
||||
expect(operationsPage?.queryTemplateKeys ?? []).toEqual([]);
|
||||
expect(operationsPluginPage?.permissions).not.toContain("server.remote.access");
|
||||
expect(operationsPluginPage?.bridgeActions).not.toContain("remote.access.request");
|
||||
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"];
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite");
|
||||
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"]));
|
||||
for (const key of expectedKeys) {
|
||||
const template = templatesByKey.get(key)!;
|
||||
expect(template.engine).toBe("sqlite");
|
||||
expect(template.transportKey).toBe("scum-database");
|
||||
expect(template.targetKey).toBe("scum-database");
|
||||
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/);
|
||||
const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8"));
|
||||
expect(parameters).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] });
|
||||
expect(result.properties.rows.maxItems).toBeLessThanOrEqual(template.maxRows);
|
||||
}
|
||||
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
||||
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
|
||||
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
|
||||
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"]));
|
||||
expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"]));
|
||||
expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
for (const pageKey of ["players", "squads", "live-map"]) {
|
||||
const pluginPage = manifest.pages.find((page) => page.key === pageKey);
|
||||
expect(pluginPage?.permissions).toContain("server.game-client.read");
|
||||
expect(pluginPage?.bridgeActions).toContain("remote.access.request");
|
||||
}
|
||||
});
|
||||
|
||||
it("declares typed SCUM RCON operations without arbitrary command inputs", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
gameClientBridge: {
|
||||
operationTemplates: Array<{ key: string; kind: string; permission: string; approvalLevel: string; payloadSchemaRef: string; resultSchemaRef?: string; confirmationSchemaRef?: string; safety?: Record<string, boolean> }>;
|
||||
pages: Array<{ pageKey: string; operationKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[] }>;
|
||||
};
|
||||
const expectedKeys = ["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver"];
|
||||
const operationsByKey = new Map(manifest.gameClientBridge.operationTemplates.map((operation) => [operation.key, operation]));
|
||||
expect([...operationsByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
for (const key of expectedKeys) {
|
||||
const operation = operationsByKey.get(key)!;
|
||||
expect(operation.kind).toBe("rcon");
|
||||
expect(operation.permission).toBe("server.game-client.command");
|
||||
expect(operation.approvalLevel).toBe("operator");
|
||||
expect(operation.safety).toMatchObject({ requiresApproval: true, requiresConfirmation: true });
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.payloadSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.resultSchemaRef!), "utf8"));
|
||||
const confirmation = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.confirmationSchemaRef!), "utf8"));
|
||||
expect(payload).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(result).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(confirmation).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/rcon|commandtext|requesttext|sql|dsn|hostpath/);
|
||||
}
|
||||
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
||||
const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts");
|
||||
expect(playersPage?.operationKeys).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify"]));
|
||||
expect(giftsPage?.operationKeys).toEqual(expect.arrayContaining(["reward.deliver", "player.notify"]));
|
||||
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toContain("server.game-client.command");
|
||||
expect(manifest.pages.find((page) => page.key === "gifts")?.permissions).toContain("server.game-client.command");
|
||||
});
|
||||
|
||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||
@@ -856,6 +931,35 @@ describe("plugin manifest validation", () => {
|
||||
expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true);
|
||||
});
|
||||
|
||||
it("validates typed operation templates and page operation bindings", () => {
|
||||
expect(validateTemporaryBridgeManifest()).toEqual([]);
|
||||
|
||||
const unsafeKeyErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.operationTemplates![0].key = "raw.sql.execute";
|
||||
});
|
||||
expect(unsafeKeyErrors.some((error) => error.includes("operationTemplates") && error.includes("arbitrary SQL"))).toBe(true);
|
||||
|
||||
const approvalErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.operationTemplates![0].approvalLevel = "none";
|
||||
});
|
||||
expect(approvalErrors.some((error) => error.includes("approvalLevel") && error.includes("operator"))).toBe(true);
|
||||
|
||||
const rconTransportErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
Object.assign(manifest.gameClientBridge.operationTemplates![0], { transportKey: "sqlite-db", targetKey: "db/sqlite" });
|
||||
});
|
||||
expect(rconTransportErrors.some((error) => error.includes("rcon operations require"))).toBe(true);
|
||||
|
||||
const mutationSafetyErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.operationTemplates![1].safety = { requiresConfirmation: true };
|
||||
});
|
||||
expect(mutationSafetyErrors.some((error) => error.includes("sqlite-mutation operations require before value"))).toBe(true);
|
||||
|
||||
const pageErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.pages[0].operationKeys = ["missing.operation"];
|
||||
});
|
||||
expect(pageErrors.some((error) => error.includes("undeclared operation template missing.operation"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("rejects unsafe query parameter schema field %s", (fieldName) => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName]));
|
||||
@@ -990,6 +1094,28 @@ describe("plugin SDK", () => {
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
|
||||
});
|
||||
|
||||
it("types controlled operation template declarations", () => {
|
||||
const declaration: GameClientBridgeOperationTemplateDeclaration = {
|
||||
key: "player.attribute.855.set",
|
||||
title: "Set player attribute 855",
|
||||
permission: "server.game-client.maintenance",
|
||||
approvalLevel: "platform-admin",
|
||||
kind: "sqlite-mutation",
|
||||
transportKey: "scum-mutation-db",
|
||||
targetKey: "scum-mutation-db",
|
||||
payloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json",
|
||||
resultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json",
|
||||
confirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json",
|
||||
timeoutSeconds: 120,
|
||||
maxPayloadBytes: 4096,
|
||||
maxRowsAffected: 1,
|
||||
mutation: { fieldKey: "855", tableKey: "prisoner", identityKey: "user_profile_id", valueKey: "value", confirmationQueryKey: "player.lookup", allowedValueType: "integer", minValue: 0, maxValue: 100000 },
|
||||
safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true }
|
||||
};
|
||||
expect(declaration).toMatchObject({ kind: "sqlite-mutation", approvalLevel: "platform-admin", maxRowsAffected: 1 });
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential|password/);
|
||||
});
|
||||
|
||||
it("builds safe game-client bridge requests without component transport material", () => {
|
||||
const request = createGameClientBridgeQueueRequest({
|
||||
profileKey: "scum-client",
|
||||
|
||||
@@ -9,18 +9,16 @@ import { configurationCatalog, validateConfigPatch, validateStatePatch, validate
|
||||
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
|
||||
|
||||
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
|
||||
const declaredWorkspace = {
|
||||
defaultDirectoryKey: "scum-config",
|
||||
directories: [
|
||||
{ key: "scum-config", label: "服务器配置", scope: "config" },
|
||||
{ key: "scum-logs", label: "日志文件", scope: "logs" }
|
||||
],
|
||||
files: [
|
||||
{ key: "scum-server-settings", directoryKey: "scum-config", label: "ServerSettings.ini", kind: "config", editable: true },
|
||||
{ key: "scum-game-config", directoryKey: "scum-config", label: "Game.ini", kind: "config" },
|
||||
{ key: "scum-admin-log", directoryKey: "scum-logs", label: "Admin.log", kind: "log", streamKey: "scum.admin" }
|
||||
],
|
||||
configFields: configurationCatalog
|
||||
const projectionData = {
|
||||
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3, hasCoordinates: true }, freshness: { status: "fresh" }, unknownFields: { "855": 100 } }],
|
||||
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 3, leaderProfileId: "profile-1", freshness: { status: "fresh" } }],
|
||||
members: [{ gamePlayerId: "steam-1", displayName: "Mira", squadId: "squad-1", rank: "Leader", freshness: { status: "fresh" } }],
|
||||
vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { subjectType: "vehicle", subjectId: "veh-1", x: 400, y: 200, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
|
||||
flags: [{ flagId: "flag-1", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { subjectType: "flag", subjectId: "flag-1", x: 100, y: 80, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
|
||||
positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }],
|
||||
operations: [{ id: "op-1", templateKey: "player.fame.set", status: "waiting", safeSummary: { message: "awaiting approval" } }],
|
||||
workflows: [{ id: "wf-1", templateKey: "scum.world-refresh", status: "queued", currentStepKey: "read-positions", createdAt: "2026-08-10T00:00:00Z", safeSummary: { message: "world refresh queued" } }],
|
||||
steps: [{ stepKey: "read-positions", status: "queued", capability: "remote.run.db.sqlite.query", safeSummary: { message: "queued safely" } }]
|
||||
};
|
||||
|
||||
describe("SCUM plugin feature module", () => {
|
||||
@@ -64,64 +62,77 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
|
||||
});
|
||||
|
||||
it("renders the compact two-level file management workbench without legacy stacked panels", () => {
|
||||
it("renders projection-backed user management without raw file/config panels", () => {
|
||||
const view = renderAndCollect();
|
||||
expect(view.nodes).toContain("section:SCUM 文件管理");
|
||||
expect(view.nodes).toContain("aside:SCUM 文件两级菜单");
|
||||
expect(view.nodes).toContain("article:文件 ServerSettings.ini");
|
||||
expect(view.texts.join("\n")).toContain("scum-config · 配置声明");
|
||||
expect(view.texts).toContain("刷新文件列表");
|
||||
expect(view.texts).toContain("ServerSettings.ini");
|
||||
expect(view.texts).toContain("Game.ini");
|
||||
expect(view.texts).toContain("配置表单");
|
||||
expect(view.texts).toContain("键值视图");
|
||||
expect(view.texts).toContain("原文模式");
|
||||
expect(view.buttons.find((button) => button.label === "刷新文件列表")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "读取文件")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "刷新结果")?.disabled).toBe(false);
|
||||
for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText);
|
||||
expect(view.nodes).toContain("section:用户管理");
|
||||
expect(view.texts.join("\n")).toContain("登录日志和 SCUM.db typed observations");
|
||||
expect(view.texts).toContain("投影/Companion 可用");
|
||||
expect(view.texts).toContain("刷新投影");
|
||||
expect(view.texts).toContain("刷新真实数据");
|
||||
expect(view.texts).toContain("Mira");
|
||||
expect(view.texts.join("\n")).toContain("Steam 76561198000000001");
|
||||
expect(view.texts.join("\n")).toContain("Profile profile-1");
|
||||
expect(view.texts.join("\n")).toContain("Fame 42");
|
||||
expect(view.buttons.find((button) => button.label === "Fame +100")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "现金 +1000")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "855 审批")?.disabled).toBe(false);
|
||||
for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText);
|
||||
});
|
||||
|
||||
it("does not invent a hardcoded SCUM file list when the platform workspace is missing", () => {
|
||||
const view = renderAndCollect({ workspace: {} });
|
||||
expect(view.texts.join("\n")).toContain("当前插件没有可展示的声明文件。");
|
||||
expect(view.texts).not.toContain("ServerSettings.ini");
|
||||
expect(view.texts).not.toContain("Game.ini");
|
||||
it("does not invent fake players when projections are empty", () => {
|
||||
const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } });
|
||||
expect(view.texts.join("\n")).toContain("暂无玩家投影");
|
||||
expect(view.texts.join("\n")).toContain("不会显示假玩家");
|
||||
expect(view.texts).not.toContain("Mira");
|
||||
expect(pageSource).not.toContain("fallbackFiles");
|
||||
expect(pageSource).not.toContain("samplePlayers");
|
||||
});
|
||||
|
||||
it("keeps declared log files in read-only raw view with encoding controls", () => {
|
||||
const view = renderAndCollect({ directoryKey: "scum-logs", fileKey: "scum-admin-log" });
|
||||
expect(view.nodes).toContain("article:文件 Admin.log");
|
||||
expect(view.texts).toContain("UTF-8");
|
||||
expect(view.texts).toContain("UTF-16 LE");
|
||||
expect(view.texts.join("\n")).toContain("尚未读取此日志文件的受控内容。");
|
||||
expect(view.nodes.some((node) => node.startsWith("textarea:"))).toBe(false);
|
||||
expect(view.texts).not.toContain("配置表单");
|
||||
expect(view.texts).not.toContain("提交写入");
|
||||
it("renders squad and flag governance from projections", () => {
|
||||
const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" });
|
||||
expect(view.nodes).toContain("section:队伍管理");
|
||||
expect(view.texts).toContain("队伍");
|
||||
expect(view.texts).toContain("成员 / 旗帜");
|
||||
expect(view.texts).toContain("Wolves");
|
||||
expect(view.texts.join("\n")).toContain("成员 3");
|
||||
expect(view.texts.join("\n")).toContain("verified");
|
||||
});
|
||||
|
||||
it("renders current config values, unknown fields, encoding switch, and guarded write actions after a read", () => {
|
||||
const view = renderAndCollect({ snapshot: { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "ServerName=Qinghuo\nMaxPlayers=96\nCustomKey=keep\n", version: 3, checksum: "sha256:cfg", sizeBytes: 48 } });
|
||||
expect(view.texts).toContain("UTF-8");
|
||||
expect(view.texts).toContain("UTF-16 LE");
|
||||
expect(view.texts).toContain("未建模配置项");
|
||||
expect(view.texts).toContain("CustomKey");
|
||||
expect(pageSource).toContain('e("option", { value: "true" }, "是")');
|
||||
expect(pageSource).toContain('type: "range"');
|
||||
expect(view.buttons.find((button) => button.label === "预览改动")?.disabled).toBe(true);
|
||||
expect(view.buttons.find((button) => button.label === "提交写入")?.disabled).toBe(true);
|
||||
it("renders realtime map overlays without sample coordinates", () => {
|
||||
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
|
||||
expect(view.nodes).toContain("section:实时地图");
|
||||
expect(view.texts).toContain("地图覆盖物");
|
||||
expect(view.texts.join("\n")).toContain("坐标点");
|
||||
expect(view.texts.join("\n")).toContain("X 10 / Y 20 / Z 3");
|
||||
expect(pageSource).toContain("map-projection-board");
|
||||
expect(pageSource).not.toContain("sampleCoordinates");
|
||||
});
|
||||
|
||||
it("loads snapshots on selection or manual refresh and bounded post-request refresh", () => {
|
||||
expect(pageSource).toContain("getFileSnapshot(selectedFile.key)");
|
||||
expect(pageSource).toContain("刷新结果");
|
||||
expect(pageSource).toContain("loadFileSnapshot(selectedFile.key, true)");
|
||||
it("renders gift and workflow typed status surfaces", () => {
|
||||
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
|
||||
expect(gifts.nodes).toContain("section:礼包管理");
|
||||
expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
|
||||
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
|
||||
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
|
||||
|
||||
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
|
||||
expect(workflows.nodes).toContain("section:Workflow 状态");
|
||||
expect(workflows.texts.join("\n")).toContain("scum.world-refresh");
|
||||
expect(workflows.texts.join("\n")).toContain("read-positions");
|
||||
});
|
||||
|
||||
it("loads projections through typed workspace actions instead of file snapshots", () => {
|
||||
expect(pageSource).toContain("listSCUMPlayers");
|
||||
expect(pageSource).toContain("createSCUMOperation");
|
||||
expect(pageSource).toContain("createSCUMWorkflow");
|
||||
expect(pageSource).not.toContain("getFileSnapshot");
|
||||
expect(pageSource).not.toContain("requestFile");
|
||||
expect(pageSource).not.toContain("writeFile");
|
||||
expect(pageSource).not.toContain("setInterval");
|
||||
});
|
||||
});
|
||||
|
||||
function renderAndCollect(options: { snapshot?: Record<string, unknown>; permissions?: string[]; directoryKey?: string; fileKey?: string; workspace?: Record<string, unknown> } = {}) {
|
||||
function renderAndCollect(options: { data?: typeof projectionData; permissions?: string[]; pageKey?: string; pageTitle?: string } = {}) {
|
||||
const nodes: string[] = [];
|
||||
const texts: string[] = [];
|
||||
const buttons: Array<{ label: string; disabled: boolean }> = [];
|
||||
@@ -137,21 +148,26 @@ function renderAndCollect(options: { snapshot?: Record<string, unknown>; permiss
|
||||
useEffect: () => undefined,
|
||||
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => {
|
||||
stateCall += 1;
|
||||
if (stateCall === 1 && options.directoryKey) return [options.directoryKey as T, () => undefined];
|
||||
if (stateCall === 2 && options.fileKey) return [options.fileKey as T, () => undefined];
|
||||
if (stateCall === 8 && options.snapshot) return [options.snapshot as T, () => undefined];
|
||||
if (stateCall === 1) return [{ status: "ready", data: options.data ?? projectionData } as T, () => undefined];
|
||||
return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined];
|
||||
}
|
||||
};
|
||||
renderPluginPage(react, {
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.files.read", "server.files.write", "server.logs.read"] },
|
||||
availability: { available: true, features: [{ key: "config.manage", available: true }] },
|
||||
workspace: options.workspace ?? declaredWorkspace,
|
||||
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] },
|
||||
availability: { available: true, features: [{ key: "player.intelligence", available: true }] },
|
||||
workspaceActions: {
|
||||
refreshWorkspace: async () => declaredWorkspace,
|
||||
requestFile: async (fileKey: string) => ({ status: "queued", message: fileKey }),
|
||||
getFileSnapshot: async (fileKey: string) => ({ serverInstanceId: "server-1", pluginId: "game.scum", key: fileKey, state: "not-read" }),
|
||||
writeFile: async (fileKey: string) => ({ status: "queued", message: fileKey })
|
||||
listSCUMPlayers: async () => ({ items: projectionData.players, count: projectionData.players.length }),
|
||||
listSCUMSquads: async () => ({ items: projectionData.squads, count: projectionData.squads.length }),
|
||||
listSCUMSquadMembers: async () => ({ items: projectionData.members, count: projectionData.members.length }),
|
||||
listSCUMVehicles: async () => ({ items: projectionData.vehicles, count: projectionData.vehicles.length }),
|
||||
listSCUMFlags: async () => ({ items: projectionData.flags, count: projectionData.flags.length }),
|
||||
listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length }),
|
||||
listSCUMOperations: async () => ({ items: projectionData.operations, count: projectionData.operations.length }),
|
||||
listSCUMWorkflows: async () => ({ items: projectionData.workflows, count: projectionData.workflows.length }),
|
||||
listSCUMWorkflowSteps: async () => ({ items: projectionData.steps, count: projectionData.steps.length }),
|
||||
createSCUMOperation: async () => ({ id: "op-new", status: "waiting" }),
|
||||
createSCUMWorkflow: async () => ({ id: "wf-new", status: "queued" })
|
||||
}
|
||||
});
|
||||
return { nodes, texts, buttons };
|
||||
|
||||
Reference in New Issue
Block a user