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
+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> }> = {}) {