Run pushes typed SCUM facts through POST /api/v1/run/scum/facts, but the production router rejected that path before the signature middleware because runServiceRequest listed individual Run channel prefixes, and MySQL kept SCUM rows inside the whole metadata snapshot instead of platform tables. - /api/v1/run/ is now the signed machine channel space while /api/v1/run/endpoints keeps normal bearer/admin authorization. - MySQL gets real scum_user, scum_user_trajectory, scum_vehicle, scum_vehicle_trajectory and scum_vehicle_lock tables with parameterized per-row repositories instead of full snapshot rewrites. Snapshot-shaped tables from the unreleased interim build are replaced, and SCUM rows still inside a metadata snapshot are migrated once. - Facts ingest verifies the target server plugin type and converges stale online users to offline after SCUMUserOfflineAfter. - The plugin page and browser read one bounded /scum/surface response instead of five list calls per refresh. Call-count budget for one server: per 5s facts batch, one SELECT plus one INSERT/UPDATE per reported user and vehicle, one INSERT per moved trajectory sample or new lock row, one bounded stale-user SELECT, and a trajectory retention DELETE at most once per hour. One browser refresh issues one surface request every 15s instead of five list requests.
316 lines
17 KiB
TypeScript
316 lines
17 KiB
TypeScript
export type RecordMap = Record<string, unknown>;
|
|
|
|
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";
|
|
|
|
const pageCollections: Record<PageKey, PluginCollectionSurfaceKey[]> = {
|
|
players: ["members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
|
|
squads: ["squads", "members", "flags"],
|
|
"live-map": ["mapPoints", "mapRegions", "mapSettings", "flags", "tradeGoods"],
|
|
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "tradeGoods"],
|
|
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"]
|
|
};
|
|
|
|
const pageSCUMTables: Record<PageKey, SCUMPlatformSurfaceKey[]> = {
|
|
players: ["players", "vehicles", "trajectories", "vehicleLocks"],
|
|
squads: ["players"],
|
|
"live-map": ["players", "vehicles", "trajectories", "vehicleLocks"],
|
|
gifts: ["players"],
|
|
workflows: ["players", "vehicles", "trajectories", "vehicleLocks"]
|
|
};
|
|
|
|
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", "礼包编号");
|
|
return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]);
|
|
}
|
|
|
|
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 items = normalizeGiftItems(gift.items);
|
|
const operations = [...new Set([...normalizeGiftOperations(gift.commands), ...normalizeGiftOperations(gift.operations)])];
|
|
if (!items.length && !operations.length) throw new Error("礼包必须包含物品或命令。");
|
|
const now = Date.now();
|
|
const grantId = safeCommandId(`gift:${giftCode}:${playerId}:${now}`);
|
|
const commands = [...items.map((item) => `#SpawnItem ${item.catalogCode} ${item.quantity}`), ...operations].map(normalizeRCONCommand);
|
|
const results = [];
|
|
for (const [index, command] of commands.entries()) {
|
|
results.push(await dispatchSCUMRCONCommand(actions, command, `${grantId}:${index + 1}`));
|
|
}
|
|
const commandIds = results.map((result) => textValue(result.result?.jobId)).filter(Boolean);
|
|
const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: commandIds[0] ?? "", commandIds, createdAt: new Date(now).toISOString() };
|
|
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", "活动编号");
|
|
return requirePluginData(actions).put(scumCollections.events, key, event);
|
|
}
|
|
|
|
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: string): Array<{ catalogCode: string; quantity: number }> {
|
|
if (!input.trim()) return [];
|
|
const items = input.split(",").map((part) => {
|
|
const [rawKey, rawQuantity, ...extra] = part.split(":").map((value) => value.trim());
|
|
const quantity = Number(rawQuantity);
|
|
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(rawKey) || !rawQuantity || extra.length || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:正整数数量。");
|
|
return { catalogCode: rawKey, quantity };
|
|
});
|
|
return items;
|
|
}
|
|
|
|
export function parseGiftCommands(input: string): Array<{ command: string }> {
|
|
return input.split(/\r?\n/).map((command) => command.trim()).filter(Boolean).map((command) => ({ command }));
|
|
}
|
|
|
|
export type SCUMMapBounds = { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number };
|
|
|
|
export function resolveMapBounds(settings?: RecordMap): SCUMMapBounds {
|
|
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 normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> {
|
|
if (value === undefined || value === null) return [];
|
|
if (!Array.isArray(value)) throw new Error("礼包物品格式无效。");
|
|
return value.map((item) => {
|
|
if (!isRecord(item)) throw new Error("礼包物品格式无效。");
|
|
const catalogCode = firstText(item, "catalogCode", "key");
|
|
const quantity = Number(item.quantity);
|
|
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品不符合 SCUM 目录代码或正整数数量约束。");
|
|
return { catalogCode, quantity };
|
|
});
|
|
}
|
|
|
|
function normalizeGiftOperations(value: unknown): string[] { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error("礼包命令格式无效。"); return value.map((item) => { const command = isRecord(item) ? firstText(item, "command", "value") : textValue(item); if (!command.trim()) throw new Error("礼包命令不能为空。"); return command.trim(); }); }
|
|
|
|
function 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); }
|