feat(scum): add bounded vehicle spawn adapter

This commit is contained in:
npc0-hue
2026-07-29 16:04:26 +08:00
parent 7ae4dbf0b2
commit daa0f330a4
24 changed files with 314 additions and 23 deletions
@@ -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 <vehicleCode>` 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
@@ -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]
}
@@ -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))
}
}
@@ -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
}
@@ -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")
}
}
@@ -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")
}
@@ -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)
@@ -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<string, string>): Promise<{ status: string; result?: Record<string, string>; error?: { message: string } }> };
export type SCUMFeatureAPI = {
availability(feature: SCUMFeatureKey): Promise<SCUMFeatureAvailability>; readConfig(version: string): Promise<SCUMConfigRead | null>; patchConfig(patch: SCUMConfigPatch): Promise<SCUMCommandResult>;
playerProfile(playerId: string): Promise<SCUMPlayerProfile | null>; stateSnapshot(playerId: string): Promise<SCUMStateSnapshot | null>; requestStatePatch(patch: SCUMStatePatch): Promise<SCUMCommandResult>;
requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise<SCUMCommandResult>;
giftGrants(): Promise<SCUMGiftGrant[]>; trajectories(): Promise<SCUMTrajectoryCollection>;
};
@@ -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<SCUMPlayerProfile>(result.result) : null; },
async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode<SCUMStateSnapshot>(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<SCUMGiftGrant[]>(result.result) ?? [] : []; },
async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode<SCUMTrajectoryCollection>(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; }
};
@@ -7,6 +7,8 @@ export type SCUMMigrationRecord<T = Record<string, unknown>> = { 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<string, unknown> };
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";
@@ -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?: <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; 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 处理器或事件生产者"}`; }
@@ -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<string, readonly Omit<SCUMStateField, "value" | "editable" | "reason">[]> = {
"0.9.700.90357": [
@@ -16,8 +16,12 @@ export const configurationFieldsByVersion: Record<string, readonly SCUMConfigFie
{ key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
]
};
export const vehicleSpawnCatalogByVersion: Record<string, readonly SCUMVehicleSpawnOption[]> = {
"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<SCUMStateField, "value" | "editable" | "reason">[] { 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;
}
@@ -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" }],
@@ -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 });
@@ -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"] }
}
}
@@ -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"] }
}
}
@@ -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" } },