511 lines
30 KiB
TypeScript
511 lines
30 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 SCUMQueryTemplateKey = "scum.player.profile" | "scum.squads" | "scum.squad-members" | "scum.vehicles" | "scum.flags" | "scum.positions" | "scum.tasks" | "scum.events" | "scum.native-timed-gifts";
|
|
|
|
export const playerAttributeCatalog = [
|
|
{ key: "stamina", label: "体力", column: "stamina", sourceKeys: ["attributes.stamina", "stamina", "体力"] },
|
|
{ key: "dexterity", label: "敏捷", column: "dexterity", sourceKeys: ["attributes.dexterity", "dexterity", "敏捷"] },
|
|
{ key: "intelligence", label: "智力", column: "intelligence", sourceKeys: ["attributes.intelligence", "intelligence", "智力"] }
|
|
] as const;
|
|
|
|
export type PlayerAttributeDraft = { fieldKey: string; label: string; before: string; after: string };
|
|
|
|
export function playerAttributeDrafts(player: RecordMap): PlayerAttributeDraft[] {
|
|
return playerAttributeCatalog.map((field) => ({ fieldKey: field.key, label: field.label, before: firstText(player, ...field.sourceKeys), after: firstText(player, ...field.sourceKeys) }));
|
|
}
|
|
|
|
export function playerAttributeSqlPreview(drafts: PlayerAttributeDraft[]): string {
|
|
const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim());
|
|
if (!changes.length) return "等待输入要提交的属性变更。";
|
|
return buildPlayerAttributeSqlText({ gamePlayerId: ":playerId" }, changes.flatMap((draft) => {
|
|
const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey);
|
|
const after = Number(draft.after);
|
|
return definition && Number.isFinite(after) ? [{ column: definition.column, after }] : [];
|
|
}));
|
|
}
|
|
|
|
export function buildPlayerAttributeMutation(player: RecordMap, drafts: PlayerAttributeDraft[]): RecordMap {
|
|
const playerId = firstText(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id");
|
|
if (!playerId) throw new Error("用户没有可用的 Steam ID 或游戏用户编号。");
|
|
const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()).map((draft) => {
|
|
const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey);
|
|
const before = draft.before.trim() ? Number(draft.before) : Number.NaN;
|
|
const after = Number(draft.after);
|
|
if (!definition || !Number.isFinite(after)) throw new Error(`${draft.label}目标值必须是数字。`);
|
|
return { fieldKey: draft.fieldKey, label: draft.label, column: definition.column, before: Number.isFinite(before) ? before : null, after };
|
|
});
|
|
if (!changes.length) throw new Error("至少填写一项与当前值不同的属性。");
|
|
const idempotencyKey = safeCommandId(`player-attributes:${playerId}:${changes.map((change) => `${change.fieldKey}:${change.after}`).join(",")}:${Date.now()}`);
|
|
return { playerId, reason: "管理员在 SCUM 用户管理中编辑属性", sqlText: buildPlayerAttributeSqlText(player, changes), changes, idempotencyKey };
|
|
}
|
|
|
|
export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, player: RecordMap, drafts: PlayerAttributeDraft[]): Promise<unknown> {
|
|
if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法提交 SQL 执行任务。");
|
|
const mutation = buildPlayerAttributeMutation(player, drafts);
|
|
const idempotencyKey = textValue(mutation.idempotencyKey);
|
|
const result = await actions.dispatch({
|
|
requestId: idempotencyKey,
|
|
action: "remote.access.request",
|
|
payload: {
|
|
capability: "remote.run.db.sqlite.execute",
|
|
declarationKey: "scum-database",
|
|
targetKey: "scum-database",
|
|
idempotencyKey,
|
|
timeoutSeconds: "60",
|
|
maxAttempts: "1",
|
|
"input.mode": "execute",
|
|
"input.sqlText": textValue(mutation.sqlText),
|
|
"input.reason": textValue(mutation.reason)
|
|
}
|
|
});
|
|
if (result?.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SQL 执行任务未进入 Run 队列。");
|
|
return result;
|
|
}
|
|
|
|
export type SCUMWorkspaceActions = {
|
|
pluginData?: PluginDataActions;
|
|
gameClient?: GameClientActions;
|
|
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[];
|
|
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: [], flags: [], trajectories: []
|
|
};
|
|
|
|
export const scumCollections = {
|
|
players: "scum_users",
|
|
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",
|
|
vehicles: "scum_vehicles",
|
|
flags: "scum_flags",
|
|
trajectories: "scum_trajectories"
|
|
} as const;
|
|
|
|
type SurfaceKey = keyof SCUMSurfaceData;
|
|
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
|
|
|
|
const pageCollections: Record<PageKey, SurfaceKey[]> = {
|
|
players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries", "trajectories", "vehicles"],
|
|
squads: ["squads", "members", "flags"],
|
|
"live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags", "trajectories", "tradeGoods"],
|
|
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players", "tradeGoods"],
|
|
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"]
|
|
};
|
|
|
|
const pageQueryTemplates: Record<PageKey, SCUMQueryTemplateKey[]> = {
|
|
players: ["scum.player.profile", "scum.positions"],
|
|
squads: ["scum.squads", "scum.squad-members", "scum.flags"],
|
|
"live-map": ["scum.player.profile", "scum.vehicles", "scum.flags", "scum.positions"],
|
|
gifts: ["scum.player.profile", "scum.native-timed-gifts"],
|
|
workflows: ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"]
|
|
};
|
|
|
|
const queryTemplatePollSeconds: Record<SCUMQueryTemplateKey, number> = {
|
|
"scum.player.profile": 5,
|
|
"scum.positions": 5,
|
|
"scum.vehicles": 5,
|
|
"scum.flags": 60,
|
|
"scum.squads": 1800,
|
|
"scum.squad-members": 1800,
|
|
"scum.tasks": 1800,
|
|
"scum.events": 1800,
|
|
"scum.native-timed-gifts": 60
|
|
};
|
|
|
|
const queryTemplateMaxRows: Record<SCUMQueryTemplateKey, number> = {
|
|
"scum.player.profile": 500,
|
|
"scum.positions": 500,
|
|
"scum.vehicles": 500,
|
|
"scum.flags": 500,
|
|
"scum.squads": 500,
|
|
"scum.squad-members": 500,
|
|
"scum.tasks": 500,
|
|
"scum.events": 500,
|
|
"scum.native-timed-gifts": 500
|
|
};
|
|
|
|
const queuedQueryBuckets = new Set<string>();
|
|
|
|
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
|
|
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
|
|
const canonical = canonicalPageKey(pageKey);
|
|
await queueSCUMDatabaseRefresh(actions, canonical).catch(() => undefined);
|
|
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
|
|
const keys = pageCollections[canonical];
|
|
const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const));
|
|
for (const [key, response] of records) data[key] = collectionRecords(response);
|
|
await mergeSCUMSnapshots(actions, data, keys).catch(() => undefined);
|
|
return data;
|
|
}
|
|
|
|
export async function queueSCUMDatabaseRefresh(actions: SCUMWorkspaceActions, pageKey: string, now = Date.now()): Promise<PluginBridgeExecutionResult[]> {
|
|
if (!actions.dispatch) return [];
|
|
const canonical = canonicalPageKey(pageKey);
|
|
const templates = [...new Set(pageQueryTemplates[canonical])];
|
|
const requests = templates.flatMap((templateKey) => {
|
|
const bucketKey = scumQueryBucketKey(templateKey, now);
|
|
if (queuedQueryBuckets.has(bucketKey)) return [];
|
|
queuedQueryBuckets.add(bucketKey);
|
|
return [actions.dispatch!({ requestId: scumQueryRequestId(canonical, templateKey, now), action: "remote.access.request", payload: scumQueryPayload(templateKey, now) })];
|
|
});
|
|
const settled = await Promise.allSettled(requests);
|
|
return settled.flatMap((item) => item.status === "fulfilled" ? [item.value] : []);
|
|
}
|
|
|
|
function scumQueryPayload(templateKey: SCUMQueryTemplateKey, now: number): Record<string, string> {
|
|
const maxRows = String(queryTemplateMaxRows[templateKey]);
|
|
const payload: Record<string, string> = {
|
|
capability: "remote.run.db.sqlite.query",
|
|
declarationKey: "scum-database",
|
|
targetKey: "scum-database",
|
|
idempotencyKey: scumQueryIdempotencyKey(templateKey, now),
|
|
timeoutSeconds: "15",
|
|
maxAttempts: "1",
|
|
"input.templateKey": templateKey,
|
|
"input.limit": maxRows,
|
|
"input.maxRows": maxRows
|
|
};
|
|
if (templateKey === "scum.player.profile" || templateKey === "scum.positions" || templateKey === "scum.vehicles") payload["input.activeWithinSeconds"] = "600";
|
|
return payload;
|
|
}
|
|
|
|
function scumQueryRequestId(pageKey: PageKey, templateKey: SCUMQueryTemplateKey, now: number): string { return `scum-query:${pageKey}:${templateKey}:${queryBucket(templateKey, now)}`; }
|
|
function scumQueryIdempotencyKey(templateKey: SCUMQueryTemplateKey, now: number): string { return `scum-query:${templateKey}:${queryBucket(templateKey, now)}`; }
|
|
function scumQueryBucketKey(templateKey: SCUMQueryTemplateKey, now: number): string { return `${templateKey}:${queryBucket(templateKey, now)}`; }
|
|
function queryBucket(templateKey: SCUMQueryTemplateKey, now: number): number { return Math.floor(now / (queryTemplatePollSeconds[templateKey] * 1000)); }
|
|
|
|
async function mergeSCUMSnapshots(actions: SCUMWorkspaceActions, data: SCUMSurfaceData, keys: SurfaceKey[]): Promise<void> {
|
|
if (!actions.gameClient) return;
|
|
const reads: Array<Promise<void>> = [];
|
|
if (keys.includes("players") && data.players.length === 0) reads.push(actions.gameClient.snapshots({ profileKey: "plugin-owned", type: "players", streamKey: "current", limit: 1 }).then((response) => { data.players = mergePlayerSnapshots(data.players, response); }));
|
|
if (keys.includes("vehicles") && data.vehicles.length === 0) reads.push(actions.gameClient.snapshots({ profileKey: "plugin-owned", type: "vehicles", streamKey: "current", limit: 1 }).then((response) => { data.vehicles = mergeVehicleSnapshots(data.vehicles, response); }));
|
|
await Promise.all(reads);
|
|
}
|
|
|
|
export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] {
|
|
const playerSnapshot = latestSnapshotPayload(playersResponse);
|
|
const merged = players.map((player) => ({ ...player }));
|
|
const snapshotPlayers = Array.isArray(playerSnapshot?.players) ? playerSnapshot.players.filter(isRecord) : [];
|
|
if (snapshotPlayers.length) {
|
|
const byIdentity = playerIndex(merged);
|
|
for (const snapshotPlayer of snapshotPlayers) {
|
|
const match = findPlayer(byIdentity, snapshotPlayer);
|
|
const value = { ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) };
|
|
if (match) merged[match.index] = value;
|
|
else {
|
|
const created = { ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") };
|
|
merged.push(created);
|
|
addPlayerToIndex(byIdentity, created, merged.length - 1);
|
|
}
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export function mergeVehicleSnapshots(vehicles: RecordMap[], vehiclesResponse: unknown): RecordMap[] {
|
|
const vehicleSnapshot = latestSnapshotPayload(vehiclesResponse);
|
|
const merged = vehicles.map((vehicle) => ({ ...vehicle }));
|
|
const snapshotVehicles = Array.isArray(vehicleSnapshot?.vehicles) ? vehicleSnapshot.vehicles.filter(isRecord) : [];
|
|
if (!snapshotVehicles.length) return merged;
|
|
const byIdentity = vehicleIndex(merged);
|
|
for (const snapshotVehicle of snapshotVehicles) {
|
|
const match = findVehicle(byIdentity, snapshotVehicle);
|
|
const value = { ...(match ? merged[match.index] : {}), ...snapshotVehicle, vehicleObservedAt: textValue(vehicleSnapshot?.observedAt) };
|
|
if (match) merged[match.index] = value;
|
|
else {
|
|
const created = { ...value, vehicleId: firstText(snapshotVehicle, "vehicleId", "entityId", "id") };
|
|
merged.push(created);
|
|
addVehicleToIndex(byIdentity, created, merged.length - 1);
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
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 latestSnapshotPayload(response: unknown): RecordMap | undefined {
|
|
if (!isRecord(response) || !Array.isArray(response.items)) return undefined;
|
|
const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left));
|
|
const latest = snapshots[0];
|
|
if (!latest) return undefined;
|
|
return isRecord(latest.payload) ? { ...latest.payload, observedAt: textValue(latest.observedAt) || textValue(latest.payload.observedAt) } : undefined;
|
|
}
|
|
|
|
function snapshotOrder(snapshot: RecordMap): number { const observed = Date.parse(textValue(snapshot.observedAt)); return Number.isNaN(observed) ? Number(snapshot.sequence) || 0 : observed; }
|
|
function playerIndex(players: RecordMap[]): Map<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => addPlayerToIndex(result, player, index)); return result; }
|
|
function addPlayerToIndex(index: Map<string, number>, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); }
|
|
function findPlayer(index: Map<string, number>, player: RecordMap): { index: number } | undefined { for (const identity of playerIdentities(player)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } return undefined; }
|
|
function vehicleIndex(vehicles: RecordMap[]): Map<string, number> { const result = new Map<string, number>(); vehicles.forEach((vehicle, index) => addVehicleToIndex(result, vehicle, index)); return result; }
|
|
function addVehicleToIndex(index: Map<string, number>, vehicle: RecordMap, vehicleIndex: number): void { vehicleIdentities(vehicle).forEach((identity) => index.set(identity, vehicleIndex)); }
|
|
function findVehicle(index: Map<string, number>, vehicle: RecordMap): { index: number } | undefined { for (const identity of vehicleIdentities(vehicle)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } return undefined; }
|
|
function vehicleIdentities(vehicle: RecordMap): string[] { return ["vehicleId", "entityId", "id"].map((key) => textValue(vehicle[key])).filter(Boolean).map((value) => `vehicle:${value}`); }
|
|
function playerIdentities(player: RecordMap): string[] {
|
|
const identities = new Set<string>();
|
|
for (const key of ["gamePlayerId", "playerId", "steamId", "id"]) { const value = textValue(player[key]); if (value) identities.add(`player:${value}`); }
|
|
for (const key of ["userProfileId", "profileId"]) { const value = textValue(player[key]); if (value) identities.add(`profile:${value}`); }
|
|
return [...identities];
|
|
}
|
|
function onlineValue(player: RecordMap): boolean { const status = firstText(player, "status", "state").toLowerCase(); return booleanValue(player.online) || ["online", "active", "connected"].includes(status); }
|
|
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 buildPlayerAttributeSqlText(player: RecordMap, changes: Array<{ column: string; after: number }>): string {
|
|
const playerId = firstText(player, "gamePlayerId", "playerId", "id");
|
|
const profileId = firstText(player, "userProfileId", "profileId");
|
|
const steamId = firstText(player, "steamId", "providerId");
|
|
const where = playerId && playerId !== ":playerId"
|
|
? `id = ${sqlLiteral(playerId)}`
|
|
: profileId
|
|
? `id = (SELECT prisoner_id FROM user_profile WHERE CAST(id AS TEXT) = ${sqlLiteral(profileId)} LIMIT 1)`
|
|
: steamId
|
|
? `id = (SELECT profile.prisoner_id FROM user_profile profile WHERE profile.user_id = ${sqlLiteral(steamId)} LIMIT 1)`
|
|
: "id = :playerId";
|
|
return changes.map((change) => `UPDATE prisoner SET ${change.column} = ${sqlNumber(change.after)} WHERE ${where};`).join("\n");
|
|
}
|
|
|
|
function sqlLiteral(value: string): string { return value === ":playerId" ? value : `'${value.replace(/'/g, "''")}'`; }
|
|
function sqlNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(value); }
|
|
|
|
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); }
|