Rebuild SCUM plugin-owned data flow
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user