diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 23a2183..320f664 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -8,13 +8,13 @@ export type PluginDataActions = { transact: (collection: string, mutations: PluginDataMutation[]) => Promise; }; -export type LogActions = { - listStreams: () => Promise; - query: (request: { logStreamId: string; afterSeq: number; limit: number }) => Promise; +export type GameClientActions = { + snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise; }; export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record }; export type PluginBridgeExecutionResult = { status?: string; result?: Record; error?: { message?: string } }; +export type SCUMQueryTemplateKey = "scum.player.profile" | "scum.squads" | "scum.squad-members" | "scum.vehicles" | "scum.flags" | "scum.positions" | "scum.tasks" | "scum.events" | "scum.native-timed-gifts"; export const playerAttributeCatalog = [ { key: "stamina", label: "体力", column: "stamina", sourceKeys: ["attributes.stamina", "stamina", "体力"] }, @@ -78,7 +78,7 @@ export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, p export type SCUMWorkspaceActions = { pluginData?: PluginDataActions; - logs?: LogActions; + gameClient?: GameClientActions; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise; }; @@ -134,8 +134,7 @@ export const scumCollections = { mapSettings: "scum_map_settings", vehicles: "scum_vehicles", flags: "scum_flags", - trajectories: "scum_trajectories", - logCursors: "scum_log_cursors" + trajectories: "scum_trajectories" } as const; type SurfaceKey = keyof SCUMSurfaceData; @@ -149,56 +148,94 @@ const pageCollections: Record = { workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"] }; +const pageQueryTemplates: Record = { + players: ["scum.player.profile", "scum.positions"], + squads: ["scum.squads", "scum.squad-members", "scum.flags"], + "live-map": ["scum.player.profile", "scum.vehicles", "scum.flags", "scum.positions"], + gifts: ["scum.player.profile", "scum.native-timed-gifts"], + workflows: ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"] +}; + +const queryTemplatePollSeconds: Record = { + "scum.player.profile": 5, + "scum.positions": 5, + "scum.vehicles": 5, + "scum.flags": 60, + "scum.squads": 1800, + "scum.squad-members": 1800, + "scum.tasks": 1800, + "scum.events": 1800, + "scum.native-timed-gifts": 60 +}; + +const queryTemplateMaxRows: Record = { + "scum.player.profile": 500, + "scum.positions": 500, + "scum.vehicles": 500, + "scum.flags": 500, + "scum.squads": 500, + "scum.squad-members": 500, + "scum.tasks": 500, + "scum.events": 500, + "scum.native-timed-gifts": 500 +}; + +const queuedQueryBuckets = new Set(); + export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise { if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); const canonical = canonicalPageKey(pageKey); - if (pageCollections[canonical].includes("players")) await projectSCUMLoginLogs(actions).catch(() => undefined); + await queueSCUMDatabaseRefresh(actions, canonical).catch(() => undefined); const data: SCUMSurfaceData = { ...emptySCUMSurfaceData }; const keys = pageCollections[canonical]; const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const)); for (const [key, response] of records) data[key] = collectionRecords(response); + await mergeSCUMSnapshots(actions, data, keys).catch(() => undefined); return data; } -export async function projectSCUMLoginLogs(actions: SCUMWorkspaceActions, limitPerStream = 200): Promise { - if (!actions.pluginData || !actions.logs) return 0; - const streamResponse = await actions.logs.listStreams(); - const streams = logStreams(streamResponse).filter((stream) => textValue(stream.streamKey) === "scum.login" && textValue(stream.id)); - if (!streams.length) return 0; - const existingPlayers = collectionRecords(await actions.pluginData.list(scumCollections.players)); - const playerByIdentity = playerIndex(existingPlayers); - const playerMutations = new Map(); - const activityMutations: PluginDataMutation[] = []; - let projected = 0; - for (const stream of streams) { - const streamId = textValue(stream.id); - const cursorKey = `scum.login:${streamId}`; - const cursor = firstCollectionRecord(await actions.pluginData.list(scumCollections.logCursors, cursorKey)); - const afterSeq = Math.max(0, numberValue(cursor?.nextSeq ?? cursor?.lastSeq)); - const response = await actions.logs.query({ logStreamId: streamId, afterSeq, limit: limitPerStream }); - const entries = logEntries(response); - let lastSeq = afterSeq; - for (const entry of entries) { - const seq = numberValue(entry.seq); - if (seq > lastSeq) lastSeq = seq; - const event = parseSCUMLoginLogEntry(entry, streamId); - if (!event) continue; - const match = findPlayer(playerByIdentity, event); - const playerKeyValue = match ? textValue(existingPlayers[match.index]._recordKey) || scumLogPlayerKey(event) : scumLogPlayerKey(event); - const existing = match ? existingPlayers[match.index] : {}; - const player = scumLoginPlayerRecord(existing, event); - playerMutations.set(playerKeyValue, { operation: "put", key: playerKeyValue, value: player }); - if (match) existingPlayers[match.index] = { ...player, _recordKey: playerKeyValue }; - else { existingPlayers.push({ ...player, _recordKey: playerKeyValue }); addPlayerToIndex(playerByIdentity, player, existingPlayers.length - 1); } - activityMutations.push({ operation: "put", key: `${streamId}:${seq || stableTextHash(textValue(event.rawLine))}`, value: scumLoginActivityRecord(event) }); - projected++; - } - const nextSeq = Math.max(lastSeq, numberValue((response as RecordMap).nextSeq), numberValue((response as RecordMap).latestSeq)); - if (nextSeq > afterSeq) await actions.pluginData.put(scumCollections.logCursors, cursorKey, { streamId, streamKey: "scum.login", nextSeq, updatedAt: new Date().toISOString() }); - } - if (playerMutations.size) await actions.pluginData.transact(scumCollections.players, [...playerMutations.values()]); - if (activityMutations.length) await actions.pluginData.transact(scumCollections.activityEvents, activityMutations); - return projected; +export async function queueSCUMDatabaseRefresh(actions: SCUMWorkspaceActions, pageKey: string, now = Date.now()): Promise { + if (!actions.dispatch) return []; + const canonical = canonicalPageKey(pageKey); + const templates = [...new Set(pageQueryTemplates[canonical])]; + const requests = templates.flatMap((templateKey) => { + const bucketKey = scumQueryBucketKey(templateKey, now); + if (queuedQueryBuckets.has(bucketKey)) return []; + queuedQueryBuckets.add(bucketKey); + return [actions.dispatch!({ requestId: scumQueryRequestId(canonical, templateKey, now), action: "remote.access.request", payload: scumQueryPayload(templateKey, now) })]; + }); + const settled = await Promise.allSettled(requests); + return settled.flatMap((item) => item.status === "fulfilled" ? [item.value] : []); +} + +function scumQueryPayload(templateKey: SCUMQueryTemplateKey, now: number): Record { + const maxRows = String(queryTemplateMaxRows[templateKey]); + const payload: Record = { + capability: "remote.run.db.sqlite.query", + declarationKey: "scum-database", + targetKey: "scum-database", + idempotencyKey: scumQueryIdempotencyKey(templateKey, now), + timeoutSeconds: "15", + maxAttempts: "1", + "input.templateKey": templateKey, + "input.limit": maxRows, + "input.maxRows": maxRows + }; + if (templateKey === "scum.player.profile" || templateKey === "scum.positions" || templateKey === "scum.vehicles") payload["input.activeWithinSeconds"] = "600"; + return payload; +} + +function scumQueryRequestId(pageKey: PageKey, templateKey: SCUMQueryTemplateKey, now: number): string { return `scum-query:${pageKey}:${templateKey}:${queryBucket(templateKey, now)}`; } +function scumQueryIdempotencyKey(templateKey: SCUMQueryTemplateKey, now: number): string { return `scum-query:${templateKey}:${queryBucket(templateKey, now)}`; } +function scumQueryBucketKey(templateKey: SCUMQueryTemplateKey, now: number): string { return `${templateKey}:${queryBucket(templateKey, now)}`; } +function queryBucket(templateKey: SCUMQueryTemplateKey, now: number): number { return Math.floor(now / (queryTemplatePollSeconds[templateKey] * 1000)); } + +async function mergeSCUMSnapshots(actions: SCUMWorkspaceActions, data: SCUMSurfaceData, keys: SurfaceKey[]): Promise { + if (!actions.gameClient) return; + const reads: Array> = []; + if (keys.includes("players") && data.players.length === 0) reads.push(actions.gameClient.snapshots({ profileKey: "plugin-owned", type: "players", streamKey: "current", limit: 1 }).then((response) => { data.players = mergePlayerSnapshots(data.players, response); })); + if (keys.includes("vehicles") && data.vehicles.length === 0) reads.push(actions.gameClient.snapshots({ profileKey: "plugin-owned", type: "vehicles", streamKey: "current", limit: 1 }).then((response) => { data.vehicles = mergeVehicleSnapshots(data.vehicles, response); })); + await Promise.all(reads); } export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] { @@ -372,69 +409,6 @@ function collectionRecords(response: unknown): RecordMap[] { }); } -function firstCollectionRecord(response: unknown): RecordMap | undefined { return collectionRecords(response)[0]; } - -function logStreams(response: unknown): RecordMap[] { return isRecord(response) && Array.isArray(response.items) ? response.items.filter(isRecord) : []; } -function logEntries(response: unknown): RecordMap[] { return isRecord(response) && Array.isArray(response.entries) ? response.entries.filter(isRecord) : []; } - -function parseSCUMLoginLogEntry(entry: RecordMap, streamId: string): RecordMap | null { - const line = textValue(entry.line); - if (!line.trim()) return null; - const fields = isRecord(entry.fields) ? entry.fields : {}; - const explicitType = firstText(fields, "eventType", "type", "action").toLowerCase(); - const lowered = line.toLowerCase(); - const logout = /\b(logged\s*out|logout|disconnected|left)\b/.test(explicitType) || /(?:\blogged\s*out\b|\blogout\s*:|\bdisconnected\b|\bleft\s+the\s+server\b)/i.test(line); - const login = /\b(logged\s*in|login|connected|joined)\b/.test(explicitType) || /(?:\blogged\s*in\b|\blogin\s*:|\bconnected\b|\bjoined\s+the\s+server\b)/i.test(line); - if (!login && !logout) return null; - const steamId = firstText(fields, "steamId", "steamID", "userId", "userID", "playerId", "gamePlayerId") || firstRegex(line, /\b\d{17}\b/); - const displayName = firstText(fields, "displayName", "playerName", "name", "characterName") || extractSCUMPlayerName(line, steamId); - const ip = firstText(fields, "lastLoginIp", "loginIp", "ipAddress", "ip") || firstRegex(line, /\b(?:\d{1,3}\.){3}\d{1,3}\b/); - if (!steamId && !displayName && !ip) return null; - const occurredAt = textValue(entry.timestamp) || new Date().toISOString(); - return { eventType: logout ? "logout" : "login", steamId, gamePlayerId: steamId, displayName, lastLoginIp: ip, loginIp: ip, occurredAt, observedAt: occurredAt, rawLine: line, streamId, seq: numberValue(entry.seq), source: "plugin.log.scum.login" }; -} - -function scumLoginPlayerRecord(existing: RecordMap, event: RecordMap): RecordMap { - const occurredAt = textValue(event.occurredAt); - const login = textValue(event.eventType) === "login"; - const identity = { steamId: textValue(event.steamId), gamePlayerId: textValue(event.gamePlayerId), displayName: textValue(event.displayName) }; - const network = textValue(event.lastLoginIp) ? { lastLoginIp: textValue(event.lastLoginIp), loginIp: textValue(event.loginIp) } : {}; - const status = login - ? { online: true, status: "online", lastSeenAt: occurredAt, lastLoginAt: occurredAt, lastLoginObservedAt: occurredAt, lastLoginRawLine: textValue(event.rawLine) } - : { online: false, status: "offline", lastSeenAt: occurredAt, lastLogoutAt: occurredAt, lastLogoutObservedAt: occurredAt, lastLogoutRawLine: textValue(event.rawLine) }; - return { ...existing, ...withoutEmpty(identity), ...network, ...status, source: "plugin.log.scum.login", updatedAt: occurredAt }; -} - -function scumLoginActivityRecord(event: RecordMap): RecordMap { - return withoutEmpty({ id: `${textValue(event.streamId)}:${numberValue(event.seq) || stableTextHash(textValue(event.rawLine))}`, eventType: textValue(event.eventType), type: textValue(event.eventType), steamId: textValue(event.steamId), gamePlayerId: textValue(event.gamePlayerId), displayName: textValue(event.displayName), lastLoginIp: textValue(event.lastLoginIp), loginIp: textValue(event.loginIp), occurredAt: textValue(event.occurredAt), observedAt: textValue(event.observedAt), rawLine: textValue(event.rawLine), streamId: textValue(event.streamId), seq: numberValue(event.seq), source: "plugin.log.scum.login" }); -} - -function scumLogPlayerKey(event: RecordMap): string { - const identity = firstText(event, "steamId", "gamePlayerId"); - if (identity) return identity; - const name = textValue(event.displayName).trim().toLowerCase(); - if (name) return `name:${safeCommandId(name)}`; - return `log:${stableTextHash(textValue(event.rawLine))}`; -} - -function extractSCUMPlayerName(line: string, steamId: string): string { - for (const pattern of [/\b(?:player|user|name|character)\s*[:=]\s*["']?([^"'(),;\[\]]{2,80})/i, /\b(?:player|user|character)\s+["']([^"']{2,80})["']/i, /["']([^"']{2,80})["']\s*(?:\(|\[|SteamID\s*)?\d{17}/i, /\d{17}\s*(?:\)|\])?\s*["']([^"']{2,80})["']/]) { - const match = pattern.exec(line); - if (match?.[1]) return match[1].trim(); - } - if (steamId) { - const index = line.indexOf(steamId); - const before = index > 0 ? line.slice(0, index).replace(/.*\b(?:player|user|character)\b\s*/i, "").replace(/[\[(,:;-]+$/g, "").trim() : ""; - if (before && before.length <= 80) return before; - } - return ""; -} - -function firstRegex(value: string, pattern: RegExp): string { return pattern.exec(value)?.[0] ?? ""; } -function numberValue(value: unknown): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } -function withoutEmpty(value: RecordMap): RecordMap { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== "" && item !== undefined && item !== null)); } -function stableTextHash(value: string): string { let hash = 2166136261; for (let index = 0; index < value.length; index++) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(16); } - function latestSnapshotPayload(response: unknown): RecordMap | undefined { if (!isRecord(response) || !Array.isArray(response.items)) return undefined; const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left)); diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index cdae665..141888f 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -141,14 +141,14 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) if (react.useEffect) react.useEffect(() => { if (playerPanel.kind === "closed") refresh(); if (playerPanel.kind !== "closed") return; - const interval = setInterval(refresh, 3000); + const interval = setInterval(refresh, 5000); return () => clearInterval(interval); }, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]); const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, - state.status === "loading" ? e("p", { className: "page-status" }, "正在读取插件自有 SCUM 集合…") : null, + state.status === "loading" ? e("p", { className: "page-status" }, "正在派发 SCUM 数据库模板查询并读取插件自有集合…") : null, state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null, state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index d596d8f..58977b5 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -239,21 +239,7 @@ "sqlRef": "sql/scum-db-v57/users.sql", "pollIntervalSeconds": 3, "maxRows": 500, - "timeoutSeconds": 15, - "projections": [ - { - "collection": "scum_users", - "rowPath": "rows", - "upsertKeys": [ - "steamId" - ], - "fixedValues": { - "source": "run.sqlite.scum.player.profile" - }, - "observedAtField": "profileSampledAt", - "mergeExisting": true - } - ] + "timeoutSeconds": 15 }, { "key": "scum.squads", @@ -295,66 +281,7 @@ "sqlRef": "sql/scum-db-v57/vehicles.sql", "pollIntervalSeconds": 3, "maxRows": 500, - "timeoutSeconds": 15, - "projections": [ - { - "collection": "scum_vehicles", - "rowPath": "rows", - "upsertKeys": [ - "vehicleId" - ], - "fixedValues": { - "source": "run.sqlite.scum.vehicles" - }, - "observedAtField": "sampledAt" - }, - { - "collection": "scum_trajectories", - "rowPath": "rows", - "upsertKeys": [ - "subjectType", - "subjectId", - "sampledAt" - ], - "fieldMappings": { - "subjectId": "vehicleId", - "vehicleId": "vehicleId", - "entityId": "entityId", - "className": "className", - "label": "label", - "x": "x", - "y": "y", - "z": "z", - "lastAccessTime": "lastAccessTime" - }, - "fixedValues": { - "subjectType": "vehicle", - "source": "run.sqlite.scum.vehicles" - }, - "observedAtField": "sampledAt" - }, - { - "collection": "scum_trade_goods", - "rowPath": "rows", - "upsertKeys": [ - "code" - ], - "fieldMappings": { - "className": "className" - }, - "fixedValues": { - "code": "#spawnvehicle {{className}}", - "spawnCommand": "#spawnvehicle {{className}}", - "catalogType": "vehicle", - "type": "21", - "typeName": "其他载具", - "imagePath": "/original/{{className}}.webp", - "source": "run.sqlite.scum.vehicles" - }, - "observedAtField": "lastSeenAt", - "mergeExisting": true - } - ] + "timeoutSeconds": 15 }, { "key": "scum.flags", @@ -382,69 +309,7 @@ "sqlRef": "sql/scum-db-v57/map-points.sql", "pollIntervalSeconds": 3, "maxRows": 500, - "timeoutSeconds": 15, - "projections": [ - { - "collection": "scum_users", - "rowPath": "rows", - "matchField": "subjectType", - "matchValue": "player", - "upsertKeys": [ - "steamId" - ], - "fieldMappings": { - "steamId": "subjectId", - "userProfileId": "userProfileId", - "gamePlayerId": "gamePlayerId", - "x": "x", - "y": "y", - "z": "z", - "lastPositionObservedAt": "observedAt" - }, - "fixedValues": { - "source": "run.sqlite.scum.positions" - }, - "observedAtField": "positionSampledAt" - }, - { - "collection": "scum_map_points", - "rowPath": "rows", - "upsertKeys": [ - "subjectType", - "subjectId" - ], - "fixedValues": { - "source": "run.sqlite.scum.positions" - }, - "observedAtField": "sampledAt" - }, - { - "collection": "scum_trajectories", - "rowPath": "rows", - "matchField": "subjectType", - "matchValue": "player", - "upsertKeys": [ - "subjectType", - "subjectId", - "sampledAt" - ], - "fieldMappings": { - "subjectType": "subjectType", - "subjectId": "subjectId", - "steamId": "subjectId", - "userProfileId": "userProfileId", - "gamePlayerId": "gamePlayerId", - "x": "x", - "y": "y", - "z": "z", - "observedAt": "observedAt" - }, - "fixedValues": { - "source": "run.sqlite.scum.positions" - }, - "observedAtField": "sampledAt" - } - ] + "timeoutSeconds": 15 }, { "key": "scum.tasks", @@ -489,121 +354,6 @@ "timeoutSeconds": 15 } ], - "lifecycleProjections": [ - { - "key": "scum.lifecycle.stop-logout", - "capabilities": [ - "process.stop" - ], - "target": { - "collection": "scum_users", - "matchField": "online", - "matchValue": "true", - "fixedValues": { - "online": "false", - "status": "offline", - "logoutReason": "server-stop", - "source": "run.lifecycle" - }, - "observedAtField": "lastLogoutObservedAt", - "activityTarget": { - "collection": "scum_activity_events", - "upsertKeys": [ - "steamId", - "observedAt", - "eventType" - ], - "rowMappings": { - "steamId": "steamId", - "displayName": "displayName" - }, - "fixedValues": { - "eventType": "logout", - "reason": "server-stop", - "source": "run.lifecycle" - }, - "observedAtField": "observedAt" - } - } - }, - { - "key": "scum.lifecycle.restart-logout", - "capabilities": [ - "process.restart" - ], - "target": { - "collection": "scum_users", - "matchField": "online", - "matchValue": "true", - "fixedValues": { - "online": "false", - "status": "offline", - "logoutReason": "server-stop", - "source": "run.lifecycle" - }, - "observedAtField": "lastLogoutObservedAt", - "activityTarget": { - "collection": "scum_activity_events", - "upsertKeys": [ - "steamId", - "observedAt", - "eventType" - ], - "rowMappings": { - "steamId": "steamId", - "displayName": "displayName" - }, - "fixedValues": { - "eventType": "logout", - "reason": "server-stop", - "source": "run.lifecycle" - }, - "observedAtField": "observedAt" - } - } - }, - { - "key": "scum.lifecycle.status-logout", - "capabilities": [ - "process.status" - ], - "processStates": [ - "stopped", - "not-started", - "exited" - ], - "target": { - "collection": "scum_users", - "matchField": "online", - "matchValue": "true", - "fixedValues": { - "online": "false", - "status": "offline", - "logoutReason": "server-stop", - "source": "run.lifecycle" - }, - "observedAtField": "lastLogoutObservedAt", - "activityTarget": { - "collection": "scum_activity_events", - "upsertKeys": [ - "steamId", - "observedAt", - "eventType" - ], - "rowMappings": { - "steamId": "steamId", - "displayName": "displayName" - }, - "fixedValues": { - "eventType": "logout", - "reason": "server-stop", - "source": "run.lifecycle" - }, - "observedAtField": "observedAt" - } - } - } - ], "dataPacks": [ { "key": "scum-db-v57", @@ -1068,7 +818,6 @@ "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "permissions": [ "server.read", - "server.logs.read", "server.remote.access", "server.game-client.read", "server.game-client.command", diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.parameters.schema.json index 7b6f247..3fa43d5 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.parameters.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.parameters.schema.json @@ -8,6 +8,7 @@ "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "steamId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "search": { "type": "string", "minLength": 1, "maxLength": 80 }, + "activeWithinSeconds": { "type": "integer", "minimum": 1, "maximum": 86400 }, "limit": { "type": "integer", "minimum": 1, "maximum": 500 } } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json index bf36882..130f8f8 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json @@ -11,21 +11,25 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"], + "required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "lastLoginIp", "registeredAt", "squadId", "squadName", "famePoints", "moneyBalance", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastLogoutTime", "lastSaveTime"], "properties": { "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, + "lastLoginIp": { "type": ["string", "null"], "maxLength": 96 }, + "registeredAt": { "type": ["string", "null"], "maxLength": 120 }, "squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "squadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 }, "famePoints": { "type": ["number", "null"] }, + "moneyBalance": { "type": ["number", "null"] }, "normalBalance": { "type": ["number", "null"] }, "goldBalance": { "type": ["number", "null"] }, "x": { "type": ["number", "null"] }, "y": { "type": ["number", "null"] }, "z": { "type": ["number", "null"] }, "lastLoginTime": { "type": ["string", "null"], "maxLength": 120 }, + "lastLogoutTime": { "type": ["string", "null"], "maxLength": 120 }, "lastSaveTime": { "type": ["string", "null"], "format": "date-time" } } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json index 35bc0a2..87ca665 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json @@ -6,6 +6,7 @@ "properties": { "subjectType": { "enum": ["player", "vehicle", "base", "flag"] }, "subjectId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "activeWithinSeconds": { "type": "integer", "minimum": 1, "maximum": 86400 }, "limit": { "type": "integer", "minimum": 1, "maximum": 500 } } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.parameters.schema.json index b9f7adb..9a9b0f6 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.parameters.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.parameters.schema.json @@ -7,6 +7,8 @@ "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, "ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "search": { "type": "string", "minLength": 1, "maxLength": 80 }, + "activeWithinSeconds": { "type": "integer", "minimum": 1, "maximum": 86400 }, "limit": { "type": "integer", "minimum": 1, "maximum": 500 } } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json index b2ef1d5..9b55f68 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json @@ -11,7 +11,7 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional"], + "required": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional", "existsInGame", "mountedPrisonerIds", "mountedUserProfileIds", "mountedSteamIds"], "properties": { "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, "entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, @@ -19,6 +19,10 @@ "label": { "type": "string", "maxLength": 120 }, "lastAccessTime": { "type": ["string", "null"], "format": "date-time" }, "isFunctional": { "type": "integer", "minimum": 0, "maximum": 1 }, + "existsInGame": { "type": "integer", "minimum": 0, "maximum": 1 }, + "mountedPrisonerIds": { "type": ["string", "null"], "maxLength": 2048 }, + "mountedUserProfileIds": { "type": ["string", "null"], "maxLength": 2048 }, + "mountedSteamIds": { "type": ["string", "null"], "maxLength": 4096 }, "x": { "type": "number" }, "y": { "type": "number" }, "z": { "type": "number" } diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql index ba6da71..22575ba 100644 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql @@ -3,7 +3,7 @@ SELECT account.id AS subjectId, CAST(profile.id AS TEXT) AS userProfileId, CAST(prisoner.id AS TEXT) AS gamePlayerId, - NULL AS vehicleId, + CAST(mount.vehicle_entity_id AS TEXT) AS vehicleId, CAST(entity.id AS TEXT) AS entityId, NULL AS baseId, entity.location_x AS x, @@ -15,8 +15,10 @@ JOIN user account ON account.id = profile.user_id JOIN prisoner ON prisoner.id = profile.prisoner_id JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id JOIN entity ON entity.id = prisoner_entity.entity_id +LEFT JOIN prisoner_vehicle_mountee_info mount ON mount.prisoner_id = prisoner.id WHERE (:subjectType IS NULL OR :subjectType = 'player') AND (:subjectId IS NULL OR account.id = :subjectId) + AND (:activeWithinSeconds IS NULL OR profile.last_logout_time IS NULL OR profile.last_login_time > profile.last_logout_time OR prisoner.last_save_time >= CAST(strftime('%s', 'now') AS INTEGER) - :activeWithinSeconds OR profile.last_login_time >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', printf('-%d seconds', :activeWithinSeconds))) UNION ALL SELECT 'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL, @@ -27,6 +29,7 @@ FROM vehicle_spawner spawner JOIN entity ON entity.id = spawner.vehicle_entity_id WHERE (:subjectType IS NULL OR :subjectType = 'vehicle') AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId) + AND (:activeWithinSeconds IS NULL OR spawner.vehicle_last_access_time >= CAST(strftime('%s', 'now') AS INTEGER) - :activeWithinSeconds) UNION ALL SELECT 'base', CAST(base.id AS TEXT), CAST(base.owner_user_profile_id AS TEXT), NULL, diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql index 43b9c91..2f3acb0 100644 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql @@ -3,15 +3,19 @@ SELECT account.id AS steamId, CAST(prisoner.id AS TEXT) AS gamePlayerId, COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) AS displayName, + account.last_direct_connection_address AS lastLoginIp, + account.creation_time AS registeredAt, CAST(member.squad_id AS TEXT) AS squadId, squad.name AS squadName, profile.fame_points AS famePoints, + profile.money_balance AS moneyBalance, MAX(CASE WHEN currency.currency_type = 1 THEN currency.account_balance END) AS normalBalance, MAX(CASE WHEN currency.currency_type = 2 THEN currency.account_balance END) AS goldBalance, entity.location_x AS x, entity.location_y AS y, entity.location_z AS z, profile.last_login_time AS lastLoginTime, + profile.last_logout_time AS lastLogoutTime, strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS lastSaveTime FROM user account LEFT JOIN user_profile profile ON profile.user_id = account.id @@ -25,6 +29,7 @@ LEFT JOIN bank_account_registry_currencies currency ON currency.bank_account_id WHERE (:userProfileId IS NULL OR CAST(profile.id AS TEXT) = :userProfileId) AND (:steamId IS NULL OR account.id = :steamId) AND (:search IS NULL OR COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) LIKE '%' || :search || '%') + AND (:activeWithinSeconds IS NULL OR profile.last_logout_time IS NULL OR profile.last_login_time > profile.last_logout_time OR profile.last_login_time >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', printf('-%d seconds', :activeWithinSeconds)) OR prisoner.last_save_time >= CAST(strftime('%s', 'now') AS INTEGER) - :activeWithinSeconds) GROUP BY account.id ORDER BY profile.last_login_time DESC LIMIT COALESCE(:limit, 500) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql index 3a7fedf..6e53262 100644 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql @@ -7,10 +7,19 @@ SELECT entity.location_y AS y, entity.location_z AS z, strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime, - spawner.is_vehicle_functional AS isFunctional + spawner.is_vehicle_functional AS isFunctional, + 1 AS existsInGame, + group_concat(DISTINCT CAST(mount.prisoner_id AS TEXT)) AS mountedPrisonerIds, + group_concat(DISTINCT CAST(mounted_profile.id AS TEXT)) AS mountedUserProfileIds, + group_concat(DISTINCT mounted_profile.user_id) AS mountedSteamIds FROM vehicle_spawner spawner JOIN entity ON entity.id = spawner.vehicle_entity_id +LEFT JOIN prisoner_vehicle_mountee_info mount ON mount.vehicle_entity_id = spawner.vehicle_entity_id +LEFT JOIN prisoner mounted_prisoner ON mounted_prisoner.id = mount.prisoner_id +LEFT JOIN user_profile mounted_profile ON mounted_profile.prisoner_id = mounted_prisoner.id WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId) AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%') + AND (:activeWithinSeconds IS NULL OR spawner.vehicle_last_access_time >= CAST(strftime('%s', 'now') AS INTEGER) - :activeWithinSeconds) +GROUP BY spawner.vehicle_entity_id ORDER BY spawner.vehicle_last_access_time DESC LIMIT COALESCE(:limit, 500) diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 30a2c19..4be6f77 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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 }> }> }; + 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; 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 = { - "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"); diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 085e0b4..1834c2d 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -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>(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)"); }); });