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
@@ -8,13 +8,13 @@ export type PluginDataActions = {
transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>; transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>;
}; };
export type LogActions = { export type GameClientActions = {
listStreams: () => Promise<unknown>; snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
query: (request: { logStreamId: string; afterSeq: number; limit: number }) => Promise<unknown>;
}; };
export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> }; export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> };
export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; error?: { message?: string } }; export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; 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 = [ export const playerAttributeCatalog = [
{ key: "stamina", label: "体力", column: "stamina", sourceKeys: ["attributes.stamina", "stamina", "体力"] }, { key: "stamina", label: "体力", column: "stamina", sourceKeys: ["attributes.stamina", "stamina", "体力"] },
@@ -78,7 +78,7 @@ export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, p
export type SCUMWorkspaceActions = { export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions; pluginData?: PluginDataActions;
logs?: LogActions; gameClient?: GameClientActions;
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
}; };
@@ -134,8 +134,7 @@ export const scumCollections = {
mapSettings: "scum_map_settings", mapSettings: "scum_map_settings",
vehicles: "scum_vehicles", vehicles: "scum_vehicles",
flags: "scum_flags", flags: "scum_flags",
trajectories: "scum_trajectories", trajectories: "scum_trajectories"
logCursors: "scum_log_cursors"
} as const; } as const;
type SurfaceKey = keyof SCUMSurfaceData; type SurfaceKey = keyof SCUMSurfaceData;
@@ -149,56 +148,94 @@ const pageCollections: Record<PageKey, SurfaceKey[]> = {
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"] workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"]
}; };
const pageQueryTemplates: Record<PageKey, SCUMQueryTemplateKey[]> = {
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<SCUMQueryTemplateKey, number> = {
"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<SCUMQueryTemplateKey, number> = {
"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<string>();
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> { export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
const canonical = canonicalPageKey(pageKey); 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 data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
const keys = pageCollections[canonical]; const keys = pageCollections[canonical];
const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const)); 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); for (const [key, response] of records) data[key] = collectionRecords(response);
await mergeSCUMSnapshots(actions, data, keys).catch(() => undefined);
return data; return data;
} }
export async function projectSCUMLoginLogs(actions: SCUMWorkspaceActions, limitPerStream = 200): Promise<number> { export async function queueSCUMDatabaseRefresh(actions: SCUMWorkspaceActions, pageKey: string, now = Date.now()): Promise<PluginBridgeExecutionResult[]> {
if (!actions.pluginData || !actions.logs) return 0; if (!actions.dispatch) return [];
const streamResponse = await actions.logs.listStreams(); const canonical = canonicalPageKey(pageKey);
const streams = logStreams(streamResponse).filter((stream) => textValue(stream.streamKey) === "scum.login" && textValue(stream.id)); const templates = [...new Set(pageQueryTemplates[canonical])];
if (!streams.length) return 0; const requests = templates.flatMap((templateKey) => {
const existingPlayers = collectionRecords(await actions.pluginData.list(scumCollections.players)); const bucketKey = scumQueryBucketKey(templateKey, now);
const playerByIdentity = playerIndex(existingPlayers); if (queuedQueryBuckets.has(bucketKey)) return [];
const playerMutations = new Map<string, PluginDataMutation>(); queuedQueryBuckets.add(bucketKey);
const activityMutations: PluginDataMutation[] = []; return [actions.dispatch!({ requestId: scumQueryRequestId(canonical, templateKey, now), action: "remote.access.request", payload: scumQueryPayload(templateKey, now) })];
let projected = 0; });
for (const stream of streams) { const settled = await Promise.allSettled(requests);
const streamId = textValue(stream.id); return settled.flatMap((item) => item.status === "fulfilled" ? [item.value] : []);
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)); function scumQueryPayload(templateKey: SCUMQueryTemplateKey, now: number): Record<string, string> {
const response = await actions.logs.query({ logStreamId: streamId, afterSeq, limit: limitPerStream }); const maxRows = String(queryTemplateMaxRows[templateKey]);
const entries = logEntries(response); const payload: Record<string, string> = {
let lastSeq = afterSeq; capability: "remote.run.db.sqlite.query",
for (const entry of entries) { declarationKey: "scum-database",
const seq = numberValue(entry.seq); targetKey: "scum-database",
if (seq > lastSeq) lastSeq = seq; idempotencyKey: scumQueryIdempotencyKey(templateKey, now),
const event = parseSCUMLoginLogEntry(entry, streamId); timeoutSeconds: "15",
if (!event) continue; maxAttempts: "1",
const match = findPlayer(playerByIdentity, event); "input.templateKey": templateKey,
const playerKeyValue = match ? textValue(existingPlayers[match.index]._recordKey) || scumLogPlayerKey(event) : scumLogPlayerKey(event); "input.limit": maxRows,
const existing = match ? existingPlayers[match.index] : {}; "input.maxRows": maxRows
const player = scumLoginPlayerRecord(existing, event); };
playerMutations.set(playerKeyValue, { operation: "put", key: playerKeyValue, value: player }); if (templateKey === "scum.player.profile" || templateKey === "scum.positions" || templateKey === "scum.vehicles") payload["input.activeWithinSeconds"] = "600";
if (match) existingPlayers[match.index] = { ...player, _recordKey: playerKeyValue }; return payload;
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++; 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)}`; }
const nextSeq = Math.max(lastSeq, numberValue((response as RecordMap).nextSeq), numberValue((response as RecordMap).latestSeq)); function scumQueryBucketKey(templateKey: SCUMQueryTemplateKey, now: number): string { return `${templateKey}:${queryBucket(templateKey, now)}`; }
if (nextSeq > afterSeq) await actions.pluginData.put(scumCollections.logCursors, cursorKey, { streamId, streamKey: "scum.login", nextSeq, updatedAt: new Date().toISOString() }); function queryBucket(templateKey: SCUMQueryTemplateKey, now: number): number { return Math.floor(now / (queryTemplatePollSeconds[templateKey] * 1000)); }
}
if (playerMutations.size) await actions.pluginData.transact(scumCollections.players, [...playerMutations.values()]); async function mergeSCUMSnapshots(actions: SCUMWorkspaceActions, data: SCUMSurfaceData, keys: SurfaceKey[]): Promise<void> {
if (activityMutations.length) await actions.pluginData.transact(scumCollections.activityEvents, activityMutations); if (!actions.gameClient) return;
return projected; const reads: Array<Promise<void>> = [];
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[] { 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 { function latestSnapshotPayload(response: unknown): RecordMap | undefined {
if (!isRecord(response) || !Array.isArray(response.items)) return undefined; if (!isRecord(response) || !Array.isArray(response.items)) return undefined;
const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left)); const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left));
@@ -141,14 +141,14 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
if (react.useEffect) react.useEffect(() => { if (react.useEffect) react.useEffect(() => {
if (playerPanel.kind === "closed") refresh(); if (playerPanel.kind === "closed") refresh();
if (playerPanel.kind !== "closed") return; if (playerPanel.kind !== "closed") return;
const interval = setInterval(refresh, 3000); const interval = setInterval(refresh, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]); }, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]);
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, 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, 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 === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, {
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
@@ -239,21 +239,7 @@
"sqlRef": "sql/scum-db-v57/users.sql", "sqlRef": "sql/scum-db-v57/users.sql",
"pollIntervalSeconds": 3, "pollIntervalSeconds": 3,
"maxRows": 500, "maxRows": 500,
"timeoutSeconds": 15, "timeoutSeconds": 15
"projections": [
{
"collection": "scum_users",
"rowPath": "rows",
"upsertKeys": [
"steamId"
],
"fixedValues": {
"source": "run.sqlite.scum.player.profile"
},
"observedAtField": "profileSampledAt",
"mergeExisting": true
}
]
}, },
{ {
"key": "scum.squads", "key": "scum.squads",
@@ -295,66 +281,7 @@
"sqlRef": "sql/scum-db-v57/vehicles.sql", "sqlRef": "sql/scum-db-v57/vehicles.sql",
"pollIntervalSeconds": 3, "pollIntervalSeconds": 3,
"maxRows": 500, "maxRows": 500,
"timeoutSeconds": 15, "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
}
]
}, },
{ {
"key": "scum.flags", "key": "scum.flags",
@@ -382,69 +309,7 @@
"sqlRef": "sql/scum-db-v57/map-points.sql", "sqlRef": "sql/scum-db-v57/map-points.sql",
"pollIntervalSeconds": 3, "pollIntervalSeconds": 3,
"maxRows": 500, "maxRows": 500,
"timeoutSeconds": 15, "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"
}
]
}, },
{ {
"key": "scum.tasks", "key": "scum.tasks",
@@ -489,121 +354,6 @@
"timeoutSeconds": 15 "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": [ "dataPacks": [
{ {
"key": "scum-db-v57", "key": "scum-db-v57",
@@ -1068,7 +818,6 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [ "permissions": [
"server.read", "server.read",
"server.logs.read",
"server.remote.access", "server.remote.access",
"server.game-client.read", "server.game-client.read",
"server.game-client.command", "server.game-client.command",
@@ -8,6 +8,7 @@
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "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}$" }, "steamId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"search": { "type": "string", "minLength": 1, "maxLength": 80 }, "search": { "type": "string", "minLength": 1, "maxLength": 80 },
"activeWithinSeconds": { "type": "integer", "minimum": 1, "maximum": 86400 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 } "limit": { "type": "integer", "minimum": 1, "maximum": 500 }
} }
} }
@@ -11,21 +11,25 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "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": { "properties": {
"gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, "steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, "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 }, "squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"squadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 }, "squadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"famePoints": { "type": ["number", "null"] }, "famePoints": { "type": ["number", "null"] },
"moneyBalance": { "type": ["number", "null"] },
"normalBalance": { "type": ["number", "null"] }, "normalBalance": { "type": ["number", "null"] },
"goldBalance": { "type": ["number", "null"] }, "goldBalance": { "type": ["number", "null"] },
"x": { "type": ["number", "null"] }, "x": { "type": ["number", "null"] },
"y": { "type": ["number", "null"] }, "y": { "type": ["number", "null"] },
"z": { "type": ["number", "null"] }, "z": { "type": ["number", "null"] },
"lastLoginTime": { "type": ["string", "null"], "maxLength": 120 }, "lastLoginTime": { "type": ["string", "null"], "maxLength": 120 },
"lastLogoutTime": { "type": ["string", "null"], "maxLength": 120 },
"lastSaveTime": { "type": ["string", "null"], "format": "date-time" } "lastSaveTime": { "type": ["string", "null"], "format": "date-time" }
} }
} }
@@ -6,6 +6,7 @@
"properties": { "properties": {
"subjectType": { "enum": ["player", "vehicle", "base", "flag"] }, "subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 }, "subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"activeWithinSeconds": { "type": "integer", "minimum": 1, "maximum": 86400 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 } "limit": { "type": "integer", "minimum": 1, "maximum": 500 }
} }
} }
@@ -7,6 +7,8 @@
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadId": { "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 } "limit": { "type": "integer", "minimum": 1, "maximum": 500 }
} }
} }
@@ -11,7 +11,7 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "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": { "properties": {
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 }, "vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 }, "entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
@@ -19,6 +19,10 @@
"label": { "type": "string", "maxLength": 120 }, "label": { "type": "string", "maxLength": 120 },
"lastAccessTime": { "type": ["string", "null"], "format": "date-time" }, "lastAccessTime": { "type": ["string", "null"], "format": "date-time" },
"isFunctional": { "type": "integer", "minimum": 0, "maximum": 1 }, "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" }, "x": { "type": "number" },
"y": { "type": "number" }, "y": { "type": "number" },
"z": { "type": "number" } "z": { "type": "number" }
@@ -3,7 +3,7 @@ SELECT
account.id AS subjectId, account.id AS subjectId,
CAST(profile.id AS TEXT) AS userProfileId, CAST(profile.id AS TEXT) AS userProfileId,
CAST(prisoner.id AS TEXT) AS gamePlayerId, 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, CAST(entity.id AS TEXT) AS entityId,
NULL AS baseId, NULL AS baseId,
entity.location_x AS x, 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 ON prisoner.id = profile.prisoner_id
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
JOIN entity ON entity.id = prisoner_entity.entity_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') WHERE (:subjectType IS NULL OR :subjectType = 'player')
AND (:subjectId IS NULL OR account.id = :subjectId) 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 UNION ALL
SELECT SELECT
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL, '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 JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle') WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId) 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 UNION ALL
SELECT SELECT
'base', CAST(base.id AS TEXT), CAST(base.owner_user_profile_id AS TEXT), NULL, 'base', CAST(base.id AS TEXT), CAST(base.owner_user_profile_id AS TEXT), NULL,
@@ -3,15 +3,19 @@ SELECT
account.id AS steamId, account.id AS steamId,
CAST(prisoner.id AS TEXT) AS gamePlayerId, CAST(prisoner.id AS TEXT) AS gamePlayerId,
COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) AS displayName, 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, CAST(member.squad_id AS TEXT) AS squadId,
squad.name AS squadName, squad.name AS squadName,
profile.fame_points AS famePoints, 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 = 1 THEN currency.account_balance END) AS normalBalance,
MAX(CASE WHEN currency.currency_type = 2 THEN currency.account_balance END) AS goldBalance, MAX(CASE WHEN currency.currency_type = 2 THEN currency.account_balance END) AS goldBalance,
entity.location_x AS x, entity.location_x AS x,
entity.location_y AS y, entity.location_y AS y,
entity.location_z AS z, entity.location_z AS z,
profile.last_login_time AS lastLoginTime, 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 strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS lastSaveTime
FROM user account FROM user account
LEFT JOIN user_profile profile ON profile.user_id = account.id 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) WHERE (:userProfileId IS NULL OR CAST(profile.id AS TEXT) = :userProfileId)
AND (:steamId IS NULL OR account.id = :steamId) 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 (: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 GROUP BY account.id
ORDER BY profile.last_login_time DESC ORDER BY profile.last_login_time DESC
LIMIT COALESCE(:limit, 500) LIMIT COALESCE(:limit, 500)
@@ -7,10 +7,19 @@ SELECT
entity.location_y AS y, entity.location_y AS y,
entity.location_z AS z, entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime, 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 FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id 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) 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 (: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 ORDER BY spawner.vehicle_last_access_time DESC
LIMIT COALESCE(:limit, 500) LIMIT COALESCE(:limit, 500)
+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); 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 { 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"); 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).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", () => { it("declares SCUM install/update and start lifecycle through plugin assets", () => {
@@ -246,7 +247,8 @@ describe("plugin manifest validation", () => {
maxPayloadBytes: number; maxPayloadBytes: number;
}>; }>;
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>; 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<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }>;
}; };
pages: Array<{ key: string; permissions?: 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.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined(); expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined();
expect(manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.vehicles")?.projections).toEqual(expect.arrayContaining([ expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined();
expect.objectContaining({ collection: "scum_trade_goods", mergeExisting: true, fixedValues: expect.objectContaining({ catalogType: "vehicle", type: "21", typeName: "其他载具" }) }) 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)).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.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"])); 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; pollIntervalSeconds: number;
maxRows: number; maxRows: number;
timeoutSeconds: 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[] }>; 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 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[]> = { 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.squads": ["squadId", "name", "leaderProfileId", "leaderPlayerId", "memberCount", "score", "memberLimit", "message", "info", "lastMemberLoginTime"],
"scum.squad-members": ["squadId", "userProfileId", "gamePlayerId", "steamId", "displayName", "rank", "isLeader"], "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.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.positions": ["subjectType", "subjectId", "userProfileId", "gamePlayerId", "vehicleId", "entityId", "baseId", "x", "y", "z", "observedAt"],
"scum.tasks": ["taskRecordId", "taskKind", "userProfileId", "mapId", "trackingDataSetId", "dataAssetPath", "sequenceIndex", "isTracked", "state", "completionDeadline"], "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 fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "scum.positions"]);
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template])); const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); 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.query");
expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute"); expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute");
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query"); 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 { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; 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 { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.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"; 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"); expect(dataClientSource).not.toContain("scum-client-manager");
}); });
it("projects SCUM login log lines inside the plugin-owned page module", async () => { it("queues SCUM database reads through declared template keys instead of log parsing", async () => {
const pluginData = pluginDataActions({ list: async (collection, key) => { const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
if (collection === scumCollections.players) return { items: [{ key: "76561198000000001", value: { steamId: "76561198000000001", displayName: "Old", normalBalance: 100 } }] }; await expect(queueSCUMDatabaseRefresh({ dispatch }, "players", 5000)).resolves.toHaveLength(2);
if (collection === scumCollections.logCursors && key) return { items: [{ key, value: { nextSeq: 1 } }] }; expect(dispatch.mock.calls.map(([envelope]) => envelope.payload?.["input.templateKey"])).toEqual(["scum.player.profile", "scum.positions"]);
return { items: [], count: 0 }; 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" }),
const logs = { expect.objectContaining({ capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database", "input.templateKey": "scum.positions", "input.activeWithinSeconds": "600" })
listStreams: vi.fn(async () => ({ items: [{ id: "stream-login", streamKey: "scum.login", latestSeq: 3 }], count: 1 })), ]));
query: vi.fn(async () => ({ logStreamId: "stream-login", entries: [ expect(dispatch.mock.calls[0]?.[0].payload).not.toHaveProperty("input.sqlText");
{ seq: 2, timestamp: "2026-08-10T00:01:00Z", line: "Login: Player 'Mira' SteamID 76561198000000001 IP 203.0.113.7" }, expect(dataClientSource).not.toContain("projectSCUMLoginLogs");
{ seq: 3, timestamp: "2026-08-10T00:04:00Z", line: "Logout: Player 'Mira' SteamID 76561198000000001 IP 203.0.113.7" } expect(dataClientSource).not.toContain("logs.query");
], 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("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => { 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); 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("pluginData");
expect(source).toContain("remote.access.request"); 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(source).not.toContain("requestSCUMPageQueries");
expect(pageSource).toContain("setInterval(refresh, 3000)"); expect(pageSource).toContain("setInterval(refresh, 5000)");
expect(pageSource).toContain("clearInterval(interval)"); expect(pageSource).toContain("clearInterval(interval)");
}); });
}); });