diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 026002b..f454926 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -39,6 +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 scumCatalogPickLimit = 48; const scumMapSize = 4096; const scumSurfaceRefreshMs = 15000; @@ -750,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, "可用目录选择器快速加入,格式为 目录代码:数量")), 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 物品分类筛选;格式为 目录代码:数量")), 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 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 }, "保存礼包")) ) @@ -1361,9 +1362,13 @@ function mapLayerSetting(settings: RecordMap | undefined): ScumMapLayer { function mapLayerLabel(layer: ScumMapLayer): string { return layer === "topo" ? "等高线" : layer === "night" ? "夜间" : "地形"; } -function catalogMatches(query: string, kind: string): ScumCatalogEntry[] { +function catalogMatches(query: string, kind: string, target: CatalogPickTarget): ScumCatalogEntry[] { const search = query.trim().toLowerCase(); - return scumCatalogEntries.filter((entry) => (kind === "all" || entry.kind === kind) && (!search || entry.name.toLowerCase().includes(search) || entry.code.toLowerCase().includes(search) || entry.command.toLowerCase().includes(search))).slice(0, scumCatalogPickLimit); + 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; + return matchesKind && (!search || entry.name.toLowerCase().includes(search) || entry.code.toLowerCase().includes(search) || entry.command.toLowerCase().includes(search)); + }).slice(0, scumCatalogPickLimit); } function appendCatalogItem(current: string, code: string): string { @@ -1390,17 +1395,35 @@ function giftCatalogIcons(gift: RecordMap): string[] { } function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) { - const matches = catalogMatches(view.catalogSearch, view.catalogKind); + const filters = target === "gift-items" ? scumGiftCatalogCategories : scumCatalogKinds; + 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" }, "图标 / 名称 / 代码")), 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": "目录类型" }, scumCatalogKinds.map(([key, label]) => e("button", { key, type: "button", className: view.catalogKind === key ? "icon-command is-active" : "icon-command", "aria-pressed": view.catalogKind === key, onClick: () => view.setCatalogKind(key) }, label))), + 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 ); } +export function scumGiftCatalogCategory(entry: ScumCatalogEntry): string { + const icon = entry.icon.toLowerCase(); + if (icon.includes("/weapons/") || icon.includes("/crafting/traps/")) return "weapons"; + if (icon.includes("/fishing/")) return "fishing"; + if (icon.includes("/clothes/")) return "gear"; + if (icon.includes("/food/")) return "food"; + if (icon.includes("/medicals/")) return "medical"; + if (icon.includes("/crafting/basebuilding/")) return "blueprints"; + if (icon.includes("/crafting/components/") || icon.includes("/misc/components/")) return "components"; + if (icon.includes("/crafting/tools/") || icon.includes("/misc/tools/")) return "tools"; + if (icon.includes("/farming/")) return "farming"; + return "misc"; +} + +export function scumCatalogCategoryLabel(entry: ScumCatalogEntry): string { 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); } @@ -1410,7 +1433,19 @@ function resettableGiftPanel(e: ReactLike["createElement"], title: string, rows: function itemCatalogPanel(e: ReactLike["createElement"], rows: RecordMap[]) { return tablePanel(e, "物品列表", recentRows(rows, "lastSeenAt", "updatedAt", "createdAt"), (item) => [tradeGoodsName(item), tradeGoodsTypeLabel(item), textField(item, "code", "itemCode", "className") || "unknown", dateField(item, "lastSeenAt", "updatedAt", "createdAt")]); } function tradeGoodsLabel(rows: RecordMap[], code: string): string { const item = tradeGoodsIndex(rows).get(code) ?? tradeGoodsIndex(rows).get(code.replace(/^#spawnvehicle\s+/i, "")); return item ? `${tradeGoodsName(item)} (${code})` : code || "unknown"; } function tradeGoodsName(item: RecordMap): string { return textField(item, "nameCn", "name_cn", "name", "className") || textField(item, "code", "itemCode") || "未命名物品"; } -function tradeGoodsTypeLabel(item: RecordMap): string { return textField(item, "typeName", "type_name") || (textField(item, "type") === "21" || isVehicleCatalogItem(item) ? "其他载具" : "未知类型"); } +export function tradeGoodsTypeLabel(item: RecordMap): string { + const code = textField(item, "code", "itemCode", "className", "spawnCommand"); + const entry = scumCatalogEntryFor(code); + if (entry?.kind === "item") return scumCatalogCategoryLabel(entry); + if (entry?.kind === "vehicle") return "载具"; + if (entry?.kind === "zombie") return "丧尸"; + if (entry?.kind === "animal") return "动物"; + if (entry?.kind === "armed-npc") return "NPC"; + if (isVehicleCatalogItem(item)) return "载具"; + const typeName = textField(item, "typeName", "type_name"); + if (typeName && !/^(?:item|物品|unknown|未知(?:类型)?|undefined|null)$/i.test(typeName.trim())) return typeName; + return textField(item, "type") === "21" || isVehicleCatalogItem(item) ? "其他载具" : "未知类型"; +} function tradeActionLabel(event: RecordMap): string { const value = textField(event, "tradeVerb", "tradeKind", "action").toLowerCase(); return value === "purchased" || value === "purchase" ? "买入" : value === "sold" || value === "sale" ? "卖出" : value || "交易"; } function tradeGoodsIndex(rows: RecordMap[]): Map { const result = new Map(); for (const row of rows) for (const key of [textField(row, "code"), textField(row, "itemCode"), textField(row, "className")].filter(Boolean)) result.set(key, row); return result; } function recentRows(rows: RecordMap[], ...keys: string[]): RecordMap[] { return [...rows].sort((left, right) => rowTime(right, keys) - rowTime(left, keys)).slice(0, 24); } diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 48db05d..609e93b 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, mapVehicleCategory, playbackStartView, playbackFollowView } from "../examples/scum-server-plugin/features/page.js"; +import { advanceMapPlayback, bundledVehicleImageUrl, collectMapPoints, mapPointStyle, mapVehicleCategory, playbackStartView, playbackFollowView, scumCatalogCategoryLabel, scumGiftCatalogCategory, 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"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; @@ -293,6 +294,29 @@ describe("SCUM plugin feature module", () => { expect(details.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["关闭", "编辑礼包"])); }); + it("uses SCUM item categories for gift catalog selection", () => { + const entry = (code: string) => scumCatalogEntries.find((item) => item.code === code)!; + expect(scumGiftCatalogCategory(entry("1H_Cleaver"))).toBe("weapons"); + expect(scumGiftCatalogCategory(entry("Aloe_Vera"))).toBe("food"); + expect(scumGiftCatalogCategory(entry("Aspirin"))).toBe("medical"); + 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\""); + }); + + it("labels catalog-backed item lists with SCUM categories", () => { + const entry = (code: string) => scumCatalogEntries.find((item) => item.code === code)!; + expect(scumCatalogCategoryLabel(entry("Aloe_Vera"))).toBe("食物"); + expect(scumCatalogCategoryLabel(entry("Aspirin"))).toBe("医疗"); + expect(scumCatalogCategoryLabel(entry("1H_Cleaver"))).toBe("武器"); + expect(tradeGoodsTypeLabel({ code: "Aloe_Vera", catalogType: "item" })).toBe("食物"); + expect(tradeGoodsTypeLabel({ code: "Aspirin", catalogType: "item" })).toBe("医疗"); + expect(tradeGoodsTypeLabel({ code: "#spawnvehicle BPC_Laika_C", type: "21" })).toBe("载具"); + expect(tradeGoodsTypeLabel({ code: "unknown-item", typeName: "item" })).toBe("未知类型"); + }); + it("renders map layers, filter controls, points, and selected-point details", () => { const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" }); expect(view.nodes).toContain("div:SCUM 地图图层");