feat(scum): add bounded vehicle spawn adapter
This commit is contained in:
@@ -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" } },
|
||||
|
||||
Reference in New Issue
Block a user