diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 92e73fc..281187c 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -462,9 +462,11 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .scum-workbench{padding:12px;overflow:visible}.scum-workbench>.console-record-list{gap:8px}.scum-filter-bar{margin:0;padding:0 0 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 68%,transparent)}.scum-filter-bar .console-field{flex:0 1 210px}.scum-filter-bar .console-field:first-child{flex:1 1 320px}.scum-filter-bar .page-status{margin-left:auto}.scum-layer-toggle{display:inline-flex;align-items:center;gap:5px;flex:0 0 auto;min-height:28px;padding:0 8px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:11px;font-weight:760}.scum-filter-bar .scum-layer-toggle{min-height:26px;padding:0 8px;gap:5px;font-size:11px;white-space:nowrap}.scum-filter-bar .scum-layer-toggle input{flex:0 0 auto;width:14px;height:14px;min-width:0;min-height:0;margin:0;accent-color:var(--accent)}.scum-layer-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:0 0 auto}.scum-map-summary{margin-left:auto;white-space:nowrap}.scum-table-wrap{border-color:color-mix(in srgb,var(--line) 72%,transparent);background:linear-gradient(180deg,color-mix(in srgb,var(--surface-solid) 84%,transparent),color-mix(in srgb,var(--surface) 66%,transparent));-webkit-backdrop-filter:blur(8px) saturate(.92);backdrop-filter:blur(8px) saturate(.92);box-shadow:inset 0 1px 0 color-mix(in srgb,var(--crystal-rim) 46%,transparent)}.scum-table-wrap::before,.scum-table-wrap::after{content:none}.scum-user-table{min-width:1080px;table-layout:fixed}.scum-user-table caption{caption-side:top;padding:0 0 8px;text-align:left;color:var(--ink-faint);font-size:12px;font-weight:800}.scum-user-table th,.scum-user-table td{height:38px;padding:6px 10px;border-bottom-color:color-mix(in srgb,var(--line) 64%,transparent);white-space:nowrap;font-size:13px;line-height:1.2}.scum-user-table th{background:color-mix(in srgb,var(--accent-soft) 52%,transparent);color:color-mix(in srgb,var(--ink) 70%,var(--accent-deep))}.scum-user-table tbody tr:hover{background:color-mix(in srgb,var(--accent-soft) 24%,transparent)}.scum-user-table th:nth-child(1),.scum-user-table td:nth-child(1){width:150px}.scum-user-table th:nth-child(2),.scum-user-table td:nth-child(2){width:170px}.scum-user-table th:nth-child(3),.scum-user-table td:nth-child(3){width:76px}.scum-user-table th:nth-child(4),.scum-user-table td:nth-child(4){width:116px}.scum-user-table th:nth-child(5),.scum-user-table td:nth-child(5){width:108px}.scum-user-table th:nth-child(6),.scum-user-table td:nth-child(6){width:108px}.scum-user-table th:nth-child(7),.scum-user-table td:nth-child(7){width:156px}.scum-user-table th:last-child,.scum-user-table td:last-child{width:76px;text-align:right}.scum-user-table td>*{max-width:100%;overflow:hidden;text-overflow:ellipsis}.scum-user-table td.provider-actions-cell{padding-right:8px;overflow:visible}.scum-user-table td.provider-actions-cell>*{overflow:visible}.scum-mono-cell{font-family:var(--font-mono);font-size:12px}.scum-user-table .icon-command{min-height:28px;padding:0 9px}.inline-action-control{position:relative;display:inline-block}.inline-action-control>summary{list-style:none}.inline-action-control>summary::-webkit-details-marker{display:none}.inline-action-control[open]>summary{border-color:var(--accent);color:var(--ink)}.inline-action-control .inline-action-menu{right:0} .gift-definitions-surface{display:grid;gap:12px;min-width:0}.gift-toolbar{margin:0;padding:0 0 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 68%,transparent)}.gift-toolbar .console-field{flex:0 1 190px}.gift-toolbar .console-field:first-child{flex:1 1 320px}.gift-toolbar .page-status{margin-left:auto}.gift-create-command{width:auto;min-width:112px}.gift-table-wrap{min-width:0}.scum-gift-table{min-width:940px;table-layout:fixed}.scum-gift-table th,.scum-gift-table td{height:54px;padding:9px 10px;border-bottom-color:color-mix(in srgb,var(--line) 64%,transparent);vertical-align:middle;font-size:12.5px}.scum-gift-table th{background:color-mix(in srgb,var(--accent-soft) 52%,transparent);color:color-mix(in srgb,var(--ink) 70%,var(--accent-deep));white-space:nowrap}.scum-gift-table tbody tr:hover{background:color-mix(in srgb,var(--accent-soft) 24%,transparent)}.scum-gift-table th:nth-child(1),.scum-gift-table td:nth-child(1){width:175px}.scum-gift-table th:nth-child(2),.scum-gift-table td:nth-child(2){width:330px}.scum-gift-table th:nth-child(3),.scum-gift-table td:nth-child(3){width:220px}.scum-gift-table th:nth-child(4),.scum-gift-table td:nth-child(4){width:150px}.scum-gift-table th:last-child,.scum-gift-table td:last-child{width:150px;text-align:right}.scum-gift-table td>*{max-width:100%;overflow-wrap:anywhere}.scum-gift-table td.provider-actions-cell{padding-right:8px;overflow:visible}.scum-gift-table td.provider-actions-cell>*{overflow:visible}.scum-gift-code{font-family:var(--font-mono);font-size:11px}.scum-gift-content-cell{display:flex;align-items:center;gap:8px;min-width:0}.scum-gift-content-cell>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gift-rule-primary{display:block;color:var(--ink);font-weight:780;line-height:1.35}.gift-status-cell{display:grid;gap:5px;min-width:0}.gift-status-cell .provider-id{white-space:normal}.scum-gift-table-actions{display:inline-flex;align-items:center;justify-content:flex-end;gap:6px}.scum-gift-table-actions .icon-command{min-height:30px;padding:0 9px;white-space:nowrap}.gift-form-wide{grid-column:1/-1}.gift-form-section{display:grid;gap:9px;padding:11px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 64%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}.gift-form-section-title{display:flex;align-items:baseline;justify-content:space-between;gap:10px;flex-wrap:wrap}.gift-form-section-title strong{color:var(--ink);font-size:13px}.gift-form-section-title span{color:var(--ink-faint);font-size:11px}.gift-dialog-heading{display:grid;gap:3px;min-width:0}.gift-dialog-heading h2{margin:0;color:var(--ink);font-size:18px}.gift-dialog-actions{grid-column:1/-1}.gift-dialog-actions .confirm-primary{min-width:128px}.gift-catalog-reference{margin-top:2px} .gift-config-details{display:grid;gap:12px}.gift-config-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0}.gift-config-entry{display:grid;gap:4px;min-width:0;padding:11px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.gift-config-entry dt{color:var(--ink-faint);font-size:11px;font-weight:750}.gift-config-entry dd{margin:0;color:var(--ink);font-size:13px;font-weight:780;overflow-wrap:anywhere}.gift-config-section{min-width:0}.gift-config-list,.gift-command-list{display:grid;gap:6px}.gift-config-row{display:grid;grid-template-columns:auto minmax(120px,1fr) minmax(160px,1.5fr) auto;align-items:center;gap:8px;min-width:0;padding:7px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent-soft) 28%,transparent)}.gift-config-row code,.gift-command-list code{min-width:0;color:var(--ink-soft);font-family:var(--font-mono);font-size:11px;overflow-wrap:anywhere}.gift-command-list code{display:block;padding:8px;border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,transparent)} +.gift-config-row{grid-template-columns:auto minmax(150px,1fr) auto auto auto}.gift-config-item-info,.gift-item-editor-info{display:grid;gap:3px;min-width:0}.gift-config-item-info strong,.gift-item-editor-info strong{color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gift-config-item-info .provider-id,.gift-item-editor-info .provider-id{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gift-item-editor-list{display:grid;gap:6px}.gift-item-editor-row{display:grid;grid-template-columns:auto minmax(150px,1fr) minmax(360px,2fr);align-items:center;gap:10px;min-width:0;padding:8px;border:1px solid var(--line);border-radius:7px;background:color-mix(in srgb,var(--accent-soft) 22%,transparent)}.gift-item-editor-fields{display:grid;grid-template-columns:repeat(3,minmax(80px,1fr)) auto;align-items:end;gap:7px;min-width:0}.gift-item-editor-fields .console-field{min-width:0}.gift-item-editor-fields input{width:100%;min-width:0}.gift-item-editor-remove{align-self:end;white-space:nowrap} :root[data-custom-background=true] .scum-workbench,:root[data-custom-background=true] .scum-table-wrap{background:linear-gradient(180deg,rgba(5,10,16,.76),rgba(5,10,16,.64));border-color:color-mix(in srgb,var(--line-strong) 54%,rgba(255,255,255,.18))}:root[data-custom-background=true][data-theme-palette=magical-girl] .scum-workbench,:root[data-custom-background=true][data-theme-palette=magical-girl] .scum-table-wrap{background:linear-gradient(180deg,rgba(18,19,26,.74),rgba(13,15,22,.64));border-color:rgba(250,240,249,.34)} .scum-workbench.has-management-dialog,:root[data-theme-palette] .scum-workbench.has-management-dialog,:root[data-custom-background=true] .scum-workbench.has-management-dialog{-webkit-backdrop-filter:none;backdrop-filter:none;clip-path:none} @media (max-width:760px){.scum-filter-bar .page-status{margin-left:0}.scum-user-table{min-width:1040px}.gift-config-grid{grid-template-columns:1fr}.gift-config-row{grid-template-columns:auto minmax(0,1fr) auto}.gift-config-row code{grid-column:2/-1}} +@media (max-width:760px){.gift-config-row{grid-template-columns:auto minmax(0,1fr);align-items:start}.gift-config-row>span{font-size:11px}.gift-config-row>span:nth-last-child(-n+3){grid-column:2}.gift-item-editor-row{grid-template-columns:auto minmax(0,1fr)}.gift-item-editor-fields{grid-column:1/-1;grid-template-columns:repeat(3,minmax(0,1fr)) auto}} @media (max-width:760px){.scum-map-bar>.console-field,.scum-map-bar>.scum-map-menu-field,.scum-map-bar>.map-playback-bar{flex:1 1 100%;min-width:0}.map-playback-summary{white-space:normal}} .provider-form input,.provider-form select,.provider-form textarea{width:100%;min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit} .provider-form textarea{min-height:120px;padding:10px;resize:vertical;font-family:var(--font-mono);font-size:13px} diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 9f581f8..34e20ed 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -1,6 +1,9 @@ import { scumCatalogEntryFor } from "./scum-catalog.js"; export type RecordMap = Record; +export type GiftItemDefinition = { catalogCode: string; quantity: number; probability: number; durability?: number }; + +const giftDefinitionFields = ["code", "name", "class", "audience", "number", "achievement", "achievementNumber", "items", "status", "createdAt", "updatedAt"] as const; export type PluginDataMutation = { operation: "put" | "delete"; key: string; value?: RecordMap }; export type PluginDataActions = { @@ -130,7 +133,10 @@ async function loadPlatformSCUMTables(actions: SCUMWorkspaceActions, data: SCUMS export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise { const key = requiredKey(gift, "code", "礼包编号"); - return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]); + const storedGift: RecordMap = {}; + for (const field of giftDefinitionFields) if (gift[field] !== undefined) storedGift[field] = gift[field]; + storedGift.items = parseGiftItems(storedGift.items); + return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: storedGift }]); } export async function deleteGiftDefinition(actions: SCUMWorkspaceActions, key: string): Promise { return requirePluginData(actions).delete(scumCollections.gifts, key); } @@ -157,18 +163,19 @@ export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: Rec const giftCode = requiredKey(gift, "code", "礼包编号"); const playerId = firstText(player, "gamePlayerId", "playerId", "steamId", "id"); if (!playerId) throw new Error("用户编号不能为空。"); - const items = normalizeGiftItems(gift.items); - const operations = [...new Set([...normalizeGiftOperations(gift.commands), ...normalizeGiftOperations(gift.operations)])]; - if (!items.length && !operations.length) throw new Error("礼包必须包含物品或命令。"); + const configuredItems = parseGiftItems(gift.items); + if (!configuredItems.length) throw new Error("礼包必须包含物品。"); + const items = selectGiftItems(configuredItems); + if (!items.length) throw new Error("本次礼包没有命中任何物品,请检查物品概率配置。"); const now = Date.now(); const grantId = safeCommandId(`gift:${giftCode}:${playerId}:${now}`); - const commands = [...giftCatalogCommands(items), ...operations].map(normalizeRCONCommand); + const commands = giftCatalogCommands(items).map(normalizeRCONCommand); const results = []; for (const [index, command] of commands.entries()) { results.push(await dispatchSCUMRCONCommand(actions, command, `${grantId}:${index + 1}`)); } const commandIds = results.map((result) => textValue(result.result?.jobId)).filter(Boolean); - const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: commandIds[0] ?? "", commandIds, createdAt: new Date(now).toISOString() }; + const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", items, commandId: commandIds[0] ?? "", commandIds, createdAt: new Date(now).toISOString() }; await createGiftDelivery(actions, record); return { status: "queued", result: { jobIds: commandIds.join(",") } }; } @@ -207,29 +214,32 @@ export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap return dispatched; } -export function parseGiftItems(input: string): Array<{ catalogCode: string; quantity: number }> { - if (!input.trim()) return []; - const items = input.split(",").map((part) => { - const [rawKey, rawQuantity, ...extra] = part.split(":").map((value) => value.trim()); - const quantity = Number(rawQuantity); - if (!/^[A-Za-z0-9_.-]{1,128}$/.test(rawKey) || !rawQuantity || extra.length || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:正整数数量。"); - return { catalogCode: rawKey, quantity }; - }); - return items; +export function parseGiftItems(input: unknown): GiftItemDefinition[] { + if (input === undefined || input === null || input === "") return []; + if (typeof input === "string") { + if (!input.trim()) return []; + return input.split(",").map((part) => { + const [rawKey = "", rawQuantity = "", ...extra] = part.split(":").map((value) => value.trim()); + if (extra.length) throw new Error("礼包物品格式无效,请从目录选择物品并配置数量、概率和耐久。"); + return parseGiftItem({ catalogCode: rawKey, quantity: rawQuantity }); + }); + } + if (!Array.isArray(input)) throw new Error("礼包物品格式无效,请使用物品列表。"); + return input.map(parseGiftItem); } -function giftCatalogCommands(items: Array<{ catalogCode: string; quantity: number }>): string[] { +export function selectGiftItems(items: GiftItemDefinition[], random: () => number = Math.random): GiftItemDefinition[] { + return items.filter((item) => item.probability >= 100 || (item.probability > 0 && random() * 100 < item.probability)); +} + +function giftCatalogCommands(items: GiftItemDefinition[]): string[] { return items.flatMap((item) => { const entry = scumCatalogEntryFor(item.catalogCode); - if (!entry || entry.kind === "item") return [`#SpawnItem ${item.catalogCode} ${item.quantity}`]; + if (!entry || entry.kind === "item") return [`#SpawnItem ${item.catalogCode} ${item.quantity}${item.durability === undefined ? "" : ` Health ${item.durability}%`}`]; 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 })); -} - export type SCUMMapBounds = { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number }; export function resolveMapBounds(settings?: RecordMap): SCUMMapBounds { @@ -283,19 +293,21 @@ function requiredKey(value: RecordMap, key: string, label: string): string { function requiredRecordKey(value: RecordMap, label: string): string { const key = firstText(value, "_recordKey", "id", "produceId"); if (!key) throw new Error(`${label}编号不能为空。`); return key.includes(":") ? key : `${firstText(value, "eventId", "event")}:${key}`; } -function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) throw new Error("礼包物品格式无效。"); - return value.map((item) => { - if (!isRecord(item)) throw new Error("礼包物品格式无效。"); - const catalogCode = firstText(item, "catalogCode", "key"); - const quantity = Number(item.quantity); - if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品不符合 SCUM 目录代码或正整数数量约束。"); - return { catalogCode, quantity }; - }); +function parseGiftItem(value: unknown): GiftItemDefinition { + if (!isRecord(value)) throw new Error("礼包物品格式无效,请使用物品列表。"); + const catalogCode = firstText(value, "catalogCode", "catalogItemKey", "key", "className"); + const quantity = Number(value.quantity); + if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品数量必须是正整数,目录代码格式无效。"); + const probability = value.probability === undefined || value.probability === null ? 100 : percentValue(value.probability, "物品概率"); + const durability = value.durability === undefined || value.durability === null || value.durability === "" ? undefined : percentValue(value.durability, "物品耐久"); + return durability === undefined ? { catalogCode, quantity, probability } : { catalogCode, quantity, probability, durability }; } -function normalizeGiftOperations(value: unknown): string[] { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error("礼包命令格式无效。"); return value.map((item) => { const command = isRecord(item) ? firstText(item, "command", "value") : textValue(item); if (!command.trim()) throw new Error("礼包命令不能为空。"); return command.trim(); }); } +function percentValue(value: unknown, label: string): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) throw new Error(`${label}必须在 0 到 100 之间。`); + return parsed; +} function normalizeRCONCommand(command: string): string { const normalized = command.trim(); diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 8ee95aa..872cbea 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -5,7 +5,6 @@ import { emptySCUMSurfaceData, loadSCUMSurface, parseGiftItems, - parseGiftCommands, queueGiftDelivery, resetGiftClaim, resetPendingGift, @@ -20,6 +19,7 @@ import { type SCUMWorkspaceActions } from "./page-data.js"; import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleEntry, type ScumCatalogEntry } from "./scum-catalog.js"; +import { scumCatalogDisplayName, scumCatalogSearchText } from "./scum-catalog-zh.js"; import { scumMapVehicleIconUrl } from "./map-vehicle-icons.js"; import type { MapPlaybackFrame, MapPlaybackState, MapSceneData, MapTimeWindow, MapTrack, MapTrackPoint, MapTrackSegment, MapViewState } from "./contracts.js"; @@ -31,6 +31,8 @@ type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; type MapVehicleFilter = "all" | "land" | "water" | "air" | "other"; type ScumMapLayer = "terrain" | "topo" | "night"; type CatalogPickTarget = "gift-items" | "produce-item"; +type GiftItemDraft = { catalogCode: string; quantity: string; probability: string; durability: string }; +type GiftItemDraftField = keyof GiftItemDraft; type PlayerPanelKind = "closed" | "gifts" | "items" | "history"; type PlayerPanelState = { kind: PlayerPanelKind; playerId: string }; type PointerEventLike = { pointerId?: number; clientX?: number; clientY?: number; button?: number; currentTarget?: { setPointerCapture?: (pointerId?: number) => void; releasePointerCapture?: (pointerId?: number) => void }; preventDefault?: () => void; stopPropagation?: () => void }; @@ -39,7 +41,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", "其他"], ["vehicle", "载具"], ["animal", "动物"], ["zombie", "丧尸"], ["armed-npc", "NPC"]]; +const scumGiftCatalogCategories: ReadonlyArray = [["all", "全部目录"], ["hunting", "狩猎"], ["food", "食物"], ["metabolism", "代谢"], ["medical", "医疗"], ["weapons", "武器"], ["crafting", "制作"], ["gear", "装备"], ["fishing", "钓鱼"], ["building", "建筑"], ["tools", "工具"], ["farming", "农业"], ["misc", "其他"], ["vehicle", "载具"], ["animal", "动物"], ["zombie", "丧尸"], ["armed-npc", "NPC"]]; const scumCatalogPickLimit = 48; const scumMapSize = 4096; const scumSurfaceRefreshMs = 15000; @@ -122,8 +124,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [giftRecordKey, setGiftRecordKey] = usePluginState(react, ""); const [giftCode, setGiftCode] = usePluginState(react, ""); const [giftName, setGiftName] = usePluginState(react, ""); - const [giftItems, setGiftItems] = usePluginState(react, ""); - const [giftCommands, setGiftCommands] = usePluginState(react, ""); + const [giftItems, setGiftItems] = usePluginState(react, []); const [giftClass, setGiftClass] = usePluginState(react, "5"); const [giftAudience, setGiftAudience] = usePluginState(react, "all"); const [giftNumber, setGiftNumber] = usePluginState(react, "1"); @@ -260,7 +261,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, eventCommand, setEventCommand, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, - giftTab, setGiftTab, giftSearch, setGiftSearch, giftStatus, setGiftStatus, giftRecordKey, setGiftRecordKey, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber, + giftTab, setGiftTab, giftSearch, setGiftSearch, giftStatus, setGiftStatus, giftRecordKey, setGiftRecordKey, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber, deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapLayers, setMapLayers, mapVehicleFilter, setMapVehicleFilter, selectedMapPoint, setSelectedMapPoint, hoverMapPoint, setHoverMapPoint, mapView, setMapView, mapViewportRef, mapPlaybackFrameRef, mapFollowRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo, @@ -310,7 +311,7 @@ type ViewState = { eventNpc: string; setEventNpc: StateSetter; eventItem: string; setEventItem: StateSetter; eventZombie: string; setEventZombie: StateSetter; eventAnimal: string; setEventAnimal: StateSetter; produceEventId: string; setProduceEventId: StateSetter; produceId: string; setProduceId: StateSetter; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter; producePercent: string; setProducePercent: StateSetter; produceValue: string; setProduceValue: StateSetter; produceRadius: string; setProduceRadius: StateSetter; produceX: string; setProduceX: StateSetter; produceY: string; setProduceY: StateSetter; produceZ: string; setProduceZ: StateSetter; - giftCode: string; setGiftCode: StateSetter; giftName: string; setGiftName: StateSetter; giftItems: string; setGiftItems: StateSetter; giftCommands: string; setGiftCommands: StateSetter; + giftCode: string; setGiftCode: StateSetter; giftName: string; setGiftName: StateSetter; giftItems: GiftItemDraft[]; setGiftItems: StateSetter; giftClass: string; setGiftClass: StateSetter; giftAudience: string; setGiftAudience: StateSetter; giftNumber: string; setGiftNumber: StateSetter; giftAchievement: string; setGiftAchievement: StateSetter; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter; deliveryGift: string; setDeliveryGift: StateSetter; deliveryPlayer: string; setDeliveryPlayer: StateSetter; mapLayers: Record; setMapLayers: StateSetter>; mapVehicleFilter: MapVehicleFilter; setMapVehicleFilter: StateSetter; @@ -603,10 +604,10 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const actions = input.workspaceActions; const openNewGift = () => { - view.setGiftRecordKey(""); view.setGiftCode(""); view.setGiftName(""); view.setGiftItems(""); view.setGiftCommands(""); view.setGiftClass("5"); view.setGiftAudience("all"); view.setGiftNumber("1"); view.setGiftAchievement("0"); view.setGiftAchievementNumber("0"); view.setGiftDialog({ mode: "edit" }); + view.setGiftRecordKey(""); view.setGiftCode(""); view.setGiftName(""); view.setGiftItems([]); view.setGiftClass("5"); view.setGiftAudience("all"); view.setGiftNumber("1"); view.setGiftAchievement("0"); view.setGiftAchievementNumber("0"); view.setGiftDialog({ mode: "edit" }); }; const openGiftDialog = (gift: RecordMap, mode: "view" | "edit") => { - view.setGiftRecordKey(textField(gift, "_recordKey", "code", "id")); view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); view.setGiftDialog({ mode, gift }); + view.setGiftRecordKey(textField(gift, "_recordKey", "code", "id")); view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemDrafts(gift)); view.setGiftDialog({ mode, gift }); }; const saveGift = () => runAction(view.setAction, "正在保存礼包定义…", async () => { const code = view.giftCode.trim(); @@ -614,14 +615,13 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu if (!code || !name) throw new Error("礼包编号和名称不能为空。"); if (!/^[A-Za-z0-9_.-]{1,128}$/.test(code)) throw new Error("礼包编号只能使用字母、数字、点、下划线或短横线。"); const items = parseGiftItems(view.giftItems); - const commands = parseGiftCommands(view.giftCommands); - if (!items.length && !commands.length) throw new Error("礼包至少需要一项物品或命令。"); + if (!items.length) throw new Error("礼包至少要包含一项物品。"); const number = minimumIntegerInput(view.giftNumber, 1, "发放次数"); const achievement = minimumIntegerInput(view.giftAchievement, 0, "成就类型"); const achievementNumber = minimumIntegerInput(view.giftAchievementNumber, 0, "成就值"); await saveGiftDefinition(actions ?? {}, { code, name, class: integerInput(view.giftClass, 5), audience: view.giftAudience, number, - achievement, achievementNumber, items, commands, + achievement, achievementNumber, items, status: "active", updatedAt: new Date().toISOString() }); if (view.giftRecordKey && view.giftRecordKey !== code) await deleteGiftDefinition(actions ?? {}, view.giftRecordKey); @@ -638,7 +638,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu if (!gift || !player) throw new Error("请选择礼包和用户。"); await queueGiftDelivery(actions ?? {}, gift, player); view.refresh(); - return "礼包发放命令已进入执行队列。"; + return "礼包发放已进入执行队列。"; }); const search = view.giftSearch.trim().toLowerCase(); const gifts = data.gifts.filter((gift) => { @@ -666,7 +666,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu const status = textField(gift, "status").toLowerCase() || "active"; return e("tr", { key: idOf(gift, `gift-${index}`) }, e("td", null, e("strong", null, textField(gift, "name") || key), e("span", { className: "provider-id scum-gift-code" }, key)), - e("td", null, e("div", { className: "scum-gift-content-cell" }, catalogIconStrip(e, giftCatalogIcons(gift)), e("span", null, giftItemsSummary(gift))), e("span", { className: "provider-id" }, giftCommandsSummary(gift))), + e("td", null, e("div", { className: "scum-gift-content-cell" }, catalogIconStrip(e, giftCatalogIcons(gift)), e("span", null, giftItemsSummary(gift)))), e("td", null, e("span", { className: "gift-rule-primary" }, `${giftClassLabel(numField(gift, "class"))} · ${giftAudienceLabel(textField(gift, "audience"))}`), e("span", { className: "provider-id" }, `发放 ${numField(gift, "number")} 次 · 成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)), e("td", null, e("div", { className: "gift-status-cell" }, e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, giftStatusLabel(status)), e("span", { className: "provider-id" }, dateField(gift, "updatedAt", "createdAt")))), e("td", { className: "provider-actions-cell" }, e("div", { className: "scum-gift-table-actions" }, @@ -706,9 +706,33 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu ); } +function giftItemEditorList(e: ReactLike["createElement"], view: ViewState) { + if (!view.giftItems.length) return e("p", { className: "page-status" }, "尚未添加物品,请使用目录选择器加入礼包内容。"); + return e("div", { className: "gift-item-editor-list" }, view.giftItems.map((item, index) => { + const entry = scumCatalogEntryFor(item.catalogCode); + const supportsDurability = giftItemSupportsDurability(entry); + const itemLabel = entry ? scumCatalogDisplayName(entry) : item.catalogCode || "未命名物品"; + const category = entry ? scumCatalogCategoryLabel(entry) : "物品"; + const itemNumber = index + 1; + return e("div", { key: `${item.catalogCode}-${index}`, className: "gift-item-editor-row" }, + catalogIconStrip(e, catalogIconsForValue(item.catalogCode)), + e("div", { className: "gift-item-editor-info" }, e("strong", null, itemLabel), e("span", { className: "provider-id" }, `${category} · ${item.catalogCode || "未配置代码"}`)), + e("div", { className: "gift-item-editor-fields" }, + labeledField(e, "数量", e("input", { type: "number", min: "1", step: "1", value: item.quantity, "aria-label": `礼包物品 ${itemNumber} 数量`, onChange: (event: InputEvent) => updateGiftItemDraft(view, index, "quantity", inputValue(event)) })), + labeledField(e, "概率", e("input", { type: "number", min: "0", max: "100", step: "1", value: item.probability, "aria-label": `礼包物品 ${itemNumber} 概率`, onChange: (event: InputEvent) => updateGiftItemDraft(view, index, "probability", inputValue(event)) })), + labeledField(e, "耐久", e("input", { type: "number", min: "0", max: "100", step: "1", value: item.durability, disabled: !supportsDurability, placeholder: supportsDurability ? "默认" : "不适用", "aria-label": `礼包物品 ${itemNumber} 耐久`, onChange: (event: InputEvent) => updateGiftItemDraft(view, index, "durability", inputValue(event)) })), + e("button", { type: "button", className: "gift-item-editor-remove icon-command", "aria-label": `删除礼包物品 ${itemNumber}`, onClick: () => view.setGiftItems((items) => items.filter((_, itemIndex) => itemIndex !== index)) }, "删除") + ) + ); + })); +} + +function updateGiftItemDraft(view: ViewState, index: number, key: GiftItemDraftField, value: string): void { + view.setGiftItems((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item)); +} + function giftDetailsDialog(e: ReactLike["createElement"], gift: RecordMap, view: ViewState, editGift: () => void) { const items = giftItemRecords(gift); - const commands = giftCommandValues(gift); const status = textField(gift, "status").toLowerCase() || "active"; return e("div", { className: "confirm-backdrop management-dialog-backdrop gift-dialog-backdrop", role: "presentation", onClick: () => view.setGiftDialog({ mode: "closed" }) }, e("div", { className: "drawer-panel management-dialog-panel management-dialog-wide", role: "dialog", "aria-modal": "true", "aria-label": "查看礼包配置", onClick: (event: InputEvent) => event.stopPropagation?.() }, @@ -723,13 +747,12 @@ function giftDetailsDialog(e: ReactLike["createElement"], gift: RecordMap, view: giftConfigEntry(e, "成就门槛", `${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`) ), e("section", { className: "gift-form-section gift-config-section", "aria-label": "礼包内容" }, - e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包内容"), e("span", null, `${items.length} 项物品,${commands.length} 条命令`)), + e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包内容"), e("span", null, `${items.length} 项物品`)), items.length ? e("div", { className: "gift-config-list" }, items.map((item, index) => { const code = textField(item, "catalogCode", "catalogItemKey", "key", "className"); const entry = scumCatalogEntryFor(code); - return e("div", { key: `${code}-${index}`, className: "gift-config-row" }, catalogIconStrip(e, catalogIconsForValue(code)), e("span", null, entry?.name || textField(item, "label") || code || "未命名物品"), e("code", null, code || "unknown"), e("strong", null, `× ${numField(item, "quantity")}`)); + return e("div", { key: `${code}-${index}`, className: "gift-config-row" }, catalogIconStrip(e, catalogIconsForValue(code)), e("div", { className: "gift-config-item-info" }, e("strong", null, entry ? scumCatalogDisplayName(entry) : textField(item, "label") || code || "未命名物品"), e("span", { className: "provider-id" }, `${entry ? scumCatalogCategoryLabel(entry) : "物品"} · ${code || "unknown"}`)), e("span", null, `数量 × ${numField(item, "quantity")}`), e("span", null, `概率 ${giftItemProbability(item)}%`), e("span", null, `耐久 ${giftItemDurability(item, entry)}`)); })) : e("p", { className: "page-status" }, "未配置物品。"), - commands.length ? e("div", { className: "gift-command-list", "aria-label": "礼包命令" }, commands.map((command, index) => e("code", { key: `${command}-${index}` }, command))) : e("p", { className: "page-status" }, "未配置附加命令。") ), e("div", { className: "confirm-actions gift-dialog-actions" }, e("button", { type: "button", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "关闭"), e("button", { type: "button", className: "confirm-primary", onClick: editGift }, "编辑礼包")) ) @@ -743,7 +766,7 @@ function giftEditorDialog(e: ReactLike["createElement"], view: ViewState, action e("div", { className: "drawer-panel management-dialog-panel management-dialog-wide", role: "dialog", "aria-modal": "true", "aria-label": editing ? "编辑礼包" : "新建礼包", onClick: (event: InputEvent) => event.stopPropagation?.() }, e("div", { className: "panel-header dialog-panel-header" }, e("div", { className: "gift-dialog-heading" }, e("h2", null, editing ? "编辑礼包" : "新建礼包"), e("span", { className: "page-status" }, editing ? "更新现有礼包定义" : "创建可领取的运营礼包")), e("button", { type: "button", className: "theme-upload drawer-close", "aria-label": "关闭礼包编辑器", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "关闭")), e("div", { className: "gift-form dialog-form management-form" }, - e("div", { className: "form-guidance management-form-wide" }, e("strong", null, "先定义规则,再配置内容"), e("span", null, "礼包编号用于数据主键,只支持字母、数字、点、下划线和短横线;礼包至少要包含一项物品或一条命令。")), + e("div", { className: "form-guidance management-form-wide" }, e("strong", null, "先定义规则,再配置内容"), e("span", null, "礼包编号用于数据主键,只支持字母、数字、点、下划线和短横线;礼包至少要包含一项物品。")), labeledField(e, "礼包编号", e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "例如 welcome_pack", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) })), labeledField(e, "礼包名称", e("input", { value: view.giftName, "aria-label": "礼包名称", placeholder: "例如 新人礼包", onChange: (event: InputEvent) => view.setGiftName(inputValue(event)) })), labeledField(e, "礼包周期", e("select", { value: view.giftClass, "aria-label": "礼包周期", onChange: (event: InputEvent) => view.setGiftClass(inputValue(event)) }, [["1", "每日"], ["2", "每周"], ["3", "每月"], ["4", "每年"], ["5", "一次"], ["6", "每日五次"]].map(([value, label]) => e("option", { key: value, value }, label)))), @@ -751,8 +774,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 目录分类筛选(物品、载具、动物、丧尸、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: "gift-form-wide gift-form-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包物品"), e("span", null, "可用目录选择器快速加入;每项配置数量、概率和耐久")), giftItemEditorList(e, view), catalogPicker(e, view, "gift-items")), 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 }, "保存礼包")) ) ) @@ -1366,16 +1388,14 @@ function catalogMatches(query: string, kind: string, target: CatalogPickTarget): const search = query.trim().toLowerCase(); return scumCatalogEntries.filter((entry) => { 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)); + return matchesKind && (!search || scumCatalogSearchText(entry).includes(search)); }).slice(0, scumCatalogPickLimit); } -function appendCatalogItem(current: string, code: string): string { - const parts = current.split(",").map((part) => part.trim()).filter(Boolean); - const index = parts.findIndex((part) => part.split(":")[0]?.trim().toLowerCase() === code.toLowerCase()); - if (index >= 0) { const [key, quantity] = parts[index].split(":"); parts[index] = `${key.trim()}:${Math.max(1, Number(quantity) || 1) + 1}`; } - else parts.push(`${code}:1`); - return parts.join(", "); +function appendCatalogItem(items: GiftItemDraft[], code: string): GiftItemDraft[] { + const index = items.findIndex((item) => item.catalogCode.toLowerCase() === code.toLowerCase()); + if (index >= 0) return items.map((item, itemIndex) => itemIndex === index ? { ...item, quantity: String(Math.max(1, Number(item.quantity) || 1) + 1) } : item); + return [...items, { catalogCode: code, quantity: "1", probability: "100", durability: "" }]; } function catalogIconsForValue(value: string): string[] { @@ -1402,20 +1422,23 @@ function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: C 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("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" }, "没有匹配的目录条目。")) + e("div", { className: "scum-catalog-grid" }, matches.length ? matches.map((entry) => { const icon = scumCatalogIconUrl(entry.icon); const displayName = scumCatalogDisplayName(entry); return e("button", { key: entry.code, type: "button", className: "scum-catalog-chip", title: `${displayName} · ${scumCatalogCategoryLabel(entry)} · ${entry.command}`, onClick: () => target === "gift-items" ? view.setGiftItems((items) => appendCatalogItem(items, 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, displayName), e("span", { className: "provider-id" }, `${scumCatalogCategoryLabel(entry)} · ${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"; + const name = `${entry.name} ${entry.code}`.toLowerCase(); + if (icon.includes("/crafting/traps/") || icon.includes("/weapons/explosives/traps/") || /\b(hunting|bait|trap)\b/.test(name)) return "hunting"; if (icon.includes("/fishing/")) return "fishing"; + if (icon.includes("/food/drinks/")) return "metabolism"; + if (icon.includes("/medicals/")) return "medical"; + if (icon.includes("/weapons/")) return "weapons"; 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/basebuilding/") || icon.includes("/crafting/fortifications/")) return "building"; + if (icon.includes("/crafting/components/") || icon.includes("/misc/components/") || icon.includes("/crafting/recipes/")) return "crafting"; if (icon.includes("/crafting/tools/") || icon.includes("/misc/tools/")) return "tools"; if (icon.includes("/farming/")) return "farming"; return "misc"; @@ -1423,7 +1446,7 @@ export function scumGiftCatalogCategory(entry: ScumCatalogEntry): string { 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)] ?? "其他"; } +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 ({ hunting: "狩猎", food: "食物", metabolism: "代谢", medical: "医疗", weapons: "武器", crafting: "制作", gear: "装备", fishing: "钓鱼", building: "建筑", 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); } @@ -1563,12 +1586,13 @@ function dateField(row: RecordMap | undefined, ...keys: string[]): string { cons function matchesText(row: RecordMap, search: string, ...keys: string[]): boolean { return !search || keys.some((key) => textField(row, key).toLowerCase().includes(search)); } function unique(values: string[]): string[] { return [...new Set(values)]; } function activeStatus(status: string): boolean { return ["active", "running", "scheduled", "enabled", "queued"].includes(status.toLowerCase()); } -function giftItemsSummary(gift: RecordMap): string { const items = field(gift, "items"); if (!Array.isArray(items) || !items.length) return "未配置物品"; return items.map((item) => { if (!isRecord(item)) return String(item); const code = textField(item, "catalogCode", "catalogItemKey", "key", "className"); const entry = scumCatalogEntryFor(code); const label = entry?.name || textField(item, "label") || code || "未命名物品"; return `${label} × ${numField(item, "quantity")}`; }).join(" · "); } -function giftCommandsSummary(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) && commands.length ? `${commands.length} 条命令` : "无命令"; } -function giftItemsInput(gift: RecordMap): string { const items = field(gift, "items"); return Array.isArray(items) ? items.map((item) => isRecord(item) ? `${textField(item, "catalogCode", "catalogItemKey", "key")}:${numField(item, "quantity")}` : "").filter(Boolean).join(", ") : ""; } -function giftCommandsInput(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) ? commands.map((item) => isRecord(item) ? textField(item, "command", "value") : String(item)).filter(Boolean).join("\n") : ""; } -function giftItemRecords(gift: RecordMap): RecordMap[] { const items = field(gift, "items"); return Array.isArray(items) ? items.filter(isRecord) : []; } -function giftCommandValues(gift: RecordMap): string[] { const commands = field(gift, "commands"); return Array.isArray(commands) ? commands.map((item) => isRecord(item) ? textField(item, "command", "value") : String(item)).filter(Boolean) : []; } +function giftItemsSummary(gift: RecordMap): string { const items = giftItemRecords(gift); if (!items.length) return "未配置物品"; return items.map((item) => { const code = textField(item, "catalogCode", "catalogItemKey", "key", "className"); const entry = scumCatalogEntryFor(code); const label = entry ? scumCatalogDisplayName(entry) : textField(item, "label") || code || "未命名物品"; return `${label} × ${numField(item, "quantity")} · 概率 ${giftItemProbability(item)}%`; }).join(" · "); } +function giftItemDrafts(gift: RecordMap): GiftItemDraft[] { return giftItemRecords(gift).map((item) => ({ catalogCode: textField(item, "catalogCode", "catalogItemKey", "key", "className"), quantity: String(field(item, "quantity") ?? "1"), probability: giftItemProbability(item), durability: giftItemRawDurability(item) })); } +function giftItemRecords(gift: RecordMap): RecordMap[] { try { return parseGiftItems(field(gift, "items")).map((item) => ({ ...item })); } catch { return []; } } +function giftItemProbability(item: RecordMap): string { const value = Number(field(item, "probability")); return Number.isFinite(value) && value >= 0 && value <= 100 ? String(value) : "100"; } +function giftItemRawDurability(item: RecordMap): string { const value = field(item, "durability"); return value === undefined || value === null ? "" : String(value); } +function giftItemDurability(item: RecordMap, entry: ScumCatalogEntry | undefined): string { if (!giftItemSupportsDurability(entry)) return "不适用"; const value = giftItemRawDurability(item); return value ? `${value}%` : "默认"; } +function giftItemSupportsDurability(entry: ScumCatalogEntry | undefined): boolean { return !entry || entry.kind === "item"; } function giftConfigEntry(e: ReactLike["createElement"], label: string, value: string) { return e("div", { className: "gift-config-entry" }, e("dt", null, label), e("dd", null, value)); } function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record)[value] ?? value; } function giftAudienceLabel(value: string): string { return (({ all: "全部玩家", pve: "PVE 玩家", pvp: "PVP 玩家" } as Record)[value] ?? value) || "全部玩家"; } diff --git a/plugins/examples/scum-server-plugin/features/scum-catalog-zh.ts b/plugins/examples/scum-server-plugin/features/scum-catalog-zh.ts new file mode 100644 index 0000000..e9fb869 --- /dev/null +++ b/plugins/examples/scum-server-plugin/features/scum-catalog-zh.ts @@ -0,0 +1,107 @@ +import type { ScumCatalogEntry } from "./scum-catalog.js"; + +const directNames: Record = { + BP_Bear2: "熊", + BP_Boar: "野猪", + BP_Chicken: "鸡", + BP_Crow: "乌鸦", + BP_Deer2: "鹿", + BP_Donkey2: "驴", + BP_Goat2: "山羊", + BP_Horse2: "马", + BP_Rabbit2: "兔子", + BP_Seagull: "海鸥", + BP_Wolf3: "狼", + WildDuck: "鹿(目录别名)", + BP_Drifter_Lvl_1: "武装 NPC 1 级", + BP_Drifter_Lvl_2: "武装 NPC 2 级", + BP_Drifter_Lvl_3: "武装 NPC 3 级", + BP_Drifter_Lvl_4: "武装 NPC 4 级", + BP_Drifter_Lvl_5: "武装 NPC 5 级", + BP_Guard_Lvl_1: "武装守卫 1 级", + BP_Guard_Lvl_2: "武装守卫 2 级", + BP_Guard_Lvl_3: "武装守卫 3 级", + BP_Guard_Lvl_4: "武装守卫 4 级", + BP_Guard_Lvl_5: "武装守卫 5 级", + BP_Zombie_Hospital_Normal: "医院普通丧尸", + BPC_Laika: "莱卡", + BPC_WolfsWagen: "沃尔夫斯瓦根", + BPC_SidecarBike: "边车摩托", + BPC_Motorcycle: "摩托车", + Aloe_Vera: "芦荟", + Aspirin: "阿司匹林", + Animal_Skin: "动物皮", + Air_Pump: "打气筒", + Backpack_02_01: "红色背包", + "1H_Cleaver": "菜刀", + Water_05l: "水", + BP_Cash_01: "现金", + Cash: "现金", + Adrenaline_Shot: "肾上腺素注射剂", + Antibiotic_Pill_Single: "抗生素药片", + AntiburnGel: "烧伤凝胶", + Bandage: "绷带", + Bread: "面包", + Burger: "汉堡", + Canteen: "水壶", + Fish_trap: "捕鱼陷阱", + FishingRod: "鱼竿", + FishingHook: "鱼钩", + Bait_trap_for_small_animals: "小型动物诱饵陷阱", + Watermelon: "西瓜" +}; + +const wordNames: Record = { + absinthe: "苦艾酒", animal: "动物", animals: "动物", apple: "苹果", armor: "护甲", arrow: "箭", arrows: "箭矢", aspirin: "阿司匹林", advanced: "高级", automatic: "自动", + backpack: "背包", bait: "诱饵", ball: "球", banana: "香蕉", bandage: "绷带", baseball: "棒球", battery: "电池", bear: "熊", beer: "啤酒", beet: "甜菜", berries: "浆果", blackberry: "黑莓", blueberries: "蓝莓", bolt: "弩箭", bottle: "瓶", bow: "弓", bread: "面包", brick: "砖块", broccoli: "西兰花", buck: "雄鹿", bullet: "子弹", bulletproof: "防弹", burger: "汉堡", burn: "烧伤", cake: "蛋糕", canned: "罐装", carbon: "碳纤维", carrot: "胡萝卜", cartridge: "弹药", cash: "现金", cheese: "奶酪", chicken: "鸡", civilian: "平民", cleaver: "菜刀", cola: "可乐", coffee: "咖啡", compound: "复合", construction: "施工", cooked: "熟制", cooking: "烹饪", crow: "乌鸦", crossbow: "弩", crucian: "鲫鱼", cup: "杯", dark: "深色", deer: "鹿", detonator: "雷管", dildo: "假阳具", donkey: "驴", double: "双头", drink: "饮料", drinks: "饮料", eagle: "鹰", edible: "可食用", energy: "能量", explosive: "爆炸", female: "女性", fish: "鱼", fishing: "钓鱼", floater: "浮漂", freshwater: "淡水", fruit: "水果", gas: "燃气", gasoline: "汽油", goat: "山羊", green: "绿色", grenade: "手雷", grey: "灰色", guts: "内脏", hammer: "锤子", hands: "手部", hat: "帽子", head: "头部", helmet: "头盔", hospital: "医院", hook: "鱼钩", horse: "马", human: "人类", improvised: "简易", injection: "注射剂", iron: "铁制", juice: "果汁", katana: "武士刀", knife: "刀", large: "大型", launcher: "发射器", leather: "皮革", level: "等级", lime: "青柠", lock: "锁", lockpick: "撬锁工具", magazine: "弹匣", male: "男性", medical: "医疗", medium: "中型", metal: "金属", milk: "牛奶", mushroom: "蘑菇", muzzle: "枪口", necklace: "项链", nylon: "尼龙", orange: "橙色", pack: "包", packaged: "包装", painkiller: "止痛药", pepper: "胡椒", pill: "药片", pills: "药片", pizza: "披萨", police: "警用", potato: "土豆", red: "红色", repair: "维修", rifle: "步枪", rope: "绳子", rabbit: "兔子", rice: "米饭", revolver: "左轮手枪", salad: "沙拉", sardine: "沙丁鱼", shotgun: "霰弹枪", shovel: "铲子", small: "小型", soda: "汽水", soup: "汤", stone: "石制", suppressor: "消音器", sword: "剑", syringe: "注射器", tea: "茶", tuna: "金枪鱼", vegetables: "蔬菜", vehicle: "载具", vest: "背心", vitamins: "维生素", vodka: "伏特加", water: "水", watermelon: "西瓜", whiskey: "威士忌", white: "白色", wolf: "狼", wooden: "木制", yellow: "黄色", zombie: "丧尸" +}; + +const additionalWordNames: Record = { + activated: "活性炭", alcohol: "酒精", alcoholic: "酒精类", anti: "抗", antibiotic: "抗生素", antimalaria: "抗疟疾", antiparasitic: "抗寄生虫", antipsychotic: "抗精神病", ammunition: "弹药", attachment: "配件", back: "后部", body: "身体", boots: "靴子", box: "盒", bucket: "桶", camp: "营地", charcoal: "木炭", clothes: "服装", component: "组件", crafted: "制作的", crafting: "制作", dirty: "脏污", door: "车门", electrodes: "电极", farming: "农业", feet: "脚部", front: "前部", gear: "装备", gloves: "手套", hiking: "徒步", hood: "引擎盖", inventory: "背包栏", item: "物品", legs: "腿部", light: "轻型", normal: "普通", other: "其他", parts: "零件", rear: "后部", rubber: "橡胶", security: "安全", seat: "座椅", side: "侧面", single: "单发", skin: "皮肤", spare: "备用", standing: "立式", steel: "钢制", tactical: "战术", top: "顶部", trap: "陷阱", underwear: "内衣", weapon: "武器", wheel: "车轮" +}; + +const phraseNames: Array = [ + [/^Armed Guard NPC Level (\d+)$/i, "武装守卫 $1 级"], + [/^Armed NPC Level (\d+)$/i, "武装 NPC $1 级"], + [/^Zombie (.+) Female$/i, "女性丧尸 $1"], + [/^Zombie (.+) Male$/i, "男性丧尸 $1"], + [/^Zombie (.+)$/i, "丧尸 $1"], + [/^(.+) Fish Bait$/i, "$1 鱼饵"], + [/^(.+) Cookbook$/i, "$1 烹饪书"], + [/^(.+) Magazine$/i, "$1 弹匣"], + [/^(.+) Crossbow Bolt$/i, "$1 弩箭"], + [/^(.+) Arrow$/i, "$1 箭"], + [/^(.+) Seed Bag$/i, "$1 种子袋"], + [/^(.+) Seeds$/i, "$1 种子"], + [/^(.+) Juice$/i, "$1 果汁"], + [/^(.+) Beer$/i, "$1 啤酒"], + [/^(.+) Vodka$/i, "$1 伏特加"], + [/^(.+) Water$/i, "$1 水"], + [/^(.+) Level (\d+)$/i, "$1 $2 级"] +]; + +const catalogKindNames: Record = { item: "物品", vehicle: "载具", animal: "动物", zombie: "丧尸", "armed-npc": "NPC", razor: "其他" }; + +export function scumCatalogNameZh(entry: ScumCatalogEntry): string { + const direct = directNames[entry.code]; + if (direct) return direct; + const source = usableName(entry); + for (const [pattern, replacement] of phraseNames) if (pattern.test(source)) return translateWords(source.replace(pattern, replacement)); + const translated = translateWords(source); + return translated === source ? `${catalogKindNames[entry.kind] ?? "物品"} · ${source}` : translated; +} + +export function scumCatalogDisplayName(entry: ScumCatalogEntry): string { return scumCatalogNameZh(entry); } + +export function scumCatalogSearchText(entry: ScumCatalogEntry): string { + return [scumCatalogNameZh(entry), entry.name, entry.code, entry.command].join(" ").toLowerCase(); +} + +function usableName(entry: ScumCatalogEntry): string { + return /^ICO[_ .]/i.test(entry.name) || /\.(?:png|webp)$/i.test(entry.name) ? entry.code.replace(/[_-]+/g, " ") : entry.name; +} + +function translateWords(value: string): string { + const spaced = value.replace(/([a-z])([A-Z])/g, "$1 $2"); + return spaced.replace(/[A-Za-z][A-Za-z'-]*/g, (word) => additionalWordNames[word.toLowerCase()] ?? wordNames[word.toLowerCase()] ?? word); +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json index 6fa8d2d..8b3335b 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json @@ -3,7 +3,7 @@ "title": "SCUMRewardDeliverPayload", "type": "object", "additionalProperties": false, - "required": ["grantId", "playerId", "items", "operations"], + "required": ["grantId", "playerId", "items"], "properties": { "playerId": { "type": "string", @@ -23,7 +23,9 @@ "required": ["catalogCode", "quantity"], "properties": { "catalogCode": { "type": "string", "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]{1,128}$" }, - "quantity": { "type": "integer", "minimum": 1 } + "quantity": { "type": "integer", "minimum": 1 }, + "probability": { "type": "number", "minimum": 0, "maximum": 100 }, + "durability": { "type": "number", "minimum": 0, "maximum": 100 } } } }, diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 2782b71..3590163 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -4,9 +4,10 @@ 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, 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 { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, selectGiftItems, 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, scumGiftCatalogFilterKey, tradeGoodsTypeLabel } from "../examples/scum-server-plugin/features/page.js"; import { scumCatalogEntries } from "../examples/scum-server-plugin/features/scum-catalog.js"; +import { scumCatalogDisplayName, scumCatalogSearchText } from "../examples/scum-server-plugin/features/scum-catalog-zh.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"; @@ -33,7 +34,7 @@ const surfaceData: SCUMSurfaceData = { { code: "#spawnvehicle BPC_Laika_C", className: "BPC_Laika_C", catalogType: "vehicle", type: "21", typeName: "其他载具", imagePath: "/original/BPC_Laika_C.webp", lastSeenAt: "2026-08-10T00:00:03Z" } ], tradeEvents: [{ steamId: "76561198000000001", itemCode: "goods-1", tradeVerb: "purchased", quantity: "2", price: "120", observedAt: "2026-08-10T00:01:00Z" }], - gifts: [{ code: "starter-pack", name: "Starter Pack", class: 5, audience: "all", number: 1, achievement: 2, achievementNumber: 10, status: "active", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], commands: [{ command: "#announce Starter pack" }] }], + gifts: [{ code: "starter-pack", name: "Starter Pack", class: 5, audience: "all", number: 1, achievement: 2, achievementNumber: 10, status: "active", items: [{ catalogCode: "Cash", quantity: 2, probability: 100, durability: 65 }] }], giftClaims: [{ id: "claim-1", playerId: "steam-1", giftCode: "starter-pack", status: "claimed", claimedAt: "2026-08-10T00:03:00Z" }], pendingGifts: [{ id: "pending-1", playerId: "steam-1", giftCode: "starter-pack", status: "pending", createdAt: "2026-08-10T00:03:30Z" }], giftDeliveries: [{ id: "delivery-1", playerId: "steam-1", giftCode: "starter-pack", status: "delivered", deliveredAt: "2026-08-10T00:04:00Z" }], @@ -141,18 +142,30 @@ describe("SCUM plugin feature module", () => { it("uses transaction, put, and delete for plugin-owned gift data", async () => { const pluginData = pluginDataActions(); const actions = { pluginData }; - 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([{ catalogCode: "BP_Cash_01", quantity: "2", probability: "80", durability: "65" }, { catalogCode: "Water-Bottle.01", quantity: 1, probability: 35 }])).toEqual([{ catalogCode: "BP_Cash_01", quantity: 2, probability: 80, durability: 65 }, { catalogCode: "Water-Bottle.01", quantity: 1, probability: 35 }]); + expect(parseGiftItems("BP_Cash_01:2, Water-Bottle.01:1")).toEqual([{ catalogCode: "BP_Cash_01", quantity: 2, probability: 100 }, { catalogCode: "Water-Bottle.01", quantity: 1, probability: 100 }]); + expect(() => parseGiftItems("cash:0")).toThrow("正整数"); 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"); expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.gifts, [{ operation: "put", key: "starter", value: { code: "starter", name: "Starter", items: [] } }]); + await saveGiftDefinition(actions, { code: "legacy", name: "Legacy", items: [{ catalogCode: "Cash", quantity: 1 }], commands: [{ command: "#announce ignored" }] }); + expect(pluginData.transact).toHaveBeenLastCalledWith(scumCollections.gifts, [{ operation: "put", key: "legacy", value: { code: "legacy", name: "Legacy", items: [{ catalogCode: "Cash", quantity: 1, probability: 100 }] } }]); expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, "delivery-1", expect.objectContaining({ giftCode: "starter", playerId: "steam-1" })); expect(pluginData.delete).toHaveBeenCalledWith(scumCollections.gifts, "starter"); }); + it("selects gift items by probability and keeps the old default at 100 percent", () => { + const items = parseGiftItems([ + { catalogCode: "always", quantity: 1, probability: 100 }, + { catalogCode: "never", quantity: 1, probability: 0 }, + { catalogCode: "half", quantity: 1, probability: 50 } + ]); + expect(selectGiftItems(items, () => 0.49).map((item) => item.catalogCode)).toEqual(["always", "half"]); + expect(selectGiftItems(items, () => 0.5).map((item) => item.catalogCode)).toEqual(["always"]); + }); + it("persists event produces, event runs, and gift resets in plugin-owned collections", async () => { const pluginData = pluginDataActions(); const dispatch = vi.fn>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } })); @@ -170,12 +183,12 @@ describe("SCUM plugin feature module", () => { expect(pluginData.put).toHaveBeenCalledWith(scumCollections.pendingGifts, "pending-1", expect.objectContaining({ status: "pending", receivedAt: null })); }); - it("queues gift and event commands through platform-mediated SCUM RCON", async () => { + it("queues gift items and event commands through platform-mediated SCUM RCON", async () => { const pluginData = pluginDataActions(); const dispatch = vi.fn>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } })); const actions: SCUMWorkspaceActions = { pluginData, dispatch }; - await queueGiftDelivery(actions, { ...surfaceData.gifts[0], operations: ["#announce Starter pack", "#SetFamePoints 250"] }, surfaceData.players[0]); - expect(dispatch.mock.calls.map(([request]) => request.payload?.["input.command"])).toEqual(["#SpawnItem BP_Cash_01 2", "#announce Starter pack", "#SetFamePoints 250"]); + await queueGiftDelivery(actions, { ...surfaceData.gifts[0], operations: ["#announce ignored"] }, surfaceData.players[0]); + expect(dispatch.mock.calls.map(([request]) => request.payload?.["input.command"])).toEqual(["#SpawnItem Cash 2 Health 65%"]); expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, expect.any(String), expect.objectContaining({ giftCode: "starter-pack", playerId: "steam-1", status: "queued" })); await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-1" }) })); @@ -290,7 +303,8 @@ describe("SCUM plugin feature module", () => { expect(deliveries.texts).toContain("delivered"); expect(deliveries.buttons.find((button) => button.label === "立即发放")?.disabled).toBe(false); const details = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftDialog: { mode: "view", gift: surfaceData.gifts[0] } }); - expect(details.texts).toEqual(expect.arrayContaining(["礼包配置详情", "礼包编号", "starter-pack", "领取周期", "一次", "礼包内容", "BP_Cash_01", "#announce Starter pack"])); + expect(details.texts).toEqual(expect.arrayContaining(["礼包配置详情", "礼包编号", "starter-pack", "领取周期", "一次", "礼包内容", "现金", "其他 · Cash", "数量 × 2", "概率 100%", "耐久 65%"])); + expect(details.texts).not.toContain("#announce Starter pack"); expect(details.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["关闭", "编辑礼包"])); }); @@ -299,9 +313,12 @@ describe("SCUM plugin feature module", () => { 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("Animal_Skin"))).toBe("crafting"); expect(scumGiftCatalogCategory(entry("Air_Pump"))).toBe("tools"); expect(scumGiftCatalogCategory(entry("Backpack_02_01"))).toBe("gear"); + expect(scumGiftCatalogCategory(entry("Bait_trap_for_small_animals"))).toBe("hunting"); + expect(scumGiftCatalogCategory(entry("Beer"))).toBe("metabolism"); + expect(scumGiftCatalogCategory(entry("Advanced_Standing_Torch_01"))).toBe("building"); expect(pageSource).toContain("礼包目录分类"); expect(scumGiftCatalogFilterKey(entry("BPC_Laika"))).toBe("vehicle"); expect(scumGiftCatalogFilterKey(entry("BP_Bear2"))).toBe("animal"); @@ -321,6 +338,14 @@ describe("SCUM plugin feature module", () => { expect(tradeGoodsTypeLabel({ code: "unknown-item", typeName: "item" })).toBe("未知类型"); }); + it("shows Chinese catalog names and searches through the translated name", () => { + const aloe = scumCatalogEntries.find((item) => item.code === "Aloe_Vera")!; + const vehicle = scumCatalogEntries.find((item) => item.code === "BPC_Laika")!; + expect(scumCatalogDisplayName(aloe)).toBe("芦荟"); + expect(scumCatalogDisplayName(vehicle)).toBe("莱卡"); + expect(scumCatalogSearchText(aloe)).toContain("芦荟"); + }); + 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 } })); @@ -413,6 +438,18 @@ describe("SCUM plugin feature module", () => { expect(workflows.inputs.map((input) => input.label)).not.toContain("搜索生成物品"); }); + it("keeps gifts as an item list with probability and durability instead of command text", () => { + expect(pageSource).toContain("礼包物品"); + expect(pageSource).toContain("礼包物品 ${itemNumber} 数量"); + expect(pageSource).toContain("礼包物品 ${itemNumber} 概率"); + expect(pageSource).toContain("礼包物品 ${itemNumber} 耐久"); + expect(pageSource).not.toContain("giftCommands"); + expect(pageSource).not.toContain("parseGiftCommands"); + expect(pageSource).not.toContain("礼包命令"); + expect(pageSource).not.toContain("命令清单"); + expect(dataClientSource).not.toContain("parseGiftCommands"); + }); + it("focuses the live map on the user who was picked from the user list", () => { const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", pageQuery: { focus: "76561198000000001", from: "2026-08-09T23:00:00Z", to: "2026-08-10T01:00:00Z" } }); expect(view.texts).toContain("用户 1/1");