Rebuild SCUM plugin-owned data flow

This commit is contained in:
npc0-hue
2026-08-18 07:01:17 +08:00
parent 302f1f64b7
commit 98bf944f4c
39 changed files with 1832 additions and 223 deletions
+90 -9
View File
@@ -26,6 +26,7 @@ import {
parseBridgeExecutionResponse,
parseAIInvocationResponse,
type GameClientBridgeQueryTemplateDeclaration,
type GameClientBridgeLogProjectionDeclaration,
type GameClientBridgeOperationTemplateDeclaration,
type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration,
@@ -35,7 +36,7 @@ import {
type PluginLifecycleActionDeclaration,
type PluginBridgeContext
} from "../sdk/index.js";
import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
import { validateGameClientBridgeCatalog, validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
const pluginsRoot = fileURLToPath(new URL("..", import.meta.url));
@@ -190,11 +191,13 @@ describe("plugin manifest validation", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
});
it("removes raw protected SQL and management request command surfaces", () => {
it("removes raw SQL command surfaces and keeps announcements as a typed protected RCON request", () => {
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; protectedRequest?: { kind: string } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; protectedRequest?: { kind: string; transportKey: string; targetKey: string; textField: string; maxTextBytes: number } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
expect(commands).toEqual([]);
expect(commands).toEqual([expect.objectContaining({ type: "announcement.send", protectedRequest: { kind: "rcon", transportKey: "scum-management", targetKey: "scum-management", textField: "requestText", maxTextBytes: 2048 } })]);
const announcementPayload = JSON.parse(fs.readFileSync(path.join(pluginDir, commands[0].payloadSchemaRef), "utf8"));
expect(announcementPayload).toMatchObject({ required: ["requestText"], properties: { requestText: { type: "string", minLength: 1, maxLength: 2048 } } });
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"]));
@@ -202,6 +205,23 @@ describe("plugin manifest validation", () => {
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
});
it("declares BattlEye login projection, presence deduplication, and plugin-owned welcome messages", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any;
const projection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.battleye.login");
expect(projection).toMatchObject({
streamKeys: ["scum.console.stdout"], correlationFields: ["slot"], maxInterveningLines: 8,
target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", displayName: "displayName", slot: "slot" }, fixedValues: { online: "true", source: "process.stdout" }, observedAtField: "lastLoginObservedAt" },
presence: { timestampField: "lastLoginObservedAt", activeWindowSeconds: 600, activityTarget: { collection: "scum_activity_events", upsertKeys: ["steamId", "observedAt"], captureMappings: { steamId: "steamId", displayName: "displayName" }, fixedValues: { eventType: "login", source: "process.stdout" }, observedAtField: "observedAt" }, announcement: { profileKey: "scum-client-manager", commandType: "announcement.send", textField: "requestText", newTextTemplate: "#announce 欢迎新玩家 {{displayName}} 加入服务器!", returningTextTemplate: "#announce 欢迎 {{displayName}} 继续游戏!" } }
});
expect(projection.steps.map((step: { pattern: string }) => step.pattern)).toEqual([
'Player "(?P<displayName>[^\"]+)" reported as player (?P<slot>\\d+)',
"Player (?P<slot>\\d+) SteamID \\(assumed\\): (?P<steamId>\\d+)"
]);
const compile = (pattern: string) => new RegExp(pattern.replaceAll("(?P<", "(?<"));
expect(compile(projection.steps[0].pattern).exec('LogBattlEye: Display: Player "love_fitting" reported as player 0')?.groups).toMatchObject({ displayName: "love_fitting", slot: "0" });
expect(compile(projection.steps[1].pattern).exec("LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111")?.groups).toMatchObject({ slot: "0", steamId: "76561199510658111" });
});
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any;
@@ -525,7 +545,7 @@ describe("plugin manifest validation", () => {
};
};
const expected = {
"announcement.send": { permission: "server.game-client.command", approvalLevel: "none" },
"announcement.send": { permission: "server.game-client.command", approvalLevel: "operator" },
"companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" },
"player.lookup": { permission: "server.game-client.read", approvalLevel: "none" },
"reward.deliver": { permission: "server.game-client.command", approvalLevel: "none" },
@@ -552,6 +572,7 @@ describe("plugin manifest validation", () => {
const schemaRefs = manifest.gameClientBridge.commands.flatMap((command) => [command.payloadSchemaRef, command.resultSchemaRef].filter((ref): ref is string => Boolean(ref)));
for (const schemaRef of schemaRefs) {
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record<string, unknown>;
const hasPluginInventedCountLimitsRemoved = ["schemas/bridge/reward-deliver.payload.schema.json", "schemas/bridge/event-start.payload.schema.json"].includes(schemaRef);
const visit = (value: unknown): void => {
if (Array.isArray(value)) {
value.forEach(visit);
@@ -565,13 +586,15 @@ describe("plugin manifest validation", () => {
expect(record.additionalProperties).toBe(false);
}
if (record.type === "array") {
expect(record.maxItems).toBeGreaterThan(0);
expect(record.items).toBeDefined();
if (!hasPluginInventedCountLimitsRemoved) expect(record.maxItems).toBeGreaterThan(0);
}
if (record.type === "string") {
expect(record.maxLength).toBeGreaterThan(0);
}
if (record.type === "integer" || record.type === "number") {
expect(record.maximum).toBeDefined();
if (!hasPluginInventedCountLimitsRemoved) expect(record.maximum).toBeDefined();
if (typeof record.minimum === "number" && typeof record.maximum === "number") expect(record.minimum).toBeLessThanOrEqual(record.maximum);
}
Object.values(record).forEach(visit);
};
@@ -634,7 +657,8 @@ describe("plugin manifest validation", () => {
parameterSchemaRef: string;
resultSchemaRef: string;
sqlRef: string;
rowTarget: { collection: string; upsertKeys: string[]; columnMappings: Record<string, string> };
pollIntervalSeconds: number;
rowTarget: { collection: string; upsertKeys: string[]; writeMode: "merge" | "replace"; columnMappings: Record<string, string> };
maxRows: number;
timeoutSeconds: number;
}>;
@@ -655,6 +679,7 @@ describe("plugin manifest validation", () => {
"scum.events": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"],
"scum.native-timed-gifts": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"]
};
const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "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");
@@ -670,6 +695,8 @@ describe("plugin manifest validation", () => {
expect(template.targetKey).toBe("scum-database");
expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/);
expect(template.rowTarget.collection).toMatch(/^scum_/);
expect(template.pollIntervalSeconds).toBe(fastTemplates.has(key) ? 3 : 1800);
expect(template.rowTarget.writeMode).toBe(key === "scum.player.profile" ? "merge" : "replace");
expect(template.rowTarget.upsertKeys.length).toBeGreaterThan(0);
expect(template.rowTarget.upsertKeys.every((upsertKey) => upsertKey in template.rowTarget.columnMappings)).toBe(true);
expect(fs.existsSync(path.join(pluginDir, template.sqlRef))).toBe(true);
@@ -688,6 +715,12 @@ describe("plugin manifest validation", () => {
expect(sql).toMatch(new RegExp(`\\bAS\\s+${column}\\b`, "i"));
}
}
expect(templatesByKey.get("scum.player.profile")?.rowTarget.upsertKeys).toEqual(["steamId"]);
expect(templatesByKey.get("scum.squad-members")?.rowTarget.upsertKeys).toEqual(["squadId", "steamId"]);
const userSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.player.profile")!.sqlRef), "utf8");
const positionSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.positions")!.sqlRef), "utf8");
expect(userSQL).toMatch(/FROM user account\s+LEFT JOIN user_profile profile/i);
expect(positionSQL).toMatch(/account\.id AS subjectId/i);
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");
@@ -884,6 +917,7 @@ describe("plugin manifest validation", () => {
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 }],
logProjections: [{ key: "player.login", streamKeys: ["process.stdout"], steps: [{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" }, { pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }], correlationFields: ["slot"], maxInterveningLines: 16, target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" }, presence: { timestampField: "lastLoginAt", activeWindowSeconds: 600, announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" } } }],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: []
@@ -894,6 +928,40 @@ describe("plugin manifest validation", () => {
expect(validate(manifest)).toBe(false);
});
it("validates ordered log projections and repeated correlation captures", () => {
const projection = {
key: "player.login",
streamKeys: ["process.stdout"],
steps: [
{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" },
{ pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }
],
correlationFields: ["slot"],
maxInterveningLines: 16,
target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" },
presence: {
timestampField: "lastLoginAt",
activeWindowSeconds: 600,
activityTarget: { collection: "activity", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "observedAt" },
announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" }
}
};
const manifest = {
permissions: ["server.game-client.command"],
runtimeProfiles: { clientManagers: [{ key: "scum-client", health: { requiredCapabilities: ["game-client.bridge"] } }] },
gameClientBridge: {
commands: [{ type: "announcement.send", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/announcement.schema.json" }],
snapshots: [],
logProjections: [projection]
}
};
expect(validateGameClientBridgeCatalog(manifest)).toEqual([]);
projection.target.captureMappings.steamId = "missing";
const errors = validateGameClientBridgeCatalog(manifest);
expect(errors.some((error) => error.includes("references undeclared capture missing"))).toBe(true);
});
it("loads and validates every schema referenced by a safe game-client bridge manifest", () => {
expect(validateTemporaryBridgeManifest()).toEqual([]);
});
@@ -1140,12 +1208,25 @@ describe("plugin SDK", () => {
parameterSchemaRef: "schemas/bridge/queries/player-by-id.parameters.schema.json",
resultSchemaRef: "schemas/bridge/queries/player-by-id.result.schema.json",
maxRows: 1,
timeoutSeconds: 10
timeoutSeconds: 10,
pollIntervalSeconds: 0
};
expect(declaration).toMatchObject({ engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", maxRows: 1 });
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
});
it("types plugin-declared ordered log projections", () => {
const declaration: GameClientBridgeLogProjectionDeclaration = {
key: "scum.player.login",
streamKeys: ["process.stdout"],
steps: [{ pattern: "Player (?<slot>\\d+) SteamID: (?<steamId>\\d+)" }],
correlationFields: ["slot"],
maxInterveningLines: 16,
target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "lastLoginAt" }
};
expect(declaration).toMatchObject({ key: "scum.player.login", correlationFields: ["slot"] });
});
it("types controlled operation template declarations", () => {
const declaration: GameClientBridgeOperationTemplateDeclaration = {
key: "player.attribute.855.set",