Rebuild SCUM plugin-owned data flow

This commit is contained in:
npc0-hue
2026-08-18 07:01:17 +08:00
parent 302f1f64b7
commit 98bf944f4c
39 changed files with 1832 additions and 223 deletions
@@ -17,9 +17,6 @@ export type PluginGameClientQueueRequest = {
expiresAt: string;
};
export type PluginDispatchEnvelope = { requestId: string; action: "remote.access.request"; payload: Record<string, string> };
export type PluginDispatchResult = { requestId: string; action: "remote.access.request"; status: string; result?: Record<string, string>; error?: { code: string; message: string; details?: string[] } };
export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions;
gameClient?: {
@@ -28,7 +25,6 @@ export type SCUMWorkspaceActions = {
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>;
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
};
dispatch?: (envelope: PluginDispatchEnvelope, signal?: AbortSignal) => Promise<PluginDispatchResult>;
};
export type SCUMSurfaceData = {
@@ -91,14 +87,6 @@ const pageCollections: Record<PageKey, SurfaceKey[]> = {
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"]
};
const pageQueries: Record<PageKey, string[]> = {
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.native-timed-gifts"],
workflows: ["scum.tasks", "scum.events"]
};
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
@@ -106,52 +94,32 @@ export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: st
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);
if (keys.includes("players") && actions.gameClient) {
const [playersSnapshot, sessionsSnapshot] = await Promise.all([
actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined),
actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "online.sessions", streamKey: "current", limit: 1 }).catch(() => undefined)
]);
data.players = mergePlayerSnapshots(data.players, playersSnapshot, sessionsSnapshot);
const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined);
data.players = mergePlayerSnapshots(data.players, playersSnapshot);
}
return data;
}
export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown, sessionsResponse: unknown): RecordMap[] {
export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] {
const playerSnapshot = latestSnapshotPayload(playersResponse);
const sessionSnapshot = latestSnapshotPayload(sessionsResponse);
let merged = players.map((player) => ({ ...player }));
const merged = players.map((player) => ({ ...player }));
const snapshotPlayers = Array.isArray(playerSnapshot?.players) ? playerSnapshot.players.filter(isRecord) : [];
if (snapshotPlayers.length) {
const byIdentity = playerIndex(merged);
for (const snapshotPlayer of snapshotPlayers) {
const match = findPlayer(merged, byIdentity, snapshotPlayer);
const value = { ...snapshotPlayer, ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) };
const match = findPlayer(byIdentity, snapshotPlayer);
const value = { ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) };
if (match) merged[match.index] = value;
else merged.push({ ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") });
else {
const created = { ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") };
merged.push(created);
addPlayerToIndex(byIdentity, created, merged.length - 1);
}
}
}
const sessions = Array.isArray(sessionSnapshot?.sessions) ? sessionSnapshot.sessions.filter(isRecord) : [];
if (sessionSnapshot && Array.isArray(sessionSnapshot.sessions)) {
const onlineNames = new Set(sessions.map((session) => firstText(session, "playerName", "displayName", "name").toLowerCase()).filter(Boolean));
merged = merged.map((player) => {
const name = firstText(player, "displayName", "playerName", "name").toLowerCase();
const session = sessions.find((candidate) => firstText(candidate, "playerName", "displayName", "name").toLowerCase() === name);
return { ...player, online: Boolean(name && onlineNames.has(name)), ...(session ? { onlineSession: session } : {}), onlineObservedAt: textValue(sessionSnapshot.observedAt) || textValue(player.onlineObservedAt) };
});
}
return merged;
}
export function hasSCUMPageQueries(pageKey: string): boolean { return pageQueries[canonicalPageKey(pageKey)].length > 0; }
export async function requestSCUMPageQueries(actions: SCUMWorkspaceActions, pageKey: string): Promise<PluginDispatchResult[]> {
if (!actions.dispatch) throw new Error("通用机器动作 dispatch 能力不可用。");
return Promise.all(pageQueries[canonicalPageKey(pageKey)].map((queryKey) => actions.dispatch!({
requestId: requestKey("scum-query", queryKey),
action: "remote.access.request",
payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database", "input.templateKey": queryKey }
})));
}
export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise<unknown> {
const key = requiredKey(gift, "code", "礼包编号");
return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]);
@@ -234,9 +202,9 @@ export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap
payload: {
eventId, eventType, class: eventClass, title: textValue(event.name) || eventId,
placard: firstText(event, "placard", "announcement"), percent: boundedInteger(event.percent ?? event.probability, 0, 100, 100),
npc: boundedInteger(event.npc, 0, 10000, 0), item: boundedInteger(event.item, 0, 10000, 0), zombie: boundedInteger(event.zombie, 0, 10000, 0), animal: boundedInteger(event.animal, 0, 10000, 0),
npc: minimumInteger(event.npc, 0, 0), item: minimumInteger(event.item, 0, 0), zombie: minimumInteger(event.zombie, 0, 0), animal: minimumInteger(event.animal, 0, 0),
produces: queuedProduces,
durationSeconds: boundedInteger(event.durationSeconds, 30, 86400, 1800), announce: event.announce !== false
durationSeconds: minimumInteger(event.durationSeconds, 1, 1800), announce: event.announce !== false
},
idempotencyKey: runId,
expiresAt: new Date(now + 5 * 60_000).toISOString()
@@ -250,10 +218,9 @@ export function parseGiftItems(input: string): Array<{ catalogCode: string; quan
const items = input.split(",").map((part) => {
const [rawKey, rawQuantity, ...extra] = part.split(":").map((value) => value.trim());
const quantity = Number(rawQuantity);
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(rawKey) || !rawQuantity || extra.length || !Number.isInteger(quantity) || quantity < 1 || quantity > 100) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:数量,数量范围 1-100。");
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(rawKey) || !rawQuantity || extra.length || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:正整数数量。");
return { catalogCode: rawKey, quantity };
});
if (items.length > 8) throw new Error("单个礼包最多包含 8 项物品。");
return items;
}
@@ -308,9 +275,15 @@ function latestSnapshotPayload(response: unknown): RecordMap | undefined {
}
function snapshotOrder(snapshot: RecordMap): number { const observed = Date.parse(textValue(snapshot.observedAt)); return Number.isNaN(observed) ? Number(snapshot.sequence) || 0 : observed; }
function playerIndex(players: RecordMap[]): Map<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => playerIdentities(player).forEach((identity) => result.set(identity, index))); return result; }
function findPlayer(players: RecordMap[], index: Map<string, number>, player: RecordMap): { index: number } | undefined { for (const identity of playerIdentities(player)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } const name = firstText(player, "displayName", "playerName", "name").toLowerCase(); const found = players.findIndex((candidate) => firstText(candidate, "displayName", "playerName", "name").toLowerCase() === name); return found >= 0 && name ? { index: found } : undefined; }
function playerIdentities(player: RecordMap): string[] { return ["gamePlayerId", "playerId", "steamId", "userProfileId", "profileId", "id"].map((key) => textValue(player[key])).filter(Boolean); }
function playerIndex(players: RecordMap[]): Map<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => addPlayerToIndex(result, player, index)); return result; }
function addPlayerToIndex(index: Map<string, number>, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); }
function findPlayer(index: Map<string, number>, player: RecordMap): { index: number } | undefined { for (const identity of playerIdentities(player)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } return undefined; }
function playerIdentities(player: RecordMap): string[] {
const identities = new Set<string>();
for (const key of ["gamePlayerId", "playerId", "steamId", "id"]) { const value = textValue(player[key]); if (value) identities.add(`player:${value}`); }
for (const key of ["userProfileId", "profileId"]) { const value = textValue(player[key]); if (value) identities.add(`profile:${value}`); }
return [...identities];
}
function onlineValue(player: RecordMap): boolean { const status = firstText(player, "status", "state").toLowerCase(); return booleanValue(player.online) || ["online", "active", "connected"].includes(status); }
function booleanValue(value: unknown): boolean { return value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true"; }
@@ -329,17 +302,19 @@ function requiredRecordKey(value: RecordMap, label: string): string { const key
function boundedInteger(value: unknown, min: number, max: number, fallback: number): number {
const number = Number(value);
return Number.isInteger(number) && number >= min && number <= max ? number : fallback;
return Number.isSafeInteger(number) && number >= min && number <= max ? number : fallback;
}
function minimumInteger(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isSafeInteger(number) && number >= min ? number : fallback; }
function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> {
if (value === undefined || value === null) return [];
if (!Array.isArray(value) || value.length > 8) throw new Error("礼包物品最多包含 8 项。");
if (!Array.isArray(value)) throw new Error("礼包物品格式无效。");
return value.map((item) => {
if (!isRecord(item)) throw new Error("礼包物品格式无效。");
const catalogCode = firstText(item, "catalogCode", "key");
const quantity = Number(item.quantity);
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isInteger(quantity) || quantity < 1 || quantity > 100) throw new Error("礼包物品不符合 SCUM 目录代码或数量约束。");
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品不符合 SCUM 目录代码或正整数数量约束。");
return { catalogCode, quantity };
});
}
@@ -350,15 +325,16 @@ function normalizeEventProduces(produces: RecordMap[]): RecordMap[] {
return produces.map((produce) => ({
tradeGoodsId: firstText(produce, "tradeGoodsId"),
percent: boundedInteger(produce.percent, 0, 100, 100),
value: boundedInteger(produce.value, 1, 10000, 1),
r: boundedNumber(produce.r, 0, 2000000, 0),
x: boundedNumber(produce.x, -2000000, 2000000, 0),
y: boundedNumber(produce.y, -2000000, 2000000, 0),
z: boundedNumber(produce.z, -2000000, 2000000, 0)
value: minimumInteger(produce.value, 1, 1),
r: minimumNumber(produce.r, 0, 0),
x: finiteNumber(produce.x, 0),
y: finiteNumber(produce.y, 0),
z: finiteNumber(produce.z, 0)
}));
}
function boundedNumber(value: unknown, min: number, max: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min && number <= max ? number : fallback; }
function minimumNumber(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min ? number : fallback; }
function finiteNumber(value: unknown, fallback: number): number { const number = Number(value); return Number.isFinite(number) ? number : fallback; }
function safeCommandId(value: string): string { return value.replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 96); }
function firstText(value: RecordMap, ...keys: string[]): string { for (const key of keys) { const result = textValue(value[key]); if (result) return result; } return ""; }