Remove legacy client-manager platform path
This commit is contained in:
+1
-1
@@ -35,7 +35,7 @@ Remote access declarations describe whether a plugin can use `ftp`, `rsync`, or
|
||||
Runtime profiles are declarative contracts, not executable scripts. A profile can declare:
|
||||
|
||||
- discovery probes for logical targets such as Java, Steam app, service, file, or toolchain checks.
|
||||
- lifecycle modes such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`.
|
||||
- lifecycle modes such as `local-process`, `hosted-ftp-rcon`, or `ftp-only`.
|
||||
- dependency probes and typed install plans for supported OS targets.
|
||||
- log sources for stdout/stderr, file tailing, FTP polling, or plugin-declared typed channels. Log bodies remain opaque and verbatim to Run and Platform.
|
||||
- transport profiles for declared file, FTP/rsync, SQL, RCON, and run-mediated operations.
|
||||
|
||||
@@ -8,6 +8,11 @@ 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 PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> };
|
||||
export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; error?: { message?: string } };
|
||||
|
||||
@@ -73,6 +78,7 @@ export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, p
|
||||
|
||||
export type SCUMWorkspaceActions = {
|
||||
pluginData?: PluginDataActions;
|
||||
logs?: LogActions;
|
||||
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
|
||||
};
|
||||
|
||||
@@ -128,7 +134,8 @@ export const scumCollections = {
|
||||
mapSettings: "scum_map_settings",
|
||||
vehicles: "scum_vehicles",
|
||||
flags: "scum_flags",
|
||||
trajectories: "scum_trajectories"
|
||||
trajectories: "scum_trajectories",
|
||||
logCursors: "scum_log_cursors"
|
||||
} as const;
|
||||
|
||||
type SurfaceKey = keyof SCUMSurfaceData;
|
||||
@@ -144,13 +151,56 @@ const pageCollections: Record<PageKey, SurfaceKey[]> = {
|
||||
|
||||
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);
|
||||
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
|
||||
const keys = pageCollections[canonicalPageKey(pageKey)];
|
||||
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);
|
||||
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 function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] {
|
||||
const playerSnapshot = latestSnapshotPayload(playersResponse);
|
||||
const merged = players.map((player) => ({ ...player }));
|
||||
@@ -322,6 +372,69 @@ 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));
|
||||
|
||||
@@ -1064,6 +1064,7 @@
|
||||
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
|
||||
"permissions": [
|
||||
"server.read",
|
||||
"server.logs.read",
|
||||
"server.remote.access",
|
||||
"server.game-client.read",
|
||||
"server.game-client.command",
|
||||
|
||||
@@ -521,7 +521,7 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"mode": { "enum": ["local-process", "hosted-ftp-rcon", "ftp-only", "custom-client"] },
|
||||
"mode": { "enum": ["local-process", "hosted-ftp-rcon", "ftp-only"] },
|
||||
"capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 },
|
||||
"actionRefs": {
|
||||
"type": "object",
|
||||
|
||||
@@ -369,7 +369,7 @@ export interface RuntimeDiscoveryProbe {
|
||||
|
||||
export interface RuntimeLifecycleProfile {
|
||||
key: string;
|
||||
mode: "local-process" | "hosted-ftp-rcon" | "ftp-only" | "custom-client";
|
||||
mode: "local-process" | "hosted-ftp-rcon" | "ftp-only";
|
||||
capabilities: RunCapability[];
|
||||
actionRefs?: Partial<Record<PluginLifecycleAction, string>>;
|
||||
transportKeys?: string[];
|
||||
|
||||
@@ -426,8 +426,8 @@ describe("plugin manifest validation", () => {
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
||||
};
|
||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }>; dataTargets?: unknown[] };
|
||||
};
|
||||
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"];
|
||||
const expectedColumnsByKey: Record<string, string[]> = {
|
||||
"scum.player.profile": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"],
|
||||
|
||||
@@ -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, 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, 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 { 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,6 +103,27 @@ 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("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
|
||||
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
|
||||
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows");
|
||||
|
||||
Reference in New Issue
Block a user