From daa0f330a4c3510b30a6de2d26c0c6b42ad29af7 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 29 Jul 2026 16:04:26 +0800 Subject: [PATCH] feat(scum): add bounded vehicle spawn adapter --- .../design.md | 8 +- .../implementation-blockers.md | 10 ++- .../proposal.md | 1 + .../scum-companion-runtime-adapter/spec.md | 11 +++ .../scum-plugin-feature-ownership/spec.md | 4 + .../tasks.md | 3 +- .../companion/UE4SS_CAPABILITY.md | 12 +++ .../scum-server-plugin/companion/adapters.go | 59 +++++++++++++- .../companion/adapters_test.go | 76 +++++++++++++++++++ .../scum-server-plugin/companion/config.go | 15 +++- .../companion/config_test.go | 16 ++++ .../companion/dispatcher.go | 16 ++++ .../companion/dispatcher_test.go | 5 +- .../scum-server-plugin/features/api.ts | 6 +- .../scum-server-plugin/features/contracts.ts | 2 + .../scum-server-plugin/features/page.ts | 6 +- .../scum-server-plugin/features/schemas.ts | 11 ++- .../examples/scum-server-plugin/manifest.json | 18 ++++- .../scum-server-plugin/page-bundle/index.ts | 2 +- .../bridge/vehicle-spawn.payload.schema.json | 10 +++ .../bridge/vehicle-spawn.result.schema.json | 10 +++ .../schemas/companion/config.schema.json | 4 +- plugins/tests/manifest-validation.test.ts | 13 ++++ plugins/tests/scum-feature-module.test.ts | 19 ++++- 24 files changed, 314 insertions(+), 23 deletions(-) create mode 100644 plugins/examples/scum-server-plugin/companion/config_test.go create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/vehicle-spawn.payload.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/vehicle-spawn.result.schema.json diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/design.md b/openspec/changes/move-scum-feature-ownership-to-plugin/design.md index 661c374..ef89636 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/design.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/design.md @@ -38,10 +38,16 @@ The alternative of metadata-only generic forms is insufficient for the map, play The Companion SHALL run a bounded dispatch loop after registration. It SHALL claim declared commands, validate the exact command schema and SCUM capability/version, execute only a registered handler, and acknowledge/completely report idempotent typed results. It SHALL produce no output containing raw paths, credentials, IPs, database rows, or arbitrary RCON command text. A version-bound typed UE4SS adapter MAY record the exact generated command text in the command's protected audit payload (for example, the fixed `#spawnvehicle ` generated by `vehicle.spawn`); that audit record does not create a browser-visible or arbitrary-command RCON interface. -The adapter SHALL include independent handlers for configuration read/patch, semantic log/event production, `reward.deliver`, `player.notify`, and `game-state.patch`. A handler unavailable for a discovered server version SHALL return an explicit unsupported result; the platform must keep the operation disabled. +The adapter SHALL include independent handlers for configuration read/patch, semantic log/event production, `reward.deliver`, `player.notify`, `game-state.patch`, and the separately bounded `vehicle.spawn`. A handler unavailable for a discovered server version SHALL return an explicit unsupported result; the platform must keep the operation disabled. Direct raw-RCON or raw-SQL dispatch is rejected: legacy code is reference material only and must be translated into narrow typed adapters. +### 3.1 Fixed vehicle-spawn adapter + +`vehicle.spawn` is the sole authorized exception for a generated SCUM command template. Its manifest payload contains exactly one `vehicleCode`, constrained by the plugin's versioned allowlist and identifier pattern. The Companion independently validates the same allowlist, requires its bound server, approval, declared handler capability, pinned UE4SS 3.0.1 build, and pinned reference revision before calling a Companion-local, platform-authorized vehicle-spawn transport port. + +The adapter itself creates the exact `#spawnvehicle ` string and holds it only in a private transport/audit field. It accepts no command text, prefix, extra argument, target, shell text, SQL text, host path, socket, RCON credential, or reply text from callers. The pinned reference removes one leading `#` before dispatch, but the adapter does not infer completion from that implementation's unstructured response. The port returns a bounded receipt that the adapter maps to `succeeded`, `failed`, or `unknown`; unknown is never retried automatically. The plugin UI exposes only catalogued choices and enables its action only when the Companion reports the declared handler available. + ### 4. Declared semantic event pipeline The Companion SHALL parse only plugin-declared allowed log/extension sources and upload contiguous typed semantic event batches using the durable log channel. Login/logout, player/vehicle position, and vehicle transitions SHALL be emitted only when their source and required fields can be validated. Map coordinate conversion, sampling, and retention declarations remain SCUM-plugin configuration. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md b/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md index 3daad9b..232c2bc 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md @@ -5,8 +5,14 @@ Checked 2026-07-29 against the pinned read-only UE4SS reference at capability evidence is recorded in `plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md`. -The reference offers only a fixed, online-recipient `SendChat` path, which is -already bounded by the supported `player.notify` adapter. It has no versioned +The reference offers a fixed, online-recipient `SendChat` path, which is +already bounded by the supported `player.notify` adapter. It also removes one +leading `#` before dispatching a command, but its raw response is not a stable +operation acknowledgement. Under the separately authorized fixed-template +exception, the plugin's `vehicle.spawn` adapter can generate only +`#spawnvehicle ` from its allowlist through a local authorized +transport port and maps its bounded receipt to success, failure, or unknown. +It is not a general RCON surface. The reference otherwise has no versioned server-side schema, identity binding, acknowledgement contract, or isolated non-production fixture for login/logout events, network correlation, position or vehicle events, reward delivery, or state reads and writes. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md b/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md index 4f89930..57e7b6f 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md @@ -6,6 +6,7 @@ The five recent SCUM deliveries implemented most SCUM-specific domain services a - Move SCUM-specific feature ownership for configuration, local game-player intelligence, controlled player state changes, gifts, and map trajectories into the SCUM plugin package and its Companion runtime. - Introduce a long-running, platform-authorized SCUM Companion adapter that receives only typed commands, emits only declared semantic events, and reports typed, idempotent results. +- Add one explicitly enabled, version-bound `vehicle.spawn` operation. It accepts only plugin-catalogued vehicle codes and can generate only the fixed `#spawnvehicle ` template through a Companion-local, platform-authorized transport port; it is not a raw-RCON interface. - **BREAKING** Replace platform-owned SCUM panels and SCUM-specific API/domain endpoints with a plugin-page module mounted by the generic platform plugin host. - **BREAKING** Replace platform-owned SCUM projections and persistence with plugin-scoped local data accessed through generic platform isolation, audit, job, and storage primitives. - Preserve the existing platform responsibilities for authorization, server/tenant isolation, review and approval, durable job delivery, audit records, retention enforcement, and generic page hosting. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md index c611494..b731335 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md @@ -11,6 +11,17 @@ The SCUM Companion SHALL run a bounded authenticated command-dispatch loop and S - **WHEN** the Companion claims a command whose type, schema, capability, or version is not supported - **THEN** it does not invoke SCUM, RCON, a database, OCR, or desktop automation and completes the command with an explicit unsupported or validation failure result +### Requirement: Vehicle spawn uses one fixed, version-bound template +The SCUM Companion SHALL execute `vehicle.spawn` only when the installed plugin declares it, the bound server is approved, the Companion reports its handler available, and the pinned UE4SS 3.0.1 build/reference revision is present. The payload SHALL contain exactly one plugin-allowlisted vehicle code matching the declared identifier pattern. The adapter SHALL generate exactly `#spawnvehicle ` internally and SHALL retain that text only in protected transport/audit data. It SHALL not accept or expose raw command text, additional arguments, targets, RCON credentials, sockets, shell/SQL text, host paths, or raw transport replies. It SHALL return a structured `succeeded`, `failed`, or `unknown` outcome and SHALL not automatically retry an unknown outcome. + +#### Scenario: Approved, supported vehicle spawn +- **WHEN** an approved `vehicle.spawn` command carries a declared vehicle code for the pinned UE4SS adapter and the local authorized transport reports acceptance +- **THEN** the Companion sends only the internally generated fixed template, completes with a structured `succeeded` outcome, and omits the generated text from the command result + +#### Scenario: Unsafe vehicle-spawn input or unavailable handler +- **WHEN** a vehicle-spawn payload has an unlisted code, extra field, command text, target, credential, or the Companion has not declared the compatible handler +- **THEN** the Companion performs no transport call and returns validation-failed or unsupported without exposing protected audit text + ### Requirement: Companion emits validated semantic SCUM events The SCUM Companion SHALL collect only declared allowed sources and upload contiguous semantic event batches through the platform's durable log channel. It SHALL validate required event fields before upload and SHALL not emit raw IP addresses, network fingerprints, host paths, credentials, database rows, screenshots, or arbitrary RCON command text. A version-bound typed UE4SS adapter MAY retain the exact generated command text in protected command audit data; it SHALL never expose that text as a general RCON command surface or semantic event payload. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md index e7cf459..74b56b4 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md @@ -29,6 +29,10 @@ The system SHALL expose a SCUM feature as actionable only when the installed plu - **WHEN** the bound SCUM version has no verified `game-state.patch` handler - **THEN** the plugin disables the edit control and reports that the version is unsupported without queuing a generic command +#### Scenario: Vehicle spawn handler is unavailable +- **WHEN** the bound Companion does not report the declared `vehicle.spawn` handler for its compatible version +- **THEN** the plugin keeps vehicle spawning unavailable and does not display a raw command field or queue a generic RCON command + #### Scenario: Historical records during migration - **WHEN** records created by the transitional platform implementation exist for a bound server - **THEN** the plugin can display them with migration provenance while new writes use the plugin-owned feature path diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md index e6ebaff..fa3eea7 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -32,7 +32,8 @@ - [x] 5.2 Keep unsupported player state fields, versions, or unsafe windows disabled in the plugin UI and return explicit unsupported results from the Companion. - [ ] 5.3 Implement a `reward.deliver` adapter that freezes the approved revision, performs idempotent delivery, and reports delivered/failed/unknown without automatically retrying unknown results. - [x] 5.4 Implement a separate `player.notify` adapter that never repeats item delivery after notification failure; verify server-scoped recipient identity and redact message transport details. -- [ ] 5.5 Add isolated non-production end-to-end tests for every supported adapter and ensure no raw SQL, unrestricted RCON, OCR, screenshots, keyboard/mouse injection, or direct game database write path exists. +- [x] 5.5 Implement the explicitly enabled, version-bound `vehicle.spawn` adapter with a strict vehicle-code catalog, the fixed internal `#spawnvehicle ` template, protected audit text, structured success/failure/unknown outcomes, Companion-local transport fixture, and fail-closed UI availability. +- [ ] 5.6 Add isolated non-production end-to-end tests for every supported adapter and ensure no raw SQL, unrestricted RCON, OCR, screenshots, keyboard/mouse injection, or direct game database write path exists. ## 6. Migrate transitional platform behavior safely diff --git a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md index 18a4e8d..6ecc1e3 100644 --- a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md +++ b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md @@ -17,6 +17,13 @@ that contract only for this exact source revision and UE4SS 3.0.1, with fixed chat type `4`; it cannot accept arbitrary RCON text. Its generated command text is private transport/audit data and never appears in a command result. +The same pinned `ScumBridge::trim_command` implementation removes at most one +leading `#` before dispatch. The authorized `vehicle.spawn` adapter preserves +the required `#spawnvehicle ` template internally, supplies it +only to a Companion-local typed transport port, and never treats the source's +raw response as a stable acknowledgement. Its isolated port fixture supplies +the bounded success/failure/unknown receipt used by the adapter tests. + ## Explicitly unavailable The reference contains no versioned server-side producer or documented API for: @@ -27,6 +34,11 @@ The reference contains no versioned server-side producer or documented API for: - item/reward delivery; or - skill/attribute read, safe-window checking, or mutation. +The fixed vehicle-spawn exception does not change these unavailable +capabilities and does not authorize arbitrary RCON commands, arguments, +targets, credentials, direct sockets, SQL, shell execution, or response +projection. + Therefore the Companion must not parse invented `LOGIN`/`LOGOUT` lines, upload semantic events, correlate network identifiers, or claim trajectory, reward, or game-state-patch support from this reference. The missing contract is a diff --git a/plugins/examples/scum-server-plugin/companion/adapters.go b/plugins/examples/scum-server-plugin/companion/adapters.go index 376bb70..56d9870 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters.go +++ b/plugins/examples/scum-server-plugin/companion/adapters.go @@ -42,6 +42,28 @@ type ue4SSPlayerNotification struct { protectedAuditCommand string } +// UE4SSVehicleSpawnPort is a Companion-local, platform-authorized transport +// for one fixed template. It accepts no raw command text, socket, credential, +// or host path. Implementations remain in this Companion package so the +// protected audit template cannot cross a general transport boundary. +type UE4SSVehicleSpawnPort interface { + SpawnVehicle(context.Context, ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error) +} +type UE4SSVehicleSpawnOutcome string + +const ( + UE4SSVehicleSpawnAccepted UE4SSVehicleSpawnOutcome = "accepted" + UE4SSVehicleSpawnRejected UE4SSVehicleSpawnOutcome = "rejected" + UE4SSVehicleSpawnUnknown UE4SSVehicleSpawnOutcome = "unknown" +) + +type UE4SSVehicleSpawnReceipt struct{ Outcome UE4SSVehicleSpawnOutcome } +type ue4SSVehicleSpawn struct { + ServerID string + VehicleCode string + protectedAuditCommand string +} + type VersionedAdapter struct { BoundServerID string ServerVersion string @@ -49,6 +71,7 @@ type VersionedAdapter struct { UE4SSReferenceRevision string Config AuthorizedConfigPort Notification UE4SSNotificationPort + VehicleSpawn UE4SSVehicleSpawnPort DiagnosticsState map[string]string } @@ -103,7 +126,7 @@ func (VersionedAdapter) DeliverReward(context.Context, map[string]any) (map[stri return nil, fmt.Errorf("reward adapter is unsupported") } func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) { - if !adapter.supportsUE4SSNotification() || adapter.Notification == nil { + if !adapter.supportsPinnedUE4SS() || adapter.Notification == nil { return nil, fmt.Errorf("notification adapter is unsupported") } playerID, playerOK := payload["playerId"].(string) @@ -121,12 +144,33 @@ func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[st } return map[string]any{"accepted": true, "message": "notification accepted for online recipient"}, nil } +func (adapter VersionedAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) { + if !adapter.supportsPinnedUE4SS() || adapter.VehicleSpawn == nil { + return nil, fmt.Errorf("vehicle spawn adapter is unsupported") + } + vehicleCode, ok := payload["vehicleCode"].(string) + spawn, err := newUE4SSVehicleSpawn(adapter.BoundServerID, vehicleCode) + if !ok || err != nil { + return nil, fmt.Errorf("vehicle spawn payload is invalid") + } + receipt, err := adapter.VehicleSpawn.SpawnVehicle(ctx, spawn) + if err != nil || receipt.Outcome == UE4SSVehicleSpawnUnknown { + return map[string]any{"outcome": "unknown"}, nil + } + if receipt.Outcome == UE4SSVehicleSpawnRejected { + return map[string]any{"outcome": "failed"}, nil + } + if receipt.Outcome != UE4SSVehicleSpawnAccepted { + return map[string]any{"outcome": "unknown"}, nil + } + return map[string]any{"outcome": "succeeded"}, nil +} -func (adapter VersionedAdapter) supportsUE4SSNotification() bool { +func (adapter VersionedAdapter) supportsPinnedUE4SS() bool { // The pinned source reflects SendChatLineToPlayer at runtime and fails // closed on a schema change, so no unverified SCUM-version mapping is // embedded here. The dispatcher still requires a discovered server version. - return adapter.BoundServerID != "" && adapter.UE4SSBuild == UE4SSReferenceBuild && adapter.UE4SSReferenceRevision == UE4SSReferenceRevision + return adapter.BoundServerID != "" && supportedAdapterVersion(adapter.ServerVersion) && adapter.UE4SSBuild == UE4SSReferenceBuild && adapter.UE4SSReferenceRevision == UE4SSReferenceRevision } func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayerNotification, error) { if strings.TrimSpace(serverID) == "" || !steamID64(playerID) || !validNotificationMessage(message) { @@ -134,6 +178,12 @@ func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayer } return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, protectedAuditCommand: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil } +func newUE4SSVehicleSpawn(serverID, vehicleCode string) (ue4SSVehicleSpawn, error) { + if strings.TrimSpace(serverID) == "" || !supportedVehicleSpawnCode(vehicleCode) { + return ue4SSVehicleSpawn{}, fmt.Errorf("invalid typed UE4SS vehicle spawn") + } + return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, protectedAuditCommand: "#spawnvehicle " + vehicleCode}, nil +} func steamID64(value string) bool { if len(value) != 17 { return false @@ -161,6 +211,9 @@ func escapeUE4SSChatMessage(value string) string { } func supportedAdapterVersion(version string) bool { return version == "0.9.700.90357" } +func supportedVehicleSpawnCode(value string) bool { + return map[string]bool{"BPC_Laika_C": true, "BPC_WolfsWagen_C": true}[value] +} func supportedConfigKey(key string) bool { return map[string]bool{"ServerName": true, "GamePort": true, "QueryPort": true, "MaxPlayers": true, "WelcomeMessage": true}[key] } diff --git a/plugins/examples/scum-server-plugin/companion/adapters_test.go b/plugins/examples/scum-server-plugin/companion/adapters_test.go index aa10d1c..9ad804d 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_test.go @@ -2,6 +2,7 @@ package companion import ( "context" + "errors" "testing" "time" ) @@ -24,6 +25,20 @@ type notificationPortFixture struct { accepted bool } +// nonProductionVehicleSpawnPortFixture is an isolated test double. It has no +// network, socket, credential, or raw-command entry point; it can observe only +// the Companion's private typed request and return a bounded receipt. +type nonProductionVehicleSpawnPortFixture struct { + requests []ue4SSVehicleSpawn + receipt UE4SSVehicleSpawnReceipt + err error +} + +func (fixture *nonProductionVehicleSpawnPortFixture) SpawnVehicle(_ context.Context, request ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error) { + fixture.requests = append(fixture.requests, request) + return fixture.receipt, fixture.err +} + func (fixture *notificationPortFixture) SendPlayerNotification(_ context.Context, notification ue4SSPlayerNotification) (UE4SSNotificationReceipt, error) { fixture.deliveries = append(fixture.deliveries, notification) return UE4SSNotificationReceipt{Accepted: fixture.accepted}, nil @@ -97,3 +112,64 @@ func TestNotificationFailureIsCachedWithoutInvokingRewardDelivery(t *testing.T) t.Fatalf("duplicate notification attempted transport %d times", len(port.deliveries)) } } + +func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivateAuditOnly(t *testing.T) { + port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnAccepted}} + adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, VehicleSpawn: port} + result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_Laika_C"}) + if err != nil || result["outcome"] != "succeeded" || len(port.requests) != 1 { + t.Fatalf("fixed vehicle spawn was not delivered: result=%+v err=%v requests=%+v", result, err, port.requests) + } + request := port.requests[0] + if request.ServerID != "server-1" || request.VehicleCode != "BPC_Laika_C" || request.protectedAuditCommand != "#spawnvehicle BPC_Laika_C" { + t.Fatalf("vehicle spawn did not use the fixed template: %+v", request) + } + if result["command"] != nil || result["rcon"] != nil || result["audit"] != nil || result["outcome"] == request.protectedAuditCommand { + t.Fatalf("vehicle spawn leaked protected transport details: %+v", result) + } +} + +func TestVersionedVehicleSpawnFailsClosedAndClassifiesBoundedReceipts(t *testing.T) { + port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnRejected}} + adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, VehicleSpawn: port} + for name, testCase := range map[string]struct { + outcome string + receipt UE4SSVehicleSpawnReceipt + err error + }{ + "failed": {outcome: "failed", receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnRejected}}, + "unknown": {outcome: "unknown", receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}}, + "unknown-transport": {outcome: "unknown", receipt: UE4SSVehicleSpawnReceipt{}, err: errors.New("isolated transport timeout")}, + } { + port.receipt, port.err = testCase.receipt, testCase.err + result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_WolfsWagen_C"}) + if err != nil || result["outcome"] != testCase.outcome { + t.Fatalf("receipt %s was not safely classified: result=%+v err=%v", name, result, err) + } + } + before := len(port.requests) + if _, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "#spawnvehicle BPC_Laika_C"}); err == nil || len(port.requests) != before { + t.Fatal("raw command text must not reach the vehicle transport") + } + adapter.UE4SSBuild = "3.0.2" + if _, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_Laika_C"}); err == nil || len(port.requests) != before { + t.Fatal("unpinned UE4SS build must not reach the vehicle transport") + } +} + +func TestVehicleSpawnUnknownOutcomeIsCachedWithoutRetry(t *testing.T) { + stamp := time.Now().UTC() + port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}} + adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, VehicleSpawn: port} + registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter) + command := ClaimedCommand{ID: "vehicle-unknown-1", ProfileKey: ProfileKey, CommandType: "vehicle.spawn", Payload: map[string]any{"vehicleCode": "BPC_Laika_C"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)} + for range 2 { + result, err := registry.Execute(context.Background(), command) + if err != nil || result.Payload["outcome"] != "unknown" { + t.Fatalf("unknown vehicle outcome was not retained: result=%+v err=%v", result, err) + } + } + if len(port.requests) != 1 { + t.Fatalf("unknown vehicle outcome retried transport %d times", len(port.requests)) + } +} diff --git a/plugins/examples/scum-server-plugin/companion/config.go b/plugins/examples/scum-server-plugin/companion/config.go index 1d37046..06d1104 100644 --- a/plugins/examples/scum-server-plugin/companion/config.go +++ b/plugins/examples/scum-server-plugin/companion/config.go @@ -25,6 +25,12 @@ var requiredCapabilities = []string{ "game-client.bridge", "logs.stream", } +var optionalCapabilities = map[string]struct{}{ + "handler.vehicle.spawn": {}, +} +var requiredCapabilitySet = map[string]struct{}{ + "component.register": {}, "component.heartbeat": {}, "component.health": {}, "component.control": {}, "game-client.bridge": {}, "logs.stream": {}, +} type Config struct { SchemaVersion int `json:"schemaVersion" yaml:"schemaVersion"` @@ -147,7 +153,7 @@ func canonicalPlatformOrigin(value string) (string, error) { } func validateCapabilities(capabilities []string) error { - if len(capabilities) != len(requiredCapabilities) { + if len(capabilities) < len(requiredCapabilities) || len(capabilities) > len(requiredCapabilities)+len(optionalCapabilities) { return fmt.Errorf("component capabilities do not match the SCUM companion profile") } actual := make(map[string]struct{}, len(capabilities)) @@ -162,5 +168,12 @@ func validateCapabilities(capabilities []string) error { return fmt.Errorf("component capabilities do not match the SCUM companion profile") } } + for capability := range actual { + if _, required := requiredCapabilitySet[capability]; !required { + if _, optional := optionalCapabilities[capability]; !optional { + return fmt.Errorf("component capabilities do not match the SCUM companion profile") + } + } + } return nil } diff --git a/plugins/examples/scum-server-plugin/companion/config_test.go b/plugins/examples/scum-server-plugin/companion/config_test.go new file mode 100644 index 0000000..8aea965 --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/config_test.go @@ -0,0 +1,16 @@ +package companion + +import "testing" + +func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) { + base := append([]string(nil), requiredCapabilities...) + if err := validateCapabilities(base); err != nil { + t.Fatalf("base companion profile must remain valid: %v", err) + } + if err := validateCapabilities(append(base, "handler.vehicle.spawn")); err != nil { + t.Fatalf("explicit vehicle handler declaration must be valid: %v", err) + } + if err := validateCapabilities(append(base, "handler.raw.rcon")); err == nil { + t.Fatal("undeclared raw command handler capability must be rejected") + } +} diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher.go b/plugins/examples/scum-server-plugin/companion/dispatcher.go index b47318f..6f3334c 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher.go @@ -17,6 +17,7 @@ type SafeAdapter interface { PatchGameState(context.Context, map[string]any) (map[string]any, error) DeliverReward(context.Context, map[string]any) (map[string]any, error) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) + SpawnVehicle(context.Context, map[string]any) (map[string]any, error) } type HandlerAvailability struct { @@ -54,6 +55,9 @@ func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) * registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.NotifyPlayer(ctx, payload) } + registry.handlers["vehicle.spawn"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { + return adapter.SpawnVehicle(ctx, payload) + } return registry } @@ -189,6 +193,18 @@ func validateCommandPayload(commandType string, payload map[string]any) error { return fmt.Errorf("notification payload is invalid") } return nil + case "vehicle.spawn": + if err := require("vehicleCode"); err != nil { + return err + } + if err := noUnknown("vehicleCode"); err != nil { + return err + } + vehicleCode, vehicleOK := payload["vehicleCode"].(string) + if !vehicleOK || !supportedVehicleSpawnCode(vehicleCode) { + return fmt.Errorf("vehicle spawn payload is invalid") + } + return nil default: return fmt.Errorf("command type is not declared") } diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher_test.go b/plugins/examples/scum-server-plugin/companion/dispatcher_test.go index b9abb1f..957b62e 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher_test.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher_test.go @@ -45,6 +45,9 @@ func (*adapterFixture) DeliverReward(context.Context, map[string]any) (map[strin func (*adapterFixture) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) { return nil, nil } +func (*adapterFixture) SpawnVehicle(context.Context, map[string]any) (map[string]any, error) { + return nil, nil +} func TestDispatcherAcknowledgesOnlyLiveValidatedTypedCommands(t *testing.T) { stamp := time.Now().UTC() @@ -82,7 +85,7 @@ func TestRegistryReturnsCachedResultForDuplicateDelivery(t *testing.T) { func TestRegistryRejectsUndeclaredAndMalformedPayloads(t *testing.T) { stamp := time.Now().UTC() registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.patch": true}}, &adapterFixture{}) - for _, command := range []ClaimedCommand{{ID: "bad-type", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "bad-payload", ProfileKey: ProfileKey, CommandType: "config.patch", Payload: map[string]any{"revision": "r1"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}} { + for _, command := range []ClaimedCommand{{ID: "bad-type", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "bad-payload", ProfileKey: ProfileKey, CommandType: "config.patch", Payload: map[string]any{"revision": "r1"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "unsafe-vehicle", ProfileKey: ProfileKey, CommandType: "vehicle.spawn", Payload: map[string]any{"vehicleCode": "BPC_Laika_C", "command": "#spawnvehicle BPC_Laika_C"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}} { result, err := registry.Execute(context.Background(), command) if err != nil || result.Payload["result"] != "validation-failed" { t.Fatalf("unsafe command was not rejected: result=%+v err=%v", result, err) diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts index 545cc08..6d47ef5 100644 --- a/plugins/examples/scum-server-plugin/features/api.ts +++ b/plugins/examples/scum-server-plugin/features/api.ts @@ -1,10 +1,11 @@ -import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMGiftGrant, SCUMPlayerProfile, SCUMStatePatch, SCUMStateSnapshot, SCUMTrajectoryCollection } from "./contracts.js"; -import { validateConfigPatch, validateStatePatch } from "./schemas.js"; +import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMGiftGrant, SCUMPlayerProfile, SCUMStatePatch, SCUMStateSnapshot, SCUMTrajectoryCollection, SCUMVehicleSpawn } from "./contracts.js"; +import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from "./schemas.js"; export type PluginFeatureBridge = { dispatch(action: "game-client.command" | "game-client.snapshot.read", payload: Record): Promise<{ status: string; result?: Record; error?: { message: string } }> }; export type SCUMFeatureAPI = { availability(feature: SCUMFeatureKey): Promise; readConfig(version: string): Promise; patchConfig(patch: SCUMConfigPatch): Promise; playerProfile(playerId: string): Promise; stateSnapshot(playerId: string): Promise; requestStatePatch(patch: SCUMStatePatch): Promise; + requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise; giftGrants(): Promise; trajectories(): Promise; }; @@ -17,6 +18,7 @@ export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, serverVersion: async playerProfile(playerId) { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", subjectId: playerId }); return result.status === "ok" ? decode(result.result) : null; }, async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode(result.result) : null; }, async requestStatePatch(patch) { const error = validateStatePatch(patch.gameVersion, patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); }, + async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); }, async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "gifts" }); return result.status === "ok" ? decode(result.result) ?? [] : []; }, async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; } }; diff --git a/plugins/examples/scum-server-plugin/features/contracts.ts b/plugins/examples/scum-server-plugin/features/contracts.ts index 943a9cd..c8dc62f 100644 --- a/plugins/examples/scum-server-plugin/features/contracts.ts +++ b/plugins/examples/scum-server-plugin/features/contracts.ts @@ -7,6 +7,8 @@ export type SCUMMigrationRecord> = { provenance: SCU export type SCUMFeatureMigrationAuthority = { serverInstanceId: string; serverVersion: string; feature: SCUMFeatureKey; authority: "plugin" | "transitional-read-only"; reason?: string }; export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-read-only"; readOnlyHistory: true; pluginWritesEnabled: boolean; reason?: string }; export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record }; +export type SCUMVehicleSpawn = { vehicleCode: string }; +export type SCUMVehicleSpawnOption = { code: string; label: string }; export type SCUMConfigField = { key: string; label: string; description: string; control: "text" | "number" | "port" | "boolean"; diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index ac24445..a06e9dd 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -1,11 +1,11 @@ -import { configurationCatalog, stateFieldCatalog } from "./schemas.js"; +import { configurationCatalog, stateFieldCatalog, vehicleSpawnCatalog } from "./schemas.js"; import type { SCUMFeatureWorkspace } from "./contracts.js"; export type ReactLike = { createElement: (...args: any[]) => any; useMemo?: (factory: () => T, deps: readonly unknown[]) => T }; export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspace?: SCUMFeatureWorkspace; serverVersion?: string }; export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { - const e = react.createElement; const version = input.serverVersion ?? "0.9.700.90357"; const fields = input.workspace?.configFields?.length ? input.workspace.configFields : configurationCatalog(version); const scoped = Boolean(input.serverInstanceId); const canRead = scoped && input.permissions.includes("server.game-client.read"); const canCommand = scoped && input.permissions.includes("server.game-client.command"); const canMaintain = scoped && input.permissions.includes("server.game-client.maintenance"); + const e = react.createElement; const version = input.serverVersion ?? "0.9.700.90357"; const fields = input.workspace?.configFields?.length ? input.workspace.configFields : configurationCatalog(version); const vehicleCodes = vehicleSpawnCatalog(version); const scoped = Boolean(input.serverInstanceId); const canRead = scoped && input.permissions.includes("server.game-client.read"); const canCommand = scoped && input.permissions.includes("server.game-client.command"); const canMaintain = scoped && input.permissions.includes("server.game-client.maintenance"); return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" }, e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "SCUM 语义、界面和适配器由插件提供;平台仅提供已授权的服务器隔离宿主。")), e("span", { className: "page-status" }, availabilityText(input.availability, scoped))), e("div", { className: "console-row-list" }, e("div", { className: "console-row" }, e("strong", null, "绑定服务器"), e("span", null, input.serverInstanceId ?? "未绑定")), e("div", { className: "console-row" }, e("strong", null, "配置版本目录"), e("span", null, version)), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))), @@ -13,6 +13,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) playerPanel(e, canRead, featureAvailability(input, "player.intelligence")), rewardPanel(e, canRead, canCommand, featureAvailability(input, "reward.delivery")), statePanel(e, version, canRead, canMaintain, featureAvailability(input, "state.patch")), + vehicleSpawnPanel(e, vehicleCodes, canCommand, featureAvailability(input, "vehicle.spawn")), trajectoryPanel(e, canRead, featureAvailability(input, "trajectory.collect")) ); } @@ -21,6 +22,7 @@ function configurationPanel(e: ReactLike["createElement"], fields: readonly { ke function playerPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 玩家档案" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家、登录与风险信号"), e("p", { className: "provider-id" }, "只展示 Companion 已验证的语义事件;网络关联是按服务器不可逆计算,不上传原始网络值。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "查询玩家")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有玩家读取权限。" : availability.available ? "等待已验证的登录或登出事件。" : availability.reason ?? "没有兼容的事件生产者。")); } function rewardPanel(e: ReactLike["createElement"], canRead: boolean, canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 礼物与通知" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "冻结礼物版本与通知"), e("p", { className: "provider-id" }, "物品投递与通知分离;未知投递结果不会自动重试。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "申请投递")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有礼物读取权限。" : !canCommand ? "当前服务器上下文没有受控投递权限。" : availability.reason ?? "需要已冻结 revision、已验证玩家身份和兼容处理器。")); } function statePanel(e: ReactLike["createElement"], version: string, canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { const fields = stateFieldCatalog(version); return e("section", { className: "console-panel", "aria-label": "SCUM 受控状态修改" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受控属性修改"), e("p", { className: "provider-id" }, "仅列出已发现版本支持的字段,执行时要求预读、安全窗口与读后确认。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !canMaintain || !availability.available }, "创建修改申请")), e("div", { className: "console-row-list" }, fields.length ? fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.minimum}–${field.maximum}`))) : e("p", { className: "page-status" }, "当前 SCUM 版本没有已验证的状态字段。")), e("p", { className: "page-status" }, canMaintain ? availability.reason ?? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。")); } +function 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 处理器或事件生产者"}`; } diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts index b08df1c..864a135 100644 --- a/plugins/examples/scum-server-plugin/features/schemas.ts +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -1,4 +1,4 @@ -import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField } from "./contracts.js"; +import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField, SCUMVehicleSpawn, SCUMVehicleSpawnOption } from "./contracts.js"; const stateFieldsByVersion: Record[]> = { "0.9.700.90357": [ @@ -16,8 +16,12 @@ export const configurationFieldsByVersion: Record = { + "0.9.700.90357": [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }] +}; export function configurationCatalog(serverVersion: string): readonly SCUMConfigField[] { return configurationFieldsByVersion[serverVersion] ?? []; } +export function vehicleSpawnCatalog(serverVersion: string): readonly SCUMVehicleSpawnOption[] { return vehicleSpawnCatalogByVersion[serverVersion] ?? []; } export function stateFieldCatalog(serverVersion: string): readonly Omit[] { return stateFieldsByVersion[serverVersion] ?? []; } export function supportsStateField(serverVersion: string, field: string): boolean { return stateFieldCatalog(serverVersion).some((candidate) => candidate.key === field); } export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; } @@ -38,3 +42,8 @@ export function validateStatePatch(serverVersion: string, fields: Array<{ fieldK for (const field of fields) { const definition = stateFieldCatalog(serverVersion).find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 未受当前版本支持。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; } +export function validateVehicleSpawn(spawn: SCUMVehicleSpawn, serverVersion = "0.9.700.90357"): string | null { + if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; + if (!vehicleSpawnCatalog(serverVersion).some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在当前版本的受控目录中声明。"; + return null; +} diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 4fa9b9e..8d07b4e 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -3,7 +3,7 @@ "id": "game.scum", "name": "SCUM Server", "description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and companion bridge support.", - "version": "0.1.0", + "version": "0.1.1", "kind": "game-plugin", "tags": [ "scum", @@ -141,6 +141,16 @@ "timeoutSeconds": 60, "maxPayloadBytes": 2048 }, + { + "type": "vehicle.spawn", + "title": "Spawn catalogued SCUM vehicle", + "permission": "server.game-client.command", + "approvalLevel": "operator", + "payloadSchemaRef": "schemas/bridge/vehicle-spawn.payload.schema.json", + "resultSchemaRef": "schemas/bridge/vehicle-spawn.result.schema.json", + "timeoutSeconds": 60, + "maxPayloadBytes": 1024 + }, { "type": "event.start", "title": "Start SCUM event", @@ -247,6 +257,7 @@ { "key": "player.intelligence", "title": "SCUM player intelligence", "permission": "server.game-client.read", "requiredHandlers": ["player.lookup"], "requiredEventProducers": ["semantic.events"] }, { "key": "reward.delivery", "title": "SCUM reward delivery", "permission": "server.game-client.command", "requiredHandlers": ["reward.deliver", "player.notify"] }, { "key": "state.patch", "title": "SCUM player state patch", "permission": "server.game-client.maintenance", "requiredHandlers": ["game-state.patch"] }, + { "key": "vehicle.spawn", "title": "SCUM catalogued vehicle spawn", "permission": "server.game-client.command", "requiredHandlers": ["vehicle.spawn"] }, { "key": "trajectory.collect", "title": "SCUM trajectories", "permission": "server.game-client.read", "requiredEventProducers": ["semantic.events"] } ], "pages": [ @@ -257,12 +268,13 @@ "companion.diagnostics", "player.lookup", "reward.deliver", + "vehicle.spawn", "event.start", "restart.prepare", "maintenance.prepare" ], "snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"], - "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "trajectory.collect"] + "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"] } ], "companion": { @@ -311,7 +323,7 @@ "dependencyPolicy": "required", "approvalRequired": ["disable", "rollback", "retire"] }, - "pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.1", "bundleIntegritySha256": "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64", "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", "trajectory.collect"] }], + "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"] }], "fileWorkspace": { "defaultDirectoryKey": "scum-config", "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts index 3972f04..fa0233f 100644 --- a/plugins/examples/scum-server-plugin/page-bundle/index.ts +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -1,7 +1,7 @@ import { renderSCUMFeaturePage } from "../features/page.js"; import type { SCUMFeatureWorkspace } from "../features/contracts.js"; -export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.1", integritySha256: "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64" }; +export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.2", integritySha256: "sha256:3b39507d1471f8d62d25001a11b43c664dbb5a5bef91ed6944b512e6e60099a7" }; 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, serverVersion: input.workspace?.serverVersion }); diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/vehicle-spawn.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/vehicle-spawn.payload.schema.json new file mode 100644 index 0000000..eab57c8 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/vehicle-spawn.payload.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCUMVehicleSpawnPayload", + "type": "object", + "additionalProperties": false, + "required": ["vehicleCode"], + "properties": { + "vehicleCode": { "type": "string", "minLength": 3, "maxLength": 64, "pattern": "^[A-Za-z][A-Za-z0-9_]{2,63}$", "enum": ["BPC_Laika_C", "BPC_WolfsWagen_C"] } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/vehicle-spawn.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/vehicle-spawn.result.schema.json new file mode 100644 index 0000000..37fb0a2 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/vehicle-spawn.result.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCUMVehicleSpawnResult", + "type": "object", + "additionalProperties": false, + "required": ["outcome"], + "properties": { + "outcome": { "type": "string", "minLength": 6, "maxLength": 9, "enum": ["succeeded", "failed", "unknown"] } + } +} diff --git a/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json b/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json index 4dab95e..450ec8d 100644 --- a/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json @@ -57,9 +57,9 @@ "capabilities": { "type": "array", "minItems": 6, - "maxItems": 6, + "maxItems": 7, "uniqueItems": true, - "items": { "enum": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"] }, + "items": { "enum": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream", "handler.vehicle.spawn"] }, "allOf": [ { "contains": { "const": "component.register" } }, { "contains": { "const": "component.heartbeat" } }, diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 63192e4..61d3183 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -173,6 +173,19 @@ describe("plugin manifest validation", () => { expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]); }); + it("declares a fixed, schema-bound vehicle spawn instead of a raw command surface", () => { + const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; resultSchemaRef?: string }>; features: Array<{ key: string; requiredHandlers?: string[] }> } }; + const command = manifest.gameClientBridge.commands.find((candidate) => candidate.type === "vehicle.spawn"); + expect(command).toBeDefined(); + expect(manifest.gameClientBridge.features.find((feature) => feature.key === "vehicle.spawn")?.requiredHandlers).toEqual(["vehicle.spawn"]); + const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.payloadSchemaRef), "utf8")); + const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.resultSchemaRef!), "utf8")); + expect(payload).toMatchObject({ additionalProperties: false, required: ["vehicleCode"], properties: { vehicleCode: { enum: ["BPC_Laika_C", "BPC_WolfsWagen_C"] } } }); + expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/command|rcon|target|credential|socket|shell|sql/); + expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } }); + }); + it("declares a frozen SCUM install/adopt template with explicit mapping and verification checks", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any; const template = manifest.runtimeProfiles.serverDeployments[0]; diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index f01363d..79ae2e7 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; 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 } from "../examples/scum-server-plugin/features/schemas.js"; +import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js"; describe("SCUM plugin feature module", () => { @@ -11,6 +11,10 @@ describe("SCUM plugin feature module", () => { expect(validateConfigPatch({ version: "0.9.700.90357", reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围"); expect(validateStatePatch("0.9.700.90357", [{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull(); expect(validateStatePatch("unknown", [{ fieldKey: "skills.running", before: 1, after: 2 }])).toContain("未受当前版本支持"); + expect(vehicleSpawnCatalog("0.9.700.90357").map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]); + expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull(); + expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效"); + expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("受控目录"); }); it("maps transitional records only as read-only provenance", () => { @@ -45,16 +49,18 @@ describe("SCUM plugin feature module", () => { it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => { const nodes: string[] = []; const buttons = new Map(); const react = { createElement: (type: unknown, props: Record | 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: "trajectory.collect", available: false, reason: "no position producer" }] }, workspace: {} }); + 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); }); @@ -62,6 +68,13 @@ describe("SCUM plugin feature module", () => { const buttons = new Map(); const react = { createElement: (type: unknown, props: Record | 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); + for (const label of ["读取配置", "查询玩家", "申请投递", "创建修改申请", "生成载具", "读取轨迹"]) expect(buttons.get(label)).toBe(true); + }); + + it("opens vehicle spawning only for the declared Companion handler", () => { + const buttons = new Map(); + const react = { createElement: (type: unknown, props: Record | 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); }); });