Rebuild SCUM plugin data ownership

This commit is contained in:
npc0-hue
2026-08-14 10:03:58 +08:00
parent c8b49c711c
commit a6c4cdac5d
79 changed files with 532 additions and 1842 deletions
@@ -1,12 +0,0 @@
{
"version": 1,
"maps": [
{ "key": "server-settings", "format": "ini", "encoding": "utf-8", "fileName": "ServerSettings.ini", "section": "General", "fields": { "scum.ServerName": "serverName", "scum.ServerDescription": "serverDescription", "scum.WelcomeMessage": "welcomeMessage", "scum.MessageOfTheDay": "motd", "scum.MaxPlayers": "maxPlayers", "scum.ServerPlaystyle": "playstyle" } },
{ "key": "admin-users", "format": "ini-list", "encoding": "utf-8", "fileName": "AdminUsers.ini", "fields": { "steamId": "steamId", "permissions": "permissions" } },
{ "key": "banned-users", "format": "ini-list", "encoding": "utf-8", "fileName": "BannedUsers.ini", "fields": { "steamId": "steamId" } },
{ "key": "whitelisted-users", "format": "ini-list", "encoding": "utf-8", "fileName": "WhitelistedUsers.ini", "fields": { "steamId": "steamId" } },
{ "key": "economy-override", "format": "json", "encoding": "utf-8", "fileName": "EconomyOverride.json", "rootPath": "economy-override", "fields": { "traders": "traders", "tradeable-code-prices": "tradeable-code-prices" } },
{ "key": "raid-times", "format": "json", "encoding": "utf-8", "fileName": "RaidTimes.json", "rootPath": "raiding-times", "fields": { "Weekdays": "weekdays", "Weekend": "weekend" } },
{ "key": "notifications", "format": "json", "encoding": "utf-8", "fileName": "Notifications.json", "rootPath": "Notifications", "fields": { "Notifications": "notifications" } }
]
}
@@ -1,8 +0,0 @@
{
"gameVersion": "0.9.700.90357",
"items": [
{ "key": "bandage", "label": "绷带", "maximumQuantity": 20 },
{ "key": "water-bottle", "label": "饮用水", "maximumQuantity": 10 },
{ "key": "improvised-spear", "label": "简易长矛", "maximumQuantity": 2 }
]
}
@@ -1,13 +0,0 @@
{
"version": 1,
"encoding": "utf-16le",
"lineEnding": "crlf",
"timestampFormat": "yyyy.MM.dd-HH.mm.ss",
"parsers": [
{ "key": "login", "eventType": "scum.login", "pattern": "^([^:]+): '([^ ]+) ([^:]+):(.+)\\([0-9]+\\)' logged in at: X=([-0-9.]+) Y=([-0-9.]+) Z=([-0-9.]+)$", "fields": ["occurredAt", "ip", "steamId", "displayName", "session", "x", "y", "z"] },
{ "key": "logout", "eventType": "scum.logout", "pattern": "^([^:]+): '([^ ]+) ([^:]+):(.+)\\([0-9]+\\)' logged out at: X=([-0-9.]+) Y=([-0-9.]+) Z=([-0-9.]+)$", "fields": ["occurredAt", "ip", "steamId", "displayName", "session", "x", "y", "z"] },
{ "key": "chat", "eventType": "scum.chat", "pattern": "^([^:]+): '([^:]+):(.+)\\([0-9]+\\)' '([^:]+): (.*)'$", "fields": ["occurredAt", "steamId", "displayName", "channel", "message"] },
{ "key": "admin", "eventType": "scum.admin", "pattern": "^([^:]+): '([^:]+):(.+)\\([0-9]+\\)' Command: '(.*)'$", "fields": ["occurredAt", "steamId", "displayName", "command"] },
{ "key": "vehicle-destruction", "eventType": "scum.vehicle.destruction", "pattern": "^([^:]+): \\[VehicleInactiveTimerReached\\] ([^.]+)\\. VehicleId: ([0-9]+)\\. Owner: (.+)\\. Location: X=([-0-9.]+) Y=([-0-9.]+) Z=([-0-9.]+)$", "fields": ["occurredAt", "vehicleClass", "vehicleId", "owner", "x", "y", "z"] }
]
}
@@ -17,46 +17,24 @@ export type SCUMPageContext = {
};
type SCUMWorkspaceActions = {
listSCUMUsers?: () => Promise<unknown>;
listSCUMPlayers?: () => Promise<unknown>;
listSCUMSquads?: () => Promise<unknown>;
listSCUMSquadMembers?: () => Promise<unknown>;
listSCUMVehicles?: () => Promise<unknown>;
listSCUMFlags?: () => Promise<unknown>;
listSCUMPositions?: () => Promise<unknown>;
listSCUMMapPoints?: () => Promise<unknown>;
listSCUMGifts?: () => Promise<unknown>;
getSCUMMapGeometry?: () => unknown;
listSCUMOperations?: () => Promise<unknown>;
pluginData?: { list: (collection: string, key?: string) => Promise<unknown>; put: (collection: string, key: string, value: RecordMap) => Promise<unknown> };
createSCUMOperation?: (request: unknown) => Promise<unknown>;
listSCUMWorkflows?: () => Promise<unknown>;
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
listGameGiftCatalogs?: () => Promise<unknown>;
saveGameGiftCatalog?: (request: unknown) => Promise<unknown>;
publishGameGiftCatalog?: (catalogId: string) => Promise<unknown>;
listGameGiftGrants?: () => Promise<unknown>;
requestGameGiftGrant?: (request: unknown) => Promise<unknown>;
approveGameGiftGrant?: (grantId: string) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
};
type RecordMap = Record<string, unknown>;
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string };
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; mapPoints: RecordMap[]; giftEvents: RecordMap[]; catalogs: RecordMap[]; grants: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[]; mapGeometry?: RecordMap };
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[] };
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], mapPoints: [], giftEvents: [], catalogs: [], grants: [], operations: [], workflows: [], steps: [] };
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], operations: [], workflows: [], steps: [] };
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement;
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const [playerQuery, setPlayerQuery] = usePluginState(react, "");
const [squadFilter, setSquadFilter] = usePluginState(react, "");
const [selectedSquad, setSelectedSquad] = usePluginState(react, "");
const [layers, setLayers] = usePluginState<Record<string, boolean>>(react, { player: true, vehicle: true, base: true, flag: true });
const [selectedMarker, setSelectedMarker] = usePluginState<RecordMap | undefined>(react, undefined);
const [giftTab, setGiftTab] = usePluginState(react, "catalogs");
const pageKey = input.pageKey ?? "players";
const refresh = () => {
@@ -67,9 +45,9 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
}
setState({ status: "loading" });
void Promise.all([
safeList(actions.listSCUMPlayers), safeList(actions.listSCUMSquads), safeList(actions.listSCUMSquadMembers), safeList(actions.listSCUMVehicles), safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions),
safeList(actions.listSCUMMapPoints), safeList(actions.listSCUMGifts), safeList(actions.listGameGiftCatalogs), safeList(actions.listGameGiftGrants), safeList(actions.listSCUMOperations), safeList(actions.listSCUMWorkflows), safeList(actions.listSCUMWorkflowSteps)
]).then(([players, squads, members, vehicles, flags, positions, mapPoints, giftEvents, catalogs, grants, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, mapPoints, giftEvents, catalogs, grants, operations, workflows, steps, mapGeometry: asRecord(actions.getSCUMMapGeometry?.()) } }))
pluginCollection(actions, "scum_users"), pluginCollection(actions, "scum_squads"), pluginCollection(actions, "scum_squad_members"), pluginCollection(actions, "scum_vehicles"),
pluginCollection(actions, "scum_flags"), pluginCollection(actions, "scum_map_points"), pluginCollection(actions, "scum_operations"), pluginCollection(actions, "scum_workflows"), pluginCollection(actions, "scum_workflow_steps")
]).then(([players, squads, members, vehicles, flags, positions, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, operations, workflows, steps } }))
.catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 投影读取失败。" }));
};
@@ -88,30 +66,25 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影…") : null,
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh, { playerQuery, setPlayerQuery, squadFilter, setSquadFilter, selectedSquad, setSelectedSquad, layers, setLayers, selectedMarker, setSelectedMarker, giftTab, setGiftTab }) : null
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh) : null
);
}
type SurfaceControls = { playerQuery: string; setPlayerQuery: StateSetter<string>; squadFilter: string; setSquadFilter: StateSetter<string>; selectedSquad: string; setSelectedSquad: StateSetter<string>; layers: Record<string, boolean>; setLayers: StateSetter<Record<string, boolean>>; selectedMarker?: RecordMap; setSelectedMarker: StateSetter<RecordMap | undefined>; giftTab: string; setGiftTab: StateSetter<string> };
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, controls: SurfaceControls) {
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
switch (pageKey) {
case "players": return playersSurface(e, data, input, setAction, refresh, controls);
case "squads": return squadsSurface(e, data, controls);
case "live-map": return mapSurface(e, data, controls);
case "gifts": return giftsSurface(e, data, input, setAction, refresh, controls);
case "players": return playersSurface(e, data, input, setAction, refresh);
case "squads": return squadsSurface(e, data);
case "live-map": return mapSurface(e, data);
case "gifts": return giftsSurface(e, data, input, setAction, refresh);
case "workflows": return workflowsSurface(e, data);
default: return playersSurface(e, data, input, setAction, refresh, controls);
default: return playersSurface(e, data, input, setAction, refresh);
}
}
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, controls: SurfaceControls) {
const squads = uniqueStrings(data.players.map((player) => textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId")));
const query = controls.playerQuery.trim().toLowerCase();
const players = data.players.filter((player) => (!controls.squadFilter || controls.squadFilter === (textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId"))) && (!query || ["DisplayName", "displayName", "SteamID", "steamId", "SquadName", "squadName"].some((key) => textField(player, key).toLowerCase().includes(query))));
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
return e("div", { className: "console-record-list" },
statsStrip(e, [["玩家投影", data.players.length], ["在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标", data.positions.length], ["待审操作", data.operations.filter((op) => field(op, "Status", "status") === "waiting").length]]),
e("div", { className: "server-toolbar" }, e("input", { type: "search", value: controls.playerQuery, placeholder: "搜索昵称、Steam 或队伍", onChange: (event: { target: { value: string } }) => controls.setPlayerQuery(event.target.value) }), e("select", { value: controls.squadFilter, onChange: (event: { target: { value: string } }) => controls.setSquadFilter(event.target.value) }, e("option", { value: "" }, "全部队伍"), squads.map((squad) => e("option", { key: squad, value: squad }, squad)))),
players.length ? players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")),
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, freshness(player))),
e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`),
@@ -120,57 +93,37 @@ function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, in
operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000),
operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true)
)
)) : e("p", { className: "page-status" }, data.players.length ? "没有符合筛选条件的真实玩家投影。" : "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
)) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
);
}
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, controls: SurfaceControls) {
const selected = controls.selectedSquad || textField(data.squads[0], "SquadID", "squadId");
const roster = data.members.filter((member) => textField(member, "SquadID", "squadId") === selected);
const flags = data.flags.filter((flag) => textField(flag, "OwnerSquadID", "ownerSquadId") === selected);
const squadRows = e("div", { className: "console-row-list" }, data.squads.map((squad) => {
const id = textField(squad, "SquadID", "squadId");
return e("button", { type: "button", key: idOf(squad), className: `console-row ${selected === id ? "console-row-selected" : ""}`, onClick: () => controls.setSelectedSquad(id) }, e("span", null, textField(squad, "Name", "name") || id), e("strong", null, `成员 ${numField(squad, "MemberCount", "memberCount")}`), e("strong", null, `分数 ${numField(squad, "Score", "score")}`), e("strong", null, freshness(squad)));
}));
const rosterRows = roster.length ? e("div", { className: "console-row-list" }, roster.map((member) => e("div", { key: idOf(member), className: "console-row" }, e("span", null, textField(member, "DisplayName", "displayName") || "未知成员"), e("strong", null, textField(member, "IsLeader", "isLeader") === "true" ? "队长" : `军衔 ${textField(member, "Rank", "rank") || "--"}`), e("strong", null, `Steam ${textField(member, "SteamID", "steamId") || "--"}`), e("strong", null, freshness(member))))) : e("p", { className: "page-status" }, "此队伍暂无成员投影。");
const flagRows = e("div", { className: "console-row-list" }, flags.map((flag) => e("div", { key: idOf(flag), className: "console-row" }, e("span", null, `旗帜 ${textField(flag, "FlagID", "flagId")}`), e("strong", null, textField(flag, "OwnershipConfidence", "ownershipConfidence") || "verified"), e("strong", null, coords(field(flag, "Position", "position") as RecordMap)), e("strong", null, freshness(flag)))));
return e("div", { className: "overview-two-col" }, e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "队伍"), e("span", { className: "page-status" }, `${data.squads.length}`)), squadRows), e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "成员 / 旗帜"), e("span", { className: "page-status" }, selected || "未选择队伍")), rosterRows, flagRows));
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
return e("div", { className: "overview-two-col" },
tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, freshness(squad)]),
tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", freshness(item)])
);
}
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, controls: SurfaceControls) {
const overlays = mapOverlays(data);
const visible = overlays.filter((point) => controls.layers[textField(point, "SubjectType", "subjectType")] !== false);
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
const overlays = [...data.positions, ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(Boolean), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(Boolean)];
return e("div", { className: "console-record-list" },
statsStrip(e, [["玩家", overlays.filter((point) => textField(point, "SubjectType", "subjectType") === "player").length], ["载具", overlays.filter((point) => textField(point, "SubjectType", "subjectType") === "vehicle").length], ["旗帜", overlays.filter((point) => textField(point, "SubjectType", "subjectType") === "flag").length], ["坐标点", visible.length]]),
e("div", { className: "map-layer-controls" }, ["player", "vehicle", "base", "flag"].map((kind) => e("label", { key: kind }, e("input", { type: "checkbox", checked: controls.layers[kind] !== false, onChange: () => controls.setLayers((previous) => ({ ...previous, [kind]: previous[kind] === false })) }), mapKindLabel(kind)))),
e("div", { className: "map-projection-board" }, visible.slice(0, 500).map((point, index) => e("button", { type: "button", key: `${idOf(point)}:${index}`, className: `map-projection-dot map-projection-dot-${textField(point, "SubjectType", "subjectType") || "unknown"}`, title: `${mapKindLabel(textField(point, "SubjectType", "subjectType"))} ${coords(point)}`, style: dotStyle(point, data.mapGeometry), onClick: () => controls.setSelectedMarker(point) }, ""))),
controls.selectedMarker ? e("article", { className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, mapKindLabel(textField(controls.selectedMarker, "SubjectType", "subjectType"))), e("span", { className: "status-pill status-active" }, freshness(controls.selectedMarker))), e("div", { className: "console-record-meta" }, e("span", null, `对象 ${textField(controls.selectedMarker, "SubjectID", "subjectId") || "--"}`), e("span", null, coords(controls.selectedMarker)), e("span", null, `观察 ${dateField(controls.selectedMarker, "ObservedAt", "observedAt")}`))) : null,
tablePanel(e, "地图覆盖物", visible, (point) => [mapKindLabel(textField(point, "SubjectType", "subjectType")), textField(point, "Label", "label") || textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]),
e("div", { className: "map-projection-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-projection-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))),
tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
);
}
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, controls: SurfaceControls) {
const tab = controls.giftTab;
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
return e("div", { className: "console-record-list" },
statsStrip(e, [["礼包定义", (data.catalogs ?? []).length], ["发放", (data.grants ?? []).length], ["定时礼包完成", (data.giftEvents ?? []).length], ["可选玩家", data.players.length]]),
e("p", { className: "page-status" }, "礼包只 typed delivery workflow;结果未知时不会重复发放。"),
e("div", { className: "section-tabs" }, [["catalogs", "礼包定义"], ["grants", "发放记录"], ["events", "定时礼包完成"]].map(([key, label]) => e("button", { type: "button", key, className: `section-tab ${tab === key ? "section-tab-active" : ""}`, onClick: () => controls.setGiftTab(key) }, label))),
tab === "catalogs" ? giftCatalogSurface(e, data, input, setAction, refresh) : null,
tab === "grants" ? giftGrantSurface(e, data, input, setAction, refresh) : null,
tab === "events" ? tablePanel(e, "游戏内已完成定时礼包", data.giftEvents ?? [], (event) => [textField(event, "displayName", "DisplayName") || "未知玩家", textField(event, "giftType", "GiftType") || "finished-timed-gift", dateField(event, "spawnAt", "SpawnAt"), textField(event, "mapId", "MapID") || "--"]) : null
statsStrip(e, [["可选玩家", data.players.length], ["发放操作", data.operations.filter((op) => textField(op, "TemplateKey", "templateKey") === "reward.deliver").length], ["未知态", data.operations.filter((op) => field(op, "Status", "status") === "unknown").length]]),
e("p", { className: "page-status" }, "礼包只创建 typed delivery workflow确认结果未知时不会重复发放。"),
data.players.slice(0, 40).map((player) => e("article", { key: idOf(player), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || idOf(player)), e("span", { className: "status-pill status-disabled" }, freshness(player))),
e("div", { className: "console-row-actions" }, operationButton(e, input, setAction, refresh, player, "reward.deliver", "rewardKey", "创建礼包发放", "starter-pack"), operationButton(e, input, setAction, refresh, player, "player.notify", "message", "发送通知", "你的礼包正在审核发放。"))
))
);
}
function giftCatalogSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
const catalogs = data.catalogs ?? [];
return e("div", { className: "console-row-list" }, catalogs.length ? catalogs.map((catalog) => e("article", { key: idOf(catalog), className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, textField(catalog, "Name", "name")), e("span", { className: "status-pill status-active" }, textField(catalog, "GameVersion", "gameVersion"))), e("span", { className: "provider-id" }, giftItemsText(field(catalog, "DraftItems", "draftItems") as RecordMap[])), e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => publishCatalog(input, setAction, refresh, idOf(catalog)) }, "发布版本")))) : e("p", { className: "page-status" }, "暂无平台运营礼包定义。"));
}
function giftGrantSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
const grants = data.grants ?? [];
return e("div", { className: "console-row-list" }, grants.length ? grants.map((grant) => e("article", { key: idOf(grant), className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, textField(grant, "PlayerDisplayName", "playerDisplayName") || "未知玩家"), e("span", { className: "status-pill status-disabled" }, textField(grant, "Status", "status"))), e("span", { className: "provider-id" }, giftItemsText(field(grant, "Items", "items") as RecordMap[])), textField(grant, "Status", "status") === "pending-approval" ? e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => approveGrant(input, setAction, refresh, idOf(grant)) }, "批准发放")) : null)) : e("p", { className: "page-status" }, "暂无礼包发放记录。"));
}
function workflowsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
return e("div", { className: "console-record-list" },
data.workflows.length ? data.workflows.map((wf) => e("article", { key: idOf(wf), className: "console-record" },
@@ -225,26 +178,8 @@ function workflowLabel(templateKey: string): string { return templateKey.include
function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); }
function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length}`)), e("div", { className: "console-row-list" }, rows.length ? rows.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实投影数据。"))); }
function mapOverlays(data: SCUMSurfaceData): RecordMap[] {
const mapPoints = (data.mapPoints ?? data.positions ?? []).map((point) => ({ ...(field(point, "Fields", "fields") as RecordMap), ...point, SubjectType: textField(field(point, "Fields", "fields") as RecordMap, "subjectType", "SubjectType") || textField(point, "SubjectType", "subjectType") || "player" }));
if (mapPoints.length) return mapPoints;
const vehicles = data.vehicles.map((vehicle) => ({ ...(field(vehicle, "Position", "position") as RecordMap), SubjectType: "vehicle", Label: textField(vehicle, "Label", "label") || textField(vehicle, "ClassName", "className"), VehicleID: textField(vehicle, "VehicleID", "vehicleId") })).filter((point) => Boolean(point));
const flags = data.flags.map((flag) => ({ ...(field(flag, "Position", "position") as RecordMap), SubjectType: "flag", Label: textField(flag, "OwnerSquadName", "ownerSquadName"), FlagID: textField(flag, "FlagID", "flagId") })).filter((point) => Boolean(point));
return [...mapPoints, ...vehicles, ...flags];
}
function dotStyle(point: RecordMap, geometry?: RecordMap): Record<string, string> {
const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0);
const minX = Number(field(geometry, "WorldMinX", "worldMinX")); const maxX = Number(field(geometry, "WorldMaxX", "worldMaxX")); const minY = Number(field(geometry, "WorldMinY", "worldMinY")); const maxY = Number(field(geometry, "WorldMaxY", "worldMaxY"));
if ([minX, maxX, minY, maxY].every(Number.isFinite) && maxX > minX && maxY > minY) return { left: `${Math.max(1, Math.min(99, (x - minX) / (maxX - minX) * 100))}%`, top: `${Math.max(1, Math.min(99, 100 - (y - minY) / (maxY - minY) * 100))}%` };
return { left: "50%", top: "50%" };
}
function mapKindLabel(kind: string): string { return kind === "player" ? "玩家" : kind === "vehicle" ? "载具" : kind === "base" ? "基地" : kind === "flag" ? "旗帜" : "实体"; }
function uniqueStrings(values: string[]): string[] { return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh-CN")); }
function asRecord(value: unknown): RecordMap | undefined { return value && typeof value === "object" ? value as RecordMap : undefined; }
function giftItemsText(items: RecordMap[] | undefined): string { return Array.isArray(items) && items.length ? items.map((item) => `${textField(item, "Label", "label", "CatalogItemKey", "catalogItemKey")} x${textField(item, "Quantity", "quantity")}`).join(" · ") : "未声明物品"; }
function publishCatalog(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, catalogId: string) { setAction({ status: "pending", message: "正在发布礼包版本…" }); void input.workspaceActions?.publishGameGiftCatalog?.(catalogId).then(() => { setAction({ status: "ok", message: "礼包版本已发布。" }); refresh(); }).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "礼包发布失败。" })); }
function approveGrant(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, grantId: string) { setAction({ status: "pending", message: "正在批准礼包发放…" }); void input.workspaceActions?.approveGameGiftGrant?.(grantId).then(() => { setAction({ status: "ok", message: "礼包已进入发放队列。" }); refresh(); }).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "礼包批准失败。" })); }
function safeList(fn?: () => Promise<unknown>): Promise<RecordMap[]> { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); }
function dotStyle(point: RecordMap): Record<string, string> { const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; }
function pluginCollection(actions: SCUMWorkspaceActions, collection: string): Promise<RecordMap[]> { return actions.pluginData?.list(collection).then((value) => Array.isArray((value as RecordMap)?.items) ? ((value as { items: Array<{ value: RecordMap }> }).items.map((item) => item.value)) : []) ?? Promise.resolve([]); }
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; }
function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(value); }
+10 -124
View File
@@ -292,10 +292,6 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json",
"sqlRef": "sql/scum-db-v57/users.sql",
"targetTable": "scum_users",
"upsertKeys": ["userProfileId"],
"columnMappings": { "userProfileId": "userProfileId", "steamId": "steamId", "gamePlayerId": "gamePlayerId", "displayName": "displayName", "famePoints": "famePoints", "normalBalance": "normalBalance", "goldBalance": "goldBalance", "x": "x", "y": "y", "z": "z", "lastSaveTime": "lastSaveTime", "lastLoginTime": "lastLoginTime", "lastLogoutTime": "lastLogoutTime", "isAlive": "isAlive" },
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -308,10 +304,6 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json",
"sqlRef": "sql/scum-db-v57/squads.sql",
"targetTable": "scum_squads",
"upsertKeys": ["squadId"],
"columnMappings": { "squadId": "squadId", "name": "name", "leaderProfileId": "leaderProfileId", "leaderPlayerId": "leaderPlayerId", "memberCount": "memberCount", "score": "score", "memberLimit": "memberLimit", "lastMemberLoginTime": "lastMemberLoginTime", "lastMemberLogoutTime": "lastMemberLogoutTime" },
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -324,10 +316,6 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json",
"sqlRef": "sql/scum-db-v57/squad-members.sql",
"targetTable": "scum_squad_members",
"upsertKeys": ["squadId", "userProfileId"],
"columnMappings": { "squadId": "squadId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "steamId": "steamId", "displayName": "displayName", "rank": "rank", "isLeader": "isLeader" },
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -340,10 +328,6 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json",
"sqlRef": "sql/scum-db-v57/vehicles.sql",
"targetTable": "scum_vehicles",
"upsertKeys": ["vehicleId"],
"columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" },
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -356,68 +340,20 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json",
"sqlRef": "sql/scum-db-v57/flags.sql",
"targetTable": "scum_flags",
"upsertKeys": ["flagId"],
"columnMappings": { "flagId": "flagId", "entityId": "entityId", "baseId": "baseId", "ownerProfileId": "ownerProfileId", "ownerPlayerId": "ownerPlayerId", "overtakerProfileId": "overtakerProfileId", "overtakeEndTime": "overtakeEndTime", "x": "x", "y": "y", "z": "z" },
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.positions",
"title": "Read SCUM v57 player, vehicle, and base map points",
"title": "Read SCUM current player, vehicle, and flag coordinates",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json",
"sqlRef": "sql/scum-db-v57/map-points.sql",
"targetTable": "scum_map_points",
"upsertKeys": ["subjectType", "subjectId"],
"columnMappings": { "subjectType": "subjectType", "subjectId": "subjectId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "vehicleId": "vehicleId", "baseId": "baseId", "x": "x", "y": "y", "z": "z", "observedAt": "observedAt" },
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.activity",
"title": "Read SCUM v57 active tasks",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-activity.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-activity.result.schema.json",
"sqlRef": "sql/scum-db-v57/activity.sql",
"targetTable": "scum_activity_events",
"upsertKeys": ["activityId"],
"columnMappings": { "activityId": "activityId", "activityType": "activityType", "userProfileId": "userProfileId", "mapId": "mapId", "subject": "subject", "sequenceIndex": "sequenceIndex", "occurredAt": "occurredAt", "state": "state" },
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.gifts",
"title": "Read SCUM v57 finished_timed_gift_spawner records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-gifts.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-gifts.result.schema.json",
"sqlRef": "sql/scum-db-v57/gifts.sql",
"targetTable": "scum_gift_events",
"upsertKeys": ["giftId"],
"columnMappings": { "giftId": "giftId", "giftType": "giftType", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt", "displayName": "displayName" },
"maxRows": 500,
"timeoutSeconds": 15
}
],
"dataPacks": [
{
"key": "scum-db-v57",
"databaseUserVersion": 57,
"logParserRefs": ["data-packs/scum-db-v57/log-parsers.json"],
"configMapRefs": ["data-packs/scum-db-v57/config-maps.json", "data-packs/scum-db-v57/gift-items.json"]
}
],
"operationTemplates": [
@@ -602,8 +538,7 @@
],
"queryTemplateKeys": [
"scum.player.profile",
"scum.positions",
"scum.activity"
"scum.positions"
],
"operationKeys": [
"player.fame.set",
@@ -640,9 +575,10 @@
"flags"
],
"queryTemplateKeys": [
"scum.positions",
"scum.player.profile",
"scum.vehicles",
"scum.flags"
"scum.flags",
"scum.positions"
],
"featureKeys": [
"trajectory.collect"
@@ -653,9 +589,6 @@
"snapshotTypes": [
"players"
],
"queryTemplateKeys": [
"scum.gifts"
],
"operationKeys": [
"reward.deliver",
"player.notify"
@@ -672,9 +605,7 @@
"scum.squad-members",
"scum.vehicles",
"scum.flags",
"scum.positions",
"scum.activity",
"scum.gifts"
"scum.positions"
],
"operationKeys": [
"player.fame.set",
@@ -762,50 +693,6 @@
{
"path": "bin/scum-start.cmd",
"mode": 448
},
{
"path": "sql/scum-db-v57/users.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/squads.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/squad-members.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/vehicles.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/flags.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/activity.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/gifts.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/map-points.sql",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/log-parsers.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/config-maps.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/gift-items.json",
"mode": 384
}
],
"productionLifecycle": {
@@ -886,7 +773,7 @@
"trajectory.collect"
]
},
{
{
"key": "gifts",
"title": "礼包管理",
"path": "/gifts",
@@ -898,10 +785,9 @@
"server.game-client.read",
"server.game-client.command"
],
"bridgeActions": [
"server.instances.read",
"remote.access.request"
],
"bridgeActions": [
"server.instances.read"
],
"featureKeys": [
"reward.delivery"
]
@@ -1,10 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMActivityParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -1,29 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMActivityResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["activityId", "activityType", "state"],
"properties": {
"activityId": { "type": "string", "minLength": 1, "maxLength": 160 },
"activityType": { "type": "string", "minLength": 1, "maxLength": 64 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"mapId": { "type": "string", "minLength": 1, "maxLength": 96 },
"subject": { "type": "string", "minLength": 1, "maxLength": 240 },
"sequenceIndex": { "type": "integer" },
"occurredAt": { "type": "string", "format": "date-time" },
"state": { "type": "string", "minLength": 1, "maxLength": 64 }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -17,9 +17,9 @@
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"baseId": { "type": "string", "minLength": 1, "maxLength": 96 },
"overtakerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"overtakeEndTime": { "type": "string", "format": "date-time" },
"ownerSquadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerSquadName": { "type": "string", "minLength": 1, "maxLength": 80 },
"ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" }
@@ -1,10 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMGiftsParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -1,28 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMGiftsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["giftId", "giftType", "spawnTime"],
"properties": {
"giftId": { "type": "string", "minLength": 1, "maxLength": 160 },
"giftType": { "type": "string", "minLength": 1, "maxLength": 64 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"mapId": { "type": "string", "minLength": 1, "maxLength": 96 },
"spawnTime": { "type": "integer" },
"spawnAt": { "type": "string", "format": "date-time" },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -25,10 +25,7 @@
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastSaveTime": { "type": "string", "format": "date-time" },
"lastLoginTime": { "type": "string", "format": "date-time" },
"lastLogoutTime": { "type": "string", "format": "date-time" },
"isAlive": { "type": "integer", "minimum": 0, "maximum": 1 }
"lastSaveTime": { "type": "string", "format": "date-time" }
}
}
},
@@ -4,7 +4,7 @@
"type": "object",
"additionalProperties": false,
"properties": {
"subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
@@ -13,16 +13,15 @@
"additionalProperties": false,
"required": ["subjectType", "subjectId", "x", "y"],
"properties": {
"subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"baseId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"observedAt": { "type": "string", "format": "date-time" }
"lastSaveTime": { "type": "string", "format": "date-time" }
}
}
},
@@ -18,10 +18,7 @@
"leaderProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"leaderPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"score": { "type": "number" },
"memberLimit": { "type": "integer", "minimum": 0, "maximum": 1000 },
"lastMemberLoginTime": { "type": "string", "format": "date-time" },
"lastMemberLogoutTime": { "type": "string", "format": "date-time" }
"score": { "type": "number" }
}
}
},
@@ -22,9 +22,7 @@
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastAccessTime": { "type": "string", "format": "date-time" },
"isFunctional": { "type": "integer", "minimum": 0, "maximum": 1 }
"z": { "type": "number" }
}
}
},
@@ -1,15 +0,0 @@
SELECT
'active-task' AS activityType,
CAST(task.id AS TEXT) AS activityId,
CAST(task.user_profile_id AS TEXT) AS userProfileId,
CAST(task.map_id AS TEXT) AS mapId,
available.task_data_asset_path AS subject,
task.sequence_index AS sequenceIndex,
NULL AS occurredAt,
CASE WHEN available.was_ever_completed = 1 THEN 'completed-before' ELSE 'active' END AS state
FROM active_task active
JOIN tracking_data_set task ON task.id = active.id
JOIN available_task available ON available.id = active.available_task_id
WHERE (:userProfileId IS NULL OR CAST(task.user_profile_id AS TEXT) = :userProfileId)
ORDER BY task.id DESC
LIMIT COALESCE(:limit, 500)
@@ -1,18 +0,0 @@
SELECT
CAST(flag.element_id AS TEXT) AS flagId,
CAST(flag.element_id AS TEXT) AS entityId,
CAST(element.base_id AS TEXT) AS baseId,
CAST(element.owner_profile_id AS TEXT) AS ownerProfileId,
CAST(owner.prisoner_id AS TEXT) AS ownerPlayerId,
CAST(flag.overtaker_user_profile_id AS TEXT) AS overtakerProfileId,
datetime(flag.overtake_end_time, 'unixepoch') AS overtakeEndTime,
element.location_x AS x,
element.location_y AS y,
element.location_z AS z
FROM base_element_flag flag
JOIN base_element element ON element.element_id = flag.element_id
LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id
WHERE (:flagId IS NULL OR CAST(flag.element_id AS TEXT) = :flagId)
AND (:ownerProfileId IS NULL OR CAST(element.owner_profile_id AS TEXT) = :ownerProfileId)
ORDER BY flag.element_id
LIMIT COALESCE(:limit, 500)
@@ -1,14 +0,0 @@
SELECT
'finished-timed-gift' AS giftType,
CAST(gift.user_profile_id AS TEXT) || ':' || CAST(gift.map_id AS TEXT) || ':' || CAST(gift.spawn_time AS TEXT) AS giftId,
CAST(gift.user_profile_id AS TEXT) AS userProfileId,
CAST(gift.map_id AS TEXT) AS mapId,
gift.spawn_time AS spawnTime,
datetime(gift.spawn_time, 'unixepoch') AS spawnAt,
COALESCE(profile.name, user.name, '') AS displayName
FROM finished_timed_gift_spawner gift
LEFT JOIN user_profile profile ON profile.id = gift.user_profile_id
LEFT JOIN user ON user.id = profile.user_id
WHERE (:userProfileId IS NULL OR CAST(gift.user_profile_id AS TEXT) = :userProfileId)
ORDER BY gift.spawn_time DESC
LIMIT COALESCE(:limit, 500)
@@ -1,62 +0,0 @@
SELECT
'player' AS subjectType,
CAST(profile.id AS TEXT) AS subjectId,
CAST(profile.id AS TEXT) AS userProfileId,
CAST(prisoner.id AS TEXT) AS gamePlayerId,
NULL AS vehicleId,
NULL AS baseId,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
datetime(prisoner.last_save_time, 'unixepoch') AS observedAt
FROM user_profile profile
JOIN prisoner ON prisoner.id = profile.prisoner_id
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
JOIN entity ON entity.id = prisoner_entity.entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'player')
UNION ALL
SELECT
'vehicle' AS subjectType,
CAST(spawner.vehicle_entity_id AS TEXT) AS subjectId,
NULL AS userProfileId,
NULL AS gamePlayerId,
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
NULL AS baseId,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
datetime(spawner.vehicle_last_access_time, 'unixepoch') AS observedAt
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
UNION ALL
SELECT
'base' AS subjectType,
CAST(base.id AS TEXT) AS subjectId,
CAST(base.owner_user_profile_id AS TEXT) AS userProfileId,
NULL AS gamePlayerId,
NULL AS vehicleId,
CAST(base.id AS TEXT) AS baseId,
base.location_x AS x,
base.location_y AS y,
0 AS z,
NULL AS observedAt
FROM base
WHERE (:subjectType IS NULL OR :subjectType = 'base')
UNION ALL
SELECT
'flag' AS subjectType,
CAST(flag.element_id AS TEXT) AS subjectId,
CAST(element.owner_profile_id AS TEXT) AS userProfileId,
CAST(owner.prisoner_id AS TEXT) AS gamePlayerId,
NULL AS vehicleId,
CAST(element.base_id AS TEXT) AS baseId,
element.location_x AS x,
element.location_y AS y,
element.location_z AS z,
datetime(flag.overtake_end_time, 'unixepoch') AS observedAt
FROM base_element_flag flag
JOIN base_element element ON element.element_id = flag.element_id
LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id
WHERE (:subjectType IS NULL OR :subjectType = 'flag')
LIMIT COALESCE(:limit, 500)
@@ -1,15 +0,0 @@
SELECT
CAST(member.squad_id AS TEXT) AS squadId,
CAST(member.user_profile_id AS TEXT) AS userProfileId,
CAST(profile.prisoner_id AS TEXT) AS gamePlayerId,
user.id AS steamId,
COALESCE(profile.name, user.name, '') AS displayName,
CAST(member.rank AS TEXT) AS rank,
CASE WHEN member.rank = 4 THEN 1 ELSE 0 END AS isLeader
FROM squad_member member
JOIN user_profile profile ON profile.id = member.user_profile_id
LEFT JOIN user ON user.id = profile.user_id
WHERE (:squadId IS NULL OR CAST(member.squad_id AS TEXT) = :squadId)
AND (:userProfileId IS NULL OR CAST(member.user_profile_id AS TEXT) = :userProfileId)
ORDER BY member.squad_id, member.rank DESC, profile.name
LIMIT COALESCE(:limit, 500)
@@ -1,19 +0,0 @@
SELECT
CAST(s.id AS TEXT) AS squadId,
COALESCE(s.name, '') AS name,
CAST(leader.user_profile_id AS TEXT) AS leaderProfileId,
CAST(leader_profile.prisoner_id AS TEXT) AS leaderPlayerId,
COUNT(member.id) AS memberCount,
s.score AS score,
s.member_limit AS memberLimit,
s.last_member_login_time AS lastMemberLoginTime,
s.last_member_logout_time AS lastMemberLogoutTime
FROM squad s
LEFT JOIN squad_member member ON member.squad_id = s.id
LEFT JOIN squad_member leader ON leader.squad_id = s.id AND leader.rank = 4
LEFT JOIN user_profile leader_profile ON leader_profile.id = leader.user_profile_id
WHERE (:squadId IS NULL OR CAST(s.id AS TEXT) = :squadId)
AND (:search IS NULL OR COALESCE(s.name, '') LIKE '%' || :search || '%')
GROUP BY s.id
ORDER BY s.score DESC, s.id
LIMIT COALESCE(:limit, 500)
@@ -1,28 +0,0 @@
SELECT
CAST(up.id AS TEXT) AS userProfileId,
u.id AS steamId,
CAST(p.id AS TEXT) AS gamePlayerId,
COALESCE(up.name, u.name, '') AS displayName,
up.fame_points AS famePoints,
MAX(CASE WHEN currency.currency_type = 1 THEN currency.account_balance END) AS normalBalance,
MAX(CASE WHEN currency.currency_type = 2 THEN currency.account_balance END) AS goldBalance,
e.location_x AS x,
e.location_y AS y,
e.location_z AS z,
datetime(p.last_save_time, 'unixepoch') AS lastSaveTime,
up.last_login_time AS lastLoginTime,
up.last_logout_time AS lastLogoutTime,
p.is_alive AS isAlive
FROM user_profile up
JOIN user u ON u.id = up.user_id
LEFT JOIN prisoner p ON p.id = up.prisoner_id
LEFT JOIN prisoner_entity pe ON pe.prisoner_id = p.id
LEFT JOIN entity e ON e.id = pe.entity_id
LEFT JOIN bank_account_registry account ON account.account_owner_user_profile_id = up.id
LEFT JOIN bank_account_registry_currencies currency ON currency.bank_account_id = account.id
WHERE (:userProfileId IS NULL OR CAST(up.id AS TEXT) = :userProfileId)
AND (:steamId IS NULL OR u.id = :steamId)
AND (:search IS NULL OR COALESCE(up.name, u.name, '') LIKE '%' || :search || '%')
GROUP BY up.id
ORDER BY up.last_login_time DESC
LIMIT COALESCE(:limit, 500)
@@ -1,16 +0,0 @@
SELECT
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
entity.class AS className,
spawner.vehicle_alias AS label,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
datetime(spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
spawner.is_vehicle_functional AS isFunctional
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
ORDER BY spawner.vehicle_last_access_time DESC
LIMIT COALESCE(:limit, 500)
@@ -271,11 +271,6 @@
"items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" },
"maxItems": 128
},
"dataPacks": {
"type": "array",
"items": { "$ref": "#/$defs/gameClientBridgeDataPack" },
"maxItems": 32
},
"operationTemplates": {
"type": "array",
"items": { "$ref": "#/$defs/gameClientBridgeOperationTemplate" },
@@ -365,25 +360,10 @@
"targetKey": { "$ref": "#/$defs/logicalKey" },
"parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
"targetTable": { "type": "string", "pattern": "^scum_[a-z][a-z0-9_]{0,62}$" },
"upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 },
"columnMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } },
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
}
},
"gameClientBridgeDataPack": {
"type": "object",
"required": ["key", "databaseUserVersion", "logParserRefs", "configMapRefs"],
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"databaseUserVersion": { "type": "integer", "minimum": 1, "maximum": 1000000 },
"logParserRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 },
"configMapRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 }
}
},
"gameClientBridgeOperationSafety": {
"type": "object",
"additionalProperties": false,
@@ -460,10 +440,6 @@
"type": "string",
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.json$"
},
"relativeSqlRef": {
"type": "string",
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)sql/[a-zA-Z0-9_./-]+\\.sql$"
},
"runCapability": {
"enum": [
"process.install",
-81
View File
@@ -134,10 +134,6 @@ function isSafeRelativeJsonRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
}
function isSafeRelativeSQLRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)sql\/[a-zA-Z0-9_./-]+\.sql$/.test(value);
}
function isSafeRelativePathRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+$/.test(value);
}
@@ -676,10 +672,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
targetKey?: string;
parameterSchemaRef?: string;
resultSchemaRef?: string;
sqlRef?: string;
targetTable?: string;
upsertKeys?: string[];
columnMappings?: Record<string, string>;
maxRows?: number;
timeoutSeconds?: number;
};
@@ -848,24 +840,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
}
}
const persistsSCUMRows = queryTemplate.targetTable !== undefined || queryTemplate.sqlRef !== undefined || queryTemplate.upsertKeys !== undefined || queryTemplate.columnMappings !== undefined;
if (persistsSCUMRows) {
if (!queryTemplate.sqlRef || !isSafeRelativeSQLRef(queryTemplate.sqlRef)) {
errors.push(`${location}.sqlRef: persisted SCUM queries must reference a package-relative .sql asset`);
}
if (!/^scum_[a-z][a-z0-9_]{0,62}$/.test(queryTemplate.targetTable ?? "")) {
errors.push(`${location}.targetTable: persisted SCUM queries must target a scum_* table`);
}
if (!Array.isArray(queryTemplate.upsertKeys) || queryTemplate.upsertKeys.length === 0 || !queryTemplate.upsertKeys.every((key) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(key))) {
errors.push(`${location}.upsertKeys: persisted SCUM queries require non-empty safe keys`);
}
const mappings = queryTemplate.columnMappings;
if (!mappings || typeof mappings !== "object" || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([target, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(target) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) {
errors.push(`${location}.columnMappings: persisted SCUM queries require safe target-to-source mappings`);
} else if (Array.isArray(queryTemplate.upsertKeys) && !queryTemplate.upsertKeys.every((key) => key in mappings)) {
errors.push(`${location}.upsertKeys: every upsert key must be declared in columnMappings`);
}
}
if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) {
errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
}
@@ -1016,59 +990,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
return errors;
}
function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
if (typeof manifest !== "object" || manifest === null) return [];
const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; logParserRefs?: string[]; configMapRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? [];
const errors: string[] = [];
const keys = new Set<string>();
for (const [index, dataPack] of dataPacks.entries()) {
const location = `manifest.gameClientBridge.dataPacks[${index}]`;
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(dataPack.key ?? "") || keys.has(dataPack.key ?? "")) errors.push(`${location}.key: must be a unique safe data-pack key`);
keys.add(dataPack.key ?? "");
if (!Number.isInteger(dataPack.databaseUserVersion) || (dataPack.databaseUserVersion ?? 0) < 1) errors.push(`${location}.databaseUserVersion: must be a positive SQLite user_version`);
for (const field of ["logParserRefs", "configMapRefs"] as const) {
const refs = dataPack[field];
if (!Array.isArray(refs) || refs.length === 0) {
errors.push(`${location}.${field}: must declare at least one package mapping asset`);
continue;
}
for (const ref of refs) {
if (!isSafeRelativeJsonRef(ref)) {
errors.push(`${location}.${field}: must use package-relative JSON assets`);
continue;
}
if (!declaredAssets.has(ref)) errors.push(`${location}.${field}: ${ref} must be declared in manifest.assetFiles`);
const target = path.resolve(manifestDir, ref);
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) errors.push(`${location}.${field}: missing package mapping asset ${ref}`);
}
}
}
return errors;
}
function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
if (typeof manifest !== "object" || manifest === null) return [];
const templates = (manifest as { gameClientBridge?: { queryTemplates?: Array<{ sqlRef?: string }> } }).gameClientBridge?.queryTemplates ?? [];
const errors: string[] = [];
for (const [index, template] of templates.entries()) {
if (!template.sqlRef) continue;
const location = `manifest.gameClientBridge.queryTemplates[${index}].sqlRef`;
if (!isSafeRelativeSQLRef(template.sqlRef)) {
errors.push(`${location}: must be a package-relative .sql asset`);
continue;
}
if (!declaredAssets.has(template.sqlRef)) errors.push(`${location}: ${template.sqlRef} must be declared in manifest.assetFiles`);
const assetPath = path.resolve(manifestDir, template.sqlRef);
if (!fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) {
errors.push(`${location}: missing SQL asset ${template.sqlRef}`);
continue;
}
const body = fs.readFileSync(assetPath, "utf8").trim();
if (!/^select\b/i.test(body) || /;\s*\S/.test(body) || /\b(?:insert|update|delete|drop|alter|create|attach|pragma)\b/i.test(body)) errors.push(`${location}: SQL assets must contain one read-only SELECT statement`);
}
return errors;
}
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) {
return [];
@@ -1457,8 +1378,6 @@ export function validateManifestFile(manifestPath: string): string[] {
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
errors.push(...assetValidation.errors);
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
errors.push(...validateGameClientBridgeDataPacks(manifest, manifestDir, assetValidation.declared));
for (const declaration of referencedLifecycleActions(manifest)) {
if (!isSafeRelativeJsonRef(declaration.ref)) {
-7
View File
@@ -56,10 +56,3 @@ The bridge must not expose:
# Client Manager lifecycle bridge
The bridge may request typed `deploy`, `start`, `stop`, `restart`, `status`, `update`, `rollback`, `revoke`, `retry`, or `uninstall` intents when Platform action gating says they are available. Results are safe logical projections with real job phase/progress and redacted recovery guidance. The bridge is not a transport for Run sessions, component keys, artifact bytes, machine paths, process IDs, sockets, or credentials; component registration and heartbeat remain component-to-Platform contracts outside the plugin page.
## Plugin-packaged database data packs
Database-backed game data stays with the game plugin. A browser request names a declared query template and supplies only values accepted by its parameter schema; it never supplies a SQL statement or a database path.
For a persisted game dataset, a query template declares package-relative `sqlRef`, `targetTable`, `upsertKeys`, and `columnMappings`. `sqlRef` must name a declared `sql/.../*.sql` asset containing one read-only `SELECT` statement. `targetTable` is a `scum_*` relation and every upsert key must have a mapping. The Platform resolves and distributes the plugin asset, while Run executes the declared statement through the template's SQLite transport.
Version-specific log parsers and configuration maps are declared in `gameClientBridge.dataPacks`. Each pack names the SQLite `databaseUserVersion` plus JSON parser/map assets. These assets describe the game format; they do not contain host paths, credentials, or browser-provided SQL.
-12
View File
@@ -262,21 +262,10 @@ export interface GameClientBridgeQueryTemplateDeclaration {
targetKey: string;
parameterSchemaRef: string;
resultSchemaRef: string;
sqlRef?: string;
targetTable?: string;
upsertKeys?: string[];
columnMappings?: Record<string, string>;
maxRows: number;
timeoutSeconds: number;
}
export interface GameClientBridgeDataPackDeclaration {
key: string;
databaseUserVersion: number;
logParserRefs: string[];
configMapRefs: string[];
}
export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation";
export interface GameClientBridgeOperationSafety {
@@ -348,7 +337,6 @@ export interface GameClientBridgeManifest {
commands: GameClientBridgeCommandDeclaration[];
snapshots: GameClientBridgeSnapshotDeclaration[];
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
dataPacks?: GameClientBridgeDataPackDeclaration[];
operationTemplates?: GameClientBridgeOperationTemplateDeclaration[];
commandRetentionSeconds: number;
maxCommands: number;
+4 -42
View File
@@ -617,7 +617,7 @@ describe("plugin manifest validation", () => {
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
});
it("declares packaged SCUM v57 query templates without browser-provided SQL", () => {
it("declares typed SCUM.db query templates without browser-visible SQL", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
permissions: string[];
@@ -633,10 +633,6 @@ describe("plugin manifest validation", () => {
targetKey: string;
parameterSchemaRef: string;
resultSchemaRef: string;
sqlRef: string;
targetTable: string;
upsertKeys: string[];
columnMappings: Record<string, string>;
maxRows: number;
timeoutSeconds: number;
}>;
@@ -645,7 +641,7 @@ describe("plugin manifest validation", () => {
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
};
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.activity", "scum.gifts"];
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"];
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
@@ -659,14 +655,7 @@ describe("plugin manifest validation", () => {
expect(template.engine).toBe("sqlite");
expect(template.transportKey).toBe("scum-database");
expect(template.targetKey).toBe("scum-database");
expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/);
expect(template.targetTable).toMatch(/^scum_/);
expect(template.upsertKeys.length).toBeGreaterThan(0);
expect(Object.keys(template.columnMappings).length).toBeGreaterThan(0);
expect(template.upsertKeys.every((key) => key in template.columnMappings)).toBe(true);
const sql = fs.readFileSync(path.join(pluginDir, template.sqlRef), "utf8");
expect(sql).toMatch(/^SELECT\b/i);
expect(sql).not.toMatch(/\b(?:INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH|PRAGMA)\b/i);
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/);
const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8"));
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8"));
expect(parameters).toMatchObject({ type: "object", additionalProperties: false });
@@ -676,7 +665,7 @@ describe("plugin manifest validation", () => {
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions", "scum.activity"]));
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"]));
expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"]));
expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"]));
for (const pageKey of ["players", "squads", "live-map"]) {
@@ -686,19 +675,6 @@ describe("plugin manifest validation", () => {
}
});
it("packages SCUM v57 UTF-16LE log and config maps", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[] }> } };
const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-db-v57");
expect(pack).toMatchObject({ databaseUserVersion: 57 });
const logParsers = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.logParserRefs[0]), "utf8"));
const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8"));
expect(logParsers.encoding).toBe("utf-16le");
expect(logParsers.parsers.map((parser: { key: string }) => parser.key)).toEqual(expect.arrayContaining(["login", "logout", "chat", "admin", "vehicle-destruction"]));
const serverSettings = configMaps.maps.find((map: { key: string }) => map.key === "server-settings");
expect(serverSettings.fields).toMatchObject({ "scum.WelcomeMessage": "welcomeMessage", "scum.MessageOfTheDay": "motd" });
});
it("declares typed SCUM RCON operations without arbitrary command inputs", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
@@ -929,20 +905,6 @@ describe("plugin manifest validation", () => {
expect(errors.some((error) => error.includes("timeoutSeconds"))).toBe(true);
});
it("rejects inline SQL and incomplete SCUM row declarations", () => {
const inlineErrors = validateTemporaryBridgeManifest((manifest) => {
Object.assign(manifest.gameClientBridge.queryTemplates![0], { sqlRef: "SELECT * FROM user_profile", targetTable: "scum_users", upsertKeys: ["userProfileId"], columnMappings: { userProfileId: "userProfileId" } });
});
expect(inlineErrors.some((error) => error.includes("sqlRef"))).toBe(true);
const incompleteErrors = validateTemporaryBridgeManifest((manifest) => {
Object.assign(manifest.gameClientBridge.queryTemplates![0], { sqlRef: "sql/scum-db-v57/users.sql", targetTable: "users", upsertKeys: [], columnMappings: {} });
});
expect(incompleteErrors.some((error) => error.includes("targetTable"))).toBe(true);
expect(incompleteErrors.some((error) => error.includes("upsertKeys"))).toBe(true);
expect(incompleteErrors.some((error) => error.includes("columnMappings"))).toBe(true);
});
it("requires query templates to match a declared sqlite transport target and capability", () => {
const targetErrors = validateTemporaryBridgeManifest((manifest) => {
manifest.gameClientBridge.queryTemplates![0].targetKey = "db/other";
+7 -6
View File
@@ -112,8 +112,8 @@ describe("SCUM plugin feature module", () => {
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(gifts.nodes).toContain("section:礼包管理");
expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
expect(gifts.texts).toContain("礼包定义");
expect(gifts.texts).toContain("定时礼包完成");
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
expect(workflows.nodes).toContain("section:Workflow 状态");
@@ -121,10 +121,11 @@ describe("SCUM plugin feature module", () => {
expect(workflows.texts.join("\n")).toContain("read-positions");
});
it("loads projections through typed workspace actions instead of file snapshots", () => {
expect(pageSource).toContain("listSCUMPlayers");
expect(pageSource).toContain("createSCUMOperation");
expect(pageSource).toContain("createSCUMWorkflow");
it("loads plugin-owned projections through generic platform collections instead of file snapshots", () => {
expect(pageSource).toContain("pluginData");
expect(pageSource).toContain('"scum_users"');
expect(pageSource).toContain('"scum_squads"');
expect(pageSource).toContain('"scum_map_points"');
expect(pageSource).not.toContain("getFileSnapshot");
expect(pageSource).not.toContain("requestFile");
expect(pageSource).not.toContain("writeFile");