fix: improve SCUM map playback filters

This commit is contained in:
npc0-hue
2026-09-20 10:45:32 +08:00
parent 60d479db71
commit 312804c55e
32 changed files with 770 additions and 293 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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<string, unknown>; 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<string, unknown>[]; settings?: Record<string, unknown>; bounds: Record<string, unknown>; window: MapTimeWindow;
catalog: Map<string, string>; users: Array<{ key: string; label: string; count: number }>; playerLabels: Map<string, string>;
segments: Map<string, MapTrackSegment[]>;
};
export type SCUMFeatureWorkspace = { defaultDirectoryKey?: string; directories?: SCUMLogicalDirectory[]; files?: SCUMLogicalFile[]; configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } };
@@ -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<string, string> = {
"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);
}
@@ -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<PageKey, PluginCollectionSurfaceKey[]> = {
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"]
};
File diff suppressed because it is too large Load Diff
@@ -5155,8 +5155,10 @@ export function vehicleAliasKeys(code: string): string[] {
}
const vehicleIconAliases: Record<string, string> = {};
const vehicleEntriesByIcon = new Map<string, ScumCatalogEntry>();
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);
+11
View File
@@ -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("<svg");
});
it("uses the small map asset for known classes while keeping catalog originals separate", () => {
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");
});
});
+51 -12
View File
@@ -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<NonNullable<SCUMWorkspaceActions["dispatch"]>>(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("归属 Mira76561198000000001 · 附属队伍 Wolvessquad-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<unknown> }> = {}) {