refactor(scum): use runtime capability probes

This commit is contained in:
npc0-hue
2026-07-29 18:34:16 +08:00
parent 03339fb3e8
commit d7465bfd32
26 changed files with 530 additions and 533 deletions
@@ -1,9 +1,8 @@
# SCUM Companion One-Shot Smoke
The currently pinned UE4SS reference does not provide semantic player or map
events. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the supported
`SendChat` evidence and the exact unavailable contracts; this Companion never
infers those events from arbitrary log lines.
Run stdout/stderr records provide bounded semantic player events. See
[UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the runtime boundary; this
Companion never infers events from arbitrary log lines.
This plugin-owned fixture proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
@@ -1,47 +1,16 @@
# Pinned UE4SS capability evidence
# Runtime capability boundary
This Companion has inspected the read-only reference repository at commit
`bae91527355f14faa63c1df65f742cc48594ba1b` (`scum_simple_rcon_ue4ss` v0.1.0,
verified build target RE-UE4SS 3.0.1).
UE4SS is only a possible Companion-local implementation detail. It is not a
feature gate: no SCUM game, database, UE4SS build, or source revision controls
plugin availability.
## Verified capability
The Companion declares availability from its server-bound typed ports and
runtime schema probes. Notification and fixed vehicle spawning can use a local
typed transport, but callers never supply a command, socket, credential, path,
or raw transport reply. Vehicle spawning creates only the private
`#spawnvehicle <vehicleCode>` template from the plugin allowlist.
The source implements a game-thread `SendChat <type 0-7> "message"
[SteamID64]` path. A targeted send accepts only a 17-digit SteamID64 that
resolves to a real, currently online `ConZPlayerController` with a live
`UNetConnection`; it fails closed when the reflected
`MiscStatics:SendChatLineToPlayer` schema differs. This can support a
version-bound, typed `player.notify` adapter when the deployed Companion is
given a platform-authorized typed transport. `VersionedAdapter` implements
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:
- successful player login/logout records;
- raw network identity/fingerprint values suitable for correlation;
- player or vehicle position, or player/vehicle transitions;
- 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
version-pinned UE4SS extension/API that defines the event or operation schema,
identity binding, acknowledgement/result semantics, and non-production
integration fixture for each capability.
Semantic events come from bounded Run stdout/stderr records. Unknown records
create diagnostics and never produce fabricated events. Run database access is
limited to typed allowlisted projections and safe mutations; DSNs, rows, SQL,
and credentials do not leave Run.
@@ -9,30 +9,62 @@ import (
"unicode/utf8"
)
var errAdapterUnsupported = errors.New("versioned adapter is unsupported")
var errAdapterUnsupported = errors.New("runtime capability is unavailable")
// AuthorizedConfigPort is supplied by a version-bound Companion integration.
// It exposes logical configuration values only: never a host path, connection
// string, credential, arbitrary command, or direct database handle.
// AuthorizedConfigPort is supplied through the platform-authorized Run channel.
// It exposes logical, allowlisted configuration values only; it never exposes a
// path, DSN, credential, arbitrary command, or database handle.
type AuthorizedConfigPort interface {
ReadConfig(context.Context) (map[string]string, error)
ApplyConfigPatch(ctx context.Context, revision string, fields []ConfigFieldPatch) (map[string]string, error)
ApplyConfigPatch(context.Context, string, []ConfigFieldPatch) (map[string]string, error)
}
type ConfigFieldPatch struct {
Key string
Value string
}
const (
UE4SSReferenceRevision = "bae91527355f14faa63c1df65f742cc48594ba1b"
UE4SSReferenceBuild = "3.0.1"
fixedNotificationType = 4
)
// AuthorizedGameDataPort is a typed, Run-owned read/patch boundary. Implementations
// must probe their local schema, use field allowlists and safe windows, and return
// bounded snapshots rather than rows or connection details.
type AuthorizedGameDataPort interface {
ReadPlayerState(context.Context, string, []string) (PlayerStateSnapshot, error)
ApplyPlayerState(context.Context, PlayerStatePatch) (PlayerStateSnapshot, error)
}
type PlayerStateSnapshot struct {
PlayerID string
StateVersion string
SafeWindow bool
Fields map[string]float64
}
type PlayerStatePatch struct {
PlayerID string
ExpectedStateVersion string
Fields []StateFieldPatch
}
type StateFieldPatch struct {
Key string
Before float64
After float64
}
// AuthorizedRewardPort accepts only a frozen grant and typed items. It cannot
// receive SQL, a raw database row, a shell command, an RCON command, or secrets.
type AuthorizedRewardPort interface {
DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error)
}
type RewardGrant struct {
GrantID string
PlayerID string
Items []RewardItem
}
type RewardItem struct {
CatalogCode string
Quantity int
}
type DeliveryReceipt struct{ Outcome string }
const fixedNotificationType = 4
// UE4SSNotificationPort is implemented only by a Companion-local,
// platform-authorized transport for the pinned UE4SS build. It receives a
// fixed typed notification, never a raw RCON command, credential, socket, or
// host path. Its private audit text is not part of command result payloads.
type UE4SSNotificationPort interface {
SendPlayerNotification(context.Context, ue4SSPlayerNotification) (UE4SSNotificationReceipt, error)
}
@@ -44,11 +76,6 @@ type ue4SSPlayerNotification struct {
chatType int
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)
}
@@ -67,31 +94,33 @@ type ue4SSVehicleSpawn struct {
protectedAuditCommand string
}
type VersionedAdapter struct {
BoundServerID string
ServerVersion string
UE4SSBuild string
UE4SSReferenceRevision string
Config AuthorizedConfigPort
Notification UE4SSNotificationPort
VehicleSpawn UE4SSVehicleSpawnPort
DiagnosticsState map[string]string
// RuntimeAdapter is bound to one server. Availability is discovered from its
// configured typed ports and declared capabilities. A failed probe affects
// only its operation.
type RuntimeAdapter struct {
BoundServerID string
Config AuthorizedConfigPort
GameData AuthorizedGameDataPort
Rewards AuthorizedRewardPort
Notification UE4SSNotificationPort
VehicleSpawn UE4SSVehicleSpawnPort
DiagnosticsState map[string]string
}
func (adapter VersionedAdapter) ServerBinding() string { return adapter.BoundServerID }
func (adapter RuntimeAdapter) ServerBinding() string { return adapter.BoundServerID }
func (adapter VersionedAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) {
if !supportedAdapterVersion(adapter.ServerVersion) || adapter.Config == nil {
func (adapter RuntimeAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) {
if adapter.Config == nil {
return nil, errAdapterUnsupported
}
fields, err := adapter.Config.ReadConfig(ctx)
if err != nil {
return nil, err
}
return map[string]any{"version": adapter.ServerVersion, "fields": redactConfigValues(fields)}, nil
return map[string]any{"fields": redactConfigValues(fields)}, nil
}
func (adapter VersionedAdapter) PatchConfiguration(ctx context.Context, payload map[string]any) (map[string]any, error) {
if !supportedAdapterVersion(adapter.ServerVersion) || adapter.Config == nil {
func (adapter RuntimeAdapter) PatchConfiguration(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.Config == nil {
return nil, errAdapterUnsupported
}
revision, _ := payload["revision"].(string)
@@ -113,10 +142,10 @@ func (adapter VersionedAdapter) PatchConfiguration(ctx context.Context, payload
if err != nil {
return nil, err
}
return map[string]any{"version": adapter.ServerVersion, "appliedFields": redactConfigValues(applied)}, nil
return map[string]any{"appliedFields": redactConfigValues(applied)}, nil
}
func (adapter VersionedAdapter) Diagnostics(context.Context) (map[string]any, error) {
state := map[string]any{"version": adapter.ServerVersion, "adapter": "version-bound", "configuration": supportedAdapterVersion(adapter.ServerVersion)}
func (adapter RuntimeAdapter) Diagnostics(context.Context) (map[string]any, error) {
state := map[string]any{"adapter": "runtime-capability"}
for key, value := range adapter.DiagnosticsState {
if safeDiagnosticField(key, value) {
state[key] = value
@@ -124,14 +153,53 @@ func (adapter VersionedAdapter) Diagnostics(context.Context) (map[string]any, er
}
return state, nil
}
func (VersionedAdapter) PatchGameState(context.Context, map[string]any) (map[string]any, error) {
return nil, errAdapterUnsupported
func (adapter RuntimeAdapter) PatchGameState(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.GameData == nil {
return nil, errAdapterUnsupported
}
playerID, _ := payload["playerId"].(string)
expected, _ := payload["expectedStateVersion"].(string)
raw, _ := payload["changes"].([]any)
fields, err := statePatchFields(raw)
if err != nil {
return nil, err
}
before, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields))
if err != nil {
return nil, err
}
if before.PlayerID != playerID || before.StateVersion != expected || !before.SafeWindow || !stateMatches(before.Fields, fields) {
return map[string]any{"outcome": "failed"}, nil
}
after, err := adapter.GameData.ApplyPlayerState(ctx, PlayerStatePatch{PlayerID: playerID, ExpectedStateVersion: expected, Fields: fields})
if err != nil {
return map[string]any{"outcome": "unknown"}, nil
}
confirmed, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields))
if err != nil || after.PlayerID != playerID || !stateApplied(confirmed.Fields, fields) {
return map[string]any{"outcome": "unknown"}, nil
}
return map[string]any{"outcome": "succeeded", "changedFields": len(fields)}, nil
}
func (VersionedAdapter) DeliverReward(context.Context, map[string]any) (map[string]any, error) {
return nil, errAdapterUnsupported
func (adapter RuntimeAdapter) DeliverReward(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.Rewards == nil {
return nil, errAdapterUnsupported
}
grant, err := rewardGrant(payload)
if err != nil {
return nil, err
}
receipt, err := adapter.Rewards.DeliverReward(ctx, grant)
if err != nil || receipt.Outcome == "unknown" {
return map[string]any{"outcome": "unknown"}, nil
}
if receipt.Outcome != "delivered" {
return map[string]any{"outcome": "failed"}, nil
}
return map[string]any{"outcome": "delivered"}, nil
}
func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
if !adapter.supportsPinnedUE4SS() || adapter.Notification == nil {
func (adapter RuntimeAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.BoundServerID == "" || adapter.Notification == nil {
return nil, errAdapterUnsupported
}
playerID, playerOK := payload["playerId"].(string)
@@ -144,13 +212,10 @@ func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[st
if err != nil {
return nil, fmt.Errorf("notification transport failed")
}
if !receipt.Accepted {
return map[string]any{"accepted": false, "message": "notification was not accepted"}, nil
}
return map[string]any{"accepted": true, "message": "notification accepted for online recipient"}, nil
return map[string]any{"accepted": receipt.Accepted}, nil
}
func (adapter VersionedAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) {
if !adapter.supportsPinnedUE4SS() || adapter.VehicleSpawn == nil {
func (adapter RuntimeAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.BoundServerID == "" || adapter.VehicleSpawn == nil {
return nil, errAdapterUnsupported
}
vehicleCode, ok := payload["vehicleCode"].(string)
@@ -170,22 +235,15 @@ func (adapter VersionedAdapter) SpawnVehicle(ctx context.Context, payload map[st
}
return map[string]any{"outcome": "succeeded"}, nil
}
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 != "" && 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) {
return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed UE4SS notification")
return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed notification")
}
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{}, fmt.Errorf("invalid typed vehicle spawn")
}
return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, protectedAuditCommand: "#spawnvehicle " + vehicleCode}, nil
}
@@ -214,8 +272,6 @@ func validNotificationMessage(value string) bool {
func escapeUE4SSChatMessage(value string) string {
return strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(value)
}
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]
}
@@ -240,3 +296,68 @@ func safeDiagnosticField(key, value string) bool {
lowered := strings.ToLower(key + "=" + value)
return !strings.Contains(lowered, "path") && !strings.Contains(lowered, "credential") && !strings.Contains(lowered, "password") && !strings.Contains(lowered, "bearer ") && !strings.Contains(lowered, "rcon") && !strings.Contains(lowered, "sql") && !strings.Contains(lowered, "://")
}
func statePatchFields(raw []any) ([]StateFieldPatch, error) {
if len(raw) == 0 || len(raw) > 8 {
return nil, fmt.Errorf("state patch payload is invalid")
}
fields := make([]StateFieldPatch, 0, len(raw))
for _, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("state patch payload is invalid")
}
key, _ := item["fieldKey"].(string)
before, beforeOK := item["before"].(float64)
after, afterOK := item["after"].(float64)
if key == "" || !beforeOK || !afterOK {
return nil, fmt.Errorf("state patch payload is invalid")
}
fields = append(fields, StateFieldPatch{Key: key, Before: before, After: after})
}
return fields, nil
}
func statePatchKeys(fields []StateFieldPatch) []string {
keys := make([]string, 0, len(fields))
for _, field := range fields {
keys = append(keys, field.Key)
}
return keys
}
func stateMatches(values map[string]float64, fields []StateFieldPatch) bool {
for _, field := range fields {
if values[field.Key] != field.Before {
return false
}
}
return true
}
func stateApplied(values map[string]float64, fields []StateFieldPatch) bool {
for _, field := range fields {
if values[field.Key] != field.After {
return false
}
}
return true
}
func rewardGrant(payload map[string]any) (RewardGrant, error) {
grantID, grantOK := payload["grantId"].(string)
playerID, playerOK := payload["playerId"].(string)
raw, itemsOK := payload["items"].([]any)
if !grantOK || !playerOK || !itemsOK || len(raw) == 0 || len(raw) > 8 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items := make([]RewardItem, 0, len(raw))
for _, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
code, codeOK := item["catalogCode"].(string)
quantity, quantityOK := item["quantity"].(float64)
if !codeOK || !quantityOK || quantity < 1 || quantity > 99 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items = append(items, RewardItem{CatalogCode: code, Quantity: int(quantity)})
}
return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items}, nil
}
@@ -89,8 +89,8 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
spawnReceipts: []UE4SSVehicleSpawnReceipt{{Outcome: UE4SSVehicleSpawnAccepted}, {Outcome: UE4SSVehicleSpawnRejected}, {Outcome: UE4SSVehicleSpawnUnknown}},
spawnErrors: []error{nil, nil, errors.New("receipt unavailable")},
}
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, Config: port, Notification: port, VehicleSpawn: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.read": true, "config.patch": true, "player.notify": true, "vehicle.spawn": true}}, adapter)
adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port, VehicleSpawn: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.read": true, "config.patch": true, "player.notify": true, "vehicle.spawn": true}}, adapter)
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{
e2eClaim("config-read", "config.read", map[string]any{}, stamp),
e2eClaim("config-patch", "config.patch", map[string]any{"revision": "r1", "fields": []any{map[string]any{"key": "ServerName", "value": "Moonlight"}}}, stamp),
@@ -129,16 +129,15 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
}
}
func TestSupportedAdaptersFailClosedForBindingApprovalVersionAndCapability(t *testing.T) {
func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
for name, testCase := range map[string]struct {
availability HandlerAvailability
adapter VersionedAdapter
adapter RuntimeAdapter
}{
"binding": {availability: HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter: VersionedAdapter{BoundServerID: "server-2", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision}},
"approval": {availability: HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: false, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter: VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision}},
"capability": {availability: HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{}}, adapter: VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision}},
"version": {availability: HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter: VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: "3.0.2", UE4SSReferenceRevision: UE4SSReferenceRevision}},
"binding": {availability: HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter: RuntimeAdapter{BoundServerID: "server-2"}},
"approval": {availability: HandlerAvailability{BoundServerID: "server-1", Approved: false, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter: RuntimeAdapter{BoundServerID: "server-1"}},
"capability": {availability: HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{}}, adapter: RuntimeAdapter{BoundServerID: "server-1"}},
} {
t.Run(name, func(t *testing.T) {
port := &isolatedAdapterPort{}
@@ -159,12 +158,12 @@ func TestSupportedAdaptersFailClosedForBindingApprovalVersionAndCapability(t *te
func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
port := &isolatedAdapterPort{patchErr: errors.New("private port failed"), notifyErr: errors.New("private notification failed")}
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, Config: port, Notification: port}
adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port}
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{
e2eClaim("patch-failure", "config.patch", map[string]any{"revision": "r1", "fields": []any{map[string]any{"key": "ServerName", "value": "Moonlight"}}}, stamp),
e2eClaim("notification-failure", "player.notify", map[string]any{"playerId": "76561198000000001", "message": "Moonlight ready"}, stamp),
}}
dispatcher := Dispatcher{Client: gateway, Registry: NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.patch": true, "player.notify": true}}, adapter), Now: func() time.Time { return stamp }}
dispatcher := Dispatcher{Client: gateway, Registry: NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.patch": true, "player.notify": true}}, adapter), Now: func() time.Time { return stamp }}
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
t.Fatalf("dispatch adapter failures: %v", err)
}
@@ -44,9 +44,9 @@ func (fixture *notificationPortFixture) SendPlayerNotification(_ context.Context
return UE4SSNotificationReceipt{Accepted: fixture.accepted}, nil
}
func TestVersionedAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testing.T) {
func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testing.T) {
port := &configPortFixture{fields: map[string]string{"ServerName": "Moon", "hostPath": "C:/secret", "Password": "nope"}}
adapter := VersionedAdapter{ServerVersion: "0.9.700.90357", Config: port, DiagnosticsState: map[string]string{"status": "healthy", "hostPath": "C:/secret"}}
adapter := RuntimeAdapter{Config: port, DiagnosticsState: map[string]string{"status": "healthy", "hostPath": "C:/secret"}}
read, err := adapter.ReadConfiguration(context.Background())
if err != nil {
t.Fatalf("read config: %v", err)
@@ -70,7 +70,7 @@ func TestVersionedAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *tes
func TestVersionedUE4SSNotificationIsFixedTypedAndRedacted(t *testing.T) {
port := &notificationPortFixture{accepted: true}
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, Notification: port}
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
result, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moon \"gift\""})
if err != nil || !result["accepted"].(bool) || len(port.deliveries) != 1 {
t.Fatalf("typed notification was not delivered: result=%+v err=%v deliveries=%+v", result, err, port.deliveries)
@@ -84,13 +84,9 @@ func TestVersionedUE4SSNotificationIsFixedTypedAndRedacted(t *testing.T) {
}
}
func TestVersionedUE4SSNotificationFailsClosedForUnpinnedBuildOrInvalidRecipient(t *testing.T) {
func TestRuntimeNotificationRejectsInvalidRecipient(t *testing.T) {
port := &notificationPortFixture{accepted: true}
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: "3.0.2", UE4SSReferenceRevision: UE4SSReferenceRevision, Notification: port}
if _, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moonlight"}); err == nil {
t.Fatal("unpinned UE4SS build must be unavailable")
}
adapter.UE4SSBuild = UE4SSReferenceBuild
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
if _, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "not-a-steam-id", "message": "Moonlight"}); err == nil {
t.Fatal("unverified recipient identity must be rejected")
}
@@ -99,8 +95,8 @@ func TestVersionedUE4SSNotificationFailsClosedForUnpinnedBuildOrInvalidRecipient
func TestNotificationFailureIsCachedWithoutInvokingRewardDelivery(t *testing.T) {
stamp := time.Now().UTC()
port := &notificationPortFixture{accepted: false}
adapter := VersionedAdapter{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", UE4SSBuild: UE4SSReferenceBuild, UE4SSReferenceRevision: UE4SSReferenceRevision, Notification: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"player.notify": true}}, adapter)
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"player.notify": true}}, adapter)
command := ClaimedCommand{ID: "notification-1", ProfileKey: ProfileKey, CommandType: "player.notify", Payload: map[string]any{"playerId": "76561198000000001", "message": "Moonlight"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}
for range 2 {
result, err := registry.Execute(context.Background(), command)
@@ -115,7 +111,7 @@ func TestNotificationFailureIsCachedWithoutInvokingRewardDelivery(t *testing.T)
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}
adapter := RuntimeAdapter{BoundServerID: "server-1", 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)
@@ -131,7 +127,7 @@ func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivateAuditOnly(t *testing.T)
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}
adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port}
for name, testCase := range map[string]struct {
outcome string
receipt UE4SSVehicleSpawnReceipt
@@ -151,17 +147,13 @@ func TestVersionedVehicleSpawnFailsClosedAndClassifiesBoundedReceipts(t *testing
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)
adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", 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)
@@ -21,14 +21,13 @@ type SafeAdapter interface {
SpawnVehicle(context.Context, map[string]any) (map[string]any, error)
}
// ServerBoundAdapter lets a versioned adapter prove that it is configured for
// ServerBoundAdapter lets a runtime adapter prove that it is configured for
// the same server as the registration which declared handler availability.
// Generic test adapters do not need this optional assertion.
type ServerBoundAdapter interface{ ServerBinding() string }
type HandlerAvailability struct {
BoundServerID string
ServerVersion string
Capabilities map[string]bool
Approved bool
}
@@ -85,7 +84,7 @@ func (registry *HandlerRegistry) Execute(ctx context.Context, command ClaimedCom
return unsupportedResult("unsupported"), nil
}
handler, exists := registry.handlers[command.CommandType]
if !exists || strings.TrimSpace(registry.availability.ServerVersion) == "" {
if !exists {
return unsupportedResult("unsupported"), nil
}
payload, err := handler(ctx, command.Payload)
@@ -171,15 +170,14 @@ func validateCommandPayload(commandType string, payload map[string]any) error {
}
return nil
case "game-state.patch":
if err := require("playerId", "gameVersion", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil {
if err := require("playerId", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil {
return err
}
if err := noUnknown("playerId", "gameVersion", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil {
if err := noUnknown("playerId", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil {
return err
}
version, ok := payload["gameVersion"].(string)
changes, changesOK := payload["changes"].([]any)
if !ok || version == "" || !changesOK || len(changes) == 0 || len(changes) > 8 {
if !changesOK || len(changes) == 0 || len(changes) > 8 {
return fmt.Errorf("state patch payload is invalid")
}
return nil
@@ -337,7 +335,7 @@ func (dispatcher Dispatcher) Run(ctx context.Context) error {
// Runtime keeps the registered companion alive with bounded heartbeat and
// polling intervals. It owns no host connection or game credential; handlers
// are the only route to a version-bound adapter.
// are the only route to runtime capability adapters.
type RuntimeGateway interface {
CommandGateway
Register(context.Context) (Registration, error)
@@ -13,7 +13,7 @@ func TestDispatcherIntegrationContainsUnsafeAndUnavailableCommands(t *testing.T)
stamp := time.Now().UTC()
adapter := &adapterFixture{}
registry := NewHandlerRegistry(HandlerAvailability{
BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true,
BoundServerID: "server-1", Approved: true,
Capabilities: map[string]bool{"config.read": true},
}, adapter)
fixture := &dispatchFixture{commands: []ClaimedCommand{
@@ -52,7 +52,7 @@ func (*adapterFixture) SpawnVehicle(context.Context, map[string]any) (map[string
func TestDispatcherAcknowledgesOnlyLiveValidatedTypedCommands(t *testing.T) {
stamp := time.Now().UTC()
adapter := &adapterFixture{}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
fixture := &dispatchFixture{commands: []ClaimedCommand{{ID: "read-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "expired-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 8, LeaseExpiresAt: stamp.Add(-time.Second), ExpiresAt: stamp.Add(-time.Second)}}}
dispatcher := Dispatcher{Client: fixture, Registry: registry, Now: func() time.Time { return stamp }}
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
@@ -69,7 +69,7 @@ func TestDispatcherAcknowledgesOnlyLiveValidatedTypedCommands(t *testing.T) {
func TestRegistryReturnsCachedResultForDuplicateDelivery(t *testing.T) {
stamp := time.Now().UTC()
adapter := &adapterFixture{}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
command := ClaimedCommand{ID: "duplicate-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}
if _, err := registry.Execute(context.Background(), command); err != nil {
t.Fatalf("first execute: %v", err)
@@ -84,7 +84,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{})
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", 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)}, {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" {
@@ -1,17 +1,96 @@
package companion
// SemanticEventProducerAvailability is intentionally fail-closed. The pinned
// UE4SS reference exposes command dispatch and online chat only; it does not
// expose a versioned server-side login, logout, position, vehicle, or network
// identity producer. Do not add a parser until such a source is versioned.
import (
"crypto/sha256"
"encoding/hex"
"strings"
"time"
)
// ConsoleRecord is supplied by Run's stdout/stderr stream, not by the server
// execution log. The channel never accepts a file path or a raw log archive.
type ConsoleRecord struct {
ServerID string
Stream string
Sequence uint64
OccurredAt time.Time
Text string
}
type SemanticEvent struct {
ServerID string
Sequence uint64
Type string
PlayerID string
OccurredAt time.Time
NetworkCorrelation string
}
type EventDiagnostic struct {
ServerID string
Sequence uint64
Code string
}
type SemanticEventBatch struct {
ServerID string
FirstSequence uint64
Events []SemanticEvent
Diagnostics []EventDiagnostic
}
// ParseConsoleRecords accepts only bounded stdout/stderr records. Unknown
// formats produce a bounded diagnostic and are skipped; they never fabricate
// events or carry raw console text across the plugin boundary.
func ParseConsoleRecords(serverID string, records []ConsoleRecord, correlationSecret string) SemanticEventBatch {
batch := SemanticEventBatch{ServerID: serverID}
if len(records) > 100 {
records = records[:100]
}
for _, record := range records {
if record.ServerID != serverID || (record.Stream != "stdout" && record.Stream != "stderr") || record.Sequence == 0 || record.OccurredAt.IsZero() || len(record.Text) > 1024 {
batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "invalid-console-record"})
continue
}
if batch.FirstSequence == 0 {
batch.FirstSequence = record.Sequence
}
event, ok := parseConsoleRecord(record, correlationSecret)
if !ok {
batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "unknown-console-format"})
continue
}
batch.Events = append(batch.Events, event)
}
return batch
}
func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) {
fields := strings.Fields(record.Text)
if len(fields) < 3 || fields[0] != "SCUM" || (fields[1] != "LOGIN" && fields[1] != "LOGOUT") || !steamID64(fields[2]) {
return SemanticEvent{}, false
}
eventType := "scum.login"
if fields[1] == "LOGOUT" {
eventType = "scum.logout"
}
event := SemanticEvent{ServerID: record.ServerID, Sequence: record.Sequence, Type: eventType, PlayerID: fields[2], OccurredAt: record.OccurredAt}
if len(fields) == 4 && secret != "" {
event.NetworkCorrelation = networkCorrelation(record.ServerID, fields[3], secret)
}
return event, true
}
func networkCorrelation(serverID, value, secret string) string {
digest := sha256.Sum256([]byte(serverID + "\x00" + secret + "\x00" + value))
return hex.EncodeToString(digest[:16])
}
func appendDiagnostic(existing []EventDiagnostic, diagnostic EventDiagnostic) []EventDiagnostic {
if len(existing) >= 32 {
return existing
}
return append(existing, diagnostic)
}
func VerifiedSemanticEventProducer() SemanticEventProducerAvailability {
return SemanticEventProducerAvailability{Available: true, Reason: "Run stdout/stderr semantic parser is available"}
}
type SemanticEventProducerAvailability struct {
Available bool
Reason string
}
func VerifiedSemanticEventProducer() SemanticEventProducerAvailability {
return SemanticEventProducerAvailability{
Available: false,
Reason: "no versioned SCUM server-side semantic event producer is installed",
}
}
@@ -1,10 +1,20 @@
package companion
import "testing"
import (
"testing"
"time"
)
func TestVerifiedSemanticEventProducerFailsClosedWithoutASource(t *testing.T) {
func TestConsoleSemanticEventProducerParsesOnlyBoundedKnownOutput(t *testing.T) {
availability := VerifiedSemanticEventProducer()
if availability.Available || availability.Reason == "" {
t.Fatalf("semantic events must remain unavailable without a versioned source: %+v", availability)
if !availability.Available || availability.Reason == "" {
t.Fatalf("console event producer should be available: %+v", availability)
}
batch := ParseConsoleRecords("server-1", []ConsoleRecord{{ServerID: "server-1", Stream: "stdout", Sequence: 1, OccurredAt: time.Now(), Text: "SCUM LOGIN 76561198000000001 10.0.0.1"}, {ServerID: "server-1", Stream: "stderr", Sequence: 2, OccurredAt: time.Now(), Text: "unrecognised output"}}, "fixture-secret")
if len(batch.Events) != 1 || batch.Events[0].Type != "scum.login" || batch.Events[0].NetworkCorrelation == "" || len(batch.Diagnostics) != 1 || batch.Diagnostics[0].Code != "unknown-console-format" {
t.Fatalf("unsafe console parsing result: %+v", batch)
}
if batch.Events[0].NetworkCorrelation == "10.0.0.1" {
t.Fatal("raw network value leaked")
}
}
@@ -3,26 +3,26 @@ import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from ".
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>;
availability(feature: SCUMFeatureKey): Promise<SCUMFeatureAvailability>; readConfig(): 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>;
};
export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, serverVersion: string, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI {
const availability = async (feature: SCUMFeatureKey) => availableFeatures.find((item) => item.feature === feature) ?? { feature, available: false, reason: "插件未声明此功能。", serverVersion };
export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI {
const availability = async (feature: SCUMFeatureKey) => availableFeatures.find((item) => item.feature === feature) ?? { feature, available: false, reason: "插件未声明此功能。" };
return {
availability,
async readConfig(version) { const result = await bridge.dispatch("game-client.command", { type: "config.read", version }); return result.status === "ok" ? decode<SCUMConfigRead>(result.result) : null; },
async readConfig() { const result = await bridge.dispatch("game-client.command", { type: "config.read" }); return result.status === "ok" ? decode<SCUMConfigRead>(result.result) : null; },
async patchConfig(patch) { const error = validateConfigPatch(patch); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "config.patch", patch: JSON.stringify(patch) })); },
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 requestStatePatch(patch) { const error = validateStatePatch(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: [] }; }
};
}
function commandResult(result: { status: string; result?: Record<string, string>; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入受控队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前版本不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "受控操作未被接受。" }; }
function commandResult(result: { status: string; result?: Record<string, string>; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入受控队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "受控操作未被接受。" }; }
function decode<T>(result: Record<string, string> | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } }
@@ -1,10 +1,10 @@
export const scumFeatureKeys = ["configuration", "players", "rewards", "state-patches", "trajectories"] as const;
export type SCUMFeatureKey = (typeof scumFeatureKeys)[number];
export type SCUMFeatureAvailability = { feature: SCUMFeatureKey; available: boolean; reason?: string; serverVersion?: string };
export type SCUMFeatureAvailability = { feature: SCUMFeatureKey; available: boolean; reason?: string };
export type SCUMMigrationProvenance = "plugin" | "transitional-read-only";
export type SCUMMigrationRecord<T = Record<string, unknown>> = { provenance: SCUMMigrationProvenance; readOnly: boolean; payload: T; recordedAt: string; sourceRecordId?: string };
export type SCUMFeatureMigrationAuthority = { serverInstanceId: string; serverVersion: string; feature: SCUMFeatureKey; authority: "plugin" | "transitional-read-only"; reason?: string };
export type SCUMFeatureMigrationAuthority = { serverInstanceId: 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 };
@@ -14,8 +14,8 @@ export type SCUMConfigField = {
key: string; label: string; description: string; control: "text" | "number" | "port" | "boolean";
configKey: string; defaultValue: string; restartImpact: "restart-required" | "none"; minimum?: number; maximum?: number;
};
export type SCUMConfigRead = { version: string; fields: Record<string, string>; observedAt: string };
export type SCUMConfigPatch = { version: string; changes: Array<{ key: string; value: string }>; reason: string; idempotencyKey: string };
export type SCUMConfigRead = { fields: Record<string, string>; observedAt: string };
export type SCUMConfigPatch = { changes: Array<{ key: string; value: string }>; reason: string; idempotencyKey: string };
export type SCUMPlayer = { id: string; gamePlayerId: string; displayName: string; lastSeenAt?: string; status: "online" | "offline" | "unknown" };
export type SCUMPlayerSession = { id: string; playerId: string; kind: "login" | "logout"; occurredAt: string; networkCorrelation?: string };
@@ -23,12 +23,12 @@ export type SCUMPlayerRisk = { kind: string; level: "low" | "medium" | "high"; o
export type SCUMPlayerProfile = { player: SCUMPlayer; sessions: SCUMPlayerSession[]; risks: SCUMPlayerRisk[] };
export type SCUMGiftItem = { key: string; label: string; quantity: number };
export type SCUMGiftRevision = { id: string; catalogId: string; revision: number; gameVersion: string; items: SCUMGiftItem[]; publishedAt: string };
export type SCUMGiftRevision = { id: string; catalogId: string; revision: number; items: SCUMGiftItem[]; publishedAt: string };
export type SCUMGiftGrant = { id: string; revisionId: string; playerId: string; notice: string; status: "pending-approval" | "queued" | "delivered" | "notification_failed" | "failed" | "unknown"; createdAt: string; completedAt?: string };
export type SCUMStateField = { key: string; label: string; value: number; minimum: number; maximum: number; editable: boolean; reason?: string };
export type SCUMStateSnapshot = { playerId: string; gameVersion: string; stateVersion: string; safetyWindow?: string; fields: SCUMStateField[]; observedAt: string };
export type SCUMStatePatch = { id: string; playerId: string; gameVersion: string; expectedStateVersion: string; safetyWindow: string; reason: string; changes: Array<{ fieldKey: string; before: number; after: number }>; status: "pending-approval" | "queued" | "succeeded" | "failed" | "unsupported" | "unknown"; createdAt: string };
export type SCUMStateSnapshot = { playerId: string; stateVersion: string; safetyWindow?: string; fields: SCUMStateField[]; observedAt: string };
export type SCUMStatePatch = { id: string; playerId: string; expectedStateVersion: string; safetyWindow: string; reason: string; changes: Array<{ fieldKey: string; before: number; after: number }>; status: "pending-approval" | "queued" | "succeeded" | "failed" | "unsupported" | "unknown"; createdAt: string };
export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subjectType: "player" | "vehicle"; x: number; y: number; z?: number; source: string };
export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance };
@@ -4,13 +4,13 @@ import { configurationCatalog } from "./schemas.js";
export function transitionalReadOnly<T extends Record<string, unknown>>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord<T> { return { provenance: "transitional-read-only", readOnly: true, payload, recordedAt, sourceRecordId }; }
export function pluginOwned<T extends Record<string, unknown>>(payload: T, recordedAt: string): SCUMMigrationRecord<T> { return { provenance: "plugin", readOnly: false, payload, recordedAt }; }
// The authority flag is exact-server and exact-version. Missing, duplicate, or
// The authority flag is exact-server. Missing, duplicate, or
// transitional flags fail closed: history remains readable, but plugin writes
// are not enabled. Execution still additionally requires Companion feature
// availability; this flag never authorizes a command by itself.
export function migrationStatus(flags: readonly SCUMFeatureMigrationAuthority[], serverInstanceId: string, serverVersion: string, feature: SCUMFeatureKey): SCUMFeatureMigrationStatus {
const matches = flags.filter((flag) => flag.serverInstanceId === serverInstanceId && flag.serverVersion === serverVersion && flag.feature === feature);
if (matches.length !== 1) return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: matches.length ? "迁移标记冲突,已保持只读。" : "当前服务器版本尚未启用插件权威记录。" };
export function migrationStatus(flags: readonly SCUMFeatureMigrationAuthority[], serverInstanceId: string, feature: SCUMFeatureKey): SCUMFeatureMigrationStatus {
const matches = flags.filter((flag) => flag.serverInstanceId === serverInstanceId && flag.feature === feature);
if (matches.length !== 1) return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: matches.length ? "迁移标记冲突,已保持只读。" : "当前服务器尚未启用插件权威记录。" };
const flag = matches[0];
if (flag.authority !== "plugin") return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: flag.reason ?? "过渡记录仅供只读查看。" };
return { authority: "plugin", readOnlyHistory: true, pluginWritesEnabled: true, reason: flag.reason };
@@ -23,8 +23,8 @@ export function migratePlayerRecord(record: Record<string, unknown>): SCUMMigrat
}
export function migrateConfigurationRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMConfigRead> | null {
const version = text(record.version) ?? text(record.gameVersion); const fields = version ? allowlistedConfigFields(version, record.fields) : null; const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!version || !fields || !observedAt) return null;
return transitionalReadOnly({ version, fields, observedAt }, observedAt, text(record.id));
const fields = allowlistedConfigFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!fields || !observedAt) return null;
return transitionalReadOnly({ fields, observedAt }, observedAt, text(record.id));
}
export function migratePlayerProfileRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayerProfile> | null {
@@ -42,9 +42,9 @@ export function migrateGiftGrantRecord(record: Record<string, unknown>): SCUMMig
}
export function migrateStatePatchRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMStatePatch> | null {
const id = text(record.id); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const gameVersion = text(record.gameVersion); const expectedStateVersion = text(record.expectedStateVersion); const safetyWindow = text(record.safetyWindow); const reason = optionalText(record.reason) ?? ""; const status = stateStatus(record.status); const createdAt = timestamp(record.createdAt); const changes = array(record.changes).map(migrateStateChange).filter((item): item is { fieldKey: string; before: number; after: number } => item !== null);
if (!id || !playerId || !gameVersion || !expectedStateVersion || !safetyWindow || !status || !createdAt || !changes.length) return null;
return transitionalReadOnly({ id, playerId, gameVersion, expectedStateVersion, safetyWindow, reason, changes, status, createdAt }, timestamp(record.updatedAt) ?? createdAt, id);
const id = text(record.id); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const expectedStateVersion = text(record.expectedStateVersion); const safetyWindow = text(record.safetyWindow); const reason = optionalText(record.reason) ?? ""; const status = stateStatus(record.status); const createdAt = timestamp(record.createdAt); const changes = array(record.changes).map(migrateStateChange).filter((item): item is { fieldKey: string; before: number; after: number } => item !== null);
if (!id || !playerId || !expectedStateVersion || !safetyWindow || !status || !createdAt || !changes.length) return null;
return transitionalReadOnly({ id, playerId, expectedStateVersion, safetyWindow, reason, changes, status, createdAt }, timestamp(record.updatedAt) ?? createdAt, id);
}
export function migrateTrajectoryRecord(record: Record<string, unknown>): SCUMTrajectory | null {
@@ -63,7 +63,7 @@ function migratePoint(value: unknown, defaultSubjectId: string, defaultSubjectTy
function migrateSession(value: unknown, defaultPlayerId: string): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId) ?? defaultPlayerId); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; }
function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; }
function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; }
function allowlistedConfigFields(version: string, value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog(version).map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; }
function allowlistedConfigFields(value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; }
function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; }
function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown" || value === "execution-unknown") return value === "execution-unknown" ? "unknown" : value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "confirmation-failed" || value === "failed" ? "failed" : null; }
function trajectorySubjectType(record: Record<string, unknown>): SCUMTrajectoryPoint["subjectType"] | null { if (record.kind === "player" || record.kind === "vehicle") return record.kind; return text(record.playerRecordId) || text(record.gamePlayerRecordId) ? "player" : text(record.vehicleId) ? "vehicle" : null; }
@@ -2,27 +2,27 @@ import { configurationCatalog, stateFieldCatalog, vehicleSpawnCatalog } from "./
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 type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspace?: SCUMFeatureWorkspace };
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 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");
const e = react.createElement; const fields = input.workspace?.configFields?.length ? input.workspace.configFields : configurationCatalog; const vehicleCodes = vehicleSpawnCatalog; const scoped = Boolean(input.serverInstanceId); const canRead = scoped && input.permissions.includes("server.game-client.read"); const canCommand = scoped && input.permissions.includes("server.game-client.command"); const canMaintain = scoped && input.permissions.includes("server.game-client.maintenance");
return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" },
e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "SCUM 语义、界面和适配器由插件提供;平台仅提供已授权的服务器隔离宿主。")), e("span", { className: "page-status" }, availabilityText(input.availability, scoped))),
e("div", { className: "console-row-list" }, e("div", { className: "console-row" }, e("strong", null, "绑定服务器"), e("span", null, input.serverInstanceId ?? "未绑定")), e("div", { className: "console-row" }, e("strong", null, "配置版本目录"), e("span", null, version)), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))),
e("div", { className: "console-row-list" }, e("div", { className: "console-row" }, e("strong", null, "绑定服务器"), e("span", null, input.serverInstanceId ?? "未绑定")), e("div", { className: "console-row" }, e("strong", null, "运行时 schema"), e("span", null, "按受限通道探测")), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))),
configurationPanel(e, fields, canRead, canMaintain, featureAvailability(input, "config.manage")),
playerPanel(e, canRead, featureAvailability(input, "player.intelligence")),
rewardPanel(e, canRead, canCommand, featureAvailability(input, "reward.delivery")),
statePanel(e, version, canRead, canMaintain, featureAvailability(input, "state.patch")),
statePanel(e, canRead, canMaintain, featureAvailability(input, "state.patch")),
vehicleSpawnPanel(e, vehicleCodes, canCommand, featureAvailability(input, "vehicle.spawn")),
trajectoryPanel(e, canRead, featureAvailability(input, "trajectory.collect"))
);
}
function configurationPanel(e: ReactLike["createElement"], fields: readonly { key: string; label: string; description: string; control: string; restartImpact: string }[], canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 配置工作台" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "版本化配置字段目录"), e("p", { className: "provider-id" }, "每项修改先生成可审查差异,再由受控 Companion 执行。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取配置")), e("div", { className: "console-record-list" }, fields.map((field) => e("div", { className: "console-record", key: field.key }, e("strong", null, field.label), e("span", null, `${field.description} · ${field.control}`), e("small", null, field.restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效")))), e("p", { className: "page-status" }, canMaintain ? "配置写入仅在审批、版本和处理器可用时开放。" : "当前服务器上下文没有配置维护权限。")); }
function configurationPanel(e: ReactLike["createElement"], fields: readonly { key: string; label: string; description: string; control: string; restartImpact: string }[], canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 配置工作台" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "运行时配置字段目录"), e("p", { className: "provider-id" }, "每项修改先生成可审查差异,再由受控 Companion 执行。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取配置")), e("div", { className: "console-record-list" }, fields.map((field) => e("div", { className: "console-record", key: field.key }, e("strong", null, field.label), e("span", null, `${field.description} · ${field.control}`), e("small", null, field.restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效")))), e("p", { className: "page-status" }, canMaintain ? "配置写入仅在审批处理器可用时开放。" : "当前服务器上下文没有配置维护权限。")); }
function playerPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 玩家档案" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家、登录与风险信号"), e("p", { className: "provider-id" }, "只展示 Companion 已验证的语义事件;网络关联是按服务器不可逆计算,不上传原始网络值。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "查询玩家")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有玩家读取权限。" : availability.available ? "等待已验证的登录或登出事件。" : availability.reason ?? "没有兼容的事件生产者。")); }
function rewardPanel(e: ReactLike["createElement"], canRead: boolean, canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 礼物与通知" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "冻结礼物版本与通知"), e("p", { className: "provider-id" }, "物品投递与通知分离;未知投递结果不会自动重试。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "申请投递")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有礼物读取权限。" : !canCommand ? "当前服务器上下文没有受控投递权限。" : availability.reason ?? "需要已冻结 revision、已验证玩家身份和兼容处理器。")); }
function statePanel(e: ReactLike["createElement"], 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 statePanel(e: ReactLike["createElement"], canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { const fields = stateFieldCatalog; return e("section", { className: "console-panel", "aria-label": "SCUM 受控状态修改" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受控属性修改"), e("p", { className: "provider-id" }, "仅列出运行时探测且在字段白名单中的字段,执行时要求预读、安全窗口与读后确认。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !canMaintain || !availability.available }, "创建修改申请")), e("div", { className: "console-row-list" }, fields.length ? fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.minimum}${field.maximum}`))) : e("p", { className: "page-status" }, "当前运行时没有已验证的状态字段。")), e("p", { className: "page-status" }, canMaintain ? availability.reason ?? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。")); }
function vehicleSpawnPanel(e: ReactLike["createElement"], vehicles: readonly { code: string; label: string }[], canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 受限载具生成" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受限载具生成"), e("p", { className: "provider-id" }, "仅可选择受控目录中的载具;不会显示或接收原始指令、参数或回包。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "生成载具")), e("div", { className: "console-row-list" }, vehicles.map((vehicle) => e("div", { className: "console-row", key: vehicle.code }, e("strong", null, vehicle.label), e("span", null, vehicle.code)))), e("p", { className: "page-status" }, !canCommand ? "当前服务器上下文没有受控指令权限。" : availability.available ? "仅在审批和 Companion 处理器均可用时开放。" : availability.reason ?? "当前没有已验证的载具生成处理器。")); }
function trajectoryPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 地图轨迹" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家与载具轨迹"), e("p", { className: "provider-id" }, "仅接受插件声明的服务器侧位置与上下车事件源;绝不使用 OCR、截图或桌面自动化。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取轨迹")), e("p", { className: "page-status" }, canRead ? availability.reason ?? "当合法位置源可用时展示采样轨迹。" : "当前服务器上下文没有轨迹读取权限。")); }
function featureAvailability(input: SCUMPageContext, key: string): { available: boolean; reason?: string } { const feature = input.featureAvailability?.find((item) => item.key === key); return feature ?? { available: false, reason: "当前服务器版本没有已验证的 Companion 处理器或事件生产者。" }; }
function availabilityText(availability: { available: boolean; reason?: string }, scoped: boolean): string { if (!scoped) return "不可用:插件页面必须绑定服务器。"; return availability.available ? "已声明且已由 Companion 验证" : `不可用:${availability.reason ?? "没有兼容的 Companion 处理器或事件生产者"}`; }
function 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,49 +1,18 @@
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": [
{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 },
{ key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }
]
};
export const configurationFieldsByVersion: Record<string, readonly SCUMConfigField[]> = {
"0.9.700.90357": [
{ key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" },
{ key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7777", restartImpact: "restart-required" },
{ key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" },
{ key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "64", restartImpact: "restart-required" },
{ 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); }
// These are safe fallback allowlists. A Companion schema probe may narrow them
// per server, but a game version never enables or disables a feature.
export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" },
{ key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7777", restartImpact: "restart-required" },
{ key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" },
{ key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "64", restartImpact: "restart-required" },
{ key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
];
export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }];
export function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); }
export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; }
export function validateConfigPatch(patch: SCUMConfigPatch): string | null {
const catalog = configurationCatalog(patch.version); if (!catalog.length) return "当前 SCUM 版本没有受支持的配置字段目录。";
if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。";
for (const change of patch.changes) {
const field = catalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 未受当前版本支持。`;
if (!change.value.trim()) return `字段 ${field.label} 不能为空。`;
if (field.control === "number" || field.control === "port") { const value = Number(change.value); if (!Number.isInteger(value) || (field.minimum !== undefined && value < field.minimum) || (field.maximum !== undefined && value > field.maximum)) return `字段 ${field.label} 超出允许范围。`; }
}
return null;
}
export function validateStatePatch(serverVersion: string, fields: Array<{ fieldKey: string; before: number; after: number }>): string | null {
if (!fields.length) return "状态修改至少需要一个字段。";
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;
}
export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在受控目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; }
export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.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): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在受控目录中声明。"; return null; }
@@ -3,6 +3,4 @@ import type { SCUMFeatureWorkspace } from "../features/contracts.js";
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 });
}
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 }); }
@@ -3,10 +3,9 @@
"title": "SCUMGameStatePatchPayload",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "gameVersion", "expectedStateVersion", "safetyWindow", "reason", "changes"],
"required": ["playerId", "expectedStateVersion", "safetyWindow", "reason", "changes"],
"properties": {
"playerId": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"gameVersion": { "const": "0.9.700.90357" },
"expectedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"reason": { "type": "string", "minLength": 4, "maxLength": 240 },
@@ -3,10 +3,9 @@
"title": "SCUMPlayerStateSnapshot",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "gameVersion", "stateVersion", "maintenanceVerified", "playerOnline", "fields"],
"required": ["playerId", "stateVersion", "maintenanceVerified", "playerOnline", "fields"],
"properties": {
"playerId": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"gameVersion": { "type": "string", "maxLength": 64, "pattern": "^[0-9][0-9A-Za-z._-]{0,63}$" },
"stateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"maintenanceVerified": { "type": "boolean" },