Files

346 lines
19 KiB
TypeScript

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 = {
list: (collection: string, key?: string) => Promise<unknown>;
put: (collection: string, key: string, value: RecordMap) => Promise<unknown>;
delete: (collection: string, key: string) => Promise<void>;
transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>;
};
export type GameClientActions = {
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
};
export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> };
export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; error?: { message?: string } };
export type SCUMPlatformActions = {
surface: () => Promise<{ users: unknown; vehicles: unknown; userTrajectories: unknown; vehicleTrajectories: unknown; vehicleLocks: unknown }>;
};
export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions;
gameClient?: GameClientActions;
scum?: SCUMPlatformActions;
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
};
export type SCUMSurfaceData = {
players: RecordMap[];
squads: RecordMap[];
members: RecordMap[];
events: RecordMap[];
eventProduces: RecordMap[];
eventRuns: RecordMap[];
nativeEventRounds: RecordMap[];
tasks: RecordMap[];
activityEvents: RecordMap[];
tradeGoods: RecordMap[];
tradeEvents: RecordMap[];
gifts: RecordMap[];
giftClaims: RecordMap[];
pendingGifts: RecordMap[];
giftDeliveries: RecordMap[];
timedGiftEvents: RecordMap[];
mapPoints: RecordMap[];
mapRegions: RecordMap[];
mapSettings: RecordMap[];
vehicles: RecordMap[];
vehicleLocks: RecordMap[];
flags: RecordMap[];
trajectories: RecordMap[];
};
export const emptySCUMSurfaceData: SCUMSurfaceData = {
players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [],
tradeGoods: [], tradeEvents: [], gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], vehicleLocks: [], flags: [], trajectories: []
};
export const scumCollections = {
squads: "scum_squads",
members: "scum_squad_members",
events: "scum_activity_definitions",
eventProduces: "scum_event_produces",
eventRuns: "scum_event_runs",
nativeEventRounds: "scum_native_event_rounds",
tasks: "scum_tasks",
activityEvents: "scum_activity_events",
tradeGoods: "scum_trade_goods",
tradeEvents: "scum_trade_events",
gifts: "scum_gifts",
giftClaims: "scum_gift_claims",
pendingGifts: "scum_pending_gifts",
giftDeliveries: "scum_gift_deliveries",
timedGiftEvents: "scum_timed_gift_events",
mapPoints: "scum_map_points",
mapRegions: "scum_map_regions",
mapSettings: "scum_map_settings",
flags: "scum_flags"
} as const;
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
type PluginCollectionSurfaceKey = keyof typeof scumCollections;
type SCUMPlatformSurfaceKey = "players" | "vehicles" | "trajectories" | "vehicleLocks";
// Call budget per live-map load/15s refresh: 7 bounded plugin collections + 1 typed SCUM surface.
// 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", "mapRegions", "mapSettings"]
};
const pageSCUMTables: Record<PageKey, SCUMPlatformSurfaceKey[]> = {
players: ["players", "vehicles", "trajectories", "vehicleLocks"],
squads: ["players"],
"live-map": ["players", "vehicles", "trajectories", "vehicleLocks"],
gifts: ["players"],
workflows: []
};
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
const canonical = canonicalPageKey(pageKey);
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
const keys = pageCollections[canonical];
if (keys.length) {
const pluginData = requirePluginData(actions);
const records = await Promise.all(keys.map(async (key) => [key, await pluginData.list(scumCollections[key])] as const));
for (const [key, response] of records) data[key] = collectionRecords(response);
}
await loadPlatformSCUMTables(actions, data, pageSCUMTables[canonical]);
return data;
}
async function loadPlatformSCUMTables(actions: SCUMWorkspaceActions, data: SCUMSurfaceData, keys: SCUMPlatformSurfaceKey[]): Promise<void> {
if (!keys.length) return;
if (!actions.scum) throw new Error("平台 SCUM 数据能力不可用。");
const surface = await actions.scum.surface();
if (keys.includes("players")) data.players = collectionRecords(surface.users);
if (keys.includes("vehicles")) data.vehicles = collectionRecords(surface.vehicles);
if (keys.includes("vehicleLocks")) data.vehicleLocks = collectionRecords(surface.vehicleLocks);
if (keys.includes("trajectories")) data.trajectories = [...collectionRecords(surface.userTrajectories), ...collectionRecords(surface.vehicleTrajectories)];
}
export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise<unknown> {
const key = requiredKey(gift, "code", "礼包编号");
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); }
export async function resetGiftClaim(actions: SCUMWorkspaceActions, claim: RecordMap): Promise<void> {
const key = firstText(claim, "_recordKey", "id", "claimId");
if (!key) throw new Error("领取记录编号不能为空。");
return requirePluginData(actions).delete(scumCollections.giftClaims, key);
}
export async function resetPendingGift(actions: SCUMWorkspaceActions, pending: RecordMap): Promise<unknown> {
const key = firstText(pending, "_recordKey", "id", "pendingId");
if (!key) throw new Error("待领记录编号不能为空。");
return requirePluginData(actions).put(scumCollections.pendingGifts, key, { ...pending, status: "pending", receivedAt: null, receiveTime: null, resetAt: new Date().toISOString() });
}
export async function createGiftDelivery(actions: SCUMWorkspaceActions, delivery: RecordMap): Promise<unknown> {
const key = requiredKey(delivery, "id", "发放记录编号");
return requirePluginData(actions).put(scumCollections.giftDeliveries, key, delivery);
}
export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: RecordMap, player: RecordMap): Promise<unknown> {
if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法通过 SCUM RCON 发放礼包。");
const giftCode = requiredKey(gift, "code", "礼包编号");
const playerId = firstText(player, "gamePlayerId", "playerId", "steamId", "id");
if (!playerId) 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).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", items, commandId: commandIds[0] ?? "", commandIds, createdAt: new Date(now).toISOString() };
await createGiftDelivery(actions, record);
return { status: "queued", result: { jobIds: commandIds.join(",") } };
}
export async function saveEventDefinition(actions: SCUMWorkspaceActions, event: RecordMap): Promise<unknown> {
const key = requiredKey(event, "id", "活动编号");
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> {
const pluginData = requirePluginData(actions);
await Promise.all(produces.filter((produce) => firstText(produce, "eventId", "event") === key).map((produce) => pluginData.delete(scumCollections.eventProduces, requiredRecordKey(produce, "生成项"))));
await pluginData.delete(scumCollections.events, key);
}
export async function saveEventProduce(actions: SCUMWorkspaceActions, produce: RecordMap): Promise<unknown> {
const eventId = firstText(produce, "eventId", "event");
if (!eventId) throw new Error("活动编号不能为空。");
const produceId = firstText(produce, "id", "produceId") || requestKey("produce", eventId);
return requirePluginData(actions).put(scumCollections.eventProduces, `${eventId}:${produceId}`, { ...produce, id: produceId, eventId });
}
export async function deleteEventProduce(actions: SCUMWorkspaceActions, produce: RecordMap): Promise<void> {
return requirePluginData(actions).delete(scumCollections.eventProduces, requiredRecordKey(produce, "生成项"));
}
export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap, produces: RecordMap[] = []): Promise<unknown> {
if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法通过 SCUM RCON 启动活动。");
const eventId = requiredKey(event, "id", "活动编号");
const command = firstText(event, "rconCommand", "command");
if (!command) throw new Error("活动未声明可执行的 SCUM RCON 命令。");
const now = Date.now();
const runId = safeCommandId(`event:${eventId}:${now}`);
const dispatched = await dispatchSCUMRCONCommand(actions, normalizeRCONCommand(command), runId);
await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: textValue(dispatched.result?.jobId), definition: event, produces, startedAt: new Date(now).toISOString() });
return dispatched;
}
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);
}
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}${item.durability === undefined ? "" : ` Health ${item.durability}%`}`];
return Array.from({ length: item.quantity }, () => entry.command);
});
}
export type SCUMMapBounds = { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number };
export function resolveMapBounds(settings?: RecordMap): SCUMMapBounds {
const fallback = { worldMinX: -905000, worldMinY: -905000, worldMaxX: 619000, worldMaxY: 619000 };
if (!settings) return fallback;
if (Object.prototype.hasOwnProperty.call(settings, "customMapEnabled") && !booleanValue(settings.customMapEnabled)) return fallback;
const explicit = [settings.worldMinX, settings.worldMinY, settings.worldMaxX, settings.worldMaxY].map(Number);
if (explicit.every(Number.isFinite) && explicit[2] > explicit[0] && explicit[3] > explicit[1]) return { worldMinX: explicit[0], worldMinY: explicit[1], worldMaxX: explicit[2], worldMaxY: explicit[3] };
if (!booleanValue(settings.customMapEnabled)) return fallback;
const centerX = Number(settings.centerX ?? settings.mapX);
const centerY = Number(settings.centerY ?? settings.mapY);
const widthKm = Number(settings.widthKm ?? settings.mapWidth);
const heightKm = Number(settings.heightKm ?? settings.mapHeight);
if (![centerX, centerY, widthKm, heightKm].every(Number.isFinite) || widthKm <= 0 || heightKm <= 0) return fallback;
const halfWidth = widthKm * 100000 / 2;
const halfHeight = heightKm * 100000 / 2;
return { worldMinX: centerX - halfWidth, worldMinY: centerY - halfHeight, worldMaxX: centerX + halfWidth, worldMaxY: centerY + halfHeight };
}
export async function saveMapSettings(actions: SCUMWorkspaceActions, settings: RecordMap): Promise<unknown> {
const value = { ...settings, ...resolveMapBounds(settings), updatedAt: new Date().toISOString() };
return requirePluginData(actions).put(scumCollections.mapSettings, "current", value);
}
function canonicalPageKey(pageKey: string): PageKey {
if (pageKey === "activity") return "workflows";
return pageKey === "squads" || pageKey === "live-map" || pageKey === "gifts" || pageKey === "workflows" ? pageKey : "players";
}
function collectionRecords(response: unknown): RecordMap[] {
if (!isRecord(response) || !Array.isArray(response.items)) return [];
return response.items.flatMap((item) => {
if (!isRecord(item)) return [];
if (isRecord(item.value)) return [{ ...item.value, _recordKey: textValue(item.key) }];
return [item];
});
}
function booleanValue(value: unknown): boolean { return value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true"; }
function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions {
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
return actions.pluginData;
}
function requiredKey(value: RecordMap, key: string, label: string): string {
const result = textValue(value[key]);
if (!result) throw new Error(`${label}不能为空。`);
return result;
}
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 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 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();
if (!normalized || /[\r\n]/.test(normalized)) throw new Error("SCUM RCON 命令必须是单行文本。");
return normalized;
}
async function dispatchSCUMRCONCommand(actions: SCUMWorkspaceActions, command: string, idempotencyKey: string): Promise<PluginBridgeExecutionResult> {
if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用。");
const result = await actions.dispatch({
requestId: idempotencyKey,
action: "remote.access.request",
payload: {
capability: "remote.run.rcon.command",
declarationKey: "scum-management",
targetKey: "scum-management",
idempotencyKey,
timeoutSeconds: "30",
maxAttempts: "1",
"input.command": command
}
});
if (result.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SCUM RCON 命令未进入 Run 队列。");
return result;
}
function safeCommandId(value: string): string { return value.replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 96); }
function firstText(value: RecordMap, ...keys: string[]): string { for (const key of keys) { const result = textValue(value[key]); if (result) return result; } return ""; }
function requestKey(prefix: string, key: string): string { return `${prefix}:${key}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`; }
function textValue(value: unknown): string { return value === undefined || value === null ? "" : String(value); }
function isRecord(value: unknown): value is RecordMap { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }