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 LogActions = { listStreams: () => Promise; query: (request: { logStreamId: string; afterSeq: number; limit: number }) => Promise; }; export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record }; export type PluginBridgeExecutionResult = { status?: string; result?: Record; error?: { message?: string } }; export const playerAttributeCatalog = [ { key: "stamina", label: "体力", column: "stamina", sourceKeys: ["attributes.stamina", "stamina", "体力"] }, { key: "dexterity", label: "敏捷", column: "dexterity", sourceKeys: ["attributes.dexterity", "dexterity", "敏捷"] }, { key: "intelligence", label: "智力", column: "intelligence", sourceKeys: ["attributes.intelligence", "intelligence", "智力"] } ] as const; export type PlayerAttributeDraft = { fieldKey: string; label: string; before: string; after: string }; export function playerAttributeDrafts(player: RecordMap): PlayerAttributeDraft[] { return playerAttributeCatalog.map((field) => ({ fieldKey: field.key, label: field.label, before: firstText(player, ...field.sourceKeys), after: firstText(player, ...field.sourceKeys) })); } export function playerAttributeSqlPreview(drafts: PlayerAttributeDraft[]): string { const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()); if (!changes.length) return "等待输入要提交的属性变更。"; return buildPlayerAttributeSqlText({ gamePlayerId: ":playerId" }, changes.flatMap((draft) => { const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey); const after = Number(draft.after); return definition && Number.isFinite(after) ? [{ column: definition.column, after }] : []; })); } export function buildPlayerAttributeMutation(player: RecordMap, drafts: PlayerAttributeDraft[]): RecordMap { const playerId = firstText(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id"); if (!playerId) throw new Error("用户没有可用的 Steam ID 或游戏用户编号。"); const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()).map((draft) => { const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey); const before = draft.before.trim() ? Number(draft.before) : Number.NaN; const after = Number(draft.after); if (!definition || !Number.isFinite(after)) throw new Error(`${draft.label}目标值必须是数字。`); return { fieldKey: draft.fieldKey, label: draft.label, column: definition.column, before: Number.isFinite(before) ? before : null, after }; }); if (!changes.length) throw new Error("至少填写一项与当前值不同的属性。"); const idempotencyKey = safeCommandId(`player-attributes:${playerId}:${changes.map((change) => `${change.fieldKey}:${change.after}`).join(",")}:${Date.now()}`); return { playerId, reason: "管理员在 SCUM 用户管理中编辑属性", sqlText: buildPlayerAttributeSqlText(player, changes), changes, idempotencyKey }; } export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, player: RecordMap, drafts: PlayerAttributeDraft[]): Promise { if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法提交 SQL 执行任务。"); const mutation = buildPlayerAttributeMutation(player, drafts); const idempotencyKey = textValue(mutation.idempotencyKey); const result = await actions.dispatch({ requestId: idempotencyKey, action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.execute", declarationKey: "scum-database", targetKey: "scum-database", idempotencyKey, timeoutSeconds: "60", maxAttempts: "1", "input.mode": "execute", "input.sqlText": textValue(mutation.sqlText), "input.reason": textValue(mutation.reason) } }); if (result?.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SQL 执行任务未进入 Run 队列。"); return result; } export type SCUMWorkspaceActions = { pluginData?: PluginDataActions; logs?: LogActions; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise; }; export type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; events: RecordMap[]; eventProduces: RecordMap[]; eventRuns: RecordMap[]; nativeEventRounds: RecordMap[]; tasks: RecordMap[]; activityEvents: RecordMap[]; tradeGoods: RecordMap[]; tradeEvents: RecordMap[]; gifts: RecordMap[]; giftClaims: RecordMap[]; pendingGifts: RecordMap[]; giftDeliveries: RecordMap[]; timedGiftEvents: RecordMap[]; mapPoints: RecordMap[]; mapRegions: RecordMap[]; mapSettings: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; trajectories: RecordMap[]; }; export const emptySCUMSurfaceData: SCUMSurfaceData = { players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [], tradeGoods: [], tradeEvents: [], gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: [], trajectories: [] }; 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", tradeGoods: "scum_trade_goods", tradeEvents: "scum_trade_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", trajectories: "scum_trajectories", logCursors: "scum_log_cursors" } as const; type SurfaceKey = keyof SCUMSurfaceData; type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; const pageCollections: Record = { players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries", "trajectories", "vehicles"], squads: ["squads", "members", "flags"], "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags", "trajectories", "tradeGoods"], gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players", "tradeGoods"], workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"] }; export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise { 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[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 { 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(); 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 })); 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(byIdentity, snapshotPlayer); const value = { ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) }; if (match) merged[match.index] = value; else { const created = { ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") }; merged.push(created); addPlayerToIndex(byIdentity, created, merged.length - 1); } } } return merged; } export function mergeVehicleSnapshots(vehicles: RecordMap[], vehiclesResponse: unknown): RecordMap[] { const vehicleSnapshot = latestSnapshotPayload(vehiclesResponse); const merged = vehicles.map((vehicle) => ({ ...vehicle })); const snapshotVehicles = Array.isArray(vehicleSnapshot?.vehicles) ? vehicleSnapshot.vehicles.filter(isRecord) : []; if (!snapshotVehicles.length) return merged; const byIdentity = vehicleIndex(merged); for (const snapshotVehicle of snapshotVehicles) { const match = findVehicle(byIdentity, snapshotVehicle); const value = { ...(match ? merged[match.index] : {}), ...snapshotVehicle, vehicleObservedAt: textValue(vehicleSnapshot?.observedAt) }; if (match) merged[match.index] = value; else { const created = { ...value, vehicleId: firstText(snapshotVehicle, "vehicleId", "entityId", "id") }; merged.push(created); addVehicleToIndex(byIdentity, created, merged.length - 1); } } return merged; } 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.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法通过 SCUM RCON 发放礼包。"); 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 commands = [...items.map((item) => `#SpawnItem ${item.catalogCode} ${item.quantity}`), ...operations].map(normalizeRCONCommand); const results = []; for (const [index, command] of commands.entries()) { results.push(await dispatchSCUMRCONCommand(actions, command, `${grantId}:${index + 1}`)); } const commandIds = results.map((result) => textValue(result.result?.jobId)).filter(Boolean); const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: commandIds[0] ?? "", commandIds, createdAt: new Date(now).toISOString() }; await createGiftDelivery(actions, record); return { status: "queued", result: { jobIds: commandIds.join(",") } }; } 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.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法通过 SCUM RCON 启动活动。"); const eventId = requiredKey(event, "id", "活动编号"); const command = firstText(event, "rconCommand", "command"); if (!command) throw new Error("活动未声明可执行的 SCUM RCON 命令。"); const now = Date.now(); const runId = safeCommandId(`event:${eventId}:${now}`); const dispatched = await dispatchSCUMRCONCommand(actions, normalizeRCONCommand(command), runId); await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: textValue(dispatched.result?.jobId), definition: event, produces, startedAt: new Date(now).toISOString() }); return dispatched; } 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.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:正整数数量。"); return { catalogCode: rawKey, quantity }; }); 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 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)); 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) => addPlayerToIndex(result, player, index)); return result; } function addPlayerToIndex(index: Map, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); } function findPlayer(index: Map, 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 vehicleIndex(vehicles: RecordMap[]): Map { const result = new Map(); vehicles.forEach((vehicle, index) => addVehicleToIndex(result, vehicle, index)); return result; } function addVehicleToIndex(index: Map, vehicle: RecordMap, vehicleIndex: number): void { vehicleIdentities(vehicle).forEach((identity) => index.set(identity, vehicleIndex)); } function findVehicle(index: Map, vehicle: RecordMap): { index: number } | undefined { for (const identity of vehicleIdentities(vehicle)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } return undefined; } function vehicleIdentities(vehicle: RecordMap): string[] { return ["vehicleId", "entityId", "id"].map((key) => textValue(vehicle[key])).filter(Boolean).map((value) => `vehicle:${value}`); } function playerIdentities(player: RecordMap): string[] { const identities = new Set(); 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"; } function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions { if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); return actions.pluginData; } function buildPlayerAttributeSqlText(player: RecordMap, changes: Array<{ column: string; after: number }>): string { const playerId = firstText(player, "gamePlayerId", "playerId", "id"); const profileId = firstText(player, "userProfileId", "profileId"); const steamId = firstText(player, "steamId", "providerId"); const where = playerId && playerId !== ":playerId" ? `id = ${sqlLiteral(playerId)}` : profileId ? `id = (SELECT prisoner_id FROM user_profile WHERE CAST(id AS TEXT) = ${sqlLiteral(profileId)} LIMIT 1)` : steamId ? `id = (SELECT profile.prisoner_id FROM user_profile profile WHERE profile.user_id = ${sqlLiteral(steamId)} LIMIT 1)` : "id = :playerId"; return changes.map((change) => `UPDATE prisoner SET ${change.column} = ${sqlNumber(change.after)} WHERE ${where};`).join("\n"); } function sqlLiteral(value: string): string { return value === ":playerId" ? value : `'${value.replace(/'/g, "''")}'`; } function sqlNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(value); } 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 normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> { if (value === undefined || value === null) return []; 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.isSafeInteger(quantity) || quantity < 1) 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 normalizeRCONCommand(command: string): string { const normalized = command.trim(); if (!normalized || /[\r\n]/.test(normalized)) throw new Error("SCUM RCON 命令必须是单行文本。"); return normalized; } async function dispatchSCUMRCONCommand(actions: SCUMWorkspaceActions, command: string, idempotencyKey: string): Promise { if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用。"); const result = await actions.dispatch({ requestId: idempotencyKey, action: "remote.access.request", payload: { capability: "remote.run.rcon.command", declarationKey: "scum-management", targetKey: "scum-management", idempotencyKey, timeoutSeconds: "30", maxAttempts: "1", "input.command": command } }); if (result.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SCUM RCON 命令未进入 Run 队列。"); return result; } 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); }