export type RecordMap = Record; export type PluginDataMutation = { operation: "put" | "delete"; key: string; value?: RecordMap }; export type PluginDataActions = { list: (collection: string, key?: string) => Promise; put: (collection: string, key: string, value: RecordMap) => Promise; delete: (collection: string, key: string) => Promise; transact: (collection: string, mutations: PluginDataMutation[]) => Promise; }; export type PluginGameClientQueueRequest = { profileKey: string; commandType: string; payload: RecordMap; idempotencyKey: string; priority?: number; expiresAt: string; }; export type PluginDispatchEnvelope = { requestId: string; action: "remote.access.request"; payload: Record }; export type PluginDispatchResult = { requestId: string; action: "remote.access.request"; status: string; result?: Record; error?: { code: string; message: string; details?: string[] } }; export type SCUMWorkspaceActions = { pluginData?: PluginDataActions; gameClient?: { queue: (request: PluginGameClientQueueRequest) => Promise; get: (commandId: string) => Promise; list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise; snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise; }; dispatch?: (envelope: PluginDispatchEnvelope, signal?: AbortSignal) => Promise; }; export type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; events: RecordMap[]; eventProduces: RecordMap[]; eventRuns: RecordMap[]; nativeEventRounds: RecordMap[]; tasks: RecordMap[]; activityEvents: RecordMap[]; gifts: RecordMap[]; giftClaims: RecordMap[]; pendingGifts: RecordMap[]; giftDeliveries: RecordMap[]; timedGiftEvents: RecordMap[]; mapPoints: RecordMap[]; mapRegions: RecordMap[]; mapSettings: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; }; export const emptySCUMSurfaceData: SCUMSurfaceData = { players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [], gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: [] }; export const scumCollections = { players: "scum_users", squads: "scum_squads", members: "scum_squad_members", events: "scum_activity_definitions", eventProduces: "scum_event_produces", eventRuns: "scum_event_runs", nativeEventRounds: "scum_native_event_rounds", tasks: "scum_tasks", activityEvents: "scum_activity_events", gifts: "scum_gifts", giftClaims: "scum_gift_claims", pendingGifts: "scum_pending_gifts", giftDeliveries: "scum_gift_deliveries", timedGiftEvents: "scum_timed_gift_events", mapPoints: "scum_map_points", mapRegions: "scum_map_regions", mapSettings: "scum_map_settings", vehicles: "scum_vehicles", flags: "scum_flags" } as const; type SurfaceKey = keyof SCUMSurfaceData; type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; const pageCollections: Record = { players: ["players", "members"], squads: ["squads", "members", "flags"], "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"], gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"], workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"] }; const pageQueries: Record = { players: ["scum.player.profile", "scum.positions"], squads: ["scum.squads", "scum.squad-members", "scum.flags"], "live-map": ["scum.player.profile", "scum.vehicles", "scum.flags", "scum.positions"], gifts: ["scum.native-timed-gifts"], workflows: ["scum.tasks", "scum.events"] }; export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise { if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); const data: SCUMSurfaceData = { ...emptySCUMSurfaceData }; const keys = pageCollections[canonicalPageKey(pageKey)]; 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); } return data; } export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown, sessionsResponse: unknown): RecordMap[] { const playerSnapshot = latestSnapshotPayload(playersResponse); const sessionSnapshot = latestSnapshotPayload(sessionsResponse); let 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) }; if (match) merged[match.index] = value; else merged.push({ ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") }); } } 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 { 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 { const key = requiredKey(gift, "code", "礼包编号"); return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]); } export async function deleteGiftDefinition(actions: SCUMWorkspaceActions, key: string): Promise { return requirePluginData(actions).delete(scumCollections.gifts, key); } export async function resetGiftClaim(actions: SCUMWorkspaceActions, claim: RecordMap): Promise { const key = firstText(claim, "_recordKey", "id", "claimId"); if (!key) throw new Error("领取记录编号不能为空。"); return requirePluginData(actions).delete(scumCollections.giftClaims, key); } export async function resetPendingGift(actions: SCUMWorkspaceActions, pending: RecordMap): Promise { const key = firstText(pending, "_recordKey", "id", "pendingId"); if (!key) throw new Error("待领记录编号不能为空。"); return requirePluginData(actions).put(scumCollections.pendingGifts, key, { ...pending, status: "pending", receivedAt: null, receiveTime: null, resetAt: new Date().toISOString() }); } export async function createGiftDelivery(actions: SCUMWorkspaceActions, delivery: RecordMap): Promise { const key = requiredKey(delivery, "id", "发放记录编号"); return requirePluginData(actions).put(scumCollections.giftDeliveries, key, delivery); } export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: RecordMap, player: RecordMap): Promise { if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。"); const giftCode = requiredKey(gift, "code", "礼包编号"); const playerId = firstText(player, "gamePlayerId", "playerId", "steamId", "id"); if (!playerId) throw new Error("用户编号不能为空。"); const items = normalizeGiftItems(gift.items); const operations = [...new Set([...normalizeGiftOperations(gift.commands), ...normalizeGiftOperations(gift.operations)])]; if (!items.length && !operations.length) throw new Error("礼包必须包含物品或命令。"); const now = Date.now(); const grantId = safeCommandId(`gift:${giftCode}:${playerId}:${now}`); const command = await actions.gameClient.queue({ profileKey: "scum-client-manager", commandType: "reward.deliver", payload: { grantId, playerId, items, operations }, idempotencyKey: grantId, expiresAt: new Date(now + 5 * 60_000).toISOString() }); const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", createdAt: new Date(now).toISOString() }; await createGiftDelivery(actions, record); return command; } export async function saveEventDefinition(actions: SCUMWorkspaceActions, event: RecordMap): Promise { const key = requiredKey(event, "id", "活动编号"); return requirePluginData(actions).put(scumCollections.events, key, event); } export async function deleteEventDefinition(actions: SCUMWorkspaceActions, key: string, produces: RecordMap[] = []): Promise { const pluginData = requirePluginData(actions); await Promise.all(produces.filter((produce) => firstText(produce, "eventId", "event") === key).map((produce) => pluginData.delete(scumCollections.eventProduces, requiredRecordKey(produce, "生成项")))); await pluginData.delete(scumCollections.events, key); } export async function saveEventProduce(actions: SCUMWorkspaceActions, produce: RecordMap): Promise { const eventId = firstText(produce, "eventId", "event"); if (!eventId) throw new Error("活动编号不能为空。"); const produceId = firstText(produce, "id", "produceId") || requestKey("produce", eventId); return requirePluginData(actions).put(scumCollections.eventProduces, `${eventId}:${produceId}`, { ...produce, id: produceId, eventId }); } export async function deleteEventProduce(actions: SCUMWorkspaceActions, produce: RecordMap): Promise { return requirePluginData(actions).delete(scumCollections.eventProduces, requiredRecordKey(produce, "生成项")); } export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap, produces: RecordMap[] = []): Promise { if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。"); const eventId = requiredKey(event, "id", "活动编号"); const eventClass = Number(event.class) === 2 || firstText(event, "eventType") === "fixed" ? 2 : 1; const eventType = eventClass === 2 ? "fixed" : "range"; const queuedProduces = normalizeEventProduces(produces); const now = Date.now(); const runId = safeCommandId(`event:${eventId}:${now}`); const command = await actions.gameClient.queue({ profileKey: "scum-client-manager", commandType: "event.start", 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), produces: queuedProduces, durationSeconds: boundedInteger(event.durationSeconds, 30, 86400, 1800), announce: event.announce !== false }, idempotencyKey: runId, expiresAt: new Date(now + 5 * 60_000).toISOString() }); await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", definition: event, produces, startedAt: new Date(now).toISOString() }); return command; } export function parseGiftItems(input: string): Array<{ catalogCode: string; quantity: number }> { if (!input.trim()) return []; 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。"); return { catalogCode: rawKey, quantity }; }); if (items.length > 8) throw new Error("单个礼包最多包含 8 项物品。"); return items; } export function parseGiftCommands(input: string): Array<{ command: string }> { return input.split(/\r?\n/).map((command) => command.trim()).filter(Boolean).map((command) => ({ command })); } export type SCUMMapBounds = { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number }; export function resolveMapBounds(settings?: RecordMap): SCUMMapBounds { const fallback = { worldMinX: -905000, worldMinY: -905000, worldMaxX: 619000, worldMaxY: 619000 }; if (!settings) return fallback; if (Object.prototype.hasOwnProperty.call(settings, "customMapEnabled") && !booleanValue(settings.customMapEnabled)) return fallback; const explicit = [settings.worldMinX, settings.worldMinY, settings.worldMaxX, settings.worldMaxY].map(Number); if (explicit.every(Number.isFinite) && explicit[2] > explicit[0] && explicit[3] > explicit[1]) return { worldMinX: explicit[0], worldMinY: explicit[1], worldMaxX: explicit[2], worldMaxY: explicit[3] }; if (!booleanValue(settings.customMapEnabled)) return fallback; const centerX = Number(settings.centerX ?? settings.mapX); const centerY = Number(settings.centerY ?? settings.mapY); const widthKm = Number(settings.widthKm ?? settings.mapWidth); const heightKm = Number(settings.heightKm ?? settings.mapHeight); if (![centerX, centerY, widthKm, heightKm].every(Number.isFinite) || widthKm <= 0 || heightKm <= 0) return fallback; const halfWidth = widthKm * 100000 / 2; const halfHeight = heightKm * 100000 / 2; return { worldMinX: centerX - halfWidth, worldMinY: centerY - halfHeight, worldMaxX: centerX + halfWidth, worldMaxY: centerY + halfHeight }; } export async function saveMapSettings(actions: SCUMWorkspaceActions, settings: RecordMap): Promise { const value = { ...settings, ...resolveMapBounds(settings), updatedAt: new Date().toISOString() }; return requirePluginData(actions).put(scumCollections.mapSettings, "current", value); } function canonicalPageKey(pageKey: string): PageKey { if (pageKey === "activity") return "workflows"; return pageKey === "squads" || pageKey === "live-map" || pageKey === "gifts" || pageKey === "workflows" ? pageKey : "players"; } function collectionRecords(response: unknown): RecordMap[] { if (!isRecord(response) || !Array.isArray(response.items)) return []; return response.items.flatMap((item) => { if (!isRecord(item)) return []; if (isRecord(item.value)) return [{ ...item.value, _recordKey: textValue(item.key) }]; return [item]; }); } 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)); const latest = snapshots[0]; if (!latest) return undefined; return isRecord(latest.payload) ? { ...latest.payload, observedAt: textValue(latest.observedAt) || textValue(latest.payload.observedAt) } : 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 { const result = new Map(); players.forEach((player, index) => playerIdentities(player).forEach((identity) => result.set(identity, index))); return result; } function findPlayer(players: RecordMap[], index: Map, 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 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"; } function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions { if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); return actions.pluginData; } function requiredKey(value: RecordMap, key: string, label: string): string { const result = textValue(value[key]); if (!result) throw new Error(`${label}不能为空。`); return result; } function requiredRecordKey(value: RecordMap, label: string): string { const key = firstText(value, "_recordKey", "id", "produceId"); if (!key) throw new Error(`${label}编号不能为空。`); return key.includes(":") ? key : `${firstText(value, "eventId", "event")}:${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; } 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 项。"); 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 目录代码或数量约束。"); return { catalogCode, quantity }; }); } function normalizeGiftOperations(value: unknown): string[] { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error("礼包命令格式无效。"); return value.map((item) => { const command = isRecord(item) ? firstText(item, "command", "value") : textValue(item); if (!command.trim()) throw new Error("礼包命令不能为空。"); return command.trim(); }); } 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) })); } 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 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 ""; } function requestKey(prefix: string, key: string): string { return `${prefix}:${key}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`; } function textValue(value: unknown): string { return value === undefined || value === null ? "" : String(value); } function isRecord(value: unknown): value is RecordMap { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }