feat: simplify SCUM activity management

This commit is contained in:
npc0-hue
2026-09-20 21:22:15 +08:00
parent 1488d5a43e
commit ab1cd01351
5 changed files with 217 additions and 157 deletions
+2
View File
@@ -463,11 +463,13 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.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}
.activity-list{display:grid;gap:10px}.activity-list-toolbar{align-items:end}.activity-list-toolbar .page-status{margin-left:0}.activity-create-command{width:auto;min-width:112px}.activity-record{display:grid;gap:0;padding:0;overflow:hidden}.activity-record>summary{display:flex;align-items:center;gap:10px;min-width:0;padding:13px 14px;cursor:pointer;list-style:none}.activity-record>summary::-webkit-details-marker{display:none}.activity-record>summary::before{content:"";display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border:1px solid var(--line);border-radius:50%;color:var(--accent);font-weight:850}.activity-record[open]>summary::before{content:""}.activity-record>summary:hover{background:color-mix(in srgb,var(--accent-soft) 22%,transparent)}.activity-record>summary>strong{min-width:0;color:var(--ink);font-size:14px}.activity-record>summary>.page-status{margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activity-record-new>summary{background:color-mix(in srgb,var(--primary-command-surface) 18%,transparent)}.activity-editor{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding:0 14px 14px;border-top:1px solid var(--line)}.activity-editor>.console-field{min-width:0}.activity-editor input[readonly]{color:var(--ink-faint);background:color-mix(in srgb,var(--control-surface) 72%,transparent)}.activity-region-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.activity-map-picker,.activity-items-section,.activity-editor-actions{grid-column:1/-1}.activity-map-picker{display:grid;gap:7px;padding-top:2px}.activity-map-picker-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;flex-wrap:wrap}.activity-map-picker-head strong{color:var(--ink);font-size:13px}.activity-map-picker-head .page-status{margin:0}.activity-map-picker-board{position:relative;height:clamp(260px,40vh,480px);overflow:hidden;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface-solid);touch-action:none;cursor:crosshair}.activity-map-picker-scene{position:absolute;inset:0;background-position:center;background-repeat:no-repeat;background-size:100% 100%}.activity-map-picker-scene .map-grid-overlay{z-index:1}.activity-map-region{position:absolute;z-index:3;display:inline-flex;align-items:center;justify-content:center;min-width:24px;min-height:20px;padding:2px 5px;border:1px solid color-mix(in srgb,var(--teal) 70%,var(--line));border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 84%,transparent);color:var(--ink);font-size:10px;line-height:1;transform:translate(-50%,-50%);cursor:pointer}.activity-map-region:hover{border-color:var(--accent);color:var(--accent-deep)}.activity-map-selection{position:absolute;z-index:2;display:block;border:2px solid var(--gold);border-radius:50%;background:color-mix(in srgb,var(--gold) 15%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--surface-solid) 78%,transparent);transform:translate(-50%,-50%);pointer-events:none}.activity-map-selection::after{content:"";position:absolute;left:50%;top:50%;width:8px;height:8px;border:2px solid var(--gold);border-radius:50%;background:var(--surface-solid);transform:translate(-50%,-50%)}.activity-editor-actions{justify-content:flex-start;padding-top:2px}.activity-editor-actions .danger-command{margin-left:auto}
: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}}
@media (max-width:760px){.activity-editor,.activity-region-fields{grid-template-columns:1fr}.activity-record>summary{align-items:flex-start;flex-wrap:wrap}.activity-record>summary>.page-status{width:100%;margin-left:30px}.activity-editor-actions .danger-command{margin-left:0}.activity-map-picker-board{height:300px}}
.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}
.provider-form input:focus,.provider-form select:focus,.provider-form textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
@@ -92,12 +92,14 @@ type SCUMPlatformSurfaceKey = "players" | "vehicles" | "trajectories" | "vehicle
// Squads and members load once per refresh; hovering, selecting and playback issue 0 API calls.
// Call budget per gifts load/15s refresh: 5 bounded plugin collections + 1 typed SCUM surface.
// Searching, viewing a definition and opening the editor issue 0 additional API calls.
// Call budget per activity load/15s refresh: 5 bounded plugin collections and 0 platform SCUM calls.
// Expanding an activity, picking a map region, and editing its item list issue 0 additional API calls.
const pageCollections: Record<PageKey, PluginCollectionSurfaceKey[]> = {
players: ["members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
squads: ["squads", "members", "flags"],
"live-map": ["mapPoints", "mapRegions", "mapSettings", "flags", "squads", "members", "tradeGoods"],
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents"],
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"]
workflows: ["events", "eventProduces", "eventRuns", "mapRegions", "mapSettings"]
};
const pageSCUMTables: Record<PageKey, SCUMPlatformSurfaceKey[]> = {
@@ -105,7 +107,7 @@ const pageSCUMTables: Record<PageKey, SCUMPlatformSurfaceKey[]> = {
squads: ["players"],
"live-map": ["players", "vehicles", "trajectories", "vehicleLocks"],
gifts: ["players"],
workflows: ["players", "vehicles", "trajectories", "vehicleLocks"]
workflows: []
};
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
@@ -182,7 +184,9 @@ export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: Rec
export async function saveEventDefinition(actions: SCUMWorkspaceActions, event: RecordMap): Promise<unknown> {
const key = requiredKey(event, "id", "活动编号");
return requirePluginData(actions).put(scumCollections.events, key, event);
const storedEvent = { ...event };
if (event.items !== undefined) storedEvent.items = parseGiftItems(event.items);
return requirePluginData(actions).put(scumCollections.events, key, storedEvent);
}
export async function deleteEventDefinition(actions: SCUMWorkspaceActions, key: string, produces: RecordMap[] = []): Promise<void> {
@@ -1,7 +1,6 @@
import {
deleteGiftDefinition,
deleteEventDefinition,
deleteEventProduce,
emptySCUMSurfaceData,
loadSCUMSurface,
parseGiftItems,
@@ -10,7 +9,6 @@ import {
resetPendingGift,
resolveMapBounds,
saveEventDefinition,
saveEventProduce,
saveGiftDefinition,
saveMapSettings,
startEvent,
@@ -30,9 +28,10 @@ type GiftDialogState = { mode: "closed" } | { mode: "view" | "edit"; gift?: Reco
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 CatalogPickTarget = "gift-items" | "activity-items";
type GiftItemDraft = { catalogCode: string; quantity: string; probability: string; durability: string };
type GiftItemDraftField = keyof GiftItemDraft;
type ActivityMapPointerEvent = { pointerId?: number; clientX?: number; clientY?: number; currentTarget?: { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number }; setPointerCapture?: (pointerId?: number) => void; releasePointerCapture?: (pointerId?: number) => void }; preventDefault?: () => void; stopPropagation?: () => void };
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 };
@@ -40,7 +39,6 @@ const scumMapLayerImages = { terrain: new URL("../assets/map/scum-map-terrain-40
const scumMapBaseLayers: ReadonlyArray<readonly [ScumMapLayer, string]> = [["terrain", "地形"], ["topo", "等高线"], ["night", "夜间"]];
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", "全部目录"], ["hunting", "狩猎"], ["food", "食物"], ["metabolism", "代谢"], ["medical", "医疗"], ["weapons", "武器"], ["crafting", "制作"], ["gear", "装备"], ["fishing", "钓鱼"], ["building", "建筑"], ["tools", "工具"], ["farming", "农业"], ["misc", "其他"], ["vehicle", "载具"], ["animal", "动物"], ["zombie", "丧尸"], ["armed-npc", "NPC"]];
const scumCatalogPickLimit = 48;
const scumMapSize = 4096;
@@ -58,6 +56,7 @@ const scumMapTrackColors = ["#4ECDC4", "#FF8A6B", "#F7DC6F", "#85C1E9", "#BB8FCE
const scumMapTrackColorCache = new Map<string, string>();
const scumMapDefaultWindowMs = 60 * 60 * 1000;
const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false };
const activityMapDragState = { active: false, pointerId: -1, startX: 0, startY: 0 };
export type ReactLike = {
createElement: (...args: any[]) => any;
@@ -96,28 +95,16 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [squadSearch, setSquadSearch] = usePluginState(react, "");
const [selectedSquadId, setSelectedSquadId] = usePluginState(react, "");
const [activityStatus, setActivityStatus] = usePluginState(react, "all");
const [activityDetailId, setActivityDetailId] = usePluginState(react, "");
const [eventId, setEventId] = usePluginState(react, "");
const [eventName, setEventName] = usePluginState(react, "");
const [eventType, setEventType] = usePluginState(react, "range");
const [eventSchedule, setEventSchedule] = usePluginState(react, "");
const [eventCommand, setEventCommand] = usePluginState(react, "");
const [eventClass, setEventClass] = usePluginState(react, "1");
const [eventPlacard, setEventPlacard] = usePluginState(react, "");
const [eventPercent, setEventPercent] = usePluginState(react, "100");
const [eventDuration, setEventDuration] = usePluginState(react, "1800");
const [eventNpc, setEventNpc] = usePluginState(react, "0");
const [eventItem, setEventItem] = usePluginState(react, "0");
const [eventZombie, setEventZombie] = usePluginState(react, "0");
const [eventAnimal, setEventAnimal] = usePluginState(react, "0");
const [produceEventId, setProduceEventId] = usePluginState(react, "");
const [produceId, setProduceId] = usePluginState(react, "");
const [produceTradeGoodsId, setProduceTradeGoodsId] = usePluginState(react, "");
const [producePercent, setProducePercent] = usePluginState(react, "100");
const [produceValue, setProduceValue] = usePluginState(react, "1");
const [produceRadius, setProduceRadius] = usePluginState(react, "0");
const [produceX, setProduceX] = usePluginState(react, "0");
const [produceY, setProduceY] = usePluginState(react, "0");
const [produceZ, setProduceZ] = usePluginState(react, "0");
const [eventItems, setEventItems] = usePluginState<GiftItemDraft[]>(react, []);
const [eventRegionName, setEventRegionName] = usePluginState(react, "");
const [eventRegionRadius, setEventRegionRadius] = usePluginState(react, "5000");
const [eventRegionX, setEventRegionX] = usePluginState(react, "");
const [eventRegionY, setEventRegionY] = usePluginState(react, "");
const [eventRegionZ, setEventRegionZ] = usePluginState(react, "0");
const [giftTab, setGiftTab] = usePluginState<GiftTab>(react, "definitions");
const [giftSearch, setGiftSearch] = usePluginState(react, "");
const [giftStatus, setGiftStatus] = usePluginState(react, "all");
@@ -146,8 +133,6 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const mapViewportRef = react.useRef ? react.useRef<HTMLElement | null>(null) : { current: null as HTMLElement | null };
const mapPlaybackFrameRef = react.useRef ? react.useRef<MapPlaybackFrame | null>(null) : { current: null as MapPlaybackFrame | null };
const mapFollowRef = react.useRef ? react.useRef(true) : { current: true };
const [eventEditorOpen, setEventEditorOpen] = usePluginState(react, false);
const [produceEditorOpen, setProduceEditorOpen] = usePluginState(react, false);
const [giftDialog, setGiftDialog] = usePluginState<GiftDialogState>(react, { mode: "closed" });
const [mapBaseLayer, setMapBaseLayer] = usePluginState<ScumMapLayer | undefined>(react, undefined);
const [mapZonesOverlay, setMapZonesOverlay] = usePluginState<boolean | undefined>(react, undefined);
@@ -164,8 +149,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
if (inFlight || disposed) return;
inFlight = true;
const currentGeneration = ++generation;
if (!input.serverInstanceId || !input.workspaceActions?.scum || !input.workspaceActions?.pluginData) {
setState({ status: "error", reason: "插件页面没有绑定服务器、平台 SCUM 数据能力或通用 pluginData 能力。" });
if (!input.serverInstanceId || !input.workspaceActions?.pluginData) {
setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" });
inFlight = false;
return;
}
@@ -258,15 +243,14 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const mapData = react.useMemo ? react.useMemo(deriveMap, [pageKey, data, mapTimeFrom, mapTimeTo, mapUsers, mapVehicleFilter]) : deriveMap();
const view: ViewState = {
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
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,
activityStatus, setActivityStatus, activityDetailId, setActivityDetailId, eventId, setEventId, eventName, setEventName,
eventCommand, setEventCommand, eventItems, setEventItems, eventRegionName, setEventRegionName, eventRegionRadius, setEventRegionRadius, eventRegionX, setEventRegionX, eventRegionY, setEventRegionY, eventRegionZ, setEventRegionZ,
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,
mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback,
eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftDialog, setGiftDialog,
giftDialog, setGiftDialog,
mapBaseLayer, setMapBaseLayer, mapZonesOverlay, setMapZonesOverlay, catalogSearch, setCatalogSearch, catalogKind, setCatalogKind, catalogOpen, setCatalogOpen,
setAction, refresh: () => setRefreshSignal((value) => value + 1),
mapData, mapSceneContents: null, mapStaticContents: null, mapSelectedTrails: [], mapSelectedLocks: []
@@ -302,15 +286,10 @@ type ViewState = {
playerSearch: string; setPlayerSearch: StateSetter<string>; playerStatus: string; setPlayerStatus: StateSetter<string>;
playerPanel: PlayerPanelState; setPlayerPanel: StateSetter<PlayerPanelState>;
squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>;
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>; giftSearch: string; setGiftSearch: StateSetter<string>; giftStatus: string; setGiftStatus: StateSetter<string>; giftRecordKey: string; setGiftRecordKey: StateSetter<string>;
activityStatus: string; setActivityStatus: StateSetter<string>; activityDetailId: string; setActivityDetailId: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>; giftSearch: string; setGiftSearch: StateSetter<string>; giftStatus: string; setGiftStatus: StateSetter<string>; giftRecordKey: string; setGiftRecordKey: StateSetter<string>;
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
eventType: string; setEventType: StateSetter<string>; eventSchedule: string; setEventSchedule: StateSetter<string>;
eventCommand: string; setEventCommand: StateSetter<string>;
eventClass: string; setEventClass: StateSetter<string>; eventPlacard: string; setEventPlacard: StateSetter<string>; eventPercent: string; setEventPercent: StateSetter<string>;
eventDuration: string; setEventDuration: StateSetter<string>;
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>;
eventItems: GiftItemDraft[]; setEventItems: StateSetter<GiftItemDraft[]>; eventRegionName: string; setEventRegionName: StateSetter<string>; eventRegionRadius: string; setEventRegionRadius: StateSetter<string>; eventRegionX: string; setEventRegionX: StateSetter<string>; eventRegionY: string; setEventRegionY: StateSetter<string>; eventRegionZ: string; setEventRegionZ: 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>;
@@ -321,7 +300,7 @@ type ViewState = {
mapFollowRef: { current: boolean };
mapTimePreset: string; setMapTimePreset: StateSetter<string>; mapTimeFrom: string; setMapTimeFrom: StateSetter<string>; mapTimeTo: string; setMapTimeTo: StateSetter<string>;
mapUsers: string[] | undefined; setMapUsers: StateSetter<string[] | undefined>; mapTrackKey: string; setMapTrackKey: StateSetter<string>; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter<MapPlaybackState>;
eventEditorOpen: boolean; setEventEditorOpen: StateSetter<boolean>; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter<boolean>; giftDialog: GiftDialogState; setGiftDialog: StateSetter<GiftDialogState>;
giftDialog: GiftDialogState; setGiftDialog: StateSetter<GiftDialogState>;
mapBaseLayer: ScumMapLayer | undefined; setMapBaseLayer: StateSetter<ScumMapLayer | undefined>; mapZonesOverlay: boolean | undefined; setMapZonesOverlay: StateSetter<boolean | undefined>;
catalogSearch: string; setCatalogSearch: StateSetter<string>; catalogKind: string; setCatalogKind: StateSetter<string>; catalogOpen: boolean; setCatalogOpen: StateSetter<boolean>;
};
@@ -502,105 +481,165 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
return view.activityStatus === "all" || status === view.activityStatus;
});
const statuses = unique(data.events.map((event) => textField(runsByEvent.get(textField(event, "id", "eventId")), "status", "state") || textField(event, "status", "state")).filter(Boolean));
const tradeEvents = recentRows(data.tradeEvents, "observedAt", "occurredAt", "createdAt");
const openNewActivity = () => {
view.setActivityDetailId("__new__"); view.setEventId(""); view.setEventName(""); view.setEventCommand(""); view.setEventItems([]);
view.setEventRegionName(""); view.setEventRegionRadius("5000"); view.setEventRegionX(""); view.setEventRegionY(""); view.setEventRegionZ("0");
};
const openActivity = (event: RecordMap) => {
const eventId = textField(event, "id", "eventId");
const produce = data.eventProduces.find((row) => textField(row, "eventId", "event") === eventId);
const region = activityRegionDraft(event, produce);
view.setActivityDetailId(eventId); view.setEventId(eventId); view.setEventName(textField(event, "title", "name")); view.setEventCommand(textField(event, "rconCommand", "command"));
view.setEventItems(activityItemDrafts(event, data.eventProduces)); view.setEventRegionName(region.name); view.setEventRegionRadius(region.radius); view.setEventRegionX(region.x); view.setEventRegionY(region.y); view.setEventRegionZ(region.z);
};
const saveEvent = () => runAction(view.setAction, "正在保存活动定义…", async () => {
const id = view.eventId.trim();
const name = view.eventName.trim();
if (!id || !name) throw new Error("活动编号和名称不能为空。");
await saveEventDefinition(actions ?? {}, {
id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(),
rconCommand: view.eventCommand.trim(), command: view.eventCommand.trim(),
placard: view.eventPlacard.trim(), announcement: view.eventPlacard.trim(), percent: integerInput(view.eventPercent, 100), probability: integerInput(view.eventPercent, 100),
npc: minimumIntegerInput(view.eventNpc, 0, "NPC 数量"), item: minimumIntegerInput(view.eventItem, 0, "物品数量"), zombie: minimumIntegerInput(view.eventZombie, 0, "僵尸数量"), animal: minimumIntegerInput(view.eventAnimal, 0, "动物数量"),
status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: minimumIntegerInput(view.eventDuration, 1, "活动持续秒数"), updatedAt: new Date().toISOString()
});
const title = view.eventName.trim();
if (!id || !title) throw new Error("活动编号和活动标题不能为空。");
if (!/^[A-Za-z0-9_.:-]{1,96}$/.test(id)) throw new Error("活动编号只能使用字母、数字、点、下划线、短横线或冒号。");
const items = parseGiftItems(view.eventItems);
if (!items.length) throw new Error("活动至少要包含一项物品或 NPC。");
const region = activityRegionValue(view);
const previous = data.events.find((event) => textField(event, "id", "eventId") === id);
const legacy = previous ? {
eventType: textField(previous, "eventType") || "range", class: integerInput(textField(previous, "class"), 1), schedule: textField(previous, "schedule", "corn"), corn: textField(previous, "corn", "schedule"),
rconCommand: view.eventCommand.trim() || textField(previous, "rconCommand", "command"), command: view.eventCommand.trim() || textField(previous, "command", "rconCommand"),
placard: textField(previous, "placard", "announcement"), announcement: textField(previous, "announcement", "placard"), percent: integerInput(textField(previous, "percent", "probability"), 100), probability: integerInput(textField(previous, "probability", "percent"), 100),
npc: activityCount(items, "armed-npc", textField(previous, "npc")), item: activityCount(items, "item", textField(previous, "item")), zombie: activityCount(items, "zombie", textField(previous, "zombie")), animal: activityCount(items, "animal", textField(previous, "animal")),
status: textField(previous, "status") || "enabled", announce: boolField(previous, "announce") || Boolean(textField(previous, "placard", "announcement")), durationSeconds: integerInput(textField(previous, "durationSeconds"), 1800)
} : { eventType: "range", class: 1, rconCommand: view.eventCommand.trim(), command: view.eventCommand.trim(), percent: 100, probability: 100, npc: activityCount(items, "armed-npc"), item: activityCount(items, "item"), zombie: activityCount(items, "zombie"), animal: activityCount(items, "animal"), status: "enabled", announce: false, durationSeconds: 1800 };
await saveEventDefinition(actions ?? {}, { ...legacy, id, name: title, title, region, area: region, items, produces: activityProduceRecords(id, items, region), updatedAt: new Date().toISOString() });
view.setActivityDetailId(id);
view.refresh();
return `活动 ${name} 已保存。`;
return `活动 ${title} 已保存。`;
});
const saveProduce = () => runAction(view.setAction, "正在保存活动生成项…", async () => {
const eventId = view.produceEventId.trim() || view.eventId.trim() || textField(data.events[0], "id", "eventId");
const tradeGoodsId = view.produceTradeGoodsId.trim();
if (!eventId || !tradeGoodsId) throw new Error("活动编号和物品编号不能为空。");
await saveEventProduce(actions ?? {}, {
id: view.produceId.trim(), eventId, tradeGoodsId, percent: integerInput(view.producePercent, 100), value: integerInput(view.produceValue, 1),
r: numberInput(view.produceRadius, 0), x: numberInput(view.produceX, 0), y: numberInput(view.produceY, 0), z: numberInput(view.produceZ, 0), updatedAt: new Date().toISOString()
});
view.refresh();
return "活动生成项已保存。";
});
const activityHistory = data.activityEvents.filter((event) => Boolean(textField(event, "occurredAt", "createdAt")) && textField(event, "taskKind").toLowerCase() !== "active-task");
return e("div", { className: "console-record-list" },
statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["物品列表", data.tradeGoods.length], ["交易记录", data.tradeEvents.length]]),
e("div", { className: "overview-two-col" },
e("details", { className: "console-module scum-editor", open: view.eventEditorOpen, onToggle: (event: InputEvent) => view.setEventEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新活动"), e("span", { className: "page-status" }, view.eventId || view.eventName ? "编辑中" : "点击展开")),
labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", onChange: (event: InputEvent) => view.setEventId(inputValue(event)) })),
labeledField(e, "活动名称", e("input", { value: view.eventName, "aria-label": "活动名称", placeholder: "例如 空投补给", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) })),
labeledField(e, "生成类型", e("select", { value: view.eventClass, "aria-label": "生成类型", onChange: (event: InputEvent) => view.setEventClass(inputValue(event)) }, e("option", { value: "1" }, "范围生成"), e("option", { value: "2" }, "固定坐标生成"))),
labeledField(e, "活动计划", e("input", { value: view.eventSchedule, "aria-label": "活动计划", placeholder: "Cron(留空为手动)", onChange: (event: InputEvent) => view.setEventSchedule(inputValue(event)) })),
labeledField(e, "RCON 命令", e("input", { value: view.eventCommand, "aria-label": "RCON 命令", placeholder: "例如 #start_event event_supply_drop", onChange: (event: InputEvent) => view.setEventCommand(inputValue(event)) })),
labeledField(e, "活动公告", e("input", { value: view.eventPlacard, "aria-label": "活动公告", placeholder: "活动开始时显示的公告", onChange: (event: InputEvent) => view.setEventPlacard(inputValue(event)) })),
labeledField(e, "触发概率 (%)", e("input", { value: view.eventPercent, "aria-label": "活动概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setEventPercent(inputValue(event)) })),
labeledField(e, "持续秒数", e("input", { value: view.eventDuration, "aria-label": "活动持续秒数", type: "number", min: 1, placeholder: "1800", onChange: (event: InputEvent) => view.setEventDuration(inputValue(event)) })),
e("div", { className: "console-row-actions" },
labeledField(e, "NPC 数量", e("input", { value: view.eventNpc, "aria-label": "NPC 数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventNpc(inputValue(event)) })),
labeledField(e, "物品数量", e("input", { value: view.eventItem, "aria-label": "物品数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventItem(inputValue(event)) })),
labeledField(e, "僵尸数量", e("input", { value: view.eventZombie, "aria-label": "僵尸数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventZombie(inputValue(event)) })),
labeledField(e, "动物数量", e("input", { value: view.eventAnimal, "aria-label": "动物数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventAnimal(inputValue(event)) }))),
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveEvent }, "保存活动")
const renderEditor = (event?: RecordMap) => {
const eventId = event ? textField(event, "id", "eventId") : "__new__";
const isNew = eventId === "__new__";
return e("div", { className: "activity-editor" },
labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", readOnly: !isNew, onChange: (event: InputEvent) => view.setEventId(inputValue(event)) })),
labeledField(e, "活动标题", e("input", { value: view.eventName, "aria-label": "活动标题", placeholder: "例如 空投补给", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) })),
labeledField(e, "区域名称", e("input", { value: view.eventRegionName, "aria-label": "活动区域名称", placeholder: "例如 北部机场补给区", onChange: (event: InputEvent) => view.setEventRegionName(inputValue(event)) })),
e("div", { className: "activity-region-fields" },
labeledField(e, "区域半径", e("input", { value: view.eventRegionRadius, "aria-label": "活动区域半径", type: "number", min: "1", step: "1", placeholder: "5000", onChange: (event: InputEvent) => view.setEventRegionRadius(inputValue(event)) })),
labeledField(e, "中心 Z", e("input", { value: view.eventRegionZ, "aria-label": "活动区域 Z", type: "number", onChange: (event: InputEvent) => view.setEventRegionZ(inputValue(event)) }))
),
e("article", { className: "console-module" }, e("h2", null, "状态筛选"),
e("select", { value: view.activityStatus, "aria-label": "活动状态", onChange: (event: InputEvent) => view.setActivityStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), statuses.map((status) => e("option", { key: status, value: status }, status))),
e("p", { className: "page-status" }, "活动定义属于插件;SCUM 原生 event_round 和 quest/task 只作为运行事实展示。")
activityMapPicker(e, data, view),
e("div", { className: "activity-region-fields activity-region-coordinates" },
labeledField(e, "中心 X", e("input", { value: view.eventRegionX, "aria-label": "活动区域 X", type: "number", onChange: (event: InputEvent) => view.setEventRegionX(inputValue(event)) })),
labeledField(e, "中心 Y", e("input", { value: view.eventRegionY, "aria-label": "活动区域 Y", type: "number", onChange: (event: InputEvent) => view.setEventRegionY(inputValue(event)) }))
),
e("div", { className: "gift-form-wide gift-form-section activity-items-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "活动物品 / NPC"), e("span", null, "复用礼包目录;每项配置数量、概率和耐久")), activityItemEditorList(e, view), catalogPicker(e, view, "activity-items")),
e("div", { className: "console-row-actions activity-editor-actions" },
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveEvent }, "保存活动"),
event && textField(event, "rconCommand", "command") ? e("button", { type: "button", className: "icon-command", disabled: !actions?.dispatch || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动已进入执行队列。"; }) }, "立即启动") : null,
event ? e("button", { type: "button", className: "icon-command danger-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.setActivityDetailId(""); view.refresh(); return "活动已删除。"; }) }, "删除") : null
)
);
};
return e("div", { className: "console-record-list activity-list" },
e("div", { className: "resource-filter-bar scum-filter-bar activity-list-toolbar" },
e("span", { className: "page-status" }, `${events.length} / ${data.events.length} 个活动`),
labeledField(e, "活动状态", e("select", { value: view.activityStatus, "aria-label": "活动状态", onChange: (event: InputEvent) => view.setActivityStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), statuses.map((status) => e("option", { key: status, value: status }, status)))),
e("button", { type: "button", className: "primary-command activity-create-command", disabled: !actions?.pluginData, onClick: openNewActivity }, "新建活动")
),
e("details", { className: "console-module scum-editor", open: view.produceEditorOpen, onToggle: (event: InputEvent) => view.setProduceEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "活动生成项"), e("span", { className: "page-status" }, view.produceEventId || view.produceId ? "编辑中" : "点击展开")),
e("div", { className: "console-row-actions" },
labeledField(e, "活动编号", e("input", { value: view.produceEventId, "aria-label": "生成项活动编号", placeholder: "默认使用上方活动", onChange: (event: InputEvent) => view.setProduceEventId(inputValue(event)) })),
labeledField(e, "生成项编号", e("input", { value: view.produceId, "aria-label": "生成项编号", placeholder: "更新时填写", onChange: (event: InputEvent) => view.setProduceId(inputValue(event)) })),
labeledField(e, "物品编号", e("input", { value: view.produceTradeGoodsId, "aria-label": "生成物品编号", placeholder: "TradeGoods ID", onChange: (event: InputEvent) => view.setProduceTradeGoodsId(inputValue(event)) })),
labeledField(e, "概率 (%)", e("input", { value: view.producePercent, "aria-label": "生成概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setProducePercent(inputValue(event)) })),
labeledField(e, "数量", e("input", { value: view.produceValue, "aria-label": "生成数量", type: "number", placeholder: "1", onChange: (event: InputEvent) => view.setProduceValue(inputValue(event)) }))),
catalogPicker(e, view, "produce-item"),
e("div", { className: "console-row-actions" },
labeledField(e, "半径", e("input", { value: view.produceRadius, "aria-label": "生成半径", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceRadius(inputValue(event)) })),
labeledField(e, "X", e("input", { value: view.produceX, "aria-label": "生成 X", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceX(inputValue(event)) })),
labeledField(e, "Y", e("input", { value: view.produceY, "aria-label": "生成 Y", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceY(inputValue(event)) })),
labeledField(e, "Z", e("input", { value: view.produceZ, "aria-label": "生成 Z", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceZ(inputValue(event)) })),
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveProduce }, "保存生成项")),
e("div", { className: "console-row-list" }, data.eventProduces.length ? data.eventProduces.map((produce, index) => e("div", { key: idOf(produce, `produce-${index}`), className: "console-row" },
catalogIconStrip(e, catalogIconsForValue(textField(produce, "tradeGoodsId", "trade_goods_id"))),
e("span", null, `${textField(produce, "eventId", "event")} / ${tradeGoodsLabel(data.tradeGoods, textField(produce, "tradeGoodsId", "trade_goods_id"))}`),
e("strong", null, `${numField(produce, "percent")}% × ${numField(produce, "value")}`),
e("strong", null, `R ${numField(produce, "r")} · ${coords(produce)}`),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setProduceEditorOpen(true); view.setProduceEventId(textField(produce, "eventId", "event")); view.setProduceId(textField(produce, "id", "produceId")); view.setProduceTradeGoodsId(textField(produce, "tradeGoodsId", "trade_goods_id")); view.setProducePercent(numField(produce, "percent")); view.setProduceValue(numField(produce, "value")); view.setProduceRadius(numField(produce, "r")); view.setProduceX(numField(produce, "x")); view.setProduceY(numField(produce, "y")); view.setProduceZ(numField(produce, "z")); } }, "编辑"),
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除生成项…", async () => { await deleteEventProduce(actions ?? {}, produce); view.refresh(); return "生成项已删除。"; }) }, "删除"))) : e("p", { className: "page-status" }, "暂无活动生成项。"))
e("details", { className: "console-module activity-record activity-record-new", open: view.activityDetailId === "__new__", onToggle: (event: InputEvent) => { if (detailOpen(event)) openNewActivity(); else if (view.activityDetailId === "__new__") view.setActivityDetailId(""); } },
e("summary", null, e("strong", null, "新建活动"), e("span", { className: "page-status" }, "展开配置活动区域和发放清单")),
view.activityDetailId === "__new__" ? renderEditor() : null
),
events.length ? events.map((event, index) => {
const eventId = textField(event, "id", "eventId");
const run = runsByEvent.get(eventId);
const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown";
return e("article", { key: idOf(event, `event-${index}`), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(event, "name", "title") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status)),
e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")),
e("div", { className: "console-row-actions" },
e("button", { type: "button", className: "primary-command", disabled: !actions?.dispatch || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventEditorOpen(true); view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventCommand(textField(event, "rconCommand", "command")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"),
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除")
)
const region = activityRegionDraft(event, data.eventProduces.find((row) => textField(row, "eventId", "event") === eventId));
const items = activityItemDrafts(event, data.eventProduces);
return e("details", { key: idOf(event, `event-${index}`), className: "console-module activity-record", open: view.activityDetailId === eventId, onToggle: (toggle: InputEvent) => { if (detailOpen(toggle)) openActivity(event); else if (view.activityDetailId === eventId) view.setActivityDetailId(""); } },
e("summary", null, e("strong", null, textField(event, "title", "name") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status), e("span", { className: "page-status" }, `${region.name || "未命名区域"} · ${items.length} 项物品 / NPC`)),
view.activityDetailId === eventId ? renderEditor(event) : null
);
}) : e("p", { className: "page-status" }, "没有符合状态筛选的真实活动。"),
e("div", { className: "overview-two-col" },
tablePanel(e, "原生赛事轮次", data.nativeEventRounds, (event) => [textField(event, "eventId") || "event", textField(event, "state") || "unknown", `Kills ${numField(event, "enemyKills")}`, dateField(event, "startTime")]),
tablePanel(e, "Quest / Task", data.tasks, (task) => [textField(task, "taskKind") || "task", textField(task, "dataAssetPath") || "unknown", textField(task, "state") || "unknown", textField(task, "userProfileId") || "unknown"])
),
e("div", { className: "overview-two-col" },
itemCatalogPanel(e, data.tradeGoods),
tablePanel(e, "最近商人交易", tradeEvents, (event) => [tradeGoodsLabel(data.tradeGoods, textField(event, "itemCode", "code", "tradeGoodsId")), `${tradeActionLabel(event)} × ${numField(event, "quantity", "itemCount")}`, `玩家 ${shortHash(textField(event, "steamId", "playerId"))}`, `价格 ${numField(event, "price", "currencyDelta")}`, dateField(event, "observedAt", "occurredAt", "createdAt")])
),
tablePanel(e, "最近活动记录", [...data.eventRuns, ...activityHistory], (event) => [textField(event, "eventName", "type", "kind", "activityType") || "event", textField(event, "subjectName", "eventId", "subjectId", "subject") || "unknown", textField(event, "status", "result", "state") || "unknown", dateField(event, "startedAt", "occurredAt", "createdAt")])
}) : e("p", { className: "page-status" }, "暂无活动,请先新建活动。")
);
}
function activityItemEditorList(e: ReactLike["createElement"], view: ViewState) { return catalogItemEditorList(e, view.eventItems, view.setEventItems, "活动物品"); }
function activityRegionDraft(event: RecordMap, produce?: RecordMap): { name: string; radius: string; x: string; y: string; z: string } {
const nested = field(event, "region", "area", "location");
const source = isRecord(nested) ? nested : event;
const fallback = produce ? positionOf(produce) : undefined;
const value = (keys: string[], fallbackValue = "") => { const direct = textField(source, ...keys); return direct || (fallback ? textField(fallback, ...keys) : fallbackValue); };
const name = isRecord(nested) ? value(["name", "label", "regionName", "areaName"]) : value(["regionName", "areaName", "regionLabel"]);
return { name, radius: value(["radius", "r", "size"], "5000"), x: value(["x", "locationX"]), y: value(["y", "locationY"]), z: value(["z", "locationZ"], "0") };
}
function activityItemDrafts(event: RecordMap, produces: RecordMap[]): GiftItemDraft[] {
const direct = giftItemDrafts(event);
if (direct.length) return direct;
const eventId = textField(event, "id", "eventId");
return produces.filter((produce) => textField(produce, "eventId", "event") === eventId).map((produce) => ({ catalogCode: textField(produce, "catalogCode", "tradeGoodsId", "trade_goods_id"), quantity: numField(produce, "quantity", "value") === "--" ? "1" : numField(produce, "quantity", "value"), probability: numField(produce, "probability", "percent") === "--" ? "100" : numField(produce, "probability", "percent"), durability: giftItemRawDurability(produce) }));
}
function activityRegionValue(view: ViewState): { name: string; radius: number; x: number; y: number; z: number } {
const radius = Number(view.eventRegionRadius); const x = Number(view.eventRegionX); const y = Number(view.eventRegionY); const z = Number(view.eventRegionZ);
if (!view.eventRegionX.trim() || !view.eventRegionY.trim() || ![radius, x, y, z].every(Number.isFinite) || radius <= 0) throw new Error("请在地图上选择活动区域,并填写有效的区域半径。");
return { name: view.eventRegionName.trim(), radius, x, y, z };
}
function activityProduceRecords(eventId: string, items: Array<{ catalogCode: string; quantity: number | string; probability: number | string; durability?: number | string }>, region: { radius: number; x: number; y: number; z: number }): RecordMap[] {
return items.map((item, index) => { const quantity = Number(item.quantity); const probability = Number(item.probability); const durability = Number(item.durability); return { id: `produce-${index + 1}`, eventId, tradeGoodsId: item.catalogCode, catalogCode: item.catalogCode, percent: Number.isFinite(probability) ? probability : 100, probability: Number.isFinite(probability) ? probability : 100, value: Number.isSafeInteger(quantity) && quantity > 0 ? quantity : 1, quantity: Number.isSafeInteger(quantity) && quantity > 0 ? quantity : 1, ...(Number.isFinite(durability) ? { durability } : {}), r: region.radius, x: region.x, y: region.y, z: region.z }; });
}
function activityCount(items: Array<{ catalogCode: string; quantity: number | string }>, kind: ScumCatalogEntry["kind"], fallback = "0"): number {
const count = items.filter((item) => scumCatalogEntryFor(item.catalogCode)?.kind === kind).reduce((total, item) => total + (Number(item.quantity) || 0), 0);
return count || Number(fallback) || 0;
}
function activityMapPicker(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) {
const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0];
const bounds = resolveMapBounds(settings);
const selected = view.eventRegionX.trim() && view.eventRegionY.trim() && Number.isFinite(Number(view.eventRegionX)) && Number.isFinite(Number(view.eventRegionY)) ? { x: Number(view.eventRegionX), y: Number(view.eventRegionY) } : undefined;
const selectedStyle = selected ? activityMapSelectionStyle(selected, Number(view.eventRegionRadius) || 5000, bounds) : undefined;
const choose = (event: ActivityMapPointerEvent) => {
const point = activityMapWorldPoint(event, bounds);
if (!point) return;
view.setEventRegionX(String(Math.round(point.x))); view.setEventRegionY(String(Math.round(point.y)));
const start = activityMapDragState;
if (start.active) {
const startPoint = activityMapWorldPoint({ ...event, clientX: start.startX, clientY: start.startY, currentTarget: event.currentTarget }, bounds);
if (startPoint) {
const radius = Math.round(Math.hypot(point.x - startPoint.x, point.y - startPoint.y));
if (radius > 0) view.setEventRegionRadius(String(radius));
}
}
activityMapDragState.active = false;
};
return e("div", { className: "activity-map-picker" },
e("div", { className: "activity-map-picker-head" }, e("strong", null, "活动区域"), e("span", { className: "page-status" }, "点击选择中心;从中心拖动可直接设置半径")),
e("div", { className: "activity-map-picker-board", role: "application", "aria-label": "选择活动区域地图", onPointerDown: (event: ActivityMapPointerEvent) => { activityMapDragState.active = true; activityMapDragState.pointerId = event.pointerId ?? -1; activityMapDragState.startX = event.clientX ?? 0; activityMapDragState.startY = event.clientY ?? 0; event.currentTarget?.setPointerCapture?.(event.pointerId); }, onPointerUp: choose, onPointerCancel: () => { activityMapDragState.active = false; } },
e("div", { className: "activity-map-picker-scene", style: { backgroundImage: mapBoardStyle(mapLayerSetting(settings), false) } },
mapGridOverlay(e),
data.mapRegions.map((region, index) => { const position = positionOf(region); return hasCoordinates(position) ? e("button", { key: idOf(region, `map-region-${index}`), type: "button", className: "activity-map-region", style: mapPointStyle(position, bounds), title: textField(region, "name", "label", "id") || "已有地图区域", onPointerDown: (event: ActivityMapPointerEvent) => event.stopPropagation?.(), onClick: (event: ActivityMapPointerEvent) => { event.stopPropagation?.(); view.setEventRegionName(textField(region, "name", "label")); view.setEventRegionX(String(Number(field(position, "x", "locationX")))); view.setEventRegionY(String(Number(field(position, "y", "locationY")))); view.setEventRegionZ(String(Number(field(position, "z", "locationZ") ?? 0))); view.setEventRegionRadius(numField(region, "radius", "r") === "--" ? "5000" : numField(region, "radius", "r")); } }, textField(region, "name", "label") || "区") : null; }),
selectedStyle ? e("span", { className: "activity-map-selection", style: selectedStyle, "aria-label": "当前活动区域" }) : null
)
),
e("p", { className: "page-status" }, `当前区域:${view.eventRegionName || "未命名"} · ${view.eventRegionX || "未选择"} / ${view.eventRegionY || "未选择"} · 半径 ${view.eventRegionRadius || "未设置"}`)
);
}
function activityMapWorldPoint(event: ActivityMapPointerEvent, bounds: { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number }): { x: number; y: number } | undefined {
const rect = event.currentTarget?.getBoundingClientRect?.();
if (!rect || !rect.width || !rect.height || event.clientX === undefined || event.clientY === undefined) return undefined;
const horizontal = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const vertical = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
return { x: bounds.worldMaxX - horizontal * (bounds.worldMaxX - bounds.worldMinX), y: bounds.worldMaxY - vertical * (bounds.worldMaxY - bounds.worldMinY) };
}
function activityMapSelectionStyle(point: { x: number; y: number }, radius: number, bounds: { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number }): Record<string, string> {
const center = mapPointStyle(point, bounds);
const diameter = Math.max(2, Math.min(80, radius * 2 / Math.max(bounds.worldMaxX - bounds.worldMinX, bounds.worldMaxY - bounds.worldMinY) * 100));
return { ...center, width: `${diameter}%`, height: `${diameter}%` };
}
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const actions = input.workspaceActions;
const openNewGift = () => {
@@ -706,9 +745,11 @@ 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) => {
function giftItemEditorList(e: ReactLike["createElement"], view: ViewState) { return catalogItemEditorList(e, view.giftItems, view.setGiftItems, "礼包物品"); }
function catalogItemEditorList(e: ReactLike["createElement"], items: GiftItemDraft[], setItems: StateSetter<GiftItemDraft[]>, labelPrefix: string) {
if (!items.length) return e("p", { className: "page-status" }, `尚未添加物品,请使用目录选择器加入${labelPrefix}`);
return e("div", { className: "gift-item-editor-list" }, items.map((item, index) => {
const entry = scumCatalogEntryFor(item.catalogCode);
const supportsDurability = giftItemSupportsDurability(entry);
const itemLabel = entry ? scumCatalogDisplayName(entry) : item.catalogCode || "未命名物品";
@@ -718,17 +759,17 @@ function giftItemEditorList(e: ReactLike["createElement"], view: ViewState) {
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)) }, "删除")
labeledField(e, "数量", e("input", { type: "number", min: "1", step: "1", value: item.quantity, "aria-label": `${labelPrefix} ${itemNumber} 数量`, onChange: (event: InputEvent) => updateCatalogItemDraft(setItems, index, "quantity", inputValue(event)) })),
labeledField(e, "概率", e("input", { type: "number", min: "0", max: "100", step: "1", value: item.probability, "aria-label": `${labelPrefix} ${itemNumber} 概率`, onChange: (event: InputEvent) => updateCatalogItemDraft(setItems, 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": `${labelPrefix} ${itemNumber} 耐久`, onChange: (event: InputEvent) => updateCatalogItemDraft(setItems, index, "durability", inputValue(event)) })),
e("button", { type: "button", className: "gift-item-editor-remove icon-command", "aria-label": `删除${labelPrefix} ${itemNumber}`, onClick: () => setItems((current) => current.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 updateCatalogItemDraft(setItems: StateSetter<GiftItemDraft[]>, index: number, key: GiftItemDraftField, value: string): void {
setItems((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item));
}
function giftDetailsDialog(e: ReactLike["createElement"], gift: RecordMap, view: ViewState, editGift: () => void) {
@@ -1387,7 +1428,7 @@ function mapLayerLabel(layer: ScumMapLayer): string { return layer === "topo" ?
function catalogMatches(query: string, kind: string, target: CatalogPickTarget): ScumCatalogEntry[] {
const search = query.trim().toLowerCase();
return scumCatalogEntries.filter((entry) => {
const matchesKind = target === "gift-items" ? kind === "all" || scumGiftCatalogFilterKey(entry) === kind : kind === "all" || entry.kind === kind;
const matchesKind = kind === "all" || scumGiftCatalogFilterKey(entry) === kind;
return matchesKind && (!search || scumCatalogSearchText(entry).includes(search));
}).slice(0, scumCatalogPickLimit);
}
@@ -1414,15 +1455,15 @@ function giftCatalogIcons(gift: RecordMap): string[] {
}
function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) {
const filters = target === "gift-items" ? scumGiftCatalogCategories : scumCatalogKinds;
const filters = scumGiftCatalogCategories;
const activeFilter = filters.some(([key]) => key === view.catalogKind) ? view.catalogKind : "all";
const matches = catalogMatches(view.catalogSearch, activeFilter, target);
return e("details", { className: "console-module scum-editor scum-catalog-picker", open: view.catalogOpen, onToggle: (event: InputEvent) => view.setCatalogOpen(detailOpen(event)) },
e("summary", null, e("strong", null, target === "gift-items" ? "从 SCUM 目录选择礼包内容" : "从 SCUM 目录选择物品"), e("span", { className: "page-status" }, "图标 / 名称 / 代码")),
e("summary", null, e("strong", null, target === "gift-items" ? "从 SCUM 目录选择礼包内容" : "从 SCUM 目录选择活动物品 / NPC"), e("span", { className: "page-status" }, "图标 / 名称 / 代码")),
view.catalogOpen ? e("div", { className: "scum-catalog-picker-body" },
e("input", { type: "search", value: view.catalogSearch, "aria-label": target === "gift-items" ? "搜索礼包目录" : "搜索生成物品", placeholder: "名 / 代码,例如 Laika", onChange: (event: InputEvent) => view.setCatalogSearch(inputValue(event)) }),
e("div", { className: "scum-catalog-kinds", role: "group", "aria-label": target === "gift-items" ? "礼包目录分类" : "目录类型" }, filters.map(([key, label]) => e("button", { key, type: "button", className: activeFilter === key ? "icon-command is-active" : "icon-command", "aria-pressed": activeFilter === key, onClick: () => view.setCatalogKind(key) }, label))),
e("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" }, "没有匹配的目录条目。"))
e("input", { type: "search", value: view.catalogSearch, "aria-label": target === "gift-items" ? "搜索礼包目录" : "搜索活动物品目录", placeholder: "中文名 / 英文名 / 代码,例如 莱卡", 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); 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.setEventItems((items) => appendCatalogItem(items, 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
);
}
@@ -53,8 +53,12 @@
"required": ["tradeGoodsId", "percent", "value", "r", "x", "y", "z"],
"properties": {
"tradeGoodsId": { "type": "string", "minLength": 1, "maxLength": 128 },
"catalogCode": { "type": "string", "minLength": 1, "maxLength": 128 },
"percent": { "type": "integer", "minimum": 0, "maximum": 100 },
"probability": { "type": "integer", "minimum": 0, "maximum": 100 },
"value": { "type": "integer", "minimum": 1 },
"quantity": { "type": "integer", "minimum": 1 },
"durability": { "type": "integer", "minimum": 0, "maximum": 100 },
"r": { "type": "number", "minimum": 0 },
"x": { "type": "number" },
"y": { "type": "number" },
+26 -17
View File
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, 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 { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventDefinition, 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";
@@ -134,9 +134,9 @@ describe("SCUM plugin feature module", () => {
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
await loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions() }, "workflows");
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]);
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.mapRegions, scumCollections.mapSettings]);
await loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions() }, "activity");
expect(list.mock.calls.slice(-8).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]);
expect(list.mock.calls.slice(-5).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.mapRegions, scumCollections.mapSettings]);
});
it("uses transaction, put, and delete for plugin-owned gift data", async () => {
@@ -170,6 +170,8 @@ describe("SCUM plugin feature module", () => {
const pluginData = pluginDataActions();
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
const actions: SCUMWorkspaceActions = { pluginData, dispatch };
await saveEventDefinition(actions, { id: "event-list", title: "补给活动", items: [{ catalogCode: "Cash", quantity: "2", probability: "65", durability: "80" }] });
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.events, "event-list", expect.objectContaining({ title: "补给活动", items: [{ catalogCode: "Cash", quantity: 2, probability: 65, durability: 80 }] }));
await saveEventProduce(actions, { id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 });
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventProduces, "event-1:produce-1", expect.objectContaining({ eventId: "event-1", tradeGoodsId: "goods-1" }));
await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces);
@@ -268,18 +270,21 @@ describe("SCUM plugin feature module", () => {
expect(view.texts.join("\n")).toContain("verified");
});
it("renders activity definitions, status filters, runs, and records", () => {
it("renders only a collapsed activity list and expands into area and reward configuration", () => {
const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" });
expect(view.inputs.map((input) => input.label)).toContain("活动状态");
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "RCON 命令", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
expect(view.inputs.map((input) => input.label)).not.toEqual(expect.arrayContaining(["RCON 命令", "活动公告", "生成物品编号", "生成 X", "生成 Y", "生成 Z"]));
expect(view.texts).toContain("Friday Range");
expect(view.texts).toContain("running");
expect(view.texts).toContain("最近活动记录");
expect(view.texts).toContain("Mira");
expect(view.texts).toContain("活动生成项");
expect(view.texts).toEqual(expect.arrayContaining(["物品列表", "最近商人交易", "Cargo Drop", "买入 × 2"]));
expect(pageSource).toContain("setEventEditorOpen(detailOpen(event))");
expect(pageSource).not.toContain("open: Boolean(view.eventId || view.eventName)");
expect(view.texts).not.toEqual(expect.arrayContaining(["最近活动记录", "活动生成项", "物品列表", "最近商人交易", "Quest / Task", "原生赛事轮次"]));
expect(view.buttons.map((button) => button.label)).toContain("新建活动");
expect(pageSource).toContain("activityMapPicker(e, data, view)");
expect(pageSource).toContain("活动物品 / NPC");
expect(pageSource).not.toContain("setEventEditorOpen(detailOpen(event))");
const expanded = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理", activityDetailId: "event-1" });
expect(expanded.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["活动标题", "活动区域名称", "活动区域半径", "活动区域 X", "活动区域 Y", "活动区域 Z"]));
expect(expanded.nodes).toContain("div:选择活动区域地图");
expect(expanded.texts).toContain("从 SCUM 目录选择活动物品 / NPC");
});
it("renders gift definitions, claims, and delivery records", () => {
@@ -434,15 +439,18 @@ describe("SCUM plugin feature module", () => {
expect(gifts.texts).not.toContain("从 SCUM 目录选择礼包物品");
expect(gifts.inputs.map((input) => input.label)).not.toContain("搜索礼包物品");
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" });
expect(workflows.texts).toContain("从 SCUM 目录选择物品");
expect(workflows.inputs.map((input) => input.label)).not.toContain("搜索生成物品");
expect(workflows.texts).not.toContain("从 SCUM 目录选择活动物品 / NPC");
const expanded = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理", activityDetailId: "event-1" });
expect(expanded.texts).toContain("从 SCUM 目录选择活动物品 / NPC");
expect(expanded.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).toContain("${labelPrefix} ${itemNumber} 数量");
expect(pageSource).toContain("${labelPrefix} ${itemNumber} 概率");
expect(pageSource).toContain("${labelPrefix} ${itemNumber} 耐久");
expect(pageSource).toContain("activityItemEditorList");
expect(pageSource).not.toContain("giftCommands");
expect(pageSource).not.toContain("parseGiftCommands");
expect(pageSource).not.toContain("礼包命令");
@@ -591,7 +599,7 @@ function scumActions(overrides: Partial<NonNullable<SCUMWorkspaceActions["scum"]
};
}
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; giftDialog?: { mode: "closed" | "view" | "edit"; gift?: RecordMap }; playerSearch?: string; pageQuery?: Record<string, string> } = {}) {
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; giftDialog?: { mode: "closed" | "view" | "edit"; gift?: RecordMap }; activityDetailId?: string; playerSearch?: string; pageQuery?: Record<string, string> } = {}) {
const nodes: string[] = [];
const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = [];
@@ -615,6 +623,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
stateCall += 1;
if (stateCall === 1) return [{ status: "ready", data: options.data ?? surfaceData } as T, () => undefined];
if (stateCall === 3 && options.playerSearch !== undefined) return [options.playerSearch as T, () => undefined];
if (stateCall === 9 && options.activityDetailId !== undefined) return [options.activityDetailId as T, () => undefined];
if (options.giftDialog && typeof initial === "object" && initial !== null && "mode" in initial && (initial as { mode?: string }).mode === "closed") return [options.giftDialog as T, () => undefined];
const value = typeof initial === "function" ? (initial as () => T)() : initial;
if (options.giftTab && value === "definitions") return [options.giftTab as T, () => undefined];