From 312804c55e1ff181828b4271126d95f8cb405f79 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Sun, 20 Sep 2026 10:45:32 +0800 Subject: [PATCH] fix: improve SCUM map playback filters --- platform_web/acceptance/fixtures/scum-map.ts | 48 ++ platform_web/acceptance/scum-map-preview.html | 1 + platform_web/acceptance/scum-map-preview.tsx | 12 + platform_web/theme/base.css | 35 +- platform_web/utils/scumMapPlayback.test.tsx | 180 +++++ .../assets/map/vehicles/ico_bicycle_01_a.webp | Bin 0 -> 2188 bytes .../assets/map/vehicles/ico_bicycle_02_a.webp | Bin 0 -> 2616 bytes .../assets/map/vehicles/ico_cruiser.webp | Bin 0 -> 1700 bytes .../assets/map/vehicles/ico_improraftbig.webp | Bin 0 -> 1814 bytes .../map/vehicles/ico_improsmallraft.webp | Bin 0 -> 1340 bytes .../map/vehicles/ico_improwheelbarrow.webp | Bin 0 -> 1594 bytes .../map/vehicles/ico_kinglet_duster_a.webp | Bin 0 -> 1738 bytes .../map/vehicles/ico_kinglet_mariner.webp | Bin 0 -> 2090 bytes .../assets/map/vehicles/ico_laika.webp | Bin 0 -> 1620 bytes .../assets/map/vehicles/ico_motorboat_02.webp | Bin 0 -> 1216 bytes .../map/vehicles/ico_motorcycle_01_a.webp | Bin 0 -> 2178 bytes .../assets/map/vehicles/ico_rager.webp | Bin 0 -> 1640 bytes .../assets/map/vehicles/ico_ris.webp | Bin 0 -> 1688 bytes .../assets/map/vehicles/ico_sidecarbike.webp | Bin 0 -> 1784 bytes .../assets/map/vehicles/ico_sup_inhands.webp | Bin 0 -> 1076 bytes .../assets/map/vehicles/ico_sup_vicinity.webp | Bin 0 -> 1330 bytes .../assets/map/vehicles/ico_tractor_01_a.webp | Bin 0 -> 2066 bytes .../assets/map/vehicles/ico_wheelbarrow.webp | Bin 0 -> 1514 bytes .../assets/map/vehicles/ico_wolfswagen.webp | Bin 0 -> 1498 bytes .../assets/map/vehicles/motoredboat_a.webp | Bin 0 -> 1188 bytes .../scum-server-plugin/features/contracts.ts | 13 + .../features/map-vehicle-icons.ts | 29 + .../scum-server-plugin/features/page-data.ts | 4 +- .../scum-server-plugin/features/page.ts | 663 +++++++++++------- .../features/scum-catalog.ts | 4 +- plugins/tests/scum-catalog.test.ts | 11 + plugins/tests/scum-feature-module.test.ts | 63 +- 32 files changed, 770 insertions(+), 293 deletions(-) create mode 100644 platform_web/acceptance/fixtures/scum-map.ts create mode 100644 platform_web/acceptance/scum-map-preview.html create mode 100644 platform_web/acceptance/scum-map-preview.tsx create mode 100644 platform_web/utils/scumMapPlayback.test.tsx create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_01_a.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_02_a.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_cruiser.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improraftbig.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improsmallraft.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_improwheelbarrow.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_duster_a.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_mariner.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_laika.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorboat_02.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorcycle_01_a.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_rager.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_ris.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sidecarbike.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_inhands.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_vicinity.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_tractor_01_a.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wheelbarrow.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wolfswagen.webp create mode 100644 plugins/examples/scum-server-plugin/assets/map/vehicles/motoredboat_a.webp create mode 100644 plugins/examples/scum-server-plugin/features/map-vehicle-icons.ts diff --git a/platform_web/acceptance/fixtures/scum-map.ts b/platform_web/acceptance/fixtures/scum-map.ts new file mode 100644 index 0000000..2eed9c6 --- /dev/null +++ b/platform_web/acceptance/fixtures/scum-map.ts @@ -0,0 +1,48 @@ +import type { ScumSurfaceResponse, ScumUserTrajectoryResponse } from "../../api/types"; +import type { PluginBridgeHostContext, PluginPageContract } from "../../contracts/pluginBridge"; +import type { PluginPageWorkspaceActions } from "../../contracts/pluginPageHost"; + +export const scumMapPage: PluginPageContract = { + key: "live-map", title: "实时地图", path: "/live-map", bundleKey: "scum-server-plugin", bundleVersion: "1.0.3", + bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e" +}; + +export const scumMapContext: PluginBridgeHostContext = { + pluginId: "game.scum", routeKey: "live-map", serverInstanceId: "map-test", permissions: ["server.read"], bridgeActions: [], aiPurposes: [], + themeTokens: { colorScheme: "dark", accentColor: "#4ECDC4" }, + pageQuery: { focus: "mira", from: "2026-09-19T00:00:00Z", to: "2026-09-19T00:10:00Z" } +}; + +// Deterministic fixture only: no server, Run channel, database or authenticated API is called. +export function createScumMapFixture(): { surface: ScumSurfaceResponse; actions: PluginPageWorkspaceActions } { + const sample = (steamId: string, displayName: string, minute: number, x: number, y: number, riding = false): ScumUserTrajectoryResponse => ({ + id: `${steamId}-${minute}`, serverInstanceId: "map-test", scumUserId: `user-${steamId}`, subjectType: "player", subjectId: steamId, steamId, displayName, + x, y, z: 0, sampledAt: `2026-09-19T00:0${minute}:00Z`, ...(riding ? { riddenVehicleId: "vehicle-1", gameVehicleId: "car-1" } : {}) + }); + const surface: ScumSurfaceResponse = { + users: { count: 2, items: [ + { id: "user-mira", serverInstanceId: "map-test", steamId: "mira", displayName: "Mira", online: true, x: 0, y: 0, z: 0, riddenVehicleId: "vehicle-1", gameVehicleId: "car-1" }, + { id: "user-nova", serverInstanceId: "map-test", steamId: "nova", displayName: "Nova", online: true, x: 1000, y: 1000, z: 0 } + ] }, + vehicles: { count: 1, items: [{ id: "vehicle-1", vehicleId: "vehicle-1", gameVehicleId: "car-1", serverInstanceId: "map-test", label: "Laika", className: "BPC_Laika_C", exists: true, functional: true, locked: true, x: 40000, y: 40000, z: 0 }] }, + userTrajectories: { count: 6, items: [sample("mira", "Mira", 0, 0, 0), sample("mira", "Mira", 4, -250000, -250000, true), sample("mira", "Mira", 8, -550000, -550000, true), sample("nova", "Nova", 0, 1000, 1000), sample("nova", "Nova", 4, 250000, 250000), sample("nova", "Nova", 8, 600000, 600000)] }, + vehicleTrajectories: { count: 0, items: [] }, + vehicleLocks: { count: 1, items: [{ id: "lock-1", serverInstanceId: "map-test", scumVehicleId: "vehicle-1", gameVehicleId: "car-1", scumUserId: "user-mira", steamId: "mira", lockedAt: "2026-09-19T00:00:00Z" }] } + }; + const collections: Record[]> = { + scum_squads: [{ squadId: "wolves", name: "Wolves" }], + scum_squad_members: [{ squadId: "wolves", steamId: "mira" }, { squadId: "wolves", steamId: "nova" }], + scum_flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerPlayerId: "mira", ownerSquadId: "wolves", x: -40000, y: 40000, z: 0 }] + }; + const actions: PluginPageWorkspaceActions = { + pluginData: { + list: async (collection) => ({ items: (collections[collection] ?? []).map((value, index) => ({ key: `${collection}-${index}`, value })) }), + put: async () => undefined, delete: async () => undefined, transact: async () => undefined + }, + scum: { + surface: async () => surface, users: async () => surface.users, vehicles: async () => surface.vehicles, + userTrajectories: async () => surface.userTrajectories, vehicleTrajectories: async () => surface.vehicleTrajectories, vehicleLocks: async () => surface.vehicleLocks + } + }; + return { surface, actions }; +} diff --git a/platform_web/acceptance/scum-map-preview.html b/platform_web/acceptance/scum-map-preview.html new file mode 100644 index 0000000..7ec1729 --- /dev/null +++ b/platform_web/acceptance/scum-map-preview.html @@ -0,0 +1 @@ +SCUM 地图交互验证
diff --git a/platform_web/acceptance/scum-map-preview.tsx b/platform_web/acceptance/scum-map-preview.tsx new file mode 100644 index 0000000..fc0f630 --- /dev/null +++ b/platform_web/acceptance/scum-map-preview.tsx @@ -0,0 +1,12 @@ +import * as React from "react"; +import { createRoot } from "react-dom/client"; +import { createScumMapFixture, scumMapContext, scumMapPage } from "./fixtures/scum-map"; +import { loadPluginPageBundle } from "../utils/pluginPageBundles"; +import { applyThemePalette } from "../theme/tokens"; +import "../theme/base.css"; + +globalThis.__PLUGIN_PAGE_REACT__ = React; +applyThemePalette("mecha-black"); +const MapPage = await loadPluginPageBundle(scumMapPage); +const { actions } = createScumMapFixture(); +createRoot(document.getElementById("root")!).render(

SCUM 地图交互验证

固定测试数据,用于检查回放、缩放和详情。

); diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 23e5b6b..d8921d3 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -460,8 +460,10 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .scum-catalog-meta .provider-id{font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .icon-command.is-active{border-color:var(--accent);color:var(--ink);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 46%,transparent)} .scum-workbench{padding:12px;overflow:visible}.scum-workbench>.console-record-list{gap:8px}.scum-filter-bar{margin:0;padding:0 0 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 68%,transparent)}.scum-filter-bar .console-field{flex:0 1 210px}.scum-filter-bar .console-field:first-child{flex:1 1 320px}.scum-filter-bar .page-status{margin-left:auto}.scum-layer-toggle{display:inline-flex;align-items:center;gap:5px;flex:0 0 auto;min-height:28px;padding:0 8px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:11px;font-weight:760}.scum-filter-bar .scum-layer-toggle{min-height:26px;padding:0 8px;gap:5px;font-size:11px;white-space:nowrap}.scum-filter-bar .scum-layer-toggle input{flex:0 0 auto;width:14px;height:14px;min-width:0;min-height:0;margin:0;accent-color:var(--accent)}.scum-layer-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:0 0 auto}.scum-map-summary{margin-left:auto;white-space:nowrap}.scum-table-wrap{border-color:color-mix(in srgb,var(--line) 72%,transparent);background:linear-gradient(180deg,color-mix(in srgb,var(--surface-solid) 84%,transparent),color-mix(in srgb,var(--surface) 66%,transparent));-webkit-backdrop-filter:blur(8px) saturate(.92);backdrop-filter:blur(8px) saturate(.92);box-shadow:inset 0 1px 0 color-mix(in srgb,var(--crystal-rim) 46%,transparent)}.scum-table-wrap::before,.scum-table-wrap::after{content:none}.scum-user-table{min-width:1080px;table-layout:fixed}.scum-user-table caption{caption-side:top;padding:0 0 8px;text-align:left;color:var(--ink-faint);font-size:12px;font-weight:800}.scum-user-table th,.scum-user-table td{height:38px;padding:6px 10px;border-bottom-color:color-mix(in srgb,var(--line) 64%,transparent);white-space:nowrap;font-size:13px;line-height:1.2}.scum-user-table th{background:color-mix(in srgb,var(--accent-soft) 52%,transparent);color:color-mix(in srgb,var(--ink) 70%,var(--accent-deep))}.scum-user-table tbody tr:hover{background:color-mix(in srgb,var(--accent-soft) 24%,transparent)}.scum-user-table th:nth-child(1),.scum-user-table td:nth-child(1){width:150px}.scum-user-table th:nth-child(2),.scum-user-table td:nth-child(2){width:170px}.scum-user-table th:nth-child(3),.scum-user-table td:nth-child(3){width:76px}.scum-user-table th:nth-child(4),.scum-user-table td:nth-child(4){width:116px}.scum-user-table th:nth-child(5),.scum-user-table td:nth-child(5){width:108px}.scum-user-table th:nth-child(6),.scum-user-table td:nth-child(6){width:108px}.scum-user-table th:nth-child(7),.scum-user-table td:nth-child(7){width:156px}.scum-user-table th:last-child,.scum-user-table td:last-child{width:76px;text-align:right}.scum-user-table td>*{max-width:100%;overflow:hidden;text-overflow:ellipsis}.scum-user-table td.provider-actions-cell{padding-right:8px;overflow:visible}.scum-user-table td.provider-actions-cell>*{overflow:visible}.scum-mono-cell{font-family:var(--font-mono);font-size:12px}.scum-user-table .icon-command{min-height:28px;padding:0 9px}.inline-action-control{position:relative;display:inline-block}.inline-action-control>summary{list-style:none}.inline-action-control>summary::-webkit-details-marker{display:none}.inline-action-control[open]>summary{border-color:var(--accent);color:var(--ink)}.inline-action-control .inline-action-menu{right:0} +.gift-definitions-surface{display:grid;gap:12px;min-width:0}.gift-toolbar{margin:0;padding:0 0 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 68%,transparent)}.gift-toolbar .console-field{flex:0 1 190px}.gift-toolbar .console-field:first-child{flex:1 1 320px}.gift-toolbar .page-status{margin-left:auto}.gift-create-command{width:auto;min-width:112px}.gift-table-wrap{min-width:0}.scum-gift-table{min-width:980px;table-layout:fixed}.scum-gift-table th,.scum-gift-table td{height:54px;padding:9px 10px;border-bottom-color:color-mix(in srgb,var(--line) 64%,transparent);vertical-align:middle;font-size:12.5px}.scum-gift-table th{background:color-mix(in srgb,var(--accent-soft) 52%,transparent);color:color-mix(in srgb,var(--ink) 70%,var(--accent-deep));white-space:nowrap}.scum-gift-table tbody tr:hover{background:color-mix(in srgb,var(--accent-soft) 24%,transparent)}.scum-gift-table th:nth-child(1),.scum-gift-table td:nth-child(1){width:170px}.scum-gift-table th:nth-child(2),.scum-gift-table td:nth-child(2){width:122px}.scum-gift-table th:nth-child(3),.scum-gift-table td:nth-child(3){width:290px}.scum-gift-table th:nth-child(4),.scum-gift-table td:nth-child(4){width:120px}.scum-gift-table th:nth-child(5),.scum-gift-table td:nth-child(5){width:86px}.scum-gift-table th:nth-child(6),.scum-gift-table td:nth-child(6){width:148px}.scum-gift-table th:last-child,.scum-gift-table td:last-child{width:142px;text-align:right}.scum-gift-table td>*{max-width:100%;overflow-wrap:anywhere}.scum-gift-table td.provider-actions-cell{padding-right:8px;overflow:visible}.scum-gift-table td.provider-actions-cell>*{overflow:visible}.scum-gift-code{font-family:var(--font-mono);font-size:11px}.scum-gift-content-cell{display:flex;align-items:center;gap:8px;min-width:0}.scum-gift-content-cell>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scum-gift-table-actions{display:inline-flex;align-items:center;justify-content:flex-end;gap:6px}.scum-gift-table-actions .icon-command{min-height:30px;padding:0 9px;white-space:nowrap}.gift-form-wide{grid-column:1/-1}.gift-form-section{display:grid;gap:9px;padding:11px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 64%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}.gift-form-section-title{display:flex;align-items:baseline;justify-content:space-between;gap:10px;flex-wrap:wrap}.gift-form-section-title strong{color:var(--ink);font-size:13px}.gift-form-section-title span{color:var(--ink-faint);font-size:11px}.gift-dialog-heading{display:grid;gap:3px;min-width:0}.gift-dialog-heading h2{margin:0;color:var(--ink);font-size:18px}.gift-dialog-actions{grid-column:1/-1}.gift-dialog-actions .confirm-primary{min-width:128px}.gift-catalog-reference{margin-top:2px} :root[data-custom-background=true] .scum-workbench,:root[data-custom-background=true] .scum-table-wrap{background:linear-gradient(180deg,rgba(5,10,16,.76),rgba(5,10,16,.64));border-color:color-mix(in srgb,var(--line-strong) 54%,rgba(255,255,255,.18))}:root[data-custom-background=true][data-theme-palette=magical-girl] .scum-workbench,:root[data-custom-background=true][data-theme-palette=magical-girl] .scum-table-wrap{background:linear-gradient(180deg,rgba(18,19,26,.74),rgba(13,15,22,.64));border-color:rgba(250,240,249,.34)} @media (max-width:760px){.scum-filter-bar .page-status{margin-left:0}.scum-user-table{min-width:1040px}} +@media (max-width:760px){.scum-map-bar>.console-field,.scum-map-bar>.scum-map-menu-field,.scum-map-bar>.map-playback-bar{flex:1 1 100%;min-width:0}.map-playback-summary{white-space:normal}} .provider-form input,.provider-form select,.provider-form textarea{width:100%;min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit} .provider-form textarea{min-height:120px;padding:10px;resize:vertical;font-family:var(--font-mono);font-size:13px} .provider-form input:focus,.provider-form select:focus,.provider-form textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)} @@ -779,29 +781,38 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .map-viewport:active{cursor:grabbing} .map-scene{position:absolute;top:50%;left:50%;height:100%;aspect-ratio:1;transform-origin:center;will-change:transform} .map-scene-image{position:absolute;inset:0;background-repeat:no-repeat;background-size:100% 100%;background-position:center} -.map-track-layer{position:absolute;inset:0;z-index:2;width:100%;height:100%;pointer-events:none}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-surface-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%) scale(var(--map-inverse,1));cursor:pointer}.map-surface-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-surface-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-surface-dot.map-layer-flags{background:var(--gold)}.map-surface-dot.map-layer-regions{background:var(--success)}.map-surface-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-view-controls{position:absolute;top:10px;right:10px;z-index:6;display:flex;align-items:center;gap:4px;padding:4px;border:1px solid color-mix(in srgb,var(--line) 72%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)} +.map-track-layer{position:absolute;inset:0;z-index:2;width:100%;height:100%;pointer-events:none}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-surface-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 0 1px var(--surface-solid);transform:translate(-50%,-50%) scale(var(--map-inverse,1));cursor:pointer}.map-surface-dot img{display:block;width:100%;height:100%;object-fit:contain}.map-surface-dot.map-layer-vehicles{width:20px;height:20px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-surface-dot.map-layer-flags{background:var(--gold)}.map-surface-dot.map-layer-regions{background:var(--success)}.map-surface-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 2px color-mix(in srgb,var(--gold) 24%,transparent)}.map-view-controls{position:absolute;top:10px;right:10px;z-index:6;display:flex;align-items:center;gap:4px;padding:4px;border:1px solid color-mix(in srgb,var(--line) 72%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)} .map-view-controls .icon-command{min-height:24px;padding:0 8px;font-size:11px} .map-zoom-badge{padding:0 6px;color:var(--ink-faint);font:800 10px/1 var(--font-mono)} -.map-track-endpoint{position:absolute;z-index:4;display:grid;place-items:center;width:18px;height:18px;border:1px solid var(--accent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 84%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:none} -.map-track-endpoint.map-layer-vehicles{border-color:var(--gold)} -.map-ride-marker{position:absolute;z-index:4;display:grid;place-items:center;width:22px;height:22px;border:1px solid color-mix(in srgb,var(--gold) 70%,var(--line));border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 76%,transparent);color:var(--gold);font:800 9px/1 var(--font-mono);transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:none} -.map-ride-marker img{width:100%;height:100%;object-fit:contain} -.map-track-live{position:absolute;z-index:5;display:flex;align-items:center;gap:4px;transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:none} +.map-track-live{position:absolute;z-index:5;display:grid;place-items:center;width:16px;height:16px;padding:0;border:0;background:transparent;color:inherit;transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:auto;cursor:pointer} .map-track-live-dot{width:11px;height:11px;border:2px solid var(--surface-solid);border-radius:999px;background:var(--track-color,var(--gold));box-shadow:0 0 0 2px color-mix(in srgb,var(--track-color,var(--gold)) 42%,transparent)} -.map-track-live.is-playing .map-track-live-dot{width:16px;height:16px} -.map-track-live-icon{width:20px;height:20px;object-fit:contain;filter:drop-shadow(0 0 4px color-mix(in srgb,var(--track-color,var(--gold)) 70%,transparent))} -.map-track-live.is-playing .map-track-live-icon{width:27px;height:27px} -.map-track-live-label{max-width:104px;overflow:hidden;padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);color:var(--track-color,var(--gold));font-size:10px;white-space:nowrap;text-overflow:ellipsis} -.map-hover-card{position:absolute;z-index:9;display:grid;gap:3px;min-width:168px;max-width:262px;padding:7px 9px;border:1px solid color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:10px;background:color-mix(in srgb,var(--surface-solid) 94%,transparent);box-shadow:0 10px 26px color-mix(in srgb,#000 46%,transparent);transform:translate(-50%,calc(-100% - 12px)) scale(var(--map-inverse,1));transform-origin:50% 100%;pointer-events:none} +.map-track-live.is-active .map-track-live-dot{width:16px;height:16px} +.map-track-live-icon{width:20px;height:20px;object-fit:contain} +.map-track-live.is-active .map-track-live-icon{width:26px;height:26px} +.map-track-live-riding.is-active{width:26px;height:26px} +.map-track-live:focus-visible{outline:2px solid var(--accent);outline-offset:4px} +.map-surface-dot-riding{width:20px;height:20px} +.map-track-live-label{position:absolute;left:calc(100% + 4px);top:50%;transform:translateY(-50%);max-width:104px;overflow:hidden;padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);color:var(--track-color,var(--gold));font-size:10px;white-space:nowrap;text-overflow:ellipsis} +.map-hover-card{position:absolute;z-index:9;display:grid;gap:3px;min-width:168px;max-width:262px;padding:7px 9px;border:1px solid color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 94%,transparent);box-shadow:0 10px 26px color-mix(in srgb,#000 46%,transparent);transform:scale(var(--map-inverse,1)) translate(-50%,calc(-100% - 12px));transform-origin:0 0;pointer-events:none} .map-hover-title{color:var(--ink);font-size:11px} .map-hover-row{display:grid;grid-template-columns:56px 1fr;gap:6px;align-items:baseline} .map-hover-row small{color:var(--ink-faint);font-size:10px} .map-hover-row b{color:var(--ink-soft);font-size:11px;font-weight:600;word-break:break-all} .map-track-swatch{display:inline-block;width:10px;height:10px;margin-right:6px;border-radius:999px;vertical-align:middle} -.map-playback-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +.map-playback-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.scum-map-bar>.map-playback-bar{flex:1 1 520px;min-width:320px} +.map-playback-time{color:var(--ink-soft);font-size:12px} +.map-playback-time{white-space:nowrap} +.map-playback-progress{flex:0 1 160px;width:90px;min-width:70px;height:5px;accent-color:var(--accent)} +.map-playback-bar>.primary-command{width:auto;min-width:112px;flex:0 0 auto;white-space:nowrap} +.map-playback-summary{margin-left:0;min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.map-playback-bar>.scum-map-menu{margin-left:auto} .map-playback-bar .page-status{margin:0} .map-playback-bar .scum-map-menu-body{left:auto;right:0} .scum-map-menu{position:relative;flex:0 0 auto} +.scum-map-bar>.console-field,.scum-map-bar>.scum-map-menu-field{flex:0 1 210px;min-width:150px} +.scum-map-filter-menu{display:block;min-width:0} +.scum-map-menu.scum-map-filter-menu>summary{box-sizing:border-box;width:100%;min-height:36px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .scum-map-menu>summary{display:inline-flex;align-items:center;min-height:26px;padding:0 9px;font-size:11px;list-style:none;cursor:pointer} .scum-map-menu>summary::-webkit-details-marker{display:none} .scum-map-menu[open]>summary{border-color:var(--accent);color:var(--ink)} diff --git a/platform_web/utils/scumMapPlayback.test.tsx b/platform_web/utils/scumMapPlayback.test.tsx new file mode 100644 index 0000000..4dfd15a --- /dev/null +++ b/platform_web/utils/scumMapPlayback.test.tsx @@ -0,0 +1,180 @@ +/** @vitest-environment jsdom */ + +import * as React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createScumMapFixture, scumMapContext, scumMapPage } from "../acceptance/fixtures/scum-map"; +import { loadPluginPageBundle } from "./pluginPageBundles"; + +let root: Root; +let container: HTMLDivElement; +const MapPage = await loadPluginPageBundle(scumMapPage); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T00:10:00Z")); + globalThis.__PLUGIN_PAGE_REACT__ = React; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +async function mount(fixture = createScumMapFixture(), context = scumMapContext) { + const loadSurface = vi.spyOn(fixture.actions.scum!, "surface"); + const list = vi.spyOn(fixture.actions.pluginData!, "list"); + await act(async () => root.render()); + return { ...fixture, loadSurface, list }; +} + +function button(label: string) { + const result = [...container.querySelectorAll("button")].find((node) => node.getAttribute("aria-label") === label || node.textContent === label); + expect(result, `button ${label}`).toBeDefined(); + return result!; +} + +async function click(node: HTMLElement) { await act(async () => node.click()); } +async function tick(ms: number) { await act(async () => vi.advanceTimersByTime(ms)); } +function zoom() { return Number(container.querySelector(".map-zoom-badge")?.textContent?.replace("×", "")); } + +describe("SCUM map through the platform bundle host", () => { + it("starts from the first sample, follows one player, preserves paused markers, and resets for a new selection", async () => { + const { loadSurface, list } = await mount(); + await click(button("播放轨迹回放")); + expect(zoom()).toBe(8); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(1); + expect(container.querySelector(".map-track-live-riding")).toBeNull(); + const initialTransform = container.querySelector(".map-scene")?.getAttribute("style"); + await tick(6000); + expect(container.querySelector(".map-scene")?.getAttribute("style")).not.toBe(initialTransform); + await click(button("暂停轨迹回放")); + const marker = container.querySelector(".map-track-live")!; + expect(marker.classList.contains("is-active")).toBe(true); + await act(async () => marker.focus()); + expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("Mira"); + expect(container.querySelector('[role="tooltip"]')?.textContent).not.toContain("乘坐Laika"); + await click(marker); + expect(container.querySelector(".map-point-strip")?.textContent).toContain("Mira"); + await click(button("全选")); + expect(container.querySelector(".map-playback-time")?.textContent).toContain("选择用户与时间后开始回放"); + await click(button("播放轨迹回放")); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(2); + expect(zoom()).toBe(8); + expect(loadSurface).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledTimes(7); + }); + + it("fits moving users together and changes ride icons at the recorded sample time", async () => { + await mount(); + await click(button("全选")); + await click(button("播放轨迹回放")); + await tick(12000); + expect(zoom()).toBeLessThan(8); + expect(container.querySelector(".map-track-live-riding")).toBeNull(); + await tick(4000); + const riding = container.querySelector(".map-track-live-riding")!; + expect(riding.getAttribute("aria-label")).toContain("Laika"); + expect(riding.querySelector("img")?.getAttribute("src")).toContain("ico_"); + await click(button("暂停轨迹回放")); + await act(async () => riding.focus()); + expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("乘坐Laika"); + const start = container.querySelector('[aria-label="轨迹开始时间"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(start, "2026-09-19T00:04"); + start.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.querySelector(".map-playback-time")?.textContent).toContain("选择用户与时间后开始回放"); + const end = container.querySelector('[aria-label="轨迹结束时间"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(end, "2026-09-18T00:00"); + end.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(0); + expect(container.querySelectorAll(".map-layer-players")).toHaveLength(0); + expect(button("播放轨迹回放").disabled).toBe(true); + }); + + it("shows player, vehicle lock and flag owner details without extra reads", async () => { + const { loadSurface, list } = await mount(); + await act(async () => button("Laika").focus()); + expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("Mira 上锁"); + expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("耐久未同步"); + await act(async () => button("Wolves Flag").focus()); + const tooltip = container.querySelector('[role="tooltip"]')?.textContent; + expect(tooltip).toContain("归属Mira(mira)"); + expect(tooltip).toContain("Wolves(wolves · 2 人)"); + expect(loadSurface).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledTimes(7); + }); + + it("excludes unselected and out-of-window players, including stale retained current positions", async () => { + const fixture = createScumMapFixture(); + fixture.surface.users.items.push({ id: "old", serverInstanceId: "map-test", steamId: "old", displayName: "Old Player", online: false, x: 100, y: 100, lastSeenAt: "2026-09-18T00:00:00Z" }); + await mount(fixture); + const labels = () => [...container.querySelectorAll(".map-track-live-label")].map((node) => node.textContent); + expect(labels()).toEqual(["Mira"]); + expect(container.querySelector('[aria-label="SCUM 地图视图"]')?.textContent).not.toContain("Nova"); + await click(button("清空")); + expect(labels()).toEqual([]); + expect(container.querySelectorAll(".map-layer-players")).toHaveLength(0); + expect(button("播放轨迹回放").disabled).toBe(true); + await click(button("时段内全部")); + expect(labels().sort()).toEqual(["Mira", "Nova"]); + expect(container.querySelector('[aria-label="SCUM 地图视图"]')?.textContent).not.toContain("Old Player"); + }); + + it("draws the travelled path up to the moving marker and keeps the final frame without duplicate endpoints", async () => { + await mount(); + const start = button("播放轨迹回放"); + expect(start.closest("details")).toBeNull(); + await click(start); + await tick(6000); + const polyline = container.querySelector(".map-track-layer polyline")!; + const last = polyline.getAttribute("points")!.split(" ").at(-1)!.split(",").map(Number); + const marker = container.querySelector(".map-track-live")!; + expect(last[0]).toBeCloseTo(parseFloat(marker.style.left) * 40.96, 0); + expect(last[1]).toBeCloseTo(parseFloat(marker.style.top) * 40.96, 0); + expect(polyline.getAttribute("vector-effect")).toBe("non-scaling-stroke"); + await tick(24100); + expect(container.querySelector(".map-playback-time")?.textContent).toContain("回放结束"); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(1); + expect(container.querySelectorAll(".map-track-endpoint")).toHaveLength(0); + expect(container.querySelector("progress")?.value).toBe(1); + }); + + it("reuses marker nodes and geometry during zoom without reparsing the loaded trajectory history", async () => { + const fixture = createScumMapFixture(); + const sample = fixture.surface.userTrajectories.items[0]; + fixture.surface.userTrajectories.items = Array.from({ length: 1000 }, (_, index) => ({ ...sample, id: `sample-${index}`, x: index * 5, sampledAt: new Date(Date.parse("2026-09-19T00:00:00Z") + index * 500).toISOString() })); + await mount(fixture); + const marker = container.querySelector(".map-track-live")!; + await click(marker); + const line = container.querySelector(".map-track-layer polyline"); + const parse = vi.spyOn(Date, "parse"); + await click(button("地图缩小")); + expect(container.querySelector(".map-track-live")).toBe(marker); + expect(container.querySelector(".map-track-layer polyline")).toBe(line); + // Only small control/detail labels may parse times; all 1,000 trajectory rows stay cached. + expect(parse.mock.calls.length).toBeLessThan(30); + expect(zoom()).toBeLessThan(8); + expect(line?.getAttribute("points")?.split(" ")).toHaveLength(1000); + }); + + it("keeps manual zoom during playback and loads only activity in a non-focused time window", async () => { + await mount(createScumMapFixture(), { ...scumMapContext, pageQuery: { from: scumMapContext.pageQuery.from, to: scumMapContext.pageQuery.to } }); + expect(container.querySelectorAll(".map-track-live")).toHaveLength(2); + await click(button("播放轨迹回放")); + await click(button("地图缩小")); + const manualZoom = zoom(); + await tick(1000); + expect(zoom()).toBe(manualZoom); + }); +}); diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_01_a.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_01_a.webp new file mode 100644 index 0000000000000000000000000000000000000000..2dd888974dffa70bb6e7a4098f7e7d155a79303e GIT binary patch literal 2188 zcmV;72y^#RNk&G52mk4pBp7xPd}!f zY?nuF15Q@Tw&#nSIlv9v^Vsz@))&hSR#3Ngp&H(aHL^7Xy0jeB>rz z0kw#kPPOa{)e$W2YAVRi2YG8T`kmpM8D(j@8`wX$Ic? z8%|xnwGX^yb=qb>ONvH6bHwFubZv!8;kiN&9H2|dWuKW+m!$Za2Rxe?M3CgHHom-o z<|leRbY3sBEbf*zTyx~)xOD1O`V5!kc~6H_-qH2L-O|^%EEem(8wTxt;|}f8-;?4M z7VWUGcK$q1z$v=- zC=H^BUo`rHi6{)iNSbp^CEil)tNqyV>&dGwhuX#{G578< zx{PmHdvScIP)aD}!t=mV6gA#rd?@`iXe&NZq{QUk&Z*Y3Y!XjMYeGGI$OC_NNP4LN zm&BKov7I)w-TL%TZ~vj@=uAq#_^5LW-c>aJ@5wuU_X4oJrB?Qw zH3SdN#w{!+fNl1?D$ylLFBLid)rwj;Br3Ulu^ABKVug*Hn!~Ym_*FnGMr^bch0*5# z<27NxMpd!p*A#Xhg9idWl-|LE=M4SWAK1ITVY)(1s&&RR{oS*ss_ezNeDH|{fU}L7 z5}jKEcrH`xLnmLlQcqO<9JS~X@_0c1#QN6e_ia9Dt1)1J=V|HTBs_;qm!v zg2mWI%Zn)4wB*e{o@u%pmt;G3YEEGip4dr`zH(&&F7LPzHJDMdR680}?Y>%}_QwOp zScUtiz?0HQcHns@aSZ`5NYY^`0-lvdrNVsR0|R_uB%Pocfnm-)J^dt%6hoz#(`0Ry zKV7Hc0X(S#&XGRN6#?gKc8Pc3@afKyzR8)n;9osS3^3BzNa>xN;n}547ZD>v&%4q+ zxx+J5?dRZK8s|z^Nyl~ro<|hbf%j%+KF0lu<`s8`c^;QO-GQ_hB|0>mZZ*fKPPM## zbRSRoA#bZgqYGTOV2=GqOIkl|!V*cE)R%4oPjJ!Sp;9(HQ;DP7J+R*`pZ@*FN7rl# z4gdgFP&goT0{{Rp5CEM4Dlh;r06u9Xkwv5-p_;8#R6quVvw&vE2-S98CwQ{}GGE^Y zQx_cmhr~Z|ubVyK(;EKe=b@VSqd)F@fqyRkvHu^|=h;{5r?PMB5+DHLQA3^52jB!d zyW$*Cm!ptLjatb=uOcP`@#E~aTFZ%7MTV|IqWJ9H z@nUJKLwciiAQj)`0Mz)MZma6EMe?iwZjc2nnS*h_6t zc-Wos@43g1-6F{E2ZDVUOdwR?56JkcI8H1b-32=vMoJKeeC|K;fXDrfeyl`j&{>v8 z4*J_f{aMa4{5#tX2UOa^osCg&w8b>}Wt^r$8U60%7$~5DMJX04IP4Fd_N(0_)@t`3OEJVe|pGF9z&)2YcFxc;LDy zo!eE@8wjo!%9D^*qt*GqF;rY|C){=xzsXXVN{2%41o?V9Cuz;fO=Hz`pQ(zfeg3NRS@re_VsZ;`fat(G}4*TCo z2a(^F+J~sL#nc?5*5qlGtUZkAEC2v_Xi%>J literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_02_a.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_bicycle_02_a.webp new file mode 100644 index 0000000000000000000000000000000000000000..d17f19daeae8b47ddc9fe4a8079f92625747c8c1 GIT binary patch literal 2616 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 literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_cruiser.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_cruiser.webp new file mode 100644 index 0000000000000000000000000000000000000000..0e8e4e335b81356fff58e245323f8e683414a4e2 GIT binary patch literal 1700 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%0Ahu-MQhPFugVx^{Jc6SbGrT+>lOa0`v87`b&`JI zdSG3_{cE8I)Dga<+{JsbFh{u0#e?~uil2k|(ev*4ZewQ-06U*usQX};{=Y4S}GG=QyP5A{&L znqT}tdp04{VgJYCyH8<4&FdbSj%u{PXz!VDK+(a77nCTo+l(My;@@Ok3+OGpL$= zo9i3;^Egdj7HG8^wrg7Ed%lOcli@>qLFRy#;xF~cZjppZp?QPRD`&dv+t6|}dZ)62 z89Z(oN;z=cruJ+s^)Zeroy!#rz2R`NtZ@XO=HHa%Km9&--hhvz! zFHxj~M42%WRb3B;VI4YT(szoZW-Djtwd|jas{bAwPRF}AOcZ)TGQbgmylB?1>Ra6+1_ShnUOONFIdd^6xg2L^r|pqD2i9`?(Rcj$Jo)eOMgdGkYUn!XP42pH z7(a6Fv+KF?2sY|^(JD#CPeAnsI>F_Q)k=%-fZ5kVPzw^y>+2)7>_*KV7hl~|s7E-$ zxUgqmNx-=mw1LhcLV|r;6+JJ17{8zRfW|hv_Y6P*@NObKz12-}fGfOs3g<=*8NTF* z%{T^JnqK5(0nU2jVyix^20PX~!)bB$rggN*g;=L*z?qPCbx(E|HFz$W^OJA zb}O@n$j%IRm7Jckd5pI+8jK!7e$bR0?l#M(O^zf2_j0+5XxUY94 gYjK?mOJ3`K_54JX@q921?8SyO5{hnyUjsM*07(g0j{pDw literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_mariner.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_kinglet_mariner.webp new file mode 100644 index 0000000000000000000000000000000000000000..14b505a7ae1748e88dc64fcde572433a26cd6aa7 GIT binary patch literal 2090 zcmV+_2-WveNk&E@2mkZD%B~@CCNEcX`H;OEz7&`0{ZZZ}%2&jX&+mJC z*ZI$`102$yUX}4z1ms1r{wJixv})v z?%%z+GT!5eC*HmF(b4AaqByH?fk1IsMsm^{+XITcEQ8hhGD|e_HAIL4SZ|vxmj?tu zi0}P+$&sr8D#$Bv;Vfok#yJ;50YIVvfC>O8+K&gI0fiB(e?lgDVwWAEs3;)_3Ih&4 zBSt}?1ROGi<}~v4y5{WS@%wMUxESh9R##dXz!}nr6g2um?j_)2< zRbr==UrKvN$Vr;<0<~V~MoRxb;8CiD)V`p1$#}Q02l5~6zw-T&f1Uro$Y3k&OrhRE?tsDYt;{thGUnqmp=ym7L(MI^hsYFI{yORT z0O(!49pg+JHQs{`arC<4A;7T;$UJ?70092=+fYTH@R;C!Nno6M0C4p3(yEyg6d@10 z*Ca9AvRsR&POT^EY!eutM>Ag}0Q#r2l4~N`f=WyR40uG{roytUH*pn=a@LzU&bpz0 zaAdd{!G)a1sd9cVXpzu|8JGkcNI@s0WS%5o}vOFs@-{W;rR!K9QpWH*B9Q>2Ofx1~Rx z&y9S$&e|AqGVtO?GcK7RYBG1TBgaxHO%efb*WY0ttLRi!Y8aQ6+{7HgLwn*kZ4%j6 z*?#AO%(3u5_TMU1En|m}Kou{$5{at@S@t63Ra`4&Qr&7PU==01(v2Ae;R)DTODAi{ zl;d|Ob)fMvoT+4E&wDCBRp*(w56x5c+`13l9-7UK&*Sj_Je@yQhS&v$GoPx%KE%TY{r+ccy-R*Q?{WM8MFHN9kTuG*nMVbl22O7sa4z2+qm?zpxMP2 z59|+-#h~SC)Zm1YlDzaE28aOw;ek1g-@i9qi69^QU*>b>Luet4RUDpkMivjQTu0(C zjGA*0Ipx)~y{W^x;gr;^X+saR+$gC3yYt*p@vv^HOTE;7eo#5l6%Uw?*y-pyFutOW z=t%_IOU?sV5CYEIuY#FV2Ya=*iBgNopD$USx~Hsi7BO7P`b?8(h%fQ&+zm%`eHVUv zJ?d38-f9`k+ltdTBfvH7LC(0#C0gv9XPW~j>^hTFI|IgQSb#uF(f;4O_~rp^bk;yL zssc7T5q`5!WTbtye%-6NW;sgRr0{x6zwucfN){CzAm62Uak{4HLwUDf$9!#I4GH&2 z)x*X1XYnsdwW U^KA{7g2h3{x;W*9VpMBT08bnO5C8xG literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_laika.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_laika.webp new file mode 100644 index 0000000000000000000000000000000000000000..40b78e6591f64dab98a2b15d3d9c3273e69a3beb GIT binary patch literal 1620 zcmV-a2CMl}Nk&FY1^@t8MM6+kP&il$0000G0000l001cf06|PpNYnxV00E$NYkT2f zmwzXj&*qtJ+qP}nwr$(CZN0W_+bHrp&wbEoX4;6D0F>%709bC4T?lJ?ntAQL+R4R#$vBQdoHQpKF)mu;w#El%z&cQ_)6|x{y4r4ChxJMx`)SMM?6j zf8>!e0^Xaw-P#At9d^l`A55o>8#0e1m4Ehp?HT@3$ob&5<37M@`aaehUKM`7(;kOk zadihy$6Fn4=CI|800Esoz%omm#$()(11XU*ZSem;cH^`Q`j2{(b2dox*!3uP)S@+) z^mtw$>O`_ON0@GVr3GB{F~;8xEO*KzBh9EYr%szSXYTw8t!AY1^de`!c=B*;{@^H* zBFt5(Ru@)Sn>o+w%JYgGF?90hAJs@T<{J}@!pb%UGISxys4UqydYKd#fPJT`im`O% zm~|5Zu=$%xV@s7ins{LK#}|^u7A?Lf#|8LH#*<=fZRDe_AppB|AfvI>kxv0Yz;aJS zim~;PHvsV1sV^yPN9ECg$1xQ~V>=q_8v_n~S!X+%DTgiD(8Z)J$cny!3?ARJ9n5A< zCig*r$1iOmykwmlFM|Vu|E?W`9Rl)~!WE23R*Fe&Yh+mf;IRGIYDuJ7G2xvE$iK8Q z<|}GKoTDUZX~JGh2AB6HIq?m%W?JvwwflHsLACu6kpFwEGI>gci#vVr$9H3CDQ4Td zW(SiLu{`7SsiZ9!`R%XDROKOP7A|>fILT-YslsIA9ti-JJK_5ZX(?vLWMkYIzF7zW zHokKr>)Ncaa?WlXaPlIOQOgnj4Tpk0!}`~EC23+JRwVLKWJLf6VCTn%GFQZs zlm{XdgU^TsU?pI!bHAEFQk80C5|j?f(9R4l6mA0#;BsAW{PW05A^# zodGH^04e}JZ7PvQBqAZ9G1h7TfDMUb0K{w&A0vFiUAcSc`e$-7uQERoowIyy(ZA>K zp0}Vqfxni2RsWaRtI>1zIhk0Ee#1^Xkc$R<=*Nl^kXs2E>eFWCC-q+tEQJngsGQm_ zoV=?86JFI5|8lB{-*s%*CaU%g?%V(X{{C?kbzDvKL&9!%HmN#qHvMS+8vpoLkF9HU z^I;Z=K}__Grq@7Lc9PyTIQpZ@uW%fF_Kh4gPf zLD+RtK!4MuIQsMIcR;np?0-$?_O1&(#UM-%2=~u}`mvwdUJu}Y5xK5CTs+e6?-8Z4 z`ZwYW4(^JDlOLLguJy@>_{yy2*?cYcvqToBP|ATfV$mEXCnNigD) z0-5^pi9 z982gUU0tBXS8m;{BhTvXY@eaRMDo$~v0gi#1i{GVvB+O=fX!d3&KMYddr)J2p zKJd&t1HNdXARp)ZfV@U}w1sqnK@k9ep0-Rx_^6%kgdJ$3Y^8m&7mX zO(fB|bK+oM{Q|)UAOJe5M8`f?pD^0TSv-}Fr?j#o3Ddgnv`0u={}(kMFvHW?gLfyb zY82~USPA~sUf|GA*3d!`LHks;o9xbKZ#!w|Qto@Wo--5C@c+NoW)yM9^8}y&YUD3O zxQUcJIAQ`6|Es=u0zJtlH}0*Lo0rr(klpF}ZpidqZ3w%v3-K*p@>g|q^@Fh|7@nUS z#8{RiK69FRG(3jE?vlpd!r{1Bapi@WG-$p6y?J0ewy1i_K<5k-nM^Ah?P&@At?XtZ z>P2g{jsG;MO3nbnBWd)dUEgKeFlSf9Xp0iF4Qjt?J#FyW&Bxw6pe8CybmyrhxyI0001j(-^A& literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorboat_02.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_motorboat_02.webp new file mode 100644 index 0000000000000000000000000000000000000000..9f7f889a79c2fb5a11f2214735f1d358a760d5a3 GIT binary patch literal 1216 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 literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_ris.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_ris.webp new file mode 100644 index 0000000000000000000000000000000000000000..9967a4622e701d608fda3f801f5f3d10523356c3 GIT binary patch literal 1688 zcmV;J250$FNk&GH1^@t8MM6+kP&il$0000G0000l001Qb06|PpNRa{n00E$N+twMu z9??GMOWU?>+qT`;w(;1bpRR4&wr$(Ct-76j-`NjIrILz>2|%ri>gpU?UU$T(R=~Q$ zCN&2XwJLhOy$mi(6hND!_|a#dJ$%Wv`<->wr3>I9m3-2r^37Ce1~7QzabNO-n=2&m zU)ZzN@cRd$W7GHBrSL{ym#hHy!V*J?Q;oQj-1$KWPb=0P44@y zLZbze44x#fz3{hrxGc{`88ZcGmbfuO+Epz>W;7Y<3DCa(8!0in!3oLAr}5adKPQO zr+@#xO_mA(D2D&a#AsGX;CA#FDH#svH+|RD((FBY7VUW|syhsL=s&*j{JhhduyC_S zj_nGUa+vu&X$-$Iu-vc5Lg5-<=08k~#zTHp&4ETwjX)B2g90j-6*jMOTr)IO! z;Kl!gv2Hj6IQg?bS+B-;5b)p`mAVQ1v=K&pe>>|o;l+q9=a;ndEF*9x+>jq>EZdBM zWWq;8f$f%*5|e=>Lz3{{VQ`R3SPnzrcU$MbP4cIePymn6m*7JnALLZMO2ubjs z>5$=5KXw2FwW@S}j8*@>Xyu_-)0iFjimIS?f$V(yXYXFQWWT+70N6@ThQCNAJOdGm z6aY;kq+O%vF!Q9vBm<|z6#{VzI2T2V2pj2{>G`}W3Kv19qDLiI$`a0nD=w$QHhOOi z2~USZ9g6Dt%)G*>)B{AY^!8uk*AIdS3IzaGP&gpi0{{T<5CEM4Dlh;h06t|RjYXs( zp&G8OR6qtqwFg`lN&IFH2N};xTY%ma-ZHKN`-g!33q5v!1|hiuIWcbWfp=@4ukaoZ zrUT87(EqWY0KdtZ&DfcA3zh)W{T$O8=xO|n~}6r*{%;rp`45Eo7}1o}91EGcU6 z`1-xAD$9Ntl^6)EmdoDPtMsTAT$}&^{{L3N-mOFmTxHP}{%JF=Ww%xiU&0Wtr3$K9 zV1-Oe5}Kc<uXh$ayT)yN~? zsE!ZJ2sPivsk=RXSlVv1CALJc=O>+5cC6jf3n(v!d-`?ygJk?nnc%nX4O_VRgGO6g%p}yC!ATq~mjP^EoR3WSC4g3WfL=-K;X62l%Mw8a4f5UE2 z%8%diclDwzV8^*_ZZPcf{qyQ7cT|a)+z((76*plb^#5B>;sw>;P=l^(Q8!e3kFn;& zR}1%v#C~8)hPcGhd58|VXY4)mGNy}#Kd|+u64C49df}QZA-tm9-05b{78SizKG;Iz zBFcC5OUffXkhEZKOrJp7v0dVrxE_)!ZX4^!fBtAn2t!s9bhS(nFx-=!c!BM)KP49L zb-g#J9U$XtH>No?b2sF*di<#Tf;zo_NZDpjR6GOg#jpk2<-43e|EJZDx=ZtK{=}cy zX3IMR9)Ih7<^9dUkM&sJeBV#>i^;+SudL4RyRUWLlQb@a&MiW&$bKuDH6_hH0=s|? z9A0n6o#OTr*;&s7=7(+SvfY&w)Wtn_A5?`;>a`oKX?`z1%xC}TdGa=v|K7j8rsH)$ iGk^TVCFZLCI}+;WrLCUtH} zGE);njKyr-le)aJPz(yOtqZ`6G5fgb=qNUEn@2uZqp`f$DojTpYy#c5Cltq9L9nu25Q zdB^r4>L83(C#P!S5fyZbrIrE4^>kQ8M`{!oAN?K?<@_tneCm;?%=dmK3$+xF${Y`mogHMg?U$za6KAUd!Qm#i(n+ zX|6ZI52H_4A%-K%MrSnZdgP!L6r#@0y@s3DXt#zu=i@83Qjjjd!My_TYUVXkM>TkH zt&)PYC&{5~4ony_n&w(6PkO8%Ws)}g2!}M?q2rzHmV9ZpH{gyN%)UU1s(vkoIbggq z_iBT&MiTsV(wpOlY^toHphrklH%{5GjN^vYFlYEFb4x2-v!X(UHgLQ~@-MHz0US@5 z!u~(?K0u0U^hyletGJni^Zg}jDoSco3~Klp5`cL*IhF2X7M3z8 zN>NRG6@v+rs2ua&aL9R3rA(4SP%~e`j4p@voH6bBOIF#Trze}XQpnW8`#fPBw%Bop zscqFi=xFCgMYZxc9P{tDT43=NPM(M-{+lxkEm?BBJ5HasKOPTeOj%c%T6;e^7=U}C zeNRQTHY_%R94sZXobh`rs>PYMp9IOwfS=b@RLe81^KLtpT~9A4suO0?rq@38#C3bL zNts#?09H^qAbtY?01yuVodGH^04xALWhReBq#_|2tyZW&21K!dL3Y*1CEK4gvt3E` zUj_URdmc9B0`Ms?R{?jsS!=`m{CX(o7wP|CUY6d5f3m24-e7l#G2%R9Fn=b~9Zpmu z-GFVmUT_wBdKzdjpuZ7eJT(+l1oq6PX!EUv&xGTzs~`vd&yOAX-_yGOKjW|GHvTb?7RW2#NM9;*C-uDE z$hXjcZTk*}{Tl7{dlKuj>7N&;aca<@^EM^*fKlxm%R|*1M+`x{Id1OmanvDF$+t-v zBqA~;*g(ZN zcsUBYq^cLrc01k_4UUS-1erK|>Hff7tDEgY!JGasGl+Xln0@G1V_ zm*D#toWNgE*72f$DSr%f(Uu`7d82&%x?(8+!gnMwNo$=LV_exI<|}2~^MsK=my4bv zc4hc=h>(j`OrSCEh2ZIW8^1G)BV-ppSC&Dc{rmcZPP=+r9`@kwk0x#z`KnqOvX#I= axT%DGygV=`pb_*^6p`%)PsTC;0002n9ef}F literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_inhands.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_sup_inhands.webp new file mode 100644 index 0000000000000000000000000000000000000000..f7be0f466a9054052d7e63d476ab13687db5a6d6 GIT binary patch literal 1076 zcmV-41k3wUNk&F21ONb6MM6+kP&il$0000G0000l0015U06|PpNRgurUnaBku9o({P+mhBLW%Y7Yhm`*d=J-N&$MSh999D5B&`m&lIwY(MFV!=( zt_W`?@eLaBzatv3Gz<)u@ShX+BLE^ewzB1H`*oYz`mZ5)k)c_2lH2|{LopJUvW7juFr!>t4 zz#w|h7y!swvFq$@=dRwkarN?rV>_15?$@eRMgZ^)A_4$bP&goP0ssIo3;>-0Dlh;a z06uLnl18K>A)zo8SO9c+YLn=eN^I-fgo4&CddSLwp#i-ga$(g9TV%C(d;QfYxpu31_()uv*D4soluwR3RzySW!nM#t# zZ0cr}w0@EXqRoD#BPJ~RgIW^Ov6j;cpYQBe>50D($Nv^HC}jLH#ooi<_qTsCv`h>F z0`TBld%nSacx@jZ=6}($GNkRLrM>|q0G}*t(iQzrbFoJw#wh?DipXl z2lEE^WS4C*q)s_fR(GrR7QXfB3)AUz+1MWRKwSaI<2nC!`q=MqtCUAH{1X4ZFGE{h zs<87v@LBdS``qNve2%3{BzZVg@JnzL+;Zt*-->;p0|RS~qGqZ5Urw$^dAH z6y3N=zxprUZp&Tm68NlZyT)rbNJ4YXxYk|23f($@r?N5&Ak>%cV- z!enRPZok~!F7x@)))pLPcVa#)`1%!$aTK{Cz`Ax9}#g`f>R7M*7R)$c;pI( zr>I66I0b@i?;mHR@t@p;0@;H3j5H3s6ot!kHQv=^6!>6O?@)3fBW)vJX$X&!m0r+f z6Zv5~2+EF3O4G05AYPZ7`BWq$44fT~??-2E?EXvix+ycCT)a zJ_eZv@t=_`Vh&coz^BJ3kuFi}^nEAn5&WO}*ZhtJuh&NuSkQ&!qSAe)tr+CskT(F1 zrhD|yxuXd@^+DPh6lD{UqM6E3ayS40{{1=6|LlwZ{f|%o`wl`ZoG-w{)-tsQfLv%uqnhp3z!mVR?*>!qO%8s<5Ku`CnGyNt&r7PXI zltgE+4gKvJU|aM_yKB*ug>-h|WAXYD9C4@YY|bT_gYFpI7735h9pBDMMf%|iBci^y z^*O{O$(WdcZmwJ%6s!M)VjXoTKr>(E-~NPh`DaFecPX|TPer%f@RuvUX=*ZndY8=T1{R*W?wk5<;aPU) zEO54TYjTnMvBka|By>8q$ED3%_0={y?)LU5PI&D62wx6(-njUoo@RCZWYFkuW(1@` z($s6v>st$){^7q{Lm5l5ztCr>bH-6m(R}^pg!ua8hdhv!_5OB*R?9Lx>2}#U0=U3J zg0=jh@jW-9Z=;yb!Fqb~bdMV8_;e4Q)6sKXU#J%bg36f~`te8Y~@ z#{R{&J+fnz?F2WLmdd!rbd#4y6D%FUd^7dgLT~SF;?imyA#UBH;;}2XaaI)Tc9M5dvpQ@T2u^O|DW%m-Ofve*ppsYT+ zWu^<3D#HW%IC_pSTunDrMvkHMH-X}IVo#UIuxoEU?f!z7yfcy)ORE8SvTyYm?NIzj o*fPP_T5;h2sQ>@v>Ho_=|N5vU|M%@5`*rZR;6J_p0Z$4502>36ssI20 literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_tractor_01_a.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_tractor_01_a.webp new file mode 100644 index 0000000000000000000000000000000000000000..4ee59619df75bbd86766b764b5bd6988bae521aa GIT binary patch literal 2066 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 Q{T>*BaAY+A0l)wN0Bu9!CIA2c literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wolfswagen.webp b/plugins/examples/scum-server-plugin/assets/map/vehicles/ico_wolfswagen.webp new file mode 100644 index 0000000000000000000000000000000000000000..87e35c77d0e6012817f2eec4f16fde959e510718 GIT binary patch literal 1498 zcmV<01tt1YNk&G}1pok7MM6+kP&il$0000G0000l001Na06|PpNHzih00E$MZQCJ9 z9?xUSwBxlXo`=c@DXPNch07yYVD7h6rmGy6hq0)6jSQH!bPn@?N#D~c zQ&xUoHpHUPKGHF54fGDOebjnFr_9BTSw1>_!GNi&{`>V$O#xU8{gaOAJFY5G6@Z8F z89Jsfd@Da-V9`@ln1hZ3;TyomL1vhPj%Q&3z}hn!$F-enn~msu7g`)PNUeEKp|I z>(qFWh41Er2?LA;{KM`)O(EbJSVT1iFrH)}S(1d~eE5+7Q+UFSe@P9LWZ*P{D5?Cr z2D}*A4c5Lv@}Eyg(mBaeA)U*}4S0xatOxkL4}Hr99XCJuk0hNq^0jYCvOjDr0GdiU*k&^sp3Cgd&_vuy`Bx|e%V0{26)@}WRTU}uQ6hmZIP&goV0{{Tf4*;D3 zDlh;g06uLjjzy#*p&0DV06+%Bwg6q3L7g7vqbavT#V6Mnodc~`c@xTJn8V_@Ni5$8 zGPvMIl00a70B1kxUtoXLezYEoNmHtb>4uv3Q?#eQPO0Qh<5DFLb^Pkr=MlTpy(prs z=Su~Wk?4}pF%CR8u2`w0lCSAdZ&hqaV}Jnu|DAEXAqQkXU>C~Bm22Uk6OjOy-(I-N ze%Jv?wzS;|Y9+L--W`wcy(=5t4Hw{p_wr`dUVn$g{c;k;9r|gi(2 zeT#I?$_J>#lPGFHnSpKiLB~B07ak21x)ePgbuqv(LM*rxNXOta|3%v*aDXkxJ?kiGH7GLY#qxXg?r17*&8Lt2ZpVq9n z$$kX6{Q^SQZ+0L=dhVx&<)l72jhwyW1E0KYw{At274X@)4(hEtR_&>xl&q~YA=Pq2 z%iJyZnfKcYobsB#^7%2p$T~;ugnV3D)ZVYRT)a((b`Gj@Gn`Z*7XeHmYj?q+nf*l{ z8XoQ_>+NM0FU!`r4y;DS$ksFQy?CRnaMVi^sve7I`>cJnUFmBPU6ztzi9B}6OX@)H zn8F!ymYi)uN;)dm{x8dDEx-?oXIljWm)Z1bmN{J!1A`WFHaIhBX0L&sVR9aTipvgG zA6OaD?0+SP<(}XYdnHVnS9(E(GlJ-V32V+u@2e5V0cy|Gh>6bJ(i)P2YZOTKzQ0(% zXTa29DdP!hZo7m1|792Oy}5eh+Go%I^PCv9pX;Fe@vy&B-{Mt={SRgTv5poJ^yr=S29>4B~|LkiQ}xg9IRTcTyZRa_UPTfx4IzEGcXVK8$GyB#6T z2m?NF%cH9*u6E#&egYfo(~ZZ0pNwAD1X_8M32hj{fbJW5NLIQbo8kFI4TR01VD)`q*eNK z++Q08@eR@re?{l$)$x9h^dbq+^6*P*m0nf9E-L~-Y?H0yiQAu*o*nl!gdlcca!?6#i6tR`T?~f)Kq af~1XTnOq&I zH&!&u0SJ<>q)nnq>Wx)x@&N*mCi_j=Sn9}c7gn^%2SDHjHQvxV{V#Ro$3vqV3O*j|vKVKf5*f0wqf|ME{ckB6E5A9kzxo!E3fCy48 z20)g)`Lm@1AcE8a09H^qAX)+d0MH8nodGH^03!fCZ7h#QBqE_9E<5`$fDMUf09DbP zeT0u+AFqiN1?tCD1TxPTA5D7!eMn?vVEA-alKQmy-a~da)N7|1Jux$_(;g3sl%V$t377isPx6; zN|B)0or!l!>!P=J>?94!)Ll}TgeZ6(i<0}Pb}XvxbJB$i8%I~q&$(oYxg<^Wj}rsa zzll<$!0i#ws!?}+Fn4OQpMM&ixP$*giQ?JZ`wu+-ahgh=6q*Dzp6wKs1=&fB*n@ Cgg5d4 literal 0 HcmV?d00001 diff --git a/plugins/examples/scum-server-plugin/features/contracts.ts b/plugins/examples/scum-server-plugin/features/contracts.ts index c5b77dd..a2451c5 100644 --- a/plugins/examples/scum-server-plugin/features/contracts.ts +++ b/plugins/examples/scum-server-plugin/features/contracts.ts @@ -36,4 +36,17 @@ export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subje export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance }; export type SCUMTrajectoryCollection = { available: boolean; reason?: string; trajectories: SCUMTrajectory[] }; +export type MapViewState = { zoom: number; x: number; y: number }; +export type MapPlaybackState = { playing: boolean; value: number; speed: number; startedAt: number; startValue: number }; +export type MapTimeWindow = { from: number; to: number; label: string }; +export type MapTrackPoint = { key: string; row: Record; x: number; y: number; time: number; riding: boolean; rideCode: string }; +export type MapTrackSegment = { key: string; mode: "walk" | "ride"; points: MapTrackPoint[] }; +export type MapTrack = { key: string; kind: "player" | "vehicle"; label: string; color: string; points: MapTrackPoint[]; walk: number; ride: number; from: number; to: number; vehicles: string[] }; +export type MapPlaybackFrame = { tracks: MapTrack[]; span: { from: number; to: number } }; +export type MapSceneData = MapPlaybackFrame & { + points: Record[]; settings?: Record; bounds: Record; window: MapTimeWindow; + catalog: Map; users: Array<{ key: string; label: string; count: number }>; playerLabels: Map; + segments: Map; +}; + export type SCUMFeatureWorkspace = { defaultDirectoryKey?: string; directories?: SCUMLogicalDirectory[]; files?: SCUMLogicalFile[]; configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } }; diff --git a/plugins/examples/scum-server-plugin/features/map-vehicle-icons.ts b/plugins/examples/scum-server-plugin/features/map-vehicle-icons.ts new file mode 100644 index 0000000..c3e260d --- /dev/null +++ b/plugins/examples/scum-server-plugin/features/map-vehicle-icons.ts @@ -0,0 +1,29 @@ +import { scumVehicleFallbackIconUrl, scumVehicleIconPathFor } from "./scum-catalog.js"; + +// 48px thumbnails for 20px map markers and 26px playback markers. Catalog originals stay separate. +const icons: Record = { + "items/vehicles/ico_bicycle_01_a.webp": new URL("../assets/map/vehicles/ico_bicycle_01_a.webp", import.meta.url).href, + "items/vehicles/ico_bicycle_02_a.webp": new URL("../assets/map/vehicles/ico_bicycle_02_a.webp", import.meta.url).href, + "items/vehicles/ico_cruiser.webp": new URL("../assets/map/vehicles/ico_cruiser.webp", import.meta.url).href, + "items/vehicles/ico_improraftbig.webp": new URL("../assets/map/vehicles/ico_improraftbig.webp", import.meta.url).href, + "items/vehicles/ico_improsmallraft.webp": new URL("../assets/map/vehicles/ico_improsmallraft.webp", import.meta.url).href, + "items/vehicles/ico_improwheelbarrow.webp": new URL("../assets/map/vehicles/ico_improwheelbarrow.webp", import.meta.url).href, + "items/vehicles/ico_kinglet_duster_a.webp": new URL("../assets/map/vehicles/ico_kinglet_duster_a.webp", import.meta.url).href, + "items/vehicles/ico_kinglet_mariner.webp": new URL("../assets/map/vehicles/ico_kinglet_mariner.webp", import.meta.url).href, + "items/vehicles/ico_laika.webp": new URL("../assets/map/vehicles/ico_laika.webp", import.meta.url).href, + "items/vehicles/ico_motorboat_02.webp": new URL("../assets/map/vehicles/ico_motorboat_02.webp", import.meta.url).href, + "items/vehicles/ico_motorcycle_01_a.webp": new URL("../assets/map/vehicles/ico_motorcycle_01_a.webp", import.meta.url).href, + "items/vehicles/ico_rager.webp": new URL("../assets/map/vehicles/ico_rager.webp", import.meta.url).href, + "items/vehicles/ico_ris.webp": new URL("../assets/map/vehicles/ico_ris.webp", import.meta.url).href, + "items/vehicles/ico_sidecarbike.webp": new URL("../assets/map/vehicles/ico_sidecarbike.webp", import.meta.url).href, + "items/vehicles/ico_sup_inhands.webp": new URL("../assets/map/vehicles/ico_sup_inhands.webp", import.meta.url).href, + "items/vehicles/ico_sup_vicinity.webp": new URL("../assets/map/vehicles/ico_sup_vicinity.webp", import.meta.url).href, + "items/vehicles/ico_tractor_01_a.webp": new URL("../assets/map/vehicles/ico_tractor_01_a.webp", import.meta.url).href, + "items/vehicles/ico_wheelbarrow.webp": new URL("../assets/map/vehicles/ico_wheelbarrow.webp", import.meta.url).href, + "items/vehicles/ico_wolfswagen.webp": new URL("../assets/map/vehicles/ico_wolfswagen.webp", import.meta.url).href, + "items/vehicles/motoredboat_a.webp": new URL("../assets/map/vehicles/motoredboat_a.webp", import.meta.url).href, +}; + +export function scumMapVehicleIconUrl(code: string): string { + return icons[scumVehicleIconPathFor(code)] || scumVehicleFallbackIconUrl(code); +} diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 286d698..9561d22 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -83,10 +83,12 @@ type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; type PluginCollectionSurfaceKey = keyof typeof scumCollections; type SCUMPlatformSurfaceKey = "players" | "vehicles" | "trajectories" | "vehicleLocks"; +// Call budget per live-map load/15s refresh: 7 bounded plugin collections + 1 typed SCUM surface. +// Squads and members load once per refresh; hovering, selecting and playback issue 0 API calls. const pageCollections: Record = { players: ["members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"], squads: ["squads", "members", "flags"], - "live-map": ["mapPoints", "mapRegions", "mapSettings", "flags", "tradeGoods"], + "live-map": ["mapPoints", "mapRegions", "mapSettings", "flags", "squads", "members", "tradeGoods"], gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "tradeGoods"], workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"] }; diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 81f4254..de39fc2 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -19,55 +19,46 @@ import { type SCUMSurfaceData, type SCUMWorkspaceActions } from "./page-data.js"; -import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleEntry, scumVehicleFallbackIconUrl, scumVehicleIconUrl, type ScumCatalogEntry } from "./scum-catalog.js"; +import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleEntry, type ScumCatalogEntry } from "./scum-catalog.js"; +import { scumMapVehicleIconUrl } from "./map-vehicle-icons.js"; +import type { MapPlaybackFrame, MapPlaybackState, MapSceneData, MapTimeWindow, MapTrack, MapTrackPoint, MapTrackSegment, MapViewState } from "./contracts.js"; type StateSetter = (next: T | ((previous: T) => T)) => void; -type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; stopPropagation?: () => void }; +type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; currentTarget?: { open?: boolean }; stopPropagation?: () => void }; type GiftTab = "definitions" | "claims" | "deliveries" | "timed"; type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; +type MapVehicleFilter = "all" | "land" | "water" | "air" | "other"; type ScumMapLayer = "terrain" | "topo" | "night"; type CatalogPickTarget = "gift-items" | "produce-item"; type PlayerPanelKind = "closed" | "gifts" | "items" | "history"; type PlayerPanelState = { kind: PlayerPanelKind; playerId: string }; -type MapViewState = { zoom: number; x: number; y: number }; -type MapPlaybackState = { playing: boolean; value: number; speed: number; startedAt: number; startValue: number }; -type MapTimeWindow = { from: number; to: number; label: string }; -type MapTrackPoint = { key: string; row: RecordMap; x: number; y: number; time: number; riding: boolean; rideCode: string }; -type MapRideSample = { key: string; code: string; time: number; scene: { x: number; y: number } }; -type MapTrackSegment = { key: string; mode: "walk" | "ride"; points: MapTrackPoint[] }; -type MapTrack = { key: string; kind: "player" | "vehicle"; label: string; color: string; points: MapTrackPoint[]; walk: number; ride: number; from: number; to: number; vehicles: string[] }; type PointerEventLike = { pointerId?: number; clientX?: number; clientY?: number; button?: number; currentTarget?: { setPointerCapture?: (pointerId?: number) => void; releasePointerCapture?: (pointerId?: number) => void }; preventDefault?: () => void; stopPropagation?: () => void }; const scumMapLayerImages = { terrain: new URL("../assets/map/scum-map-terrain-4096.webp", import.meta.url).href, topo: new URL("../assets/map/scum-map-topo-4096.webp", import.meta.url).href, night: new URL("../assets/map/scum-map-night-4096.webp", import.meta.url).href, zones: new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href } as const; const scumMapBaseLayers: ReadonlyArray = [["terrain", "地形"], ["topo", "等高线"], ["night", "夜间"]]; +const scumMapVehicleFilters: ReadonlyArray = [["all", "全部载具"], ["land", "陆地载具"], ["water", "水上载具"], ["air", "飞行类载具"], ["other", "其他载具"]]; const scumCatalogKinds: ReadonlyArray = [["all", "全部"], ["item", "物品"], ["vehicle", "载具"], ["zombie", "丧尸"], ["animal", "动物"], ["armed-npc", "NPC"]]; const scumCatalogPickLimit = 48; const scumMapSize = 4096; const scumSurfaceRefreshMs = 15000; const scumMapZoomMin = 1; const scumMapZoomMax = 8; -const scumMapTrackPointLimit = 400; -const scumMapTrackLimit = 24; const scumMapSegmentGapMs = 15 * 60 * 1000; -const scumMapSegmentJump = 52000; const scumMapDefaultView: MapViewState = { zoom: 1, x: 0, y: 0 }; const scumMapPlaybackIdle: MapPlaybackState = { playing: false, value: 1, speed: 1, startedAt: 0, startValue: 1 }; // Playback runs on wall-clock time so the 1x / 2x / 4x switch is instant and timer jitter cannot drift. const scumMapPlaybackDurationMs = 30000; -const scumMapRideWindowMs = 10 * 1000; -const scumMapRideDistance = 1500; const scumMapTimePresets: ReadonlyArray = [["1h", "最近 1 小时"], ["15m", "最近 15 分钟"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["all", "全部时段"], ["custom", "自定义起止"]]; const scumMapPresetMs: Record = { "15m": 15 * 60 * 1000, "1h": 60 * 60 * 1000, "6h": 6 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000 }; const scumMapTrackColors = ["#4ECDC4", "#FF8A6B", "#F7DC6F", "#85C1E9", "#BB8FCE", "#98D8C8", "#FFA07A", "#45B7D1"]; const scumMapTrackColorCache = new Map(); const scumMapDefaultWindowMs = 60 * 60 * 1000; const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false }; -// Latest rendered playback frame; the interval effect reads it to keep the follow camera in sync. -let mapPlaybackFrame: { tracks: MapTrack[]; span: { from: number; to: number } } | null = null; export type ReactLike = { createElement: (...args: any[]) => any; useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void; useRef?: (initial: T) => { current: T }; + useMemo?: (factory: () => T, deps: readonly unknown[]) => T; useState?: (initialState: T | (() => T)) => [T, StateSetter]; }; @@ -123,6 +114,9 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [produceY, setProduceY] = usePluginState(react, "0"); const [produceZ, setProduceZ] = usePluginState(react, "0"); const [giftTab, setGiftTab] = usePluginState(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(react, ""); @@ -135,6 +129,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [deliveryGift, setDeliveryGift] = usePluginState(react, ""); const [deliveryPlayer, setDeliveryPlayer] = usePluginState(react, ""); const [mapLayers, setMapLayers] = usePluginState>(react, { players: true, vehicles: true, flags: true, regions: true, other: true }); + const [mapVehicleFilter, setMapVehicleFilter] = usePluginState(react, "all"); const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, ""); const [hoverMapPoint, setHoverMapPoint] = usePluginState(react, ""); const [mapView, setMapView] = usePluginState(react, scumMapDefaultView); @@ -145,6 +140,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [mapTrackKey, setMapTrackKey] = usePluginState(react, ""); const [mapPlayback, setMapPlayback] = usePluginState(react, scumMapPlaybackIdle); const mapViewportRef = react.useRef ? react.useRef(null) : { current: null as HTMLElement | null }; + const mapPlaybackFrameRef = react.useRef ? react.useRef(null) : { current: null as MapPlaybackFrame | null }; + const mapFollowRef = react.useRef ? react.useRef(true) : { current: true }; const [eventEditorOpen, setEventEditorOpen] = usePluginState(react, false); const [produceEditorOpen, setProduceEditorOpen] = usePluginState(react, false); const [giftEditorOpen, setGiftEditorOpen] = usePluginState(react, false); @@ -181,28 +178,60 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) 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(); - setMapView((previous) => zoomMapView(previous, event.deltaY < 0 ? 1.16 : 1 / 1.16, event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2)); + 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); }; + 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 camera = mapPlaybackFrame ? playbackFollowView(mapPlaybackFrame.tracks, next, mapPlaybackFrame.span, mapViewportRef.current) : undefined; + const frame = mapPlaybackFrameRef.current; + const camera = frame && mapFollowRef.current ? playbackFollowView(frame.tracks, next, frame.span, mapViewportRef.current) : undefined; if (camera) setMapView(camera); - }, 90); + }, 33); return () => { clearInterval(timer); }; - }, [mapPlayback.playing, mapPlayback.startedAt, mapPlayback.startValue, mapPlayback.speed, setMapPlayback, setMapView, mapViewportRef]); + }, [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 (!giftEditorOpen || typeof document === "undefined") return; + const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setGiftEditorOpen(false); }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [giftEditorOpen]); if (react.useEffect) react.useEffect(() => { if (pageKey !== "live-map") return; - if (!focusUser) { setMapUsers(undefined); applyMapTimePreset(setMapTimePreset, setMapTimeFrom, setMapTimeTo, "1h"); 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"); @@ -215,37 +244,61 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) if (!node) return; const settings = state.data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? state.data.mapSettings[0]; const range = mapQueryRange(pageQuery.from, pageQuery.to); - const points = mapTrackPointList(mapTracks(state.data, resolveMapBounds(settings), { from: range.from, to: range.to, label: "" }, [focusUser], mapVehicleClassIndex(state.data))); - if (points.length) setMapView(fitMapView(points, node, 0.8)); + 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; - return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, - action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, - state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台 SCUM 用户与载具表…") : null, - state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null, - state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { + 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, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, eventCommand, setEventCommand, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, - giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber, - deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint, + giftTab, setGiftTab, giftSearch, setGiftSearch, giftStatus, setGiftStatus, giftRecordKey, setGiftRecordKey, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, 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, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo, + mapView, setMapView, mapViewportRef, mapPlaybackFrameRef, mapFollowRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo, mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback, eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen, mapBaseLayer, setMapBaseLayer, mapZonesOverlay, setMapZonesOverlay, catalogSearch, setCatalogSearch, catalogKind, setCatalogKind, catalogOpen, setCatalogOpen, - setAction, refresh: () => setRefreshSignal((value) => value + 1) - }) : null + 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", "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; playerStatus: string; setPlayerStatus: StateSetter; playerPanel: PlayerPanelState; setPlayerPanel: StateSetter; squadSearch: string; setSquadSearch: StateSetter; selectedSquadId: string; setSelectedSquadId: StateSetter; - activityStatus: string; setActivityStatus: StateSetter; giftTab: GiftTab; setGiftTab: StateSetter; + activityStatus: string; setActivityStatus: StateSetter; giftTab: GiftTab; setGiftTab: StateSetter; giftSearch: string; setGiftSearch: StateSetter; giftStatus: string; setGiftStatus: StateSetter; giftRecordKey: string; setGiftRecordKey: StateSetter; eventId: string; setEventId: StateSetter; eventName: string; setEventName: StateSetter; eventType: string; setEventType: StateSetter; eventSchedule: string; setEventSchedule: StateSetter; eventCommand: string; setEventCommand: StateSetter; @@ -257,9 +310,11 @@ type ViewState = { giftCode: string; setGiftCode: StateSetter; giftName: string; setGiftName: StateSetter; giftItems: string; setGiftItems: StateSetter; giftCommands: string; setGiftCommands: StateSetter; giftClass: string; setGiftClass: StateSetter; giftAudience: string; setGiftAudience: StateSetter; giftNumber: string; setGiftNumber: StateSetter; giftAchievement: string; setGiftAchievement: StateSetter; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter; deliveryGift: string; setDeliveryGift: StateSetter; deliveryPlayer: string; setDeliveryPlayer: StateSetter; - mapLayers: Record; setMapLayers: StateSetter>; + mapLayers: Record; setMapLayers: StateSetter>; mapVehicleFilter: MapVehicleFilter; setMapVehicleFilter: StateSetter; selectedMapPoint: string; setSelectedMapPoint: StateSetter; hoverMapPoint: string; setHoverMapPoint: StateSetter; setAction: StateSetter; refresh: () => void; mapView: MapViewState; setMapView: StateSetter; mapViewportRef: { current: HTMLElement | null }; + mapPlaybackFrameRef: { current: MapPlaybackFrame | null }; + mapFollowRef: { current: boolean }; mapTimePreset: string; setMapTimePreset: StateSetter; mapTimeFrom: string; setMapTimeFrom: StateSetter; mapTimeTo: string; setMapTimeTo: StateSetter; mapUsers: string[] | undefined; setMapUsers: StateSetter; mapTrackKey: string; setMapTrackKey: StateSetter; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter; eventEditorOpen: boolean; setEventEditorOpen: StateSetter; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter; @@ -544,18 +599,30 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, 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.setGiftCommands(""); view.setGiftClass("5"); view.setGiftAudience("all"); view.setGiftNumber("1"); view.setGiftAchievement("0"); view.setGiftAchievementNumber("0"); view.setGiftEditorOpen(true); + }; + const openGiftEditor = (gift: RecordMap) => { + 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(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); view.setGiftEditorOpen(true); + }; 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); const commands = parseGiftCommands(view.giftCommands); if (!items.length && !commands.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: integerInput(view.giftNumber, 1), - achievement: integerInput(view.giftAchievement, 0), achievementNumber: integerInput(view.giftAchievementNumber, 0), items, commands, + code, name, class: integerInput(view.giftClass, 5), audience: view.giftAudience, number, + achievement, achievementNumber, items, commands, status: "active", updatedAt: new Date().toISOString() }); + if (view.giftRecordKey && view.giftRecordKey !== code) await deleteGiftDefinition(actions ?? {}, view.giftRecordKey); + view.setGiftEditorOpen(false); view.setGiftRecordKey(""); view.refresh(); return `礼包 ${name} 已保存。`; }); @@ -569,35 +636,51 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu 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], ["物品列表", data.tradeGoods.length]]), e("div", { className: "console-row-actions", role: "tablist", "aria-label": "礼包视图" }, giftTabButton(e, view, "definitions", "礼包定义"), giftTabButton(e, view, "claims", "领取/待领"), giftTabButton(e, view, "deliveries", "发放记录"), giftTabButton(e, view, "timed", "游戏定时记录") ), - view.giftTab === "definitions" ? e("div", { className: "overview-two-col" }, - e("details", { className: "console-module scum-editor", open: view.giftEditorOpen, onToggle: (event: InputEvent) => view.setGiftEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新礼包"), e("span", { className: "page-status" }, view.giftCode || view.giftName ? "编辑中" : "点击展开")), - labeledField(e, "礼包编号", e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "例如 welcome_pack", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) })), - labeledField(e, "礼包名称", e("input", { value: view.giftName, "aria-label": "礼包名称", placeholder: "例如 新人礼包", onChange: (event: InputEvent) => view.setGiftName(inputValue(event)) })), - labeledField(e, "礼包周期", e("select", { value: view.giftClass, "aria-label": "礼包周期", onChange: (event: InputEvent) => view.setGiftClass(inputValue(event)) }, [["1", "每日"], ["2", "每周"], ["3", "每月"], ["4", "每年"], ["5", "一次"], ["6", "每日五次"]].map(([value, label]) => e("option", { key: value, value }, label)))), - labeledField(e, "适用玩家", e("select", { value: view.giftAudience, "aria-label": "适用玩家", onChange: (event: InputEvent) => view.setGiftAudience(inputValue(event)) }, e("option", { value: "all" }, "全部玩家"), e("option", { value: "pve" }, "PVE 玩家"), e("option", { value: "pvp" }, "PVP 玩家"))), - labeledField(e, "发放次数", e("input", { value: view.giftNumber, "aria-label": "发放次数", type: "number", placeholder: "1", onChange: (event: InputEvent) => view.setGiftNumber(inputValue(event)) })), - e("div", { className: "console-row-actions" }, labeledField(e, "成就类型", e("input", { value: view.giftAchievement, "aria-label": "成就类型", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievement(inputValue(event)) })), labeledField(e, "成就值", e("input", { value: view.giftAchievementNumber, "aria-label": "成就值", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievementNumber(inputValue(event)) }))), - labeledField(e, "礼包物品", e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "SCUM 目录代码:数量", onChange: (event: InputEvent) => view.setGiftItems(inputValue(event)) })), - catalogPicker(e, view, "gift-items"), - labeledField(e, "礼包命令", e("textarea", { value: view.giftCommands, "aria-label": "礼包命令", placeholder: "每行一条命令", onChange: (event: InputEvent) => view.setGiftCommands(inputValue(event)) })), - e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包") + 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("span", null, giftClassLabel(numField(gift, "class"))), e("span", { className: "provider-id" }, giftAudienceLabel(textField(gift, "audience")))), + e("td", null, e("div", { className: "scum-gift-content-cell" }, catalogIconStrip(e, giftCatalogIcons(gift)), e("span", null, giftItemsSummary(gift))), e("span", { className: "provider-id" }, giftCommandsSummary(gift))), + e("td", null, e("strong", null, numField(gift, "number")), e("span", { className: "provider-id" }, `成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)), + e("td", null, e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, giftStatusLabel(status))), + e("td", null, 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: () => openGiftEditor(gift) }, "编辑"), + 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: () => { 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: 7 }, e("p", { className: "page-status" }, data.gifts.length ? "没有符合筛选条件的礼包。" : "暂无礼包定义,请先新建一个礼包。")))) + ) ), - e("div", { className: "console-record-list" }, data.gifts.length ? data.gifts.map((gift, index) => { - const key = textField(gift, "_recordKey", "code", "id"); - return e("article", { key: idOf(gift, `gift-${index}`), className: "console-record" }, - e("div", { className: "console-record-head" }, e("strong", null, textField(gift, "name") || key), e("span", { className: "status-pill status-active" }, textField(gift, "status") || "active")), - e("div", { className: "console-record-meta" }, e("span", null, `周期 ${giftClassLabel(numField(gift, "class"))}`), e("span", null, `适用 ${textField(gift, "audience") || "all"}`), e("span", null, `次数 ${numField(gift, "number")}`), e("span", null, `成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)), - e("span", { className: "provider-id" }, giftItemsSummary(gift)), - catalogIconStrip(e, giftCatalogIcons(gift)), - e("span", { className: "provider-id" }, giftCommandsSummary(gift)), - e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => view.setDeliveryGift(key) }, "选择发放"), e("button", { type: "button", className: "icon-command", onClick: () => { view.setGiftEditorOpen(true); view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除")) - ); - }) : e("p", { className: "page-status" }, "暂无礼包定义。")) ) : null, view.giftTab === "definitions" ? itemCatalogPanel(e, data.tradeGoods) : null, view.giftTab === "claims" ? e("div", { className: "overview-two-col" }, @@ -615,32 +698,78 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu 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 + ) : null, + view.giftEditorOpen ? giftEditorDialog(e, view, actions, saveGift) : null ); } +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.setGiftEditorOpen(false) }, + 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.setGiftEditorOpen(false) }, "关闭")), + 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, "可用目录选择器快速加入,格式为 目录代码:数量")), labeledField(e, "物品清单", e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "例如 BP_Cash_01:2, Water-Bottle.01:1", onChange: (event: InputEvent) => view.setGiftItems(inputValue(event)) })), catalogPicker(e, view, "gift-items")), + e("div", { className: "gift-form-wide gift-form-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包命令"), e("span", null, "每行一条 SCUM RCON 命令,可选")), labeledField(e, "命令清单", e("textarea", { value: view.giftCommands, "aria-label": "礼包命令", placeholder: "例如 #announce Welcome\n每行一条命令", onChange: (event: InputEvent) => view.setGiftCommands(inputValue(event)) }))), + e("div", { className: "confirm-actions gift-dialog-actions" }, e("button", { type: "button", onClick: () => view.setGiftEditorOpen(false) }, "取消"), 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 points = collectMapPoints(data); - const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0]; - const bounds = resolveMapBounds(settings); + 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")); - const window = mapTimeWindow(view); - const catalog = mapVehicleClassIndex(data); - const users = mapTrackUsers(data, window); - const playerLabels = new Map(); - for (const player of data.players) { const key = textField(player, "steamId", "gamePlayerId", "id"); const label = textField(player, "displayName", "name", "playerName"); if (key && label) playerLabels.set(key, label); } - const tracks = mapTracks(data, bounds, window, view.mapUsers, catalog); - const span = mapPlaybackSpan(window, tracks); - mapPlaybackFrame = { tracks, span }; + view.mapPlaybackFrameRef.current = { tracks, span }; const cursor = playbackCursor(span, view.mapPlayback); const zoom = view.mapView.zoom; - const visible = points.filter((point) => view.mapLayers[layerOf(point)]); - const hovered = visible.find((point, index) => idOf(point, `point-${index}`) === view.hoverMapPoint); - const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0]; - const selectedTrails = selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : []; - const selectedLocks = selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : []; + 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)))), @@ -648,24 +777,21 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: 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)))), - e("span", { className: "page-status scum-map-summary" }, `${visible.length} / ${points.length} 个点 · ${tracks.length} 条轨迹 · ${window.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; const originX = mapDragState.originX; const originY = mapDragState.originY; view.setMapView((previous) => ({ ...previous, x: originX + deltaX, y: originY + deltaY })); }, + 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.setMapView((previous) => zoomMapView(previous, 1.6, 0, 0)); } }, + 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) }), - mapGridOverlay(e), - e("svg", { className: "map-track-layer", viewBox: `0 0 ${scumMapSize} ${scumMapSize}`, preserveAspectRatio: "none", "aria-hidden": "true" }, mapTrackShapes(e, tracks, cursor, 18 / zoom)), - mapTrackMarkers(e, tracks, cursor, catalog, view.mapPlayback.playing), - visible.map((point, index) => mapPointMarker(e, point, index, bounds, view, data)), - hovered ? mapHoverCard(e, hovered, data, mapPointStyle(hovered, bounds)) : null + view.mapSceneContents ), - e("div", { className: "map-view-controls" }, + 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)) }, "复位"), @@ -673,27 +799,27 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: e("span", { className: "map-zoom-badge" }, `${zoom.toFixed(2)}×`) ) ), - mapPlaybackBar(e, view, span, tracks, tracks.length > 0), mapTrackLegend(e, view, tracks, playerLabels), - selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null + 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" ? textField(point, "riddenVehicleId", "gameVehicleId") : ""; - const icon = layer === "vehicles" || ride ? vehicleIconFor(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 ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onPointerEnter: open, onPointerLeave: close, onFocus: open, onBlur: close, onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, + 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. -// Every row is driven by data the platform already returns, so a missing field drops the row instead -// of printing a placeholder. +// 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) { const layer = layerOf(point); const rows: Array<[string, string]> = []; @@ -703,19 +829,23 @@ function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCU push("用户", textField(point, "displayName", "name", "label") || pointTitle(point)); push("SteamID", textField(point, "steamId", "gamePlayerId", "subjectId")); push("队伍", mapSquadLabel(data, textField(point, "squadId", "squadName"))); - push("状态", boolField(point, "online") ? "在线" : "离线"); + 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("耐久", vehicleDurabilityLabel(point) || "未同步"); push("车锁", vehicleLockLabel(point, data)); pushDate("最后观测", "vehicleObservedAt", "lastObservedAt", "observedAt", "updatedAt"); } else if (layer === "flags") { push("领地旗", textField(point, "name", "label", "flagId", "id") || "领地旗"); - push("归属", mapOwnerLabel(data, point)); - push("队伍", mapSquadLabel(data, textField(point, "ownerSquadId", "squadId"))); + push("归属", 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)); @@ -723,7 +853,7 @@ function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCU push("来源", textField(point, "source")); } if (hasCoordinates(point)) push("坐标", coords(point)); - return e("div", { className: `map-hover-card map-hover-${layer}`, role: "tooltip", style: pointStyle }, + 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))) ); @@ -732,17 +862,27 @@ function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCU function mapSquadLabel(data: SCUMSurfaceData, value: string): string { if (!value) return ""; const squad = data.squads.find((row) => textField(row, "squadId", "id", "_recordKey") === value); - return squad ? `${textField(squad, "name", "label") || value}(${value})` : value; + 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 steamId = textField(point, "ownerSteamId", "steamId", "ownerProfileId", "ownerId"); - if (!steamId) return ""; - const owner = data.players.find((player) => [textField(player, "steamId"), textField(player, "gamePlayerId"), textField(player, "id")].includes(steamId)); - return owner ? `${textField(owner, "displayName", "name") || steamId}(${steamId})` : steamId; + 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) { @@ -756,6 +896,8 @@ function vehicleLockLabel(point: RecordMap, data: SCUMSurfaceData): string { } 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")}` : ""}` : ""; } @@ -768,17 +910,20 @@ function vehicleFunctionalLabel(point: RecordMap): string { function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>, labels: Map) { const selected = view.mapUsers; const rows = [...users, ...(selected ?? []).filter((key) => !users.some((user) => user.key === key)).map((key) => ({ key, label: labels.get(key) || key, count: 0 }))]; - const summary = selected === undefined ? "全部" : selected.length ? `${selected.length}/${rows.length}` : "未选"; - return e("details", { className: "scum-map-menu", "aria-label": "轨迹用户筛选" }, - e("summary", { className: "icon-command" }, `用户 ${summary}`), - e("div", { className: "scum-map-menu-body" }, - e("div", { className: "console-row-actions" }, - e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(users.map((user) => user.key)) }, "全选"), - e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers([]) }, "清空"), - e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(undefined) }, "不限") - ), - e("p", { className: "page-status" }, "列出所选起止时间内有轨迹的用户,颜色与地图轨迹一致。"), - rows.length ? rows.map((user) => e("label", { key: user.key, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: selected === undefined || selected.includes(user.key), onChange: (event: InputEvent) => view.setMapUsers(toggleMapUser(selected, user.key, Boolean(event.target?.checked), users)) }), e("span", { className: "map-track-swatch", style: { background: mapTrackColor(user.key) } }), `${user.label} · ${user.count} 点`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。") + 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" }, "当前时间范围内没有用户轨迹。") + ) ) ); } @@ -799,12 +944,15 @@ function mapTrackLegend(e: ReactLike["createElement"], view: ViewState, tracks: function mapLayerMenu(e: ReactLike["createElement"], view: ViewState, zonesOverlay: boolean, onZones: (next: boolean) => void) { const layers = ["players", "vehicles", "flags", "regions", "other"] as MapLayer[]; const enabled = layers.filter((layer) => view.mapLayers[layer]).length; - return e("details", { className: "scum-map-menu", "aria-label": "地图图层筛选" }, - e("summary", { className: "icon-command" }, `图层 ${enabled}/${layers.length}${zonesOverlay ? " + 建筑区" : ""}`), - e("div", { className: "scum-map-menu-body" }, - layers.map((layer) => e("label", { key: layer, className: "scum-layer-toggle", title: layer === "regions" ? "区域来自平台 scum_map_regions 记录,例如安全区或活动区域" : undefined }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer))), - e("label", { key: "zones", className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: zonesOverlay, onChange: (event: InputEvent) => onZones(Boolean(event.target?.checked)) }), "建筑区"), - e("p", { className: "page-status" }, "区域 = 平台 scum_map_regions 里的地图区域记录(安全区 / 活动区域),没有写入记录时该图层为空。") + 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 里的地图区域记录(安全区 / 活动区域),没有写入记录时该图层为空。") + ) ) ); } @@ -814,86 +962,94 @@ function persistMapLayers(view: ViewState, actions: SCUMWorkspaceActions | undef 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) { +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 = () => { - const value = view.mapPlayback.value >= 1 ? 0 : view.mapPlayback.value; + 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); - if (value === 0) { const camera = playbackStartView(tracks, view.mapViewportRef.current); if (camera) view.setMapView(camera); } + 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" }, playing ? "回放 播放中" : "回放"), + e("summary", { className: "icon-command" }, "回放设置"), e("div", { className: "scum-map-menu-body" }, - e("p", { className: "page-status" }, Number.isFinite(cursor) ? `回放位置 ${timeLabel(cursor)}` : "回放位置 全部"), - e("div", { className: "console-row-actions" }, - e("button", { type: "button", className: playing ? "primary-command" : "icon-command", disabled: !enabled, "aria-label": playing ? "暂停轨迹回放" : "播放轨迹回放", onClick: () => { if (playing) view.setMapPlayback(rebaseMapPlayback(view.mapPlayback, { playing: false })); else start(); } }, playing ? "暂停" : "开始"), - e("button", { type: "button", className: "icon-command", disabled: !view.mapPlayback.playing && view.mapPlayback.value >= 1, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "回到最新") - ), labeledField(e, "速度", e("select", { value: String(view.mapPlayback.speed), "aria-label": "轨迹回放速度", onChange: (event: InputEvent) => view.setMapPlayback((previous) => rebaseMapPlayback(previous, { speed: Number(inputValue(event)) || 1 })) }, [1, 2, 4].map((speed) => e("option", { key: speed, value: String(speed) }, `${speed}x`)))), - e("p", { className: "page-status" }, tracks.filter((track) => track.kind === "player").length === 1 ? "单人回放:镜头跟随并放到最大比例。" : "多人回放:镜头框住全部所选用户。") + 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[], cursor: number, stroke: number) { +function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], segments: Map, cursor: number) { const shapes: unknown[] = []; - for (const track of tracks) for (const segment of mapTrackSegments(track)) { + 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.length}`, points: points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" "), fill: "none", stroke: track.color, strokeWidth: segment.mode === "ride" ? stroke * 1.2 : stroke, strokeLinecap: "round", strokeLinejoin: "round", strokeDasharray: segment.mode === "walk" ? `${stroke * 2.4} ${stroke * 1.6}` : undefined, opacity: segment.mode === "ride" ? 0.95 : 0.75 })); + 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, playing: boolean) { - const markers: unknown[] = []; - for (const track of tracks) { - const points = track.points.filter((point) => point.time <= cursor); - if (!points.length) continue; - if (Number.isFinite(cursor)) { - const position = mapTrackPositionAt(track, cursor); - if (position) markers.push(mapLiveMarker(e, track, position, catalog, playing)); - continue; - } - const first = points[0]; - const last = points[points.length - 1]; - markers.push(e("span", { key: `${track.key}:start`, className: `map-track-endpoint map-track-start map-layer-${track.kind === "player" ? "players" : "vehicles"}`, style: mapSceneStyle(first), title: `${track.label} 起点 ${timeLabel(first.time)}` }, "起")); - if (last !== first) markers.push(e("span", { key: `${track.key}:end`, className: "map-track-endpoint map-track-end", style: mapSceneStyle(last), title: `${track.label} 最新 ${timeLabel(last.time)}` }, "终")); - let placed = 0; - for (const segment of mapTrackSegments(track)) { - if (placed >= 4 || segment.mode !== "ride" || segment.points.length < 2) continue; - const visible = segment.points.filter((point) => point.time <= cursor); - if (!visible.length) continue; - const middle = visible[Math.floor(visible.length / 2)]; - const code = middle.rideCode || mapRideCode(middle.row) || track.label; - const icon = trajectoryVehicleIcon(middle.row) || vehicleIconByCode(catalog.get(code) ?? code); - placed += 1; - markers.push(e("span", { key: `${segment.key}:ride`, className: "map-ride-marker", style: mapSceneStyle(middle), title: `${track.label} 乘坐 ${code}` }, icon ? e("img", { src: icon, alt: "", loading: "lazy", decoding: "async" }) : "车")); - } - } - return markers; +function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map, 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, playing: boolean) { +function mapLiveMarker(e: ReactLike["createElement"], track: MapTrack, point: MapTrackPoint, catalog: Map, active: boolean, view: ViewState) { const rideCode = point.rideCode; const icon = rideCode ? vehicleIconByCode(catalog.get(rideCode) ?? rideCode) : ""; - const label = rideCode ? `${track.label} · ${scumVehicleEntry(rideCode)?.name || catalog.get(rideCode) || "载具"}` : track.label; - const className = ["map-track-live", playing ? "is-playing" : "", rideCode ? "map-track-live-riding" : "", track.kind === "vehicle" ? "map-track-live-vehicle" : ""].filter(Boolean).join(" "); - return e("span", { key: `${track.key}:live`, className, style: { ...mapSceneStyle(point), "--track-color": track.color }, title: `${label} ${timeLabel(point.time)}` }, + 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 { return { left: `${Math.max(0, Math.min(100, point.x / scumMapSize * 100))}%`, top: `${Math.max(0, Math.min(100, point.y / scumMapSize * 100))}%` }; } -function mapTimeWindow(view: ViewState): MapTimeWindow { +function mapTimeWindow(view: Pick): MapTimeWindow { const from = parseTimeInput(view.mapTimeFrom); const to = parseTimeInput(view.mapTimeTo); if (from === undefined && to === undefined) return { from: -Infinity, to: Infinity, label: "全部时段" }; @@ -928,25 +1084,20 @@ function applyMapTimePreset(setPreset: StateSetter, setFrom: StateSetter function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, users: string[] | undefined, catalog: Map): MapTrack[] { const grouped = new Map(); const windowRows = data.trajectories.filter((row) => { const time = trajectoryOrder(row); return time >= window.from && time <= window.to; }); - const rideSamples = mapRideSamples(data, bounds); - const riddenVehicles = new Set(); - if (users !== undefined) for (const row of windowRows) { - if (layerOf(row) === "vehicles" || !users.includes(mapPlayerTrackIdentity(row))) continue; - const vehicle = playerRide(row, rideSamples, bounds)?.key ?? ""; - if (vehicle) riddenVehicles.add(vehicle); - } for (const row of windowRows) { + if (layerOf(row) !== "players") continue; const position = positionOf(row); if (!hasCoordinates(position)) continue; - const kind: "player" | "vehicle" = layerOf(row) === "vehicles" ? "vehicle" : "player"; - const identity = kind === "player" ? mapPlayerTrackIdentity(row) : textField(row, "gameVehicleId", "vehicleId", "subjectId", "id"); + const kind = "player"; + const identity = mapPlayerTrackIdentity(row); if (!identity) continue; - if (kind === "player" ? users !== undefined && !users.includes(identity) : users !== undefined && !riddenVehicles.has(identity)) continue; + if (users !== undefined && !users.includes(identity)) continue; const time = trajectoryOrder(row); const scene = mapScenePoint(row, bounds); - const rideCode = kind === "player" ? playerRideCode(row, rideSamples, bounds) : mapRideCode(row); + // 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: (kind === "player" ? textField(row, "displayName", "name") : textField(row, "label", "name", "className")) || identity, color: mapTrackColor(identity), points: [], walk: 0, ride: 0, from: time, to: time, vehicles: [] }; + 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); @@ -956,29 +1107,18 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind const tracks = [...grouped.values()]; for (const track of tracks) { track.points.sort((left, right) => left.time - right.time); - if (track.points.length > scumMapTrackPointLimit) track.points = track.points.slice(-scumMapTrackPointLimit); + track.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); } - return tracks.sort((left, right) => right.to - left.to).slice(0, scumMapTrackLimit); + // 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, "riddenVehicleId", "gameVehicleId", "vehicleId"); } - -// Explicit ridden_vehicle_id wins; otherwise the vehicle sample from the same poll window is used. -function playerRide(row: RecordMap, samples: MapRideSample[], bounds: RecordMap): MapRideSample | undefined { - const explicit = mapRiddenVehicleId(row); - if (explicit) return { key: explicit, code: mapRideCode(row), time: trajectoryOrder(row), scene: mapScenePoint(row, bounds) }; - return mapRiddenSample(samples, row, bounds); -} - -function playerRideCode(row: RecordMap, samples: MapRideSample[], bounds: RecordMap): string { - const ride = playerRide(row, samples, bounds); - if (!ride) return ""; - return ride.code || "vehicle"; -} +function 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(); @@ -992,6 +1132,16 @@ function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ ke 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)); } @@ -1005,16 +1155,20 @@ function toggleMapUser(selected: string[] | undefined, key: string, checked: boo function mapTrackSegments(track: MapTrack): MapTrackSegment[] { const segments: MapTrackSegment[] = []; let current: MapTrackPoint[] = []; - let mode: "walk" | "ride" = track.kind === "vehicle" ? "ride" : "walk"; - for (const point of track.points) { - const nextMode: "walk" | "ride" = track.kind === "vehicle" || point.riding ? "ride" : "walk"; - const previous = current[current.length - 1]; - const broken = previous ? nextMode !== mode || point.time - previous.time > scumMapSegmentGapMs || Math.hypot(point.x - previous.x, point.y - previous.y) > scumMapSegmentJump : false; - if (broken) { segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); current = []; } + 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) segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); + if (current.length > 1) segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); return segments; } @@ -1030,14 +1184,13 @@ function mapVehicleClassIndex(data: SCUMSurfaceData): Map { return index; } -function mapRideCode(row: RecordMap): string { return textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId"); } - // Playback covers the recorded samples, not the whole filter window: a 24h window with two hours of // data would otherwise spend most of the run showing an empty map. function mapPlaybackSpan(window: MapTimeWindow, tracks: MapTrack[]): { from: number; to: number } { - const times = tracks.length ? [Math.min(...tracks.map((track) => track.from)), Math.max(...tracks.map((track) => track.to))] : [0, 0]; - if (times[1] > times[0]) return { from: times[0], to: times[1] }; - return { from: Number.isFinite(window.from) ? window.from : times[0], to: Number.isFinite(window.to) ? window.to : times[1] }; + 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 { @@ -1048,65 +1201,33 @@ export function advanceMapPlayback(previous: MapPlaybackState, now = Date.now()) } function rebaseMapPlayback(previous: MapPlaybackState, patch: Partial, 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.value >= 1 ? Infinity : span.from + (span.to - span.from) * playback.value; + 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. -function playbackStartView(tracks: MapTrack[], node: HTMLElement | null): MapViewState | undefined { +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(mapTrackPointList(players), node, 0.8); + if (players.length > 1) return fitMapView(players.map((track) => track.points[0]), node, 0.8); return undefined; } -function playbackFollowView(tracks: MapTrack[], playback: MapPlaybackState, span: { from: number; to: number }, node: HTMLElement | null): MapViewState | undefined { - if (!playback.playing) return undefined; +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); - if (players.length !== 1) return undefined; - const position = mapTrackPositionAt(players[0], playbackCursor(span, playback)); - return position ? focusMapView(position, node, scumMapZoomMax) : undefined; -} - -// The player trajectory table only carries ridden_vehicle_id when the collector fills it. Until then -// every player sample is matched against the vehicle samples recorded in the same poll window, so the -// surrounding vehicle still shows up as a ride marker instead of a walk segment. -function mapRideSamples(data: SCUMSurfaceData, bounds: RecordMap): MapRideSample[] { - const samples: MapRideSample[] = []; - for (const row of data.trajectories) { - if (layerOf(row) !== "vehicles") continue; - const position = positionOf(row); - if (!hasCoordinates(position)) continue; - const time = trajectoryOrder(row); - const key = textField(row, "gameVehicleId", "vehicleId", "subjectId", "id"); - if (!key) continue; - samples.push({ key, code: mapRideCode(row), time, scene: mapScenePoint(row, bounds) }); - } - return samples.sort((left, right) => left.time - right.time); -} - -function mapRiddenSample(samples: MapRideSample[], row: RecordMap, bounds: RecordMap): MapRideSample | undefined { - const position = positionOf(row); - if (!hasCoordinates(position)) return undefined; - const time = trajectoryOrder(row); - const scene = mapScenePoint(row, bounds); - let best: MapRideSample | undefined; - let bestDistance = Infinity; - for (const sample of samples) { - const delta = Math.abs(sample.time - time); - if (delta > scumMapRideWindowMs) continue; - const distance = Math.hypot(sample.scene.x - scene.x, sample.scene.y - scene.y); - if (distance > scumMapRideDistance || distance >= bestDistance) continue; - best = sample; - bestDistance = distance; - } - return best; + 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 { @@ -1117,12 +1238,11 @@ function mapTrackPositionAt(track: MapTrack, cursor: number): MapTrackPoint | un for (let index = 1; index < points.length; index += 1) { const previous = points[index - 1]; const next = points[index]; - if (cursor > next.time) continue; + if (cursor >= next.time) continue; const gap = next.time - previous.time; if (gap <= 0 || gap > scumMapSegmentGapMs) return { ...previous, time: cursor }; const ratio = (cursor - previous.time) / gap; - const riding = ratio < 0.5 ? previous.riding : next.riding; - return { key: `${track.key}:live`, row: riding ? next.row : previous.row, x: previous.x + (next.x - previous.x) * ratio, y: previous.y + (next.y - previous.y) * ratio, time: cursor, riding, rideCode: riding ? (next.rideCode || previous.rideCode) : "" }; + return { ...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]; } @@ -1152,9 +1272,9 @@ function fitMapView(points: MapTrackPoint[], node: HTMLElement | null, padding: if (!points.length) return scumMapDefaultView; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const point of points) { minX = Math.min(minX, point.x); maxX = Math.max(maxX, point.x); minY = Math.min(minY, point.y); maxY = Math.max(maxY, point.y); } - const span = Math.max(48, maxX - minX, maxY - minY); - const zoom = clampMapZoom(scumMapSize * padding / span); const size = Math.max(240, Number(node?.clientHeight) || 640); + 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 }; } @@ -1172,8 +1292,11 @@ function mapTrackColor(identity: string): string { 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[]) { +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, "地图点详情"), @@ -1184,7 +1307,10 @@ function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: e("span", { className: "provider-id" }, `ID ${textField(point, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", { className: "provider-id" }, `来源 ${textField(point, "source") || "平台表"}`), e("span", { className: "provider-id" }, freshness(point)), - layerOf(point) === "vehicles" ? e("span", { className: "provider-id" }, `${textField(point, "className", "vehicleClass", "vehicleType") || "unknown"} · ${textField(point, "status", "state") || vehicleFunctionalLabel(point) || "unknown"} · ${boolField(point, "locked") ? "已上锁" : "未上锁/未知"} · 访问 ${dateField(point, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`) : null), + 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 ); @@ -1230,11 +1356,6 @@ function giftCatalogIcons(gift: RecordMap): string[] { return Array.isArray(items) ? items.flatMap((item) => isRecord(item) ? catalogIconsForValue(textField(item, "catalogCode", "catalogItemKey", "key", "className")) : []).slice(0, 12) : []; } -function trajectoryVehicleIcon(row: RecordMap): string { - const code = textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId"); - return code ? scumVehicleIconUrl(code) || scumVehicleFallbackIconUrl(code) : ""; -} - function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) { const matches = catalogMatches(view.catalogSearch, view.catalogKind); return e("details", { className: "console-module scum-editor scum-catalog-picker", open: view.catalogOpen, onToggle: (event: InputEvent) => view.setCatalogOpen(detailOpen(event)) }, @@ -1287,7 +1408,15 @@ function positionOf(row: RecordMap | undefined): RecordMap | undefined { const n function hasCoordinates(row: RecordMap | undefined): row is RecordMap { return Boolean(row) && Number.isFinite(Number(field(row, "x", "locationX"))) && Number.isFinite(Number(field(row, "y", "locationY"))); } function layerOf(point: RecordMap): MapLayer { const value = textField(point, "layer", "subjectType", "type").toLowerCase(); if (value.includes("player") || value.includes("user")) return "players"; if (value.includes("vehicle")) return "vehicles"; if (value.includes("flag")) return "flags"; if (value.includes("region") || value.includes("zone") || value === "base") return "regions"; return "other"; } function layerLabel(layer: MapLayer): string { return layer === "players" ? "用户" : layer === "vehicles" ? "载具" : layer === "flags" ? "旗帜" : layer === "regions" ? "区域" : "其他"; } -function pointTitle(point: RecordMap): string { return textField(point, "name", "label", "subjectName") || textField(point, "subjectType", "type") || textField(point, "subjectId", "id") || "地图点"; } +function mapVehicleCategory(point: RecordMap): MapVehicleFilter { + const entry = scumVehicleEntry(textField(point, "className", "vehicleClass", "entityClass", "vehicleType", "name", "label")); + const value = [textField(point, "vehicleType", "className", "vehicleClass", "entityClass", "name", "label"), entry?.code || "", entry?.name || ""].join(" ").toLowerCase(); + if (/kinglet|duster|mariner|scout|aircraft|airplane|aeroplane|helicopter|plane|flight|飞行/.test(value)) return "air"; + 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 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); @@ -1306,7 +1435,7 @@ export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record 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))); + 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))); } @@ -1323,15 +1452,13 @@ export function bundledVehicleImageUrl(value: string): string { } function vehicleIconFor(point: RecordMap): string { - const explicit = bundledVehicleImageUrl(textField(point, "imagePath", "image_path")); - if (explicit) return explicit; return vehicleIconByCode(textField(point, "className", "vehicleClass", "entityClass", "vehicleType")); } function vehicleIconByCode(code: string): string { if (!code.trim()) return ""; const normalized = normalizedVehicleClass(code); - return scumVehicleIconUrl(normalized) || scumVehicleFallbackIconUrl(normalized); + return scumMapVehicleIconUrl(normalized); } function vehicleLabelFor(point: RecordMap): string { @@ -1371,6 +1498,8 @@ function giftCommandsSummary(gift: RecordMap): string { const commands = field(g function giftItemsInput(gift: RecordMap): string { const items = field(gift, "items"); return Array.isArray(items) ? items.map((item) => isRecord(item) ? `${textField(item, "catalogCode", "catalogItemKey", "key")}:${numField(item, "quantity")}` : "").filter(Boolean).join(", ") : ""; } function giftCommandsInput(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) ? commands.map((item) => isRecord(item) ? textField(item, "command", "value") : String(item)).filter(Boolean).join("\n") : ""; } function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record)[value] ?? value; } +function giftAudienceLabel(value: string): string { return (({ all: "全部玩家", pve: "PVE 玩家", pvp: "PVP 玩家" } as Record)[value] ?? value) || "全部玩家"; } +function giftStatusLabel(value: string): string { return (({ active: "启用", enabled: "启用", running: "启用", scheduled: "启用", queued: "启用", disabled: "停用", inactive: "停用" } as Record)[value] ?? value) || "启用"; } function integerInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : fallback; } function minimumIntegerInput(value: string, minimum: number, label: string): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error(`${label}必须是不小于 ${minimum} 的整数。`); return parsed; } function numberInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } diff --git a/plugins/examples/scum-server-plugin/features/scum-catalog.ts b/plugins/examples/scum-server-plugin/features/scum-catalog.ts index 4211a45..6a5563e 100644 --- a/plugins/examples/scum-server-plugin/features/scum-catalog.ts +++ b/plugins/examples/scum-server-plugin/features/scum-catalog.ts @@ -5155,8 +5155,10 @@ export function vehicleAliasKeys(code: string): string[] { } const vehicleIconAliases: Record = {}; +const vehicleEntriesByIcon = new Map(); for (const entry of scumCatalogEntries) { if (entry.kind !== "vehicle" || !entry.icon) continue; + if (!vehicleEntriesByIcon.has(entry.icon)) vehicleEntriesByIcon.set(entry.icon, entry); for (const key of vehicleAliasKeys(entry.code)) vehicleIconAliases[key] = entry.icon; } @@ -5239,7 +5241,7 @@ export function scumVehicleEntry(vehicleCode: string): ScumCatalogEntry | undefi for (const key of vehicleAliasKeys(vehicleCode)) { const icon = vehicleIconAliases[key]; if (!icon) continue; - const entry = scumCatalogEntries.find((value) => value.kind === "vehicle" && value.icon === icon); + const entry = vehicleEntriesByIcon.get(icon); if (entry) return entry; } return scumCatalogEntryFor(vehicleCode); diff --git a/plugins/tests/scum-catalog.test.ts b/plugins/tests/scum-catalog.test.ts index 9c6ea09..864e304 100644 --- a/plugins/tests/scum-catalog.test.ts +++ b/plugins/tests/scum-catalog.test.ts @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { scumCatalogEntries, scumCatalogIconUrl, scumVehicleEntry, scumVehicleFallbackIconUrl, scumVehicleIconUrl } from "../examples/scum-server-plugin/features/scum-catalog.js"; +import { scumMapVehicleIconUrl } from "../examples/scum-server-plugin/features/map-vehicle-icons.js"; const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin"); const catalogSource = readFileSync(join(pluginRoot, "features/scum-catalog.ts"), "utf8"); @@ -35,4 +36,14 @@ describe("scum catalog icons", () => { expect(scumVehicleFallbackIconUrl("Quad_01_D_ES")).toContain("data:image/svg+xml"); expect(decodeURIComponent(scumVehicleFallbackIconUrl("Dinghy_ES"))).toContain(" { + for (const code of ["Rager_ES", "Laika_ES", "BPC_Laika_C", "Dinghy_ES", "Tractor_ES"]) { + const icon = scumMapVehicleIconUrl(code); + expect(icon).toContain("/map/vehicles/"); + expect(existsSync(fileURLToPath(icon))).toBe(true); + expect(readFileSync(fileURLToPath(icon)).length).toBeLessThan(4000); + } + expect(scumVehicleIconUrl("Laika_ES")).toContain("scum-catalog"); + }); }); diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index b4df7e1..f524307 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, parseGiftCommands, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; -import { advanceMapPlayback, bundledVehicleImageUrl, collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js"; +import { advanceMapPlayback, bundledVehicleImageUrl, collectMapPoints, mapPointStyle, playbackStartView, playbackFollowView } from "../examples/scum-server-plugin/features/page.js"; +import type { MapTrack, MapTrackPoint } from "../examples/scum-server-plugin/features/contracts.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js"; @@ -124,6 +125,7 @@ describe("SCUM plugin feature module", () => { const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); const dispatch = vi.fn>(async () => ({ status: "queued" })); await expect(loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions(), dispatch }, "live-map")).resolves.toMatchObject({ players: expect.any(Array), vehicles: expect.any(Array) }); + expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.mapPoints, scumCollections.mapRegions, scumCollections.mapSettings, scumCollections.flags, scumCollections.squads, scumCollections.members, scumCollections.tradeGoods]); expect(dispatch).not.toHaveBeenCalled(); }); @@ -272,8 +274,12 @@ describe("SCUM plugin feature module", () => { expect(definitions.texts).toContain("物品列表"); expect(definitions.texts).toContain("Starter Pack"); expect(definitions.texts).toContain("Cargo Drop"); - expect(definitions.buttons.find((button) => button.label === "保存礼包")?.disabled).toBe(false); - expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["礼包周期", "适用玩家", "发放次数", "成就类型", "成就值", "礼包物品", "礼包命令"])); + expect(definitions.texts).toEqual(expect.arrayContaining(["礼包", "领取规则", "礼包内容", "次数 / 门槛", "状态", "最近更新", "操作"])); + expect(definitions.buttons.find((button) => button.label === "新建礼包")?.disabled).toBe(false); + expect(definitions.buttons.find((button) => button.label === "保存礼包")).toBeUndefined(); + expect(definitions.texts).not.toContain("先定义规则,再配置内容"); + expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["搜索礼包", "礼包状态"])); + expect(definitions.inputs.map((input) => input.label)).not.toContain("礼包周期"); const claims = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "claims" }); expect(claims.texts).toContain("领取记录"); expect(claims.texts).toContain("claimed"); @@ -318,7 +324,7 @@ describe("SCUM plugin feature module", () => { expect(view.nodes).toContain("details:轨迹回放"); expect(view.texts).not.toContain("轨迹列表"); expect(view.elements.map((element) => element.label)).toContain("轨迹颜色图例"); - expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["全选", "清空", "不限", "开始", "回到最新"])); + expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["全选", "清空", "时段内全部", "开始回放", "从头播放", "查看完整轨迹"])); expect(pageSource).toContain('new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href'); expect(pageSource).toContain("mapBoardStyle(baseLayer, zonesOverlay)"); expect(pageSource).toContain("map-hover-card"); @@ -335,14 +341,14 @@ describe("SCUM plugin feature module", () => { const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: riding, pageQuery: mapTestWindow }); expect(view.nodes).toContain("polyline:"); expect(view.texts).toEqual(expect.arrayContaining(["用户 · 4 点 · 步行 2 / 乘车 2"])); - expect(view.elements.filter((element) => element.className.includes("map-ride-marker")).length).toBeGreaterThan(0); - expect(view.elements.filter((element) => element.className.includes("map-track-endpoint")).length).toBeGreaterThan(1); + expect(view.elements.filter((element) => element.className.includes("map-track-live-riding"))).toHaveLength(1); + expect(view.elements.filter((element) => element.className.includes("map-track-endpoint"))).toHaveLength(0); expect(view.texts).toContain("Mira · 4 点"); }); it("keeps the extracted icon catalog closed until an editor asks for it", () => { const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" }); - expect(gifts.texts).toContain("从 SCUM 目录选择礼包物品"); + expect(gifts.texts).not.toContain("从 SCUM 目录选择礼包物品"); expect(gifts.inputs.map((input) => input.label)).not.toContain("搜索礼包物品"); const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" }); expect(workflows.texts).toContain("从 SCUM 目录选择物品"); @@ -358,7 +364,7 @@ describe("SCUM plugin feature module", () => { expect(pageSource).toContain("scumMapDefaultWindowMs"); }); - it("keeps only the selected user's tracks and the vehicles that user rode", () => { + it("draws one selected user route with the ridden vehicle on the same marker", () => { const data: SCUMSurfaceData = { ...surfaceData, trajectories: [ { subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, riddenVehicleId: "veh-1", gameVehicleId: "veh-1", sampledAt: "2026-08-10T00:00:01Z" }, { subjectType: "player", subjectId: "76561198000000009", steamId: "76561198000000009", displayName: "Nova", x: 30, y: 40, z: 3, sampledAt: "2026-08-10T00:00:02Z" }, @@ -367,15 +373,16 @@ describe("SCUM plugin feature module", () => { ] }; const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data, pageQuery: { ...mapTestWindow, focus: "76561198000000001" } }); expect(view.texts).toContain("Mira"); - expect(view.texts).toContain("Laika"); + expect(view.elements.filter((element) => element.className.includes("map-track-live")).map((element) => element.label)).toEqual(expect.arrayContaining(["Mira · Laika"])); expect(view.texts).not.toContain("Nova"); expect(view.texts).not.toContain("WolfsWagen"); const all = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data, pageQuery: mapTestWindow }); - expect(all.texts).toEqual(expect.arrayContaining(["Mira", "Nova", "Laika", "WolfsWagen"])); + expect(all.texts).toEqual(expect.arrayContaining(["Mira", "Nova"])); + expect(all.elements.filter((element) => element.className.includes("map-track-chip"))).toHaveLength(2); }); it("loads the vehicle icon for trajectory rows and gift items", () => { - expect(pageSource).toContain("trajectoryVehicleIcon(middle.row)"); + expect(pageSource.includes("scumMapVehicleIconUrl(normalized)")).toBe(true); expect(pageSource).toContain("catalogIconStrip(e, giftCatalogIcons(gift))"); }); @@ -389,7 +396,7 @@ describe("SCUM plugin feature module", () => { const pluginData = pluginDataActions(); await saveMapSettings({ pluginData }, { customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 }); expect(pluginData.put).toHaveBeenCalledWith(scumCollections.mapSettings, "current", expect.objectContaining(bounds)); - expect(pageSource).toContain('textField(point, "imagePath", "image_path")'); + expect(pageSource.includes('bundledVehicleImageUrl(textField(point, "imagePath"')).toBe(false); expect(pageSource).toContain('"className", "vehicleClass", "entityClass", "vehicleType"'); expect(pageSource).not.toContain("visible.slice(0, 240)"); }); @@ -434,6 +441,38 @@ describe("SCUM plugin feature module", () => { const paused = advanceMapPlayback({ playing: false, value: 0.3, speed: 1, startedAt: 0, startValue: 0 }, 900_000); expect(paused.value).toBeCloseTo(0.3, 6); }); + + it("fits playback from selected users' first samples", () => { + const point = (key: string, x: number, y: number, time: number): MapTrackPoint => ({ key, row: {}, x, y, time, riding: false, rideCode: "" }); + const track = (key: string, points: MapTrackPoint[]): MapTrack => ({ key, kind: "player", label: key, color: "#fff", points, walk: points.length, ride: 0, from: points[0].time, to: points[points.length - 1].time, vehicles: [] }); + expect(playbackStartView([track("one", [point("one:1", 300, 500, 1)])], null)?.zoom).toBe(8); + const diverging = [track("a", [point("a:1", 300, 500, 0), point("a:2", 0, 0, 1000)]), track("b", [point("b:1", 520, 680, 0), point("b:2", 4096, 4096, 1000)])]; + expect(playbackStartView(diverging, null)?.zoom).toBe(8); + expect(playbackFollowView(diverging, { playing: true, value: 0.5, speed: 1, startedAt: 0, startValue: 0 }, { from: 0, to: 1000 }, null)?.zoom).toBeLessThan(2); + expect(playbackFollowView(diverging, { playing: false, value: 1, speed: 1, startedAt: 0, startValue: 0 }, { from: 0, to: 1000 }, null)?.zoom).toBe(1); + }); + + it("does not infer riding from a nearby vehicle sample", () => { + const view = renderAndCollect({ pageKey: "live-map", pageQuery: { ...mapTestWindow, focus: "76561198000000001" } }); + expect(view.texts).toContain("用户 · 1 点 · 步行 1 / 乘车 0"); + expect(view.elements.some((element) => element.className.includes("map-ride-marker"))).toBe(false); + expect(view.elements.filter((element) => element.className.includes("map-track-chip"))).toHaveLength(1); + }); + + it("shows flag ownership and attached squad details in the selected point strip", () => { + const flag = { flagId: "flag-1", name: "Wolves Flag", ownerPlayerId: "76561198000000001", squadId: "squad-1", status: "active", layer: "flags", subjectId: "flag-1", x: 100, y: 80, z: 0 }; + const data: SCUMSurfaceData = { ...surfaceData, mapPoints: [flag], mapRegions: [], vehicles: [], vehicleLocks: [], trajectories: [], flags: [] }; + const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data }); + expect(view.texts).toContain("归属 Mira(76561198000000001) · 附属队伍 Wolves(squad-1 · 1 人)"); + const unattachedFlag = { ...flag, ownerPlayerId: "", squadId: "" }; + const unattached = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...data, mapPoints: [unattachedFlag] } }); + expect(unattached.texts).toContain("归属 未同步 · 附属队伍 无附属队伍"); + }); + + it("shows current vehicle durability and treats an unlocked vehicle as unlocked even with lock history", () => { + const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...surfaceData, players: [], mapPoints: [], flags: [], mapRegions: [], trajectories: [], vehicles: [{ ...surfaceData.vehicles[0], locked: false, healthPercent: 72 }], vehicleLocks: surfaceData.vehicleLocks } }); + expect(view.texts).toContain("载具 Laika · 耐久 72% · 未上锁"); + }); }); function pluginDataActions(overrides: Partial<{ list: (collection: string, key?: string) => Promise }> = {}) {