Files
browser/plugins/examples/scum-server-plugin/features/page-data.ts
T

458 lines
27 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 PluginGameClientQueueRequest = {
profileKey: string;
commandType: string;
payload: RecordMap;
idempotencyKey: string;
priority?: number;
expiresAt: string;
};
export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> };
export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; error?: { message?: string } };
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?: {
queue: (request: PluginGameClientQueueRequest) => Promise<unknown>;
get: (commandId: string) => Promise<unknown>;
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>;
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
};
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"]
};
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
const keys = pageCollections[canonicalPageKey(pageKey)];
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);
if (keys.includes("players") && actions.gameClient) {
const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined);
data.players = mergePlayerSnapshots(data.players, playersSnapshot);
}
if (keys.includes("vehicles") && actions.gameClient) {
const vehiclesSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "vehicles", streamKey: "current", limit: 1 }).catch(() => undefined);
data.vehicles = mergeVehicleSnapshots(data.vehicles, vehiclesSnapshot);
}
return data;
}
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.gameClient) throw new Error("通用 gameClient 能力不可用。");
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 command = await actions.gameClient.queue({
profileKey: "scum-client-manager",
commandType: "reward.deliver",
payload: { grantId, playerId, items, operations },
idempotencyKey: grantId,
expiresAt: new Date(now + 5 * 60_000).toISOString()
});
const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", createdAt: new Date(now).toISOString() };
await createGiftDelivery(actions, record);
return command;
}
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.gameClient) throw new Error("通用 gameClient 能力不可用。");
const eventId = requiredKey(event, "id", "活动编号");
const eventClass = Number(event.class) === 2 || firstText(event, "eventType") === "fixed" ? 2 : 1;
const eventType = eventClass === 2 ? "fixed" : "range";
const queuedProduces = normalizeEventProduces(produces);
const now = Date.now();
const runId = safeCommandId(`event:${eventId}:${now}`);
const command = await actions.gameClient.queue({
profileKey: "scum-client-manager",
commandType: "event.start",
payload: {
eventId, eventType, class: eventClass, title: textValue(event.name) || eventId,
placard: firstText(event, "placard", "announcement"), percent: boundedInteger(event.percent ?? event.probability, 0, 100, 100),
npc: minimumInteger(event.npc, 0, 0), item: minimumInteger(event.item, 0, 0), zombie: minimumInteger(event.zombie, 0, 0), animal: minimumInteger(event.animal, 0, 0),
produces: queuedProduces,
durationSeconds: minimumInteger(event.durationSeconds, 1, 1800), announce: event.announce !== false
},
idempotencyKey: runId,
expiresAt: new Date(now + 5 * 60_000).toISOString()
});
await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", definition: event, produces, startedAt: new Date(now).toISOString() });
return command;
}
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 boundedInteger(value: unknown, min: number, max: number, fallback: number): number {
const number = Number(value);
return Number.isSafeInteger(number) && number >= min && number <= max ? number : fallback;
}
function minimumInteger(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isSafeInteger(number) && number >= min ? number : fallback; }
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 normalizeEventProduces(produces: RecordMap[]): RecordMap[] {
return produces.map((produce) => ({
tradeGoodsId: firstText(produce, "tradeGoodsId"),
percent: boundedInteger(produce.percent, 0, 100, 100),
value: minimumInteger(produce.value, 1, 1),
r: minimumNumber(produce.r, 0, 0),
x: finiteNumber(produce.x, 0),
y: finiteNumber(produce.y, 0),
z: finiteNumber(produce.z, 0)
}));
}
function minimumNumber(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min ? number : fallback; }
function finiteNumber(value: unknown, fallback: number): number { const number = Number(value); return Number.isFinite(number) ? number : fallback; }
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); }