Keep SCUM data parsing plugin-owned

This commit is contained in:
npc0-hue
2026-09-09 10:13:47 +08:00
parent 4f20fcaf5b
commit 84380105fc
13 changed files with 149 additions and 402 deletions
+12 -11
View File
@@ -147,13 +147,14 @@ describe("plugin manifest validation", () => {
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
});
it("declares SCUM user projection through SQLite", () => {
it("declares SCUM SQLite templates without platform projections", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
gameClientBridge: { queryTemplates: Array<{ key: string; engine: string; transportKey: string; targetKey: string; projections?: Array<{ collection: string; fixedValues?: Record<string, string> }> }> };
gameClientBridge: { lifecycleProjections?: unknown[]; queryTemplates: Array<{ key: string; engine: string; transportKey: string; targetKey: string; projections?: unknown[] }> };
};
const users = manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.player.profile");
expect(users).toMatchObject({ engine: "sqlite", transportKey: "scum-database", targetKey: "scum-database" });
expect(users?.projections).toEqual(expect.arrayContaining([expect.objectContaining({ collection: "scum_users", fixedValues: { source: "run.sqlite.scum.player.profile" } })]));
expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined();
for (const template of manifest.gameClientBridge.queryTemplates) expect(template.projections).toBeUndefined();
});
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
@@ -246,7 +247,8 @@ describe("plugin manifest validation", () => {
maxPayloadBytes: number;
}>;
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
queryTemplates: Array<{ key: string; projections?: Array<{ collection?: string; fixedValues?: Record<string, string>; mergeExisting?: boolean }> }>;
queryTemplates: Array<{ key: string; projections?: unknown[] }>;
lifecycleProjections?: unknown[];
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }>;
};
pages: Array<{ key: string; permissions?: string[] }>;
@@ -277,9 +279,8 @@ describe("plugin manifest validation", () => {
]));
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: "其他载具" }) })
]));
expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined();
for (const template of manifest.gameClientBridge.queryTemplates) expect(template.projections).toBeUndefined();
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 === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"]));
@@ -423,7 +424,7 @@ describe("plugin manifest validation", () => {
pollIntervalSeconds: number;
maxRows: number;
timeoutSeconds: number;
projections?: Array<{ collection: string; rowPath: string; upsertKeys: string[]; observedAtField?: string; mergeExisting?: boolean }>;
projections?: unknown[];
}>;
pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>;
};
@@ -432,10 +433,10 @@ describe("plugin manifest validation", () => {
};
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"];
const expectedColumnsByKey: Record<string, string[]> = {
"scum.player.profile": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"],
"scum.player.profile": ["userProfileId", "steamId", "gamePlayerId", "displayName", "lastLoginIp", "registeredAt", "squadId", "squadName", "famePoints", "moneyBalance", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastLogoutTime", "lastSaveTime"],
"scum.squads": ["squadId", "name", "leaderProfileId", "leaderPlayerId", "memberCount", "score", "memberLimit", "message", "info", "lastMemberLoginTime"],
"scum.squad-members": ["squadId", "userProfileId", "gamePlayerId", "steamId", "displayName", "rank", "isLeader"],
"scum.vehicles": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional"],
"scum.vehicles": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional", "existsInGame", "mountedPrisonerIds", "mountedUserProfileIds", "mountedSteamIds"],
"scum.flags": ["flagId", "entityId", "baseId", "ownerProfileId", "ownerPlayerId", "ownerSquadId", "ownerSquadName", "overtakerProfileId", "overtakeEndTime", "ownershipConfidence", "x", "y", "z"],
"scum.positions": ["subjectType", "subjectId", "userProfileId", "gamePlayerId", "vehicleId", "entityId", "baseId", "x", "y", "z", "observedAt"],
"scum.tasks": ["taskRecordId", "taskKind", "userProfileId", "mapId", "trackingDataSetId", "dataAssetPath", "sequenceIndex", "isTracked", "state", "completionDeadline"],
@@ -445,7 +446,7 @@ describe("plugin manifest validation", () => {
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(templatesByKey.get("scum.player.profile")?.projections).toEqual([expect.objectContaining({ collection: "scum_users", rowPath: "rows", upsertKeys: ["steamId"], observedAtField: "profileSampledAt", mergeExisting: true })]);
expect([...templatesByKey.values()].some((template) => Boolean(template.projections))).toBe(false);
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute");
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
+16 -22
View File
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { buildPlayerAttributeMutation, createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, playerAttributeDrafts, playerAttributeSqlPreview, projectSCUMLoginLogs, queueGiftDelivery, queuePlayerAttributePatch, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js";
import { buildPlayerAttributeMutation, createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, playerAttributeDrafts, playerAttributeSqlPreview, queueGiftDelivery, queuePlayerAttributePatch, queueSCUMDatabaseRefresh, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js";
import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
@@ -103,25 +103,17 @@ describe("SCUM plugin feature module", () => {
expect(dataClientSource).not.toContain("scum-client-manager");
});
it("projects SCUM login log lines inside the plugin-owned page module", async () => {
const pluginData = pluginDataActions({ list: async (collection, key) => {
if (collection === scumCollections.players) return { items: [{ key: "76561198000000001", value: { steamId: "76561198000000001", displayName: "Old", normalBalance: 100 } }] };
if (collection === scumCollections.logCursors && key) return { items: [{ key, value: { nextSeq: 1 } }] };
return { items: [], count: 0 };
} });
const logs = {
listStreams: vi.fn(async () => ({ items: [{ id: "stream-login", streamKey: "scum.login", latestSeq: 3 }], count: 1 })),
query: vi.fn(async () => ({ logStreamId: "stream-login", entries: [
{ seq: 2, timestamp: "2026-08-10T00:01:00Z", line: "Login: Player 'Mira' SteamID 76561198000000001 IP 203.0.113.7" },
{ seq: 3, timestamp: "2026-08-10T00:04:00Z", line: "Logout: Player 'Mira' SteamID 76561198000000001 IP 203.0.113.7" }
], nextSeq: 3, latestSeq: 3 }))
};
await expect(projectSCUMLoginLogs({ pluginData, logs })).resolves.toBe(2);
expect(logs.query).toHaveBeenCalledWith({ logStreamId: "stream-login", afterSeq: 1, limit: 200 });
expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.players, [expect.objectContaining({ key: "76561198000000001", value: expect.objectContaining({ displayName: "Mira", online: false, status: "offline", normalBalance: 100, lastLoginIp: "203.0.113.7", lastLogoutObservedAt: "2026-08-10T00:04:00Z", source: "plugin.log.scum.login" }) })]);
expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.activityEvents, expect.arrayContaining([expect.objectContaining({ key: "stream-login:2", value: expect.objectContaining({ eventType: "login", rawLine: expect.stringContaining("Login:") }) }), expect.objectContaining({ key: "stream-login:3", value: expect.objectContaining({ eventType: "logout", rawLine: expect.stringContaining("Logout:") }) })]));
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.logCursors, "scum.login:stream-login", expect.objectContaining({ nextSeq: 3 }));
expect(dataClientSource).toContain("projectSCUMLoginLogs");
it("queues SCUM database reads through declared template keys instead of log parsing", async () => {
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
await expect(queueSCUMDatabaseRefresh({ dispatch }, "players", 5000)).resolves.toHaveLength(2);
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload?.["input.templateKey"])).toEqual(["scum.player.profile", "scum.positions"]);
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload)).toEqual(expect.arrayContaining([
expect.objectContaining({ capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database", "input.templateKey": "scum.player.profile", "input.activeWithinSeconds": "600" }),
expect.objectContaining({ capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database", "input.templateKey": "scum.positions", "input.activeWithinSeconds": "600" })
]));
expect(dispatch.mock.calls[0]?.[0].payload).not.toHaveProperty("input.sqlText");
expect(dataClientSource).not.toContain("projectSCUMLoginLogs");
expect(dataClientSource).not.toContain("logs.query");
});
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
@@ -312,9 +304,11 @@ describe("SCUM plugin feature module", () => {
for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden);
expect(source).toContain("pluginData");
expect(source).toContain("remote.access.request");
expect(source).not.toContain("input.templateKey");
expect(source).toContain("input.templateKey");
expect(source).not.toContain("projectSCUMLoginLogs");
expect(source).not.toContain("logs.query");
expect(source).not.toContain("requestSCUMPageQueries");
expect(pageSource).toContain("setInterval(refresh, 3000)");
expect(pageSource).toContain("setInterval(refresh, 5000)");
expect(pageSource).toContain("clearInterval(interval)");
});
});