feat(scum): add map trajectory projection
This commit is contained in:
@@ -54,6 +54,7 @@ import type {
|
||||
GamePlayerStatePatchRequest,
|
||||
GamePlayerStatePatchResponse,
|
||||
GamePlayerStateResponse,
|
||||
GameMapTrajectoryResponse,
|
||||
GameGiftCatalogListResponse,
|
||||
GameGiftCatalogRequest,
|
||||
GameGiftCatalogResponse,
|
||||
@@ -596,6 +597,7 @@ export class PlatformApiClient {
|
||||
async getGamePlayerProfile(serverInstanceId: string, playerId: string): Promise<GamePlayerProfileResponse> {
|
||||
return this.request<GamePlayerProfileResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}`);
|
||||
}
|
||||
async getGameMapTrajectories(serverInstanceId: string, filter: { from?: string; to?: string; playerIds?: string[]; vehicleIds?: string[] } = {}): Promise<GameMapTrajectoryResponse> { const params = new URLSearchParams(); if (filter.from) params.set("from", filter.from); if (filter.to) params.set("to", filter.to); if (filter.playerIds?.length) params.set("playerId", filter.playerIds.join(",")); if (filter.vehicleIds?.length) params.set("vehicleId", filter.vehicleIds.join(",")); const query = params.toString(); return this.request<GameMapTrajectoryResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-map-trajectories${query ? `?${query}` : ""}`); }
|
||||
|
||||
async getGamePlayerState(serverInstanceId: string, playerId: string): Promise<GamePlayerStateResponse> { return this.request<GamePlayerStateResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state`); }
|
||||
async listGamePlayerStatePatches(serverInstanceId: string, playerId: string): Promise<GamePlayerStatePatchListResponse> { return this.request<GamePlayerStatePatchListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`); }
|
||||
|
||||
@@ -310,6 +310,11 @@ export interface GamePlayerStatePatchChangeRequest { fieldKey: string; before: n
|
||||
export interface GamePlayerStatePatchRequest { gameVersion: string; expectedStateVersion: string; safetyWindow: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; }
|
||||
export interface GamePlayerStatePatchResponse { id: string; gameVersion: string; expectedStateVersion: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; requesterId: string; approverId?: string; status: "pending-approval" | "queued" | "execution-failed" | "execution-unknown" | "confirmation-failed" | "confirmed"; bridgeCommandId?: string; executionSummary?: string; confirmedStateVersion?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
|
||||
export interface GamePlayerStatePatchListResponse { items: GamePlayerStatePatchResponse[]; }
|
||||
export interface GameMapTrajectoryPointResponse { mapX: number; mapY: number; occurredAt: string; collectedAt: string; source: "companion" | "log-projection"; }
|
||||
export interface GameMapTrajectoryEntityResponse { kind: "player" | "vehicle"; entityId: string; gamePlayerRecordId?: string; label: string; points: GameMapTrajectoryPointResponse[]; collectedAt?: string; sources: string[]; }
|
||||
export interface GameMapTrajectorySegmentResponse { gamePlayerRecordId: string; vehicleId: string; startedAt: string; endedAt?: string; }
|
||||
export interface GameMapTrajectoryMapResponse { mapId: string; mapVersion: string; imageWidth: number; imageHeight: number; precision: number; }
|
||||
export interface GameMapTrajectoryResponse { status: "ready" | "empty" | "missing-map"; reason?: string; map?: GameMapTrajectoryMapResponse; from?: string; to?: string; players: GameMapTrajectoryEntityResponse[]; vehicles: GameMapTrajectoryEntityResponse[]; rideSegments: GameMapTrajectorySegmentResponse[]; }
|
||||
export interface GameGiftItemRequest { catalogItemKey: string; quantity: number; }
|
||||
export interface GameGiftItemResponse extends GameGiftItemRequest { label: string; }
|
||||
export interface GameGiftCatalogRequest { id?: string; name: string; gameVersion: string; items: GameGiftItemRequest[]; }
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { trajectoryPath } from "./ScumMapTrajectoryPanel";
|
||||
|
||||
describe("trajectoryPath", () => {
|
||||
it("projects normalized map points into a stable SVG path", () => {
|
||||
expect(trajectoryPath([{ mapX: 0, mapY: 0 }, { mapX: 500, mapY: 250 }, { mapX: 1000, mapY: 1000 }])).toBe("M0,1000 L500,750 L1000,0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CarFront, MapPinned, RefreshCw, UserRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GameMapTrajectoryEntityResponse, GameMapTrajectoryResponse } from "../api/types";
|
||||
import { ErrorState, LoadingState } from "./StateViews";
|
||||
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; value: GameMapTrajectoryResponse };
|
||||
|
||||
export function ScumMapTrajectoryPanel({ serverInstanceId }: { serverInstanceId: string }) {
|
||||
const [state, setState] = useState<State>({ status: "loading" }); const [hours, setHours] = useState("1"); const [playerIds, setPlayerIds] = useState(""); const [vehicleIds, setVehicleIds] = useState(""); const [detail, setDetail] = useState("");
|
||||
const load = useCallback(async () => { setState({ status: "loading" }); const to = new Date(); const from = new Date(to.valueOf() - Math.max(1, Math.min(24, Number(hours) || 1)) * 3600000); try { setState({ status: "ready", value: await platformApiClient.getGameMapTrajectories(serverInstanceId, { from: from.toISOString(), to: to.toISOString(), playerIds: ids(playerIds), vehicleIds: ids(vehicleIds) }) }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "地图轨迹读取失败。" }); } }, [hours, playerIds, serverInstanceId, vehicleIds]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
async function openPlayer(entity: GameMapTrajectoryEntityResponse) { if (!entity.gamePlayerRecordId) return; try { const profile = await platformApiClient.getGamePlayerProfile(serverInstanceId, entity.gamePlayerRecordId); setDetail(`玩家详情:${profile.player.displayName}(${profile.player.gamePlayerId}),别名 ${profile.aliases.length} 条,会话 ${profile.sessions.length} 条。`); } catch (error) { setDetail(error instanceof Error ? error.message : "玩家详情不可用。"); } }
|
||||
if (state.status === "loading") return <LoadingState label="正在读取 SCUM 安全地图投影…" />; if (state.status === "error") return <ErrorState title="地图轨迹不可用" reason={state.reason} onRetry={() => void load()} />;
|
||||
const value = state.value; return <section className="console-panel scum-map-panel" aria-label="SCUM 玩家与车辆地图轨迹"><div className="panel-header"><div><h2><MapPinned size={16} /> SCUM 玩家与车辆地图轨迹</h2><p className="provider-id">仅显示插件声明坐标转换后的安全投影;数据来源与采集时间始终可解释。</p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span>刷新</span></button></div><div className="console-row-actions"><label>时间窗<select value={hours} onChange={(event) => setHours(event.target.value)}><option value="1">近 1 小时</option><option value="6">近 6 小时</option><option value="24">近 24 小时</option></select></label><label>玩家记录 ID<input value={playerIds} onChange={(event) => setPlayerIds(event.target.value)} placeholder="逗号分隔" /></label><label>车辆 ID<input value={vehicleIds} onChange={(event) => setVehicleIds(event.target.value)} placeholder="逗号分隔" /></label><button type="button" className="command-button" onClick={() => void load()}>应用筛选</button></div>{value.status === "missing-map" ? <p className="page-status">缺失地图声明:{value.reason}</p> : <><p className="page-status">地图 {value.map?.mapId} · 版本 {value.map?.mapVersion} · 精度 {value.map?.precision};窗口 {formatTime(value.from)} 至 {formatTime(value.to)}</p>{value.status === "empty" ? <p className="page-status">{value.reason}</p> : <TrajectoryMap players={value.players} vehicles={value.vehicles} onPlayer={openPlayer} onVehicle={(entity) => setDetail(`车辆详情上下文:${entity.entityId}。此链接只使用服务器内安全车辆标识,不查询原始存储。`)} />}{value.rideSegments.length > 0 && <div className="console-record-list">{value.rideSegments.map((segment) => <div className="console-record" key={`${segment.gamePlayerRecordId}-${segment.vehicleId}-${segment.startedAt}`}><strong>乘车区段</strong><span>玩家 {segment.gamePlayerRecordId} · 车辆 {segment.vehicleId}</span><small>{formatTime(segment.startedAt)} → {segment.endedAt ? formatTime(segment.endedAt) : "采集窗口内仍在车上"}</small></div>)}</div>}</>}{detail && <p className="page-status">{detail}</p>}</section>;
|
||||
}
|
||||
|
||||
export function trajectoryPath(points: { mapX: number; mapY: number }[]) { return points.map((point, index) => `${index ? "L" : "M"}${point.mapX},${1000 - point.mapY}`).join(" "); }
|
||||
function TrajectoryMap({ players, vehicles, onPlayer, onVehicle }: { players: GameMapTrajectoryEntityResponse[]; vehicles: GameMapTrajectoryEntityResponse[]; onPlayer: (entity: GameMapTrajectoryEntityResponse) => void; onVehicle: (entity: GameMapTrajectoryEntityResponse) => void }) { const entities = useMemo(() => [...players, ...vehicles], [players, vehicles]); return <><div className="scum-map-canvas" role="img" aria-label="已筛选的玩家和车辆地图轨迹"><svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet">{entities.map((entity) => <g key={`${entity.kind}-${entity.entityId}`}><path className={entity.kind === "player" ? "scum-map-player-line" : "scum-map-vehicle-line"} d={trajectoryPath(entity.points)} />{entity.points.map((point, index) => <circle key={`${point.occurredAt}-${index}`} className={entity.kind === "player" ? "scum-map-player-point" : "scum-map-vehicle-point"} cx={point.mapX} cy={1000 - point.mapY} r="7"><title>{`${entity.label} · ${formatTime(point.occurredAt)} · ${point.source}`}</title></circle>)}</g>)}</svg></div><div className="console-record-list">{entities.map((entity) => <div className="console-record" key={`${entity.kind}-${entity.entityId}`}><strong>{entity.kind === "player" ? <UserRound size={15} /> : <CarFront size={15} />} {entity.label}</strong><span>{entity.points.length} 个采样点 · 来源 {entity.sources.join(" / ") || "--"}</span><small>最后采集:{formatTime(entity.collectedAt)}</small><button type="button" className="icon-command" onClick={() => entity.kind === "player" ? onPlayer(entity) : onVehicle(entity)}>{entity.kind === "player" ? <UserRound size={14} /> : <CarFront size={14} />}<span>{entity.kind === "player" ? "进入玩家详情" : "进入车辆上下文"}</span></button></div>)}</div></>; }
|
||||
function ids(value: string) { return value.split(",").map((item) => item.trim()).filter(Boolean).slice(0, 20); }
|
||||
function formatTime(value: string | undefined) { if (!value) return "--"; const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
|
||||
@@ -6,6 +6,7 @@ import type { GamePluginResponse } from "../api/types";
|
||||
import { PageFrame } from "../components/PageFrame";
|
||||
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
|
||||
import { GamePlayerIntelligencePanel } from "../components/GamePlayerIntelligencePanel";
|
||||
import { ScumMapTrajectoryPanel } from "../components/ScumMapTrajectoryPanel";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
||||
@@ -108,6 +109,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
||||
</section>
|
||||
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
|
||||
{scumResolution?.available && <GamePlayerIntelligencePanel serverInstanceId={serverId} />}
|
||||
{scumResolution?.available && <ScumMapTrajectoryPanel serverInstanceId={serverId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -755,6 +755,7 @@ button.operations-inline-warning{cursor:pointer}
|
||||
.operations-tray-item-copy code{color:var(--gold);font-size:10px}
|
||||
.operations-tray-item time{color:var(--ink-faint);font-size:10px;white-space:nowrap}
|
||||
.operations-tray-empty{margin:10px 0 2px;color:var(--ink-faint);font-size:11px}
|
||||
.scum-map-panel .console-row-actions{align-items:end}.scum-map-panel label{display:grid;gap:4px;min-width:150px;color:var(--ink-soft);font-size:12px}.scum-map-panel input,.scum-map-panel select{max-width:220px}.scum-map-canvas{margin:12px 0;border:1px solid var(--line);background:var(--frosted-surface),linear-gradient(90deg,color-mix(in srgb,var(--line) 36%,transparent) 1px,transparent 1px),linear-gradient(180deg,color-mix(in srgb,var(--line) 28%,transparent) 1px,transparent 1px);background-size:auto,10% 10%,10% 10%;box-shadow:var(--jelly-inset);aspect-ratio:16/9;overflow:hidden}.scum-map-canvas svg{display:block;width:100%;height:100%}.scum-map-player-line,.scum-map-vehicle-line{fill:none;stroke-width:5;stroke-linecap:round;stroke-linejoin:round}.scum-map-player-line{stroke:var(--accent)}.scum-map-vehicle-line{stroke:var(--gold)}.scum-map-player-point{fill:var(--accent-deep);stroke:var(--accent);stroke-width:3}.scum-map-vehicle-point{fill:var(--gold);stroke:var(--pink);stroke-width:3}
|
||||
.app-shell-sidebar-collapsed .operations-tray{width:44px}
|
||||
.app-shell-sidebar-collapsed .operations-tray-trigger{grid-template-columns:1fr;place-items:center;padding:6px 0}
|
||||
.app-shell-sidebar-collapsed .operations-tray-copy,.app-shell-sidebar-collapsed .operations-tray-trigger>svg{display:none}
|
||||
|
||||
Reference in New Issue
Block a user