import { deleteGiftDefinition, deleteEventDefinition, deleteEventProduce, emptySCUMSurfaceData, loadSCUMSurface, parseGiftItems, parseGiftCommands, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventDefinition, saveEventProduce, saveGiftDefinition, saveMapSettings, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "./page-data.js"; import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleIconUrl, type ScumCatalogEntry } from "./scum-catalog.js"; type StateSetter = (next: T | ((previous: T) => T)) => void; type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; stopPropagation?: () => void }; type GiftTab = "definitions" | "claims" | "deliveries" | "timed"; type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; type ScumMapLayer = "terrain" | "topo" | "night"; type CatalogPickTarget = "gift-items" | "produce-item"; type PlayerPanelKind = "closed" | "gifts" | "items" | "history" | "trajectory"; type PlayerPanelState = { kind: PlayerPanelKind; playerId: string }; type MapViewState = { zoom: number; x: number; y: number }; type MapPlaybackState = { playing: boolean; value: number; speed: number }; type MapTimeWindow = { from: number; to: number; label: string }; type MapTrackPoint = { key: string; row: RecordMap; x: number; y: number; time: number; riding: boolean }; type MapTrackSegment = { key: string; mode: "walk" | "ride"; points: MapTrackPoint[] }; type MapTrack = { key: string; kind: "player" | "vehicle"; label: string; color: string; points: MapTrackPoint[]; walk: number; ride: number; from: number; to: number; vehicles: string[] }; type PointerEventLike = { pointerId?: number; clientX?: number; clientY?: number; button?: number; currentTarget?: { setPointerCapture?: (pointerId?: number) => void; releasePointerCapture?: (pointerId?: number) => void }; preventDefault?: () => void; stopPropagation?: () => void }; const scumMapLayerImages = { terrain: new URL("../assets/map/scum-map-terrain-4096.webp", import.meta.url).href, topo: new URL("../assets/map/scum-map-topo-4096.webp", import.meta.url).href, night: new URL("../assets/map/scum-map-night-4096.webp", import.meta.url).href, zones: new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href } as const; const scumMapBaseLayers: ReadonlyArray = [["terrain", "地形"], ["topo", "等高线"], ["night", "夜间"]]; const scumCatalogKinds: ReadonlyArray = [["all", "全部"], ["item", "物品"], ["vehicle", "载具"], ["zombie", "丧尸"], ["animal", "动物"], ["armed-npc", "NPC"]]; const scumCatalogPickLimit = 48; const scumMapSize = 4096; const scumSurfaceRefreshMs = 15000; const scumMapZoomMin = 1; const scumMapZoomMax = 8; const scumMapTrackPointLimit = 400; const scumMapTrackLimit = 24; const scumMapSegmentGapMs = 15 * 60 * 1000; const scumMapSegmentJump = 52000; const scumMapDefaultView: MapViewState = { zoom: 1, x: 0, y: 0 }; const scumMapPlaybackIdle: MapPlaybackState = { playing: false, value: 1, speed: 1 }; const scumMapTimePresets: ReadonlyArray = [["all", "全部时段"], ["15m", "最近 15 分钟"], ["1h", "最近 1 小时"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["custom", "自定义起止"]]; const scumMapPresetMs: Record = { "15m": 15 * 60 * 1000, "1h": 60 * 60 * 1000, "6h": 6 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000 }; const scumMapTrackColors = ["#4ECDC4", "#FF8A6B", "#F7DC6F", "#85C1E9", "#BB8FCE", "#98D8C8", "#FFA07A", "#45B7D1"]; const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false }; export type ReactLike = { createElement: (...args: any[]) => any; useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void; useRef?: (initial: T) => { current: T }; useState?: (initialState: T | (() => T)) => [T, StateSetter]; }; export type SCUMPageContext = { pageKey?: string; pageTitle?: string; serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspaceActions?: SCUMWorkspaceActions; }; type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData }; type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string }; export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { const e = react.createElement; const [state, setState] = usePluginState(react, { status: "loading" }); const [action, setAction] = usePluginState(react, { status: "idle" }); const [playerSearch, setPlayerSearch] = usePluginState(react, ""); const [playerStatus, setPlayerStatus] = usePluginState(react, "all"); const [playerPanel, setPlayerPanel] = usePluginState(react, { kind: "closed", playerId: "" }); const [squadSearch, setSquadSearch] = usePluginState(react, ""); const [selectedSquadId, setSelectedSquadId] = usePluginState(react, ""); const [activityStatus, setActivityStatus] = usePluginState(react, "all"); const [eventId, setEventId] = usePluginState(react, ""); const [eventName, setEventName] = usePluginState(react, ""); const [eventType, setEventType] = usePluginState(react, "range"); const [eventSchedule, setEventSchedule] = usePluginState(react, ""); const [eventCommand, setEventCommand] = usePluginState(react, ""); const [eventClass, setEventClass] = usePluginState(react, "1"); const [eventPlacard, setEventPlacard] = usePluginState(react, ""); const [eventPercent, setEventPercent] = usePluginState(react, "100"); const [eventDuration, setEventDuration] = usePluginState(react, "1800"); const [eventNpc, setEventNpc] = usePluginState(react, "0"); const [eventItem, setEventItem] = usePluginState(react, "0"); const [eventZombie, setEventZombie] = usePluginState(react, "0"); const [eventAnimal, setEventAnimal] = usePluginState(react, "0"); const [produceEventId, setProduceEventId] = usePluginState(react, ""); const [produceId, setProduceId] = usePluginState(react, ""); const [produceTradeGoodsId, setProduceTradeGoodsId] = usePluginState(react, ""); const [producePercent, setProducePercent] = usePluginState(react, "100"); const [produceValue, setProduceValue] = usePluginState(react, "1"); const [produceRadius, setProduceRadius] = usePluginState(react, "0"); const [produceX, setProduceX] = usePluginState(react, "0"); const [produceY, setProduceY] = usePluginState(react, "0"); const [produceZ, setProduceZ] = usePluginState(react, "0"); const [giftTab, setGiftTab] = usePluginState(react, "definitions"); const [giftCode, setGiftCode] = usePluginState(react, ""); const [giftName, setGiftName] = usePluginState(react, ""); const [giftItems, setGiftItems] = usePluginState(react, ""); const [giftCommands, setGiftCommands] = usePluginState(react, ""); const [giftClass, setGiftClass] = usePluginState(react, "5"); const [giftAudience, setGiftAudience] = usePluginState(react, "all"); const [giftNumber, setGiftNumber] = usePluginState(react, "1"); const [giftAchievement, setGiftAchievement] = usePluginState(react, "0"); const [giftAchievementNumber, setGiftAchievementNumber] = usePluginState(react, "0"); const [deliveryGift, setDeliveryGift] = usePluginState(react, ""); const [deliveryPlayer, setDeliveryPlayer] = usePluginState(react, ""); const [mapLayers, setMapLayers] = usePluginState>(react, { players: true, vehicles: true, flags: true, regions: true, other: true }); const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, ""); const [mapView, setMapView] = usePluginState(react, scumMapDefaultView); const [mapTimePreset, setMapTimePreset] = usePluginState(react, "all"); const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, ""); const [mapTimeTo, setMapTimeTo] = usePluginState(react, ""); const [mapUsers, setMapUsers] = usePluginState(react, undefined); const [mapTrackKey, setMapTrackKey] = usePluginState(react, ""); const [mapPlayback, setMapPlayback] = usePluginState(react, scumMapPlaybackIdle); const mapViewportRef = react.useRef ? react.useRef(null) : { current: null as HTMLElement | null }; const [eventEditorOpen, setEventEditorOpen] = usePluginState(react, false); const [produceEditorOpen, setProduceEditorOpen] = usePluginState(react, false); const [giftEditorOpen, setGiftEditorOpen] = usePluginState(react, false); const [mapBaseLayer, setMapBaseLayer] = usePluginState(react, undefined); const [mapZonesOverlay, setMapZonesOverlay] = usePluginState(react, undefined); const [catalogSearch, setCatalogSearch] = usePluginState(react, ""); const [catalogKind, setCatalogKind] = usePluginState(react, "all"); const [catalogOpen, setCatalogOpen] = usePluginState(react, false); const [refreshSignal, setRefreshSignal] = usePluginState(react, 0); const pageKey = input.pageKey ?? "players"; if (react.useEffect) react.useEffect(() => { let inFlight = false; let generation = 0; let disposed = false; const refresh = () => { if (inFlight || disposed) return; inFlight = true; const currentGeneration = ++generation; if (!input.serverInstanceId || !input.workspaceActions?.scum || !input.workspaceActions?.pluginData) { setState({ status: "error", reason: "插件页面没有绑定服务器、平台 SCUM 数据能力或通用 pluginData 能力。" }); inFlight = false; return; } void loadSCUMSurface(input.workspaceActions, pageKey) .then((data) => { if (!disposed && currentGeneration === generation) setState({ status: "ready", data }); }) .catch((error) => { if (!disposed && currentGeneration === generation) setState({ status: "error", reason: errorMessage(error, "SCUM 插件数据读取失败。") }); }) .finally(() => { inFlight = false; }); }; if (playerPanel.kind === "closed") refresh(); if (playerPanel.kind !== "closed") return; const interval = setInterval(refresh, scumSurfaceRefreshMs); return () => { disposed = true; generation += 1; clearInterval(interval); }; }, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind, refreshSignal]); if (react.useEffect) react.useEffect(() => { const node = mapViewportRef.current; if (pageKey !== "live-map" || !node || typeof node.addEventListener !== "function") return; const onWheel = (event: { deltaY: number; clientX: number; clientY: number; preventDefault: () => void }) => { event.preventDefault(); const rect = node.getBoundingClientRect(); setMapView((previous) => zoomMapView(previous, event.deltaY < 0 ? 1.16 : 1 / 1.16, event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2)); }; node.addEventListener("wheel", onWheel, { passive: false }); setMapView((previous) => (previous.zoom === scumMapZoomMin && previous.x === 0 && previous.y === 0 ? defaultMapView(node) : previous)); return () => { node.removeEventListener("wheel", onWheel); }; }, [pageKey, state.status, mapViewportRef, setMapView]); if (react.useEffect) react.useEffect(() => { if (!mapPlayback.playing) return; const timer = setInterval(() => setMapPlayback((previous) => advanceMapPlayback(previous)), 90); return () => { clearInterval(timer); }; }, [mapPlayback.playing, setMapPlayback]); 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 === "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, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, eventCommand, setEventCommand, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber, deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint, mapView, setMapView, mapViewportRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo, mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback, eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen, mapBaseLayer, setMapBaseLayer, mapZonesOverlay, setMapZonesOverlay, catalogSearch, setCatalogSearch, catalogKind, setCatalogKind, catalogOpen, setCatalogOpen, setAction, refresh: () => setRefreshSignal((value) => value + 1) }) : null ); } type ViewState = { playerSearch: string; setPlayerSearch: StateSetter; playerStatus: string; setPlayerStatus: StateSetter; playerPanel: PlayerPanelState; setPlayerPanel: StateSetter; squadSearch: string; setSquadSearch: StateSetter; selectedSquadId: string; setSelectedSquadId: StateSetter; activityStatus: string; setActivityStatus: StateSetter; giftTab: GiftTab; setGiftTab: StateSetter; eventId: string; setEventId: StateSetter; eventName: string; setEventName: StateSetter; eventType: string; setEventType: StateSetter; eventSchedule: string; setEventSchedule: StateSetter; eventCommand: string; setEventCommand: StateSetter; eventClass: string; setEventClass: StateSetter; eventPlacard: string; setEventPlacard: StateSetter; eventPercent: string; setEventPercent: StateSetter; eventDuration: string; setEventDuration: StateSetter; eventNpc: string; setEventNpc: StateSetter; eventItem: string; setEventItem: StateSetter; eventZombie: string; setEventZombie: StateSetter; eventAnimal: string; setEventAnimal: StateSetter; produceEventId: string; setProduceEventId: StateSetter; produceId: string; setProduceId: StateSetter; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter; producePercent: string; setProducePercent: StateSetter; produceValue: string; setProduceValue: StateSetter; produceRadius: string; setProduceRadius: StateSetter; produceX: string; setProduceX: StateSetter; produceY: string; setProduceY: StateSetter; produceZ: string; setProduceZ: StateSetter; giftCode: string; setGiftCode: StateSetter; giftName: string; setGiftName: StateSetter; giftItems: string; setGiftItems: StateSetter; giftCommands: string; setGiftCommands: StateSetter; giftClass: string; setGiftClass: StateSetter; giftAudience: string; setGiftAudience: StateSetter; giftNumber: string; setGiftNumber: StateSetter; giftAchievement: string; setGiftAchievement: StateSetter; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter; deliveryGift: string; setDeliveryGift: StateSetter; deliveryPlayer: string; setDeliveryPlayer: StateSetter; mapLayers: Record; setMapLayers: StateSetter>; selectedMapPoint: string; setSelectedMapPoint: StateSetter; setAction: StateSetter; refresh: () => void; mapView: MapViewState; setMapView: StateSetter; mapViewportRef: { current: HTMLElement | null }; mapTimePreset: string; setMapTimePreset: StateSetter; mapTimeFrom: string; setMapTimeFrom: StateSetter; mapTimeTo: string; setMapTimeTo: StateSetter; mapUsers: string[] | undefined; setMapUsers: StateSetter; mapTrackKey: string; setMapTrackKey: StateSetter; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter; eventEditorOpen: boolean; setEventEditorOpen: StateSetter; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter; mapBaseLayer: ScumMapLayer | undefined; setMapBaseLayer: StateSetter; mapZonesOverlay: boolean | undefined; setMapZonesOverlay: StateSetter; catalogSearch: string; setCatalogSearch: StateSetter; catalogKind: string; setCatalogKind: StateSetter; catalogOpen: boolean; setCatalogOpen: StateSetter; }; function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { switch (pageKey) { case "players": return playersSurface(e, data, input, view); case "squads": return squadsSurface(e, data, view); case "live-map": return mapSurface(e, data, input, view); case "gifts": return giftsSurface(e, data, input, view); case "workflows": case "activity": return activitiesSurface(e, data, input, view); default: return playersSurface(e, data, input, view); } } function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const search = view.playerSearch.trim().toLowerCase(); const players = data.players.filter((player) => matchesText(player, search, "displayName", "playerName", "gamePlayerId", "playerId", "userProfileId", "profileId", "steamId", "squadName", "lastLoginIp", "loginIp", "ipAddress", "ip") && (view.playerStatus === "all" || (view.playerStatus === "online") === playerOnline(player))); const onlineCount = data.players.filter(playerOnline).length; const selectedPlayer = data.players.find((player) => playerKey(player) === view.playerPanel.playerId); return e("div", { className: "console-record-list scum-surface" }, e("div", { className: "resource-filter-bar scum-filter-bar" }, labeledField(e, "搜索用户", e("input", { type: "search", value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / UUID / 登录 IP / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) })), labeledField(e, "在线状态", e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知"))), e("span", { className: "scum-online-summary" }, "在线用户 ", e("strong", null, `${onlineCount} / ${data.players.length}`)), players.length !== data.players.length ? e("span", { className: "page-status" }, `当前显示 ${players.length} 人`) : null ), e("div", { className: "provider-table-wrap scum-table-wrap" }, e("table", { className: "resource-table scum-user-table" }, e("thead", null, e("tr", null, ["用户名", "Steam ID", "渣币", "登录 IP", "队伍", "状态", "最后活动", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))), e("tbody", null, players.length ? players.map((player, index) => playerTableRow(e, player, index, data, input, view)) : e("tr", null, e("td", { colSpan: 8 }, e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。")))) ) ), selectedPlayer && view.playerPanel.kind !== "closed" ? playerDrawer(e, selectedPlayer, data, input, view) : null ); } function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index: number, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const name = textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户"; const steamId = textField(player, "steamId", "providerId"); const uuid = playerUuid(player); const open = (kind: PlayerPanelKind) => view.setPlayerPanel({ kind, playerId: playerKey(player) }); return e("tr", { key: idOf(player, `player-${index}`) }, e("td", null, e("strong", null, name)), e("td", null, e("span", { className: "provider-id scum-mono-cell", title: uuid ? `UUID ${uuid}` : undefined }, steamId || "未同步")), e("td", null, e("strong", null, playerCurrency(player))), e("td", null, e("span", { className: "provider-id" }, textField(player, "lastLoginIp", "loginIp", "ipAddress", "ip") || "未同步")), e("td", null, e("span", null, textField(player, "squadName", "squadId") || "未加入"), e("span", { className: "provider-id" }, textField(player, "squadId") ? `ID ${textField(player, "squadId")}` : "")), e("td", null, e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知"), e("span", { className: "provider-id" }, numField(player, "pingMs") === "--" ? "" : `${numField(player, "pingMs")} ms`)), e("td", null, userDateField(player, "lastSeenAt", "lastLoginTime", "lastLoginAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")), e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, view, open)) ); } function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, view: ViewState, open: (kind: PlayerPanelKind) => void) { return e("details", { className: "inline-action-control" }, e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"), e("div", { className: "inline-action-menu", role: "menu", "aria-label": "用户操作" }, e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("gifts") }, "礼包状态"), e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("items") }, "他的物品"), e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("history") }, "登录历史"), e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("trajectory") }, "用户轨迹") ) ); } function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const kind = view.playerPanel.kind; const name = textField(player, "displayName", "playerName", "name") || playerKey(player); const close = () => view.setPlayerPanel({ kind: "closed", playerId: "" }); const content = kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : kind === "history" ? playerHistoryPanel(e, player) : playerTrajectoryPanel(e, player, data); const title = kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : kind === "history" ? "登录历史" : "用户轨迹"; return e("div", { className: "confirm-backdrop", role: "presentation", onClick: close }, e("aside", { className: "drawer-panel", role: "dialog", "aria-modal": "true", "aria-label": `${name} / ${title}`, onClick: (event: InputEvent) => event.stopPropagation?.() }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")), e("div", { className: "console-record-meta scum-player-overview" }, e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`), e("span", null, `UUID ${playerUuid(player) || "未同步"}`), e("span", null, `登录 IP ${textField(player, "lastLoginIp", "loginIp", "ipAddress", "ip") || "未同步"}`), e("span", null, `网络相关 ${shortHash(textField(player, "networkCorrelation")) || "未同步"}`), e("span", null, `Fame ${numField(player, "famePoints")}`), e("span", null, `渣币 ${playerCurrency(player)} · 金条 ${playerGold(player)}`), e("span", null, `上次登录 ${userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")}`)), content ) ); } function playerGiftPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const claims = playerRecords(data.giftClaims, player); const pending = playerRecords(data.pendingGifts, player); const deliveries = playerRecords(data.giftDeliveries, player); const reset = (row: RecordMap, mode: "claim" | "pending") => runAction(view.setAction, "正在重置礼包状态…", async () => { if (mode === "claim") await resetGiftClaim(input.workspaceActions ?? {}, row); else await resetPendingGift(input.workspaceActions ?? {}, row); view.refresh(); return "礼包状态已重置。"; }); return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "状态来自插件礼包集合。重置会让该记录回到可领取状态,不会生成礼包定义或样例记录。"), giftStatusRecord(e, "已领取", claims, (row) => e("button", { type: "button", className: "runtime-action-item", disabled: !input.workspaceActions?.pluginData, onClick: () => reset(row, "claim") }, "重置状态")), giftStatusRecord(e, "待领取", pending, (row) => e("button", { type: "button", className: "runtime-action-item", disabled: !input.workspaceActions?.pluginData, onClick: () => reset(row, "pending") }, "重置状态")), giftStatusRecord(e, "发放记录", deliveries) ); } function giftStatusRecord(e: ReactLike["createElement"], title: string, rows: RecordMap[], action?: (row: RecordMap) => unknown) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, e("span", null, textField(row, "giftName", "giftCode", "giftType") || "礼包未命名"), e("strong", null, textField(row, "status") || "未知"), e("strong", null, dateField(row, "claimedAt", "receivedAt", "deliveredAt", "createdAt")), action ? e("span", { className: "console-row-actions" }, action(row)) : null)) : e("p", { className: "page-status" }, "暂无该用户的真实礼包记录。"))); } function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) { const items = field(player, "items", "inventory", "inventoryItems"); const rows = Array.isArray(items) ? items : []; return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "物品清单只展示平台用户事实中已经同步的内容。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((item, index) => e("div", { key: `item-${index}`, className: "console-row" }, e("span", null, isRecord(item) ? textField(item, "name", "label", "itemId", "className") || "未命名物品" : String(item)), e("strong", null, isRecord(item) ? `× ${numField(item, "quantity", "count")}` : ""))) : e("p", { className: "page-status" }, "没有该用户的真实物品记录,等待 Run 推送用户事实。"))); } function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap) { const rows = [ { label: "最近登录", at: textField(player, "lastLoginAt", "lastLoginTime", "lastLoginObservedAt"), meta: textField(player, "lastLoginIp", "loginIp", "ipAddress", "ip") || "无登录 IP" }, { label: "最后活动", at: textField(player, "lastSeenAt", "lastActivityAt", "updatedAt"), meta: playerOnline(player) ? "在线" : "离线/未知" } ].filter((row) => row.at); return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录状态来自平台 scum_user 表,由 Run 推送的登录事实维护。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: `history-${index}`, className: "console-row" }, e("span", null, row.label), e("strong", null, dateField(row as RecordMap, "at")), e("strong", null, row.meta))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); } function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { const rows = playerRecords(data.trajectories, player).filter((row) => layerOf(row) === "players" && hasCoordinates(positionOf(row))).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 120); return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自平台 scum_user_trajectory 表;乘车状态使用轨迹记录声明的载具。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = textField(row, "riddenVehicleId", "gameVehicleId"); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", { className: "scum-trajectory-vehicle" }, trajectoryVehicleIcon(row) ? e("img", { className: "scum-catalog-icon", src: trajectoryVehicleIcon(row), alt: "" }) : null, ride ? `乘坐 ${ride}` : textField(row, "source") || "平台轨迹表")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。"))); } function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); } function playerIdentities(player: RecordMap): string[] { return ["steamId", "gamePlayerId", "playerId", "userProfileId", "profileId", "id"].map((key) => textField(player, key)).filter(Boolean); } function playerKey(player: RecordMap): string { return textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey"); } function userDateField(row: RecordMap | undefined, ...keys: string[]): string { return textField(row, ...keys) ? dateField(row, ...keys) : "未同步"; } function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) { const search = view.squadSearch.trim().toLowerCase(); const squads = data.squads.filter((squad) => matchesText(squad, search, "name", "squadId", "leaderProfileId")); const activeId = view.selectedSquadId || textField(squads[0], "squadId", "id"); const roster = data.members.filter((member) => textField(member, "squadId") === activeId); return e("div", { className: "overview-two-col" }, e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "队伍"), e("span", { className: "page-status" }, `${squads.length} 支`)), e("div", { className: "resource-filter-bar scum-filter-bar" }, labeledField(e, "搜索队伍", e("input", { type: "search", value: view.squadSearch, "aria-label": "搜索队伍", placeholder: "队名 / 队长 / 队伍 ID", onChange: (event: InputEvent) => view.setSquadSearch(inputValue(event)) }))), e("div", { className: "console-row-list" }, squads.length ? squads.map((squad, index) => { const squadId = textField(squad, "squadId", "id"); const memberCount = data.members.filter((member) => textField(member, "squadId") === squadId).length; return e("button", { key: idOf(squad, `squad-${index}`), type: "button", className: "console-row", onClick: () => view.setSelectedSquadId(squadId) }, e("span", null, textField(squad, "name") || squadId || "未命名队伍"), e("strong", null, `成员 ${memberCount || numField(squad, "memberCount")} / ${numField(squad, "memberLimit", "member_limit")}`), e("strong", null, `队长 ${textField(squad, "leaderName", "leaderProfileId") || "unknown"}`), e("strong", null, `分数 ${numField(squad, "score")}`), e("strong", null, textField(squad, "message", "info") || "无队伍公告")); }) : e("p", { className: "page-status" }, "没有符合筛选条件的真实队伍记录。")) ), e("div", { className: "console-record-list" }, tablePanel(e, "队伍成员", roster, (member) => [textField(member, "displayName", "gamePlayerId") || "unknown", textField(member, "rank") || "member", `Score ${numField(member, "score")}`, dateField(member, "lastLoginAt", "lastMemberLogin", "lastSeenAt")]), tablePanel(e, "领地旗帜", data.flags.filter((flag) => !activeId || textField(flag, "ownerSquadId", "squadId") === activeId), (flag) => [textField(flag, "name", "flagId") || "flag", textField(flag, "ownershipConfidence") || "unknown", coords(positionOf(flag)), freshness(flag)]) ) ); } function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const actions = input.workspaceActions; const runsByEvent = new Map(); for (const run of data.eventRuns) runsByEvent.set(textField(run, "eventId", "activityId"), run); const events = data.events.filter((event) => { const run = runsByEvent.get(textField(event, "id", "eventId")); const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown"; return view.activityStatus === "all" || status === view.activityStatus; }); const statuses = unique(data.events.map((event) => textField(runsByEvent.get(textField(event, "id", "eventId")), "status", "state") || textField(event, "status", "state")).filter(Boolean)); const tradeEvents = recentRows(data.tradeEvents, "observedAt", "occurredAt", "createdAt"); const saveEvent = () => runAction(view.setAction, "正在保存活动定义…", async () => { const id = view.eventId.trim(); const name = view.eventName.trim(); if (!id || !name) throw new Error("活动编号和名称不能为空。"); await saveEventDefinition(actions ?? {}, { id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(), rconCommand: view.eventCommand.trim(), command: view.eventCommand.trim(), placard: view.eventPlacard.trim(), announcement: view.eventPlacard.trim(), percent: integerInput(view.eventPercent, 100), probability: integerInput(view.eventPercent, 100), npc: minimumIntegerInput(view.eventNpc, 0, "NPC 数量"), item: minimumIntegerInput(view.eventItem, 0, "物品数量"), zombie: minimumIntegerInput(view.eventZombie, 0, "僵尸数量"), animal: minimumIntegerInput(view.eventAnimal, 0, "动物数量"), status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: minimumIntegerInput(view.eventDuration, 1, "活动持续秒数"), updatedAt: new Date().toISOString() }); view.refresh(); return `活动 ${name} 已保存。`; }); const saveProduce = () => runAction(view.setAction, "正在保存活动生成项…", async () => { const eventId = view.produceEventId.trim() || view.eventId.trim() || textField(data.events[0], "id", "eventId"); const tradeGoodsId = view.produceTradeGoodsId.trim(); if (!eventId || !tradeGoodsId) throw new Error("活动编号和物品编号不能为空。"); await saveEventProduce(actions ?? {}, { id: view.produceId.trim(), eventId, tradeGoodsId, percent: integerInput(view.producePercent, 100), value: integerInput(view.produceValue, 1), r: numberInput(view.produceRadius, 0), x: numberInput(view.produceX, 0), y: numberInput(view.produceY, 0), z: numberInput(view.produceZ, 0), updatedAt: new Date().toISOString() }); view.refresh(); return "活动生成项已保存。"; }); const activityHistory = data.activityEvents.filter((event) => Boolean(textField(event, "occurredAt", "createdAt")) && textField(event, "taskKind").toLowerCase() !== "active-task"); return e("div", { className: "console-record-list" }, statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["物品列表", data.tradeGoods.length], ["交易记录", data.tradeEvents.length]]), e("div", { className: "overview-two-col" }, e("details", { className: "console-module scum-editor", open: view.eventEditorOpen, onToggle: (event: InputEvent) => view.setEventEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新活动"), e("span", { className: "page-status" }, view.eventId || view.eventName ? "编辑中" : "点击展开")), labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", onChange: (event: InputEvent) => view.setEventId(inputValue(event)) })), labeledField(e, "活动名称", e("input", { value: view.eventName, "aria-label": "活动名称", placeholder: "例如 空投补给", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) })), labeledField(e, "生成类型", e("select", { value: view.eventClass, "aria-label": "生成类型", onChange: (event: InputEvent) => view.setEventClass(inputValue(event)) }, e("option", { value: "1" }, "范围生成"), e("option", { value: "2" }, "固定坐标生成"))), labeledField(e, "活动计划", e("input", { value: view.eventSchedule, "aria-label": "活动计划", placeholder: "Cron(留空为手动)", onChange: (event: InputEvent) => view.setEventSchedule(inputValue(event)) })), labeledField(e, "RCON 命令", e("input", { value: view.eventCommand, "aria-label": "RCON 命令", placeholder: "例如 #start_event event_supply_drop", onChange: (event: InputEvent) => view.setEventCommand(inputValue(event)) })), labeledField(e, "活动公告", e("input", { value: view.eventPlacard, "aria-label": "活动公告", placeholder: "活动开始时显示的公告", onChange: (event: InputEvent) => view.setEventPlacard(inputValue(event)) })), labeledField(e, "触发概率 (%)", e("input", { value: view.eventPercent, "aria-label": "活动概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setEventPercent(inputValue(event)) })), labeledField(e, "持续秒数", e("input", { value: view.eventDuration, "aria-label": "活动持续秒数", type: "number", min: 1, placeholder: "1800", onChange: (event: InputEvent) => view.setEventDuration(inputValue(event)) })), e("div", { className: "console-row-actions" }, labeledField(e, "NPC 数量", e("input", { value: view.eventNpc, "aria-label": "NPC 数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventNpc(inputValue(event)) })), labeledField(e, "物品数量", e("input", { value: view.eventItem, "aria-label": "物品数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventItem(inputValue(event)) })), labeledField(e, "僵尸数量", e("input", { value: view.eventZombie, "aria-label": "僵尸数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventZombie(inputValue(event)) })), labeledField(e, "动物数量", e("input", { value: view.eventAnimal, "aria-label": "动物数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventAnimal(inputValue(event)) }))), e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveEvent }, "保存活动") ), e("article", { className: "console-module" }, e("h2", null, "状态筛选"), e("select", { value: view.activityStatus, "aria-label": "活动状态", onChange: (event: InputEvent) => view.setActivityStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), statuses.map((status) => e("option", { key: status, value: status }, status))), e("p", { className: "page-status" }, "活动定义属于插件;SCUM 原生 event_round 和 quest/task 只作为运行事实展示。") ) ), e("details", { className: "console-module scum-editor", open: view.produceEditorOpen, onToggle: (event: InputEvent) => view.setProduceEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "活动生成项"), e("span", { className: "page-status" }, view.produceEventId || view.produceId ? "编辑中" : "点击展开")), e("div", { className: "console-row-actions" }, labeledField(e, "活动编号", e("input", { value: view.produceEventId, "aria-label": "生成项活动编号", placeholder: "默认使用上方活动", onChange: (event: InputEvent) => view.setProduceEventId(inputValue(event)) })), labeledField(e, "生成项编号", e("input", { value: view.produceId, "aria-label": "生成项编号", placeholder: "更新时填写", onChange: (event: InputEvent) => view.setProduceId(inputValue(event)) })), labeledField(e, "物品编号", e("input", { value: view.produceTradeGoodsId, "aria-label": "生成物品编号", placeholder: "TradeGoods ID", onChange: (event: InputEvent) => view.setProduceTradeGoodsId(inputValue(event)) })), labeledField(e, "概率 (%)", e("input", { value: view.producePercent, "aria-label": "生成概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setProducePercent(inputValue(event)) })), labeledField(e, "数量", e("input", { value: view.produceValue, "aria-label": "生成数量", type: "number", placeholder: "1", onChange: (event: InputEvent) => view.setProduceValue(inputValue(event)) }))), catalogPicker(e, view, "produce-item"), e("div", { className: "console-row-actions" }, labeledField(e, "半径", e("input", { value: view.produceRadius, "aria-label": "生成半径", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceRadius(inputValue(event)) })), labeledField(e, "X", e("input", { value: view.produceX, "aria-label": "生成 X", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceX(inputValue(event)) })), labeledField(e, "Y", e("input", { value: view.produceY, "aria-label": "生成 Y", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceY(inputValue(event)) })), labeledField(e, "Z", e("input", { value: view.produceZ, "aria-label": "生成 Z", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceZ(inputValue(event)) })), e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveProduce }, "保存生成项")), e("div", { className: "console-row-list" }, data.eventProduces.length ? data.eventProduces.map((produce, index) => e("div", { key: idOf(produce, `produce-${index}`), className: "console-row" }, catalogIconStrip(e, catalogIconsForValue(textField(produce, "tradeGoodsId", "trade_goods_id"))), e("span", null, `${textField(produce, "eventId", "event")} / ${tradeGoodsLabel(data.tradeGoods, textField(produce, "tradeGoodsId", "trade_goods_id"))}`), e("strong", null, `${numField(produce, "percent")}% × ${numField(produce, "value")}`), e("strong", null, `R ${numField(produce, "r")} · ${coords(produce)}`), e("button", { type: "button", className: "icon-command", onClick: () => { view.setProduceEditorOpen(true); view.setProduceEventId(textField(produce, "eventId", "event")); view.setProduceId(textField(produce, "id", "produceId")); view.setProduceTradeGoodsId(textField(produce, "tradeGoodsId", "trade_goods_id")); view.setProducePercent(numField(produce, "percent")); view.setProduceValue(numField(produce, "value")); view.setProduceRadius(numField(produce, "r")); view.setProduceX(numField(produce, "x")); view.setProduceY(numField(produce, "y")); view.setProduceZ(numField(produce, "z")); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除生成项…", async () => { await deleteEventProduce(actions ?? {}, produce); view.refresh(); return "生成项已删除。"; }) }, "删除"))) : e("p", { className: "page-status" }, "暂无活动生成项。")) ), events.length ? events.map((event, index) => { const eventId = textField(event, "id", "eventId"); const run = runsByEvent.get(eventId); const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown"; return e("article", { key: idOf(event, `event-${index}`), className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, textField(event, "name", "title") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status)), e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")), e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "primary-command", disabled: !actions?.dispatch || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"), e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventEditorOpen(true); view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventCommand(textField(event, "rconCommand", "command")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除") ) ); }) : e("p", { className: "page-status" }, "没有符合状态筛选的真实活动。"), e("div", { className: "overview-two-col" }, tablePanel(e, "原生赛事轮次", data.nativeEventRounds, (event) => [textField(event, "eventId") || "event", textField(event, "state") || "unknown", `Kills ${numField(event, "enemyKills")}`, dateField(event, "startTime")]), tablePanel(e, "Quest / Task", data.tasks, (task) => [textField(task, "taskKind") || "task", textField(task, "dataAssetPath") || "unknown", textField(task, "state") || "unknown", textField(task, "userProfileId") || "unknown"]) ), e("div", { className: "overview-two-col" }, itemCatalogPanel(e, data.tradeGoods), tablePanel(e, "最近商人交易", tradeEvents, (event) => [tradeGoodsLabel(data.tradeGoods, textField(event, "itemCode", "code", "tradeGoodsId")), `${tradeActionLabel(event)} × ${numField(event, "quantity", "itemCount")}`, `玩家 ${shortHash(textField(event, "steamId", "playerId"))}`, `价格 ${numField(event, "price", "currencyDelta")}`, dateField(event, "observedAt", "occurredAt", "createdAt")]) ), tablePanel(e, "最近活动记录", [...data.eventRuns, ...activityHistory], (event) => [textField(event, "eventName", "type", "kind", "activityType") || "event", textField(event, "subjectName", "eventId", "subjectId", "subject") || "unknown", textField(event, "status", "result", "state") || "unknown", dateField(event, "startedAt", "occurredAt", "createdAt")]) ); } function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const actions = input.workspaceActions; const saveGift = () => runAction(view.setAction, "正在保存礼包定义…", async () => { const code = view.giftCode.trim(); const name = view.giftName.trim(); if (!code || !name) throw new Error("礼包编号和名称不能为空。"); const items = parseGiftItems(view.giftItems); const commands = parseGiftCommands(view.giftCommands); if (!items.length && !commands.length) throw new Error("礼包至少需要一项物品或命令。"); await saveGiftDefinition(actions ?? {}, { code, name, class: integerInput(view.giftClass, 5), audience: view.giftAudience, number: integerInput(view.giftNumber, 1), achievement: integerInput(view.giftAchievement, 0), achievementNumber: integerInput(view.giftAchievementNumber, 0), items, commands, status: "active", updatedAt: new Date().toISOString() }); view.refresh(); return `礼包 ${name} 已保存。`; }); const queueDelivery = () => runAction(view.setAction, "正在创建发放记录…", async () => { const giftCode = view.deliveryGift || textField(data.gifts[0], "code", "id"); const playerId = view.deliveryPlayer || textField(data.players[0], "gamePlayerId", "steamId", "id"); const gift = data.gifts.find((item) => textField(item, "code", "id") === giftCode); const player = data.players.find((item) => textField(item, "gamePlayerId", "steamId", "id") === playerId); if (!gift || !player) throw new Error("请选择礼包和用户。"); await queueGiftDelivery(actions ?? {}, gift, player); view.refresh(); return "礼包发放命令已进入执行队列。"; }); return e("div", { className: "console-record-list" }, statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["物品列表", data.tradeGoods.length]]), e("div", { className: "console-row-actions", role: "tablist", "aria-label": "礼包视图" }, giftTabButton(e, view, "definitions", "礼包定义"), giftTabButton(e, view, "claims", "领取/待领"), giftTabButton(e, view, "deliveries", "发放记录"), giftTabButton(e, view, "timed", "游戏定时记录") ), view.giftTab === "definitions" ? e("div", { className: "overview-two-col" }, e("details", { className: "console-module scum-editor", open: view.giftEditorOpen, onToggle: (event: InputEvent) => view.setGiftEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新礼包"), e("span", { className: "page-status" }, view.giftCode || view.giftName ? "编辑中" : "点击展开")), labeledField(e, "礼包编号", e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "例如 welcome_pack", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) })), labeledField(e, "礼包名称", e("input", { value: view.giftName, "aria-label": "礼包名称", placeholder: "例如 新人礼包", onChange: (event: InputEvent) => view.setGiftName(inputValue(event)) })), labeledField(e, "礼包周期", e("select", { value: view.giftClass, "aria-label": "礼包周期", onChange: (event: InputEvent) => view.setGiftClass(inputValue(event)) }, [["1", "每日"], ["2", "每周"], ["3", "每月"], ["4", "每年"], ["5", "一次"], ["6", "每日五次"]].map(([value, label]) => e("option", { key: value, value }, label)))), labeledField(e, "适用玩家", e("select", { value: view.giftAudience, "aria-label": "适用玩家", onChange: (event: InputEvent) => view.setGiftAudience(inputValue(event)) }, e("option", { value: "all" }, "全部玩家"), e("option", { value: "pve" }, "PVE 玩家"), e("option", { value: "pvp" }, "PVP 玩家"))), labeledField(e, "发放次数", e("input", { value: view.giftNumber, "aria-label": "发放次数", type: "number", placeholder: "1", onChange: (event: InputEvent) => view.setGiftNumber(inputValue(event)) })), e("div", { className: "console-row-actions" }, labeledField(e, "成就类型", e("input", { value: view.giftAchievement, "aria-label": "成就类型", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievement(inputValue(event)) })), labeledField(e, "成就值", e("input", { value: view.giftAchievementNumber, "aria-label": "成就值", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievementNumber(inputValue(event)) }))), labeledField(e, "礼包物品", e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "SCUM 目录代码:数量", onChange: (event: InputEvent) => view.setGiftItems(inputValue(event)) })), catalogPicker(e, view, "gift-items"), labeledField(e, "礼包命令", e("textarea", { value: view.giftCommands, "aria-label": "礼包命令", placeholder: "每行一条命令", onChange: (event: InputEvent) => view.setGiftCommands(inputValue(event)) })), e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包") ), e("div", { className: "console-record-list" }, data.gifts.length ? data.gifts.map((gift, index) => { const key = textField(gift, "_recordKey", "code", "id"); return e("article", { key: idOf(gift, `gift-${index}`), className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, textField(gift, "name") || key), e("span", { className: "status-pill status-active" }, textField(gift, "status") || "active")), e("div", { className: "console-record-meta" }, e("span", null, `周期 ${giftClassLabel(numField(gift, "class"))}`), e("span", null, `适用 ${textField(gift, "audience") || "all"}`), e("span", null, `次数 ${numField(gift, "number")}`), e("span", null, `成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)), e("span", { className: "provider-id" }, giftItemsSummary(gift)), catalogIconStrip(e, giftCatalogIcons(gift)), e("span", { className: "provider-id" }, giftCommandsSummary(gift)), e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => view.setDeliveryGift(key) }, "选择发放"), e("button", { type: "button", className: "icon-command", onClick: () => { view.setGiftEditorOpen(true); view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除")) ); }) : e("p", { className: "page-status" }, "暂无礼包定义。")) ) : null, view.giftTab === "definitions" ? itemCatalogPanel(e, data.tradeGoods) : null, view.giftTab === "claims" ? e("div", { className: "overview-two-col" }, resettableGiftPanel(e, "领取记录", data.giftClaims, "重置领取", (claim) => runAction(view.setAction, "正在重置领取记录…", async () => { await resetGiftClaim(actions ?? {}, claim); view.refresh(); return "领取记录已重置。"; }), actions), resettableGiftPanel(e, "待领礼包", data.pendingGifts, "重置待领", (pending) => runAction(view.setAction, "正在重置待领记录…", async () => { await resetPendingGift(actions ?? {}, pending); view.refresh(); return "待领状态已重置。"; }), actions) ) : null, view.giftTab === "deliveries" ? e("div", { className: "console-record-list" }, e("article", { className: "console-module" }, e("h2", null, "创建发放记录"), labeledField(e, "礼包", e("select", { value: view.deliveryGift, "aria-label": "选择礼包", onChange: (event: InputEvent) => view.setDeliveryGift(inputValue(event)) }, e("option", { value: "" }, "选择礼包"), data.gifts.map((gift, index) => { const key = textField(gift, "code", "id"); return e("option", { key: idOf(gift, `gift-option-${index}`), value: key }, textField(gift, "name") || key); }))), labeledField(e, "用户", e("select", { value: view.deliveryPlayer, "aria-label": "选择用户", onChange: (event: InputEvent) => view.setDeliveryPlayer(inputValue(event)) }, e("option", { value: "" }, "选择用户"), data.players.map((player, index) => { const key = textField(player, "gamePlayerId", "steamId", "id"); return e("option", { key: idOf(player, `player-option-${index}`), value: key }, textField(player, "displayName") || key); }))), e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData || !actions?.dispatch, onClick: queueDelivery }, "立即发放") ), tablePanel(e, "发放记录", data.giftDeliveries, (delivery) => [textField(delivery, "playerName", "playerId") || "unknown", textField(delivery, "giftName", "giftCode") || "unknown", textField(delivery, "status") || "unknown", dateField(delivery, "deliveredAt", "createdAt")]) ) : null, view.giftTab === "timed" ? e("div", { className: "console-record-list" }, e("p", { className: "page-status" }, "这里是 SCUM 原生 finished_timed_gift_spawner 完成记录,不是插件运营礼包定义。"), tablePanel(e, "游戏原生定时礼包", data.timedGiftEvents, (event) => [textField(event, "userProfileId") || "unknown", textField(event, "mapId") || "unknown", textField(event, "spawnTime") || "unknown", dateField(event, "spawnAt")]) ) : null ); } function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const actions = input.workspaceActions; const points = collectMapPoints(data); const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0]; const bounds = resolveMapBounds(settings); const baseLayer = view.mapBaseLayer ?? mapLayerSetting(settings); const zonesOverlay = view.mapZonesOverlay ?? Boolean(settings && boolField(settings, "buildingZones")); const window = mapTimeWindow(view); const catalog = mapVehicleClassIndex(data); const tracks = mapTracks(data, bounds, window, view, catalog); const span = mapPlaybackSpan(window, tracks); const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value; const zoom = view.mapView.zoom; const visible = points.filter((point) => view.mapLayers[layerOf(point)]); const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0]; const selectedTrails = selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : []; const selectedLocks = selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : []; return e("div", { className: "console-record-list scum-map-surface" }, e("div", { className: "resource-filter-bar scum-filter-bar scum-map-bar" }, labeledField(e, "时间", e("select", { value: view.mapTimePreset, "aria-label": "轨迹时间范围", onChange: (event: InputEvent) => view.setMapTimePreset(inputValue(event)) }, scumMapTimePresets.map(([key, label]) => e("option", { key, value: key }, label)))), view.mapTimePreset === "custom" ? labeledField(e, "起", e("input", { type: "datetime-local", value: view.mapTimeFrom, "aria-label": "轨迹起始时间", onChange: (event: InputEvent) => view.setMapTimeFrom(inputValue(event)) })) : null, view.mapTimePreset === "custom" ? labeledField(e, "止", e("input", { type: "datetime-local", value: view.mapTimeTo, "aria-label": "轨迹结束时间", onChange: (event: InputEvent) => view.setMapTimeTo(inputValue(event)) })) : null, mapUserMenu(e, view, mapTrackUsers(data, window)), mapLayerMenu(e, view, zonesOverlay, (next) => { persistMapLayers(view, actions, baseLayer, next); }), labeledField(e, "底图", e("select", { value: baseLayer, "aria-label": "地图底图", onChange: (event: InputEvent) => { const next = mapBaseLayerValue(inputValue(event)); view.setMapBaseLayer(next); persistMapLayers(view, actions, next, zonesOverlay); } }, scumMapBaseLayers.map(([key, label]) => e("option", { key, value: key }, label)))), e("span", { className: "page-status scum-map-summary" }, `${visible.length} / ${points.length} 个点 · ${tracks.length} 条轨迹 · ${window.label}`) ), e("div", { className: "map-viewport", "aria-label": "SCUM 地图视图", ref: view.mapViewportRef, onPointerDown: (event: PointerEventLike) => { if (event.button && event.button !== 0) return; mapDragState.active = true; mapDragState.pointerId = event.pointerId ?? -1; mapDragState.startX = event.clientX ?? 0; mapDragState.startY = event.clientY ?? 0; mapDragState.originX = view.mapView.x; mapDragState.originY = view.mapView.y; mapDragState.moved = false; event.currentTarget?.setPointerCapture?.(event.pointerId); }, onPointerMove: (event: PointerEventLike) => { if (!mapDragState.active || (event.pointerId ?? -1) !== mapDragState.pointerId) return; const deltaX = (event.clientX ?? 0) - mapDragState.startX; const deltaY = (event.clientY ?? 0) - mapDragState.startY; if (Math.abs(deltaX) + Math.abs(deltaY) > 3) mapDragState.moved = true; const originX = mapDragState.originX; const originY = mapDragState.originY; view.setMapView((previous) => ({ ...previous, x: originX + deltaX, y: originY + deltaY })); }, onPointerUp: (event: PointerEventLike) => { if ((event.pointerId ?? -1) !== mapDragState.pointerId) return; mapDragState.active = false; event.currentTarget?.releasePointerCapture?.(event.pointerId); }, onPointerCancel: () => { mapDragState.active = false; }, onDoubleClick: () => { view.setMapView((previous) => zoomMapView(previous, 1.6, 0, 0)); } }, e("div", { className: "map-scene", style: { transform: `translate(-50%, -50%) translate(${view.mapView.x}px, ${view.mapView.y}px) scale(${zoom})`, "--map-inverse": String(1 / zoom) } }, e("div", { className: "map-scene-image", "aria-label": "SCUM 地图图层", style: mapBoardStyle(baseLayer, zonesOverlay) }), mapGridOverlay(e), e("svg", { className: "map-track-layer", viewBox: `0 0 ${scumMapSize} ${scumMapSize}`, preserveAspectRatio: "none", "aria-hidden": "true" }, mapTrackShapes(e, tracks, cursor, 18 / zoom)), mapTrackMarkers(e, tracks, cursor, catalog), visible.map((point, index) => mapPointMarker(e, point, index, bounds, view)) ), e("div", { className: "map-view-controls" }, e("button", { type: "button", className: "icon-command", "aria-label": "地图放大", onClick: () => view.setMapView((previous) => zoomMapView(previous, 1.25, 0, 0)) }, "+"), e("button", { type: "button", className: "icon-command", "aria-label": "地图缩小", onClick: () => view.setMapView((previous) => zoomMapView(previous, 0.8, 0, 0)) }, "-"), e("button", { type: "button", className: "icon-command", "aria-label": "地图复位", onClick: () => view.setMapView(defaultMapView(view.mapViewportRef.current)) }, "复位"), e("button", { type: "button", className: "icon-command", "aria-label": "适应轨迹范围", disabled: !tracks.length, onClick: () => view.setMapView(fitMapView(mapTrackPointList(tracks), view.mapViewportRef.current, 0.8)) }, "适应轨迹"), e("span", { className: "map-zoom-badge" }, `${zoom.toFixed(2)}×`) ) ), mapPlaybackBar(e, view, span, tracks.length > 0), mapTrackList(e, view, tracks, catalog), selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null ); } function mapPointMarker(e: ReactLike["createElement"], point: RecordMap, index: number, bounds: RecordMap, view: ViewState) { const ride = layerOf(point) === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : ""; const icon = vehicleIconFor(point); const key = idOf(point, `point-${index}`); return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layerOf(point)}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, icon ? e("img", { src: icon, alt: "" }) : ""); } function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>) { const selected = view.mapUsers; const summary = selected === undefined ? "全部" : selected.length ? `${selected.length}/${users.length}` : "未选"; return e("details", { className: "scum-map-menu", "aria-label": "轨迹用户筛选" }, e("summary", { className: "icon-command" }, `用户 ${summary}`), e("div", { className: "scum-map-menu-body" }, e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(users.map((user) => user.key)) }, "全选"), e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers([]) }, "清空"), e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(undefined) }, "不限") ), users.length ? users.map((user) => e("label", { key: user.key, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: selected === undefined || selected.includes(user.key), onChange: (event: InputEvent) => view.setMapUsers(toggleMapUser(selected, user.key, Boolean(event.target?.checked), users)) }), `${user.label} · ${user.count}`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。") ) ); } function mapLayerMenu(e: ReactLike["createElement"], view: ViewState, zonesOverlay: boolean, onZones: (next: boolean) => void) { const layers = ["players", "vehicles", "flags", "regions", "other"] as MapLayer[]; const enabled = layers.filter((layer) => view.mapLayers[layer]).length; return e("details", { className: "scum-map-menu", "aria-label": "地图图层筛选" }, e("summary", { className: "icon-command" }, `图层 ${enabled}/${layers.length}${zonesOverlay ? " + 建筑区" : ""}`), e("div", { className: "scum-map-menu-body" }, layers.map((layer) => e("label", { key: layer, className: "scum-layer-toggle", title: layer === "regions" ? "区域来自平台 scum_map_regions 记录,例如安全区或活动区域" : undefined }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer))), e("label", { key: "zones", className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: zonesOverlay, onChange: (event: InputEvent) => onZones(Boolean(event.target?.checked)) }), "建筑区"), e("p", { className: "page-status" }, "区域 = 平台 scum_map_regions 里的地图区域记录(安全区 / 活动区域),没有写入记录时该图层为空。") ) ); } function persistMapLayers(view: ViewState, actions: SCUMWorkspaceActions | undefined, baseLayer: ScumMapLayer, zones: boolean) { if (!actions?.pluginData) return; runAction(view.setAction, "正在保存地图图层…", async () => { await saveMapSettings(actions, { baseLayer, buildingZones: zones }); view.refresh(); return "地图图层已保存。"; }); } function mapPlaybackBar(e: ReactLike["createElement"], view: ViewState, span: { from: number; to: number }, enabled: boolean) { const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value; return e("div", { className: "map-playback-bar" }, e("button", { type: "button", className: view.mapPlayback.playing ? "primary-command" : "icon-command", disabled: !enabled, "aria-label": view.mapPlayback.playing ? "暂停轨迹回放" : "播放轨迹回放", onClick: () => view.setMapPlayback((previous) => ({ ...previous, playing: !previous.playing, value: !previous.playing && previous.value >= 1 ? 0 : previous.value })) }, view.mapPlayback.playing ? "暂停" : "回放"), e("input", { type: "range", min: "0", max: "1000", step: "1", value: String(Math.round(view.mapPlayback.value * 1000)), "aria-label": "轨迹回放进度", disabled: !enabled, onChange: (event: InputEvent) => view.setMapPlayback((previous) => ({ ...previous, playing: false, value: Math.min(1, Math.max(0, Number(inputValue(event)) / 1000)) })) }), e("span", { className: "page-status" }, Number.isFinite(cursor) ? `回放 ${timeLabel(cursor)}` : "回放 全部"), labeledField(e, "速度", e("select", { value: String(view.mapPlayback.speed), "aria-label": "轨迹回放速度", onChange: (event: InputEvent) => view.setMapPlayback((previous) => ({ ...previous, speed: Number(inputValue(event)) || 1 })) }, [1, 2, 4].map((speed) => e("option", { key: speed, value: String(speed) }, `${speed}x`)))), e("button", { type: "button", className: "icon-command", disabled: !enabled, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "回到最新") ); } function mapTrackList(e: ReactLike["createElement"], view: ViewState, tracks: MapTrack[], catalog: Map) { return e("article", { className: "console-module scum-map-tracks" }, e("div", { className: "panel-header" }, e("h2", null, "轨迹列表"), e("span", { className: "page-status" }, `${tracks.length} 条 · 虚线 步行 · 实线 乘车 · 色块对应用户`)), tracks.length ? e("div", { className: "console-row-list" }, tracks.map((track) => e("button", { key: track.key, type: "button", className: view.mapTrackKey === track.key ? "console-row console-row-button is-selected" : "console-row console-row-button", onClick: () => { view.setMapTrackKey(track.key); view.setMapView(fitMapView(track.points, view.mapViewportRef.current, 0.8)); } }, e("span", null, e("span", { className: "map-track-swatch", style: { background: track.color } }), e("strong", null, track.label), e("small", null, `${track.kind === "player" ? "用户" : "载具"} · ${track.points.length} 点 · 步行 ${track.walk} / 乘车 ${track.ride}`)), e("strong", null, `${timeLabel(track.from)} → ${timeLabel(track.to)}`), e("span", { className: "scum-catalog-icons" }, track.vehicles.slice(0, 3).map((code, index) => { const icon = scumVehicleIconUrl(catalog.get(code) ?? code); return icon ? e("img", { key: `${code}-${index}`, className: "scum-catalog-icon", src: icon, alt: "" }) : null; })) ))) : e("p", { className: "page-status" }, "当前时间范围内的轨迹记录为空。") ); } function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, stroke: number) { const shapes: unknown[] = []; for (const track of tracks) for (const segment of mapTrackSegments(track)) { const points = segment.points.filter((point) => point.time <= cursor); if (points.length < 2) continue; shapes.push(e("polyline", { key: `${segment.key}:${points.length}`, points: points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" "), fill: "none", stroke: track.color, strokeWidth: segment.mode === "ride" ? stroke * 1.2 : stroke, strokeLinecap: "round", strokeLinejoin: "round", strokeDasharray: segment.mode === "walk" ? `${stroke * 2.4} ${stroke * 1.6}` : undefined, opacity: segment.mode === "ride" ? 0.95 : 0.75 })); } return shapes; } function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map) { const markers: unknown[] = []; for (const track of tracks) { const points = track.points.filter((point) => point.time <= cursor); if (!points.length) continue; const first = points[0]; const last = points[points.length - 1]; markers.push(e("span", { key: `${track.key}:start`, className: `map-track-endpoint map-track-start map-layer-${track.kind === "player" ? "players" : "vehicles"}`, style: mapSceneStyle(first), title: `${track.label} 起点 ${timeLabel(first.time)}` }, "起")); if (last !== first) markers.push(e("span", { key: `${track.key}:end`, className: "map-track-endpoint map-track-end", style: mapSceneStyle(last), title: `${track.label} 最新 ${timeLabel(last.time)}` }, "终")); let placed = 0; for (const segment of mapTrackSegments(track)) { if (placed >= 4 || segment.mode !== "ride" || segment.points.length < 2) continue; const visible = segment.points.filter((point) => point.time <= cursor); if (!visible.length) continue; const middle = visible[Math.floor(visible.length / 2)]; const code = mapRideCode(middle.row) || track.label; const icon = scumVehicleIconUrl(catalog.get(code) ?? code); placed += 1; markers.push(e("span", { key: `${segment.key}:ride`, className: "map-ride-marker", style: mapSceneStyle(middle), title: `${track.label} 乘坐 ${code}` }, icon ? e("img", { src: icon, alt: "" }) : "车")); } } return markers; } function mapSceneStyle(point: MapTrackPoint): Record { return { left: `${Math.max(0, Math.min(100, point.x / scumMapSize * 100))}%`, top: `${Math.max(0, Math.min(100, point.y / scumMapSize * 100))}%` }; } function mapTimeWindow(view: ViewState): MapTimeWindow { if (view.mapTimePreset === "custom") { const from = Date.parse(view.mapTimeFrom); const to = Date.parse(view.mapTimeTo); const safeFrom = Number.isNaN(from) ? -Infinity : from; const safeTo = Number.isNaN(to) ? Infinity : to; return { from: safeFrom, to: safeTo, label: `${timeLabel(safeFrom)} – ${timeLabel(safeTo)}` }; } const span = scumMapPresetMs[view.mapTimePreset]; if (!span) return { from: -Infinity, to: Infinity, label: "全部时段" }; const to = Date.now(); const label = scumMapTimePresets.find(([key]) => key === view.mapTimePreset)?.[1] ?? "最近"; return { from: to - span, to, label }; } function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, view: ViewState, catalog: Map): MapTrack[] { const grouped = new Map(); for (const row of data.trajectories) { const position = positionOf(row); if (!hasCoordinates(position)) continue; const kind: "player" | "vehicle" = layerOf(row) === "vehicles" ? "vehicle" : "player"; const identity = kind === "player" ? textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id") : textField(row, "gameVehicleId", "vehicleId", "subjectId", "id"); if (!identity) continue; if (kind === "player" && view.mapUsers !== undefined && !view.mapUsers.includes(identity)) continue; const time = trajectoryOrder(row); if (time < window.from || time > window.to) continue; const scene = mapScenePoint(row, bounds); const rideCode = mapRideCode(row); const key = `${kind}:${identity}`; const track = grouped.get(key) ?? { key, kind, label: (kind === "player" ? textField(row, "displayName", "name") : textField(row, "label", "name", "className")) || identity, color: scumMapTrackColors[stableIndex(identity, scumMapTrackColors.length)], points: [], walk: 0, ride: 0, from: time, to: time, vehicles: [] }; track.points.push({ key: `${key}:${track.points.length}`, row, x: scene.x, y: scene.y, time, riding: Boolean(rideCode) }); if (rideCode && !track.vehicles.includes(rideCode)) track.vehicles.push(rideCode); track.from = Math.min(track.from, time); track.to = Math.max(track.to, time); grouped.set(key, track); } const tracks = [...grouped.values()]; for (const track of tracks) { track.points.sort((left, right) => left.time - right.time); if (track.points.length > scumMapTrackPointLimit) track.points = track.points.slice(-scumMapTrackPointLimit); track.walk = track.points.filter((point) => !point.riding).length; track.ride = track.points.length - track.walk; track.vehicles = track.vehicles.map((code) => catalog.get(code) ?? code); } return tracks.sort((left, right) => right.to - left.to).slice(0, scumMapTrackLimit); } function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ key: string; label: string; count: number }> { const users = new Map(); for (const row of data.trajectories) { if (layerOf(row) === "vehicles") continue; const identity = textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id"); if (!identity) continue; const time = trajectoryOrder(row); if (time < window.from || time > window.to) continue; const current = users.get(identity) ?? { key: identity, label: textField(row, "displayName", "name") || identity, count: 0 }; current.count += 1; users.set(identity, current); } return [...users.values()].sort((left, right) => right.count - left.count || left.label.localeCompare(right.label)); } function toggleMapUser(selected: string[] | undefined, key: string, checked: boolean, users: Array<{ key: string }>): string[] { const base = new Set(selected ?? users.map((user) => user.key)); if (checked) base.add(key); else base.delete(key); return [...base]; } function mapTrackSegments(track: MapTrack): MapTrackSegment[] { const segments: MapTrackSegment[] = []; let current: MapTrackPoint[] = []; let mode: "walk" | "ride" = track.kind === "vehicle" ? "ride" : "walk"; for (const point of track.points) { const nextMode: "walk" | "ride" = track.kind === "vehicle" || point.riding ? "ride" : "walk"; const previous = current[current.length - 1]; const broken = previous ? nextMode !== mode || point.time - previous.time > scumMapSegmentGapMs || Math.hypot(point.x - previous.x, point.y - previous.y) > scumMapSegmentJump : false; if (broken) { segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); current = []; } mode = nextMode; current.push(point); } if (current.length) segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); return segments; } function mapTrackPointList(tracks: MapTrack[]): MapTrackPoint[] { return tracks.reduce((all, track) => all.concat(track.points), []); } function mapVehicleClassIndex(data: SCUMSurfaceData): Map { const index = new Map(); for (const vehicle of data.vehicles) { const code = textField(vehicle, "className", "vehicleClass", "entityClass", "vehicleType"); if (!code) continue; for (const key of [textField(vehicle, "gameVehicleId"), textField(vehicle, "vehicleId"), textField(vehicle, "id"), textField(vehicle, "label")].filter(Boolean)) index.set(key, code); } return index; } function mapRideCode(row: RecordMap): string { return textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId"); } function mapPlaybackSpan(window: MapTimeWindow, tracks: MapTrack[]): { from: number; to: number } { const times = tracks.length ? [Math.min(...tracks.map((track) => track.from)), Math.max(...tracks.map((track) => track.to))] : [0, 0]; return { from: Number.isFinite(window.from) ? window.from : times[0], to: Number.isFinite(window.to) ? window.to : times[1] }; } function advanceMapPlayback(previous: MapPlaybackState): MapPlaybackState { const value = previous.value + 0.012 * (previous.speed || 1); return value >= 1 ? { ...previous, playing: false, value: 1 } : { ...previous, value }; } function zoomMapView(previous: MapViewState, factor: number, anchorX: number, anchorY: number): MapViewState { const zoom = clampMapZoom(previous.zoom * factor); if (zoom === previous.zoom) return previous; const ratio = zoom / previous.zoom; return { zoom, x: anchorX - (anchorX - previous.x) * ratio, y: anchorY - (anchorY - previous.y) * ratio }; } function clampMapZoom(value: number): number { return Math.min(scumMapZoomMax, Math.max(scumMapZoomMin, Number.isFinite(value) ? value : scumMapZoomMin)); } function defaultMapView(node: HTMLElement | null): MapViewState { const width = Number(node?.clientWidth) || 0; const height = Number(node?.clientHeight) || 0; return width > height && height > 0 ? { zoom: clampMapZoom(width / height), x: 0, y: 0 } : scumMapDefaultView; } function fitMapView(points: MapTrackPoint[], node: HTMLElement | null, padding: number): MapViewState { if (!points.length) return scumMapDefaultView; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const point of points) { minX = Math.min(minX, point.x); maxX = Math.max(maxX, point.x); minY = Math.min(minY, point.y); maxY = Math.max(maxY, point.y); } const span = Math.max(48, maxX - minX, maxY - minY); const zoom = clampMapZoom(scumMapSize * padding / span); const size = Math.max(240, Number(node?.clientHeight) || 640); return { zoom, x: -((minX + maxX) / 2 / scumMapSize - 0.5) * size * zoom, y: -((minY + maxY) / 2 / scumMapSize - 0.5) * size * zoom }; } function mapBaseLayerValue(value: string): ScumMapLayer { return value === "topo" || value === "night" ? value : "terrain"; } function stableIndex(value: string, length: number): number { let hash = 0; for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) % 1000003; return hash % length; } function timeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "unknown" : date.toLocaleString(); } function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: RecordMap[], locks: RecordMap[]) { const icon = layerOf(point) === "vehicles" ? vehicleIconFor(point) : ""; return e("article", { className: "console-module map-point-strip" }, e("div", { className: "map-point-strip-head" }, e("h2", null, "地图点详情"), icon ? e("img", { className: "scum-catalog-icon", src: icon, alt: "" }) : null, e("strong", null, pointTitle(point)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(point))), e("span", { className: "provider-id" }, coords(point)), e("span", { className: "provider-id" }, `ID ${textField(point, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", { className: "provider-id" }, `来源 ${textField(point, "source") || "平台表"}`), e("span", { className: "provider-id" }, freshness(point)), layerOf(point) === "vehicles" ? e("span", { className: "provider-id" }, `${textField(point, "className", "vehicleClass", "vehicleType") || "unknown"} · ${textField(point, "status", "state", "isFunctional") || "unknown"} · ${boolField(point, "locked") ? "已上锁" : "未上锁/未知"} · 访问 ${dateField(point, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`) : null), locks.length ? e("div", { className: "console-row-list" }, locks.map((row, index) => e("div", { key: `selected-lock-${index}`, className: "console-row" }, e("span", null, dateField(row, "lockedAt", "createdAt")), e("strong", null, textField(row, "scumUserId") || "unknown"), e("strong", null, textField(row, "steamId") || "unknown")))) : null, trails.length ? e("div", { className: "console-row-list" }, trails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "平台轨迹表")))) : null ); } function mapBoardStyle(base: ScumMapLayer, zones: boolean): Record { const layers = [zones ? `url(${scumMapLayerImages.zones})` : "", `url(${scumMapLayerImages[base]})`].filter(Boolean); return { backgroundImage: layers.join(", ") }; } function mapLayerSetting(settings: RecordMap | undefined): ScumMapLayer { const value = textField(settings, "baseLayer", "base_layer").toLowerCase(); return value === "topo" || value === "night" ? value : "terrain"; } function mapLayerLabel(layer: ScumMapLayer): string { return layer === "topo" ? "等高线" : layer === "night" ? "夜间" : "地形"; } function catalogMatches(query: string, kind: string): ScumCatalogEntry[] { const search = query.trim().toLowerCase(); return scumCatalogEntries.filter((entry) => (kind === "all" || entry.kind === kind) && (!search || entry.name.toLowerCase().includes(search) || entry.code.toLowerCase().includes(search) || entry.command.toLowerCase().includes(search))).slice(0, scumCatalogPickLimit); } function appendCatalogItem(current: string, code: string): string { const parts = current.split(",").map((part) => part.trim()).filter(Boolean); const index = parts.findIndex((part) => part.split(":")[0]?.trim().toLowerCase() === code.toLowerCase()); if (index >= 0) { const [key, quantity] = parts[index].split(":"); parts[index] = `${key.trim()}:${Math.max(1, Number(quantity) || 1) + 1}`; } else parts.push(`${code}:1`); return parts.join(", "); } function catalogIconsForValue(value: string): string[] { const entry = scumCatalogEntryFor(value); const url = entry ? scumCatalogIconUrl(entry.icon) : ""; return url ? [url] : []; } function catalogIconStrip(e: ReactLike["createElement"], icons: string[]) { return icons.length ? e("span", { className: "scum-catalog-icons" }, icons.map((url, index) => e("img", { key: `${url}-${index}`, className: "scum-catalog-icon", src: url, alt: "" }))) : null; } function giftCatalogIcons(gift: RecordMap): string[] { const items = field(gift, "items"); return Array.isArray(items) ? items.flatMap((item) => isRecord(item) ? catalogIconsForValue(textField(item, "catalogCode", "catalogItemKey", "key", "className")) : []).slice(0, 12) : []; } function trajectoryVehicleIcon(row: RecordMap): string { const code = textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId"); return code ? scumVehicleIconUrl(code) : ""; } function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) { const matches = catalogMatches(view.catalogSearch, view.catalogKind); return e("details", { className: "console-module scum-editor scum-catalog-picker", open: view.catalogOpen, onToggle: (event: InputEvent) => view.setCatalogOpen(detailOpen(event)) }, e("summary", null, e("strong", null, target === "gift-items" ? "从 SCUM 目录选择礼包物品" : "从 SCUM 目录选择物品"), e("span", { className: "page-status" }, "图标 / 名称 / 代码")), view.catalogOpen ? e("div", { className: "scum-catalog-picker-body" }, e("input", { type: "search", value: view.catalogSearch, "aria-label": target === "gift-items" ? "搜索礼包物品" : "搜索生成物品", placeholder: "名称 / 代码,例如 Laika", onChange: (event: InputEvent) => view.setCatalogSearch(inputValue(event)) }), e("div", { className: "scum-catalog-kinds", role: "group", "aria-label": "目录类型" }, scumCatalogKinds.map(([key, label]) => e("button", { key, type: "button", className: view.catalogKind === key ? "icon-command is-active" : "icon-command", "aria-pressed": view.catalogKind === key, onClick: () => view.setCatalogKind(key) }, label))), e("div", { className: "scum-catalog-grid" }, matches.length ? matches.map((entry) => { const icon = scumCatalogIconUrl(entry.icon); return e("button", { key: entry.code, type: "button", className: "scum-catalog-chip", title: `${entry.name} · ${entry.command}`, onClick: () => target === "gift-items" ? view.setGiftItems(appendCatalogItem(view.giftItems, entry.code)) : view.setProduceTradeGoodsId(entry.code) }, icon ? e("img", { className: "scum-catalog-icon", src: icon, alt: "" }) : e("span", { className: "scum-catalog-icon scum-catalog-icon-empty" }), e("span", { className: "scum-catalog-meta" }, e("strong", null, entry.name), e("span", { className: "provider-id" }, entry.code))); }) : e("p", { className: "page-status" }, "没有匹配的目录条目。")) ) : null ); } function labeledField(e: ReactLike["createElement"], label: string, control: unknown) { return e("label", { className: "console-field" }, e("span", null, label), control); } function detailOpen(event: InputEvent): boolean { return Boolean(event.target?.open); } function giftTabButton(e: ReactLike["createElement"], view: ViewState, tab: GiftTab, label: string) { return e("button", { type: "button", role: "tab", "aria-selected": view.giftTab === tab, className: view.giftTab === tab ? "primary-command" : "icon-command", onClick: () => view.setGiftTab(tab) }, label); } function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); } function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, render(row).map((part, partIndex) => partIndex === 0 ? e("span", { key: partIndex }, String(part ?? "unknown")) : e("strong", { key: partIndex }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实记录。"))); } function resettableGiftPanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], actionLabel: string, onReset: (row: RecordMap) => void, actions: SCUMWorkspaceActions | undefined) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, e("span", null, textField(row, "playerName", "playerId", "displayName", "userProfileId") || "unknown"), e("strong", null, textField(row, "giftName", "giftCode", "giftType") || "unknown"), e("strong", null, textField(row, "status") || "unknown"), e("strong", null, dateField(row, "claimedAt", "receivedAt", "createdAt")), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => onReset(row) }, actionLabel))) : e("p", { className: "page-status" }, "暂无真实记录。"))); } function itemCatalogPanel(e: ReactLike["createElement"], rows: RecordMap[]) { return tablePanel(e, "物品列表", recentRows(rows, "lastSeenAt", "updatedAt", "createdAt"), (item) => [tradeGoodsName(item), tradeGoodsTypeLabel(item), textField(item, "code", "itemCode", "className") || "unknown", dateField(item, "lastSeenAt", "updatedAt", "createdAt")]); } function tradeGoodsLabel(rows: RecordMap[], code: string): string { const item = tradeGoodsIndex(rows).get(code) ?? tradeGoodsIndex(rows).get(code.replace(/^#spawnvehicle\s+/i, "")); return item ? `${tradeGoodsName(item)} (${code})` : code || "unknown"; } function tradeGoodsName(item: RecordMap): string { return textField(item, "nameCn", "name_cn", "name", "className") || textField(item, "code", "itemCode") || "未命名物品"; } function tradeGoodsTypeLabel(item: RecordMap): string { return textField(item, "typeName", "type_name") || (textField(item, "type") === "21" || isVehicleCatalogItem(item) ? "其他载具" : "未知类型"); } function tradeActionLabel(event: RecordMap): string { const value = textField(event, "tradeVerb", "tradeKind", "action").toLowerCase(); return value === "purchased" || value === "purchase" ? "买入" : value === "sold" || value === "sale" ? "卖出" : value || "交易"; } function tradeGoodsIndex(rows: RecordMap[]): Map { const result = new Map(); for (const row of rows) for (const key of [textField(row, "code"), textField(row, "itemCode"), textField(row, "className")].filter(Boolean)) result.set(key, row); return result; } function recentRows(rows: RecordMap[], ...keys: string[]): RecordMap[] { return [...rows].sort((left, right) => rowTime(right, keys) - rowTime(left, keys)).slice(0, 24); } function rowTime(row: RecordMap, keys: string[]): number { for (const key of keys) { const stamp = Date.parse(textField(row, key)); if (!Number.isNaN(stamp)) return stamp; } return 0; } function isVehicleCatalogItem(item: RecordMap): boolean { const type = textField(item, "catalogType", "kind", "typeName", "type_name", "type").toLowerCase(); const code = textField(item, "code", "spawnCommand").toLowerCase(); return type.includes("vehicle") || type.includes("载具") || textField(item, "type") === "21" || code.startsWith("#spawnvehicle"); } function vehicleCatalogIndex(rows: RecordMap[]): Map { const result = new Map(); for (const row of rows) if (isVehicleCatalogItem(row)) for (const key of vehicleCatalogKeys(row)) result.set(key, row); return result; } function vehicleCatalogKeys(row: RecordMap): string[] { return ["className", "vehicleClass", "entityClass", "vehicleType", "name", "code", "spawnCommand"].map((key) => vehicleClassKey(textField(row, key))).filter(Boolean); } function enrichVehicleFromCatalog(vehicle: RecordMap, catalog: Map): RecordMap { const found = vehicleCatalogKeys(vehicle).map((key) => catalog.get(key)).find(Boolean); return found ? { ...found, ...vehicle, imagePath: textField(vehicle, "imagePath", "image_path") || textField(found, "imagePath", "image_path"), catalogCode: textField(found, "code"), spawnCommand: textField(found, "spawnCommand", "code") } : vehicle; } export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] { const direct = data.mapPoints.map((point) => ({ ...point, layer: textField(point, "layer", "subjectType", "type") || "other" })); const players = data.players.flatMap((player) => withPosition(player, "players", textField(player, "displayName"), textField(player, "steamId", "gamePlayerId", "id"))); const catalog = vehicleCatalogIndex(data.tradeGoods); const vehicles = data.vehicles.flatMap((vehicle) => { const enriched = enrichVehicleFromCatalog(vehicle, catalog); return withPosition(enriched, "vehicles", textField(enriched, "label", "name", "className"), textField(enriched, "vehicleId", "id")); }); const flags = data.flags.flatMap((flag) => withPosition(flag, "flags", textField(flag, "name"), textField(flag, "flagId", "id"))); const regions = data.mapRegions.flatMap((region) => withPosition(region, "regions", textField(region, "name"), textField(region, "id", "regionId"))); const uniquePoints = new Map(); for (const point of [...direct, ...players, ...vehicles, ...flags, ...regions].filter(hasCoordinates)) { const key = mapPointIdentity(point); const current = uniquePoints.get(key); uniquePoints.set(key, current ? { ...point, ...current, name: textField(point, "name") || textField(current, "name") } : point); } return [...uniquePoints.values()]; } function withPosition(row: RecordMap, layer: MapLayer, name: string, subjectId: string): RecordMap[] { const position = positionOf(row); return hasCoordinates(position) ? [{ ...row, ...position, layer, name, subjectId, _recordKey: `${layer}:${subjectId}`, source: textField(row, "source") }] : []; } function positionOf(row: RecordMap | undefined): RecordMap | undefined { const nested = field(row, "position", "location"); return isRecord(nested) ? nested : row; } function hasCoordinates(row: RecordMap | undefined): row is RecordMap { return Boolean(row) && Number.isFinite(Number(field(row, "x", "locationX"))) && Number.isFinite(Number(field(row, "y", "locationY"))); } function layerOf(point: RecordMap): MapLayer { const value = textField(point, "layer", "subjectType", "type").toLowerCase(); if (value.includes("player") || value.includes("user")) return "players"; if (value.includes("vehicle")) return "vehicles"; if (value.includes("flag")) return "flags"; if (value.includes("region") || value.includes("zone") || value === "base") return "regions"; return "other"; } function layerLabel(layer: MapLayer): string { return layer === "players" ? "用户" : layer === "vehicles" ? "载具" : layer === "flags" ? "旗帜" : layer === "regions" ? "区域" : "其他"; } function pointTitle(point: RecordMap): string { return textField(point, "name", "label", "subjectName") || textField(point, "subjectType", "type") || textField(point, "subjectId", "id") || "地图点"; } function mapPointIdentity(point: RecordMap): string { const subject = textField(point, "subjectId", "gamePlayerId", "vehicleId", "flagId", "regionId"); if (subject) return `${layerOf(point)}:${subject}`; const record = textField(point, "id", "_recordKey"); if (record) return `${layerOf(point)}:${record}`; return `${layerOf(point)}:${pointTitle(point).toLowerCase()}:${numField(point, "x", "locationX")}:${numField(point, "y", "locationY")}`; } export function mapScenePoint(point: RecordMap, bounds: RecordMap): { x: number; y: number } { const x = Number(field(point, "x", "locationX") ?? 0); const y = Number(field(point, "y", "locationY") ?? 0); const minX = Number(field(bounds, "worldMinX")); const minY = Number(field(bounds, "worldMinY")); const maxX = Number(field(bounds, "worldMaxX")); const maxY = Number(field(bounds, "worldMaxY")); const sceneX = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? scumMapSize - (x - minX) * scumMapSize / (maxX - minX) : scumMapSize / 2; const sceneY = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? scumMapSize - (y - minY) * scumMapSize / (maxY - minY) : scumMapSize / 2; return { x: sceneX, y: sceneY }; } export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record { const scene = mapScenePoint(point, bounds); const left = scene.x / scumMapSize * 100; const top = scene.y / scumMapSize * 100; return { left: `${Math.max(1, Math.min(99, left))}%`, top: `${Math.max(1, Math.min(99, top))}%` }; } function mapGridOverlay(e: ReactLike["createElement"]) { const rows = ["D", "C", "B", "A", "Z"]; const cols = ["4", "3", "2", "1", "0"]; const breaks = [20, 40, 60, 80]; return e("div", { className: "map-grid-overlay", "aria-hidden": "true" }, breaks.map((value) => e("span", { key: `v-${value}`, className: "map-grid-line map-grid-line-v", style: { left: `${value}%` } })), breaks.map((value) => e("span", { key: `h-${value}`, className: "map-grid-line map-grid-line-h", style: { top: `${value}%` } })), cols.map((label, index) => e("span", { key: `c-${label}`, className: "map-grid-label map-grid-col-label", style: { left: `${(index + 0.5) * 20}%` } }, label)), rows.map((label, index) => e("span", { key: `r-${label}`, className: "map-grid-label map-grid-row-label", style: { top: `${(index + 0.5) * 20}%` } }, label))); } function trajectoryRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordMap[] { const ids = new Set([textField(point, "subjectId"), textField(point, "steamId"), textField(point, "gamePlayerId"), textField(point, "vehicleId"), textField(point, "id")].filter(Boolean)); const layer = layerOf(point); return rows.filter((row) => layerOf(row) === layer && trajectoryIdentity(row).some((identity) => ids.has(identity))); } function vehicleLockRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordMap[] { const ids = new Set([textField(point, "id"), textField(point, "scumVehicleId"), textField(point, "vehicleId"), textField(point, "gameVehicleId"), textField(point, "entityId"), textField(point, "subjectId")].filter(Boolean)); return rows.filter((row) => [textField(row, "scumVehicleId"), textField(row, "vehicleId"), textField(row, "gameVehicleId")].some((identity) => ids.has(identity))); } function trajectoryIdentity(row: RecordMap): string[] { return [textField(row, "subjectId"), textField(row, "steamId"), textField(row, "gamePlayerId"), textField(row, "vehicleId"), textField(row, "id")].filter(Boolean); } function trajectoryOrder(row: RecordMap): number { const stamp = Date.parse(textField(row, "sampledAt", "observedAt", "createdAt")); return Number.isNaN(stamp) ? 0 : stamp; } function vehicleIconFor(point: RecordMap): string { const explicit = textField(point, "imagePath", "image_path"); if (explicit) return explicit.startsWith("/") ? explicit : `/${explicit}`; return scumVehicleIconUrl(normalizedVehicleClass(textField(point, "className", "vehicleClass", "entityClass", "vehicleType"))); } function vehicleClassKey(value: string): string { return normalizedVehicleClass(value.replace(/^#spawnvehicle\s+/i, "")); } function normalizedVehicleClass(value: string): string { return value.replace(/_C$/i, "").split(".").pop()?.trim() ?? value.trim(); } function shortHash(value: string): string { return value ? `${value.slice(0, 10)}…${value.slice(-6)}` : ""; } function runAction(setAction: StateSetter, pending: string, task: () => Promise) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); } function usePluginState(react: ReactLike, initial: T): [T, StateSetter] { return react.useState ? react.useState(initial) : [initial, () => undefined]; } function inputValue(event: InputEvent): string { return event.target?.value ?? ""; } function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) { if (row[key] !== undefined) return row[key]; const normalized = normalizeKey(key); const found = Object.keys(row).find((candidate) => normalizeKey(candidate) === normalized); if (found && row[found] !== undefined) return row[found]; } return undefined; } function normalizeKey(value: string): string { return value.replace(/[_-]/g, "").toLowerCase(); } function textField(row: RecordMap | undefined, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null ? "" : String(value); } function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === 1 || value === "1" || value === "true"; } const playerActivityKeys = ["lastSeenAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt"]; const playerOnlineWindowMs = 5 * 60 * 1000; function playerOnline(player: RecordMap): boolean { const status = textField(player, "status").toLowerCase(); const active = boolField(player, "online") || ["online", "active", "connected"].includes(status); if (!active) return false; const lastActivity = field(player, ...playerActivityKeys); if (lastActivity === undefined || lastActivity === null || lastActivity === "") return true; const timestamp = Date.parse(String(lastActivity)); return Number.isNaN(timestamp) || Date.now() - timestamp <= playerOnlineWindowMs; } function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); } function playerUuid(player: RecordMap): string { const steamId = textField(player, "steamId", "providerId"); const uuid = textField(player, "gamePlayerId", "playerId"); return uuid && uuid !== steamId ? uuid : textField(player, "userProfileId", "profileId"); } function playerCurrency(player: RecordMap): string { return valueField(player, "normalBalance", "moneyBalance", "balance", "cash", "cashBalance", "currencyBalance") || "未同步"; } function playerGold(player: RecordMap): string { return valueField(player, "goldBalance", "gold") || "未同步"; } function valueField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "" : String(value); } function idOf(row: RecordMap | undefined, fallback: string): string { return textField(row, "_recordKey", "id", "gamePlayerId", "squadId", "vehicleId", "flagId", "eventId", "code") || fallback; } function freshness(row: RecordMap): string { const fresh = field(row, "freshness"); return isRecord(fresh) ? textField(fresh, "status") || "unknown" : textField(row, "freshnessStatus", "updatedAt") || "unknown"; } function coords(row?: RecordMap): string { if (!row || !hasCoordinates(row)) return "坐标 unknown"; return `X ${numField(row, "x", "locationX")} / Y ${numField(row, "y", "locationY")} / Z ${numField(row, "z", "locationZ")}`; } function dateField(row: RecordMap | undefined, ...keys: string[]): string { const value = textField(row, ...keys); if (!value) return "unknown"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } function matchesText(row: RecordMap, search: string, ...keys: string[]): boolean { return !search || keys.some((key) => textField(row, key).toLowerCase().includes(search)); } function unique(values: string[]): string[] { return [...new Set(values)]; } function activeStatus(status: string): boolean { return ["active", "running", "scheduled", "enabled", "queued"].includes(status.toLowerCase()); } function giftItemsSummary(gift: RecordMap): string { const items = field(gift, "items"); if (!Array.isArray(items)) return "物品清单未记录"; return items.map((item) => isRecord(item) ? `${textField(item, "label", "catalogCode", "catalogItemKey", "className", "key") || "item"} × ${numField(item, "quantity")}` : String(item)).join(" · "); } function giftCommandsSummary(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) && commands.length ? `${commands.length} 条命令` : "无命令"; } function giftItemsInput(gift: RecordMap): string { const items = field(gift, "items"); return Array.isArray(items) ? items.map((item) => isRecord(item) ? `${textField(item, "catalogCode", "catalogItemKey", "key")}:${numField(item, "quantity")}` : "").filter(Boolean).join(", ") : ""; } function giftCommandsInput(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) ? commands.map((item) => isRecord(item) ? textField(item, "command", "value") : String(item)).filter(Boolean).join("\n") : ""; } function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record)[value] ?? value; } function integerInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : fallback; } function minimumIntegerInput(value: string, minimum: number, label: string): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error(`${label}必须是不小于 ${minimum} 的整数。`); return parsed; } function numberInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function isRecord(value: unknown): value is RecordMap { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function errorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : pageKey === "workflows" || pageKey === "activity" ? "活动管理" : "用户管理"; }