fix: redesign SCUM gift item lists

This commit is contained in:
npc0-hue
2026-09-20 18:33:31 +08:00
parent 5f2bac3280
commit 1488d5a43e
6 changed files with 267 additions and 83 deletions
+2
View File
@@ -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}
@@ -1,6 +1,9 @@
import { scumCatalogEntryFor } from "./scum-catalog.js";
export type RecordMap = Record<string, unknown>;
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<unknown> {
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<void> { 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();
@@ -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<readonly [ScumMapLayer, string]> = [["ter
const scumMapVehicleFilters: ReadonlyArray<readonly [MapVehicleFilter, string]> = [["all", "全部载具"], ["land", "陆地载具"], ["water", "水上载具"], ["air", "飞行类载具"], ["other", "其他载具"]];
const scumFlightVehicleKeys = new Set(["kinglet_duster", "kinglet_mariner", "kinglet_scout"]);
const scumCatalogKinds: ReadonlyArray<readonly [string, string]> = [["all", "全部"], ["item", "物品"], ["vehicle", "载具"], ["zombie", "丧尸"], ["animal", "动物"], ["armed-npc", "NPC"]];
const scumGiftCatalogCategories: ReadonlyArray<readonly [string, string]> = [["all", "全部目录"], ["weapons", "武器"], ["fishing", "钓鱼"], ["gear", "装备"], ["food", "食物"], ["medical", "医疗"], ["components", "组件"], ["blueprints", "蓝图"], ["tools", "工具"], ["farming", "农业"], ["misc", "其他"], ["vehicle", "载具"], ["animal", "动物"], ["zombie", "丧尸"], ["armed-npc", "NPC"]];
const scumGiftCatalogCategories: ReadonlyArray<readonly [string, string]> = [["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<GiftItemDraft[]>(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<string>; eventItem: string; setEventItem: StateSetter<string>; eventZombie: string; setEventZombie: StateSetter<string>; eventAnimal: string; setEventAnimal: StateSetter<string>;
produceEventId: string; setProduceEventId: StateSetter<string>; produceId: string; setProduceId: StateSetter<string>; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter<string>;
producePercent: string; setProducePercent: StateSetter<string>; produceValue: string; setProduceValue: StateSetter<string>; produceRadius: string; setProduceRadius: StateSetter<string>; produceX: string; setProduceX: StateSetter<string>; produceY: string; setProduceY: StateSetter<string>; produceZ: string; setProduceZ: StateSetter<string>;
giftCode: string; setGiftCode: StateSetter<string>; giftName: string; setGiftName: StateSetter<string>; giftItems: string; setGiftItems: StateSetter<string>; giftCommands: string; setGiftCommands: StateSetter<string>;
giftCode: string; setGiftCode: StateSetter<string>; giftName: string; setGiftName: StateSetter<string>; giftItems: GiftItemDraft[]; setGiftItems: StateSetter<GiftItemDraft[]>;
giftClass: string; setGiftClass: StateSetter<string>; giftAudience: string; setGiftAudience: StateSetter<string>; giftNumber: string; setGiftNumber: StateSetter<string>; giftAchievement: string; setGiftAchievement: StateSetter<string>; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter<string>;
deliveryGift: string; setDeliveryGift: StateSetter<string>; deliveryPlayer: string; setDeliveryPlayer: StateSetter<string>;
mapLayers: Record<MapLayer, boolean>; setMapLayers: StateSetter<Record<MapLayer, boolean>>; mapVehicleFilter: MapVehicleFilter; setMapVehicleFilter: StateSetter<MapVehicleFilter>;
@@ -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<string, string>)[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<string, string>)[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<string, string>)[value] ?? value; }
function giftAudienceLabel(value: string): string { return (({ all: "全部玩家", pve: "PVE 玩家", pvp: "PVP 玩家" } as Record<string, string>)[value] ?? value) || "全部玩家"; }
@@ -0,0 +1,107 @@
import type { ScumCatalogEntry } from "./scum-catalog.js";
const directNames: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<readonly [RegExp, string]> = [
[/^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<string, string> = { 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);
}
@@ -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 }
}
}
},
+47 -10
View File
@@ -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<NonNullable<SCUMWorkspaceActions["dispatch"]>>(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<NonNullable<SCUMWorkspaceActions["dispatch"]>>(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<NonNullable<SCUMWorkspaceActions["dispatch"]>>(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");