1646 lines
152 KiB
TypeScript
1646 lines
152 KiB
TypeScript
import {
|
||
deleteGiftDefinition,
|
||
deleteEventDefinition,
|
||
emptySCUMSurfaceData,
|
||
loadSCUMSurface,
|
||
parseGiftItems,
|
||
queueGiftDelivery,
|
||
resetGiftClaim,
|
||
resetPendingGift,
|
||
resolveMapBounds,
|
||
saveEventDefinition,
|
||
saveGiftDefinition,
|
||
saveMapSettings,
|
||
startEvent,
|
||
type RecordMap,
|
||
type SCUMSurfaceData,
|
||
type SCUMWorkspaceActions
|
||
} from "./page-data.js";
|
||
import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleEntry, type ScumCatalogEntry } from "./scum-catalog.js";
|
||
import { scumCatalogDisplayName, scumCatalogSearchText } from "./scum-catalog-zh.js";
|
||
import { scumMapVehicleIconUrl } from "./map-vehicle-icons.js";
|
||
import type { MapPlaybackFrame, MapPlaybackState, MapSceneData, MapTimeWindow, MapTrack, MapTrackPoint, MapTrackSegment, MapViewState } from "./contracts.js";
|
||
|
||
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
|
||
type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; currentTarget?: { open?: boolean }; stopPropagation?: () => void };
|
||
type GiftTab = "definitions" | "claims" | "deliveries" | "timed";
|
||
type GiftDialogState = { mode: "closed" } | { mode: "view" | "edit"; gift?: RecordMap };
|
||
type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other";
|
||
type MapVehicleFilter = "all" | "land" | "water" | "air" | "other";
|
||
type ScumMapLayer = "terrain" | "topo" | "night";
|
||
type CatalogPickTarget = "gift-items" | "activity-items";
|
||
type GiftItemDraft = { catalogCode: string; quantity: string; probability: string; durability: string };
|
||
type GiftItemDraftField = keyof GiftItemDraft;
|
||
type ActivityMapPointerEvent = { pointerId?: number; clientX?: number; clientY?: number; currentTarget?: { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number }; setPointerCapture?: (pointerId?: number) => void; releasePointerCapture?: (pointerId?: number) => void }; preventDefault?: () => void; stopPropagation?: () => void };
|
||
type PlayerPanelKind = "closed" | "gifts" | "items" | "history";
|
||
type PlayerPanelState = { kind: PlayerPanelKind; playerId: 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 scumMapVehicleFilters: ReadonlyArray<readonly [MapVehicleFilter, string]> = [["all", "全部载具"], ["land", "陆地载具"], ["water", "水上载具"], ["air", "飞行类载具"], ["other", "其他载具"]];
|
||
const scumFlightVehicleKeys = new Set(["kinglet_duster", "kinglet_mariner", "kinglet_scout"]);
|
||
const scumGiftCatalogCategories: ReadonlyArray<readonly [string, string]> = [["all", "全部目录"], ["hunting", "狩猎"], ["food", "食物"], ["metabolism", "代谢"], ["medical", "医疗"], ["weapons", "武器"], ["crafting", "制作"], ["gear", "装备"], ["fishing", "钓鱼"], ["building", "建筑"], ["tools", "工具"], ["farming", "农业"], ["misc", "其他"], ["vehicle", "载具"], ["animal", "动物"], ["zombie", "丧尸"], ["armed-npc", "NPC"]];
|
||
const scumMapSize = 4096;
|
||
const scumSurfaceRefreshMs = 15000;
|
||
const scumMapZoomMin = 1;
|
||
const scumMapZoomMax = 8;
|
||
const scumMapSegmentGapMs = 15 * 60 * 1000;
|
||
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 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 };
|
||
const activityMapDragState = { active: false, pointerId: -1, startX: 0, startY: 0 };
|
||
|
||
export type ReactLike = {
|
||
createElement: (...args: any[]) => any;
|
||
useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void;
|
||
useRef?: <T>(initial: T) => { current: T };
|
||
useMemo?: <T>(factory: () => T, deps: readonly unknown[]) => 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 [activityDetailId, setActivityDetailId] = usePluginState(react, "");
|
||
const [eventId, setEventId] = usePluginState(react, "");
|
||
const [eventName, setEventName] = usePluginState(react, "");
|
||
const [eventCommand, setEventCommand] = usePluginState(react, "");
|
||
const [eventItems, setEventItems] = usePluginState<GiftItemDraft[]>(react, []);
|
||
const [eventRegionName, setEventRegionName] = usePluginState(react, "");
|
||
const [eventRegionRadius, setEventRegionRadius] = usePluginState(react, "5000");
|
||
const [eventRegionX, setEventRegionX] = usePluginState(react, "");
|
||
const [eventRegionY, setEventRegionY] = usePluginState(react, "");
|
||
const [eventRegionZ, setEventRegionZ] = usePluginState(react, "0");
|
||
const [giftTab, setGiftTab] = usePluginState<GiftTab>(react, "definitions");
|
||
const [giftSearch, setGiftSearch] = usePluginState(react, "");
|
||
const [giftStatus, setGiftStatus] = usePluginState(react, "all");
|
||
const [giftRecordKey, setGiftRecordKey] = usePluginState(react, "");
|
||
const [giftCode, setGiftCode] = usePluginState(react, "");
|
||
const [giftName, setGiftName] = usePluginState(react, "");
|
||
const [giftItems, setGiftItems] = usePluginState<GiftItemDraft[]>(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 [mapVehicleFilter, setMapVehicleFilter] = usePluginState<MapVehicleFilter>(react, "all");
|
||
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 mapPlaybackFrameRef = react.useRef ? react.useRef<MapPlaybackFrame | null>(null) : { current: null as MapPlaybackFrame | null };
|
||
const mapFollowRef = react.useRef ? react.useRef(true) : { current: true };
|
||
const [giftDialog, setGiftDialog] = usePluginState<GiftDialogState>(react, { mode: "closed" });
|
||
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?.pluginData) {
|
||
setState({ status: "error", reason: "插件页面没有绑定服务器或通用 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;
|
||
let wheelFrame = 0;
|
||
let factor = 1;
|
||
let anchorX = 0;
|
||
let anchorY = 0;
|
||
const onWheel = (event: { deltaY: number; clientX: number; clientY: number; preventDefault: () => void }) => {
|
||
event.preventDefault();
|
||
mapFollowRef.current = false;
|
||
const rect = node.getBoundingClientRect();
|
||
factor *= event.deltaY < 0 ? 1.16 : 1 / 1.16;
|
||
anchorX = event.clientX - rect.left - rect.width / 2;
|
||
anchorY = event.clientY - rect.top - rect.height / 2;
|
||
if (!wheelFrame) wheelFrame = requestAnimationFrame(() => {
|
||
const nextFactor = factor;
|
||
const x = anchorX; const y = anchorY;
|
||
wheelFrame = 0; factor = 1;
|
||
setMapView((previous) => zoomMapView(previous, nextFactor, x, y));
|
||
});
|
||
};
|
||
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); if (wheelFrame) cancelAnimationFrame(wheelFrame); };
|
||
}, [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 frame = mapPlaybackFrameRef.current;
|
||
const camera = frame && mapFollowRef.current ? playbackFollowView(frame.tracks, next, frame.span, mapViewportRef.current) : undefined;
|
||
if (camera) setMapView(camera);
|
||
}, 33);
|
||
return () => { clearInterval(timer); };
|
||
}, [mapPlayback.playing, mapPlayback.startedAt, mapPlayback.startValue, mapPlayback.speed, setMapPlayback, setMapView, mapViewportRef, mapPlaybackFrameRef]);
|
||
if (react.useEffect) react.useEffect(() => {
|
||
setMapPlayback(scumMapPlaybackIdle);
|
||
setHoverMapPoint("");
|
||
setSelectedMapPoint("");
|
||
}, [input.serverInstanceId, pageKey, mapUsers, mapTimeFrom, mapTimeTo]);
|
||
if (react.useEffect) react.useEffect(() => {
|
||
if (giftDialog.mode === "closed" || typeof document === "undefined") return;
|
||
const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setGiftDialog({ mode: "closed" }); };
|
||
document.addEventListener("keydown", onKeyDown);
|
||
return () => document.removeEventListener("keydown", onKeyDown);
|
||
}, [giftDialog.mode]);
|
||
if (react.useEffect) react.useEffect(() => {
|
||
if (pageKey !== "live-map") return;
|
||
if (!focusUser) {
|
||
setMapUsers(undefined);
|
||
if (pageQuery.from || pageQuery.to) {
|
||
const range = mapQueryRange(pageQuery.from, pageQuery.to);
|
||
setMapTimePreset("custom"); setMapTimeFrom(localDateTimeValue(range.from)); setMapTimeTo(localDateTimeValue(range.to));
|
||
} else 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 tracks = mapTracks(state.data, resolveMapBounds(settings), { from: range.from, to: range.to, label: "" }, [focusUser], mapVehicleClassIndex(state.data));
|
||
const camera = playbackStartView(tracks, node);
|
||
if (camera) setMapView(camera);
|
||
}, [pageKey, focusKey, state.status]);
|
||
|
||
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
|
||
const deriveMap = () => pageKey === "live-map" ? buildMapSceneData(data, mapTimeWindow({ mapTimeFrom, mapTimeTo }), mapUsers, mapVehicleFilter) : null;
|
||
const mapData = react.useMemo ? react.useMemo(deriveMap, [pageKey, data, mapTimeFrom, mapTimeTo, mapUsers, mapVehicleFilter]) : deriveMap();
|
||
const view: ViewState = {
|
||
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
|
||
activityStatus, setActivityStatus, activityDetailId, setActivityDetailId, eventId, setEventId, eventName, setEventName,
|
||
eventCommand, setEventCommand, eventItems, setEventItems, eventRegionName, setEventRegionName, eventRegionRadius, setEventRegionRadius, eventRegionX, setEventRegionX, eventRegionY, setEventRegionY, eventRegionZ, setEventRegionZ,
|
||
giftTab, setGiftTab, giftSearch, setGiftSearch, giftStatus, setGiftStatus, giftRecordKey, setGiftRecordKey, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber,
|
||
deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapLayers, setMapLayers, mapVehicleFilter, setMapVehicleFilter, selectedMapPoint, setSelectedMapPoint,
|
||
hoverMapPoint, setHoverMapPoint,
|
||
mapView, setMapView, mapViewportRef, mapPlaybackFrameRef, mapFollowRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo,
|
||
mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback,
|
||
giftDialog, setGiftDialog,
|
||
mapBaseLayer, setMapBaseLayer, mapZonesOverlay, setMapZonesOverlay, catalogSearch, setCatalogSearch, catalogKind, setCatalogKind, catalogOpen, setCatalogOpen,
|
||
setAction, refresh: () => setRefreshSignal((value) => value + 1),
|
||
mapData, mapSceneContents: null, mapStaticContents: null, mapSelectedTrails: [], mapSelectedLocks: []
|
||
};
|
||
const selectedHistory = () => {
|
||
const visible = mapData ? mapVisiblePoints(mapData, view) : [];
|
||
const track = mapData?.tracks.find((candidate) => `track:${candidate.key}` === selectedMapPoint);
|
||
const selected = track?.points[0]?.row ?? visible.find((point, index) => idOf(point, `point-${index}`) === selectedMapPoint) ?? visible[0];
|
||
return {
|
||
trails: selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : [],
|
||
locks: selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : []
|
||
};
|
||
};
|
||
const history = react.useMemo ? react.useMemo(selectedHistory, [mapData, mapLayers, selectedMapPoint]) : selectedHistory();
|
||
view.mapSelectedTrails = history.trails;
|
||
view.mapSelectedLocks = history.locks;
|
||
const renderStaticPoints = () => mapData ? mapVisiblePoints(mapData, view).map((point, index) => mapPointMarker(e, point, index, mapData.bounds, view, data)) : null;
|
||
view.mapStaticContents = react.useMemo ? react.useMemo(renderStaticPoints, [mapData, mapLayers]) : renderStaticPoints();
|
||
const renderMapScene = () => mapData ? mapSceneContents(e, data, view, mapData) : null;
|
||
// Pan/zoom only changes the parent transform. Keep the geometry and marker elements stable.
|
||
view.mapSceneContents = react.useMemo ? react.useMemo(renderMapScene, [mapData, mapLayers, mapPlayback.value, mapPlayback.startedAt, hoverMapPoint]) : renderMapScene();
|
||
return e("section", { className: `console-panel scum-workbench${giftDialog.mode === "closed" ? "" : " has-management-dialog"}`, "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, view) : null
|
||
);
|
||
}
|
||
|
||
type ViewState = {
|
||
mapData: MapSceneData | null; mapSceneContents: unknown; mapStaticContents: unknown;
|
||
mapSelectedTrails: RecordMap[]; mapSelectedLocks: RecordMap[];
|
||
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>; activityDetailId: string; setActivityDetailId: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>; giftSearch: string; setGiftSearch: StateSetter<string>; giftStatus: string; setGiftStatus: StateSetter<string>; giftRecordKey: string; setGiftRecordKey: StateSetter<string>;
|
||
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
|
||
eventCommand: string; setEventCommand: StateSetter<string>;
|
||
eventItems: GiftItemDraft[]; setEventItems: StateSetter<GiftItemDraft[]>; eventRegionName: string; setEventRegionName: StateSetter<string>; eventRegionRadius: string; setEventRegionRadius: StateSetter<string>; eventRegionX: string; setEventRegionX: StateSetter<string>; eventRegionY: string; setEventRegionY: StateSetter<string>; eventRegionZ: string; setEventRegionZ: StateSetter<string>;
|
||
giftCode: string; setGiftCode: StateSetter<string>; giftName: string; setGiftName: StateSetter<string>; giftItems: GiftItemDraft[]; setGiftItems: StateSetter<GiftItemDraft[]>;
|
||
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>>; mapVehicleFilter: MapVehicleFilter; setMapVehicleFilter: StateSetter<MapVehicleFilter>;
|
||
selectedMapPoint: string; setSelectedMapPoint: StateSetter<string>; hoverMapPoint: string; setHoverMapPoint: StateSetter<string>; setAction: StateSetter<ActionState>; refresh: () => void;
|
||
mapView: MapViewState; setMapView: StateSetter<MapViewState>; mapViewportRef: { current: HTMLElement | null };
|
||
mapPlaybackFrameRef: { current: MapPlaybackFrame | null };
|
||
mapFollowRef: { current: boolean };
|
||
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>;
|
||
giftDialog: GiftDialogState; setGiftDialog: StateSetter<GiftDialogState>;
|
||
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 openNewActivity = () => {
|
||
view.setActivityDetailId("__new__"); view.setEventId(""); view.setEventName(""); view.setEventCommand(""); view.setEventItems([]);
|
||
view.setEventRegionName(""); view.setEventRegionRadius("5000"); view.setEventRegionX(""); view.setEventRegionY(""); view.setEventRegionZ("0");
|
||
};
|
||
const openActivity = (event: RecordMap) => {
|
||
const eventId = textField(event, "id", "eventId");
|
||
const produce = data.eventProduces.find((row) => textField(row, "eventId", "event") === eventId);
|
||
const region = activityRegionDraft(event, produce);
|
||
view.setActivityDetailId(eventId); view.setEventId(eventId); view.setEventName(textField(event, "title", "name")); view.setEventCommand(textField(event, "rconCommand", "command"));
|
||
view.setEventItems(activityItemDrafts(event, data.eventProduces)); view.setEventRegionName(region.name); view.setEventRegionRadius(region.radius); view.setEventRegionX(region.x); view.setEventRegionY(region.y); view.setEventRegionZ(region.z);
|
||
};
|
||
const saveEvent = () => runAction(view.setAction, "正在保存活动定义…", async () => {
|
||
const id = view.eventId.trim();
|
||
const title = view.eventName.trim();
|
||
if (!id || !title) throw new Error("活动编号和活动标题不能为空。");
|
||
if (!/^[A-Za-z0-9_.:-]{1,96}$/.test(id)) throw new Error("活动编号只能使用字母、数字、点、下划线、短横线或冒号。");
|
||
const items = parseGiftItems(view.eventItems);
|
||
if (!items.length) throw new Error("活动至少要包含一项物品或 NPC。");
|
||
const region = activityRegionValue(view);
|
||
const previous = data.events.find((event) => textField(event, "id", "eventId") === id);
|
||
const legacy = previous ? {
|
||
eventType: textField(previous, "eventType") || "range", class: integerInput(textField(previous, "class"), 1), schedule: textField(previous, "schedule", "corn"), corn: textField(previous, "corn", "schedule"),
|
||
rconCommand: view.eventCommand.trim() || textField(previous, "rconCommand", "command"), command: view.eventCommand.trim() || textField(previous, "command", "rconCommand"),
|
||
placard: textField(previous, "placard", "announcement"), announcement: textField(previous, "announcement", "placard"), percent: integerInput(textField(previous, "percent", "probability"), 100), probability: integerInput(textField(previous, "probability", "percent"), 100),
|
||
npc: activityCount(items, "armed-npc", textField(previous, "npc")), item: activityCount(items, "item", textField(previous, "item")), zombie: activityCount(items, "zombie", textField(previous, "zombie")), animal: activityCount(items, "animal", textField(previous, "animal")),
|
||
status: textField(previous, "status") || "enabled", announce: boolField(previous, "announce") || Boolean(textField(previous, "placard", "announcement")), durationSeconds: integerInput(textField(previous, "durationSeconds"), 1800)
|
||
} : { eventType: "range", class: 1, rconCommand: view.eventCommand.trim(), command: view.eventCommand.trim(), percent: 100, probability: 100, npc: activityCount(items, "armed-npc"), item: activityCount(items, "item"), zombie: activityCount(items, "zombie"), animal: activityCount(items, "animal"), status: "enabled", announce: false, durationSeconds: 1800 };
|
||
await saveEventDefinition(actions ?? {}, { ...legacy, id, name: title, title, region, area: region, items, produces: activityProduceRecords(id, items, region), updatedAt: new Date().toISOString() });
|
||
view.setActivityDetailId(id);
|
||
view.refresh();
|
||
return `活动 ${title} 已保存。`;
|
||
});
|
||
const renderEditor = (event?: RecordMap) => {
|
||
const eventId = event ? textField(event, "id", "eventId") : "__new__";
|
||
const isNew = eventId === "__new__";
|
||
return e("div", { className: "activity-editor" },
|
||
labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", readOnly: !isNew, 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("input", { value: view.eventRegionName, "aria-label": "活动区域名称", placeholder: "例如 北部机场补给区", onChange: (event: InputEvent) => view.setEventRegionName(inputValue(event)) })),
|
||
e("div", { className: "activity-region-fields" },
|
||
labeledField(e, "区域半径", e("input", { value: view.eventRegionRadius, "aria-label": "活动区域半径", type: "number", min: "1", step: "1", placeholder: "5000", onChange: (event: InputEvent) => view.setEventRegionRadius(inputValue(event)) })),
|
||
labeledField(e, "中心 Z", e("input", { value: view.eventRegionZ, "aria-label": "活动区域 Z", type: "number", onChange: (event: InputEvent) => view.setEventRegionZ(inputValue(event)) }))
|
||
),
|
||
activityMapPicker(e, data, view),
|
||
e("div", { className: "activity-region-fields activity-region-coordinates" },
|
||
labeledField(e, "中心 X", e("input", { value: view.eventRegionX, "aria-label": "活动区域 X", type: "number", onChange: (event: InputEvent) => view.setEventRegionX(inputValue(event)) })),
|
||
labeledField(e, "中心 Y", e("input", { value: view.eventRegionY, "aria-label": "活动区域 Y", type: "number", onChange: (event: InputEvent) => view.setEventRegionY(inputValue(event)) }))
|
||
),
|
||
e("div", { className: "gift-form-wide gift-form-section activity-items-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "活动物品 / NPC"), e("span", null, "复用礼包目录;每项配置数量、概率和耐久")), activityItemEditorList(e, view), catalogPicker(e, view, "activity-items")),
|
||
e("div", { className: "console-row-actions activity-editor-actions" },
|
||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveEvent }, "保存活动"),
|
||
event && textField(event, "rconCommand", "command") ? e("button", { type: "button", className: "icon-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 "活动已进入执行队列。"; }) }, "立即启动") : null,
|
||
event ? e("button", { type: "button", className: "icon-command danger-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.setActivityDetailId(""); view.refresh(); return "活动已删除。"; }) }, "删除") : null
|
||
)
|
||
);
|
||
};
|
||
return e("div", { className: "console-record-list activity-list" },
|
||
e("div", { className: "resource-filter-bar scum-filter-bar activity-list-toolbar" },
|
||
e("span", { className: "page-status" }, `${events.length} / ${data.events.length} 个活动`),
|
||
labeledField(e, "活动状态", 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("button", { type: "button", className: "primary-command activity-create-command", disabled: !actions?.pluginData, onClick: openNewActivity }, "新建活动")
|
||
),
|
||
e("details", { className: "console-module activity-record activity-record-new", open: view.activityDetailId === "__new__", onToggle: (event: InputEvent) => { if (detailOpen(event)) openNewActivity(); else if (view.activityDetailId === "__new__") view.setActivityDetailId(""); } },
|
||
e("summary", null, e("strong", null, "新建活动"), e("span", { className: "page-status" }, "展开配置活动区域和发放清单")),
|
||
view.activityDetailId === "__new__" ? renderEditor() : null
|
||
),
|
||
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";
|
||
const region = activityRegionDraft(event, data.eventProduces.find((row) => textField(row, "eventId", "event") === eventId));
|
||
const items = activityItemDrafts(event, data.eventProduces);
|
||
return e("details", { key: idOf(event, `event-${index}`), className: "console-module activity-record", open: view.activityDetailId === eventId, onToggle: (toggle: InputEvent) => { if (detailOpen(toggle)) openActivity(event); else if (view.activityDetailId === eventId) view.setActivityDetailId(""); } },
|
||
e("summary", null, e("strong", null, textField(event, "title", "name") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status), e("span", { className: "page-status" }, `${region.name || "未命名区域"} · ${items.length} 项物品 / NPC`)),
|
||
view.activityDetailId === eventId ? renderEditor(event) : null
|
||
);
|
||
}) : e("p", { className: "page-status" }, "暂无活动,请先新建活动。")
|
||
);
|
||
}
|
||
|
||
function activityItemEditorList(e: ReactLike["createElement"], view: ViewState) { return catalogItemEditorList(e, view.eventItems, view.setEventItems, "活动物品"); }
|
||
|
||
function activityRegionDraft(event: RecordMap, produce?: RecordMap): { name: string; radius: string; x: string; y: string; z: string } {
|
||
const nested = field(event, "region", "area", "location");
|
||
const source = isRecord(nested) ? nested : event;
|
||
const fallback = produce ? positionOf(produce) : undefined;
|
||
const value = (keys: string[], fallbackValue = "") => { const direct = textField(source, ...keys); return direct || (fallback ? textField(fallback, ...keys) : fallbackValue); };
|
||
const name = isRecord(nested) ? value(["name", "label", "regionName", "areaName"]) : value(["regionName", "areaName", "regionLabel"]);
|
||
return { name, radius: value(["radius", "r", "size"], "5000"), x: value(["x", "locationX"]), y: value(["y", "locationY"]), z: value(["z", "locationZ"], "0") };
|
||
}
|
||
|
||
function activityItemDrafts(event: RecordMap, produces: RecordMap[]): GiftItemDraft[] {
|
||
const direct = giftItemDrafts(event);
|
||
if (direct.length) return direct;
|
||
const eventId = textField(event, "id", "eventId");
|
||
return produces.filter((produce) => textField(produce, "eventId", "event") === eventId).map((produce) => ({ catalogCode: textField(produce, "catalogCode", "tradeGoodsId", "trade_goods_id"), quantity: numField(produce, "quantity", "value") === "--" ? "1" : numField(produce, "quantity", "value"), probability: numField(produce, "probability", "percent") === "--" ? "100" : numField(produce, "probability", "percent"), durability: giftItemRawDurability(produce) }));
|
||
}
|
||
|
||
function activityRegionValue(view: ViewState): { name: string; radius: number; x: number; y: number; z: number } {
|
||
const radius = Number(view.eventRegionRadius); const x = Number(view.eventRegionX); const y = Number(view.eventRegionY); const z = Number(view.eventRegionZ);
|
||
if (!view.eventRegionX.trim() || !view.eventRegionY.trim() || ![radius, x, y, z].every(Number.isFinite) || radius <= 0) throw new Error("请在地图上选择活动区域,并填写有效的区域半径。");
|
||
return { name: view.eventRegionName.trim(), radius, x, y, z };
|
||
}
|
||
|
||
function activityProduceRecords(eventId: string, items: Array<{ catalogCode: string; quantity: number | string; probability: number | string; durability?: number | string }>, region: { radius: number; x: number; y: number; z: number }): RecordMap[] {
|
||
return items.map((item, index) => { const quantity = Number(item.quantity); const probability = Number(item.probability); const durability = Number(item.durability); return { id: `produce-${index + 1}`, eventId, tradeGoodsId: item.catalogCode, catalogCode: item.catalogCode, percent: Number.isFinite(probability) ? probability : 100, probability: Number.isFinite(probability) ? probability : 100, value: Number.isSafeInteger(quantity) && quantity > 0 ? quantity : 1, quantity: Number.isSafeInteger(quantity) && quantity > 0 ? quantity : 1, ...(Number.isFinite(durability) ? { durability } : {}), r: region.radius, x: region.x, y: region.y, z: region.z }; });
|
||
}
|
||
|
||
function activityCount(items: Array<{ catalogCode: string; quantity: number | string }>, kind: ScumCatalogEntry["kind"], fallback = "0"): number {
|
||
const count = items.filter((item) => scumCatalogEntryFor(item.catalogCode)?.kind === kind).reduce((total, item) => total + (Number(item.quantity) || 0), 0);
|
||
return count || Number(fallback) || 0;
|
||
}
|
||
|
||
function activityMapPicker(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) {
|
||
const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0];
|
||
const bounds = resolveMapBounds(settings);
|
||
const selected = view.eventRegionX.trim() && view.eventRegionY.trim() && Number.isFinite(Number(view.eventRegionX)) && Number.isFinite(Number(view.eventRegionY)) ? { x: Number(view.eventRegionX), y: Number(view.eventRegionY) } : undefined;
|
||
const selectedStyle = selected ? activityMapSelectionStyle(selected, Number(view.eventRegionRadius) || 5000, bounds) : undefined;
|
||
const choose = (event: ActivityMapPointerEvent) => {
|
||
const point = activityMapWorldPoint(event, bounds);
|
||
if (!point) return;
|
||
view.setEventRegionX(String(Math.round(point.x))); view.setEventRegionY(String(Math.round(point.y)));
|
||
const start = activityMapDragState;
|
||
if (start.active) {
|
||
const startPoint = activityMapWorldPoint({ ...event, clientX: start.startX, clientY: start.startY, currentTarget: event.currentTarget }, bounds);
|
||
if (startPoint) {
|
||
const radius = Math.round(Math.hypot(point.x - startPoint.x, point.y - startPoint.y));
|
||
if (radius > 0) view.setEventRegionRadius(String(radius));
|
||
}
|
||
}
|
||
activityMapDragState.active = false;
|
||
};
|
||
return e("div", { className: "activity-map-picker" },
|
||
e("div", { className: "activity-map-picker-head" }, e("strong", null, "活动区域"), e("span", { className: "page-status" }, "点击选择中心;从中心拖动可直接设置半径")),
|
||
e("div", { className: "activity-map-picker-board", role: "application", "aria-label": "选择活动区域地图", onPointerDown: (event: ActivityMapPointerEvent) => { activityMapDragState.active = true; activityMapDragState.pointerId = event.pointerId ?? -1; activityMapDragState.startX = event.clientX ?? 0; activityMapDragState.startY = event.clientY ?? 0; event.currentTarget?.setPointerCapture?.(event.pointerId); }, onPointerUp: choose, onPointerCancel: () => { activityMapDragState.active = false; } },
|
||
e("div", { className: "activity-map-picker-scene", style: { backgroundImage: mapBoardStyle(mapLayerSetting(settings), false) } },
|
||
mapGridOverlay(e),
|
||
data.mapRegions.map((region, index) => { const position = positionOf(region); return hasCoordinates(position) ? e("button", { key: idOf(region, `map-region-${index}`), type: "button", className: "activity-map-region", style: mapPointStyle(position, bounds), title: textField(region, "name", "label", "id") || "已有地图区域", onPointerDown: (event: ActivityMapPointerEvent) => event.stopPropagation?.(), onClick: (event: ActivityMapPointerEvent) => { event.stopPropagation?.(); view.setEventRegionName(textField(region, "name", "label")); view.setEventRegionX(String(Number(field(position, "x", "locationX")))); view.setEventRegionY(String(Number(field(position, "y", "locationY")))); view.setEventRegionZ(String(Number(field(position, "z", "locationZ") ?? 0))); view.setEventRegionRadius(numField(region, "radius", "r") === "--" ? "5000" : numField(region, "radius", "r")); } }, textField(region, "name", "label") || "区") : null; }),
|
||
selectedStyle ? e("span", { className: "activity-map-selection", style: selectedStyle, "aria-label": "当前活动区域" }) : null
|
||
)
|
||
),
|
||
e("p", { className: "page-status" }, `当前区域:${view.eventRegionName || "未命名"} · ${view.eventRegionX || "未选择"} / ${view.eventRegionY || "未选择"} · 半径 ${view.eventRegionRadius || "未设置"}`)
|
||
);
|
||
}
|
||
|
||
function activityMapWorldPoint(event: ActivityMapPointerEvent, bounds: { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number }): { x: number; y: number } | undefined {
|
||
const rect = event.currentTarget?.getBoundingClientRect?.();
|
||
if (!rect || !rect.width || !rect.height || event.clientX === undefined || event.clientY === undefined) return undefined;
|
||
const horizontal = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||
const vertical = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
|
||
return { x: bounds.worldMaxX - horizontal * (bounds.worldMaxX - bounds.worldMinX), y: bounds.worldMaxY - vertical * (bounds.worldMaxY - bounds.worldMinY) };
|
||
}
|
||
|
||
function activityMapSelectionStyle(point: { x: number; y: number }, radius: number, bounds: { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number }): Record<string, string> {
|
||
const center = mapPointStyle(point, bounds);
|
||
const diameter = Math.max(2, Math.min(80, radius * 2 / Math.max(bounds.worldMaxX - bounds.worldMinX, bounds.worldMaxY - bounds.worldMinY) * 100));
|
||
return { ...center, width: `${diameter}%`, height: `${diameter}%` };
|
||
}
|
||
|
||
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
const actions = input.workspaceActions;
|
||
const openNewGift = () => {
|
||
view.setGiftRecordKey(""); view.setGiftCode(""); view.setGiftName(""); view.setGiftItems([]); view.setGiftClass("5"); view.setGiftAudience("all"); view.setGiftNumber("1"); view.setGiftAchievement("0"); view.setGiftAchievementNumber("0"); view.setGiftDialog({ mode: "edit" });
|
||
};
|
||
const openGiftDialog = (gift: RecordMap, mode: "view" | "edit") => {
|
||
view.setGiftRecordKey(textField(gift, "_recordKey", "code", "id")); 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(giftItemDrafts(gift)); view.setGiftDialog({ mode, gift });
|
||
};
|
||
const saveGift = () => runAction(view.setAction, "正在保存礼包定义…", async () => {
|
||
const code = view.giftCode.trim();
|
||
const name = view.giftName.trim();
|
||
if (!code || !name) throw new Error("礼包编号和名称不能为空。");
|
||
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(code)) throw new Error("礼包编号只能使用字母、数字、点、下划线或短横线。");
|
||
const items = parseGiftItems(view.giftItems);
|
||
if (!items.length) throw new Error("礼包至少要包含一项物品。");
|
||
const number = minimumIntegerInput(view.giftNumber, 1, "发放次数");
|
||
const achievement = minimumIntegerInput(view.giftAchievement, 0, "成就类型");
|
||
const achievementNumber = minimumIntegerInput(view.giftAchievementNumber, 0, "成就值");
|
||
await saveGiftDefinition(actions ?? {}, {
|
||
code, name, class: integerInput(view.giftClass, 5), audience: view.giftAudience, number,
|
||
achievement, achievementNumber, items,
|
||
status: "active", updatedAt: new Date().toISOString()
|
||
});
|
||
if (view.giftRecordKey && view.giftRecordKey !== code) await deleteGiftDefinition(actions ?? {}, view.giftRecordKey);
|
||
view.setGiftDialog({ mode: "closed" }); view.setGiftRecordKey("");
|
||
view.refresh();
|
||
return `礼包 ${name} 已保存。`;
|
||
});
|
||
const selectedGift = view.giftDialog.mode === "closed" ? undefined : view.giftDialog.gift;
|
||
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 "礼包发放已进入执行队列。";
|
||
});
|
||
const search = view.giftSearch.trim().toLowerCase();
|
||
const gifts = data.gifts.filter((gift) => {
|
||
const status = textField(gift, "status").toLowerCase() || "active";
|
||
const statusFilter = activeStatus(status) ? "active" : "disabled";
|
||
return matchesText(gift, search, "code", "name", "audience", "status") && (view.giftStatus === "all" || statusFilter === view.giftStatus);
|
||
});
|
||
return e("div", { className: "console-record-list" },
|
||
statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.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: "gift-definitions-surface" },
|
||
e("div", { className: "resource-filter-bar scum-filter-bar gift-toolbar" },
|
||
labeledField(e, "搜索礼包", e("input", { type: "search", value: view.giftSearch, "aria-label": "搜索礼包", placeholder: "名称 / 编号 / 适用玩家", onChange: (event: InputEvent) => view.setGiftSearch(inputValue(event)) })),
|
||
labeledField(e, "礼包状态", e("select", { value: view.giftStatus, "aria-label": "礼包状态", onChange: (event: InputEvent) => view.setGiftStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "active" }, "启用"), e("option", { value: "disabled" }, "停用"))),
|
||
e("span", { className: "page-status" }, `${gifts.length} / ${data.gifts.length} 个礼包`),
|
||
e("button", { type: "button", className: "primary-command gift-create-command", disabled: !actions?.pluginData, onClick: openNewGift }, "新建礼包")
|
||
),
|
||
e("div", { className: "provider-table-wrap scum-table-wrap gift-table-wrap" },
|
||
e("table", { className: "resource-table scum-gift-table" },
|
||
e("thead", null, e("tr", null, ["礼包", "礼包内容", "领取规则", "状态 / 更新时间", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))),
|
||
e("tbody", null, gifts.length ? gifts.map((gift, index) => {
|
||
const key = textField(gift, "_recordKey", "code", "id");
|
||
const status = textField(gift, "status").toLowerCase() || "active";
|
||
return e("tr", { key: idOf(gift, `gift-${index}`) },
|
||
e("td", null, e("strong", null, textField(gift, "name") || key), e("span", { className: "provider-id scum-gift-code" }, key)),
|
||
e("td", null, e("div", { className: "scum-gift-content-cell" }, catalogIconStrip(e, giftCatalogIcons(gift)), e("span", null, giftItemsSummary(gift)))),
|
||
e("td", null, e("span", { className: "gift-rule-primary" }, `${giftClassLabel(numField(gift, "class"))} · ${giftAudienceLabel(textField(gift, "audience"))}`), e("span", { className: "provider-id" }, `发放 ${numField(gift, "number")} 次 · 成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)),
|
||
e("td", null, e("div", { className: "gift-status-cell" }, e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, giftStatusLabel(status)), e("span", { className: "provider-id" }, dateField(gift, "updatedAt", "createdAt")))),
|
||
e("td", { className: "provider-actions-cell" }, e("div", { className: "scum-gift-table-actions" },
|
||
e("button", { type: "button", className: "icon-command", onClick: () => openGiftDialog(gift, "view") }, "查看配置"),
|
||
e("details", { className: "inline-action-control" },
|
||
e("summary", { className: "icon-command", "aria-label": `打开${textField(gift, "name") || key}更多操作` }, "更多"),
|
||
e("div", { className: "inline-action-menu", role: "menu", "aria-label": "礼包操作" },
|
||
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => openGiftDialog(gift, "edit") }, "编辑礼包"),
|
||
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => { view.setDeliveryGift(key); view.setGiftTab("deliveries"); } }, "选择发放"),
|
||
e("button", { type: "button", className: "runtime-action-item danger-command", role: "menuitem", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除")
|
||
)
|
||
)
|
||
))
|
||
);
|
||
}) : e("tr", null, e("td", { colSpan: 5 }, e("p", { className: "page-status" }, data.gifts.length ? "没有符合筛选条件的礼包。" : "暂无礼包定义,请先新建一个礼包。"))))
|
||
)
|
||
),
|
||
) : 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,
|
||
view.giftDialog.mode === "view" && selectedGift ? giftDetailsDialog(e, selectedGift, view, () => openGiftDialog(selectedGift, "edit")) : null,
|
||
view.giftDialog.mode === "edit" ? giftEditorDialog(e, view, actions, saveGift) : null
|
||
);
|
||
}
|
||
|
||
function giftItemEditorList(e: ReactLike["createElement"], view: ViewState) { return catalogItemEditorList(e, view.giftItems, view.setGiftItems, "礼包物品"); }
|
||
|
||
function catalogItemEditorList(e: ReactLike["createElement"], items: GiftItemDraft[], setItems: StateSetter<GiftItemDraft[]>, labelPrefix: string) {
|
||
if (!items.length) return e("p", { className: "page-status" }, `尚未添加物品,请使用目录选择器加入${labelPrefix}。`);
|
||
return e("div", { className: "gift-item-editor-list" }, items.map((item, index) => {
|
||
const entry = scumCatalogEntryFor(item.catalogCode);
|
||
const supportsDurability = giftItemSupportsDurability(entry);
|
||
const itemLabel = entry ? scumCatalogDisplayName(entry) : item.catalogCode || "未命名物品";
|
||
const category = entry ? scumCatalogCategoryLabel(entry) : "物品";
|
||
const itemNumber = index + 1;
|
||
return e("div", { key: `${item.catalogCode}-${index}`, className: "gift-item-editor-row" },
|
||
catalogIconStrip(e, catalogIconsForValue(item.catalogCode)),
|
||
e("div", { className: "gift-item-editor-info" }, e("strong", null, itemLabel), e("span", { className: "provider-id" }, `${category} · ${item.catalogCode || "未配置代码"}`)),
|
||
e("div", { className: "gift-item-editor-fields" },
|
||
labeledField(e, "数量", e("input", { type: "number", min: "1", step: "1", value: item.quantity, "aria-label": `${labelPrefix} ${itemNumber} 数量`, onChange: (event: InputEvent) => updateCatalogItemDraft(setItems, index, "quantity", inputValue(event)) })),
|
||
labeledField(e, "概率", e("input", { type: "number", min: "0", max: "100", step: "1", value: item.probability, "aria-label": `${labelPrefix} ${itemNumber} 概率`, onChange: (event: InputEvent) => updateCatalogItemDraft(setItems, index, "probability", inputValue(event)) })),
|
||
labeledField(e, "耐久", e("input", { type: "number", min: "0", max: "100", step: "1", value: item.durability, disabled: !supportsDurability, placeholder: supportsDurability ? "默认" : "不适用", "aria-label": `${labelPrefix} ${itemNumber} 耐久`, onChange: (event: InputEvent) => updateCatalogItemDraft(setItems, index, "durability", inputValue(event)) })),
|
||
e("button", { type: "button", className: "gift-item-editor-remove icon-command", "aria-label": `删除${labelPrefix} ${itemNumber}`, onClick: () => setItems((current) => current.filter((_, itemIndex) => itemIndex !== index)) }, "删除")
|
||
)
|
||
);
|
||
}));
|
||
}
|
||
|
||
function updateCatalogItemDraft(setItems: StateSetter<GiftItemDraft[]>, index: number, key: GiftItemDraftField, value: string): void {
|
||
setItems((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item));
|
||
}
|
||
|
||
function giftDetailsDialog(e: ReactLike["createElement"], gift: RecordMap, view: ViewState, editGift: () => void) {
|
||
const items = giftItemRecords(gift);
|
||
const status = textField(gift, "status").toLowerCase() || "active";
|
||
return e("div", { className: "confirm-backdrop management-dialog-backdrop gift-dialog-backdrop", role: "presentation", onClick: () => view.setGiftDialog({ mode: "closed" }) },
|
||
e("div", { className: "drawer-panel management-dialog-panel management-dialog-wide", role: "dialog", "aria-modal": "true", "aria-label": "查看礼包配置", onClick: (event: InputEvent) => event.stopPropagation?.() },
|
||
e("div", { className: "panel-header dialog-panel-header" }, e("div", { className: "gift-dialog-heading" }, e("h2", null, textField(gift, "name") || textField(gift, "code") || "礼包配置"), e("span", { className: "page-status" }, "礼包配置详情")), e("button", { type: "button", className: "theme-upload drawer-close", "aria-label": "关闭礼包配置", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "关闭")),
|
||
e("div", { className: "gift-config-details dialog-form" },
|
||
e("dl", { className: "gift-config-grid" },
|
||
giftConfigEntry(e, "礼包编号", textField(gift, "code", "id") || "未配置"),
|
||
giftConfigEntry(e, "状态", giftStatusLabel(status)),
|
||
giftConfigEntry(e, "领取周期", giftClassLabel(numField(gift, "class"))),
|
||
giftConfigEntry(e, "适用玩家", giftAudienceLabel(textField(gift, "audience"))),
|
||
giftConfigEntry(e, "发放次数", numField(gift, "number")),
|
||
giftConfigEntry(e, "成就门槛", `${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)
|
||
),
|
||
e("section", { className: "gift-form-section gift-config-section", "aria-label": "礼包内容" },
|
||
e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包内容"), e("span", null, `${items.length} 项物品`)),
|
||
items.length ? e("div", { className: "gift-config-list" }, items.map((item, index) => {
|
||
const code = textField(item, "catalogCode", "catalogItemKey", "key", "className");
|
||
const entry = scumCatalogEntryFor(code);
|
||
return e("div", { key: `${code}-${index}`, className: "gift-config-row" }, catalogIconStrip(e, catalogIconsForValue(code)), e("div", { className: "gift-config-item-info" }, e("strong", null, entry ? scumCatalogDisplayName(entry) : textField(item, "label") || code || "未命名物品"), e("span", { className: "provider-id" }, `${entry ? scumCatalogCategoryLabel(entry) : "物品"} · ${code || "unknown"}`)), e("span", null, `数量 × ${numField(item, "quantity")}`), e("span", null, `概率 ${giftItemProbability(item)}%`), e("span", null, `耐久 ${giftItemDurability(item, entry)}`));
|
||
})) : e("p", { className: "page-status" }, "未配置物品。"),
|
||
),
|
||
e("div", { className: "confirm-actions gift-dialog-actions" }, e("button", { type: "button", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "关闭"), e("button", { type: "button", className: "confirm-primary", onClick: editGift }, "编辑礼包"))
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
function giftEditorDialog(e: ReactLike["createElement"], view: ViewState, actions: SCUMWorkspaceActions | undefined, saveGift: () => void) {
|
||
const editing = Boolean(view.giftRecordKey);
|
||
return e("div", { className: "confirm-backdrop management-dialog-backdrop gift-dialog-backdrop", role: "presentation", onClick: () => view.setGiftDialog({ mode: "closed" }) },
|
||
e("div", { className: "drawer-panel management-dialog-panel management-dialog-wide", role: "dialog", "aria-modal": "true", "aria-label": editing ? "编辑礼包" : "新建礼包", onClick: (event: InputEvent) => event.stopPropagation?.() },
|
||
e("div", { className: "panel-header dialog-panel-header" }, e("div", { className: "gift-dialog-heading" }, e("h2", null, editing ? "编辑礼包" : "新建礼包"), e("span", { className: "page-status" }, editing ? "更新现有礼包定义" : "创建可领取的运营礼包")), e("button", { type: "button", className: "theme-upload drawer-close", "aria-label": "关闭礼包编辑器", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "关闭")),
|
||
e("div", { className: "gift-form dialog-form management-form" },
|
||
e("div", { className: "form-guidance management-form-wide" }, e("strong", null, "先定义规则,再配置内容"), e("span", null, "礼包编号用于数据主键,只支持字母、数字、点、下划线和短横线;礼包至少要包含一项物品。")),
|
||
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", min: "1", placeholder: "1", onChange: (event: InputEvent) => view.setGiftNumber(inputValue(event)) })),
|
||
labeledField(e, "成就类型", e("input", { value: view.giftAchievement, "aria-label": "成就类型", type: "number", min: "0", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievement(inputValue(event)) })),
|
||
labeledField(e, "成就值", e("input", { value: view.giftAchievementNumber, "aria-label": "成就值", type: "number", min: "0", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievementNumber(inputValue(event)) })),
|
||
e("div", { className: "gift-form-wide gift-form-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包物品"), e("span", null, "可用目录选择器快速加入;每项配置数量、概率和耐久")), giftItemEditorList(e, view), catalogPicker(e, view, "gift-items")),
|
||
e("div", { className: "confirm-actions gift-dialog-actions" }, e("button", { type: "button", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "取消"), e("button", { type: "button", className: "confirm-primary", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包"))
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
function buildMapSceneData(data: SCUMSurfaceData, window: MapTimeWindow, selectedUsers: string[] | undefined, vehicleFilter: MapVehicleFilter): MapSceneData {
|
||
const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0];
|
||
const bounds = resolveMapBounds(settings);
|
||
const catalog = mapVehicleClassIndex(data);
|
||
const users = mapTrackUsers(data, window);
|
||
const allowed = new Set(users.filter((user) => selectedUsers === undefined || selectedUsers.includes(user.key)).map((user) => user.key));
|
||
const points = collectMapPoints(data).filter((point) => (layerOf(point) !== "vehicles" || vehicleFilter === "all" || mapVehicleCategory(point) === vehicleFilter) && (layerOf(point) !== "players" || playerIdentities(point).some((id) => allowed.has(id)) || allowed.has(textField(point, "subjectId"))));
|
||
const playerLabels = new Map(data.players.map((player) => [textField(player, "steamId", "gamePlayerId", "id"), textField(player, "displayName", "name", "playerName")]));
|
||
const tracks = mapTracks(data, bounds, window, [...allowed], catalog);
|
||
return { points, settings, bounds, window, catalog, users, playerLabels, tracks, span: mapPlaybackSpan(window, tracks), segments: new Map(tracks.map((track) => [track.key, mapTrackSegments(track)])) };
|
||
}
|
||
|
||
function mapVisiblePoints(scene: MapSceneData, view: ViewState): RecordMap[] {
|
||
const trackedUsers = new Set(scene.tracks.map((track) => track.key.slice("player:".length)));
|
||
return scene.points.filter((point) => view.mapLayers[layerOf(point)] && !(layerOf(point) === "players" && (playerIdentities(point).some((id) => trackedUsers.has(id)) || trackedUsers.has(textField(point, "subjectId")))));
|
||
}
|
||
|
||
function mapSceneContents(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState, scene: MapSceneData) {
|
||
const { tracks, span, catalog, bounds, segments } = scene;
|
||
const cursor = playbackCursor(span, view.mapPlayback);
|
||
const visible = mapVisiblePoints(scene, view);
|
||
const hovered = visible.find((point, index) => idOf(point, `point-${index}`) === view.hoverMapPoint);
|
||
const hoveredTrack = tracks.find((track) => `track:${track.key}` === view.hoverMapPoint);
|
||
const hoveredPosition = hoveredTrack ? mapTrackPositionAt(hoveredTrack, cursor) : undefined;
|
||
return [mapGridOverlay(e),
|
||
view.mapLayers.players ? e("svg", { key: "tracks", className: "map-track-layer", viewBox: `0 0 ${scumMapSize} ${scumMapSize}`, preserveAspectRatio: "none", "aria-hidden": "true" }, mapTrackShapes(e, tracks, segments, cursor)) : null,
|
||
view.mapLayers.players ? mapTrackMarkers(e, tracks, cursor, catalog, Number.isFinite(cursor), view) : null,
|
||
view.mapStaticContents,
|
||
hovered ? mapHoverCard(e, hovered, data, mapPointStyle(hovered, bounds)) : hoveredPosition ? mapHoverCard(e, mapTrackHoverRow(hoveredPosition.row, data), data, mapSceneStyle(hoveredPosition)) : null
|
||
];
|
||
}
|
||
|
||
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
const actions = input.workspaceActions;
|
||
const scene = view.mapData!;
|
||
const { points, settings, window, users, playerLabels, tracks, span } = scene;
|
||
const baseLayer = view.mapBaseLayer ?? mapLayerSetting(settings);
|
||
const zonesOverlay = view.mapZonesOverlay ?? Boolean(settings && boolField(settings, "buildingZones"));
|
||
view.mapPlaybackFrameRef.current = { tracks, span };
|
||
const cursor = playbackCursor(span, view.mapPlayback);
|
||
const zoom = view.mapView.zoom;
|
||
const visible = mapVisiblePoints(scene, view);
|
||
const selectedTrack = tracks.find((track) => `track:${track.key}` === view.selectedMapPoint);
|
||
const selectedPosition = selectedTrack ? mapTrackPositionAt(selectedTrack, cursor) : undefined;
|
||
const selected = selectedPosition ? mapTrackHoverRow(selectedPosition.row, data) : visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0];
|
||
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: view.mapVehicleFilter, "aria-label": "载具类型", onChange: (event: InputEvent) => view.setMapVehicleFilter(inputValue(event) as MapVehicleFilter) }, scumMapVehicleFilters.map(([key, label]) => e("option", { key, value: key }, label)))),
|
||
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)))),
|
||
mapPlaybackBar(e, view, span, tracks, tracks.length > 0, `${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; view.mapFollowRef.current = false; } 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.mapFollowRef.current = false; 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) }),
|
||
view.mapSceneContents
|
||
),
|
||
e("div", { className: "map-view-controls", onClick: () => { view.mapFollowRef.current = false; }, onPointerDown: (event: PointerEventLike) => event.stopPropagation?.(), onDoubleClick: (event: PointerEventLike) => event.stopPropagation?.() },
|
||
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)}×`)
|
||
)
|
||
),
|
||
mapTrackLegend(e, view, tracks, playerLabels),
|
||
selected ? mapPointStrip(e, selected, view.mapSelectedTrails, view.mapSelectedLocks, data) : null
|
||
);
|
||
}
|
||
|
||
function mapPointMarker(e: ReactLike["createElement"], point: RecordMap, index: number, bounds: RecordMap, view: ViewState, data: SCUMSurfaceData) {
|
||
const layer = layerOf(point);
|
||
const ride = layer === "players" ? mapRiddenVehicleId(point) : "";
|
||
const riddenVehicle = ride ? vehicleForIdentity(data.vehicles, ride) : undefined;
|
||
const icon = layer === "vehicles" ? vehicleIconFor(point) : riddenVehicle ? vehicleIconFor(riddenVehicle) : ride ? vehicleIconByCode(ride) : "";
|
||
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 ? ` · 乘坐 ${riddenVehicle ? vehicleLabelFor(riddenVehicle) : ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onPointerEnter: open, onPointerLeave: close, onFocus: open, onBlur: close, onPointerDown: (event: PointerEventLike) => { mapDragState.moved = false; event.stopPropagation?.(); }, 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.
|
||
// Ownership and durability are shown only from reported records; unavailable required facts are
|
||
// labelled as unsynchronized.
|
||
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")));
|
||
const riddenVehicle = vehicleForIdentity(data.vehicles, mapRiddenVehicleId(point));
|
||
push("乘坐", riddenVehicle ? vehicleLabelFor(riddenVehicle) : mapRiddenVehicleId(point));
|
||
push("状态", playerOnline(point) ? "在线" : "离线");
|
||
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) || "未同步");
|
||
const squadId = textField(point, "ownerSquadId", "squadId");
|
||
push("附属队伍", squadId ? mapSquadLabel(data, squadId) : "无附属队伍");
|
||
push("状态", textField(point, "status", "state") || "未知");
|
||
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", { key: "hover", 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);
|
||
const memberCount = data.members.filter((row) => textField(row, "squadId", "squad_id") === value).length;
|
||
const declaredCount = squad ? numField(squad, "memberCount", "member_count") : "--";
|
||
const count = memberCount || (declaredCount === "--" ? 0 : Number(declaredCount));
|
||
if (!squad) return count ? `${value} · ${count} 人` : value;
|
||
return `${textField(squad, "name", "label") || value}(${value}${count ? ` · ${count} 人` : ""})`;
|
||
}
|
||
|
||
function mapOwnerLabel(data: SCUMSurfaceData, point: RecordMap): string {
|
||
const ownerId = textField(point, "ownerSteamId", "ownerPlayerId", "steamId", "ownerProfileId", "ownerId");
|
||
if (!ownerId) return "";
|
||
const owner = data.players.find((player) => playerIdentities(player).includes(ownerId));
|
||
return owner ? `${textField(owner, "displayName", "name") || ownerId}(${ownerId})` : ownerId;
|
||
}
|
||
|
||
function vehicleForIdentity(rows: RecordMap[], identity: string): RecordMap | undefined {
|
||
if (!identity) return undefined;
|
||
return rows.find((row) => [textField(row, "id"), textField(row, "vehicleId"), textField(row, "gameVehicleId"), textField(row, "entityId")].includes(identity));
|
||
}
|
||
|
||
function vehicleLockLabel(point: RecordMap, data: SCUMSurfaceData): string {
|
||
if (field(point, "locked", "isLocked") !== undefined && !boolField(point, "locked", "isLocked")) return "未上锁";
|
||
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 percent = textField(point, "healthPercent", "durabilityPercent", "conditionPercent");
|
||
if (percent) return `${percent}%`;
|
||
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("div", { className: "console-field scum-map-menu-field" },
|
||
e("span", null, "用户"),
|
||
e("details", { className: "scum-map-menu scum-map-filter-menu", "aria-label": "轨迹用户筛选", onMouseLeave: (event: InputEvent) => { if (event.currentTarget) event.currentTarget.open = false; } },
|
||
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("div", { className: "console-field scum-map-menu-field" },
|
||
e("span", null, "图层"),
|
||
e("details", { className: "scum-map-menu scum-map-filter-menu", "aria-label": "地图图层筛选", onMouseLeave: (event: InputEvent) => { if (event.currentTarget) event.currentTarget.open = false; } },
|
||
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, summary: string) {
|
||
const cursor = playbackCursor(span, view.mapPlayback);
|
||
const playing = view.mapPlayback.playing;
|
||
const start = (restart = false) => {
|
||
view.mapFollowRef.current = true;
|
||
const value = restart || view.mapPlayback.value >= 1 ? 0 : view.mapPlayback.value;
|
||
const next = rebaseMapPlayback(view.mapPlayback, { playing: true, value });
|
||
view.setMapPlayback(next);
|
||
const camera = playbackFollowView(tracks, next, span, view.mapViewportRef.current);
|
||
if (camera) view.setMapView(camera);
|
||
};
|
||
return e("div", { className: "map-playback-bar" },
|
||
e("button", { type: "button", className: "primary-command", disabled: !enabled, "aria-label": playing ? "暂停轨迹回放" : "播放轨迹回放", onClick: () => { if (playing) view.setMapPlayback(rebaseMapPlayback(view.mapPlayback, { playing: false })); else start(); } }, playing ? "暂停" : Number.isFinite(cursor) && view.mapPlayback.value < 1 ? "继续回放" : "开始回放"),
|
||
e("span", { className: "map-playback-time" }, Number.isFinite(cursor) ? `${view.mapPlayback.value >= 1 ? "回放结束" : playing ? "播放中" : "已暂停"} · ${timeLabel(cursor)}` : "选择用户与时间后开始回放"),
|
||
e("span", { className: "page-status scum-map-summary map-playback-summary" }, summary),
|
||
e("progress", { className: "map-playback-progress", max: 1, value: Number.isFinite(cursor) ? view.mapPlayback.value : 0, "aria-label": "回放完成比例" }),
|
||
e("details", { className: "scum-map-menu", "aria-label": "轨迹回放" },
|
||
e("summary", { className: "icon-command" }, "回放设置"),
|
||
e("div", { className: "scum-map-menu-body" },
|
||
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("div", { className: "console-row-actions" },
|
||
e("button", { type: "button", className: "icon-command", disabled: !enabled, onClick: () => start(true) }, "从头播放"),
|
||
e("button", { type: "button", className: "icon-command", disabled: view.mapPlayback.startedAt === 0, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "查看完整轨迹")),
|
||
e("p", { className: "page-status" }, "1x 用 30 秒回放本段记录;虚线为步行,实线为乘车。")
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], segments: Map<string, MapTrackSegment[]>, cursor: number) {
|
||
const shapes: unknown[] = [];
|
||
for (const track of tracks) for (const segment of segments.get(track.key) ?? []) {
|
||
const points = segment.points.filter((point) => point.time <= cursor);
|
||
const next = segment.points[points.length];
|
||
if (points.length && next && Number.isFinite(cursor)) {
|
||
const previous = points[points.length - 1];
|
||
const ratio = (cursor - previous.time) / (next.time - previous.time);
|
||
points.push({ ...previous, x: previous.x + (next.x - previous.x) * ratio, y: previous.y + (next.y - previous.y) * ratio });
|
||
}
|
||
if (points.length < 2) continue;
|
||
shapes.push(e("polyline", { key: segment.key, points: points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" "), fill: "none", stroke: track.color, strokeWidth: segment.mode === "ride" ? 3 : 2.5, vectorEffect: "non-scaling-stroke", strokeLinecap: "round", strokeLinejoin: "round", strokeDasharray: segment.mode === "walk" ? "6 4" : undefined, opacity: segment.mode === "ride" ? 0.95 : 0.8 }));
|
||
}
|
||
return shapes;
|
||
}
|
||
|
||
function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map<string, string>, active: boolean, view: ViewState) {
|
||
// One named marker per user; the same marker becomes a vehicle when the recorded user mounts it.
|
||
return tracks.flatMap((track) => {
|
||
const point = mapTrackPositionAt(track, cursor);
|
||
return point ? [mapLiveMarker(e, track, point, catalog, active, view)] : [];
|
||
});
|
||
}
|
||
|
||
// 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>, active: boolean, view: ViewState) {
|
||
const rideCode = point.rideCode;
|
||
const icon = rideCode ? vehicleIconByCode(catalog.get(rideCode) ?? rideCode) : "";
|
||
const vehicleCode = catalog.get(rideCode) ?? rideCode;
|
||
const label = rideCode ? `${track.label} · ${scumVehicleEntry(vehicleCode)?.name || vehicleCode || "载具"}` : track.label;
|
||
const className = ["map-track-live", active ? "is-active" : "", rideCode ? "map-track-live-riding" : "", track.kind === "vehicle" ? "map-track-live-vehicle" : ""].filter(Boolean).join(" ");
|
||
const hoverKey = `track:${track.key}`;
|
||
const close = () => view.setHoverMapPoint((current) => current === hoverKey ? "" : current);
|
||
return e("button", { key: `${track.key}:live`, type: "button", className, style: { ...mapSceneStyle(point), "--track-color": track.color }, title: `${label} ${timeLabel(point.time)}`, "aria-label": label, onPointerEnter: () => view.setHoverMapPoint(hoverKey), onPointerLeave: close, onFocus: () => view.setHoverMapPoint(hoverKey), onBlur: close, onPointerDown: (event: PointerEventLike) => event.stopPropagation?.(), onClick: () => view.setSelectedMapPoint(hoverKey) },
|
||
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 mapTrackHoverRow(row: RecordMap, data: SCUMSurfaceData): RecordMap {
|
||
const layer = layerOf(row);
|
||
if (layer === "vehicles") {
|
||
const identity = textField(row, "gameVehicleId", "vehicleId", "subjectId", "id");
|
||
const vehicle = vehicleForIdentity(data.vehicles, identity);
|
||
return vehicle ? { ...vehicle, ...row } : row;
|
||
}
|
||
if (layer === "players") {
|
||
const identity = mapPlayerTrackIdentity(row);
|
||
const player = data.players.find((candidate) => playerIdentities(candidate).includes(identity));
|
||
// An absent ride ID on a historical sample must not inherit the player's current vehicle.
|
||
return { ...player, ...row, riddenVehicleId: textField(row, "riddenVehicleId"), gameVehicleId: textField(row, "gameVehicleId") };
|
||
}
|
||
return row;
|
||
}
|
||
|
||
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: Pick<ViewState, "mapTimeFrom" | "mapTimeTo">): 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; });
|
||
for (const row of windowRows) {
|
||
if (layerOf(row) !== "players") continue;
|
||
const position = positionOf(row);
|
||
if (!hasCoordinates(position)) continue;
|
||
const kind = "player";
|
||
const identity = mapPlayerTrackIdentity(row);
|
||
if (!identity) continue;
|
||
if (users !== undefined && !users.includes(identity)) continue;
|
||
const time = trajectoryOrder(row);
|
||
const scene = mapScenePoint(row, bounds);
|
||
// A nearby vehicle is not evidence of riding; only the recorded mount relationship is used.
|
||
const rideCode = mapRiddenVehicleId(row);
|
||
const key = `${kind}:${identity}`;
|
||
const track = grouped.get(key) ?? { key, kind, label: textField(row, "displayName", "name") || 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);
|
||
track.from = track.points[0].time;
|
||
track.to = track.points[track.points.length - 1].time;
|
||
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);
|
||
}
|
||
// The platform surface already bounds records; preserve every returned selected-user sample.
|
||
return tracks.sort((left, right) => right.to - left.to);
|
||
}
|
||
|
||
function mapPlayerTrackIdentity(row: RecordMap): string { return textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id"); }
|
||
function mapRiddenVehicleId(row: RecordMap): string { return textField(row, "gameVehicleId") || textField(row, "riddenVehicleId") || textField(row, "vehicleId"); }
|
||
|
||
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);
|
||
}
|
||
for (const player of data.players) {
|
||
const identity = textField(player, "steamId", "gamePlayerId", "id");
|
||
if (!identity || users.has(identity)) continue;
|
||
const lastSeen = Date.parse(textField(player, "lastActivityAt", "lastSeenAt"));
|
||
const login = Date.parse(textField(player, "lastLoginAt"));
|
||
const observedInWindow = Number.isFinite(lastSeen) && lastSeen >= window.from && lastSeen <= window.to;
|
||
const sessionInWindow = Number.isFinite(login) && Number.isFinite(lastSeen) && login <= window.to && lastSeen >= window.from;
|
||
if (!observedInWindow && !sessionInWindow) continue;
|
||
users.set(identity, { key: identity, label: textField(player, "displayName", "name") || identity, count: 0 });
|
||
}
|
||
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" = "walk";
|
||
for (let index = 0; index < track.points.length; index += 1) {
|
||
const point = track.points[index];
|
||
const previous = track.points[index - 1];
|
||
const nextMode = previous?.riding ? "ride" : "walk";
|
||
const gap = previous && point.time - previous.time > scumMapSegmentGapMs;
|
||
if (previous && (gap || nextMode !== mode)) {
|
||
if (current.length > 1) segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current });
|
||
current = gap ? [] : [previous];
|
||
}
|
||
mode = nextMode;
|
||
current.push(point);
|
||
}
|
||
if (current.length > 1) 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;
|
||
}
|
||
|
||
// 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 players = tracks.filter((track) => track.kind === "player");
|
||
const subjects = players.length ? players : tracks;
|
||
if (subjects.length) return { from: Math.min(...subjects.map((track) => track.from)), to: Math.max(...subjects.map((track) => track.to)) };
|
||
return { from: Number.isFinite(window.from) ? window.from : 0, to: Number.isFinite(window.to) ? window.to : 0 };
|
||
}
|
||
|
||
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 {
|
||
if (previous.startedAt === 0 && patch.playing !== true) return { ...previous, ...patch };
|
||
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.startedAt === 0 ? 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.
|
||
export 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(players.map((track) => track.points[0]), node, 0.8);
|
||
return undefined;
|
||
}
|
||
|
||
export function playbackFollowView(tracks: MapTrack[], playback: MapPlaybackState, span: { from: number; to: number }, node: HTMLElement | null): MapViewState | undefined {
|
||
const players = tracks.filter((track) => track.kind === "player" && track.points.length);
|
||
const positions = players.flatMap((track) => {
|
||
const point = mapTrackPositionAt(track, playbackCursor(span, playback));
|
||
return point ? [point] : [];
|
||
});
|
||
if (positions.length === 1) return focusMapView(positions[0], node, scumMapZoomMax);
|
||
return positions.length ? fitMapView(positions, node, 0.8) : undefined;
|
||
}
|
||
|
||
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;
|
||
return { ...previous, key: `${track.key}:live`, x: previous.x + (next.x - previous.x) * ratio, y: previous.y + (next.y - previous.y) * ratio, time: cursor };
|
||
}
|
||
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 size = Math.max(240, Number(node?.clientHeight) || 640);
|
||
const width = Number(node?.clientWidth) || size;
|
||
const zoom = clampMapZoom(Math.min(scumMapSize * padding * width / size / Math.max(48, maxX - minX), scumMapSize * padding / Math.max(48, maxY - minY)));
|
||
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[], data: SCUMSurfaceData) {
|
||
const layer = layerOf(point);
|
||
const icon = layerOf(point) === "vehicles" ? vehicleIconFor(point) : "";
|
||
const ride = mapRiddenVehicleId(point);
|
||
const riddenVehicle = ride ? vehicleForIdentity(data.vehicles, ride) : undefined;
|
||
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)),
|
||
layer === "players" ? e("span", { className: "provider-id" }, `用户 ${textField(point, "displayName", "name", "label") || pointTitle(point)} · ${playerOnline(point) ? "在线" : "离线"}${ride ? ` · 乘坐 ${riddenVehicle ? vehicleLabelFor(riddenVehicle) : ride}` : ""}`) : null,
|
||
layer === "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,
|
||
layer === "vehicles" ? e("span", { className: "provider-id" }, `载具 ${vehicleLabelFor(point)} · 耐久 ${vehicleDurabilityLabel(point) || "未同步"} · ${vehicleLockLabel(point, data)}`) : null,
|
||
layer === "flags" ? e("span", { className: "provider-id" }, `归属 ${mapOwnerLabel(data, point) || "未同步"} · 附属队伍 ${textField(point, "ownerSquadId", "squadId") ? mapSquadLabel(data, textField(point, "ownerSquadId", "squadId")) : "无附属队伍"}`) : 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, target: CatalogPickTarget): ScumCatalogEntry[] {
|
||
const search = query.trim().toLowerCase();
|
||
return scumCatalogEntries.filter((entry) => {
|
||
const matchesKind = kind === "all" || scumGiftCatalogFilterKey(entry) === kind;
|
||
return matchesKind && (!search || scumCatalogSearchText(entry).includes(search));
|
||
});
|
||
}
|
||
|
||
function appendCatalogItem(items: GiftItemDraft[], code: string): GiftItemDraft[] {
|
||
const index = items.findIndex((item) => item.catalogCode.toLowerCase() === code.toLowerCase());
|
||
if (index >= 0) return items.map((item, itemIndex) => itemIndex === index ? { ...item, quantity: String(Math.max(1, Number(item.quantity) || 1) + 1) } : item);
|
||
return [...items, { catalogCode: code, quantity: "1", probability: "100", durability: "" }];
|
||
}
|
||
|
||
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 catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) {
|
||
const filters = scumGiftCatalogCategories;
|
||
const activeFilter = filters.some(([key]) => key === view.catalogKind) ? view.catalogKind : "all";
|
||
const matches = catalogMatches(view.catalogSearch, activeFilter, target);
|
||
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 目录选择活动物品 / NPC"), 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: "中文名 / 英文名 / 代码,例如 莱卡", onChange: (event: InputEvent) => view.setCatalogSearch(inputValue(event)) }),
|
||
e("div", { className: "scum-catalog-kinds", role: "group", "aria-label": target === "gift-items" ? "礼包目录分类" : "活动目录分类" }, filters.map(([key, label]) => e("button", { key, type: "button", className: activeFilter === key ? "icon-command is-active" : "icon-command", "aria-pressed": activeFilter === key, onClick: () => view.setCatalogKind(key) }, label))),
|
||
e("div", { className: "scum-catalog-grid" }, matches.length ? matches.map((entry) => { const icon = scumCatalogIconUrl(entry.icon); const displayName = scumCatalogDisplayName(entry); return e("button", { key: entry.code, type: "button", className: "scum-catalog-chip", title: `${displayName} · ${scumCatalogCategoryLabel(entry)} · ${entry.command}`, onClick: () => target === "gift-items" ? view.setGiftItems((items) => appendCatalogItem(items, entry.code)) : view.setEventItems((items) => appendCatalogItem(items, 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, displayName), e("span", { className: "provider-id" }, `${scumCatalogCategoryLabel(entry)} · ${entry.code}`))); }) : e("p", { className: "page-status" }, "没有匹配的目录条目。"))
|
||
) : null
|
||
);
|
||
}
|
||
|
||
export function scumGiftCatalogCategory(entry: ScumCatalogEntry): string {
|
||
const icon = entry.icon.toLowerCase();
|
||
const name = `${entry.name} ${entry.code}`.toLowerCase();
|
||
if (icon.includes("/crafting/traps/") || icon.includes("/weapons/explosives/traps/") || /\b(hunting|bait|trap)\b/.test(name)) return "hunting";
|
||
if (icon.includes("/fishing/")) return "fishing";
|
||
if (icon.includes("/food/drinks/")) return "metabolism";
|
||
if (icon.includes("/medicals/")) return "medical";
|
||
if (icon.includes("/weapons/")) return "weapons";
|
||
if (icon.includes("/clothes/")) return "gear";
|
||
if (icon.includes("/food/")) return "food";
|
||
if (icon.includes("/crafting/basebuilding/") || icon.includes("/crafting/fortifications/")) return "building";
|
||
if (icon.includes("/crafting/components/") || icon.includes("/misc/components/") || icon.includes("/crafting/recipes/")) return "crafting";
|
||
if (icon.includes("/crafting/tools/") || icon.includes("/misc/tools/")) return "tools";
|
||
if (icon.includes("/farming/")) return "farming";
|
||
return "misc";
|
||
}
|
||
|
||
export function scumGiftCatalogFilterKey(entry: ScumCatalogEntry): string { return entry.kind === "item" ? scumGiftCatalogCategory(entry) : entry.kind; }
|
||
|
||
export function scumCatalogCategoryLabel(entry: ScumCatalogEntry): string { if (entry.kind === "vehicle") return "载具"; if (entry.kind === "animal") return "动物"; if (entry.kind === "zombie") return "丧尸"; if (entry.kind === "armed-npc") return "NPC"; return ({ hunting: "狩猎", food: "食物", metabolism: "代谢", medical: "医疗", weapons: "武器", crafting: "制作", gear: "装备", fishing: "钓鱼", building: "建筑", tools: "工具", farming: "农业", misc: "其他" } as Record<string, string>)[scumGiftCatalogCategory(entry)] ?? "其他"; }
|
||
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") || "未命名物品"; }
|
||
export function tradeGoodsTypeLabel(item: RecordMap): string {
|
||
const code = textField(item, "code", "itemCode", "className", "spawnCommand");
|
||
const entry = scumCatalogEntryFor(code);
|
||
if (entry?.kind === "item") return scumCatalogCategoryLabel(entry);
|
||
if (entry?.kind === "vehicle") return "载具";
|
||
if (entry?.kind === "zombie") return "丧尸";
|
||
if (entry?.kind === "animal") return "动物";
|
||
if (entry?.kind === "armed-npc") return "NPC";
|
||
if (isVehicleCatalogItem(item)) return "载具";
|
||
const typeName = textField(item, "typeName", "type_name");
|
||
if (typeName && !/^(?:item|物品|unknown|未知(?:类型)?|undefined|null)$/i.test(typeName.trim())) return typeName;
|
||
return 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" ? "区域" : "其他"; }
|
||
export function mapVehicleCategory(point: RecordMap): MapVehicleFilter {
|
||
const entry = scumVehicleEntry(textField(point, "className", "vehicleClass", "entityClass", "vehicleType", "name", "label"));
|
||
const values = [textField(point, "vehicleType", "className", "vehicleClass", "entityClass", "name", "label"), entry?.code || "", entry?.name || ""];
|
||
if (values.some((value) => scumFlightVehicleKeys.has(mapVehicleKey(value)))) return "air";
|
||
const value = values.join(" ").toLowerCase();
|
||
if (/boat|dinghy|raft|motorboat|barba|sup|water|船|水上/.test(value)) return "water";
|
||
if (/bike|bicycle|motorcycle|sidecar|cruiser|laika|rager|ris|wolfswagen|tractor|pickup|suv|quad|sportbike|wheelbarrow|car|land|陆地/.test(value)) return "land";
|
||
return "other";
|
||
}
|
||
function mapVehicleKey(value: string): string { return normalizedVehicleClass(value.replace(/^#spawnvehicle\s+/i, "")).replace(/^(?:bpc_|bp_)/i, "").replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toLowerCase(); }
|
||
function pointTitle(point: RecordMap): string { return textField(point, "displayName", "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", { key: "grid", 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 {
|
||
return vehicleIconByCode(textField(point, "className", "vehicleClass", "entityClass", "vehicleType"));
|
||
}
|
||
|
||
function vehicleIconByCode(code: string): string {
|
||
if (!code.trim()) return "";
|
||
const normalized = normalizedVehicleClass(code);
|
||
return scumMapVehicleIconUrl(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 = giftItemRecords(gift); if (!items.length) return "未配置物品"; return items.map((item) => { const code = textField(item, "catalogCode", "catalogItemKey", "key", "className"); const entry = scumCatalogEntryFor(code); const label = entry ? scumCatalogDisplayName(entry) : textField(item, "label") || code || "未命名物品"; return `${label} × ${numField(item, "quantity")} · 概率 ${giftItemProbability(item)}%`; }).join(" · "); }
|
||
function giftItemDrafts(gift: RecordMap): GiftItemDraft[] { return giftItemRecords(gift).map((item) => ({ catalogCode: textField(item, "catalogCode", "catalogItemKey", "key", "className"), quantity: String(field(item, "quantity") ?? "1"), probability: giftItemProbability(item), durability: giftItemRawDurability(item) })); }
|
||
function giftItemRecords(gift: RecordMap): RecordMap[] { try { return parseGiftItems(field(gift, "items")).map((item) => ({ ...item })); } catch { return []; } }
|
||
function giftItemProbability(item: RecordMap): string { const value = Number(field(item, "probability")); return Number.isFinite(value) && value >= 0 && value <= 100 ? String(value) : "100"; }
|
||
function giftItemRawDurability(item: RecordMap): string { const value = field(item, "durability"); return value === undefined || value === null ? "" : String(value); }
|
||
function giftItemDurability(item: RecordMap, entry: ScumCatalogEntry | undefined): string { if (!giftItemSupportsDurability(entry)) return "不适用"; const value = giftItemRawDurability(item); return value ? `${value}%` : "默认"; }
|
||
function giftItemSupportsDurability(entry: ScumCatalogEntry | undefined): boolean { return !entry || entry.kind === "item"; }
|
||
function giftConfigEntry(e: ReactLike["createElement"], label: string, value: string) { return e("div", { className: "gift-config-entry" }, e("dt", null, label), e("dd", null, value)); }
|
||
function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record<string, string>)[value] ?? value; }
|
||
function giftAudienceLabel(value: string): string { return (({ all: "全部玩家", pve: "PVE 玩家", pvp: "PVP 玩家" } as Record<string, string>)[value] ?? value) || "全部玩家"; }
|
||
function giftStatusLabel(value: string): string { return (({ active: "启用", enabled: "启用", running: "启用", scheduled: "启用", queued: "启用", disabled: "停用", inactive: "停用" } 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" ? "活动管理" : "用户管理"; }
|