Rebuild SCUM plugin-owned data flow
This commit is contained in:
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
|
||||
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, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, requestSCUMPageQueries, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js";
|
||||
import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, 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 { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.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";
|
||||
@@ -84,34 +84,25 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` });
|
||||
});
|
||||
|
||||
it("merges the latest typed player and online-session snapshots into database users", async () => {
|
||||
it("merges player snapshots only by stable identifiers and ignores name-only online sessions", async () => {
|
||||
const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } });
|
||||
const gameClient = gameClientActions();
|
||||
gameClient.snapshots.mockImplementation(async (query) => query?.type === "players" ? { items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] } : { items: [{ sequence: 3, observedAt: "2026-08-10T00:01:00Z", payload: { sessions: [{ sessionId: "session-1", playerName: "Mira" }] } }] });
|
||||
gameClient.snapshots.mockResolvedValue({ items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] });
|
||||
const data = await loadSCUMSurface({ pluginData, gameClient }, "players");
|
||||
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "online.sessions"]);
|
||||
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:01:00Z" });
|
||||
expect(mergePlayerSnapshots([{ gamePlayerId: "steam-2", displayName: "Noah" }], { items: [] }, { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { sessions: [] } }] })[0]).toMatchObject({ online: false });
|
||||
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players"]);
|
||||
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:00:00Z" });
|
||||
const sameName = mergePlayerSnapshots([{ steamId: "steam-2", displayName: "Noah", online: false }], { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { players: [{ playerId: "steam-3", playerName: "Noah", status: "online" }] } }] });
|
||||
expect(sameName).toHaveLength(2);
|
||||
expect(sameName.find((player) => player.steamId === "steam-2")).toMatchObject({ online: false });
|
||||
expect(dataClientSource).not.toContain('type: "online.sessions"');
|
||||
});
|
||||
|
||||
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
|
||||
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
|
||||
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows");
|
||||
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
|
||||
const dispatch = dispatchAction();
|
||||
await requestSCUMPageQueries({ dispatch }, "workflows");
|
||||
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]);
|
||||
dispatch.mockClear();
|
||||
await requestSCUMPageQueries({ dispatch }, "activity");
|
||||
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]);
|
||||
});
|
||||
|
||||
it("dispatches only declared SQLite query envelopes for machine refresh", async () => {
|
||||
const dispatch = dispatchAction();
|
||||
await requestSCUMPageQueries({ dispatch }, "squads");
|
||||
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.squads", "scum.squad-members", "scum.flags"]);
|
||||
for (const [envelope] of dispatch.mock.calls) expect(envelope).toMatchObject({ action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database" } });
|
||||
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "activity");
|
||||
expect(list.mock.calls.slice(-6).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
|
||||
});
|
||||
|
||||
it("uses transaction, put, and delete for plugin-owned gift data", async () => {
|
||||
@@ -120,7 +111,7 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(parseGiftItems("BP_Cash_01:2, Water-Bottle.01:1")).toEqual([{ catalogCode: "BP_Cash_01", quantity: 2 }, { catalogCode: "Water-Bottle.01", quantity: 1 }]);
|
||||
expect(parseGiftCommands("#announce Hello\n#spawnitem BP_Cash_01 2")).toEqual([{ command: "#announce Hello" }, { command: "#spawnitem BP_Cash_01 2" }]);
|
||||
expect(() => parseGiftItems("cash:0")).toThrow("格式无效");
|
||||
expect(() => parseGiftItems("a:1,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toThrow("最多包含 8 项");
|
||||
expect(parseGiftItems("a:101,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toHaveLength(9);
|
||||
await saveGiftDefinition(actions, { code: "starter", name: "Starter", items: [] });
|
||||
await createGiftDelivery(actions, { id: "delivery-1", giftCode: "starter", playerId: "steam-1" });
|
||||
await deleteGiftDefinition(actions, "starter");
|
||||
@@ -172,12 +163,21 @@ describe("SCUM plugin feature module", () => {
|
||||
}) }));
|
||||
});
|
||||
|
||||
it("keeps positive event duration and counts above the removed arbitrary limits", async () => {
|
||||
const gameClient = gameClientActions();
|
||||
await startEvent({ pluginData: pluginDataActions(), gameClient }, { id: "event-large", name: "Large Event", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]);
|
||||
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({
|
||||
durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004,
|
||||
produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]
|
||||
}) }));
|
||||
});
|
||||
|
||||
it("renders searchable user management from real collection values", () => {
|
||||
const view = renderAndCollect();
|
||||
expect(view.nodes).toContain("section:用户管理");
|
||||
expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步");
|
||||
expect(view.texts).toContain("通用数据/机器动作可用");
|
||||
expect(view.buttons.find((button) => button.label === "同步 SCUM.db")?.disabled).toBe(false);
|
||||
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"]));
|
||||
expect(view.inputs.map((input) => input.label)).toContain("搜索用户");
|
||||
expect(view.texts).toContain("Mira");
|
||||
expect(view.texts.join("\n")).toContain("Steam 76561198000000001");
|
||||
@@ -204,7 +204,7 @@ describe("SCUM plugin feature module", () => {
|
||||
it("renders activity definitions, status filters, runs, and records", () => {
|
||||
const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" });
|
||||
expect(view.inputs.map((input) => input.label)).toContain("活动状态");
|
||||
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
|
||||
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
|
||||
expect(view.texts).toContain("Friday Range");
|
||||
expect(view.texts).toContain("running");
|
||||
expect(view.texts).toContain("最近活动记录");
|
||||
@@ -241,7 +241,7 @@ describe("SCUM plugin feature module", () => {
|
||||
});
|
||||
|
||||
it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => {
|
||||
const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "steam-1", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] };
|
||||
const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "76561198000000001", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] };
|
||||
expect(collectMapPoints(duplicateData)).toHaveLength(264);
|
||||
const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 });
|
||||
expect(bounds).toEqual({ worldMinX: -100000, worldMinY: 100000, worldMaxX: 300000, worldMaxY: 300000 });
|
||||
@@ -256,8 +256,11 @@ describe("SCUM plugin feature module", () => {
|
||||
const source = `${pageSource}\n${dataClientSource}`;
|
||||
for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "SELECT ", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden);
|
||||
expect(source).toContain("pluginData");
|
||||
expect(source).toContain("remote.access.request");
|
||||
expect(source).toContain("input.templateKey");
|
||||
expect(source).not.toContain("remote.access.request");
|
||||
expect(source).not.toContain("input.templateKey");
|
||||
expect(source).not.toContain("requestSCUMPageQueries");
|
||||
expect(pageSource).toContain("setInterval(refresh, 3000)");
|
||||
expect(pageSource).toContain("clearInterval(interval)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,10 +273,6 @@ function pluginDataActions(overrides: Partial<{ list: (collection: string, key?:
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchAction() {
|
||||
return vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ requestId: envelope.requestId, action: envelope.action, status: "queued" }));
|
||||
}
|
||||
|
||||
function gameClientActions() {
|
||||
const queue = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["queue"]>(async () => ({ id: "command-1", state: "pending" }));
|
||||
const get = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["get"]>(async () => ({ id: "command-1", state: "pending" }));
|
||||
@@ -310,7 +309,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
|
||||
return [value, () => undefined];
|
||||
}
|
||||
};
|
||||
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: dispatchAction() };
|
||||
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions() };
|
||||
renderPluginPage(react, {
|
||||
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },
|
||||
|
||||
Reference in New Issue
Block a user