diff --git a/platform_web/components/ServerDeploymentWorkflow.test.tsx b/platform_web/components/ServerDeploymentWorkflow.test.tsx index 0216e46..6f4e75c 100644 --- a/platform_web/components/ServerDeploymentWorkflow.test.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.test.tsx @@ -48,6 +48,70 @@ afterEach(async () => { }); describe("ServerDeploymentWorkflow", () => { + it("merges plugin defaults into guided edit inputs", async () => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const scumPlugin: GamePluginResponse = { ...plugin, id: "game.scum", name: "SCUM Server", serverType: "scum", serverDisplayName: "SCUM Dedicated Server", createFields: [{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "7779" }, { key: "queryPort", label: "查询端口", type: "port", required: true, defaultValue: "27015" }] }; + const initialForm = { ...defaultServerCreateForm([scumPlugin]), name: "Moon Base", createInputs: { gamePort: "28000" } }; + + await act(async () => { + root?.render( + undefined} + onSubmit={async () => undefined} + /> + ); + }); + + expect(container.textContent).toContain("游戏端口"); + expect(container.textContent).toContain("查询端口"); + expect(Array.from(container.querySelectorAll('input[type="number"]')).map((input) => input.value)).toEqual(["28000", "27015"]); + }); + + it("normalizes the create wizard to canonical plugins with visible guided inputs", async () => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const examplePlugin: GamePluginResponse = { ...plugin, id: "game.example", name: "Example Server", serverType: "example", serverDisplayName: "Example Server", createFields: [], tags: ["development"] }; + const staleScumPlugin: GamePluginResponse = { ...plugin, id: "game.scum.codex.20260804095301", name: "SCUM Server", version: "0.1.4", serverType: "scum", serverDisplayName: "SCUM Dedicated Server", manifestRef: "plugins/examples/scum-server-plugin/game.scum.codex.20260804095301/manifest.json", tags: ["scum"], createFields: [{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "27000" }] }; + const scumPlugin: GamePluginResponse = { ...staleScumPlugin, id: "game.scum", version: "0.1.15", manifestRef: "artifact://manifests/game.scum/0.1.15", createFields: [{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "7779" }, { key: "queryPort", label: "查询端口", type: "port", required: true, defaultValue: "27015" }] }; + const initialForm = defaultServerCreateForm([examplePlugin, staleScumPlugin, scumPlugin]); + + await act(async () => { + root?.render( + undefined} + onSubmit={async () => undefined} + /> + ); + }); + + const pluginSelect = container.querySelector('select[name="pluginId"]'); + expect(pluginSelect?.value).toBe("game.scum"); + expect(Array.from(pluginSelect?.options ?? []).map((option) => option.value)).toEqual(["game.scum", "game.example"]); + + const nameInput = container.querySelector('input[name="name"]'); + if (!nameInput) throw new Error("server name input not found"); + await act(async () => { + setInputValue(nameInput, "Moon Base"); + }); + + await submitWorkflow(container); + await submitWorkflow(container); + expect(container.querySelector('input[value="7779"]')).not.toBeNull(); + expect(container.querySelector('input[value="27015"]')).not.toBeNull(); + }); + it("submits deployment mode and matching startup inputs from the create wizard", async () => { container = document.createElement("div"); document.body.append(container); diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx index d2d6ef3..ff83ad5 100644 --- a/platform_web/components/ServerDeploymentWorkflow.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -3,7 +3,7 @@ import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from " import type { GamePluginResponse, ServerDeploymentResponse, ServerDeploymentRevealResponse } from "../api/types"; import { ManagementDialog } from "./OperationControls"; -import { pluginCreateInputDefaults, pluginLabel, type ServerCreateFormState } from "../contracts/serverManagement"; +import { pluginCreateInputDefaults, pluginCreateInputValues, pluginLabel, serverCreatePluginOptions, type ServerCreateFormState } from "../contracts/serverManagement"; import { cx } from "../utils/classes"; type WorkflowKind = "create" | "edit"; @@ -25,7 +25,8 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep const [form, setForm] = useState(initialForm); const [revealBusy, setRevealBusy] = useState(false); const [revealError, setRevealError] = useState(""); - const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]); + const selectablePlugins = useMemo(() => kind === "create" ? serverCreatePluginOptions(plugins) : plugins, [kind, plugins]); + const selectedPlugin = useMemo(() => selectablePlugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, selectablePlugins]); const pluginFields = selectedPlugin?.createFields ?? []; const workflowSteps = kind === "create" ? [{ label: "基本信息", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }] @@ -38,10 +39,10 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep useEffect(() => { if (!open) return; setStep(0); - setForm(initialForm); + setForm(normalizeWorkflowForm(initialForm, selectablePlugins, kind === "create")); setRevealBusy(false); setRevealError(""); - }, [initialForm, kind, open]); + }, [initialForm, kind, open, selectablePlugins]); useEffect(() => { if (!open || kind !== "edit" || !onReveal) return; @@ -52,7 +53,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep const { name, value } = event.target; setForm((current) => { if (name === "pluginId") { - const plugin = plugins.find((item) => item.id === value); + const plugin = selectablePlugins.find((item) => item.id === value); return { ...current, pluginId: value, createInputs: pluginCreateInputDefaults(plugin) }; } return { ...current, [name]: value }; @@ -107,7 +108,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
    {workflowSteps.map((item, index) => { const Icon = item.icon; return
  1. {index < step ? : }{index + 1}. {item.label}
  2. ; })}
{step === pluginStep &&
创建基础信息插件决定下一步显示哪些部署方式和游戏参数。
配置启动项新建安装、接管已有和自定义启动分别填写自己的字段。
平台构建 Run 包平台在自有构建器中打包,Run 启动后自动上报心跳。
-
+
} {step === modeStep &&

选择这台服务器的创建方式;下一步只显示该方式需要的启动项。

插件生命周期链路Run 会按插件声明执行预检、安装或扫描、配置映射与健康验证;目录本身不代表安装完成。
setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> @@ -127,6 +128,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep )} ))}
+ {form.deploymentMode === "guided-install" && pluginFields.length === 0 &&
当前插件没有声明可填写的创建项可以返回选择其他插件,或改用自定义启动方式填写命令、目录和启动参数。
} {form.deploymentMode === "guided-install" && } {form.deploymentMode === "existing-server" && } {form.deploymentMode === "custom-command" &&
高级启动设置

只有自定义启动器需要这些设置。执行目录留空时,Run 以服务器目录执行。

} @@ -137,6 +139,11 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep ; } +function normalizeWorkflowForm(form: ServerCreateFormState, plugins: GamePluginResponse[], preferFirstPlugin = false): ServerCreateFormState { + const plugin = preferFirstPlugin ? plugins[0] : plugins.find((item) => item.id === form.pluginId) ?? plugins[0]; + return { ...form, pluginId: plugin?.id ?? "", createInputs: pluginCreateInputValues(plugin, plugin?.id === form.pluginId ? form.createInputs : undefined) }; +} + function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return ; } function GuidedInstallPlan({ pluginName }: { pluginName: string }) { diff --git a/platform_web/contracts/serverManagement.ts b/platform_web/contracts/serverManagement.ts index e70c38d..082f617 100644 --- a/platform_web/contracts/serverManagement.ts +++ b/platform_web/contracts/serverManagement.ts @@ -94,6 +94,24 @@ export function pluginLabel(plugin: GamePluginResponse | undefined, pluginId: st return plugin.serverDisplayName || plugin.name || plugin.id; } +export function serverCreatePluginOptions(plugins: GamePluginResponse[]): GamePluginResponse[] { + const selected = new Map(); + for (const plugin of plugins.filter(pluginCanCreateServer)) { + const key = serverCreatePluginIdentity(plugin); + const current = selected.get(key); + if (!current || compareServerCreatePlugins(plugin, current) < 0) selected.set(key, plugin); + } + return Array.from(selected.values()).sort(compareServerCreatePlugins); +} + +export function pluginCanCreateServer(plugin: GamePluginResponse): boolean { + return plugin.status === "installed" && plugin.declaredPermissions.includes("server.create"); +} + +export function pluginCreateInputValues(plugin: GamePluginResponse | undefined, values: Record | undefined): Record { + return { ...pluginCreateInputDefaults(plugin), ...(values ?? {}) }; +} + export function canStartServer(state: ServerInstanceState): boolean { return state === "ready" || state === "stopped" || state === "failed"; } @@ -119,6 +137,35 @@ export function pluginCreateInputDefaults(plugin: GamePluginResponse | undefined return Object.fromEntries((plugin?.createFields ?? []).map((field) => [field.key, field.defaultValue ?? ""])); } +function serverCreatePluginIdentity(plugin: GamePluginResponse): string { + return `${plugin.serverType}:${plugin.serverDisplayName || plugin.name || plugin.id}`.toLowerCase(); +} + +function compareServerCreatePlugins(left: GamePluginResponse, right: GamePluginResponse): number { + const leftScore = serverCreatePluginScore(left); + const rightScore = serverCreatePluginScore(right); + for (let index = 0; index < leftScore.length; index += 1) { + const delta = leftScore[index]-rightScore[index]; + if (delta !== 0) return delta; + } + const labelDelta = pluginLabel(left, left.id).localeCompare(pluginLabel(right, right.id)); + return labelDelta || left.id.localeCompare(right.id); +} + +function serverCreatePluginScore(plugin: GamePluginResponse): number[] { + return [ + (plugin.createFields?.length ?? 0) > 0 ? 0 : 1, + plugin.tags.includes("development") ? 1 : 0, + plugin.id === `game.${plugin.serverType}` ? 0 : 1, + plugin.id.includes(".codex.") ? 1 : 0, + -versionWeight(plugin.version) + ]; +} + +function versionWeight(version: string): number { + return version.split(/[^0-9]+/).filter(Boolean).slice(0, 3).reduce((weight, part, index) => weight+Number(part || 0)*Math.pow(1000, 2-index), 0); +} + export function serverMetadataFormFromInstance(instance: ServerInstanceResponse): ServerMetadataFormState { return { name: instance.name }; } diff --git a/platform_web/schemas/serverManagement.test.ts b/platform_web/schemas/serverManagement.test.ts index 9701e73..0e608b6 100644 --- a/platform_web/schemas/serverManagement.test.ts +++ b/platform_web/schemas/serverManagement.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { GamePluginResponse, ServerInstanceResponse } from "../api/types"; -import { defaultServerCreateForm } from "../contracts/serverManagement"; +import { defaultServerCreateForm, pluginCreateInputValues, serverCreatePluginOptions } from "../contracts/serverManagement"; import { minimalServerCreateRequestFromForm, serverCreateRequestFromForm, serverInstanceIdFromName, serverLifecycleCommandRequest } from "./serverManagement"; const plugin: GamePluginResponse = { @@ -112,4 +112,18 @@ describe("runtime profile server creation contracts", () => { expect("profileKey" in (request.deployment ?? {})).toBe(false); expect("runtimeBindings" in (request.deployment ?? {})).toBe(false); }); + + it("keeps the create picker focused on canonical create-capable plugins", () => { + const examplePlugin = { ...plugin, id: "game.example", name: "Example Server", serverType: "example", serverDisplayName: "Example Server", createFields: [], tags: ["development"] }; + const staleScumPlugin = { ...plugin, id: "game.scum.codex.20260804095301", name: "SCUM Server", version: "0.1.4", serverType: "scum", serverDisplayName: "SCUM Dedicated Server", manifestRef: "plugins/examples/scum-server-plugin/game.scum.codex.20260804095301/manifest.json", tags: ["scum"] }; + const scumPlugin = { ...staleScumPlugin, id: "game.scum", version: "0.1.15", manifestRef: "artifact://manifests/game.scum/0.1.15", createFields: [{ key: "gamePort", label: "游戏端口", type: "port" as const, required: true, defaultValue: "7779" }] }; + + const options = serverCreatePluginOptions([examplePlugin, staleScumPlugin, scumPlugin]); + expect(options.map((item) => item.id)).toEqual(["game.scum", "game.example"]); + expect(defaultServerCreateForm(options)).toMatchObject({ pluginId: "game.scum", createInputs: { gamePort: "7779" } }); + }); + + it("merges saved create inputs over plugin defaults", () => { + expect(pluginCreateInputValues(plugin, { maxPlayers: "96" })).toEqual({ gamePort: "7777", maxPlayers: "96" }); + }); }); diff --git a/scripts/local-debug/smoke.sh b/scripts/local-debug/smoke.sh index 691e55b..b996410 100755 --- a/scripts/local-debug/smoke.sh +++ b/scripts/local-debug/smoke.sh @@ -460,6 +460,19 @@ if (!logSources.some((source) => source.kind === "process.stdout" && source.stre if (!logSources.some((source) => source.kind === "process.stderr" && source.streamKey === "scum.console.stderr")) { missing.push("SCUM process stderr log source"); } +const createFields = plugin.createFields ?? []; +if (!createFields.some((field) => field.key === "serverName")) { + missing.push("SCUM server name create field"); +} +if (!createFields.some((field) => field.key === "gamePort" && field.defaultValue === "7779")) { + missing.push("SCUM game port create field"); +} +if (!createFields.some((field) => field.key === "queryPort" && field.defaultValue === "27015")) { + missing.push("SCUM query port create field"); +} +if (!createFields.some((field) => field.key === "maxPlayers" && field.defaultValue === "128")) { + missing.push("SCUM max players create field"); +} if (!plugin.gameClientBridge?.commands?.length) { missing.push("game client bridge declarations"); } @@ -1121,7 +1134,8 @@ const manifest = { type: source.server.type, displayName: source.server.displayName, supportedOs: source.server.supportedOS || source.server.supportedOs || [], - createFormSchema: source.server.createFormSchema + createFormSchema: source.server.createFormSchema, + createFields: source.server.createFields ?? [] }, capabilities: localRunCapabilities, permissions: source.permissions,