Add SCUM file management workbench

This commit is contained in:
npc0-hue
2026-08-04 11:33:34 +08:00
parent 2921edb401
commit f028a343d7
36 changed files with 1384 additions and 158 deletions
@@ -14,16 +14,19 @@ The SCUM plugin already declares runtime config mappings and log sources, while
**Non-Goals:** **Non-Goals:**
- Arbitrary filesystem browsing, path entry, terminals, FTP/rsync controls, raw secrets, or host/socket exposure. - Arbitrary filesystem browsing, path entry, terminals, FTP/rsync controls, raw secrets, or host/socket exposure.
- Raw text as the default configuration editor, write access for unknown fields, or a new run-side protocol. - Raw text as the default configuration editor, direct write access for unknown fields, or a new run-side protocol.
- Changing global server-list behavior or non-SCUM plugin pages. - Changing global server-list behavior or non-SCUM plugin pages.
## Decisions ## Decisions
1. Add `fileWorkspace` to the plugin manifest/runtime projection. It contains safe logical directories/files and modeled fields, rather than host paths or unbounded schemas. This makes a page contract auditable and keeps ownership with the plugin. 1. Add `fileWorkspace` to the plugin manifest/runtime projection. It contains safe logical directories/files and modeled fields, rather than host paths or unbounded schemas. This makes a page contract auditable and keeps ownership with the plugin.
2. Give every modeled field an owning logical file key, Chinese operational metadata, and constrained control details. The frontend uses only these fields to compose proposed INI content; unmapped lines remain visible as read-only field records. 2. Give every modeled field an owning logical file key, Chinese operational metadata, and constrained control details. The frontend uses only these fields to compose proposed INI content; unmapped lines remain visible as read-only field records.
3. Reuse `GET /config`, config diff preview, and config approval for the declared primary config file. Log-file selection maps declared logical files to existing Platform log streams; no new direct file-read API is introduced. 3. Reuse declared file read/write dispatch for logical file keys. A completed `files.read` job may be projected through a Platform-owned declared-file snapshot endpoint, which accepts only a declared logical file key, returns no host path or job payload, and redacts secret-like assignment values before returning content. Modeled edits and optional raw config edits require a visible diff preview before the Platform queues a declared `files.write` job.
4. Move SCUM from `operations` to `files-config`; the route resolver redirects old `overview`, `config`, `logs`, and `operations` keys only for `game.scum`. Other plugins keep their declared page keys unchanged. 4. Move SCUM from `operations` to `files-config`; the route resolver redirects old `overview`, `config`, `logs`, and `operations` keys only for `game.scum`. Other plugins keep their declared page keys unchanged.
5. Build page content from existing shared console form/list/diff classes. No page-owned surface system or global decoration is added. 5. Build page content from existing shared console form/list/diff classes. No page-owned surface system or global decoration is added.
6. Treat the SCUM workbench as one selected logical file at a time. The left pane contains only declared directories and files. The right pane presents either modeled fields or an optional raw mode for an editable configuration file, and a read-only log view for a log file. Selection changes must not trigger unbounded polling or path-based requests.
7. Render the plugin bundle in embedded mode when it is mounted under a server-detail section. The generic plugin page frame and host-context diagnostics remain available for direct plugin routes, but must not be nested inside the file-management tab.
8. Keep Companion player, reward, state, vehicle, and trajectory controls out of this page. Those remain available through their separately scoped plugin-control experiences and must not obscure the file workflow.
## Risks / Trade-offs ## Risks / Trade-offs
@@ -31,6 +34,8 @@ The SCUM plugin already declares runtime config mappings and log sources, while
- [Existing installations still link old route keys] → normalize those keys in the common resolver before page lookup. - [Existing installations still link old route keys] → normalize those keys in the common resolver before page lookup.
- [A declared log stream is absent] → show an explicit unavailable state and never fall back to a filesystem path. - [A declared log stream is absent] → show an explicit unavailable state and never fall back to a filesystem path.
- [INI parsing has formatting limits] → patch only declared simple key/value fields and rely on Platform preview before approval. - [INI parsing has formatting limits] → patch only declared simple key/value fields and rely on Platform preview before approval.
- [A raw file has not completed an authorized read] → show an explicit pending or empty state rather than an invented template or stale file content.
- [A declared file contains a secret-like assignment] → redact its value in the snapshot while retaining the surrounding file structure for operational review.
## Migration Plan ## Migration Plan
@@ -6,8 +6,10 @@ SCUM operators currently reach separate overview, configuration, and semantic-lo
- Replace the SCUM overview-first experience with a unified `文件与配置` workbench whose default scope is the plugin-declared configuration directory. - Replace the SCUM overview-first experience with a unified `文件与配置` workbench whose default scope is the plugin-declared configuration directory.
- Add plugin-declared logical file directories/files and a modeled configuration-field catalog, including Chinese labels, help, control metadata, constraints, defaults, restart impact, and owning file. - Add plugin-declared logical file directories/files and a modeled configuration-field catalog, including Chinese labels, help, control metadata, constraints, defaults, restart impact, and owning file.
- Reuse Platform-mediated file requests and the existing configuration diff-preview/approval/write flow so modeled changes are previewed before dispatch. - Reuse Platform-mediated file requests and the existing configuration diff-preview/approval/write flow so modeled changes are previewed before dispatch; expose completed declared-file reads through a bounded, redacted Platform snapshot rather than through host paths or general job payloads.
- Expose logs as declared log files inside the same workbench, with separate safe log-file scope; retain unknown configuration fields as read-only information. - Expose logs as declared log files inside the same workbench, with separate safe log-file scope; retain unknown configuration fields as read-only information.
- Replace the static, stacked SCUM panels with a single selection-based file workbench: directory/file navigation on one side and the selected file's modeled configuration, optional raw config mode, or read-only log content on the other.
- Embed the workbench inside the server detail section without a second plugin-page header or unrelated Companion feature panels.
- Safely migrate legacy SCUM overview/config/log routes to the new workbench. - Safely migrate legacy SCUM overview/config/log routes to the new workbench.
## Capabilities ## Capabilities
@@ -22,4 +24,4 @@ SCUM operators currently reach separate overview, configuration, and semantic-lo
## Impact ## Impact
- Affects the SCUM plugin manifest/declarations, Platform plugin validation and safe file/config DTO handling, and SCUM frontend contracts/routes/components. - Affects the SCUM plugin manifest/declarations, Platform plugin validation and safe file/config DTO handling, and SCUM frontend contracts/routes/components.
- Reuses `files.request`, existing server configuration diff/approval/write APIs, and the SCUM runtime profile without expanding filesystem or remote-access authority. - Reuses `files.request`, existing server configuration diff/approval/write APIs, and the SCUM runtime profile; adds a Platform-owned read-result projection without expanding filesystem or remote-access authority.
@@ -29,6 +29,50 @@ The SCUM workbench SHALL present plugin-declared log files as a log-file scope a
- **WHEN** an operator selects a declared SCUM log file - **WHEN** an operator selects a declared SCUM log file
- **THEN** the page MUST query only its declared Platform log stream and show readable file content without host-path disclosure - **THEN** the page MUST query only its declared Platform log stream and show readable file content without host-path disclosure
### Requirement: Raw file content is a bounded declared-file snapshot
Platform SHALL expose raw configuration or log text only from the latest completed `files.read` result for the same server and a plugin-declared logical file key.
#### Scenario: Declared file result is ready
- **WHEN** an authorized operator requests the raw view of a declared file after its read job completes
- **THEN** Platform MUST return only that file's bounded content, logical key, version, checksum, and read state
- **AND THEN** the response MUST NOT contain a host path, a generic job execution payload, or another file's content
#### Scenario: Declared file is not read yet
- **WHEN** no successful declared-file read is available for the selected file
- **THEN** Platform MUST return an explicit pending or not-read state without inventing file contents
#### Scenario: Raw file contains a secret-like assignment
- **WHEN** a completed declared-file result includes a secret-like `key=value` assignment
- **THEN** Platform MUST redact the assignment value before returning the raw snapshot
### Requirement: SCUM workbench has one active file surface
The SCUM workbench SHALL keep declared directory/file navigation separate from the selected file content, rather than stacking every declared file and every unrelated SCUM feature on one page.
#### Scenario: Operator selects a declared configuration file
- **WHEN** an operator selects `ServerSettings.ini` or another declared configuration file
- **THEN** the workbench MUST show only that file's metadata and supported modes in the content pane
- **AND THEN** modeled fields MUST be limited to fields owned by that selected file
#### Scenario: Operator switches a selected configuration file to raw mode
- **WHEN** an operator activates the raw configuration mode for a selected declared configuration file
- **THEN** the workbench MUST show only the most recent Platform-mediated file result or an explicit not-yet-read state
- **AND THEN** an editable declared configuration file MAY expose a raw editor only after a completed read snapshot is available
- **AND THEN** raw-mode changes MUST require a visible diff preview before dispatching a declared logical `files.write` request
- **AND THEN** it MUST NOT expose a host path, arbitrary file selector, or unrestricted text editor
#### Scenario: Operator selects a declared log file
- **WHEN** an operator selects a declared log file
- **THEN** the workbench MUST replace the configuration controls with that log file's declared read surface
- **AND THEN** the log raw view MUST stay read-only and support switching between UTF-8 and UTF-16 LE display
### Requirement: Embedded SCUM file management avoids duplicate page chrome
When the SCUM workbench is rendered inside a server detail section, the frontend SHALL render it without a second plugin page frame, host-context panel, or unrelated Companion feature panels.
#### Scenario: Server detail opens SCUM file management
- **WHEN** an operator opens the SCUM `文件管理` section in server detail
- **THEN** the first workbench surface MUST be the declared directory/file navigation and selected file content
- **AND THEN** the page MUST NOT render a nested `PLUGIN PAGE` title or `平台托管上下文` panel
### Requirement: Legacy SCUM pages migrate safely ### Requirement: Legacy SCUM pages migrate safely
The frontend SHALL migrate legacy SCUM overview, config, logs, and operations page keys to the `files-config` page while leaving non-SCUM routing unchanged. The frontend SHALL migrate legacy SCUM overview, config, logs, and operations page keys to the `files-config` page while leaving non-SCUM routing unchanged.
@@ -3,14 +3,16 @@
- [x] 1.1 Add safe logical file-directory/file and modeled-field contracts to plugin manifests, Platform domain/DTO projections, copying, and validation. - [x] 1.1 Add safe logical file-directory/file and modeled-field contracts to plugin manifests, Platform domain/DTO projections, copying, and validation.
- [x] 1.2 Declare SCUM configuration/log files and bounded Chinese configuration field metadata, then remove legacy SCUM overview page declarations. - [x] 1.2 Declare SCUM configuration/log files and bounded Chinese configuration field metadata, then remove legacy SCUM overview page declarations.
- [x] 1.3 Add focused Platform validation/projection tests for safe declaration and field ownership behavior. - [x] 1.3 Add focused Platform validation/projection tests for safe declaration and field ownership behavior.
- [x] 1.4 Add a bounded redacted Platform projection for completed declared-file read results.
## 2. SCUM file and configuration workbench ## 2. SCUM file and configuration workbench
- [x] 2.1 Add frontend declaration contracts and resolver for the SCUM `files-config` default workbench and legacy route migration. - [x] 2.1 Add frontend declaration contracts and resolver for the SCUM `files-config` default workbench and legacy route migration.
- [x] 2.2 Build the shared-theme file list, modeled configuration form, read-only unknown-field list, preview diff, and approval experience. - [x] 2.2 Build the shared-theme selection-based file list, selected-file modeled configuration form, raw config mode, unknown-field list, preview diff, and declared write dispatch experience.
- [x] 2.3 Add declared log-file scope and Platform log stream content reading without path exposure. - [x] 2.3 Add declared log-file scope and Platform log stream content reading without path exposure.
- [x] 2.4 Render the SCUM workbench embedded in server detail and keep unrelated Companion feature panels out of the file-management tab.
## 3. Verification ## 3. Verification
- [x] 3.1 Add/update focused backend, plugin, route, contract, and component tests. - [x] 3.1 Add/update focused backend, plugin, route, contract, and component tests for selection state, raw mode, and embedded rendering.
- [x] 3.2 Run strict OpenSpec validation, relevant backend/plugin/frontend checks, and scripts/check-structure.sh. - [x] 3.2 Run strict OpenSpec validation, relevant backend/plugin/frontend checks, browser verification, and scripts/check-structure.sh.
+28
View File
@@ -110,6 +110,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs) mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents) mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill) mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverDeclaredFileReadSnapshot)
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff) mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove) mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig) mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig)
@@ -1430,6 +1431,33 @@ func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config)) writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config))
} }
// serverDeclaredFileReadSnapshot godoc
// @Summary Read the latest declared file snapshot
// @Description Returns a redacted bounded result only for an authorized plugin-declared logical file key.
// @Tags server-instances
// @Produce json
// @Param id path string true "Server instance ID"
// @Param key query string true "Plugin-declared logical file key"
// @Success 200 {object} dto.DeclaredFileReadSnapshotResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/files/read-snapshot [get]
func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
snapshot, err := h.core.GetDeclaredFileReadSnapshotForSession(bearerToken(r), r.PathValue("id"), r.URL.Query().Get("key"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot))
}
// serverInstanceConfigDiff godoc // serverInstanceConfigDiff godoc
// @Summary Preview server config diff // @Summary Preview server config diff
// @Description Compares current logical server config with proposed content without dispatching a write job. // @Description Compares current logical server config with proposed content without dispatching a write job.
+99
View File
@@ -301,6 +301,105 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
} }
} }
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
ID: "user-owner-file-snapshot-api",
DisplayName: "File Snapshot API Owner",
Email: "owner-file-snapshot-api@example.test",
Roles: []string{"server-owner"},
Password: "secret-password",
}, adminSession)
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
ID: "user-other-file-snapshot-api",
DisplayName: "File Snapshot API Other",
Email: "other-file-snapshot-api@example.test",
Roles: []string{"server-admin"},
Password: "secret-password",
}, adminSession)
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
pluginRequest := validGamePluginRequest()
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesRead)
pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"}
pluginRequest.Permissions.Files = true
pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{
DefaultDirectoryKey: "scum-config",
Directories: []dto.PluginLogicalDirectoryBody{
{Key: "scum-config", Label: "服务器配置", Scope: "config"},
{Key: "scum-logs", Label: "日志文件", Scope: "logs"},
},
Files: []dto.PluginLogicalFileBody{
{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true},
{Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"},
},
ConfigFields: []dto.PluginConfigFieldBody{
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
},
}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-file-snapshot-api",
PluginID: "server.scum",
RunEndpointID: "run-local",
Name: "File Snapshot API Server",
State: domain.ServerInstanceStateRunning,
}, ownerSession)
snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
if snapshot.State != "not-read" || snapshot.Content != "" {
t.Fatalf("expected not-read snapshot, got %+v", snapshot)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=logs/latest.log", "", ownerSession), http.StatusBadRequest, errorCodeValidation)
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", otherSession), http.StatusForbidden, errorCodeForbidden)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
ID: "job-file-snapshot-api-read",
ServerInstanceID: instance.ID,
RunEndpointID: "run-local",
Capability: domain.JobCapabilityFilesRead,
TargetKey: "scum-server-settings",
IdempotencyKey: "idem-file-snapshot-api-read",
})
helloRequest := validRunControlHelloRequest()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesRead)
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capabilities: []string{domain.JobCapabilityFilesRead},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}))
if !claim.HasJob || claim.Job.JobID != "job-file-snapshot-api-read" {
t.Fatalf("expected file read job claim, got %+v", claim)
}
content := "ServerName=API\nRconPassword=secret\n"
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateSucceeded,
Progress: dto.JobProgressBody{Percent: 100, Message: "file read completed"},
Message: "file read completed",
ExecutionResult: dto.RunJobExecutionResultBody{
Kind: "file.read",
Version: 9,
SizeBytes: int64(len(content)),
Content: content,
},
})
assertStatus(t, resultRecorder, http.StatusOK)
ready := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
if ready.State != "ready" || ready.Version != 9 || !strings.Contains(ready.Content, "ServerName=API") || !strings.Contains(ready.Content, "RconPassword=<redacted>") || strings.Contains(ready.Content, "secret") {
t.Fatalf("expected ready redacted snapshot, got %+v", ready)
}
}
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
releaseBuilds := make(chan struct{}) releaseBuilds := make(chan struct{})
t.Cleanup(func() { close(releaseBuilds) }) t.Cleanup(func() { close(releaseBuilds) })
+16
View File
@@ -1030,6 +1030,22 @@ type FileOperationDispatchResult struct {
Status string Status string
} }
// DeclaredFileReadSnapshot is the redacted, bounded projection of a completed
// files.read job for one plugin-declared logical file.
type DeclaredFileReadSnapshot struct {
ServerInstanceID string
PluginID string
Key string
State string
Content string
Version int
Checksum string
SizeBytes int64
JobID string
ReadAt time.Time
Reason string
}
type RunCapacity struct { type RunCapacity struct {
MaxJobs int MaxJobs int
RunningJobs int RunningJobs int
+9 -6
View File
@@ -114,10 +114,13 @@ Installed `GamePlugin` records persist the validated manifest `runtimeProfiles`
Plugin lifecycle assets are registered as manifest-declared files plus a Plugin lifecycle assets are registered as manifest-declared files plus a
content-bearing registration payload. Platform packages those assets into content-bearing registration payload. Platform packages those assets into
generated Run workspaces so plugin action refs such as `actions/install.json` generated Run workspaces so plugin action refs such as `actions/install.json`
and script refs such as `bin/scum-start.cmd` are available before the first and helper refs such as `bin/scum-install-update.cmd` are available before the
install/start job. Game-specific install/update/start policy, including SCUM first bootstrap job. Guided deployments whose selected lifecycle profile
SteamCMD app IDs and launch flags, stays in the plugin asset bundle rather than supports `process.start` may bootstrap through the plugin-owned start action so
in platform services or Run executors. the script can install-if-missing and then launch the supervised process whose
stdout/stderr feed the live terminal. Game-specific install/update/start policy,
including SCUM SteamCMD app IDs and launch flags, stays in the plugin asset
bundle rather than in platform services or Run executors.
Bindings are used for action gating and future run-side profile resolution. File and MySQL metadata snapshots include them so a platform restart does not make a configured server appear complete or lose its selected profile. API responses expose only logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never expose stored binding values, raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, component auth keys, or internal secret locations. Bindings are used for action gating and future run-side profile resolution. File and MySQL metadata snapshots include them so a platform restart does not make a configured server appear complete or lose its selected profile. API responses expose only logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never expose stored binding values, raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, component auth keys, or internal secret locations.
@@ -161,8 +164,8 @@ Dependency checks and installs are queued as run jobs with logical `dependencies
Lifecycle workflow jobs use fixed capabilities: Lifecycle workflow jobs use fixed capabilities:
- `process.install`: dispatched by server create workflow and projects successful terminal results to `ready`. - `process.install`: dispatched by install bootstrap workflows and projects successful terminal results to `ready`.
- `process.start`: dispatched by server start workflow and projects successful terminal results to `running`. - `process.start`: dispatched by server start workflow or guided supervised bootstrap and projects successful terminal results to `running`.
- `process.stop`: dispatched by server stop workflow and projects successful terminal results to `stopped`. - `process.stop`: dispatched by server stop workflow and projects successful terminal results to `stopped`.
- `run.self-update`: dispatched by runtime distribution APIs with an approved artifact ref and checksum. - `run.self-update`: dispatched by runtime distribution APIs with an approved artifact ref and checksum.
- `dependencies.check`: dispatched by dependency check APIs for a declared probe key. - `dependencies.check`: dispatched by dependency check APIs for a declared probe key.
+30
View File
@@ -703,6 +703,20 @@ type FileOperationDispatchResponse struct {
Job JobResponse `json:"job"` Job JobResponse `json:"job"`
} }
type DeclaredFileReadSnapshotResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
Key string `json:"key"`
State string `json:"state"`
Content string `json:"content,omitempty"`
Version int `json:"version,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
JobID string `json:"jobId,omitempty"`
ReadAt time.Time `json:"readAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
type RunCapacityResponse struct { type RunCapacityResponse struct {
MaxJobs int `json:"maxJobs"` MaxJobs int `json:"maxJobs"`
RunningJobs int `json:"runningJobs"` RunningJobs int `json:"runningJobs"`
@@ -1756,6 +1770,22 @@ func FileOperationDispatchFromDomain(result domain.FileOperationDispatchResult)
} }
} }
func DeclaredFileReadSnapshotFromDomain(snapshot domain.DeclaredFileReadSnapshot) DeclaredFileReadSnapshotResponse {
return DeclaredFileReadSnapshotResponse{
ServerInstanceID: snapshot.ServerInstanceID,
PluginID: snapshot.PluginID,
Key: snapshot.Key,
State: snapshot.State,
Content: snapshot.Content,
Version: snapshot.Version,
Checksum: snapshot.Checksum,
SizeBytes: snapshot.SizeBytes,
JobID: snapshot.JobID,
ReadAt: snapshot.ReadAt,
Reason: snapshot.Reason,
}
}
func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse { func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse {
endpoint = domain.CopyRunEndpoint(endpoint) endpoint = domain.CopyRunEndpoint(endpoint)
return RunEndpointResponse{ return RunEndpointResponse{
+6 -5
View File
@@ -6,11 +6,12 @@ when a server uses `custom-command` mode and Run executes the operator-reviewed
argv-oriented command inside its local policy. argv-oriented command inside its local policy.
Guided or existing-server game setup is plugin-owned. Platform dispatches the Guided or existing-server game setup is plugin-owned. Platform dispatches the
plugin-declared lifecycle action reference, such as `actions/install.json`, and plugin-declared lifecycle action reference, such as `actions/install.json` or a
may include generic deployment context (`mode`, `profileKey`, `serverRoot`, and supervised `actions/start.json` bootstrap, and may include generic deployment
`createInputs`) so the plugin action can resolve its own behavior. Platform does context (`mode`, `profileKey`, `serverRoot`, and `createInputs`) so the plugin
not create a game-specific `serverDeploymentPlan`, and SCUM no longer requires a action can resolve its own behavior. Platform does not create a game-specific
`deployment.scum.v1` capability. `serverDeploymentPlan`, and SCUM no longer requires a `deployment.scum.v1`
capability.
## Capability and policy ## Capability and policy
+5 -5
View File
@@ -19,9 +19,9 @@ A server instance is created from one installed game management plugin and bound
### States ### States
- `draft`: instance record exists but install job has not completed. - `draft`: instance record exists but the first bootstrap job has not completed.
- `installing`: run install job is active. - `installing`: the first plugin-owned bootstrap job is active.
- `ready`: install succeeded and the server can start. - `ready`: install/bootstrap succeeded without starting a supervised process, and the server can start.
- `running`: server process is running. - `running`: server process is running.
- `stopped`: server process is stopped. - `stopped`: server process is stopped.
- `failed`: last lifecycle operation failed. - `failed`: last lifecycle operation failed.
@@ -36,7 +36,7 @@ A server instance is created from one installed game management plugin and bound
## Lifecycle Actions ## Lifecycle Actions
- `create`: validate plugin, create instance record, dispatch install job. - `create`: validate plugin, create instance record, dispatch the plugin-owned bootstrap job.
- `start`: dispatch process start job through the bound run endpoint. - `start`: dispatch process start job through the bound run endpoint.
- `stop`: dispatch process stop job through the bound run endpoint. - `stop`: dispatch process stop job through the bound run endpoint.
- `restart`: dispatch stop/start or plugin-defined restart job. - `restart`: dispatch stop/start or plugin-defined restart job.
@@ -48,7 +48,7 @@ A server instance is created from one installed game management plugin and bound
- `GET /api/v1/plugin-marketplace/plugins` lists plugin marketplace summaries from registry metadata with status, server type, capability, and keyword filters. - `GET /api/v1/plugin-marketplace/plugins` lists plugin marketplace summaries from registry metadata with status, server type, capability, and keyword filters.
- `GET /api/v1/plugin-marketplace/plugins/{id}` returns one registry-backed marketplace detail. - `GET /api/v1/plugin-marketplace/plugins/{id}` returns one registry-backed marketplace detail.
- `POST /api/v1/plugin-marketplace/plugins/{id}/state` applies metadata-only `install`, `enable`, or `disable` state changes. - `POST /api/v1/plugin-marketplace/plugins/{id}/state` applies metadata-only `install`, `enable`, or `disable` state changes.
- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, a compatible run endpoint, a non-empty idempotency key, and required lifecycle action references. It creates the instance in `installing` state and queues a `process.install` job. - `POST /api/v1/server-instances/workflows/create` validates an installed plugin, a compatible run endpoint, a non-empty idempotency key, and required lifecycle action references. It creates the instance in `installing` state and queues either `process.install` or, for guided deployments whose selected lifecycle profile supports supervised start, `process.start` so the plugin start script can install-if-missing and stream process logs.
- `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job. - `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job.
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job. - `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
- `GET /api/v1/server-instances/{id}/config` returns logical read-only config content for an authorized server instance with config version, format, key, source, and update timestamp metadata. - `GET /api/v1/server-instances/{id}/config` returns logical read-only config content for an authorized server instance with config version, format, key, source, and update timestamp metadata.
+2 -1
View File
@@ -130,7 +130,8 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
// queueManagedGuidedDeploymentAfterRegistration advances only a newly-created, // queueManagedGuidedDeploymentAfterRegistration advances only a newly-created,
// dedicated guided server. Selecting guided-install is the owner's prior // dedicated guided server. Selecting guided-install is the owner's prior
// authorization for this bounded write; reconnects remain idempotent. // authorization for the plugin-declared bootstrap action; reconnects remain
// idempotent.
func (svc *CoreService) queueManagedGuidedDeploymentAfterRegistration(hello domain.RunControlHello) error { func (svc *CoreService) queueManagedGuidedDeploymentAfterRegistration(hello domain.RunControlHello) error {
if hello.ComponentKind != domain.DistributionComponentRun || strings.TrimSpace(hello.ServerInstanceID) == "" { if hello.ComponentKind != domain.DistributionComponentRun || strings.TrimSpace(hello.ServerInstanceID) == "" {
return nil return nil
+15 -15
View File
@@ -291,16 +291,16 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID) registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
stored, err := svc.GetServerInstance(guided.Instance.ID) stored, err := svc.GetServerInstance(guided.Instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateInstalling { if err != nil || stored.State != domain.ServerInstanceStateInstalling {
t.Fatalf("guided registration should queue install, server=%+v err=%v", stored, err) t.Fatalf("guided registration should queue bootstrap start, server=%+v err=%v", stored, err)
} }
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID}) jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall { if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].TargetKey != "actions/start.json" || len(jobs[0].ExecutionInput.LogSources) != 2 {
t.Fatalf("expected one automatic install job, jobs=%+v err=%v", jobs, err) t.Fatalf("expected one automatic supervised start job, jobs=%+v err=%v", jobs, err)
} }
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID) registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID}) jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
if len(jobs) != 1 { if len(jobs) != 1 {
t.Fatalf("Run reconnect must not duplicate automatic install, jobs=%+v", jobs) t.Fatalf("Run reconnect must not duplicate automatic bootstrap, jobs=%+v", jobs)
} }
generatedRunDraft := domain.ServerInstance{ generatedRunDraft := domain.ServerInstance{
@@ -319,11 +319,11 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
registerDedicatedRunForTest(t, svc, generatedRunDraft, plugin.ID) registerDedicatedRunForTest(t, svc, generatedRunDraft, plugin.ID)
storedGenerated, err := svc.GetServerInstance(generatedRunDraft.ID) storedGenerated, err := svc.GetServerInstance(generatedRunDraft.ID)
if err != nil || storedGenerated.State != domain.ServerInstanceStateInstalling { if err != nil || storedGenerated.State != domain.ServerInstanceStateInstalling {
t.Fatalf("generated Run registration should queue install without deployment target, server=%+v err=%v", storedGenerated, err) t.Fatalf("generated Run registration should queue supervised start without deployment target, server=%+v err=%v", storedGenerated, err)
} }
jobs, err = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: generatedRunDraft.ID}) jobs, err = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: generatedRunDraft.ID})
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall || jobs[0].ExecutionInput.WorkspaceScope != "local" { if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].ExecutionInput.WorkspaceScope != "local" {
t.Fatalf("expected one scoped automatic generated Run install job, jobs=%+v err=%v", jobs, err) t.Fatalf("expected one scoped automatic generated Run start job, jobs=%+v err=%v", jobs, err)
} }
existing, err := svc.CreateServerInstanceWorkflowForSession(owner, domain.ServerLifecycleCreate{ID: "managed-existing", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Managed Existing", IdempotencyKey: "managed-existing-create", ProfileKey: "local", Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: "C:\\existing-scum"}}) existing, err := svc.CreateServerInstanceWorkflowForSession(owner, domain.ServerLifecycleCreate{ID: "managed-existing", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Managed Existing", IdempotencyKey: "managed-existing-create", ProfileKey: "local", Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: "C:\\existing-scum"}})
@@ -337,7 +337,7 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
} }
} }
func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T) { func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedStart(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
plugin := scumDeploymentTestPlugin() plugin := scumDeploymentTestPlugin()
plugin.Name = "SCUM" plugin.Name = "SCUM"
@@ -424,22 +424,22 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
stored, err := svc.GetServerInstance(instance.ID) stored, err := svc.GetServerInstance(instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateInstalling { if err != nil || stored.State != domain.ServerInstanceStateInstalling {
t.Fatalf("generated SCUM registration should queue install, server=%+v err=%v", stored, err) t.Fatalf("generated SCUM registration should queue supervised bootstrap start, server=%+v err=%v", stored, err)
} }
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 1 { if err != nil || len(jobs) != 1 {
t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err) t.Fatalf("expected one SCUM start job, jobs=%+v err=%v", jobs, err)
} }
job := jobs[0] job := jobs[0]
if job.Capability != domain.LifecycleCapabilityInstall || job.TargetKey != "actions/install.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil { if job.Capability != domain.LifecycleCapabilityStart || job.TargetKey != "actions/start.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil || len(job.ExecutionInput.LogSources) == 0 {
t.Fatalf("expected SCUM install job with scoped plugin action and generic deployment inputs, job=%+v", job) t.Fatalf("expected SCUM supervised start job with scoped plugin action and generic deployment inputs, job=%+v", job)
} }
if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" { if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("SCUM install job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs) t.Fatalf("SCUM start job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs)
} }
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}}) claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.WorkspaceScope != "run-local" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil { if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/start.json" || claim.Job.ExecutionInput.WorkspaceScope != "run-local" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("generated SCUM Run should claim scoped plugin-owned install action, claim=%+v err=%v", claim, err) t.Fatalf("generated SCUM Run should claim scoped plugin-owned start action, claim=%+v err=%v", claim, err)
} }
} }
+137
View File
@@ -123,6 +123,7 @@ type Core interface {
ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error) ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error)
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error) RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
GetServerConfigForSession(string, string) (domain.ServerConfig, error) GetServerConfigForSession(string, string) (domain.ServerConfig, error)
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error) ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error) DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
@@ -1883,6 +1884,120 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan
return domain.CopyServerConfig(config), nil return domain.CopyServerConfig(config), nil
} }
func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.DeclaredFileReadSnapshot{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.DeclaredFileReadSnapshot{}, err
}
if plugin.Status != domain.GamePluginStatusInstalled || (!plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read")) {
return domain.DeclaredFileReadSnapshot{}, ErrForbidden
}
file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, domain.FileOperationDispatchRequest{Operation: domain.FileOperationRead, Key: fileKey})
if !constrained || !allowed || file.Key == "" {
return domain.DeclaredFileReadSnapshot{}, validationError("file key must reference a plugin-declared file")
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.DeclaredFileReadSnapshot{}, err
}
var completed *domain.Job
var pending *domain.Job
for i := range jobs {
job := jobs[i]
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != file.Key {
continue
}
if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
if completed == nil || newerJob(job, *completed) {
copy := job
completed = &copy
}
continue
}
if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) {
copy := job
pending = &copy
}
}
base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: file.Key}
if completed != nil {
return domain.DeclaredFileReadSnapshot{
ServerInstanceID: base.ServerInstanceID,
PluginID: base.PluginID,
Key: base.Key,
State: "ready",
Content: redactDeclaredFileReadContent(completed.ExecutionResult.Content),
Version: completed.ExecutionResult.Version,
Checksum: completed.ExecutionResult.Checksum,
SizeBytes: completed.ExecutionResult.SizeBytes,
JobID: completed.ID,
ReadAt: jobCompletedAt(*completed),
}, nil
}
if pending != nil {
base.State = "pending"
base.JobID = pending.ID
base.Reason = "等待运行端完成文件读取。"
return base, nil
}
base.State = "not-read"
base.Reason = "尚未读取此声明文件。"
return base, nil
}
func declaredFileReadPendingState(state domain.JobState) bool {
switch state {
case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateRetrying:
return true
default:
return false
}
}
func newerJob(left domain.Job, right domain.Job) bool {
leftTime, rightTime := jobCompletedAt(left), jobCompletedAt(right)
if !leftTime.Equal(rightTime) {
return leftTime.After(rightTime)
}
return left.ID > right.ID
}
func jobCompletedAt(job domain.Job) time.Time {
if !job.TerminalAt.IsZero() {
return job.TerminalAt
}
if !job.UpdatedAt.IsZero() {
return job.UpdatedAt
}
return job.CreatedAt
}
func redactDeclaredFileReadContent(content string) string {
lines := strings.Split(content, "\n")
for index, line := range lines {
key, _, found := strings.Cut(line, "=")
if !found || !secretLikeFileAssignmentKey(key) {
continue
}
lines[index] = key + "=<redacted>"
}
return strings.Join(lines, "\n")
}
func secretLikeFileAssignmentKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(key), "_", ""), "-", ""))
for _, marker := range []string{"password", "passwd", "secret", "token", "apikey", "accesskey", "privatekey", "rcon"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) { func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) {
if request.Key == "" { if request.Key == "" {
request.Key = "server.properties" request.Key = "server.properties"
@@ -2022,6 +2137,12 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") { if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") {
return domain.FileOperationDispatchResult{}, ErrForbidden return domain.FileOperationDispatchResult{}, ErrForbidden
} }
if file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, request); constrained && !allowed {
if file.Key == "" {
return domain.FileOperationDispatchResult{}, validationError("file key must reference a plugin-declared file")
}
return domain.FileOperationDispatchResult{}, validationError("file key is not writable by plugin declaration")
}
} }
capability := domain.JobCapabilityFilesRead capability := domain.JobCapabilityFilesRead
message := "file read queued" message := "file read queued"
@@ -2054,6 +2175,22 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
}), nil }), nil
} }
func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) {
if len(workspace.Files) == 0 {
return domain.PluginLogicalFile{}, false, true
}
for _, file := range workspace.Files {
if file.Key != request.Key {
continue
}
if request.Operation == domain.FileOperationWrite && (file.Kind != "config" || !file.Editable) {
return file, true, false
}
return file, true, true
}
return domain.PluginLogicalFile{}, true, false
}
func (svc *CoreService) runtimeProfileScope(serverInstanceID string) string { func (svc *CoreService) runtimeProfileScope(serverInstanceID string) string {
binding, err := svc.runtimeBindingForServer(serverInstanceID) binding, err := svc.runtimeBindingForServer(serverInstanceID)
if err != nil { if err != nil {
+159
View File
@@ -839,6 +839,165 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) {
} }
} }
func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.FileWorkspace = scumTestFileWorkspace()
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin workspace: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
ID: "user-owner-file-workspace",
DisplayName: "File Workspace Owner",
Email: "file-workspace-owner@example.test",
Roles: []string{"server-owner"},
PasswordHash: "secret-password",
})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
ID: "server-file-workspace",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "File Workspace Server",
State: domain.ServerInstanceStateRunning,
})
if err != nil {
t.Fatalf("create server: %v", err)
}
createCompleteRuntimeBinding(t, svc, instance, "local")
allowed, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
Operation: domain.FileOperationRead,
Key: "scum-server-settings",
IdempotencyKey: "idem-file-workspace-read",
})
if err != nil {
t.Fatalf("dispatch declared file read: %v", err)
}
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead {
t.Fatalf("unexpected declared file dispatch: %+v", allowed)
}
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
Operation: domain.FileOperationRead,
Key: "logs/latest.log",
IdempotencyKey: "idem-file-workspace-unknown",
}); err == nil || !strings.Contains(err.Error(), "plugin-declared file") {
t.Fatalf("expected undeclared file key rejection, got %v", err)
}
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
Operation: domain.FileOperationWrite,
Key: "scum-chat-log",
InputRef: "input://file-workspace/update",
Content: "line",
IdempotencyKey: "idem-file-workspace-log-write",
}); err == nil || !strings.Contains(err.Error(), "not writable") {
t.Fatalf("expected log write rejection, got %v", err)
}
}
func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.FileWorkspace = scumTestFileWorkspace()
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin workspace: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-owner", DisplayName: "File Snapshot Owner", Email: "file-snapshot-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-other", DisplayName: "File Snapshot Other", Email: "file-snapshot-other@example.test", Roles: []string{"server-admin"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-snapshot", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Snapshot Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
snapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.State != "not-read" {
t.Fatalf("expected not-read without jobs, snapshot=%+v err=%v", snapshot, err)
}
queued := createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-queued", domain.JobStateQueued, 1, "")
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.State != "pending" || snapshot.JobID != queued.ID {
t.Fatalf("expected pending queued job, snapshot=%+v err=%v", snapshot, err)
}
queued.State = domain.JobStateFailed
queued.UpdatedAt = fixedTime.Add(2 * time.Minute)
queued.TerminalAt = fixedTime.Add(2 * time.Minute)
if err := svc.store.Jobs().Update(queued); err != nil {
t.Fatalf("update failed read job: %v", err)
}
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-cancelled", domain.JobStateCancelled, 3, "")
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.State != "not-read" {
t.Fatalf("failed/cancelled reads must not mask not-read, snapshot=%+v err=%v", snapshot, err)
}
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-old", domain.JobStateSucceeded, 4, "ServerName=Old\nRconPassword=secret\n")
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-failed-newer", domain.JobStateFailed, 5, "")
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.State != "ready" || snapshot.JobID != "job-file-snapshot-success-old" || !strings.Contains(snapshot.Content, "RconPassword=<redacted>") {
t.Fatalf("expected older successful redacted result, snapshot=%+v err=%v", snapshot, err)
}
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-new", domain.JobStateSucceeded, 6, "ServerName=New\nApiToken=secret\n")
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || !strings.Contains(snapshot.Content, "ServerName=New") || strings.Contains(snapshot.Content, "secret") {
t.Fatalf("expected newest successful redacted result, snapshot=%+v err=%v", snapshot, err)
}
if _, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log"); err == nil || !strings.Contains(err.Error(), "plugin-declared file") {
t.Fatalf("expected unknown logical key rejection, got %v", err)
}
if _, err := svc.GetDeclaredFileReadSnapshotForSession(otherSession, instance.ID, "scum-server-settings"); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected unrelated session forbidden, got %v", err)
}
}
func scumTestFileWorkspace() domain.PluginFileWorkspace {
return domain.PluginFileWorkspace{
DefaultDirectoryKey: "scum-config",
Directories: []domain.PluginLogicalDirectory{
{Key: "scum-config", Label: "服务器配置", Scope: "config"},
{Key: "scum-logs", Label: "日志文件", Scope: "logs"},
},
Files: []domain.PluginLogicalFile{
{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true},
{Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"},
},
ConfigFields: []domain.PluginConfigField{
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
},
}
}
func createDeclaredFileReadJob(t *testing.T, svc *CoreService, instance domain.ServerInstance, endpoint domain.RunEndpoint, id string, state domain.JobState, minuteOffset int, content string) domain.Job {
t.Helper()
job, err := svc.CreateJob(domain.Job{
ID: id,
ServerInstanceID: instance.ID,
RunEndpointID: endpoint.ID,
Capability: domain.JobCapabilityFilesRead,
TargetKey: "scum-server-settings",
IdempotencyKey: id,
})
if err != nil {
t.Fatalf("create declared file read job: %v", err)
}
stamp := fixedTime.Add(time.Duration(minuteOffset) * time.Minute)
job.State = state
job.UpdatedAt = stamp
if state == domain.JobStateSucceeded || state == domain.JobStateFailed || state == domain.JobStateCancelled {
job.TerminalAt = stamp
}
if state == domain.JobStateSucceeded {
job.ExecutionResult = domain.JobExecutionResult{Kind: "file.read", Version: minuteOffset, Checksum: validator.BytesChecksum([]byte(content)), SizeBytes: int64(len(content)), Content: content}
}
if err := svc.store.Jobs().Update(job); err != nil {
t.Fatalf("update declared file read job: %v", err)
}
return job
}
func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) { func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin, endpoint := createPluginAndRunEndpoint(t, svc)
+8 -7
View File
@@ -85,8 +85,8 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
} }
// deployServerInstance is the platform-owned transition from a saved deployment // deployServerInstance is the platform-owned transition from a saved deployment
// definition to one fenced install job. Callers must already have established // definition to one fenced plugin-owned bootstrap job. Callers must already
// the authority to act for the server. // have established the authority to act for the server.
func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := validator.ValidateServerLifecycleCommand(command); err != nil { if err := validator.ValidateServerLifecycleCommand(command); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
@@ -118,16 +118,17 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment) instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment)
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil { bootstrapAction := deploymentBootstrapLifecycleAction(plugin, instance)
if err := validateLifecycleActionRef(plugin, bootstrapAction); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil { if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityInstall); err != nil { if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityInstall); err != nil { if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if strings.TrimSpace(instance.Deployment.ProfileKey) != "" && deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) { if strings.TrimSpace(instance.Deployment.ProfileKey) != "" && deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
@@ -153,7 +154,7 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
if err := svc.store.ServerInstances().Update(instance); err != nil { if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, command.IdempotencyKey) job, err := svc.dispatchLifecycleJob(instance, bootstrapAction, command.IdempotencyKey)
if err != nil { if err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
+13 -13
View File
@@ -99,7 +99,7 @@ func TestCoreServiceUpdatesDeploymentWhileServerIsActive(t *testing.T) {
} }
} }
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) { func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedSupervisedStartAction(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
runHello := validRunControlHello() runHello := validRunControlHello()
runHello.RunEndpointID = "run-scum-guided" runHello.RunEndpointID = "run-scum-guided"
@@ -132,15 +132,15 @@ func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testin
if err != nil { if err != nil {
t.Fatalf("SCUM guided deployment should not require unrelated manifest capabilities: %v", err) t.Fatalf("SCUM guided deployment should not require unrelated manifest capabilities: %v", err)
} }
if created.Job.TargetKey != "actions/install.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil { if created.Job.Capability != domain.LifecycleCapabilityStart || created.Job.TargetKey != "actions/start.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil || len(created.Job.ExecutionInput.LogSources) == 0 {
t.Fatalf("expected plugin-owned install action without SCUM deployment plan, job=%+v", created.Job) t.Fatalf("expected plugin-owned supervised start action without SCUM deployment plan, job=%+v", created.Job)
} }
if created.Job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || created.Job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" { if created.Job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || created.Job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("SCUM install job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs) t.Fatalf("SCUM start job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs)
} }
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil { if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/start.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("claimed SCUM install must use plugin action without SCUM plan, claim=%+v err=%v", claim, err) t.Fatalf("claimed SCUM start must use plugin action without SCUM plan, claim=%+v err=%v", claim, err)
} }
} }
@@ -163,11 +163,11 @@ func TestCoreServiceDeploymentLifecycleFailureDoesNotRequireExecutionReceipt(t *
if err != nil { if err != nil {
t.Fatalf("create deployment lifecycle server: %v", err) t.Fatalf("create deployment lifecycle server: %v", err)
} }
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil { if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil {
t.Fatalf("claim deployment lifecycle job: claim=%+v err=%v", claim, err) t.Fatalf("claim deployment lifecycle job: claim=%+v err=%v", claim, err)
} }
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "installing"}); err != nil { if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "starting"}); err != nil {
t.Fatalf("ack deployment lifecycle job: %v", err) t.Fatalf("ack deployment lifecycle job: %v", err)
} }
result, err := svc.CompleteRunJob(domain.RunJobResult{ result, err := svc.CompleteRunJob(domain.RunJobResult{
@@ -215,11 +215,11 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
if err != nil { if err != nil {
t.Fatalf("create guided lifecycle server: %v", err) t.Fatalf("create guided lifecycle server: %v", err)
} }
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil { if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil {
t.Fatalf("claim guided lifecycle job: claim=%+v err=%v", claim, err) t.Fatalf("claim guided lifecycle job: claim=%+v err=%v", claim, err)
} }
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "installing"}); err != nil { if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "starting"}); err != nil {
t.Fatalf("ack guided lifecycle job: %v", err) t.Fatalf("ack guided lifecycle job: %v", err)
} }
if _, err := svc.CompleteRunJob(domain.RunJobResult{ if _, err := svc.CompleteRunJob(domain.RunJobResult{
@@ -232,8 +232,8 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
if err != nil { if err != nil {
t.Fatalf("get ready guided instance: %v", err) t.Fatalf("get ready guided instance: %v", err)
} }
if instance.State != domain.ServerInstanceStateReady { if instance.State != domain.ServerInstanceStateRunning {
t.Fatalf("expected guided plugin lifecycle success to mark ready, got %+v", instance) t.Fatalf("expected guided plugin lifecycle success to mark running, got %+v", instance)
} }
} }
+22 -4
View File
@@ -109,13 +109,17 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
if err != nil { if err != nil {
return domain.ServerLifecycleResult{}, fmt.Errorf("get run endpoint dependency: %w", err) return domain.ServerLifecycleResult{}, fmt.Errorf("get run endpoint dependency: %w", err)
} }
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil { bootstrapAction := deploymentBootstrapLifecycleAction(plugin, instance)
if err := validateLifecycleActionRef(plugin, bootstrapAction); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
var binding domain.RuntimeBinding var binding domain.RuntimeBinding
@@ -135,7 +139,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
} }
} }
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey) job, err := svc.dispatchLifecycleJob(instance, bootstrapAction, create.IdempotencyKey)
if err != nil { if err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
@@ -358,6 +362,20 @@ func lifecycleProcessLogSources(profiles domain.GamePluginRuntimeProfiles) []dom
return sources return sources
} }
func deploymentBootstrapLifecycleAction(plugin domain.GamePlugin, instance domain.ServerInstance) domain.ServerLifecycleAction {
if instance.Deployment.Mode != domain.ServerDeploymentModeGuided {
return domain.ServerLifecycleActionCreate
}
profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, instance.Deployment.ProfileKey)
if !exists || !containsString(profile.Capabilities, domain.LifecycleCapabilityStart) {
return domain.ServerLifecycleActionCreate
}
if strings.TrimSpace(runtimeProfileActionRef(profile.ActionRefs, domain.ServerLifecycleActionStart)) == "" && strings.TrimSpace(plugin.LifecycleActions.Start) == "" {
return domain.ServerLifecycleActionCreate
}
return domain.ServerLifecycleActionStart
}
func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress { func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress {
if deployment.Mode != "" { if deployment.Mode != "" {
return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"} return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"}
+6
View File
@@ -36,6 +36,7 @@ import type {
CurrentUserResponse, CurrentUserResponse,
DependencyCatalogResponse, DependencyCatalogResponse,
DependencyJobRequest, DependencyJobRequest,
DeclaredFileReadSnapshotResponse,
FileOperationDispatchRequest, FileOperationDispatchRequest,
FileOperationDispatchResponse, FileOperationDispatchResponse,
GameClientBridgeCancelRequest, GameClientBridgeCancelRequest,
@@ -627,6 +628,11 @@ export class PlatformApiClient {
}); });
} }
async getDeclaredFileReadSnapshot(serverInstanceId: string, fileKey: string): Promise<DeclaredFileReadSnapshotResponse> {
const query = new URLSearchParams({ key: fileKey });
return this.request<DeclaredFileReadSnapshotResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${query.toString()}`);
}
async listLogStreams(): Promise<LogStreamListResponse> { async listLogStreams(): Promise<LogStreamListResponse> {
return this.request<LogStreamListResponse>("/log-streams"); return this.request<LogStreamListResponse>("/log-streams");
} }
+14
View File
@@ -1455,6 +1455,20 @@ export interface FileOperationDispatchResponse {
job: JobResponse; job: JobResponse;
} }
export interface DeclaredFileReadSnapshotResponse {
serverInstanceId: string;
pluginId: string;
key: string;
state: "ready" | "pending" | "not-read" | string;
content?: string;
version?: number;
checksum?: string;
sizeBytes?: number;
jobId?: string;
readAt?: string;
reason?: string;
}
export interface LogStreamResponse { export interface LogStreamResponse {
id: string; id: string;
serverInstanceId: string; serverInstanceId: string;
+25
View File
@@ -0,0 +1,25 @@
export interface PluginPageFileRequestResult {
status: string;
message: string;
jobId?: string;
}
export interface PluginPageFileReadSnapshot {
serverInstanceId: string;
pluginId: string;
key: string;
state: "ready" | "pending" | "not-read" | "unavailable" | string;
content?: string;
version?: number;
checksum?: string;
sizeBytes?: number;
jobId?: string;
readAt?: string;
reason?: string;
}
export interface PluginPageWorkspaceActions {
requestFile?: (fileKey: string) => Promise<PluginPageFileRequestResult>;
getFileSnapshot?: (fileKey: string) => Promise<PluginPageFileReadSnapshot>;
writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise<PluginPageFileRequestResult>;
}
+1
View File
@@ -326,6 +326,7 @@ describe("first-party console pages", () => {
expect(serverLiveOperationsSource).toContain("mergeTerminalLines"); expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000"); expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000"); expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000");
expect(serverLiveOperationsSource).not.toContain("queryLogStream(");
expect(serverLiveOperationsSource).not.toContain("SaveWorld"); expect(serverLiveOperationsSource).not.toContain("SaveWorld");
}); });
@@ -95,4 +95,11 @@ describe("PluginPageHostPage", () => {
expect(hostSource).toContain("loadPluginPageBundle"); expect(hostSource).toContain("loadPluginPageBundle");
expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/); expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/);
}); });
it("keeps file workspace callbacks stable across parent operational refreshes", () => {
expect(hostSource).toContain("readyPluginRef.current = readyPlugin");
expect(hostSource).toContain("hostContextRef.current = hostContext");
expect(hostSource).toContain("}, [pluginId, serverId]);");
expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);");
});
}); });
+113 -16
View File
@@ -1,6 +1,6 @@
import { ArrowLeft, PlugZap } from "lucide-react"; import { ArrowLeft, PlugZap } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useCallback, useEffect, useState, type ComponentType } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types"; import type { GamePluginResponse } from "../api/types";
@@ -8,7 +8,8 @@ import { PageFrame } from "../components/PageFrame";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge"; import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost"; import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles"; import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
type PluginPageState = type PluginPageState =
@@ -18,16 +19,19 @@ type PluginPageState =
interface PluginPageHostPageProps extends PageComponentProps { interface PluginPageHostPageProps extends PageComponentProps {
initialPlugin?: GamePluginResponse; initialPlugin?: GamePluginResponse;
embedded?: boolean;
} }
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) { export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded = false }: PluginPageHostPageProps) {
const pluginId = params.pluginId ?? ""; const pluginId = params.pluginId ?? "";
const routeKey = params.routeKey ?? ""; const routeKey = params.routeKey ?? "";
const serverId = params.serverId ?? ""; const serverId = params.serverId ?? "";
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" }); const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; availability: PluginPageAvailability }> | null>(null); const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null);
const [bundleError, setBundleError] = useState(""); const [bundleError, setBundleError] = useState("");
const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证 Companion 可用性。" }); const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证 Companion 可用性。" });
const readyPluginRef = useRef<GamePluginResponse | undefined>(undefined);
const hostContextRef = useRef<ReturnType<typeof createPluginBridgeHostContext> | undefined>(undefined);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!pluginId || !routeKey) { if (!pluginId || !routeKey) {
@@ -52,19 +56,107 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
const readyPlugin = state.status === "ready" ? state.plugin : undefined; const readyPlugin = state.status === "ready" ? state.plugin : undefined;
const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey); const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey);
const declaredBundlePage = readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin).pages.find((candidate) => candidate.key === routeKey) : undefined; const manifestContract = useMemo(() => (readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin) : undefined), [readyPlugin]);
const declaredBundlePage = manifestContract?.pages.find((candidate) => candidate.key === routeKey);
const hostContext = useMemo(() => manifestContract ? createPluginBridgeHostContext({
plugin: manifestContract,
routeKey,
serverInstanceId: serverId || undefined,
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
}) : undefined, [manifestContract, routeKey, serverId]);
readyPluginRef.current = readyPlugin;
hostContextRef.current = hostContext;
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
if (!pluginId || !serverId) return undefined;
return {
requestFile: async (fileKey) => {
const plugin = readyPluginRef.current;
const context = hostContextRef.current;
if (!plugin || !context) {
return { status: "denied", message: "插件页面上下文尚未就绪。" };
}
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
}
const dispatch = createPluginBridgeDispatcher(context, platformApiClient);
const response = await dispatch({
requestId: `web:plugin-file-read:${serverId}:${fileKey}:${Date.now()}`,
action: "files.request",
payload: { operation: "read", key: fileKey }
});
const jobId = response.result?.jobId;
if (response.status === "queued" || response.status === "ok") {
return { status: response.status, jobId, message: jobId ? `读取任务 ${jobId} 已提交。` : "已提交文件读取请求。" };
}
return { status: response.status, message: response.error?.message ?? "文件读取请求未能提交。" };
},
getFileSnapshot: async (fileKey) => {
const plugin = readyPluginRef.current;
if (!plugin) {
return { serverInstanceId: serverId, pluginId, key: fileKey, state: "unavailable", reason: "插件页面上下文尚未就绪。" };
}
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
return { serverInstanceId: serverId, pluginId: plugin.id, key: fileKey, state: "unavailable", reason: "该文件不在当前插件声明的工作区内。" };
}
try {
return await platformApiClient.getDeclaredFileReadSnapshot(serverId, fileKey);
} catch (error) {
return {
serverInstanceId: serverId,
pluginId: plugin.id,
key: fileKey,
state: "unavailable",
reason: error instanceof Error ? error.message : "无法读取文件快照。"
};
}
},
writeFile: async (fileKey, content, options) => {
const plugin = readyPluginRef.current;
const context = hostContextRef.current;
if (!plugin || !context) {
return { status: "denied", message: "插件页面上下文尚未就绪。" };
}
const file = plugin.fileWorkspace?.files.find((candidate) => candidate.key === fileKey);
if (!file) {
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
}
if (file.kind !== "config" || !file.editable) {
return { status: "denied", message: "该声明文件不允许通过配置工作台写入。" };
}
if (!context.permissions.includes("server.files.write")) {
return { status: "denied", message: "当前页面没有声明文件写入权限。" };
}
try {
const response = await platformApiClient.dispatchFileOperation({
serverInstanceId: serverId,
pluginId: plugin.id,
operation: "write",
key: fileKey,
content,
expectedChecksum: options?.expectedChecksum,
idempotencyKey: `web:plugin-file-write:${serverId}:${fileKey}:${Date.now()}`
});
return { status: response.status, jobId: response.job.id, message: `写入任务 ${response.job.id} 已提交。` };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "文件写入请求未能提交。" };
}
}
};
}, [pluginId, serverId]);
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
const loadableBundlePage = useMemo(() => declaredBundlePage, [bundleLoadKey]);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
if (!declaredBundlePage) return () => { active = false; }; if (!loadableBundlePage) return () => { active = false; };
globalThis.__PLUGIN_PAGE_REACT__ = React; globalThis.__PLUGIN_PAGE_REACT__ = React;
setBundle(null); setBundleError(""); setBundle(null); setBundleError("");
void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); }); void loadPluginPageBundle(loadableBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); });
if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; } if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; }
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => { void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => {
if (active) setAvailability({ available: status.available, reason: status.reason, features: status.features }); if (active) setAvailability({ available: status.available, reason: status.reason, features: status.features });
}).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); }); }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
return () => { active = false; }; return () => { active = false; };
}, [declaredBundlePage, serverId]); }, [bundleLoadKey, loadableBundlePage, serverId]);
if (state.status === "loading") { if (state.status === "loading") {
return <LoadingState label="正在加载插件页面声明…" />; return <LoadingState label="正在加载插件页面声明…" />;
@@ -77,13 +169,18 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
if (!page) { if (!page) {
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />; return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
} }
const manifestContract = pluginBridgeManifestContractFromResponse(state.plugin); if (!hostContext) {
const hostContext = createPluginBridgeHostContext({ return <ErrorState title="插件页面不可用" reason="插件页面上下文初始化失败。" />;
plugin: manifestContract, }
routeKey, if (embedded) {
serverInstanceId: serverId || undefined, return (
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" } <>
}); {bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
</>
);
}
return ( return (
<div className="console-page"> <div className="console-page">
<PageFrame <PageFrame
@@ -110,7 +207,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
</section> </section>
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />} {bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />} {!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, availability })} {bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
</div> </div>
); );
} }
@@ -170,6 +170,15 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain('capability: "process.install"'); expect(serverDetailPageSource).not.toContain('capability: "process.install"');
}); });
it("routes the SCUM logs section to the declared file management workbench", () => {
expect(serverDetailPageSource).toContain("serverDetailSectionLabel(entry, instance.data.pluginId)");
expect(serverDetailPageSource).toContain('"文件管理"');
expect(serverDetailPageSource).toContain("ScumFileManagementSection");
expect(serverDetailPageSource).toContain("PluginPageHostPage");
expect(serverDetailPageSource).toContain('routeKey: "files-config"');
expect(serverDetailPageSource).toContain('file.kind === "config"');
});
it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => { it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => {
expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)"); expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)");
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)"); expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)");
+28 -7
View File
@@ -73,6 +73,7 @@ import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePlugi
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer"; import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
import { stateLabel, statusClass } from "./ServersPage"; import { stateLabel, statusClass } from "./ServersPage";
import { PluginPageHostPage } from "./PluginPageHostPage";
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents"; import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
@@ -81,7 +82,8 @@ const defaultConfigKey = "server.properties";
const serverDetailRefreshMs = 5000; const serverDetailRefreshMs = 5000;
const serverMetricFreshMs = 30000; const serverMetricFreshMs = 30000;
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) { export function ServerDetailPage(props: PageComponentProps) {
const { session, params, operations, onNavigate } = props;
const serverId = params.serverId ?? ""; const serverId = params.serverId ?? "";
const [section, setSection] = useState<ServerDetailSection>("logs"); const [section, setSection] = useState<ServerDetailSection>("logs");
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" }); const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
@@ -267,7 +269,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
aria-current={section === entry.id ? "page" : undefined} aria-current={section === entry.id ? "page" : undefined}
onClick={() => setSection(entry.id)} onClick={() => setSection(entry.id)}
> >
{entry.label} {serverDetailSectionLabel(entry)}
</button> </button>
))} ))}
</nav> </nav>
@@ -330,12 +332,12 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
aria-current={section === entry.id ? "page" : undefined} aria-current={section === entry.id ? "page" : undefined}
onClick={() => setSection(entry.id)} onClick={() => setSection(entry.id)}
> >
{entry.label} {serverDetailSectionLabel(entry, instance.data.pluginId)}
</button> </button>
))} ))}
</nav> </nav>
{section === "logs" && <LogsSection serverId={serverId} />} {section === "logs" && (instance.data.pluginId === "game.scum" ? <ScumFileManagementSection pageProps={props} serverId={serverId} plugin={plugins.find((plugin) => plugin.id === instance.data.pluginId)} /> : <LogsSection serverId={serverId} />)}
{section === "terminal" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />} {section === "terminal" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{section === "runtime" && ( {section === "runtime" && (
<RuntimeBindingSection <RuntimeBindingSection
@@ -409,6 +411,24 @@ function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
return [...byID.values()]; return [...byID.values()];
} }
function serverDetailSectionLabel(entry: { id: ServerDetailSection; label: string }, pluginId?: string): string {
return pluginId === "game.scum" && entry.id === "logs" ? "文件管理" : entry.label;
}
interface ScumFileManagementSectionProps {
pageProps: PageComponentProps;
serverId: string;
plugin?: GamePluginResponse;
}
function ScumFileManagementSection({ pageProps, serverId, plugin }: ScumFileManagementSectionProps) {
const params = useMemo(() => ({ ...pageProps.params, pluginId: plugin?.id ?? "", routeKey: "files-config", serverId }), [pageProps.params.pluginId, pageProps.params.routeKey, pageProps.params.serverId, plugin?.id, serverId]);
if (!plugin) {
return <LoadingState label="正在加载 SCUM 文件工作台…" compact />;
}
return <PluginPageHostPage {...pageProps} params={params} initialPlugin={plugin} embedded />;
}
interface ServerMetadataSectionProps { interface ServerMetadataSectionProps {
instance: ServerInstanceResponse; instance: ServerInstanceResponse;
session: PageComponentProps["session"]; session: PageComponentProps["session"];
@@ -1999,7 +2019,7 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
requestId: `web:bridge:${serverId}:${plugin.id}:${action}:${Date.now()}`, requestId: `web:bridge:${serverId}:${plugin.id}:${action}:${Date.now()}`,
action, action,
aiPurpose: action === "ai.invoke" ? plugin.aiPurposes[0] : undefined, aiPurpose: action === "ai.invoke" ? plugin.aiPurposes[0] : undefined,
payload: bridgePayloadForAction(action, serverInstance, artifacts[0]) payload: bridgePayloadForAction(action, plugin, serverInstance, artifacts[0])
}); });
setPendingAction(null); setPendingAction(null);
if (response.status === "ok" || response.status === "queued") { if (response.status === "ok" || response.status === "queued") {
@@ -2044,9 +2064,10 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
); );
} }
function bridgePayloadForAction(action: PluginBridgeAction, serverInstance: ServerInstanceResponse, artifact?: ArtifactResponse): Record<string, string> | undefined { function bridgePayloadForAction(action: PluginBridgeAction, plugin: GamePluginResponse, serverInstance: ServerInstanceResponse, artifact?: ArtifactResponse): Record<string, string> | undefined {
if (action === "files.request") { if (action === "files.request") {
return { operation: "read", key: "logs/latest.log", expectedConfigVersion: String(serverInstance.configVersion) }; const declaredFileKey = plugin.fileWorkspace?.files.find((file) => file.kind === "config")?.key ?? plugin.fileWorkspace?.files[0]?.key ?? "logs/latest.log";
return { operation: "read", key: declaredFileKey, expectedConfigVersion: String(serverInstance.configVersion) };
} }
if (action === "artifacts.open" && artifact) { if (action === "artifacts.open" && artifact) {
return { artifactId: artifact.id }; return { artifactId: artifact.id };
+3
View File
@@ -470,6 +470,9 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap} .section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap}
.section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0} .section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0}
.section-tab-active{background:var(--primary-command-surface);border-color:var(--accent-deep);color:#fff;font-weight:700;box-shadow:0 10px 24px var(--candy-glow),inset 0 1px 0 var(--crystal-rim),0 0 18px var(--moonbeam)} .section-tab-active{background:var(--primary-command-surface);border-color:var(--accent-deep);color:#fff;font-weight:700;box-shadow:0 10px 24px var(--candy-glow),inset 0 1px 0 var(--crystal-rim),0 0 18px var(--moonbeam)}
.file-workbench{display:grid;grid-template-columns:minmax(200px,.82fr) minmax(0,2fr);gap:12px;align-items:start;min-width:0}.file-workbench-nav{display:grid;gap:12px;min-width:0;padding-right:12px;border-right:1px solid var(--line)}.file-workbench-directory{display:grid;gap:4px;min-width:0}.file-workbench-directory-heading{display:grid;gap:2px;padding:0 8px 4px;color:var(--ink-soft);font-size:12px}.file-workbench-directory-heading strong{color:var(--ink);font-size:13px}.file-workbench-directory-heading span{font-family:var(--font-mono);overflow-wrap:anywhere}.file-workbench-file{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;width:100%;min-height:42px;align-items:center;padding:7px 8px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--ink-soft);font:inherit;text-align:left;cursor:pointer}.file-workbench-file strong{min-width:0;color:var(--ink);overflow-wrap:anywhere}.file-workbench-file span{font-size:11px;color:var(--ink-faint);white-space:nowrap}.file-workbench-file:hover,.file-workbench-file:focus-visible{border-color:var(--line-strong);background:var(--control-surface);outline:0}.file-workbench-file[aria-current=page]{border-color:var(--accent);background:var(--primary-command-surface);color:#fff}.file-workbench-file[aria-current=page] strong,.file-workbench-file[aria-current=page] span{color:#fff}.file-workbench-detail{display:grid;gap:12px;min-width:0}.file-workbench-detail>.panel-header{padding-bottom:10px;border-bottom:1px solid var(--line)}.file-workbench-mode{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding-bottom:10px;border-bottom:1px solid var(--line)}.file-workbench-mode>span{margin-right:auto;color:var(--ink-soft);font-size:12px}.file-workbench-mode button{min-height:32px;padding:0 10px;border:1px solid var(--line-strong);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);font:inherit;cursor:pointer}.file-workbench-mode button:hover,.file-workbench-mode button:focus-visible{border-color:var(--accent);outline:0}.file-workbench-mode .file-workbench-mode-active{border-color:var(--accent);background:var(--primary-command-surface);color:#fff}.file-workbench-fields{display:grid;gap:0}.file-workbench-field{display:grid;grid-template-columns:minmax(0,1fr) minmax(150px,.72fr);gap:8px 12px;align-items:center;padding:10px 0;border-bottom:1px solid color-mix(in srgb,var(--line) 74%,transparent)}.file-workbench-field>span{display:grid;gap:3px;min-width:0}.file-workbench-field strong{color:var(--ink);font-size:13px}.file-workbench-field small{color:var(--ink-faint);overflow-wrap:anywhere}.file-workbench-field input{min-width:0;min-height:34px;border:1px solid var(--line-strong);border-radius:6px;padding:0 9px;background:var(--surface-solid);color:var(--ink);font:inherit}.file-workbench-field input:read-only,.file-workbench-field input:disabled{color:var(--ink-soft);background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.file-workbench-field>small{grid-column:1/-1}.file-workbench-raw{display:grid;gap:8px;min-width:0}.file-workbench-raw .runtime-task-log{margin:0;max-height:560px;overflow:auto}.file-workbench-raw small{color:var(--ink-faint);font-family:var(--font-mono);overflow-wrap:anywhere}
.file-workbench-picker{display:grid;gap:6px;color:var(--ink-soft);font-size:12px;font-weight:700}.file-workbench-picker select,.file-workbench-field select{width:100%;min-height:36px;border:1px solid var(--line-strong);border-radius:7px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit}.file-workbench-picker select:focus,.file-workbench-field select:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}.file-workbench-number-control{display:grid;grid-template-columns:minmax(0,1fr) 92px;gap:8px;align-items:center}.file-workbench-number-control input[type=range]{padding:0;accent-color:var(--accent)}.file-workbench-raw-editor{width:100%;min-height:420px;resize:vertical;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--code-surface);color:var(--code-ink);font-family:var(--font-mono);font-size:12px;line-height:1.55}.file-workbench-raw-editor:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}.file-workbench-actions{justify-content:flex-start}.file-workbench-unknown,.file-workbench-diff{display:grid;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}.file-workbench-unknown h3{margin:0;color:var(--ink);font-size:13px}.file-workbench-diff .runtime-task-log{max-height:260px}.file-workbench-encoding{padding-top:0}
@media (max-width:760px){.file-workbench{grid-template-columns:1fr}.file-workbench-nav{padding:0 0 10px;border-right:0;border-bottom:1px solid var(--line)}.file-workbench-field{grid-template-columns:1fr}.file-workbench-mode>span{width:100%;margin-right:0}.file-workbench-mode button{flex:1 1 0}}
.overview-two-col{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} .overview-two-col{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.log-filter-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center} .log-filter-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.log-filter-bar input,.log-filter-bar select{min-height:36px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit;font-size:13px} .log-filter-bar input,.log-filter-bar select{min-height:36px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit;font-size:13px}
+4 -3
View File
@@ -1,23 +1,24 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import type { PluginBridgeHostContext, PluginPageContract } from "../contracts/pluginBridge"; import type { PluginBridgeHostContext, PluginPageContract } from "../contracts/pluginBridge";
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
export interface PluginPageBundleModule { export interface PluginPageBundleModule {
pluginPageBundle: { key: string; version: string; integritySha256: string }; pluginPageBundle: { key: string; version: string; integritySha256: string };
renderPluginPage: (react: { createElement: typeof import("react").createElement }, input: { page: PluginPageContract; context: PluginBridgeHostContext; workspace?: unknown; availability: PluginPageAvailability }) => ReturnType<typeof import("react").createElement>; renderPluginPage: (react: typeof import("react"), input: { page: PluginPageContract; context: PluginBridgeHostContext; workspace?: unknown; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }) => ReturnType<typeof import("react").createElement>;
} }
export type PluginPageAvailability = { available: boolean; reason?: string; features?: Array<{ key: string; available: boolean; reason?: string }> }; export type PluginPageAvailability = { available: boolean; reason?: string; features?: Array<{ key: string; available: boolean; reason?: string }> };
const pageBundles = import.meta.glob<PluginPageBundleModule>("../../plugins/examples/*/page-bundle/index.ts"); const pageBundles = import.meta.glob<PluginPageBundleModule>("../../plugins/examples/*/page-bundle/index.ts");
export async function loadPluginPageBundle(page: PluginPageContract): Promise<ComponentType<{ context: PluginBridgeHostContext; workspace?: unknown; availability: PluginPageAvailability }>> { export async function loadPluginPageBundle(page: PluginPageContract): Promise<ComponentType<{ context: PluginBridgeHostContext; workspace?: unknown; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }>> {
if (!page.bundleKey || !page.bundleVersion || !page.bundleIntegritySha256) throw new Error("插件没有声明受验证的页面 bundle。"); if (!page.bundleKey || !page.bundleVersion || !page.bundleIntegritySha256) throw new Error("插件没有声明受验证的页面 bundle。");
const match = Object.entries(pageBundles).find(([path]) => path.endsWith(`/${page.bundleKey}/page-bundle/index.ts`)); const match = Object.entries(pageBundles).find(([path]) => path.endsWith(`/${page.bundleKey}/page-bundle/index.ts`));
if (!match) throw new Error("已声明的插件页面 bundle 未安装。"); if (!match) throw new Error("已声明的插件页面 bundle 未安装。");
const module = await match[1](); const module = await match[1]();
if (module.pluginPageBundle.key !== page.bundleKey || module.pluginPageBundle.version !== page.bundleVersion || module.pluginPageBundle.integritySha256 !== page.bundleIntegritySha256) throw new Error("插件页面 bundle 完整性或版本校验失败。"); if (module.pluginPageBundle.key !== page.bundleKey || module.pluginPageBundle.version !== page.bundleVersion || module.pluginPageBundle.integritySha256 !== page.bundleIntegritySha256) throw new Error("插件页面 bundle 完整性或版本校验失败。");
return ({ context, workspace, availability }) => module.renderPluginPage({ createElement: (awaitReact()).createElement }, { page, context, workspace, availability }); return ({ context, workspace, workspaceActions, availability }) => module.renderPluginPage(awaitReact(), { page, context, workspace, workspaceActions, availability });
} }
function awaitReact(): typeof import("react") { function awaitReact(): typeof import("react") {
@@ -10,22 +10,44 @@ if "%SERVER_CREATE_GAMEPORT%"=="" set "SERVER_CREATE_GAMEPORT=7779"
if "%SERVER_CREATE_MAXPLAYERS%"=="" set "SERVER_CREATE_MAXPLAYERS=128" if "%SERVER_CREATE_MAXPLAYERS%"=="" set "SERVER_CREATE_MAXPLAYERS=128"
if "%SERVER_LOG_FLAG%"=="" set "SERVER_LOG_FLAG=-log" if "%SERVER_LOG_FLAG%"=="" set "SERVER_LOG_FLAG=-log"
set "SCUM_INSTALL_UPDATE=%~dp0scum-install-update.cmd"
set "SCUM_EXE_MARKER=%SERVER_ROOT_WINDOWS%\.scum-exe-path" set "SCUM_EXE_MARKER=%SERVER_ROOT_WINDOWS%\.scum-exe-path"
if exist "%SCUM_EXE_MARKER%" set /p SCUM_EXE=<"%SCUM_EXE_MARKER%" if exist "%SCUM_EXE_MARKER%" set /p SCUM_EXE=<"%SCUM_EXE_MARKER%"
if "%SCUM_EXE%"=="" set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%" if "%SCUM_EXE%"=="" set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%"
if not exist "%SCUM_EXE%" call :resolve_scum_exe if not exist "%SCUM_EXE%" call :resolve_scum_exe
if not exist "%SCUM_EXE%" exit /b 2 set "SCUM_NEEDS_INSTALL=0"
if not exist "%SCUM_EXE%" set "SCUM_NEEDS_INSTALL=1"
if not exist "%SCUM_EXE%" (
echo [scum-start] SCUM executable was not found. Running plugin install/update script.
call :install_or_update
)
if "%SCUM_NEEDS_INSTALL%"=="1" if errorlevel 1 exit /b %ERRORLEVEL%
if "%SCUM_NEEDS_INSTALL%"=="1" call :resolve_scum_exe
if not exist "%SCUM_EXE%" (
echo [scum-start] SCUM executable is still missing after install/update.
exit /b 2
)
>"%SCUM_EXE_MARKER%" echo(%SCUM_EXE%
for %%I in ("%SCUM_EXE%") do set "SCUM_EXE_DIR=%%~dpI" for %%I in ("%SCUM_EXE%") do set "SCUM_EXE_DIR=%%~dpI"
for %%I in ("%SCUM_EXE_DIR%..\..\..") do set "SCUM_WORKDIR=%%~fI" for %%I in ("%SCUM_EXE_DIR%..\..\..") do set "SCUM_WORKDIR=%%~fI"
if not exist "%SCUM_WORKDIR%" set "SCUM_WORKDIR=%SERVER_ROOT_WINDOWS%" if not exist "%SCUM_WORKDIR%" set "SCUM_WORKDIR=%SERVER_ROOT_WINDOWS%"
pushd "%SCUM_WORKDIR%" pushd "%SCUM_WORKDIR%"
echo [scum-start] Starting "%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%
"%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG% "%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%
set "SCUM_START_RESULT=%ERRORLEVEL%" set "SCUM_START_RESULT=%ERRORLEVEL%"
popd popd
exit /b %SCUM_START_RESULT% exit /b %SCUM_START_RESULT%
:install_or_update
if not exist "%SCUM_INSTALL_UPDATE%" (
echo [scum-start] Missing plugin install/update script: %SCUM_INSTALL_UPDATE%
exit /b 3
)
call "%SCUM_INSTALL_UPDATE%"
exit /b %ERRORLEVEL%
:resolve_scum_exe :resolve_scum_exe
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%" set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%"
if exist "%SCUM_EXE%" exit /b 0 if exist "%SCUM_EXE%" exit /b 0
@@ -9,10 +9,12 @@ export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-r
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record<string, unknown> }; export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record<string, unknown> };
export type SCUMVehicleSpawn = { vehicleCode: string }; export type SCUMVehicleSpawn = { vehicleCode: string };
export type SCUMVehicleSpawnOption = { code: string; label: string }; export type SCUMVehicleSpawnOption = { code: string; label: string };
export type SCUMLogicalDirectory = { key: string; label: string; scope: "config" | "logs" };
export type SCUMLogicalFile = { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean };
export type SCUMConfigField = { export type SCUMConfigField = {
key: string; label: string; description: string; control: "text" | "number" | "port" | "boolean"; key: string; label: string; description: string; control: "text" | "number" | "port" | "boolean";
configKey: string; defaultValue: string; restartImpact: "restart-required" | "none"; minimum?: number; maximum?: number; fileKey: string; configKey: string; defaultValue: string; restartImpact: "restart-required" | "none"; minimum?: number; maximum?: number;
}; };
export type SCUMConfigRead = { fields: Record<string, string>; observedAt: string }; export type SCUMConfigRead = { fields: Record<string, string>; observedAt: string };
export type SCUMConfigPatch = { changes: Array<{ key: string; value: string }>; reason: string; idempotencyKey: string }; export type SCUMConfigPatch = { changes: Array<{ key: string; value: string }>; reason: string; idempotencyKey: string };
@@ -34,4 +36,4 @@ export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subje
export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance }; export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance };
export type SCUMTrajectoryCollection = { available: boolean; reason?: string; trajectories: SCUMTrajectory[] }; 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 } }; export type SCUMFeatureWorkspace = { defaultDirectoryKey?: string; directories?: SCUMLogicalDirectory[]; files?: SCUMLogicalFile[]; configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } };
@@ -1,28 +1,432 @@
import { configurationCatalog, stateFieldCatalog, vehicleSpawnCatalog } from "./schemas.js"; import { configurationCatalog } from "./schemas.js";
import type { SCUMFeatureWorkspace } from "./contracts.js"; import type { SCUMConfigField, SCUMFeatureWorkspace, SCUMLogicalDirectory, SCUMLogicalFile } from "./contracts.js";
export type ReactLike = { createElement: (...args: any[]) => any; useMemo?: <T>(factory: () => T, deps: readonly unknown[]) => T }; type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspace?: SCUMFeatureWorkspace }; export type ReactLike = {
createElement: (...args: any[]) => any;
useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void;
useState?: <T>(initialState: T | (() => T)) => [T, StateSetter<T>];
};
export type SCUMFileReadSnapshot = {
serverInstanceId: string;
pluginId: string;
key: string;
state: "ready" | "pending" | "not-read" | "unavailable" | string;
content?: string;
version?: number;
checksum?: string;
sizeBytes?: number;
jobId?: string;
readAt?: string;
reason?: string;
};
export type SCUMPageContext = {
serverInstanceId?: string;
permissions: string[];
availability: { available: boolean; reason?: string };
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
workspace?: SCUMFeatureWorkspace;
workspaceActions?: {
requestFile?: (fileKey: string) => Promise<{ status: string; message: string; jobId?: string }>;
getFileSnapshot?: (fileKey: string) => Promise<SCUMFileReadSnapshot>;
writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise<{ status: string; message: string; jobId?: string }>;
};
};
type NormalizedWorkspace = { defaultDirectoryKey: string; directories: readonly SCUMLogicalDirectory[]; files: readonly SCUMLogicalFile[]; configFields: readonly SCUMConfigField[] };
type ConfigMode = "fields" | "source";
type RawEncoding = "utf-8" | "utf-16le";
type FileRequestState = { fileKey: string; status: string; message: string; jobId?: string } | null;
type PreviewState = { fileKey: string; mode: ConfigMode; summary: string; proposedContent: string; lines: readonly DiffLine[] } | null;
type DiffLine = { kind: "same" | "added" | "removed"; text: string };
const fallbackDirectories: readonly SCUMLogicalDirectory[] = [
{ key: "scum-config", label: "服务器配置", scope: "config" },
{ key: "scum-logs", label: "日志文件", scope: "logs" }
];
const fallbackFiles: readonly SCUMLogicalFile[] = [
{ key: "scum-server-settings", directoryKey: "scum-config", label: "ServerSettings.ini", kind: "config", editable: true },
{ key: "scum-game-config", directoryKey: "scum-config", label: "Game.ini", kind: "config" },
{ key: "scum-engine-config", directoryKey: "scum-config", label: "Engine.ini", kind: "config" },
{ key: "scum-game-user-settings", directoryKey: "scum-config", label: "GameUserSettings.ini", kind: "config" },
{ key: "scum-admin-log", directoryKey: "scum-logs", label: "Admin.log", kind: "log", streamKey: "scum.admin" },
{ key: "scum-chat-log", directoryKey: "scum-logs", label: "Chat.log", kind: "log", streamKey: "scum.chat" },
{ key: "scum-kill-log", directoryKey: "scum-logs", label: "Kill.log", kind: "log", streamKey: "scum.kill" },
{ key: "scum-login-log", directoryKey: "scum-logs", label: "Login.log", kind: "log", streamKey: "scum.login" },
{ key: "scum-server-log", directoryKey: "scum-logs", label: "Server.log", kind: "log", streamKey: "scum.server" }
];
const directoryRelativePaths: Record<string, string> = { "scum-config": "/SCUM/Saved/Config/WindowsServer", "scum-logs": "/SCUM/Saved/SaveFiles/Logs" };
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement; const fields = input.workspace?.configFields?.length ? input.workspace.configFields : configurationCatalog; const vehicleCodes = vehicleSpawnCatalog; 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"); const e = react.createElement;
return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" }, const workspace = normalizeWorkspace(input.workspace);
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))), const [selectedDirectoryKey, setSelectedDirectoryKey] = usePluginState(react, workspace.defaultDirectoryKey);
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, "运行时 schema"), e("span", null, "按受限通道探测")), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))), const effectiveDirectoryKey = workspace.directories.some((directory) => directory.key === selectedDirectoryKey) ? selectedDirectoryKey : workspace.defaultDirectoryKey;
configurationPanel(e, fields, canRead, canMaintain, featureAvailability(input, "config.manage")), const directoryFiles = workspace.files.filter((file) => file.directoryKey === effectiveDirectoryKey);
playerPanel(e, canRead, featureAvailability(input, "player.intelligence")), const defaultFileKey = initialFileKey(workspace, effectiveDirectoryKey);
rewardPanel(e, canRead, canCommand, featureAvailability(input, "reward.delivery")), const [selectedFileKey, setSelectedFileKey] = usePluginState(react, defaultFileKey);
statePanel(e, canRead, canMaintain, featureAvailability(input, "state.patch")), const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0] ?? workspace.files[0];
vehicleSpawnPanel(e, vehicleCodes, canCommand, featureAvailability(input, "vehicle.spawn")), const selectedFields = selectedFile ? workspace.configFields.filter((field) => field.fileKey === selectedFile.key) : [];
trajectoryPanel(e, canRead, featureAvailability(input, "trajectory.collect")) const [configMode, setConfigMode] = usePluginState<ConfigMode>(react, "fields");
const [fieldDraft, setFieldDraft] = usePluginState<Record<string, string>>(react, {});
const [rawDraft, setRawDraft] = usePluginState<Record<string, string>>(react, {});
const [rawEncoding, setRawEncoding] = usePluginState<RawEncoding>(react, "utf-8");
const [snapshot, setSnapshot] = usePluginState<SCUMFileReadSnapshot | null>(react, null);
const [requestState, setRequestState] = usePluginState<FileRequestState>(react, null);
const [writeState, setWriteState] = usePluginState<FileRequestState>(react, null);
const [preview, setPreview] = usePluginState<PreviewState>(react, null);
const scoped = Boolean(input.serverInstanceId);
const canFilesRead = scoped && input.permissions.includes("server.files.read");
const canFilesWrite = scoped && input.permissions.includes("server.files.write");
if (react.useEffect) {
react.useEffect(() => {
let active = true;
setPreview(null);
setWriteState(null);
if (!selectedFile || !input.workspaceActions?.getFileSnapshot) {
setSnapshot(null);
return () => { active = false; };
}
void input.workspaceActions.getFileSnapshot(selectedFile.key).then((next) => {
if (active) setSnapshot(next);
}).catch((error) => {
if (active) setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" });
});
return () => { active = false; };
}, [input.serverInstanceId, input.workspaceActions?.getFileSnapshot, selectedFile?.key]);
}
function selectDirectory(directoryKey: string) {
const nextFiles = workspace.files.filter((file) => file.directoryKey === directoryKey);
const nextFile = nextFiles.find((file) => file.kind === "config") ?? nextFiles[0];
setSelectedDirectoryKey(directoryKey);
if (nextFile) selectFile(nextFile, directoryKey);
}
function selectFile(file: SCUMLogicalFile, directoryKey = file.directoryKey) {
setSelectedDirectoryKey(directoryKey);
setSelectedFileKey(file.key);
setConfigMode(file.kind === "config" && workspace.configFields.some((field) => field.fileKey === file.key) ? "fields" : "source");
setRequestState(null);
setWriteState(null);
setPreview(null);
setRawEncoding("utf-8");
}
function requestSelectedFile() {
if (!selectedFile || !canFilesRead || !input.workspaceActions?.requestFile) return;
setRequestState({ fileKey: selectedFile.key, status: "pending", message: "正在提交文件读取请求..." });
setPreview(null);
void input.workspaceActions.requestFile(selectedFile.key).then((result) => {
setRequestState({ fileKey: selectedFile.key, status: result.status, message: result.message, jobId: result.jobId });
if (result.status === "queued" || result.status === "ok") {
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "pending", jobId: result.jobId, reason: "等待运行端完成文件读取。" });
}
}).catch((error) => setRequestState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件读取请求失败。" }));
}
function refreshSelectedSnapshot() {
if (!selectedFile || !input.workspaceActions?.getFileSnapshot) return;
setPreview(null);
void input.workspaceActions.getFileSnapshot(selectedFile.key).then(setSnapshot).catch((error) => {
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" });
});
}
function previewModeChange(mode: ConfigMode, content: string, current: string) {
if (!selectedFile) return;
const diff = buildSimpleDiff(current, content);
setPreview({ fileKey: selectedFile.key, mode, proposedContent: content, summary: diff.summary, lines: diff.lines });
}
function writePreviewedContent() {
if (!selectedFile || !preview || preview.fileKey !== selectedFile.key || !input.workspaceActions?.writeFile) return;
setWriteState({ fileKey: selectedFile.key, status: "pending", message: "正在提交声明文件写入..." });
void input.workspaceActions.writeFile(selectedFile.key, preview.proposedContent, { expectedChecksum: snapshot?.checksum }).then((result) => {
setWriteState({ fileKey: selectedFile.key, status: result.status, message: result.message, jobId: result.jobId });
setPreview(null);
}).catch((error) => setWriteState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件写入请求失败。" }));
}
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("span", { className: "page-status" }, scoped ? "声明文件工作区" : "插件页面未绑定服务器")
),
e("div", { className: "file-workbench" },
navigationPane(e, workspace, effectiveDirectoryKey, directoryFiles, selectedFile?.key, selectDirectory, selectFile),
selectedFile
? fileDetail(e, {
file: selectedFile,
fields: selectedFields,
mode: selectedFile.kind === "log" ? "source" : configMode,
setMode: setConfigMode,
fieldDraft,
setFieldDraft,
rawDraft,
setRawDraft,
rawEncoding,
setRawEncoding,
snapshot: snapshot?.key === selectedFile.key ? snapshot : null,
requestState: requestState?.fileKey === selectedFile.key ? requestState : null,
writeState: writeState?.fileKey === selectedFile.key ? writeState : null,
preview: preview?.fileKey === selectedFile.key ? preview : null,
canFilesRead,
canFilesWrite,
canRequestFile: Boolean(input.workspaceActions?.requestFile),
canRefreshSnapshot: Boolean(input.workspaceActions?.getFileSnapshot),
canWriteFile: Boolean(input.workspaceActions?.writeFile),
onRequestFile: requestSelectedFile,
onRefreshSnapshot: refreshSelectedSnapshot,
onPreview: previewModeChange,
onWrite: writePreviewedContent
})
: e("div", { className: "file-workbench-detail" }, e("p", { className: "page-status" }, "当前插件没有可展示的声明文件。"))
)
); );
} }
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 navigationPane(e: ReactLike["createElement"], workspace: NormalizedWorkspace, directoryKey: string, directoryFiles: readonly SCUMLogicalFile[], selectedFileKey: string | undefined, onDirectoryChange: (directoryKey: string) => void, onFileSelect: (file: SCUMLogicalFile) => void) {
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 ?? "没有兼容的事件生产者。")); } const activeDirectory = workspace.directories.find((directory) => directory.key === directoryKey);
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、已验证玩家身份和兼容处理器。")); } const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0];
function statePanel(e: ReactLike["createElement"], canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { const fields = stateFieldCatalog; 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" }, "当前运行时没有已验证的状态字段。")), e("p", { className: "page-status" }, canMaintain ? availability.reason ?? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。")); } return e("aside", { className: "file-workbench-nav", "aria-label": "SCUM 文件两级菜单" },
function vehicleSpawnPanel(e: ReactLike["createElement"], vehicles: readonly { code: string; label: string }[], 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("div", { className: "console-row-list" }, vehicles.map((vehicle) => e("div", { className: "console-row", key: vehicle.code }, e("strong", null, vehicle.label), e("span", null, vehicle.code)))), e("p", { className: "page-status" }, !canCommand ? "当前服务器上下文没有受控指令权限。" : availability.available ? "仅在审批和 Companion 处理器均可用时开放。" : availability.reason ?? "当前没有已验证的载具生成处理器。")); } e("label", { className: "file-workbench-picker" },
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 ?? "当合法位置源可用时展示采样轨迹。" : "当前服务器上下文没有轨迹读取权限。")); } e("span", null, "目录"),
function featureAvailability(input: SCUMPageContext, key: string): { available: boolean; reason?: string } { const feature = input.featureAvailability?.find((item) => item.key === key); return feature ?? { available: false, reason: "当前服务器没有已验证的 Companion 处理器或事件生产者。" }; } e("select", { value: directoryKey, onChange: (event: { target: { value: string } }) => onDirectoryChange(event.target.value), "aria-label": "选择目录" },
function availabilityText(availability: { available: boolean; reason?: string }, scoped: boolean): string { if (!scoped) return "不可用:插件页面必须绑定服务器。"; return availability.available ? "已声明且已由 Companion 验证" : `不可用:${availability.reason ?? "没有可用的 Companion 处理器或事件生产者"}`; } workspace.directories.map((directory) => e("option", { key: directory.key, value: directory.key }, directoryRelativePath(directory.key) ?? directory.label))
)
),
e("label", { className: "file-workbench-picker" },
e("span", null, "文件"),
e("select", { value: selectedFile?.key ?? "", onChange: (event: { target: { value: string } }) => { const file = directoryFiles.find((candidate) => candidate.key === event.target.value); if (file) onFileSelect(file); }, "aria-label": "选择文件" },
directoryFiles.length ? directoryFiles.map((file) => e("option", { key: file.key, value: file.key }, file.label)) : e("option", { value: "" }, "没有已声明文件")
)
),
e("div", { className: "file-workbench-directory-heading" },
e("strong", null, activeDirectory?.label ?? "插件声明目录"),
e("span", null, directoryRelativePath(directoryKey) ?? "插件逻辑目录"),
e("small", null, `${directoryFiles.length} 个声明文件`)
)
);
}
function fileDetail(e: ReactLike["createElement"], props: {
file: SCUMLogicalFile; fields: readonly SCUMConfigField[]; mode: ConfigMode; setMode: StateSetter<ConfigMode>; fieldDraft: Record<string, string>; setFieldDraft: StateSetter<Record<string, string>>;
rawDraft: Record<string, string>; setRawDraft: StateSetter<Record<string, string>>; rawEncoding: RawEncoding; setRawEncoding: StateSetter<RawEncoding>; snapshot: SCUMFileReadSnapshot | null;
requestState: FileRequestState; writeState: FileRequestState; preview: PreviewState; canFilesRead: boolean; canFilesWrite: boolean; canRequestFile: boolean; canRefreshSnapshot: boolean; canWriteFile: boolean;
onRequestFile: () => void; onRefreshSnapshot: () => void; onPreview: (mode: ConfigMode, content: string, current: string) => void; onWrite: () => void;
}) {
const canShowFields = props.file.kind === "config" && props.fields.length > 0;
const currentContent = props.snapshot?.state === "ready" ? decodeRawContent(props.snapshot.content ?? "", props.rawEncoding) : "";
const snapshotValues = props.snapshot?.state === "ready" ? parseIniAssignments(currentContent) : {};
return e("article", { className: "file-workbench-detail", "aria-label": `文件 ${props.file.label}` },
e("div", { className: "panel-header" },
e("div", null, e("h2", null, props.file.label), e("p", { className: "provider-id" }, `${directoryRelativePath(props.file.directoryKey) ?? "插件声明目录"} · ${props.file.kind === "config" ? "配置文件" : "日志文件"}`)),
e("div", { className: "console-row-actions" },
e("button", { type: "button", className: "icon-command", disabled: !props.canFilesRead || !props.canRequestFile, onClick: props.onRequestFile }, "读取文件"),
e("button", { type: "button", className: "icon-command", disabled: !props.canRefreshSnapshot, onClick: props.onRefreshSnapshot }, "刷新结果")
)
),
props.requestState ? e("p", { className: "page-status", "data-state": props.requestState.status }, props.requestState.message) : null,
props.writeState ? e("p", { className: "page-status", "data-state": props.writeState.status }, props.writeState.message) : null,
props.file.kind === "log" || props.snapshot?.state === "ready" ? encodingSwitcher(e, props.rawEncoding, props.setRawEncoding) : null,
canShowFields
? e("div", { className: "file-workbench-mode", role: "tablist", "aria-label": "表单配置与原文模式" },
e("span", null, "编辑视图"),
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "fields", className: props.mode === "fields" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("fields") }, "配置表单"),
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "source", className: props.mode === "source" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("source") }, "原文模式")
)
: null,
canShowFields && props.mode === "fields"
? modeledConfigurationFields(e, props.file, props.fields, props.fieldDraft, props.setFieldDraft, snapshotValues, currentContent, props.snapshot?.state === "ready", props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
: rawFileView(e, props.file, currentContent, props.rawDraft, props.setRawDraft, props.snapshot, props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
);
}
function encodingSwitcher(e: ReactLike["createElement"], encoding: RawEncoding, setEncoding: StateSetter<RawEncoding>) {
return e("div", { className: "file-workbench-mode file-workbench-encoding", role: "tablist", "aria-label": "原文编码" },
e("span", null, "文本编码"),
e("button", { type: "button", role: "tab", "aria-selected": encoding === "utf-8", className: encoding === "utf-8" ? "file-workbench-mode-active" : undefined, onClick: () => setEncoding("utf-8") }, "UTF-8"),
e("button", { type: "button", role: "tab", "aria-selected": encoding === "utf-16le", className: encoding === "utf-16le" ? "file-workbench-mode-active" : undefined, onClick: () => setEncoding("utf-16le") }, "UTF-16 LE")
);
}
function modeledConfigurationFields(e: ReactLike["createElement"], file: SCUMLogicalFile, fields: readonly SCUMConfigField[], draft: Record<string, string>, setDraft: StateSetter<Record<string, string>>, values: Record<string, string>, currentContent: string, hasReadSnapshot: boolean, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) {
const editable = hasReadSnapshot && canFilesWrite && file.editable !== false;
const fieldKeys = new Set(fields.map((field) => field.key));
const selectedDraft = Object.fromEntries(Object.entries(draft).filter(([key]) => fieldKeys.has(key)));
const hasChanges = Object.keys(selectedDraft).length > 0;
const unknown = unknownIniAssignments(currentContent, fields);
return e("div", { className: "file-workbench-fields" },
fields.map((field) => {
const value = draft[field.key] ?? values[field.configKey] ?? field.defaultValue;
return e("label", { className: "file-workbench-field", key: field.key },
e("span", null, e("strong", null, field.label), e("small", null, `${field.configKey} · ${field.description}`)),
fieldControl(e, field, value, editable, (next) => setDraft((current) => ({ ...current, [field.key]: next }))),
e("small", null, `${controlLabel(field)} · ${field.restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效"}`)
);
}),
unknown.length ? e("section", { className: "file-workbench-unknown", "aria-label": "未建模配置项" }, e("h3", null, "未建模配置项"), unknown.map((item) => e("div", { className: "console-row", key: `${item.key}:${item.index}` }, e("span", null, item.key), e("strong", null, item.value || "空值")))) : null,
e("div", { className: "console-row-actions file-workbench-actions" },
e("button", { type: "button", className: "icon-command", disabled: !editable || !hasChanges, onClick: () => onPreview("fields", composeIniContent(currentContent, fields, selectedDraft), currentContent) }, "预览改动"),
e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "fields", onClick: onWrite }, "提交写入")
),
preview && preview.mode === "fields" ? diffPreview(e, preview) : null,
e("p", { className: "page-status" }, hasReadSnapshot ? editable ? "配置表单会保留原文中的未知行;提交前先预览差异。" : "当前账号仅有读取权限,配置项为只读。" : "先读取文件后才显示服务器当前值;未读取时不会把默认值伪装成原文。")
);
}
function fieldControl(e: ReactLike["createElement"], field: SCUMConfigField, value: string, editable: boolean, onChange: (next: string) => void) {
if (field.control === "boolean") {
return e("select", { value: normalizeBoolean(value) ? "true" : "false", disabled: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) },
e("option", { value: "true" }, "是"),
e("option", { value: "false" }, "否")
);
}
if (field.control === "number" || field.control === "port") {
return e("div", { className: "file-workbench-number-control" },
e("input", { type: "range", value, min: field.minimum, max: field.maximum, disabled: !editable, "aria-label": `${field.label}滑动输入`, onChange: (event: { target: { value: string } }) => onChange(event.target.value) }),
e("input", { type: "number", value, min: field.minimum, max: field.maximum, readOnly: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) })
);
}
return e("input", { type: "text", value, readOnly: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) });
}
function rawFileView(e: ReactLike["createElement"], file: SCUMLogicalFile, currentContent: string, rawDraft: Record<string, string>, setRawDraft: StateSetter<Record<string, string>>, snapshot: SCUMFileReadSnapshot | null, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) {
if (snapshot?.state !== "ready") {
const message = snapshot?.reason ?? (file.kind === "config" ? "尚未读取此配置文件的受控原文。" : "尚未读取此日志文件的受控内容。");
return e("div", { className: "file-workbench-raw" }, e("p", { className: "page-status" }, message), snapshot?.jobId ? e("small", null, `读取任务 ${snapshot.jobId}`) : null);
}
const rawValue = rawDraft[file.key] ?? currentContent;
const editable = file.kind === "config" && canFilesWrite && file.editable !== false;
if (file.kind === "log") {
return e("div", { className: "file-workbench-raw" }, e("pre", { className: "runtime-task-log" }, rawValue), e("p", { className: "page-status" }, `日志原文只读 · ${contentLineCount(rawValue)} 行 · ${snapshot.sizeBytes ?? 0} B`));
}
return e("div", { className: "file-workbench-raw" },
e("textarea", { className: "file-workbench-raw-editor", value: rawValue, readOnly: !editable, spellCheck: false, "aria-label": `${file.label} 原文模式编辑`, onChange: (event: { target: { value: string } }) => setRawDraft((current) => ({ ...current, [file.key]: event.target.value })) }),
e("div", { className: "console-row-actions file-workbench-actions" },
e("button", { type: "button", className: "icon-command", disabled: !editable || rawValue === currentContent, onClick: () => onPreview("source", rawValue, currentContent) }, "预览改动"),
e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "source", onClick: onWrite }, "提交写入")
),
preview && preview.mode === "source" ? diffPreview(e, preview) : null,
e("p", { className: "page-status" }, editable ? "原文模式会整文件写入声明 file key;提交前请先预览差异。" : "原文配置当前只读。")
);
}
function diffPreview(e: ReactLike["createElement"], preview: Exclude<PreviewState, null>) {
return e("section", { className: "file-workbench-diff", "aria-label": "文件改动预览" },
e("div", { className: "console-row" }, e("span", null, "差异预览"), e("strong", null, preview.summary)),
e("pre", { className: "runtime-task-log" }, preview.lines.map((line) => `${line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "} ${line.text}`).join("\n"))
);
}
function usePluginState<T>(react: ReactLike, initialState: T): [T, StateSetter<T>] {
return react.useState ? react.useState(initialState) : [initialState, () => undefined];
}
function normalizeWorkspace(workspace?: SCUMFeatureWorkspace): NormalizedWorkspace {
const directories = workspace?.directories?.length ? workspace.directories : fallbackDirectories;
const files = workspace?.files?.length ? workspace.files : fallbackFiles;
const configFields = workspace?.configFields?.length ? workspace.configFields : configurationCatalog;
return { defaultDirectoryKey: workspace?.defaultDirectoryKey && directories.some((directory) => directory.key === workspace.defaultDirectoryKey) ? workspace.defaultDirectoryKey : directories[0]?.key ?? "scum-config", directories, files, configFields };
}
function initialFileKey(workspace: NormalizedWorkspace, directoryKey: string): string {
return workspace.files.find((file) => file.directoryKey === directoryKey && file.kind === "config")?.key ?? workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? workspace.files[0]?.key ?? "";
}
function directoryRelativePath(key: string): string | undefined { return directoryRelativePaths[key]; }
function controlLabel(field: SCUMConfigField): string {
const range = field.minimum !== undefined || field.maximum !== undefined ? `范围 ${field.minimum ?? "不限"}-${field.maximum ?? "不限"}` : "";
const label = field.control === "boolean" ? "是/否选择" : field.control === "number" || field.control === "port" ? "滑动输入" : "文本填空";
return [label, range].filter(Boolean).join(" · ");
}
function normalizeBoolean(value: string): boolean {
return /^(true|1|yes|on)$/i.test(String(value).trim());
}
function parseIniAssignments(content: string): Record<string, string> {
const values: Record<string, string> = {};
for (const line of content.split("\n")) {
const parsed = parseIniAssignment(line);
if (parsed) values[parsed.key] = parsed.value;
}
return values;
}
function unknownIniAssignments(content: string, fields: readonly SCUMConfigField[]): Array<{ key: string; value: string; index: number }> {
const known = new Set(fields.map((field) => field.configKey));
const unknown: Array<{ key: string; value: string; index: number }> = [];
content.split("\n").forEach((line, index) => {
const parsed = parseIniAssignment(line);
if (parsed && !known.has(parsed.key)) unknown.push({ ...parsed, index });
});
return unknown;
}
function parseIniAssignment(line: string): { key: string; value: string } | null {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";") || trimmed.startsWith("[")) return null;
const separator = line.indexOf("=");
if (separator < 1) return null;
return { key: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
}
function composeIniContent(content: string, fields: readonly SCUMConfigField[], draft: Record<string, string>): string {
const byField = new Map(fields.map((field) => [field.key, field]));
const changes = new Map<string, string>();
for (const [fieldKey, value] of Object.entries(draft)) {
const field = byField.get(fieldKey);
if (field) changes.set(field.configKey, value);
}
if (changes.size === 0) return content;
const applied = new Set<string>();
const lines = content.split("\n").map((line) => {
const parsed = parseIniAssignment(line);
if (!parsed || !changes.has(parsed.key)) return line;
applied.add(parsed.key);
return `${line.slice(0, line.indexOf("=") + 1)}${changes.get(parsed.key) ?? ""}`;
});
for (const [key, value] of changes) {
if (!applied.has(key)) lines.push(`${key}=${value}`);
}
return content.endsWith("\n") ? lines.join("\n") : lines.join("\n").replace(/\n$/, "");
}
function decodeRawContent(content: string, encoding: RawEncoding): string {
if (encoding === "utf-8") return content;
const bytes = Uint8Array.from(Array.from(content), (char) => char.charCodeAt(0) & 0xff);
if (typeof TextDecoder !== "undefined") return new TextDecoder("utf-16le").decode(bytes);
let decoded = "";
for (let index = 0; index < bytes.length; index += 2) decoded += String.fromCharCode(bytes[index] | ((bytes[index + 1] ?? 0) << 8));
return decoded;
}
function buildSimpleDiff(current: string, proposed: string): { summary: string; lines: readonly DiffLine[] } {
const currentLines = current.split("\n");
const proposedLines = proposed.split("\n");
const max = Math.max(currentLines.length, proposedLines.length);
const lines: DiffLine[] = [];
let added = 0;
let removed = 0;
for (let index = 0; index < max; index += 1) {
const before = currentLines[index];
const after = proposedLines[index];
if (before === after) {
if (before !== undefined) lines.push({ kind: "same", text: before });
continue;
}
if (before !== undefined) { removed += 1; lines.push({ kind: "removed", text: before }); }
if (after !== undefined) { added += 1; lines.push({ kind: "added", text: after }); }
}
return { summary: `+${added} / -${removed} 行变更`, lines };
}
function contentLineCount(content: string): number {
return content ? content.split("\n").length : 0;
}
@@ -3,11 +3,11 @@ import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMSta
// These are safe fallback allowlists. A Companion schema probe may narrow them // These are safe fallback allowlists. A Companion schema probe may narrow them
// per server, but a game version never enables or disables a feature. // per server, but a game version never enables or disables a feature.
export const configurationCatalog: readonly SCUMConfigField[] = [ export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" }, { key: "server-name", fileKey: "scum-server-settings", 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: "7779", restartImpact: "restart-required" }, { key: "game-port", fileKey: "scum-server-settings", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" },
{ key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", 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", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" }, { key: "max-players", fileKey: "scum-server-settings", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" },
{ key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" } { key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
]; ];
export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }]; export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }]; export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }];
@@ -373,11 +373,11 @@
"dependencyPolicy": "required", "dependencyPolicy": "required",
"approvalRequired": ["disable", "rollback", "retire"] "approvalRequired": ["disable", "rollback", "retire"]
}, },
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.2", "bundleIntegritySha256": "sha256:3b39507d1471f8d62d25001a11b43c664dbb5a5bef91ed6944b512e6e60099a7", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"] }], "pages": [{ "key": "files-config", "title": "文件管理", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read"], "bridgeActions": ["server.instances.read", "files.request", "logs.query"], "featureKeys": ["config.manage"] }],
"fileWorkspace": { "fileWorkspace": {
"defaultDirectoryKey": "scum-config", "defaultDirectoryKey": "scum-config",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], "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" }], "files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-game-config", "directoryKey": "scum-config", "label": "Game.ini", "kind": "config" }, { "key": "scum-engine-config", "directoryKey": "scum-config", "label": "Engine.ini", "kind": "config" }, { "key": "scum-game-user-settings", "directoryKey": "scum-config", "label": "GameUserSettings.ini", "kind": "config" }, { "key": "scum-admin-log", "directoryKey": "scum-logs", "label": "Admin.log", "kind": "log", "streamKey": "scum.admin" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "Chat.log", "kind": "log", "streamKey": "scum.chat" }, { "key": "scum-kill-log", "directoryKey": "scum-logs", "label": "Kill.log", "kind": "log", "streamKey": "scum.kill" }, { "key": "scum-login-log", "directoryKey": "scum-logs", "label": "Login.log", "kind": "log", "streamKey": "scum.login" }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "Server.log", "kind": "log", "streamKey": "scum.server" }],
"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": "7779", "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": "128", "restartImpact": "restart-required" }, { "key": "welcome-message", "fileKey": "scum-server-settings", "configKey": "WelcomeMessage", "label": "欢迎消息", "description": "登录成功后由已声明的服务器扩展显示给玩家。", "control": "text", "defaultValue": "", "restartImpact": "none" }] "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": "7779", "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": "128", "restartImpact": "restart-required" }, { "key": "welcome-message", "fileKey": "scum-server-settings", "configKey": "WelcomeMessage", "label": "欢迎消息", "description": "登录成功后由已声明的服务器扩展显示给玩家。", "control": "text", "defaultValue": "", "restartImpact": "none" }]
}, },
"ai": { "ai": {
@@ -1,6 +1,6 @@
import { renderSCUMFeaturePage } from "../features/page.js"; import { renderSCUMFeaturePage } from "../features/page.js";
import type { SCUMFeatureWorkspace } from "../features/contracts.js"; import type { SCUMFeatureWorkspace } from "../features/contracts.js";
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.2", integritySha256: "sha256:3b39507d1471f8d62d25001a11b43c664dbb5a5bef91ed6944b512e6e60099a7" }; export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2" };
export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined }); } export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined, workspaceActions: input.workspaceActions }); }
+14
View File
@@ -217,6 +217,10 @@ describe("plugin manifest validation", () => {
expect(installScript).toContain("steamcmd\\steamapps\\common\\SCUM Server"); expect(installScript).toContain("steamcmd\\steamapps\\common\\SCUM Server");
expect(installScript).toContain(".scum-exe-path"); expect(installScript).toContain(".scum-exe-path");
expect(startScript).toContain(".scum-exe-path"); expect(startScript).toContain(".scum-exe-path");
expect(startScript).toContain("call :install_or_update");
expect(startScript).toContain("scum-install-update.cmd");
expect(startScript).toContain("SCUM executable was not found. Running plugin install/update script.");
expect(startScript).not.toContain("SCUM.log");
expect(startScript).toContain("%SERVER_INSTALL_DIR_WINDOWS%\\%SERVER_EXECUTABLE_REF:/=\\%"); expect(startScript).toContain("%SERVER_INSTALL_DIR_WINDOWS%\\%SERVER_EXECUTABLE_REF:/=\\%");
expect(startScript).toContain("steamcmd\\steamapps\\common\\SCUM Server"); expect(startScript).toContain("steamcmd\\steamapps\\common\\SCUM Server");
expect(startScript).toContain("SCUM_WORKDIR"); expect(startScript).toContain("SCUM_WORKDIR");
@@ -438,6 +442,12 @@ describe("plugin manifest validation", () => {
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>; pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>;
}; };
pages: Array<{ key: string }>; pages: Array<{ key: string }>;
fileWorkspace?: {
defaultDirectoryKey: string;
directories: Array<{ key: string; label: string; scope: string }>;
files: Array<{ key: string; directoryKey: string; label: string; kind: string; streamKey?: string; editable?: boolean }>;
configFields: Array<{ key: string; fileKey: string; configKey: string; label: string }>;
};
runtimeProfiles?: { runtimeProfiles?: {
lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>; lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>;
logSources?: Array<{ key: string }>; logSources?: Array<{ key: string }>;
@@ -477,6 +487,10 @@ describe("plugin manifest validation", () => {
"maintenance.prepare" "maintenance.prepare"
])); ]));
expect(manifest.pages.map((page) => page.key)).toContain("files-config"); expect(manifest.pages.map((page) => page.key)).toContain("files-config");
expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config");
expect(manifest.fileWorkspace?.directories.map((directory) => directory.key)).toEqual(["scum-config", "scum-logs"]);
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-game-config", "scum-engine-config", "scum-game-user-settings", "scum-admin-log", "scum-chat-log", "scum-kill-log", "scum-login-log", "scum-server-log"]));
expect(manifest.fileWorkspace?.configFields.every((field) => field.fileKey === "scum-server-settings")).toBe(true);
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command"); expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-events"])); expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-events"]));
}); });
+80 -26
View File
@@ -1,10 +1,15 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js"; import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
describe("SCUM plugin feature module", () => { describe("SCUM plugin feature module", () => {
it("owns runtime allowlists without a version gate", () => { it("owns runtime allowlists without a version gate", () => {
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message"); expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
@@ -46,35 +51,84 @@ describe("SCUM plugin feature module", () => {
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false }); expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
}); });
it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => { it("renders the compact two-level file management workbench without legacy stacked panels", () => {
const nodes: string[] = []; const buttons = new Map<string, boolean>(); const view = renderAndCollect();
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } }; expect(view.nodes).toContain("section:SCUM 文件管理");
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true, features: [{ key: "config.manage", available: true }, { key: "player.intelligence", available: false, reason: "no event producer" }, { key: "reward.delivery", available: false, reason: "no delivery handler" }, { key: "state.patch", available: false, reason: "no state handler" }, { key: "vehicle.spawn", available: false, reason: "no vehicle handler" }, { key: "trajectory.collect", available: false, reason: "no position producer" }] }, workspace: {} }); expect(view.nodes).toContain("aside:SCUM 文件两级菜单");
expect(nodes).toContain("section:SCUM 配置工作台"); expect(view.nodes).toContain("article:文件 ServerSettings.ini");
expect(nodes).toContain("section:SCUM 玩家档案"); expect(view.texts.join("\n")).toContain("/SCUM/Saved/Config/WindowsServer");
expect(nodes).toContain("section:SCUM 礼物与通知"); expect(view.texts.join("\n")).toContain("/SCUM/Saved/SaveFiles/Logs");
expect(nodes).toContain("section:SCUM 受控状态修改"); expect(view.texts).toContain("ServerSettings.ini");
expect(nodes).toContain("section:SCUM 受限载具生成"); expect(view.texts).toContain("Game.ini");
expect(nodes).toContain("section:SCUM 地图轨迹"); expect(view.texts).toContain("配置表单");
expect(buttons.get("读取配置")).toBe(false); expect(view.texts).toContain("原文模式");
expect(buttons.get("查询玩家")).toBe(true); expect(view.buttons.find((button) => button.label === "读取文件")?.disabled).toBe(false);
expect(buttons.get("申请投递")).toBe(true); expect(view.buttons.find((button) => button.label === "刷新结果")?.disabled).toBe(false);
expect(buttons.get("创建修改申请")).toBe(true); for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText);
expect(buttons.get("生成载具")).toBe(true);
expect(buttons.get("读取轨迹")).toBe(true);
}); });
it("fails closed when a generally online Companion omits feature availability", () => { it("keeps declared log files in read-only raw view with encoding controls", () => {
const buttons = new Map<string, boolean>(); const view = renderAndCollect({ directoryKey: "scum-logs", fileKey: "scum-admin-log" });
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } }; expect(view.nodes).toContain("article:文件 Admin.log");
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} }); expect(view.texts).toContain("UTF-8");
for (const label of ["读取配置", "查询玩家", "申请投递", "创建修改申请", "生成载具", "读取轨迹"]) expect(buttons.get(label)).toBe(true); expect(view.texts).toContain("UTF-16 LE");
expect(view.texts.join("\n")).toContain("尚未读取此日志文件的受控内容。");
expect(view.nodes.some((node) => node.startsWith("textarea:"))).toBe(false);
expect(view.texts).not.toContain("配置表单");
expect(view.texts).not.toContain("提交写入");
}); });
it("opens vehicle spawning only for the declared Companion handler", () => { it("renders current config values, unknown fields, encoding switch, and guarded write actions after a read", () => {
const buttons = new Map<string, boolean>(); const view = renderAndCollect({ snapshot: { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "ServerName=Qinghuo\nMaxPlayers=96\nCustomKey=keep\n", version: 3, checksum: "sha256:cfg", sizeBytes: 48 } });
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } }; expect(view.texts).toContain("UTF-8");
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.command"] }, availability: { available: true, features: [{ key: "vehicle.spawn", available: true }] }, workspace: {} }); expect(view.texts).toContain("UTF-16 LE");
expect(buttons.get("生成载具")).toBe(false); expect(view.texts).toContain("未建模配置项");
expect(view.texts).toContain("CustomKey");
expect(pageSource).toContain('e("option", { value: "true" }, "是")');
expect(pageSource).toContain('type: "range"');
expect(view.buttons.find((button) => button.label === "预览改动")?.disabled).toBe(true);
expect(view.buttons.find((button) => button.label === "提交写入")?.disabled).toBe(true);
});
it("loads snapshots on selection or manual refresh without interval polling", () => {
expect(pageSource).toContain("getFileSnapshot(selectedFile.key)");
expect(pageSource).toContain("刷新结果");
expect(pageSource).not.toContain("setInterval");
expect(pageSource).not.toContain("setTimeout");
}); });
}); });
function renderAndCollect(options: { snapshot?: Record<string, unknown>; permissions?: string[]; directoryKey?: string; fileKey?: string } = {}) {
const nodes: string[] = [];
const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean }> = [];
const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); };
let stateCall = 0;
const react = {
createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => {
if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`);
children.forEach(collectText);
if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled) });
return { type, props, children };
},
useEffect: () => undefined,
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => {
stateCall += 1;
if (stateCall === 1 && options.directoryKey) return [options.directoryKey as T, () => undefined];
if (stateCall === 2 && options.fileKey) return [options.fileKey as T, () => undefined];
if (stateCall === 7 && options.snapshot) return [options.snapshot as T, () => undefined];
return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined];
}
};
renderPluginPage(react, {
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.files.read", "server.files.write", "server.logs.read"] },
availability: { available: true, features: [{ key: "config.manage", available: true }] },
workspace: {},
workspaceActions: {
requestFile: async (fileKey: string) => ({ status: "queued", message: fileKey }),
getFileSnapshot: async (fileKey: string) => ({ serverInstanceId: "server-1", pluginId: "game.scum", key: fileKey, state: "not-read" }),
writeFile: async (fileKey: string) => ({ status: "queued", message: fileKey })
}
});
return { nodes, texts, buttons };
}