Files
browser/plugins/examples/scum-server-plugin/features/page.ts
T

1380 lines
128 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, scumVehicleEntry, scumVehicleFallbackIconUrl, scumVehicleIconUrl, type ScumCatalogEntry } from "./scum-catalog.js";
type StateSetter<T> = (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";
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
type MapViewState = { zoom: number; x: number; y: number };
type MapPlaybackState = { playing: boolean; value: number; speed: number; startedAt: number; startValue: number };
type MapTimeWindow = { from: number; to: number; label: string };
type MapTrackPoint = { key: string; row: RecordMap; x: number; y: number; time: number; riding: boolean; rideCode: string };
type MapRideSample = { key: string; code: string; time: number; scene: { x: number; y: number } };
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<readonly [ScumMapLayer, string]> = [["terrain", "地形"], ["topo", "等高线"], ["night", "夜间"]];
const scumCatalogKinds: ReadonlyArray<readonly [string, string]> = [["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, startedAt: 0, startValue: 1 };
// Playback runs on wall-clock time so the 1x / 2x / 4x switch is instant and timer jitter cannot drift.
const scumMapPlaybackDurationMs = 30000;
const scumMapRideWindowMs = 10 * 1000;
const scumMapRideDistance = 1500;
const scumMapTimePresets: ReadonlyArray<readonly [string, string]> = [["1h", "最近 1 小时"], ["15m", "最近 15 分钟"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["all", "全部时段"], ["custom", "自定义起止"]];
const scumMapPresetMs: Record<string, number> = { "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 scumMapTrackColorCache = new Map<string, string>();
const scumMapDefaultWindowMs = 60 * 60 * 1000;
const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false };
// Latest rendered playback frame; the interval effect reads it to keep the follow camera in sync.
let mapPlaybackFrame: { tracks: MapTrack[]; span: { from: number; to: number } } | null = null;
export type ReactLike = {
createElement: (...args: any[]) => any;
useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void;
useRef?: <T>(initial: T) => { current: T };
useState?: <T>(initialState: T | (() => T)) => [T, StateSetter<T>];
};
export type SCUMPageContext = {
pageKey?: string;
pageTitle?: string;
pluginId?: string;
serverInstanceId?: string;
pageQuery?: Record<string, 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 pageQuery = input.pageQuery ?? {};
const focusUser = pageQuery.focus ?? "";
const focusKey = [input.pageKey ?? "players", focusUser, pageQuery.from ?? "", pageQuery.to ?? ""].join("|");
const defaultMapRange = mapQueryRange(pageQuery.from, pageQuery.to);
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const [playerSearch, setPlayerSearch] = usePluginState(react, "");
const [playerStatus, setPlayerStatus] = usePluginState(react, "all");
const [playerPanel, setPlayerPanel] = usePluginState<PlayerPanelState>(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<GiftTab>(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<Record<MapLayer, boolean>>(react, { players: true, vehicles: true, flags: true, regions: true, other: true });
const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, "");
const [hoverMapPoint, setHoverMapPoint] = usePluginState(react, "");
const [mapView, setMapView] = usePluginState<MapViewState>(react, scumMapDefaultView);
const [mapTimePreset, setMapTimePreset] = usePluginState(react, pageQuery.from || pageQuery.to ? "custom" : "1h");
const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, localDateTimeValue(defaultMapRange.from));
const [mapTimeTo, setMapTimeTo] = usePluginState(react, localDateTimeValue(defaultMapRange.to));
const [mapUsers, setMapUsers] = usePluginState<string[] | undefined>(react, focusUser ? [focusUser] : undefined);
const [mapTrackKey, setMapTrackKey] = usePluginState(react, "");
const [mapPlayback, setMapPlayback] = usePluginState<MapPlaybackState>(react, scumMapPlaybackIdle);
const mapViewportRef = react.useRef ? react.useRef<HTMLElement | null>(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<ScumMapLayer | undefined>(react, undefined);
const [mapZonesOverlay, setMapZonesOverlay] = usePluginState<boolean | undefined>(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(() => {
const next = advanceMapPlayback(mapPlayback, Date.now());
setMapPlayback(next);
const camera = mapPlaybackFrame ? playbackFollowView(mapPlaybackFrame.tracks, next, mapPlaybackFrame.span, mapViewportRef.current) : undefined;
if (camera) setMapView(camera);
}, 90);
return () => { clearInterval(timer); };
}, [mapPlayback.playing, mapPlayback.startedAt, mapPlayback.startValue, mapPlayback.speed, setMapPlayback, setMapView, mapViewportRef]);
if (react.useEffect) react.useEffect(() => {
if (pageKey !== "live-map") return;
if (!focusUser) { setMapUsers(undefined); applyMapTimePreset(setMapTimePreset, setMapTimeFrom, setMapTimeTo, "1h"); return; }
const range = mapQueryRange(pageQuery.from, pageQuery.to);
setMapUsers([focusUser]);
setMapTimePreset("custom");
setMapTimeFrom(localDateTimeValue(range.from));
setMapTimeTo(localDateTimeValue(range.to));
}, [pageKey, focusKey]);
if (react.useEffect) react.useEffect(() => {
if (pageKey !== "live-map" || !focusUser || state.status !== "ready") return;
const node = mapViewportRef.current;
if (!node) return;
const settings = state.data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? state.data.mapSettings[0];
const range = mapQueryRange(pageQuery.from, pageQuery.to);
const points = mapTrackPointList(mapTracks(state.data, resolveMapBounds(settings), { from: range.from, to: range.to, label: "" }, [focusUser], mapVehicleClassIndex(state.data)));
if (points.length) setMapView(fitMapView(points, node, 0.8));
}, [pageKey, focusKey, state.status]);
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,
hoverMapPoint, setHoverMapPoint,
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<string>; playerStatus: string; setPlayerStatus: StateSetter<string>;
playerPanel: PlayerPanelState; setPlayerPanel: StateSetter<PlayerPanelState>;
squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>;
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>;
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
eventType: string; setEventType: StateSetter<string>; eventSchedule: string; setEventSchedule: StateSetter<string>;
eventCommand: string; setEventCommand: StateSetter<string>;
eventClass: string; setEventClass: StateSetter<string>; eventPlacard: string; setEventPlacard: StateSetter<string>; eventPercent: string; setEventPercent: StateSetter<string>;
eventDuration: string; setEventDuration: StateSetter<string>;
eventNpc: string; setEventNpc: StateSetter<string>; eventItem: string; setEventItem: StateSetter<string>; eventZombie: string; setEventZombie: StateSetter<string>; eventAnimal: string; setEventAnimal: StateSetter<string>;
produceEventId: string; setProduceEventId: StateSetter<string>; produceId: string; setProduceId: StateSetter<string>; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter<string>;
producePercent: string; setProducePercent: StateSetter<string>; produceValue: string; setProduceValue: StateSetter<string>; produceRadius: string; setProduceRadius: StateSetter<string>; produceX: string; setProduceX: StateSetter<string>; produceY: string; setProduceY: StateSetter<string>; produceZ: string; setProduceZ: StateSetter<string>;
giftCode: string; setGiftCode: StateSetter<string>; giftName: string; setGiftName: StateSetter<string>; giftItems: string; setGiftItems: StateSetter<string>; giftCommands: string; setGiftCommands: StateSetter<string>;
giftClass: string; setGiftClass: StateSetter<string>; giftAudience: string; setGiftAudience: StateSetter<string>; giftNumber: string; setGiftNumber: StateSetter<string>; giftAchievement: string; setGiftAchievement: StateSetter<string>; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter<string>;
deliveryGift: string; setDeliveryGift: StateSetter<string>; deliveryPlayer: string; setDeliveryPlayer: StateSetter<string>;
mapLayers: Record<MapLayer, boolean>; setMapLayers: StateSetter<Record<MapLayer, boolean>>;
selectedMapPoint: string; setSelectedMapPoint: StateSetter<string>; hoverMapPoint: string; setHoverMapPoint: StateSetter<string>; setAction: StateSetter<ActionState>; refresh: () => void;
mapView: MapViewState; setMapView: StateSetter<MapViewState>; mapViewportRef: { current: HTMLElement | null };
mapTimePreset: string; setMapTimePreset: StateSetter<string>; mapTimeFrom: string; setMapTimeFrom: StateSetter<string>; mapTimeTo: string; setMapTimeTo: StateSetter<string>;
mapUsers: string[] | undefined; setMapUsers: StateSetter<string[] | undefined>; mapTrackKey: string; setMapTrackKey: StateSetter<string>; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter<MapPlaybackState>;
eventEditorOpen: boolean; setEventEditorOpen: StateSetter<boolean>; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter<boolean>; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter<boolean>;
mapBaseLayer: ScumMapLayer | undefined; setMapBaseLayer: StateSetter<ScumMapLayer | undefined>; mapZonesOverlay: boolean | undefined; setMapZonesOverlay: StateSetter<boolean | undefined>;
catalogSearch: string; setCatalogSearch: StateSetter<string>; catalogKind: string; setCatalogKind: StateSetter<string>; catalogOpen: boolean; setCatalogOpen: StateSetter<boolean>;
};
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, input, open))
);
}
function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, input: SCUMPageContext, 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: () => openPlayerTrajectoryOnMap(player, input) }, "用户轨迹")
)
);
}
function openPlayerTrajectoryOnMap(player: RecordMap, input: SCUMPageContext) {
if (!input.serverInstanceId || typeof window === "undefined") return;
const identity = textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey");
if (!identity) return;
const to = Date.now();
const from = (playerLastSeenAt(player) ?? to) - scumMapDefaultWindowMs;
const query = new URLSearchParams({ serverInstanceId: input.serverInstanceId, focus: identity, from: new Date(from).toISOString(), to: new Date(to).toISOString() });
window.location.hash = `#/plugin-pages/${encodeURIComponent(input.pluginId ?? "game.scum")}/live-map?${query.toString()}`;
}
function playerLastSeenAt(player: RecordMap): number | undefined {
for (const key of ["lastSeenAt", "lastLoginTime", "lastLoginAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt"]) {
const stamp = Date.parse(textField(player, key));
if (!Number.isNaN(stamp)) return stamp;
}
return undefined;
}
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) : playerHistoryPanel(e, player);
const title = kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : "登录历史";
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 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<string, RecordMap>();
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 users = mapTrackUsers(data, window);
const playerLabels = new Map<string, string>();
for (const player of data.players) { const key = textField(player, "steamId", "gamePlayerId", "id"); const label = textField(player, "displayName", "name", "playerName"); if (key && label) playerLabels.set(key, label); }
const tracks = mapTracks(data, bounds, window, view.mapUsers, catalog);
const span = mapPlaybackSpan(window, tracks);
mapPlaybackFrame = { tracks, span };
const cursor = playbackCursor(span, view.mapPlayback);
const zoom = view.mapView.zoom;
const visible = points.filter((point) => view.mapLayers[layerOf(point)]);
const hovered = visible.find((point, index) => idOf(point, `point-${index}`) === view.hoverMapPoint);
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) => applyMapTimePreset(view.setMapTimePreset, view.setMapTimeFrom, view.setMapTimeTo, inputValue(event)) }, scumMapTimePresets.map(([key, label]) => e("option", { key, value: key }, label)))),
labeledField(e, "开始", e("input", { type: "datetime-local", value: view.mapTimeFrom, "aria-label": "轨迹开始时间", onChange: (event: InputEvent) => { view.setMapTimePreset("custom"); view.setMapTimeFrom(inputValue(event)); } })),
labeledField(e, "结束", e("input", { type: "datetime-local", value: view.mapTimeTo, "aria-label": "轨迹结束时间", onChange: (event: InputEvent) => { view.setMapTimePreset("custom"); view.setMapTimeTo(inputValue(event)); } })),
mapUserMenu(e, view, users, playerLabels),
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, view.mapPlayback.playing),
visible.map((point, index) => mapPointMarker(e, point, index, bounds, view, data)),
hovered ? mapHoverCard(e, hovered, data, mapPointStyle(hovered, bounds)) : null
),
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, tracks.length > 0),
mapTrackLegend(e, view, tracks, playerLabels),
selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null
);
}
function mapPointMarker(e: ReactLike["createElement"], point: RecordMap, index: number, bounds: RecordMap, view: ViewState, data: SCUMSurfaceData) {
const layer = layerOf(point);
const ride = layer === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : "";
const icon = layer === "vehicles" || ride ? vehicleIconFor(point) : "";
const key = idOf(point, `point-${index}`);
const open = () => view.setHoverMapPoint(key);
const close = () => view.setHoverMapPoint((current) => (current === key ? "" : current));
return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layer}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onPointerEnter: open, onPointerLeave: close, onFocus: open, onBlur: close, onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } },
icon ? e("img", { src: icon, alt: "", loading: "lazy", decoding: "async" }) : ""
);
}
// Hover card: the map is unreadable without knowing whose dot it is and which vehicle it belongs to.
// Every row is driven by data the platform already returns, so a missing field drops the row instead
// of printing a placeholder.
function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCUMSurfaceData, pointStyle: Record<string, string>) {
const layer = layerOf(point);
const rows: Array<[string, string]> = [];
const push = (label: string, value: string) => { if (value) rows.push([label, value]); };
const pushDate = (label: string, ...keys: string[]) => { if (textField(point, ...keys)) push(label, dateField(point, ...keys)); };
if (layer === "players") {
push("用户", textField(point, "displayName", "name", "label") || pointTitle(point));
push("SteamID", textField(point, "steamId", "gamePlayerId", "subjectId"));
push("队伍", mapSquadLabel(data, textField(point, "squadId", "squadName")));
push("状态", boolField(point, "online") ? "在线" : "离线");
pushDate("最后出现", "lastSeenAt", "lastActivityAt", "updatedAt");
} else if (layer === "vehicles") {
push("载具", vehicleLabelFor(point));
push("类名", textField(point, "className", "vehicleClass", "entityClass", "vehicleType"));
push("状态", textField(point, "status", "state") || vehicleFunctionalLabel(point) || (boolField(point, "exists", "existsInGame") ? "存在" : ""));
push("耐久", vehicleDurabilityLabel(point));
push("车锁", vehicleLockLabel(point, data));
pushDate("最后观测", "vehicleObservedAt", "lastObservedAt", "observedAt", "updatedAt");
} else if (layer === "flags") {
push("领地旗", textField(point, "name", "label", "flagId", "id") || "领地旗");
push("归属", mapOwnerLabel(data, point));
push("队伍", mapSquadLabel(data, textField(point, "ownerSquadId", "squadId")));
push("置信度", textField(point, "ownershipConfidence"));
} else {
push("名称", pointTitle(point));
push("类型", textField(point, "type", "subjectType", "layer"));
push("来源", textField(point, "source"));
}
if (hasCoordinates(point)) push("坐标", coords(point));
return e("div", { className: `map-hover-card map-hover-${layer}`, role: "tooltip", style: pointStyle },
e("strong", { className: "map-hover-title" }, pointTitle(point)),
rows.map(([label, value]) => e("span", { key: label, className: "map-hover-row" }, e("small", null, label), e("b", null, value)))
);
}
function mapSquadLabel(data: SCUMSurfaceData, value: string): string {
if (!value) return "";
const squad = data.squads.find((row) => textField(row, "squadId", "id", "_recordKey") === value);
return squad ? `${textField(squad, "name", "label") || value}${value}` : value;
}
function mapOwnerLabel(data: SCUMSurfaceData, point: RecordMap): string {
const steamId = textField(point, "ownerSteamId", "steamId", "ownerProfileId", "ownerId");
if (!steamId) return "";
const owner = data.players.find((player) => [textField(player, "steamId"), textField(player, "gamePlayerId"), textField(player, "id")].includes(steamId));
return owner ? `${textField(owner, "displayName", "name") || steamId}${steamId}` : steamId;
}
function vehicleLockLabel(point: RecordMap, data: SCUMSurfaceData): string {
const locks = vehicleLockRecordsForPoint(data.vehicleLocks, point).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"]));
const latest = locks[0];
if (latest) {
const steamId = textField(latest, "steamId", "scumUserId");
const owner = data.players.find((player) => [textField(player, "steamId"), textField(player, "gamePlayerId"), textField(player, "id"), textField(player, "scumUserId")].includes(steamId));
const who = owner ? textField(owner, "displayName", "name") || steamId : steamId;
return `${who || "未知玩家"} 上锁 · ${dateField(latest, "lockedAt", "createdAt")}`;
}
if (boolField(point, "locked")) return "已上锁(未记录上锁人)";
return textField(point, "lockState") || "未上锁";
}
function vehicleDurabilityLabel(point: RecordMap): string {
const value = textField(point, "durability", "vehicleDurability", "health", "vehicleHealth");
return value ? `${value}${textField(point, "durabilityMax", "maxHealth") ? ` / ${textField(point, "durabilityMax", "maxHealth")}` : ""}` : "";
}
function vehicleFunctionalLabel(point: RecordMap): string {
if (field(point, "functional", "isFunctional") === undefined) return "";
return boolField(point, "functional", "isFunctional") ? "可用" : "不可用";
}
function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>, labels: Map<string, string>) {
const selected = view.mapUsers;
const rows = [...users, ...(selected ?? []).filter((key) => !users.some((user) => user.key === key)).map((key) => ({ key, label: labels.get(key) || key, count: 0 }))];
const summary = selected === undefined ? "全部" : selected.length ? `${selected.length}/${rows.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) }, "不限")
),
e("p", { className: "page-status" }, "列出所选起止时间内有轨迹的用户,颜色与地图轨迹一致。"),
rows.length ? rows.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)) }), e("span", { className: "map-track-swatch", style: { background: mapTrackColor(user.key) } }), `${user.label} · ${user.count} 点`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。")
)
);
}
function mapTrackLegend(e: ReactLike["createElement"], view: ViewState, tracks: MapTrack[], labels: Map<string, string>) {
const selected = view.mapUsers;
const empty = selected && selected.length ? `${selected.map((key) => labels.get(key) || key).join("、")} 在当前时间范围内没有轨迹记录,可放宽快捷时间范围。` : "当前时间范围内没有轨迹。";
return e("div", { className: "map-track-legend", role: "group", "aria-label": "轨迹颜色图例", title: "轨迹来自平台 scum_user_trajectory 与 scum_vehicle_trajectory 表;虚线为步行,实线为乘车。" },
e("span", { className: "page-status" }, tracks.length ? `${tracks.length} 条轨迹 · 虚线 步行 · 实线 乘车 · 点击色块可定位` : empty),
tracks.map((track) => e("button", { key: track.key, type: "button", className: view.mapTrackKey === track.key ? "map-track-chip is-selected" : "map-track-chip", title: `${track.label} ${timeLabel(track.from)}${timeLabel(track.to)}`, onClick: () => { view.setMapTrackKey(track.key); view.setMapView(fitMapView(track.points, view.mapViewportRef.current, 0.8)); } },
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}`)
))
);
}
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 }, tracks: MapTrack[], enabled: boolean) {
const cursor = playbackCursor(span, view.mapPlayback);
const playing = view.mapPlayback.playing;
const start = () => {
const value = view.mapPlayback.value >= 1 ? 0 : view.mapPlayback.value;
const next = rebaseMapPlayback(view.mapPlayback, { playing: true, value });
view.setMapPlayback(next);
if (value === 0) { const camera = playbackStartView(tracks, view.mapViewportRef.current); if (camera) view.setMapView(camera); }
};
return e("div", { className: "map-playback-bar" },
e("details", { className: "scum-map-menu", "aria-label": "轨迹回放" },
e("summary", { className: "icon-command" }, playing ? "回放 播放中" : "回放"),
e("div", { className: "scum-map-menu-body" },
e("p", { className: "page-status" }, Number.isFinite(cursor) ? `回放位置 ${timeLabel(cursor)}` : "回放位置 全部"),
e("div", { className: "console-row-actions" },
e("button", { type: "button", className: playing ? "primary-command" : "icon-command", disabled: !enabled, "aria-label": playing ? "暂停轨迹回放" : "播放轨迹回放", onClick: () => { if (playing) view.setMapPlayback(rebaseMapPlayback(view.mapPlayback, { playing: false })); else start(); } }, playing ? "暂停" : "开始"),
e("button", { type: "button", className: "icon-command", disabled: !view.mapPlayback.playing && view.mapPlayback.value >= 1, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "回到最新")
),
labeledField(e, "速度", e("select", { value: String(view.mapPlayback.speed), "aria-label": "轨迹回放速度", onChange: (event: InputEvent) => view.setMapPlayback((previous) => rebaseMapPlayback(previous, { speed: Number(inputValue(event)) || 1 })) }, [1, 2, 4].map((speed) => e("option", { key: speed, value: String(speed) }, `${speed}x`)))),
e("p", { className: "page-status" }, tracks.filter((track) => track.kind === "player").length === 1 ? "单人回放:镜头跟随并放到最大比例。" : "多人回放:镜头框住全部所选用户。")
)
)
);
}
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<string, string>, playing: boolean) {
const markers: unknown[] = [];
for (const track of tracks) {
const points = track.points.filter((point) => point.time <= cursor);
if (!points.length) continue;
if (Number.isFinite(cursor)) {
const position = mapTrackPositionAt(track, cursor);
if (position) markers.push(mapLiveMarker(e, track, position, catalog, playing));
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 = middle.rideCode || mapRideCode(middle.row) || track.label;
const icon = trajectoryVehicleIcon(middle.row) || vehicleIconByCode(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: "", loading: "lazy", decoding: "async" }) : "车"));
}
}
return markers;
}
// The moving marker is what makes playback readable: the selected users stay enlarged, and a ridden
// vehicle swaps the dot for the vehicle icon the way a navigation app draws a car instead of a pin.
function mapLiveMarker(e: ReactLike["createElement"], track: MapTrack, point: MapTrackPoint, catalog: Map<string, string>, playing: boolean) {
const rideCode = point.rideCode;
const icon = rideCode ? vehicleIconByCode(catalog.get(rideCode) ?? rideCode) : "";
const label = rideCode ? `${track.label} · ${scumVehicleEntry(rideCode)?.name || catalog.get(rideCode) || "载具"}` : track.label;
const className = ["map-track-live", playing ? "is-playing" : "", rideCode ? "map-track-live-riding" : "", track.kind === "vehicle" ? "map-track-live-vehicle" : ""].filter(Boolean).join(" ");
return e("span", { key: `${track.key}:live`, className, style: { ...mapSceneStyle(point), "--track-color": track.color }, title: `${label} ${timeLabel(point.time)}` },
icon ? e("img", { className: "map-track-live-icon", src: icon, alt: "", loading: "lazy", decoding: "async" }) : e("span", { className: "map-track-live-dot" }),
e("strong", { className: "map-track-live-label" }, track.label)
);
}
function mapSceneStyle(point: MapTrackPoint): Record<string, string> { 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 {
const from = parseTimeInput(view.mapTimeFrom);
const to = parseTimeInput(view.mapTimeTo);
if (from === undefined && to === undefined) return { from: -Infinity, to: Infinity, label: "全部时段" };
const lower = from ?? -Infinity;
const upper = to ?? Infinity;
return { from: lower, to: upper, label: `${shortTimeLabel(lower)} ${shortTimeLabel(upper)}` };
}
function parseTimeInput(value: string | undefined): number | undefined {
const stamp = Date.parse(value ?? "");
return Number.isNaN(stamp) ? undefined : stamp;
}
function mapQueryRange(from: string | undefined, to: string | undefined): { from: number; to: number } {
const upper = parseTimeInput(to) ?? Date.now();
return { from: parseTimeInput(from) ?? upper - scumMapDefaultWindowMs, to: upper };
}
function localDateTimeValue(value: number): string {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function applyMapTimePreset(setPreset: StateSetter<string>, setFrom: StateSetter<string>, setTo: StateSetter<string>, preset: string) {
setPreset(preset);
const span = scumMapPresetMs[preset];
if (span) { const now = Date.now(); setFrom(localDateTimeValue(now - span)); setTo(localDateTimeValue(now)); return; }
if (preset === "all") { setFrom(""); setTo(""); }
}
function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, users: string[] | undefined, catalog: Map<string, string>): MapTrack[] {
const grouped = new Map<string, MapTrack>();
const windowRows = data.trajectories.filter((row) => { const time = trajectoryOrder(row); return time >= window.from && time <= window.to; });
const rideSamples = mapRideSamples(data, bounds);
const riddenVehicles = new Set<string>();
if (users !== undefined) for (const row of windowRows) {
if (layerOf(row) === "vehicles" || !users.includes(mapPlayerTrackIdentity(row))) continue;
const vehicle = playerRide(row, rideSamples, bounds)?.key ?? "";
if (vehicle) riddenVehicles.add(vehicle);
}
for (const row of windowRows) {
const position = positionOf(row);
if (!hasCoordinates(position)) continue;
const kind: "player" | "vehicle" = layerOf(row) === "vehicles" ? "vehicle" : "player";
const identity = kind === "player" ? mapPlayerTrackIdentity(row) : textField(row, "gameVehicleId", "vehicleId", "subjectId", "id");
if (!identity) continue;
if (kind === "player" ? users !== undefined && !users.includes(identity) : users !== undefined && !riddenVehicles.has(identity)) continue;
const time = trajectoryOrder(row);
const scene = mapScenePoint(row, bounds);
const rideCode = kind === "player" ? playerRideCode(row, rideSamples, bounds) : 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: mapTrackColor(identity), 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), 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 mapPlayerTrackIdentity(row: RecordMap): string { return textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id"); }
function mapRiddenVehicleId(row: RecordMap): string { return textField(row, "riddenVehicleId", "gameVehicleId", "vehicleId"); }
// Explicit ridden_vehicle_id wins; otherwise the vehicle sample from the same poll window is used.
function playerRide(row: RecordMap, samples: MapRideSample[], bounds: RecordMap): MapRideSample | undefined {
const explicit = mapRiddenVehicleId(row);
if (explicit) return { key: explicit, code: mapRideCode(row), time: trajectoryOrder(row), scene: mapScenePoint(row, bounds) };
return mapRiddenSample(samples, row, bounds);
}
function playerRideCode(row: RecordMap, samples: MapRideSample[], bounds: RecordMap): string {
const ride = playerRide(row, samples, bounds);
if (!ride) return "";
return ride.code || "vehicle";
}
function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ key: string; label: string; count: number }> {
const users = new Map<string, { key: string; label: string; count: number }>();
for (const row of data.trajectories) {
if (layerOf(row) === "vehicles") continue;
const identity = mapPlayerTrackIdentity(row);
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<MapTrackPoint[]>((all, track) => all.concat(track.points), []); }
function mapVehicleClassIndex(data: SCUMSurfaceData): Map<string, string> {
const index = new Map<string, string>();
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"); }
// Playback covers the recorded samples, not the whole filter window: a 24h window with two hours of
// data would otherwise spend most of the run showing an empty map.
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];
if (times[1] > times[0]) return { from: times[0], to: times[1] };
return { from: Number.isFinite(window.from) ? window.from : times[0], to: Number.isFinite(window.to) ? window.to : times[1] };
}
export function advanceMapPlayback(previous: MapPlaybackState, now = Date.now()): MapPlaybackState {
if (!previous.playing) return previous;
const elapsed = Math.max(0, now - previous.startedAt);
const value = previous.startValue + elapsed / scumMapPlaybackDurationMs * (previous.speed || 1);
return value >= 1 ? { ...previous, playing: false, value: 1 } : { ...previous, value };
}
function rebaseMapPlayback(previous: MapPlaybackState, patch: Partial<MapPlaybackState>, now = Date.now()): MapPlaybackState {
const current = advanceMapPlayback(previous, now);
return { ...current, ...patch, startedAt: now, startValue: patch.value ?? current.value };
}
function playbackCursor(span: { from: number; to: number }, playback: MapPlaybackState): number {
return playback.value >= 1 ? Infinity : span.from + (span.to - span.from) * playback.value;
}
// A single selected user fills the viewport at the maximum zoom and is followed; several users are
// framed together, so two neighbouring players still reach the maximum zoom while two players on
// opposite corners fall back to the minimum.
function playbackStartView(tracks: MapTrack[], node: HTMLElement | null): MapViewState | undefined {
const players = tracks.filter((track) => track.kind === "player" && track.points.length);
if (players.length === 1) return focusMapView(players[0].points[0], node, scumMapZoomMax);
if (players.length > 1) return fitMapView(mapTrackPointList(players), node, 0.8);
return undefined;
}
function playbackFollowView(tracks: MapTrack[], playback: MapPlaybackState, span: { from: number; to: number }, node: HTMLElement | null): MapViewState | undefined {
if (!playback.playing) return undefined;
const players = tracks.filter((track) => track.kind === "player" && track.points.length);
if (players.length !== 1) return undefined;
const position = mapTrackPositionAt(players[0], playbackCursor(span, playback));
return position ? focusMapView(position, node, scumMapZoomMax) : undefined;
}
// The player trajectory table only carries ridden_vehicle_id when the collector fills it. Until then
// every player sample is matched against the vehicle samples recorded in the same poll window, so the
// surrounding vehicle still shows up as a ride marker instead of a walk segment.
function mapRideSamples(data: SCUMSurfaceData, bounds: RecordMap): MapRideSample[] {
const samples: MapRideSample[] = [];
for (const row of data.trajectories) {
if (layerOf(row) !== "vehicles") continue;
const position = positionOf(row);
if (!hasCoordinates(position)) continue;
const time = trajectoryOrder(row);
const key = textField(row, "gameVehicleId", "vehicleId", "subjectId", "id");
if (!key) continue;
samples.push({ key, code: mapRideCode(row), time, scene: mapScenePoint(row, bounds) });
}
return samples.sort((left, right) => left.time - right.time);
}
function mapRiddenSample(samples: MapRideSample[], row: RecordMap, bounds: RecordMap): MapRideSample | undefined {
const position = positionOf(row);
if (!hasCoordinates(position)) return undefined;
const time = trajectoryOrder(row);
const scene = mapScenePoint(row, bounds);
let best: MapRideSample | undefined;
let bestDistance = Infinity;
for (const sample of samples) {
const delta = Math.abs(sample.time - time);
if (delta > scumMapRideWindowMs) continue;
const distance = Math.hypot(sample.scene.x - scene.x, sample.scene.y - scene.y);
if (distance > scumMapRideDistance || distance >= bestDistance) continue;
best = sample;
bestDistance = distance;
}
return best;
}
function mapTrackPositionAt(track: MapTrack, cursor: number): MapTrackPoint | undefined {
const points = track.points;
if (!points.length) return undefined;
if (!Number.isFinite(cursor)) return points[points.length - 1];
if (cursor < points[0].time) return undefined;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const next = points[index];
if (cursor > next.time) continue;
const gap = next.time - previous.time;
if (gap <= 0 || gap > scumMapSegmentGapMs) return { ...previous, time: cursor };
const ratio = (cursor - previous.time) / gap;
const riding = ratio < 0.5 ? previous.riding : next.riding;
return { key: `${track.key}:live`, row: riding ? next.row : previous.row, x: previous.x + (next.x - previous.x) * ratio, y: previous.y + (next.y - previous.y) * ratio, time: cursor, riding, rideCode: riding ? (next.rideCode || previous.rideCode) : "" };
}
return points[points.length - 1];
}
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 focusMapView(point: MapTrackPoint, node: HTMLElement | null, zoom: number): MapViewState {
const size = Math.max(240, Number(node?.clientHeight) || 640);
const clamped = clampMapZoom(zoom);
return { zoom: clamped, x: -(point.x / scumMapSize - 0.5) * size * clamped, y: -(point.y / scumMapSize - 0.5) * size * clamped };
}
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 mapTrackColor(identity: string): string {
const cached = scumMapTrackColorCache.get(identity);
if (cached) return cached;
const index = scumMapTrackColorCache.size;
const color = index < scumMapTrackColors.length ? scumMapTrackColors[index] : `hsl(${(index * 137) % 360} 72% 62%)`;
scumMapTrackColorCache.set(identity, color);
return color;
}
function timeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "unknown" : date.toLocaleString(); }
function shortTimeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "unknown"; const pad = (part: number) => String(part).padStart(2, "0"); return `${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`; }
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") || vehicleFunctionalLabel(point) || "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<string, string> {
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) || scumVehicleFallbackIconUrl(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<string, RecordMap> { const result = new Map<string, RecordMap>(); 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<string, RecordMap> { const result = new Map<string, RecordMap>(); 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<string, RecordMap>): 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<string, RecordMap>();
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<string, string> {
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; }
// The machine-side collector reports its own dump paths (for example "/original/Rager_ES.webp");
// those files are not part of the plugin bundle, so only URLs the bundle really serves may reach an <img>.
export function bundledVehicleImageUrl(value: string): string {
const url = value.trim();
if (!url || /^https?:\/\//i.test(url)) return "";
if (/^(?:data|blob):/i.test(url)) return url;
return /^\/(?:@fs|assets|src|node_modules)\//.test(url) ? url : "";
}
function vehicleIconFor(point: RecordMap): string {
const explicit = bundledVehicleImageUrl(textField(point, "imagePath", "image_path"));
if (explicit) return explicit;
return vehicleIconByCode(textField(point, "className", "vehicleClass", "entityClass", "vehicleType"));
}
function vehicleIconByCode(code: string): string {
if (!code.trim()) return "";
const normalized = normalizedVehicleClass(code);
return scumVehicleIconUrl(normalized) || scumVehicleFallbackIconUrl(normalized);
}
function vehicleLabelFor(point: RecordMap): string {
const code = textField(point, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId");
return scumVehicleEntry(code)?.name || textField(point, "displayName", "label", "name") || code || "未知载具";
}
function vehicleClassKey(value: string): string { return normalizedVehicleClass(value.replace(/^#spawnvehicle\s+/i, "")); }
// Live classes arrive as "<name>_ES" (spawner suffix) or "<name>_C" (blueprint suffix); the catalog
// keys the bare name, so every lookup path strips both before comparing.
function normalizedVehicleClass(value: string): string { return (value.split(".").pop() ?? value).replace(/_(?:ES|C)$/i, "").trim(); }
function shortHash(value: string): string { return value ? `${value.slice(0, 10)}${value.slice(-6)}` : ""; }
function runAction(setAction: StateSetter<ActionState>, pending: string, task: () => Promise<string>) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); }
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(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<string, string>)[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" ? "活动管理" : "用户管理"; }