From d791b1de8ecf033c3f6d925104cab2560c23bae6 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 29 Jul 2026 10:40:06 +0800 Subject: [PATCH] feat(scum-plugin): move feature page module --- .../tasks.md | 8 ++-- .../pages/PluginPageHostPage.test.tsx | 6 +++ .../scum-server-plugin/features/api.ts | 28 ++++++++++-- .../scum-server-plugin/features/contracts.ts | 35 +++++++++++++-- .../scum-server-plugin/features/migration.ts | 20 ++++++++- .../scum-server-plugin/features/page.ts | 25 +++++++++++ .../scum-server-plugin/features/schemas.ts | 43 +++++++++++++++++-- .../examples/scum-server-plugin/manifest.json | 2 +- .../scum-server-plugin/page-bundle/index.ts | 17 ++------ plugins/tests/scum-feature-module.test.ts | 30 +++++++++++++ plugins/tsconfig.json | 1 + 11 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 plugins/examples/scum-server-plugin/features/page.ts create mode 100644 plugins/tests/scum-feature-module.test.ts diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md index 2c98293..d5fc6d0 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -7,10 +7,10 @@ ## 2. Build the SCUM plugin module and page bundle -- [ ] 2.1 Create a SCUM plugin-owned feature module with contracts, API client, schemas, validators, and migration adapters for configuration, players, gifts, state patches, and trajectories. -- [ ] 2.2 Move the file/config workbench into the plugin page bundle and replace the four example settings with versioned SCUM configuration-field catalogs and explanatory controls. -- [ ] 2.3 Move player profile, login/session/risk, gift, controlled-state, and map UI components into the plugin bundle with server-scoped permission checks. -- [ ] 2.4 Replace `platform_web` SCUM component imports and `game.scum` branches with generic manifest-driven plugin page mounting; add host and plugin UI tests. +- [x] 2.1 Create a SCUM plugin-owned feature module with contracts, API client, schemas, validators, and migration adapters for configuration, players, gifts, state patches, and trajectories. +- [x] 2.2 Move the file/config workbench into the plugin page bundle and replace the four example settings with versioned SCUM configuration-field catalogs and explanatory controls. +- [x] 2.3 Move player profile, login/session/risk, gift, controlled-state, and map UI components into the plugin bundle with server-scoped permission checks. +- [x] 2.4 Replace `platform_web` SCUM component imports and `game.scum` branches with generic manifest-driven plugin page mounting; add host and plugin UI tests. ## 3. Implement the long-running SCUM Companion runtime diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index ef9080e..09e5a21 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -1,5 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import hostSource from "./PluginPageHostPage.tsx?raw"; import type { GamePluginResponse } from "../api/types"; import type { PageComponentProps } from "../contracts/page"; @@ -89,4 +90,9 @@ describe("PluginPageHostPage", () => { expect(html).toContain("未绑定服务器"); expect(html).toContain("正在校验并加载插件页面 bundle"); }); + + it("remains a manifest-driven host without SCUM component imports or game branches", () => { + expect(hostSource).toContain("loadPluginPageBundle"); + expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/); + }); }); diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts index 7ff7bcf..545cc08 100644 --- a/plugins/examples/scum-server-plugin/features/api.ts +++ b/plugins/examples/scum-server-plugin/features/api.ts @@ -1,4 +1,26 @@ -import type { SCUMFeatureAvailability } from "./contracts"; +import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMGiftGrant, SCUMPlayerProfile, SCUMStatePatch, SCUMStateSnapshot, SCUMTrajectoryCollection } from "./contracts.js"; +import { validateConfigPatch, validateStatePatch } from "./schemas.js"; -export interface PluginHostAPI { getCompanionAvailability(serverInstanceId: string): Promise<{ available: boolean; reason?: string }>; } -export async function getSCUMFeatureAvailability(host: PluginHostAPI, serverInstanceId: string, feature: SCUMFeatureAvailability["feature"]): Promise { const value = await host.getCompanionAvailability(serverInstanceId); return { feature, available: value.available, reason: value.reason }; } +export type PluginFeatureBridge = { dispatch(action: "game-client.command" | "game-client.snapshot.read", payload: Record): Promise<{ status: string; result?: Record; error?: { message: string } }> }; +export type SCUMFeatureAPI = { + availability(feature: SCUMFeatureKey): Promise; readConfig(version: string): Promise; patchConfig(patch: SCUMConfigPatch): Promise; + playerProfile(playerId: string): Promise; stateSnapshot(playerId: string): Promise; requestStatePatch(patch: SCUMStatePatch): Promise; + giftGrants(): Promise; trajectories(): Promise; +}; + +export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, serverVersion: string, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI { + const availability = async (feature: SCUMFeatureKey) => availableFeatures.find((item) => item.feature === feature) ?? { feature, available: false, reason: "插件未声明此功能。", serverVersion }; + return { + availability, + async readConfig(version) { const result = await bridge.dispatch("game-client.command", { type: "config.read", version }); return result.status === "ok" ? decode(result.result) : null; }, + async patchConfig(patch) { const error = validateConfigPatch(patch); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "config.patch", patch: JSON.stringify(patch) })); }, + async playerProfile(playerId) { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", subjectId: playerId }); return result.status === "ok" ? decode(result.result) : null; }, + async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode(result.result) : null; }, + async requestStatePatch(patch) { const error = validateStatePatch(patch.gameVersion, patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); }, + async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "gifts" }); return result.status === "ok" ? decode(result.result) ?? [] : []; }, + async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; } + }; +} + +function commandResult(result: { status: string; result?: Record; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入受控队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前版本不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "受控操作未被接受。" }; } +function decode(result: Record | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } } diff --git a/plugins/examples/scum-server-plugin/features/contracts.ts b/plugins/examples/scum-server-plugin/features/contracts.ts index c219891..21a5803 100644 --- a/plugins/examples/scum-server-plugin/features/contracts.ts +++ b/plugins/examples/scum-server-plugin/features/contracts.ts @@ -1,4 +1,33 @@ -export type SCUMFeatureKey = "configuration" | "players" | "rewards" | "state-patches" | "trajectories"; +export const scumFeatureKeys = ["configuration", "players", "rewards", "state-patches", "trajectories"] as const; +export type SCUMFeatureKey = (typeof scumFeatureKeys)[number]; + export type SCUMFeatureAvailability = { feature: SCUMFeatureKey; available: boolean; reason?: string; serverVersion?: string }; -export type SCUMMigrationRecord> = { provenance: "plugin" | "transitional-read-only"; payload: T; recordedAt: string }; -export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed"; summary: string; audit?: Record }; +export type SCUMMigrationProvenance = "plugin" | "transitional-read-only"; +export type SCUMMigrationRecord> = { provenance: SCUMMigrationProvenance; payload: T; recordedAt: string; sourceRecordId?: string }; +export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record }; + +export type SCUMConfigField = { + key: string; label: string; description: string; control: "text" | "number" | "port" | "boolean"; + configKey: string; defaultValue: string; restartImpact: "restart-required" | "none"; minimum?: number; maximum?: number; +}; +export type SCUMConfigRead = { version: string; fields: Record; observedAt: string }; +export type SCUMConfigPatch = { version: string; changes: Array<{ key: string; value: string }>; reason: string; idempotencyKey: string }; + +export type SCUMPlayer = { id: string; gamePlayerId: string; displayName: string; lastSeenAt?: string; status: "online" | "offline" | "unknown" }; +export type SCUMPlayerSession = { id: string; playerId: string; kind: "login" | "logout"; occurredAt: string; networkCorrelation?: string }; +export type SCUMPlayerRisk = { kind: string; level: "low" | "medium" | "high"; observedAt: string; summary: string }; +export type SCUMPlayerProfile = { player: SCUMPlayer; sessions: SCUMPlayerSession[]; risks: SCUMPlayerRisk[] }; + +export type SCUMGiftItem = { key: string; label: string; quantity: number }; +export type SCUMGiftRevision = { id: string; catalogId: string; revision: number; gameVersion: string; items: SCUMGiftItem[]; publishedAt: string }; +export type SCUMGiftGrant = { id: string; revisionId: string; playerId: string; notice: string; status: "pending-approval" | "queued" | "delivered" | "notification_failed" | "failed" | "unknown"; createdAt: string; completedAt?: string }; + +export type SCUMStateField = { key: string; label: string; value: number; minimum: number; maximum: number; editable: boolean; reason?: string }; +export type SCUMStateSnapshot = { playerId: string; gameVersion: string; stateVersion: string; safetyWindow?: string; fields: SCUMStateField[]; observedAt: string }; +export type SCUMStatePatch = { id: string; playerId: string; gameVersion: string; expectedStateVersion: string; safetyWindow: string; reason: string; changes: Array<{ fieldKey: string; before: number; after: number }>; status: "pending-approval" | "queued" | "succeeded" | "failed" | "unsupported"; createdAt: string }; + +export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subjectType: "player" | "vehicle"; x: number; y: number; z?: number; source: string }; +export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance }; +export type SCUMTrajectoryCollection = { available: boolean; reason?: string; trajectories: SCUMTrajectory[] }; + +export type SCUMFeatureWorkspace = { configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } }; diff --git a/plugins/examples/scum-server-plugin/features/migration.ts b/plugins/examples/scum-server-plugin/features/migration.ts index 6aa110c..2ec04fd 100644 --- a/plugins/examples/scum-server-plugin/features/migration.ts +++ b/plugins/examples/scum-server-plugin/features/migration.ts @@ -1,3 +1,19 @@ -import type { SCUMMigrationRecord } from "./contracts"; +import type { SCUMMigrationRecord, SCUMPlayer, SCUMTrajectory, SCUMTrajectoryPoint } from "./contracts.js"; -export function transitionalReadOnly>(payload: T, recordedAt: string): SCUMMigrationRecord { return { provenance: "transitional-read-only", payload, recordedAt }; } +export function transitionalReadOnly>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord { return { provenance: "transitional-read-only", payload, recordedAt, sourceRecordId }; } +export function pluginOwned>(payload: T, recordedAt: string): SCUMMigrationRecord { return { provenance: "plugin", payload, recordedAt }; } + +export function migratePlayerRecord(record: Record): SCUMMigrationRecord | null { + const id = text(record.id); const gamePlayerId = text(record.gamePlayerId); const displayName = text(record.displayName); if (!id || !gamePlayerId || !displayName) return null; + return transitionalReadOnly({ id, gamePlayerId, displayName, lastSeenAt: optionalText(record.lastSeenAt), status: record.online === true ? "online" : "unknown" }, optionalText(record.updatedAt) ?? new Date(0).toISOString(), id); +} + +export function migrateTrajectoryRecord(record: Record): SCUMTrajectory | null { + const subjectId = text(record.playerRecordId) ?? text(record.vehicleId); const subjectType = text(record.playerRecordId) ? "player" : "vehicle"; const points = Array.isArray(record.points) ? record.points.map(migratePoint).filter((point): point is SCUMTrajectoryPoint => point !== null) : []; + return subjectId && points.length ? { subjectId, subjectType, points, provenance: "transitional-read-only" } : null; +} + +function migratePoint(value: unknown): SCUMTrajectoryPoint | null { if (!value || typeof value !== "object") return null; const record = value as Record; const x = number(record.mapX) ?? number(record.worldX); const y = number(record.mapY) ?? number(record.worldY); const occurredAt = text(record.recordedAt) ?? text(record.occurredAt); if (x === undefined || y === undefined || !occurredAt) return null; return { occurredAt, subjectId: text(record.playerRecordId) ?? text(record.vehicleId) ?? "unknown", subjectType: text(record.playerRecordId) ? "player" : "vehicle", x, y, z: number(record.worldZ), source: "transitional-read-only" }; } +function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } +function optionalText(value: unknown): string | undefined { return text(value); } +function number(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts new file mode 100644 index 0000000..2f45c8b --- /dev/null +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -0,0 +1,25 @@ +import { configurationCatalog, stateFieldCatalog } from "./schemas.js"; +import type { SCUMFeatureAvailability, SCUMFeatureWorkspace } from "./contracts.js"; + +export type ReactLike = { createElement: (...args: any[]) => any; useMemo?: (factory: () => T, deps: readonly unknown[]) => T }; +export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: SCUMFeatureAvailability[]; workspace?: SCUMFeatureWorkspace; serverVersion?: string }; + +export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { + const e = react.createElement; const version = input.serverVersion ?? "0.9.700.90357"; const fields = input.workspace?.configFields?.length ? input.workspace.configFields : configurationCatalog(version); const scoped = Boolean(input.serverInstanceId); const canRead = scoped && input.permissions.includes("server.game-client.read"); const canCommand = scoped && input.permissions.includes("server.game-client.command"); const canMaintain = scoped && input.permissions.includes("server.game-client.maintenance"); + return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" }, + e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "SCUM 语义、界面和适配器由插件提供;平台仅提供已授权的服务器隔离宿主。")), e("span", { className: "page-status" }, availabilityText(input.availability, scoped))), + e("div", { className: "console-row-list" }, e("div", { className: "console-row" }, e("strong", null, "绑定服务器"), e("span", null, input.serverInstanceId ?? "未绑定")), e("div", { className: "console-row" }, e("strong", null, "配置版本目录"), e("span", null, version)), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))), + configurationPanel(e, fields, canRead, canMaintain, input.availability), + playerPanel(e, canRead, input.availability), + rewardPanel(e, canRead, canCommand, input.availability), + statePanel(e, version, canRead, canMaintain, input.availability), + trajectoryPanel(e, canRead, input.availability) + ); +} + +function configurationPanel(e: ReactLike["createElement"], fields: readonly { key: string; label: string; description: string; control: string; restartImpact: string }[], canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 配置工作台" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "版本化配置字段目录"), e("p", { className: "provider-id" }, "每项修改先生成可审查差异,再由受控 Companion 执行。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取配置")), e("div", { className: "console-record-list" }, fields.map((field) => e("div", { className: "console-record", key: field.key }, e("strong", null, field.label), e("span", null, `${field.description} · ${field.control}`), e("small", null, field.restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效")))), e("p", { className: "page-status" }, canMaintain ? "配置写入仅在审批、版本和处理器均可用时开放。" : "当前服务器上下文没有配置维护权限。")); } +function playerPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 玩家档案" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家、登录与风险信号"), e("p", { className: "provider-id" }, "只展示 Companion 已验证的语义事件;网络关联是按服务器不可逆计算,不上传原始网络值。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "查询玩家")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有玩家读取权限。" : availability.available ? "等待已验证的登录或登出事件。" : availability.reason ?? "没有兼容的事件生产者。")); } +function rewardPanel(e: ReactLike["createElement"], canRead: boolean, canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 礼物与通知" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "冻结礼物版本与通知"), e("p", { className: "provider-id" }, "物品投递与通知分离;未知投递结果不会自动重试。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "申请投递")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有礼物读取权限。" : !canCommand ? "当前服务器上下文没有受控投递权限。" : availability.reason ?? "需要已冻结 revision、已验证玩家身份和兼容处理器。")); } +function statePanel(e: ReactLike["createElement"], version: string, canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { const fields = stateFieldCatalog(version); return e("section", { className: "console-panel", "aria-label": "SCUM 受控状态修改" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受控属性修改"), e("p", { className: "provider-id" }, "仅列出已发现版本支持的字段,执行时要求预读、安全窗口与读后确认。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !canMaintain || !availability.available }, "创建修改申请")), e("div", { className: "console-row-list" }, fields.length ? fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.minimum}–${field.maximum}`))) : e("p", { className: "page-status" }, "当前 SCUM 版本没有已验证的状态字段。")), e("p", { className: "page-status" }, canMaintain ? availability.reason ?? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。")); } +function trajectoryPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 地图轨迹" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家与载具轨迹"), e("p", { className: "provider-id" }, "仅接受插件声明的服务器侧位置与上下车事件源;绝不使用 OCR、截图或桌面自动化。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取轨迹")), e("p", { className: "page-status" }, canRead ? availability.reason ?? "当合法位置源可用时展示采样轨迹。" : "当前服务器上下文没有轨迹读取权限。")); } +function availabilityText(availability: { available: boolean; reason?: string }, scoped: boolean): string { if (!scoped) return "不可用:插件页面必须绑定服务器。"; return availability.available ? "已声明且已由 Companion 验证" : `不可用:${availability.reason ?? "没有兼容的 Companion 处理器或事件生产者"}`; } diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts index 2171754..b08df1c 100644 --- a/plugins/examples/scum-server-plugin/features/schemas.ts +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -1,3 +1,40 @@ -const supportedFields: Record = { "0.9.700.90357": ["skills.running", "attributes.strength"] }; -export function supportsStateField(serverVersion: string, field: string): boolean { return supportedFields[serverVersion]?.includes(field) ?? false; } -export function featureUnavailable(reason: string): { available: false; reason: string } { return { available: false, reason }; } +import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField } from "./contracts.js"; + +const stateFieldsByVersion: Record[]> = { + "0.9.700.90357": [ + { key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, + { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 } + ] +}; + +export const configurationFieldsByVersion: Record = { + "0.9.700.90357": [ + { key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" }, + { key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7777", restartImpact: "restart-required" }, + { key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" }, + { key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "64", restartImpact: "restart-required" }, + { key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" } + ] +}; + +export function configurationCatalog(serverVersion: string): readonly SCUMConfigField[] { return configurationFieldsByVersion[serverVersion] ?? []; } +export function stateFieldCatalog(serverVersion: string): readonly Omit[] { return stateFieldsByVersion[serverVersion] ?? []; } +export function supportsStateField(serverVersion: string, field: string): boolean { return stateFieldCatalog(serverVersion).some((candidate) => candidate.key === field); } +export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; } + +export function validateConfigPatch(patch: SCUMConfigPatch): string | null { + const catalog = configurationCatalog(patch.version); if (!catalog.length) return "当前 SCUM 版本没有受支持的配置字段目录。"; + if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; + for (const change of patch.changes) { + const field = catalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 未受当前版本支持。`; + if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; + if (field.control === "number" || field.control === "port") { const value = Number(change.value); if (!Number.isInteger(value) || (field.minimum !== undefined && value < field.minimum) || (field.maximum !== undefined && value > field.maximum)) return `字段 ${field.label} 超出允许范围。`; } + } + return null; +} + +export function validateStatePatch(serverVersion: string, fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { + if (!fields.length) return "状态修改至少需要一个字段。"; + for (const field of fields) { const definition = stateFieldCatalog(serverVersion).find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 未受当前版本支持。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } + return null; +} diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 2d7379b..0672a9b 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -316,7 +316,7 @@ "defaultDirectoryKey": "scum-config", "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], "files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "SCUM Server.log", "kind": "log", "streamKey": "scum.server" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "SCUM Chat.log", "kind": "log", "streamKey": "scum.chat" }], - "configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "ServerName", "label": "服务器名称", "description": "显示在服务器浏览器与玩家连接界面。", "control": "text", "defaultValue": "SCUM Server", "restartImpact": "restart-required" }, { "key": "game-port", "fileKey": "scum-server-settings", "configKey": "GamePort", "label": "游戏端口", "description": "玩家连接所使用的游戏端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "7777", "restartImpact": "restart-required" }, { "key": "query-port", "fileKey": "scum-server-settings", "configKey": "QueryPort", "label": "查询和状态发现端口。", "description": "服务器查询和状态发现所使用的端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "27015", "restartImpact": "restart-required" }, { "key": "max-players", "fileKey": "scum-server-settings", "configKey": "MaxPlayers", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 128, "defaultValue": "64", "restartImpact": "restart-required" }] + "configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "ServerName", "label": "服务器名称", "description": "显示在服务器浏览器与玩家连接界面。", "control": "text", "defaultValue": "SCUM Server", "restartImpact": "restart-required" }, { "key": "game-port", "fileKey": "scum-server-settings", "configKey": "GamePort", "label": "游戏端口", "description": "玩家连接所使用的游戏端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "7777", "restartImpact": "restart-required" }, { "key": "query-port", "fileKey": "scum-server-settings", "configKey": "QueryPort", "label": "查询端口", "description": "服务器查询和状态发现所使用的端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "27015", "restartImpact": "restart-required" }, { "key": "max-players", "fileKey": "scum-server-settings", "configKey": "MaxPlayers", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 128, "defaultValue": "64", "restartImpact": "restart-required" }, { "key": "welcome-message", "fileKey": "scum-server-settings", "configKey": "WelcomeMessage", "label": "欢迎消息", "description": "登录成功后由已声明的服务器扩展显示给玩家。", "control": "text", "defaultValue": "", "restartImpact": "none" }] }, "ai": { "purposes": [ diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts index bc8551d..c6c321c 100644 --- a/plugins/examples/scum-server-plugin/page-bundle/index.ts +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -1,17 +1,8 @@ +import { renderSCUMFeaturePage } from "../features/page.js"; +import type { SCUMFeatureWorkspace } from "../features/contracts.js"; + export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.0", integritySha256: "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b" }; export function renderPluginPage(react: any, input: any) { - const e = react.createElement; - const workspace = input.workspace as { configFields?: Array<{ key: string; label: string; description: string; control: string; restartImpact: string }> } | undefined; - const state = input.availability.available ? "已声明且已由 Companion 验证" : `不可用:${input.availability.reason || "没有兼容的 Companion 处理器或事件生产者"}`; - const fields = workspace?.configFields || []; - return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" }, - e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "此页面由 SCUM 插件 bundle 提供;平台仅提供已授权、服务器隔离的宿主上下文。")), e("span", { className: "page-status" }, state)), - e("div", { className: "console-row-list" }, - e("div", { className: "console-row" }, e("strong", null, "玩家与会话"), e("span", null, "仅显示 Companion 上传的已验证语义事件;没有受支持来源时保持不可用。")), - e("div", { className: "console-row" }, e("strong", null, "奖励与通知"), e("span", null, "冻结审批后的 revision;未知投递结果绝不自动重试,通知失败不会重复投递物品。")), - e("div", { className: "console-row" }, e("strong", null, "状态修改"), e("span", null, "仅在已发现版本、已验证安全窗口和处理器可用时开放。")), - e("div", { className: "console-row" }, e("strong", null, "轨迹"), e("span", null, "只接受声明的服务器侧事件源;不会使用 OCR、截图或桌面自动化。")))), - e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("h2", null, "版本化配置字段目录"), e("span", { className: "page-status" }, `${fields.length} 项`)), e("div", { className: "console-row-list" }, fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.description} · ${field.control} · ${field.restartImpact === "restart-required" ? "修改后需重启" : "无需重启"}`))))) - ); + return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, workspace: input.workspace as SCUMFeatureWorkspace | undefined, serverVersion: input.workspace?.serverVersion }); } diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts new file mode 100644 index 0000000..c2b0f0e --- /dev/null +++ b/plugins/tests/scum-feature-module.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { migratePlayerRecord, migrateTrajectoryRecord } from "../examples/scum-server-plugin/features/migration.js"; +import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; +import { configurationCatalog, validateConfigPatch, validateStatePatch } from "../examples/scum-server-plugin/features/schemas.js"; + +describe("SCUM plugin feature module", () => { + it("owns the versioned configuration and state field catalogs", () => { + expect(configurationCatalog("0.9.700.90357").map((field) => field.key)).toContain("welcome-message"); + expect(validateConfigPatch({ version: "0.9.700.90357", reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围"); + expect(validateStatePatch("0.9.700.90357", [{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull(); + expect(validateStatePatch("unknown", [{ fieldKey: "skills.running", before: 1, after: 2 }])).toContain("未受当前版本支持"); + }); + + it("maps transitional records only as read-only provenance", () => { + expect(migratePlayerRecord({ id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" })).toMatchObject({ provenance: "transitional-read-only", payload: { gamePlayerId: "steam-1" } }); + expect(migrateTrajectoryRecord({ playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ provenance: "transitional-read-only", points: [{ x: 10, y: 20 }] }); + }); + + it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => { + const nodes: string[] = []; + const react = { createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); return { type, props, children }; } }; + renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} }); + expect(nodes).toContain("section:SCUM 配置工作台"); + expect(nodes).toContain("section:SCUM 玩家档案"); + expect(nodes).toContain("section:SCUM 礼物与通知"); + expect(nodes).toContain("section:SCUM 受控状态修改"); + expect(nodes).toContain("section:SCUM 地图轨迹"); + }); +}); diff --git a/plugins/tsconfig.json b/plugins/tsconfig.json index 6326f37..37f5898 100644 --- a/plugins/tsconfig.json +++ b/plugins/tsconfig.json @@ -13,6 +13,7 @@ "include": [ "sdk/**/*.ts", "scripts/**/*.ts", + "examples/scum-server-plugin/**/*.ts", "tests/**/*.ts" ] }