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>;
};
export type LogActions = {
listStreams: () => Promise<unknown>;
query: (request: { logStreamId: string; afterSeq: number; limit: number }) => Promise<unknown>;
export type GameClientActions = {
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
};
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 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<PluginBridgeExecutionResult>;
};
@@ -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<PageKey, SurfaceKey[]> = {
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> {
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<number> {
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<string, PluginDataMutation>();
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<PluginBridgeExecutionResult[]> {
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<string, string> {
const maxRows = String(queryTemplateMaxRows[templateKey]);
const payload: Record<string, string> = {
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<void> {
if (!actions.gameClient) return;
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[] {
@@ -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));
@@ -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,