Add SCUM file management workbench
This commit is contained in:
@@ -14,16 +14,19 @@ The SCUM plugin already declares runtime config mappings and log sources, while
|
||||
**Non-Goals:**
|
||||
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- [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.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Capabilities
|
||||
@@ -22,4 +24,4 @@ SCUM operators currently reach separate overview, configuration, and semantic-lo
|
||||
## Impact
|
||||
|
||||
- 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.
|
||||
|
||||
+44
@@ -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
|
||||
- **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
|
||||
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.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.4 Add a bounded redacted Platform projection for completed declared-file read results.
|
||||
|
||||
## 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.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.4 Render the SCUM workbench embedded in server detail and keep unrelated Companion feature panels out of the file-management tab.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Add/update focused backend, plugin, route, contract, and component tests.
|
||||
- [x] 3.2 Run strict OpenSpec validation, relevant backend/plugin/frontend checks, and scripts/check-structure.sh.
|
||||
- [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, browser verification, and scripts/check-structure.sh.
|
||||
|
||||
@@ -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/events", h.serverLogEvents)
|
||||
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/approve", h.serverInstanceConfigApprove)
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
// @Summary Preview server config diff
|
||||
// @Description Compares current logical server config with proposed content without dispatching a write job.
|
||||
|
||||
@@ -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) {
|
||||
releaseBuilds := make(chan struct{})
|
||||
t.Cleanup(func() { close(releaseBuilds) })
|
||||
|
||||
@@ -1030,6 +1030,22 @@ type FileOperationDispatchResult struct {
|
||||
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 {
|
||||
MaxJobs int
|
||||
RunningJobs int
|
||||
|
||||
@@ -114,10 +114,13 @@ Installed `GamePlugin` records persist the validated manifest `runtimeProfiles`
|
||||
Plugin lifecycle assets are registered as manifest-declared files plus a
|
||||
content-bearing registration payload. Platform packages those assets into
|
||||
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
|
||||
install/start job. 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.
|
||||
and helper refs such as `bin/scum-install-update.cmd` are available before the
|
||||
first bootstrap job. Guided deployments whose selected lifecycle profile
|
||||
supports `process.start` may bootstrap through the plugin-owned start action so
|
||||
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.
|
||||
|
||||
@@ -161,8 +164,8 @@ Dependency checks and installs are queued as run jobs with logical `dependencies
|
||||
|
||||
Lifecycle workflow jobs use fixed capabilities:
|
||||
|
||||
- `process.install`: dispatched by server create workflow and projects successful terminal results to `ready`.
|
||||
- `process.start`: dispatched by server start workflow and projects successful terminal results to `running`.
|
||||
- `process.install`: dispatched by install bootstrap workflows and projects successful terminal results to `ready`.
|
||||
- `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`.
|
||||
- `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.
|
||||
|
||||
@@ -703,6 +703,20 @@ type FileOperationDispatchResponse struct {
|
||||
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 {
|
||||
MaxJobs int `json:"maxJobs"`
|
||||
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 {
|
||||
endpoint = domain.CopyRunEndpoint(endpoint)
|
||||
return RunEndpointResponse{
|
||||
|
||||
@@ -6,11 +6,12 @@ when a server uses `custom-command` mode and Run executes the operator-reviewed
|
||||
argv-oriented command inside its local policy.
|
||||
|
||||
Guided or existing-server game setup is plugin-owned. Platform dispatches the
|
||||
plugin-declared lifecycle action reference, such as `actions/install.json`, and
|
||||
may include generic deployment context (`mode`, `profileKey`, `serverRoot`, and
|
||||
`createInputs`) so the plugin action can resolve its own behavior. Platform does
|
||||
not create a game-specific `serverDeploymentPlan`, and SCUM no longer requires a
|
||||
`deployment.scum.v1` capability.
|
||||
plugin-declared lifecycle action reference, such as `actions/install.json` or a
|
||||
supervised `actions/start.json` bootstrap, and may include generic deployment
|
||||
context (`mode`, `profileKey`, `serverRoot`, and `createInputs`) so the plugin
|
||||
action can resolve its own behavior. Platform does not create a game-specific
|
||||
`serverDeploymentPlan`, and SCUM no longer requires a `deployment.scum.v1`
|
||||
capability.
|
||||
|
||||
## Capability and policy
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ A server instance is created from one installed game management plugin and bound
|
||||
|
||||
### States
|
||||
|
||||
- `draft`: instance record exists but install job has not completed.
|
||||
- `installing`: run install job is active.
|
||||
- `ready`: install succeeded and the server can start.
|
||||
- `draft`: instance record exists but the first bootstrap job has not completed.
|
||||
- `installing`: the first plugin-owned bootstrap job is active.
|
||||
- `ready`: install/bootstrap succeeded without starting a supervised process, and the server can start.
|
||||
- `running`: server process is running.
|
||||
- `stopped`: server process is stopped.
|
||||
- `failed`: last lifecycle operation failed.
|
||||
@@ -36,7 +36,7 @@ A server instance is created from one installed game management plugin and bound
|
||||
|
||||
## 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.
|
||||
- `stop`: dispatch process stop job through the bound run endpoint.
|
||||
- `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/{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/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}/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.
|
||||
|
||||
@@ -130,7 +130,8 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
|
||||
|
||||
// queueManagedGuidedDeploymentAfterRegistration advances only a newly-created,
|
||||
// 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 {
|
||||
if hello.ComponentKind != domain.DistributionComponentRun || strings.TrimSpace(hello.ServerInstanceID) == "" {
|
||||
return nil
|
||||
|
||||
@@ -291,16 +291,16 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
|
||||
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
|
||||
stored, err := svc.GetServerInstance(guided.Instance.ID)
|
||||
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})
|
||||
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall {
|
||||
t.Fatalf("expected one automatic install job, jobs=%+v err=%v", jobs, err)
|
||||
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 supervised start job, jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
|
||||
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
|
||||
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{
|
||||
@@ -319,11 +319,11 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
|
||||
registerDedicatedRunForTest(t, svc, generatedRunDraft, plugin.ID)
|
||||
storedGenerated, err := svc.GetServerInstance(generatedRunDraft.ID)
|
||||
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})
|
||||
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall || jobs[0].ExecutionInput.WorkspaceScope != "local" {
|
||||
t.Fatalf("expected one scoped automatic generated Run install job, jobs=%+v err=%v", jobs, err)
|
||||
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].ExecutionInput.WorkspaceScope != "local" {
|
||||
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"}})
|
||||
@@ -337,7 +337,7 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T) {
|
||||
func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedStart(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin := scumDeploymentTestPlugin()
|
||||
plugin.Name = "SCUM"
|
||||
@@ -424,22 +424,22 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
|
||||
|
||||
stored, err := svc.GetServerInstance(instance.ID)
|
||||
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})
|
||||
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]
|
||||
if job.Capability != domain.LifecycleCapabilityInstall || job.TargetKey != "actions/install.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("expected SCUM install job with scoped plugin action and generic deployment inputs, job=%+v", job)
|
||||
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 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" {
|
||||
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}})
|
||||
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.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)
|
||||
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 start action, claim=%+v err=%v", claim, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ type Core interface {
|
||||
ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error)
|
||||
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
|
||||
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
|
||||
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
|
||||
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
|
||||
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
|
||||
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
|
||||
@@ -1883,6 +1884,120 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan
|
||||
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 = ©
|
||||
}
|
||||
continue
|
||||
}
|
||||
if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) {
|
||||
copy := job
|
||||
pending = ©
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if request.Key == "" {
|
||||
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") {
|
||||
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
|
||||
message := "file read queued"
|
||||
@@ -2054,6 +2175,22 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
}), 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 {
|
||||
binding, err := svc.runtimeBindingForServer(serverInstanceID)
|
||||
if err != nil {
|
||||
|
||||
@@ -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) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
|
||||
@@ -85,8 +85,8 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
|
||||
}
|
||||
|
||||
// deployServerInstance is the platform-owned transition from a saved deployment
|
||||
// definition to one fenced install job. Callers must already have established
|
||||
// the authority to act for the server.
|
||||
// definition to one fenced plugin-owned bootstrap job. Callers must already
|
||||
// have established the authority to act for the server.
|
||||
func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||
if err := validator.ValidateServerLifecycleCommand(command); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
@@ -118,16 +118,17 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil {
|
||||
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, command.IdempotencyKey)
|
||||
job, err := svc.dispatchLifecycleJob(instance, bootstrapAction, command.IdempotencyKey)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestCoreServiceUpdatesDeploymentWhileServerIsActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) {
|
||||
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedSupervisedStartAction(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
runHello := validRunControlHello()
|
||||
runHello.RunEndpointID = "run-scum-guided"
|
||||
@@ -132,15 +132,15 @@ func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testin
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("expected plugin-owned install action without SCUM deployment plan, job=%+v", created.Job)
|
||||
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 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" {
|
||||
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}})
|
||||
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("claimed SCUM install must use plugin action without SCUM plan, claim=%+v err=%v", claim, err)
|
||||
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/start.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
result, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||
@@ -215,11 +215,11 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||
@@ -232,8 +232,8 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
|
||||
if err != nil {
|
||||
t.Fatalf("get ready guided instance: %v", err)
|
||||
}
|
||||
if instance.State != domain.ServerInstanceStateReady {
|
||||
t.Fatalf("expected guided plugin lifecycle success to mark ready, got %+v", instance)
|
||||
if instance.State != domain.ServerInstanceStateRunning {
|
||||
t.Fatalf("expected guided plugin lifecycle success to mark running, got %+v", instance)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,13 +109,17 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
|
||||
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
@@ -358,6 +362,20 @@ func lifecycleProcessLogSources(profiles domain.GamePluginRuntimeProfiles) []dom
|
||||
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 {
|
||||
if deployment.Mode != "" {
|
||||
return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"}
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
CurrentUserResponse,
|
||||
DependencyCatalogResponse,
|
||||
DependencyJobRequest,
|
||||
DeclaredFileReadSnapshotResponse,
|
||||
FileOperationDispatchRequest,
|
||||
FileOperationDispatchResponse,
|
||||
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> {
|
||||
return this.request<LogStreamListResponse>("/log-streams");
|
||||
}
|
||||
|
||||
@@ -1455,6 +1455,20 @@ export interface FileOperationDispatchResponse {
|
||||
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 {
|
||||
id: string;
|
||||
serverInstanceId: string;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -326,6 +326,7 @@ describe("first-party console pages", () => {
|
||||
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
|
||||
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
|
||||
expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000");
|
||||
expect(serverLiveOperationsSource).not.toContain("queryLogStream(");
|
||||
expect(serverLiveOperationsSource).not.toContain("SaveWorld");
|
||||
});
|
||||
|
||||
|
||||
@@ -95,4 +95,11 @@ describe("PluginPageHostPage", () => {
|
||||
expect(hostSource).toContain("loadPluginPageBundle");
|
||||
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]);");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ArrowLeft, PlugZap } from "lucide-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 type { GamePluginResponse } from "../api/types";
|
||||
@@ -8,7 +8,8 @@ import { PageFrame } from "../components/PageFrame";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
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";
|
||||
|
||||
type PluginPageState =
|
||||
@@ -18,16 +19,19 @@ type PluginPageState =
|
||||
|
||||
interface PluginPageHostPageProps extends PageComponentProps {
|
||||
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 routeKey = params.routeKey ?? "";
|
||||
const serverId = params.serverId ?? "";
|
||||
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 [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 () => {
|
||||
if (!pluginId || !routeKey) {
|
||||
@@ -52,19 +56,107 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
||||
|
||||
const readyPlugin = state.status === "ready" ? state.plugin : undefined;
|
||||
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(() => {
|
||||
let active = true;
|
||||
if (!declaredBundlePage) return () => { active = false; };
|
||||
if (!loadableBundlePage) return () => { active = false; };
|
||||
globalThis.__PLUGIN_PAGE_REACT__ = React;
|
||||
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; }; }
|
||||
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => {
|
||||
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 可用性。" }); });
|
||||
return () => { active = false; };
|
||||
}, [declaredBundlePage, serverId]);
|
||||
}, [bundleLoadKey, loadableBundlePage, serverId]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <LoadingState label="正在加载插件页面声明…" />;
|
||||
@@ -77,13 +169,18 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
||||
if (!page) {
|
||||
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
|
||||
}
|
||||
const manifestContract = pluginBridgeManifestContractFromResponse(state.plugin);
|
||||
const hostContext = createPluginBridgeHostContext({
|
||||
plugin: manifestContract,
|
||||
routeKey,
|
||||
serverInstanceId: serverId || undefined,
|
||||
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
|
||||
});
|
||||
if (!hostContext) {
|
||||
return <ErrorState title="插件页面不可用" reason="插件页面上下文初始化失败。" />;
|
||||
}
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="console-page">
|
||||
<PageFrame
|
||||
@@ -110,7 +207,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
||||
</section>
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,15 @@ describe("ServerDetailPage config write approval", () => {
|
||||
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", () => {
|
||||
expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)");
|
||||
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)");
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
ServerMemberResponse,
|
||||
ServerMetricsResponse,
|
||||
RuntimeBindingResponse,
|
||||
ServerDeploymentResponse,
|
||||
ServerDeploymentResponse,
|
||||
ServerRuntimeActionsResponse,
|
||||
MetricSampleResponse,
|
||||
RemoteAdapterDeclarationResponse
|
||||
@@ -73,6 +73,7 @@ import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePlugi
|
||||
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
|
||||
import { cx } from "../utils/classes";
|
||||
import { stateLabel, statusClass } from "./ServersPage";
|
||||
import { PluginPageHostPage } from "./PluginPageHostPage";
|
||||
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 };
|
||||
@@ -81,7 +82,8 @@ const defaultConfigKey = "server.properties";
|
||||
const serverDetailRefreshMs = 5000;
|
||||
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 [section, setSection] = useState<ServerDetailSection>("logs");
|
||||
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
|
||||
@@ -94,7 +96,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
|
||||
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
|
||||
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
|
||||
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
||||
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
||||
const [liveLogOpen, setLiveLogOpen] = useState(false);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||
@@ -107,7 +109,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
}
|
||||
setInstance({ status: "loading" });
|
||||
try {
|
||||
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
|
||||
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
|
||||
platformApiClient.getServerInstance(serverId),
|
||||
platformApiClient.listGamePlugins(),
|
||||
platformApiClient.listJobs(serverId),
|
||||
@@ -119,10 +121,10 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
.getServerRuntimeBinding(serverId)
|
||||
.then((data): LoadState<RuntimeBindingResponse> => ({ status: "ready", data }))
|
||||
.catch((error): LoadState<RuntimeBindingResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })),
|
||||
platformApiClient
|
||||
.getServerDeployment(serverId)
|
||||
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
|
||||
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
|
||||
platformApiClient
|
||||
.getServerDeployment(serverId)
|
||||
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
|
||||
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
|
||||
platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })),
|
||||
platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })),
|
||||
platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 }))
|
||||
@@ -132,7 +134,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
setJobs(jobResponse.items);
|
||||
setRuntimeActions(runtimeResponse);
|
||||
setRuntimeBinding(bindingResponse);
|
||||
setDeployment(deploymentResponse);
|
||||
setDeployment(deploymentResponse);
|
||||
setMetricHistory(metricHistoryResponse.items);
|
||||
setBackups(backupResponse.items);
|
||||
setRemoteAdapters(adapterResponse.items);
|
||||
@@ -150,7 +152,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
setArtifacts([]);
|
||||
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
|
||||
setRuntimeBinding({ status: "error", reason: "运行配置加载失败" });
|
||||
setDeployment({ status: "error", reason: "部署定义加载失败" });
|
||||
setDeployment({ status: "error", reason: "部署定义加载失败" });
|
||||
setMetricHistory([]);
|
||||
setBackups([]);
|
||||
setRemoteAdapters([]);
|
||||
@@ -267,7 +269,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
aria-current={section === entry.id ? "page" : undefined}
|
||||
onClick={() => setSection(entry.id)}
|
||||
>
|
||||
{entry.label}
|
||||
{serverDetailSectionLabel(entry)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -330,12 +332,12 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
aria-current={section === entry.id ? "page" : undefined}
|
||||
onClick={() => setSection(entry.id)}
|
||||
>
|
||||
{entry.label}
|
||||
{serverDetailSectionLabel(entry, instance.data.pluginId)}
|
||||
</button>
|
||||
))}
|
||||
</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 === "runtime" && (
|
||||
<RuntimeBindingSection
|
||||
@@ -409,6 +411,24 @@ function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
|
||||
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 {
|
||||
instance: ServerInstanceResponse;
|
||||
session: PageComponentProps["session"];
|
||||
@@ -1999,7 +2019,7 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
|
||||
requestId: `web:bridge:${serverId}:${plugin.id}:${action}:${Date.now()}`,
|
||||
action,
|
||||
aiPurpose: action === "ai.invoke" ? plugin.aiPurposes[0] : undefined,
|
||||
payload: bridgePayloadForAction(action, serverInstance, artifacts[0])
|
||||
payload: bridgePayloadForAction(action, plugin, serverInstance, artifacts[0])
|
||||
});
|
||||
setPendingAction(null);
|
||||
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") {
|
||||
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) {
|
||||
return { artifactId: artifact.id };
|
||||
|
||||
@@ -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: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)}
|
||||
.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}
|
||||
.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}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type { PluginBridgeHostContext, PluginPageContract } from "../contracts/pluginBridge";
|
||||
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
|
||||
|
||||
export interface PluginPageBundleModule {
|
||||
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 }> };
|
||||
|
||||
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。");
|
||||
const match = Object.entries(pageBundles).find(([path]) => path.endsWith(`/${page.bundleKey}/page-bundle/index.ts`));
|
||||
if (!match) throw new Error("已声明的插件页面 bundle 未安装。");
|
||||
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 完整性或版本校验失败。");
|
||||
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") {
|
||||
|
||||
@@ -10,22 +10,44 @@ if "%SERVER_CREATE_GAMEPORT%"=="" set "SERVER_CREATE_GAMEPORT=7779"
|
||||
if "%SERVER_CREATE_MAXPLAYERS%"=="" set "SERVER_CREATE_MAXPLAYERS=128"
|
||||
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"
|
||||
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 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_DIR%..\..\..") do set "SCUM_WORKDIR=%%~fI"
|
||||
if not exist "%SCUM_WORKDIR%" set "SCUM_WORKDIR=%SERVER_ROOT_WINDOWS%"
|
||||
|
||||
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%
|
||||
set "SCUM_START_RESULT=%ERRORLEVEL%"
|
||||
popd
|
||||
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
|
||||
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%"
|
||||
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 SCUMVehicleSpawn = { vehicleCode: 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 = {
|
||||
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 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 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 type { SCUMFeatureWorkspace } from "./contracts.js";
|
||||
import { configurationCatalog } from "./schemas.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 };
|
||||
export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspace?: SCUMFeatureWorkspace };
|
||||
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
|
||||
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) {
|
||||
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");
|
||||
return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" },
|
||||
e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "SCUM 语义、界面和适配器由插件提供;平台仅提供已授权的服务器隔离宿主。")), e("span", { className: "page-status" }, availabilityText(input.availability, scoped))),
|
||||
e("div", { className: "console-row-list" }, e("div", { className: "console-row" }, e("strong", null, "绑定服务器"), e("span", null, input.serverInstanceId ?? "未绑定")), e("div", { className: "console-row" }, e("strong", null, "运行时 schema"), e("span", null, "按受限通道探测")), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))),
|
||||
configurationPanel(e, fields, canRead, canMaintain, featureAvailability(input, "config.manage")),
|
||||
playerPanel(e, canRead, featureAvailability(input, "player.intelligence")),
|
||||
rewardPanel(e, canRead, canCommand, featureAvailability(input, "reward.delivery")),
|
||||
statePanel(e, canRead, canMaintain, featureAvailability(input, "state.patch")),
|
||||
vehicleSpawnPanel(e, vehicleCodes, canCommand, featureAvailability(input, "vehicle.spawn")),
|
||||
trajectoryPanel(e, canRead, featureAvailability(input, "trajectory.collect"))
|
||||
const e = react.createElement;
|
||||
const workspace = normalizeWorkspace(input.workspace);
|
||||
const [selectedDirectoryKey, setSelectedDirectoryKey] = usePluginState(react, workspace.defaultDirectoryKey);
|
||||
const effectiveDirectoryKey = workspace.directories.some((directory) => directory.key === selectedDirectoryKey) ? selectedDirectoryKey : workspace.defaultDirectoryKey;
|
||||
const directoryFiles = workspace.files.filter((file) => file.directoryKey === effectiveDirectoryKey);
|
||||
const defaultFileKey = initialFileKey(workspace, effectiveDirectoryKey);
|
||||
const [selectedFileKey, setSelectedFileKey] = usePluginState(react, defaultFileKey);
|
||||
const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0] ?? workspace.files[0];
|
||||
const selectedFields = selectedFile ? workspace.configFields.filter((field) => field.fileKey === selectedFile.key) : [];
|
||||
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 playerPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 玩家档案" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家、登录与风险信号"), e("p", { className: "provider-id" }, "只展示 Companion 已验证的语义事件;网络关联是按服务器不可逆计算,不上传原始网络值。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "查询玩家")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有玩家读取权限。" : availability.available ? "等待已验证的登录或登出事件。" : availability.reason ?? "没有兼容的事件生产者。")); }
|
||||
function rewardPanel(e: ReactLike["createElement"], canRead: boolean, canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 礼物与通知" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "冻结礼物版本与通知"), e("p", { className: "provider-id" }, "物品投递与通知分离;未知投递结果不会自动重试。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "申请投递")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有礼物读取权限。" : !canCommand ? "当前服务器上下文没有受控投递权限。" : availability.reason ?? "需要已冻结 revision、已验证玩家身份和兼容处理器。")); }
|
||||
function statePanel(e: ReactLike["createElement"], 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 ?? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。")); }
|
||||
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 ?? "当前没有已验证的载具生成处理器。")); }
|
||||
function trajectoryPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 地图轨迹" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家与载具轨迹"), e("p", { className: "provider-id" }, "仅接受插件声明的服务器侧位置与上下车事件源;绝不使用 OCR、截图或桌面自动化。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取轨迹")), e("p", { className: "page-status" }, canRead ? availability.reason ?? "当合法位置源可用时展示采样轨迹。" : "当前服务器上下文没有轨迹读取权限。")); }
|
||||
function featureAvailability(input: SCUMPageContext, key: string): { available: boolean; reason?: string } { const feature = input.featureAvailability?.find((item) => item.key === key); return feature ?? { available: false, reason: "当前服务器没有已验证的 Companion 处理器或事件生产者。" }; }
|
||||
function availabilityText(availability: { available: boolean; reason?: string }, scoped: boolean): string { if (!scoped) return "不可用:插件页面必须绑定服务器。"; return availability.available ? "已声明且已由 Companion 验证" : `不可用:${availability.reason ?? "没有可用的 Companion 处理器或事件生产者"}`; }
|
||||
function navigationPane(e: ReactLike["createElement"], workspace: NormalizedWorkspace, directoryKey: string, directoryFiles: readonly SCUMLogicalFile[], selectedFileKey: string | undefined, onDirectoryChange: (directoryKey: string) => void, onFileSelect: (file: SCUMLogicalFile) => void) {
|
||||
const activeDirectory = workspace.directories.find((directory) => directory.key === directoryKey);
|
||||
const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0];
|
||||
return e("aside", { className: "file-workbench-nav", "aria-label": "SCUM 文件两级菜单" },
|
||||
e("label", { className: "file-workbench-picker" },
|
||||
e("span", null, "目录"),
|
||||
e("select", { value: directoryKey, onChange: (event: { target: { value: string } }) => onDirectoryChange(event.target.value), "aria-label": "选择目录" },
|
||||
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
|
||||
// per server, but a game version never enables or disables a feature.
|
||||
export const configurationCatalog: readonly SCUMConfigField[] = [
|
||||
{ key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" },
|
||||
{ key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" },
|
||||
{ key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" },
|
||||
{ key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" },
|
||||
{ key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
|
||||
{ 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" }
|
||||
];
|
||||
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 }];
|
||||
|
||||
@@ -373,11 +373,11 @@
|
||||
"dependencyPolicy": "required",
|
||||
"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": {
|
||||
"defaultDirectoryKey": "scum-config",
|
||||
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
|
||||
"files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "SCUM Server.log", "kind": "log", "streamKey": "scum.server" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "SCUM Chat.log", "kind": "log", "streamKey": "scum.chat" }],
|
||||
"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" }]
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderSCUMFeaturePage } from "../features/page.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 }); }
|
||||
|
||||
@@ -217,6 +217,10 @@ describe("plugin manifest validation", () => {
|
||||
expect(installScript).toContain("steamcmd\\steamapps\\common\\SCUM Server");
|
||||
expect(installScript).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("steamcmd\\steamapps\\common\\SCUM Server");
|
||||
expect(startScript).toContain("SCUM_WORKDIR");
|
||||
@@ -438,6 +442,12 @@ describe("plugin manifest validation", () => {
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: 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?: {
|
||||
lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>;
|
||||
logSources?: Array<{ key: string }>;
|
||||
@@ -477,6 +487,10 @@ describe("plugin manifest validation", () => {
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
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?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-events"]));
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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 { 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 { 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", () => {
|
||||
it("owns runtime allowlists without a version gate", () => {
|
||||
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 });
|
||||
});
|
||||
|
||||
it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => {
|
||||
const nodes: string[] = []; const buttons = new Map<string, boolean>();
|
||||
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 }; } };
|
||||
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(nodes).toContain("section:SCUM 配置工作台");
|
||||
expect(nodes).toContain("section:SCUM 玩家档案");
|
||||
expect(nodes).toContain("section:SCUM 礼物与通知");
|
||||
expect(nodes).toContain("section:SCUM 受控状态修改");
|
||||
expect(nodes).toContain("section:SCUM 受限载具生成");
|
||||
expect(nodes).toContain("section:SCUM 地图轨迹");
|
||||
expect(buttons.get("读取配置")).toBe(false);
|
||||
expect(buttons.get("查询玩家")).toBe(true);
|
||||
expect(buttons.get("申请投递")).toBe(true);
|
||||
expect(buttons.get("创建修改申请")).toBe(true);
|
||||
expect(buttons.get("生成载具")).toBe(true);
|
||||
expect(buttons.get("读取轨迹")).toBe(true);
|
||||
it("renders the compact two-level file management workbench without legacy stacked panels", () => {
|
||||
const view = renderAndCollect();
|
||||
expect(view.nodes).toContain("section:SCUM 文件管理");
|
||||
expect(view.nodes).toContain("aside:SCUM 文件两级菜单");
|
||||
expect(view.nodes).toContain("article:文件 ServerSettings.ini");
|
||||
expect(view.texts.join("\n")).toContain("/SCUM/Saved/Config/WindowsServer");
|
||||
expect(view.texts.join("\n")).toContain("/SCUM/Saved/SaveFiles/Logs");
|
||||
expect(view.texts).toContain("ServerSettings.ini");
|
||||
expect(view.texts).toContain("Game.ini");
|
||||
expect(view.texts).toContain("配置表单");
|
||||
expect(view.texts).toContain("原文模式");
|
||||
expect(view.buttons.find((button) => button.label === "读取文件")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "刷新结果")?.disabled).toBe(false);
|
||||
for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText);
|
||||
});
|
||||
|
||||
it("fails closed when a generally online Companion omits feature availability", () => {
|
||||
const buttons = new Map<string, boolean>();
|
||||
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 }; } };
|
||||
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} });
|
||||
for (const label of ["读取配置", "查询玩家", "申请投递", "创建修改申请", "生成载具", "读取轨迹"]) expect(buttons.get(label)).toBe(true);
|
||||
it("keeps declared log files in read-only raw view with encoding controls", () => {
|
||||
const view = renderAndCollect({ directoryKey: "scum-logs", fileKey: "scum-admin-log" });
|
||||
expect(view.nodes).toContain("article:文件 Admin.log");
|
||||
expect(view.texts).toContain("UTF-8");
|
||||
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", () => {
|
||||
const buttons = new Map<string, boolean>();
|
||||
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 }; } };
|
||||
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.command"] }, availability: { available: true, features: [{ key: "vehicle.spawn", available: true }] }, workspace: {} });
|
||||
expect(buttons.get("生成载具")).toBe(false);
|
||||
it("renders current config values, unknown fields, encoding switch, and guarded write actions after a read", () => {
|
||||
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 } });
|
||||
expect(view.texts).toContain("UTF-8");
|
||||
expect(view.texts).toContain("UTF-16 LE");
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user