From 5f2bac328041a5d53d1d8c661798410bf5586ce9 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Sun, 20 Sep 2026 16:46:42 +0800 Subject: [PATCH] fix: support mixed SCUM gift catalog entries --- .../scum-config-v57/gift-items.json | 5 +++- .../scum-server-plugin/features/page-data.ts | 12 +++++++- .../scum-server-plugin/features/page.ts | 16 +++++------ plugins/tests/scum-feature-module.test.ts | 28 +++++++++++++++++-- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-config-v57/gift-items.json b/plugins/examples/scum-server-plugin/data-packs/scum-config-v57/gift-items.json index af69629..b170413 100644 --- a/plugins/examples/scum-server-plugin/data-packs/scum-config-v57/gift-items.json +++ b/plugins/examples/scum-server-plugin/data-packs/scum-config-v57/gift-items.json @@ -21,6 +21,9 @@ ], "deliveryCommands": [ { "kind": "item", "template": "#SpawnItem {itemCode} {quantity}" }, - { "kind": "vehicle", "template": "#SpawnVehicle {vehicleCode}" } + { "kind": "vehicle", "template": "#SpawnVehicle {vehicleCode}" }, + { "kind": "animal", "template": "#SpawnAnimal {entityCode}" }, + { "kind": "zombie", "template": "#SpawnZombie {entityCode}" }, + { "kind": "armed-npc", "template": "#SpawnArmedNPC {entityCode}" } ] } diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index f17a544..9f581f8 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -1,3 +1,5 @@ +import { scumCatalogEntryFor } from "./scum-catalog.js"; + export type RecordMap = Record; export type PluginDataMutation = { operation: "put" | "delete"; key: string; value?: RecordMap }; @@ -160,7 +162,7 @@ export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: Rec if (!items.length && !operations.length) throw new Error("礼包必须包含物品或命令。"); const now = Date.now(); const grantId = safeCommandId(`gift:${giftCode}:${playerId}:${now}`); - const commands = [...items.map((item) => `#SpawnItem ${item.catalogCode} ${item.quantity}`), ...operations].map(normalizeRCONCommand); + const commands = [...giftCatalogCommands(items), ...operations].map(normalizeRCONCommand); const results = []; for (const [index, command] of commands.entries()) { results.push(await dispatchSCUMRCONCommand(actions, command, `${grantId}:${index + 1}`)); @@ -216,6 +218,14 @@ export function parseGiftItems(input: string): Array<{ catalogCode: string; quan return items; } +function giftCatalogCommands(items: Array<{ catalogCode: string; quantity: number }>): string[] { + return items.flatMap((item) => { + const entry = scumCatalogEntryFor(item.catalogCode); + if (!entry || entry.kind === "item") return [`#SpawnItem ${item.catalogCode} ${item.quantity}`]; + return Array.from({ length: item.quantity }, () => entry.command); + }); +} + export function parseGiftCommands(input: string): Array<{ command: string }> { return input.split(/\r?\n/).map((command) => command.trim()).filter(Boolean).map((command) => ({ command })); } diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index f454926..8ee95aa 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -39,7 +39,7 @@ const scumMapBaseLayers: ReadonlyArray = [["ter const scumMapVehicleFilters: ReadonlyArray = [["all", "全部载具"], ["land", "陆地载具"], ["water", "水上载具"], ["air", "飞行类载具"], ["other", "其他载具"]]; const scumFlightVehicleKeys = new Set(["kinglet_duster", "kinglet_mariner", "kinglet_scout"]); const scumCatalogKinds: ReadonlyArray = [["all", "全部"], ["item", "物品"], ["vehicle", "载具"], ["zombie", "丧尸"], ["animal", "动物"], ["armed-npc", "NPC"]]; -const scumGiftCatalogCategories: ReadonlyArray = [["all", "全部物品"], ["weapons", "武器"], ["fishing", "钓鱼"], ["gear", "装备"], ["food", "食物"], ["medical", "医疗"], ["components", "组件"], ["blueprints", "蓝图"], ["tools", "工具"], ["farming", "农业"], ["misc", "其他"]]; +const scumGiftCatalogCategories: ReadonlyArray = [["all", "全部目录"], ["weapons", "武器"], ["fishing", "钓鱼"], ["gear", "装备"], ["food", "食物"], ["medical", "医疗"], ["components", "组件"], ["blueprints", "蓝图"], ["tools", "工具"], ["farming", "农业"], ["misc", "其他"], ["vehicle", "载具"], ["animal", "动物"], ["zombie", "丧尸"], ["armed-npc", "NPC"]]; const scumCatalogPickLimit = 48; const scumMapSize = 4096; const scumSurfaceRefreshMs = 15000; @@ -751,7 +751,7 @@ function giftEditorDialog(e: ReactLike["createElement"], view: ViewState, action 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, "按 SCUM 物品分类筛选;格式为 目录代码:数量")), labeledField(e, "物品清单", e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "例如 Aloe_Vera:2, BakedBeans: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 目录分类筛选(物品、载具、动物、丧尸、NPC);格式为 目录代码:数量")), labeledField(e, "物品清单", e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "例如 Aloe_Vera:2, BPC_Laika:1, BP_Bear2:2", 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.setGiftDialog({ mode: "closed" }) }, "取消"), e("button", { type: "button", className: "confirm-primary", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包")) ) @@ -1365,8 +1365,7 @@ function mapLayerLabel(layer: ScumMapLayer): string { return layer === "topo" ? function catalogMatches(query: string, kind: string, target: CatalogPickTarget): ScumCatalogEntry[] { const search = query.trim().toLowerCase(); return scumCatalogEntries.filter((entry) => { - if (target === "gift-items" && entry.kind !== "item") return false; - const matchesKind = target === "gift-items" ? kind === "all" || scumGiftCatalogCategory(entry) === kind : kind === "all" || entry.kind === kind; + const matchesKind = target === "gift-items" ? kind === "all" || scumGiftCatalogFilterKey(entry) === kind : kind === "all" || entry.kind === kind; return matchesKind && (!search || entry.name.toLowerCase().includes(search) || entry.code.toLowerCase().includes(search) || entry.command.toLowerCase().includes(search)); }).slice(0, scumCatalogPickLimit); } @@ -1399,10 +1398,10 @@ function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: C const activeFilter = filters.some(([key]) => key === view.catalogKind) ? view.catalogKind : "all"; const matches = catalogMatches(view.catalogSearch, activeFilter, target); return e("details", { className: "console-module scum-editor scum-catalog-picker", open: view.catalogOpen, onToggle: (event: InputEvent) => view.setCatalogOpen(detailOpen(event)) }, - e("summary", null, e("strong", null, target === "gift-items" ? "从 SCUM 目录选择礼包物品" : "从 SCUM 目录选择物品"), e("span", { className: "page-status" }, "图标 / 名称 / 代码")), + e("summary", null, e("strong", null, target === "gift-items" ? "从 SCUM 目录选择礼包内容" : "从 SCUM 目录选择物品"), e("span", { className: "page-status" }, "图标 / 名称 / 代码")), view.catalogOpen ? e("div", { className: "scum-catalog-picker-body" }, - e("input", { type: "search", value: view.catalogSearch, "aria-label": target === "gift-items" ? "搜索礼包物品" : "搜索生成物品", placeholder: "名称 / 代码,例如 Laika", onChange: (event: InputEvent) => view.setCatalogSearch(inputValue(event)) }), - e("div", { className: "scum-catalog-kinds", role: "group", "aria-label": target === "gift-items" ? "礼包物品分类" : "目录类型" }, filters.map(([key, label]) => e("button", { key, type: "button", className: activeFilter === key ? "icon-command is-active" : "icon-command", "aria-pressed": activeFilter === key, onClick: () => view.setCatalogKind(key) }, label))), + e("input", { type: "search", value: view.catalogSearch, "aria-label": target === "gift-items" ? "搜索礼包目录" : "搜索生成物品", placeholder: "名称 / 代码,例如 Laika", onChange: (event: InputEvent) => view.setCatalogSearch(inputValue(event)) }), + e("div", { className: "scum-catalog-kinds", role: "group", "aria-label": target === "gift-items" ? "礼包目录分类" : "目录类型" }, filters.map(([key, label]) => e("button", { key, type: "button", className: activeFilter === key ? "icon-command is-active" : "icon-command", "aria-pressed": activeFilter === key, onClick: () => view.setCatalogKind(key) }, label))), e("div", { className: "scum-catalog-grid" }, matches.length ? matches.map((entry) => { const icon = scumCatalogIconUrl(entry.icon); return e("button", { key: entry.code, type: "button", className: "scum-catalog-chip", title: `${entry.name} · ${entry.command}`, onClick: () => target === "gift-items" ? view.setGiftItems(appendCatalogItem(view.giftItems, entry.code)) : view.setProduceTradeGoodsId(entry.code) }, icon ? e("img", { className: "scum-catalog-icon", src: icon, alt: "" }) : e("span", { className: "scum-catalog-icon scum-catalog-icon-empty" }), e("span", { className: "scum-catalog-meta" }, e("strong", null, entry.name), e("span", { className: "provider-id" }, entry.code))); }) : e("p", { className: "page-status" }, "没有匹配的目录条目。")) ) : null ); @@ -1422,8 +1421,9 @@ export function scumGiftCatalogCategory(entry: ScumCatalogEntry): string { return "misc"; } -export function scumCatalogCategoryLabel(entry: ScumCatalogEntry): string { return ({ weapons: "武器", fishing: "钓鱼", gear: "装备", food: "食物", medical: "医疗", components: "组件", blueprints: "蓝图", tools: "工具", farming: "农业", misc: "其他" } as Record)[scumGiftCatalogCategory(entry)] ?? "其他"; } +export function scumGiftCatalogFilterKey(entry: ScumCatalogEntry): string { return entry.kind === "item" ? scumGiftCatalogCategory(entry) : entry.kind; } +export function scumCatalogCategoryLabel(entry: ScumCatalogEntry): string { if (entry.kind === "vehicle") return "载具"; if (entry.kind === "animal") return "动物"; if (entry.kind === "zombie") return "丧尸"; if (entry.kind === "armed-npc") return "NPC"; return ({ weapons: "武器", fishing: "钓鱼", gear: "装备", food: "食物", medical: "医疗", components: "组件", blueprints: "蓝图", tools: "工具", farming: "农业", misc: "其他" } as Record)[scumGiftCatalogCategory(entry)] ?? "其他"; } function labeledField(e: ReactLike["createElement"], label: string, control: unknown) { return e("label", { className: "console-field" }, e("span", null, label), control); } function detailOpen(event: InputEvent): boolean { return Boolean(event.target?.open); } function giftTabButton(e: ReactLike["createElement"], view: ViewState, tab: GiftTab, label: string) { return e("button", { type: "button", role: "tab", "aria-selected": view.giftTab === tab, className: view.giftTab === tab ? "primary-command" : "icon-command", onClick: () => view.setGiftTab(tab) }, label); } diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 609e93b..2782b71 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -5,7 +5,7 @@ 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, mapVehicleCategory, playbackStartView, playbackFollowView, scumCatalogCategoryLabel, scumGiftCatalogCategory, tradeGoodsTypeLabel } from "../examples/scum-server-plugin/features/page.js"; +import { advanceMapPlayback, bundledVehicleImageUrl, collectMapPoints, mapPointStyle, mapVehicleCategory, playbackStartView, playbackFollowView, scumCatalogCategoryLabel, scumGiftCatalogCategory, scumGiftCatalogFilterKey, tradeGoodsTypeLabel } from "../examples/scum-server-plugin/features/page.js"; import { scumCatalogEntries } from "../examples/scum-server-plugin/features/scum-catalog.js"; import type { MapTrack, MapTrackPoint } from "../examples/scum-server-plugin/features/contracts.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; @@ -302,8 +302,12 @@ describe("SCUM plugin feature module", () => { expect(scumGiftCatalogCategory(entry("Animal_Skin"))).toBe("components"); expect(scumGiftCatalogCategory(entry("Air_Pump"))).toBe("tools"); expect(scumGiftCatalogCategory(entry("Backpack_02_01"))).toBe("gear"); - expect(pageSource).toContain("礼包物品分类"); - expect(pageSource).toContain("entry.kind !== \"item\""); + expect(pageSource).toContain("礼包目录分类"); + expect(scumGiftCatalogFilterKey(entry("BPC_Laika"))).toBe("vehicle"); + expect(scumGiftCatalogFilterKey(entry("BP_Bear2"))).toBe("animal"); + expect(scumGiftCatalogFilterKey(entry("BP_Zombie_Hospital_Normal"))).toBe("zombie"); + expect(scumGiftCatalogFilterKey(entry("BP_Drifter_Lvl_1"))).toBe("armed-npc"); + expect(pageSource).not.toContain("entry.kind !== \"item\""); }); it("labels catalog-backed item lists with SCUM categories", () => { @@ -317,6 +321,24 @@ describe("SCUM plugin feature module", () => { expect(tradeGoodsTypeLabel({ code: "unknown-item", typeName: "item" })).toBe("未知类型"); }); + it("uses catalog spawn commands for mixed gift contents", async () => { + const pluginData = pluginDataActions(); + const dispatch = vi.fn>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } })); + await queueGiftDelivery({ pluginData, dispatch }, { code: "mixed-gift", name: "Mixed Gift", items: [ + { catalogCode: "BPC_Laika", quantity: 1 }, + { catalogCode: "BP_Bear2", quantity: 2 }, + { catalogCode: "BP_Zombie_Hospital_Normal", quantity: 1 }, + { catalogCode: "BP_Drifter_Lvl_1", quantity: 1 } + ] }, surfaceData.players[0]); + expect(dispatch.mock.calls.map(([request]) => request.payload?.["input.command"])).toEqual([ + "#spawnvehicle BPC_Laika", + "#spawnanimal BP_Bear2", + "#spawnanimal BP_Bear2", + "#spawnzombie BP_Zombie_Hospital_Normal", + "#SpawnArmedNPC BP_Drifter_Lvl_1" + ]); + }); + it("renders map layers, filter controls, points, and selected-point details", () => { const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" }); expect(view.nodes).toContain("div:SCUM 地图图层");