From d7465bfd3229e2412a1a3e2d299e276189d2dcab Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 29 Jul 2026 18:34:16 +0800 Subject: [PATCH] refactor(scum): use runtime capability probes --- .../design.md | 100 ++------ .../implementation-blockers.md | 84 +----- .../proposal.md | 39 ++- .../scum-companion-runtime-adapter/spec.md | 71 +++--- .../scum-plugin-feature-ownership/spec.md | 51 ++-- .../tasks.md | 56 ++-- .../scum-server-plugin/companion/README.md | 7 +- .../companion/UE4SS_CAPABILITY.md | 57 +---- .../scum-server-plugin/companion/adapters.go | 241 +++++++++++++----- .../companion/adapters_e2e_test.go | 19 +- .../companion/adapters_test.go | 30 +-- .../companion/dispatcher.go | 14 +- .../companion/dispatcher_integration_test.go | 2 +- .../companion/dispatcher_test.go | 6 +- .../scum-server-plugin/companion/events.go | 101 +++++++- .../companion/events_test.go | 18 +- .../scum-server-plugin/features/api.ts | 12 +- .../scum-server-plugin/features/contracts.ts | 14 +- .../scum-server-plugin/features/migration.ts | 20 +- .../scum-server-plugin/features/page.ts | 18 +- .../scum-server-plugin/features/schemas.ts | 61 ++--- .../scum-server-plugin/page-bundle/index.ts | 4 +- .../game-state-patch.payload.schema.json | 3 +- .../bridge/player-state.snapshot.schema.json | 3 +- .../tests/fixtures/scum-migration-parity.ts | 8 +- plugins/tests/scum-feature-module.test.ts | 24 +- 26 files changed, 530 insertions(+), 533 deletions(-) diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/design.md b/openspec/changes/move-scum-feature-ownership-to-plugin/design.md index ef89636..db80529 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/design.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/design.md @@ -1,81 +1,29 @@ -## Context +## Design -The current SCUM work has a generic platform command/log bridge, but the five SCUM feature areas are implemented as SCUM-named services, models, API handlers, and React panels in `platform/` and `platform_web/`. The SCUM plugin only declares schemas and command names. Its Companion can register, heartbeat, claim, acknowledge, complete, and upload snapshots, but has no long-running dispatcher, SCUM command handlers, semantic-log uploader, or event collector. +The platform owns only reusable authorization, server isolation, auditing, +queues, opaque storage, and channels to Run. The SCUM plugin owns its page, +allowlists, schemas, event parsers, and Companion adapters. `platform_web` +mounts the declared plugin page generically. -The repository requires platform/run communication to remain channelized and forbids plugins from receiving credentials, raw host paths, direct sockets, or raw AI keys. The referenced legacy SCUM client uses OCR and keyboard/mouse automation, which is explicitly excluded from the trajectory feature and is not an acceptable replacement adapter. +Run emits SCUM process stdout/stderr records through the durable log channel; +these are not server execution logs. The Companion parses only declared, +bounded record formats into semantic events. Unknown records make a bounded +diagnostic and are skipped. A per-server correlation digest may be derived +locally but never includes a raw network value in an upload. -## Goals / Non-Goals +The Companion receives only typed commands and invokes only registered typed +ports. Its game-data port exposes allowlisted player, vehicle, and position +data as bounded projections, never DSNs, paths, credentials, or rows. Fixed +server-management ports expose only declared operations. State changes read +the precondition, verify a safe window, write allowed fields, then confirm the +write. Reward delivery freezes a grant and maps each receipt to delivered, +failed, or unknown without retrying unknown outcomes. A command's failure or +unknown result affects that command alone. -**Goals:** +Runtime capability/schema probes decide whether a particular handler is +available. They do not depend on a server/game/UE4SS/database version, build, +or source revision, and a failed probe never disables unrelated features. -- Make `plugins/examples/scum-server-plugin/` the owner of SCUM configuration semantics, feature APIs/types/UI, Companion behavior, event parsing, coordinate transforms, and command adapters. -- Keep `platform/` limited to reusable plugin primitives: authorization, server isolation, plugin-scoped opaque record storage, review/audit, typed command transport, durable log transport, retention scheduling, and plugin-bundle hosting. -- Establish a safe long-running Companion protocol that handles only declared typed commands, emits declared semantic events, and never exposes raw game/database/network data to the browser. -- Migrate without discarding existing records until equivalent plugin-owned reads and writes have been verified. - -**Non-Goals:** - -- Reintroducing `scum_robot`, `scum_client`, direct game-database access, OCR, screenshots, keyboard/mouse injection, desktop automation, or unrestricted RCON/SQL/JSON commands. -- Adding payment, QQ/SMS, cloud, or other legacy robot services. -- Automatically banning, punishing, or retrying unknown item-delivery outcomes. -- Claiming support for a SCUM version/operation before a versioned adapter has integration evidence. - -## Decisions - -### 1. Plugin-owned feature modules, generic platform primitives - -SCUM feature contracts, validators, repository adapters, Companion handlers, and UI modules SHALL live under the SCUM plugin directory. The platform SHALL provide generic names and opaque/plugin-scoped payloads rather than SCUM-named domain services or routes. - -The alternative of retaining SCUM services in the platform makes a manifest-only plugin easy to render but permanently couples every SCUM release to the platform binary. It is rejected because it is the ownership failure being corrected. - -### 2. Plugin page bundles mounted by a generic host - -The SCUM plugin SHALL declare a versioned page-bundle entry and page contracts. `platform_web` SHALL authenticate, load the declared bundle through a generic plugin-page host, and provide shared theme/navigation/permission context. It SHALL not import SCUM component classes or branch on `game.scum`. - -The alternative of metadata-only generic forms is insufficient for the map, player timeline, gift preview, and controlled attribute workflow. The alternative of a standalone plugin web application is rejected because it would bypass platform session, tenancy, and theme integration. - -### 3. Long-running SCUM Companion adapter - -The Companion SHALL run a bounded dispatch loop after registration. It SHALL claim declared commands, validate the exact command schema and SCUM capability/version, execute only a registered handler, and acknowledge/completely report idempotent typed results. It SHALL produce no output containing raw paths, credentials, IPs, database rows, or arbitrary RCON command text. A version-bound typed UE4SS adapter MAY record the exact generated command text in the command's protected audit payload (for example, the fixed `#spawnvehicle ` generated by `vehicle.spawn`); that audit record does not create a browser-visible or arbitrary-command RCON interface. - -The adapter SHALL include independent handlers for configuration read/patch, semantic log/event production, `reward.deliver`, `player.notify`, `game-state.patch`, and the separately bounded `vehicle.spawn`. A handler unavailable for a discovered server version SHALL return an explicit unsupported result; the platform must keep the operation disabled. - -Direct raw-RCON or raw-SQL dispatch is rejected: legacy code is reference material only and must be translated into narrow typed adapters. - -### 3.1 Fixed vehicle-spawn adapter - -`vehicle.spawn` is the sole authorized exception for a generated SCUM command template. Its manifest payload contains exactly one `vehicleCode`, constrained by the plugin's versioned allowlist and identifier pattern. The Companion independently validates the same allowlist, requires its bound server, approval, declared handler capability, pinned UE4SS 3.0.1 build, and pinned reference revision before calling a Companion-local, platform-authorized vehicle-spawn transport port. - -The adapter itself creates the exact `#spawnvehicle ` string and holds it only in a private transport/audit field. It accepts no command text, prefix, extra argument, target, shell text, SQL text, host path, socket, RCON credential, or reply text from callers. The pinned reference removes one leading `#` before dispatch, but the adapter does not infer completion from that implementation's unstructured response. The port returns a bounded receipt that the adapter maps to `succeeded`, `failed`, or `unknown`; unknown is never retried automatically. The plugin UI exposes only catalogued choices and enables its action only when the Companion reports the declared handler available. - -### 4. Declared semantic event pipeline - -The Companion SHALL parse only plugin-declared allowed log/extension sources and upload contiguous typed semantic event batches using the durable log channel. Login/logout, player/vehicle position, and vehicle transitions SHALL be emitted only when their source and required fields can be validated. Map coordinate conversion, sampling, and retention declarations remain SCUM-plugin configuration. - -The platform may persist and query events as opaque plugin records for isolation and retention, but it SHALL not contain SCUM-specific parsers or projectors. Events without a verified producer SHALL remain unavailable in the plugin UI rather than being simulated from test data. - -### 5. Staged migration and compatibility - -Migration SHALL proceed feature by feature behind a plugin capability/version flag. Platform-owned SCUM records are read-only migration input until the plugin module has parity tests and at least a controlled end-to-end Companion test. The generic platform host is switched only after the plugin bundle is available; SCUM-specific platform routes/components are removed only after no remaining callers exist. - -## Risks / Trade-offs - -- [SCUM may not expose a safe API for a requested operation] → require capability/version discovery and show disabled/unsupported, never fall back to raw DB writes or automation. -- [Plugin bundle isolation introduces a loading/deployment surface] → version page bundles with the manifest, verify integrity, and fail closed to an unavailable-page state. -- [Record migration can lose audit traceability] → preserve immutable audit linkage and migration provenance; do not bulk-delete old records before retention expiry and parity verification. -- [Semantic log formats can drift with SCUM updates] → version parser adapters, keep fixtures from supported formats, and reject unknown formats without inventing fields. -- [Moving too much out of platform duplicates security logic] → keep authorization, tenancy, queue, audit, and storage primitives generic and platform-owned. - -## Migration Plan - -1. Add generic plugin-page bundle and plugin-scoped record/event/command primitives without new SCUM-specific platform APIs. -2. Create the SCUM feature module and Companion dispatcher with read-only diagnostics and event-producer proof. -3. Migrate configuration and player/event reads, then gifts and state patches behind capability/version gates. -4. Replace the hard-coded SCUM imports in the platform host with the generic mount; run compatibility and end-to-end tests against an isolated non-production server. -5. Remove SCUM-named platform services/routes/models only after plugin parity, migration audit, and a rollback window. Rollback keeps the prior platform records read-only and disables the plugin capability flag; it never re-enables unsafe execution paths. - -## Open Questions - -- Which current SCUM server versions and legitimate extension/log sources can produce player position and vehicle transitions without OCR or input automation? -- Which skill/attribute and reward operations have a safe, documented, version-bound server-side adapter rather than legacy direct database mutation? -- Should plugin bundles be compiled from TypeScript into the manifest package or loaded as signed static artifacts from the plugin registry? The implementation must choose one before frontend migration begins. +`vehicle.spawn` is the one fixed administration template. It accepts only a +catalogued identifier and builds exactly `#spawnvehicle ` inside +the Companion. The text stays private to its typed transport/audit boundary. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md b/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md index c1223d7..9e7667f 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md @@ -1,73 +1,17 @@ -# Verified implementation blockers +## Runtime probe evidence -Checked 2026-07-29 against the pinned read-only UE4SS reference at -`bae91527355f14faa63c1df65f742cc48594ba1b` (UE4SS 3.0.1). The accompanying -capability evidence is recorded in -`plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md`. +The former UE4SS reference/build/revision requirement has been removed. SCUM +features are not disabled by an update string. The Companion uses typed, +platform-authorized non-production fixtures for configuration, player-state, +reward, notification, and vehicle operations; no remote server is contacted. -The reference offers a fixed, online-recipient `SendChat` path, which is -already bounded by the supported `player.notify` adapter. It also removes one -leading `#` before dispatching a command, but its raw response is not a stable -operation acknowledgement. Under the separately authorized fixed-template -exception, the plugin's `vehicle.spawn` adapter can generate only -`#spawnvehicle ` from its allowlist through a local authorized -transport port and maps its bounded receipt to success, failure, or unknown. -It is not a general RCON surface. The reference otherwise has no versioned -server-side schema, identity binding, acknowledgement contract, or isolated -non-production fixture for login/logout events, network correlation, position -or vehicle events, reward delivery, or state reads and writes. +Run's required integration boundary is a bounded stdout/stderr record stream, +typed database projections, and fixed administration ports. It must not expose +paths, DSNs, credentials, raw rows, arbitrary SQL, shell, socket, or RCON to +the plugin, platform web, or AI. Unknown console formats create a bounded +diagnostic and no event. -Controlled read-only migration fixtures now verify configuration, player -history, gifts, state-patch audits, and trajectories in -`plugins/tests/fixtures/scum-migration-parity.ts`. They preserve only -allowlisted plugin fields and maintain transition provenance. The existing -isolated Companion tests continue to cover its safe command boundary. Neither -test set provides an end-to-end SCUM executor or event producer. - -Consequently these tasks remain open and blocked rather than simulated: - -- 4.1–4.4: no legitimate versioned event producer exists for semantic player, - network, position, or vehicle data. -- 5.1 and 5.3: no documented, version-bound state-patch or reward-delivery - adapter exists. -- 6.2: controlled read-only migration fixtures and isolated Companion - typed-port coverage now exist for the supported configuration, notification, - and vehicle-spawn adapters, but no legitimate versioned producer exists for - the remaining player-history, reward, state-patch, or trajectory parity. -- 6.3: transitional SCUM APIs and models still have callers and cannot be - removed before the parity and rollback evidence required by 6.2. -- 6.4: final full verification is deferred until the blocked adapters and - isolated integration environment exist. - -No fallback to raw RCON, credentials, SQL, direct game-database access, OCR, -screenshots, keyboard/mouse injection, or desktop automation is permitted. - -## 6.3 frontend cleanup and rollback evidence (2026-07-29) - -The generic `PluginPageHostPage` has no hard-coded SCUM import or `game.scum` -branch. Static `rg` call-graph audit found zero non-test callers for the -following former `platform_web` implementation, so this batch removed it with -its private tests, contracts, schemas, API-client methods/types, and map-only -styles: - -- `ScumOperationsPanel`, `ScumFileConfigWorkbench`, and the private - `scumOperations` contract/schema; -- `GamePlayerIntelligencePanel` and `GameGiftCatalogPanel`; -- `ScumMapTrajectoryPanel`. - -Their plugin-owned replacement is `features/page.ts`, with typed bridge reads -and commands in `features/api.ts`, and the read-only provenance adapters in -`features/migration.ts`. The replacement remains capability-gated: missing -login/position event producers and missing state-patch/reward-delivery -handlers render unavailable controls and never synthesize records or enable a -fallback execution path. - -The platform-side `/game-players`, `/game-gifts`, and `/game-map-trajectories` -routes, DTO/domain/model/service/repository implementations, and their -fixtures remain intentionally. `plugins/tests/fixtures/scum-migration-parity.ts` -continues to consume their historical record shapes through allowlisted, -`transitional-read-only` migration records. Rollback therefore consists of -disabling the exact server/version plugin authority flag, leaving those prior -records visible and read-only; it does not restore any deleted host panel or -re-enable an unsafe execution route. Backend removal remains blocked until 6.2 -parity and the required versioned event/operation protocols exist. +Remaining production enablement is operational: a deployed Run implementation +must provide the declared typed ports. Until then only the affected operation +is reported unavailable; the plugin page and unrelated feature capabilities +remain active. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md b/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md index 57e7b6f..d0a2562 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md @@ -1,30 +1,23 @@ ## Why -The five recent SCUM deliveries implemented most SCUM-specific domain services and React panels in `platform/` and `platform_web/`, leaving the SCUM plugin as a manifest and schema declaration. This makes the plugin non-portable, hard-codes SCUM into the platform host, and does not provide a real server-side adapter that can collect events or execute approved game operations. +SCUM plugin behavior must survive server updates without treating a game, UE4SS, +database, build, or revision string as a feature kill switch. The prior plan +incorrectly used static compatibility gates. ## What Changes -- Move SCUM-specific feature ownership for configuration, local game-player intelligence, controlled player state changes, gifts, and map trajectories into the SCUM plugin package and its Companion runtime. -- Introduce a long-running, platform-authorized SCUM Companion adapter that receives only typed commands, emits only declared semantic events, and reports typed, idempotent results. -- Add one explicitly enabled, version-bound `vehicle.spawn` operation. It accepts only plugin-catalogued vehicle codes and can generate only the fixed `#spawnvehicle ` template through a Companion-local, platform-authorized transport port; it is not a raw-RCON interface. -- **BREAKING** Replace platform-owned SCUM panels and SCUM-specific API/domain endpoints with a plugin-page module mounted by the generic platform plugin host. -- **BREAKING** Replace platform-owned SCUM projections and persistence with plugin-scoped local data accessed through generic platform isolation, audit, job, and storage primitives. -- Preserve the existing platform responsibilities for authorization, server/tenant isolation, review and approval, durable job delivery, audit records, retention enforcement, and generic page hosting. -- Treat current platform-side implementations as transitional control-plane behavior; do not claim an operation is available until the Companion has a verified executor or event producer. +- Move all SCUM feature authority to the plugin and its Companion, with generic + platform authorization, isolation, audit, queue, storage, and Run channels. +- Replace build/version/revision gates with runtime schema and capability probes. +- Let Run provide bounded SCUM stdout/stderr records, typed database reads, and + fixed administration operations only through platform-authorized channels. +- Require field allowlists, pre-read/safe-window/write-confirmation flows, and + `succeeded`/`failed`/`unknown` results for mutating adapters. +- Preserve fixed-template `vehicle.spawn`; its private `#spawnvehicle + ` audit text never enters a result or page payload. -## Capabilities +## Non-Goals -### New Capabilities - -- `scum-plugin-feature-ownership`: Defines the required ownership boundary between the generic platform and the SCUM plugin, including plugin-page mounting and migration of the five SCUM feature areas. -- `scum-companion-runtime-adapter`: Defines the safe, typed Companion command/event adapter needed for real SCUM operation execution and semantic event collection. - -### Modified Capabilities - -- None. The prior SCUM requirements currently exist only in completed change artifacts, not in the repository's canonical `openspec/specs/` tree; this change establishes their replacement canonical contract. - -## Impact - -- Affected roots: `plugins/examples/scum-server-plugin/`, `plugins/sdk/`, `platform/`, and `platform_web/`. -- The SCUM Companion becomes the only place that translates approved typed operations into SCUM/RCON/legitimate-extension work and translates server signals into semantic events. -- Existing SCUM-specific platform APIs, models, services, and hard-coded frontend panels will require a staged migration with compatibility checks; unrelated platform and plugin behavior remains out of scope. +No arbitrary RCON, SQL, shell, socket, path, DSN, credential, raw database +row, OCR, screenshot, keyboard/mouse injection, or desktop automation is +introduced. No SCUM import or `game.scum` branch is added to `platform_web`. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md index b731335..37c38a6 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md @@ -1,45 +1,52 @@ ## ADDED Requirements -### Requirement: Companion dispatches only declared typed SCUM operations -The SCUM Companion SHALL run a bounded authenticated command-dispatch loop and SHALL execute only command types declared by the installed SCUM plugin, validated against their payload schema, server binding, permission/approval result, and compatible SCUM capability/version. It SHALL return an idempotent typed result for every claimed command. +### Requirement: Companion uses runtime capability isolation -#### Scenario: Supported gift delivery command -- **WHEN** the Companion claims a valid, unexpired `reward.deliver` command for a server version with a registered delivery handler -- **THEN** it executes only that handler and completes the command with a typed delivered, failed, notification-failed, or unknown result +The SCUM Companion SHALL dispatch only declared typed handlers bound to its +authorized server. Handler availability SHALL come from runtime capability and +schema probes, not a game, UE4SS, database, build, revision, or version gate. +A probe or command failure SHALL affect only that handler invocation. -#### Scenario: Unsupported or malformed command -- **WHEN** the Companion claims a command whose type, schema, capability, or version is not supported -- **THEN** it does not invoke SCUM, RCON, a database, OCR, or desktop automation and completes the command with an explicit unsupported or validation failure result +#### Scenario: A runtime adapter is unavailable -### Requirement: Vehicle spawn uses one fixed, version-bound template -The SCUM Companion SHALL execute `vehicle.spawn` only when the installed plugin declares it, the bound server is approved, the Companion reports its handler available, and the pinned UE4SS 3.0.1 build/reference revision is present. The payload SHALL contain exactly one plugin-allowlisted vehicle code matching the declared identifier pattern. The adapter SHALL generate exactly `#spawnvehicle ` internally and SHALL retain that text only in protected transport/audit data. It SHALL not accept or expose raw command text, additional arguments, targets, RCON credentials, sockets, shell/SQL text, host paths, or raw transport replies. It SHALL return a structured `succeeded`, `failed`, or `unknown` outcome and SHALL not automatically retry an unknown outcome. +- **WHEN** a typed port or schema probe is unavailable +- **THEN** the Companion returns a typed unavailable/failed/unknown result for + that command and does not disable an unrelated plugin feature -#### Scenario: Approved, supported vehicle spawn -- **WHEN** an approved `vehicle.spawn` command carries a declared vehicle code for the pinned UE4SS adapter and the local authorized transport reports acceptance -- **THEN** the Companion sends only the internally generated fixed template, completes with a structured `succeeded` outcome, and omits the generated text from the command result +### Requirement: Run data channels are bounded -#### Scenario: Unsafe vehicle-spawn input or unavailable handler -- **WHEN** a vehicle-spawn payload has an unlisted code, extra field, command text, target, credential, or the Companion has not declared the compatible handler -- **THEN** the Companion performs no transport call and returns validation-failed or unsupported without exposing protected audit text +Run SHALL send SCUM stdout/stderr records to the Companion through the durable +log channel and SHALL provide database data only as typed allowlisted +projections and fixed server-management operations. No plugin, web page, or +AI request SHALL receive a path, DSN, credential, raw row, arbitrary SQL, +shell, socket, or RCON command. -### Requirement: Companion emits validated semantic SCUM events -The SCUM Companion SHALL collect only declared allowed sources and upload contiguous semantic event batches through the platform's durable log channel. It SHALL validate required event fields before upload and SHALL not emit raw IP addresses, network fingerprints, host paths, credentials, database rows, screenshots, or arbitrary RCON command text. A version-bound typed UE4SS adapter MAY retain the exact generated command text in protected command audit data; it SHALL never expose that text as a general RCON command surface or semantic event payload. +#### Scenario: Unknown console output -#### Scenario: Valid login event -- **WHEN** a supported SCUM source produces a successful-login record containing the declared player identity and timestamp fields -- **THEN** the Companion uploads a validated `scum.login` semantic event and the plugin can create or update the local game-player profile +- **WHEN** stdout or stderr does not match a declared semantic parser +- **THEN** the Companion records a bounded diagnostic and uploads no semantic + event or raw line -#### Scenario: Unknown source format -- **WHEN** a log or extension source does not match a supported parser version or lacks required fields -- **THEN** the Companion records a bounded diagnostic and does not fabricate a semantic login, position, vehicle, or security event +### Requirement: Mutations prove safety -### Requirement: Map event collection remains controlled -The SCUM Companion SHALL emit `player.position`, `vehicle.position`, `player.vehicle.enter`, and `player.vehicle.leave` only from a verified legitimate server-side source declared by the plugin. The plugin SHALL apply its declared coordinate transform, sampling precision, and retention policy before exposing trajectory data. +State patch adapters SHALL use field allowlists, a pre-read, safe-window +verification, a bounded write, and read-after-write confirmation. Reward +adapters SHALL freeze their typed grant and return delivered, failed, or +unknown without automatically retrying unknown outcomes. -#### Scenario: Position source is unavailable -- **WHEN** no supported server-side source can provide a validated player or vehicle position -- **THEN** the trajectory page reports collection unavailable and does not use OCR, screenshots, keyboard/mouse injection, client-screen reading, or inferred synthetic tracks +#### Scenario: Confirmation cannot be established -#### Scenario: Cross-server isolation -- **WHEN** events are emitted for two bound SCUM servers -- **THEN** the Companion tags each batch with its bound server identity and no trajectory, player, or vehicle data from one server is returned for the other +- **WHEN** a typed write or post-write read cannot establish success +- **THEN** the Companion returns `unknown` and does not repeat the operation + +### Requirement: Vehicle spawning remains fixed + +`vehicle.spawn` SHALL accept only a catalogued vehicle code and create exactly +`#spawnvehicle ` inside the Companion. Protected audit text SHALL +not be present in command results or browser payloads. + +#### Scenario: Unsafe spawn input + +- **WHEN** input includes an unlisted code, an extra field, command text, SQL, + a path, credential, socket, shell text, or RCON text +- **THEN** no transport call occurs and validation fails diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md index 74b56b4..be2cd6e 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md @@ -1,38 +1,37 @@ ## ADDED Requirements ### Requirement: SCUM feature ownership is plugin-local -The system SHALL place SCUM-specific configuration semantics, player/gift/map/state-patch behavior, schemas, validators, Companion handlers, and feature UI modules in the SCUM plugin package. The platform SHALL expose only generic authorization, isolation, audit, storage, queue, retention, and plugin-host primitives and SHALL NOT add new SCUM-named domain services, API handlers, or frontend panels. -#### Scenario: A new SCUM capability is added -- **WHEN** a SCUM-specific configuration field, event type, command, or view is introduced -- **THEN** its implementation and tests are added to the SCUM plugin package while the platform change, if any, is reusable by non-SCUM plugins +The SCUM plugin SHALL own SCUM schemas, allowlists, migration adapters, +Companion behavior, and feature UI. The platform SHALL retain only reusable +authorization, isolation, auditing, queues, storage, and generic plugin-host +primitives. `platform_web` SHALL not import SCUM code or branch on `game.scum`. -#### Scenario: Existing platform SCUM code is migrated -- **WHEN** an existing SCUM service or panel is replaced by its plugin equivalent -- **THEN** the platform retains only a generic primitive and no hard-coded `game.scum` branch or SCUM component import remains in the plugin host +#### Scenario: Page mounting -### Requirement: Plugin-owned pages mount in the platform host -The SCUM plugin SHALL declare versioned page-module entries and their required permissions/capabilities. The platform web host SHALL mount the declared module inside the existing authenticated themed shell and SHALL pass only server-scoped, permission-filtered host context. +- **WHEN** an authorized administrator opens the installed plugin route +- **THEN** the generic host mounts the declared plugin bundle with only + server-scoped permission context -#### Scenario: Authorized administrator opens a SCUM page -- **WHEN** an administrator with the declared permission opens a SCUM plugin route for an assigned server -- **THEN** the host loads the SCUM-declared page module with that server-scoped context and does not use a platform-owned SCUM page implementation +### Requirement: Feature availability is runtime scoped -#### Scenario: Page module is unavailable or incompatible -- **WHEN** the declared SCUM page bundle fails integrity/version/capability validation -- **THEN** the host shows an unavailable-page state and does not fall back to a hard-coded SCUM panel +The plugin SHALL expose a feature as actionable only when its declared +Companion handler or event producer is currently available for that server. +Availability SHALL not be gated by a game or adapter version/build/revision. -### Requirement: Feature availability requires a plugin implementation -The system SHALL expose a SCUM feature as actionable only when the installed plugin declares the feature and its Companion reports a compatible handler or event producer for the bound server version. Transitional platform records MAY be displayed as migrated read-only history but SHALL NOT imply executable capability. +#### Scenario: One adapter fails -#### Scenario: Unsupported state patch adapter -- **WHEN** the bound SCUM version has no verified `game-state.patch` handler -- **THEN** the plugin disables the edit control and reports that the version is unsupported without queuing a generic command +- **WHEN** a schema probe for state patching fails +- **THEN** state patching is unavailable with a typed reason while other + declared capabilities remain independently available -#### Scenario: Vehicle spawn handler is unavailable -- **WHEN** the bound Companion does not report the declared `vehicle.spawn` handler for its compatible version -- **THEN** the plugin keeps vehicle spawning unavailable and does not display a raw command field or queue a generic RCON command +### Requirement: Transitional records are read-only migration input -#### Scenario: Historical records during migration -- **WHEN** records created by the transitional platform implementation exist for a bound server -- **THEN** the plugin can display them with migration provenance while new writes use the plugin-owned feature path +Platform records MAY be displayed with provenance while plugin-owned records +become authoritative per server and feature. Migration flags SHALL be scoped +to the server and feature, never to a game version. + +#### Scenario: Migration flag is absent + +- **WHEN** no unique server-feature migration flag is present +- **THEN** historical records remain readable and plugin writes stay disabled diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md index f607ffd..2f8f726 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -1,48 +1,22 @@ -## 1. Establish generic extension primitives +## 1. Replace version gates with runtime probes -- [x] 1.1 Audit every SCUM-named platform API, model, service, route, and hard-coded frontend import introduced by the five transitional deliveries; document its plugin-owned replacement and migration dependency. -- [x] 1.2 Define and test generic plugin-scoped record/event storage, audit linkage, retention, and typed command-result primitives without SCUM field names. -- [x] 1.3 Extend the plugin manifest/SDK with versioned page-bundle entries, feature capability declarations, and Companion handler/event-producer availability reporting. -- [x] 1.4 Add generic platform authorization, server isolation, bundle integrity/version validation, and unavailable-feature behavior for those declarations. +- [x] 1.1 Remove SCUM/game/UE4SS/database build, revision, and version feature gates from the change contract, manifest-facing feature layer, Companion registry, adapters, and tests. +- [x] 1.2 Make handler availability server-bound and capability/schema-probe based; isolate failure to the affected command. -## 2. Build the SCUM plugin module and page bundle +## 2. Establish typed Run/Companion boundaries -- [x] 2.1 Create a SCUM plugin-owned feature module with contracts, API client, schemas, validators, and migration adapters for configuration, players, gifts, state patches, and trajectories. -- [x] 2.2 Move the file/config workbench into the plugin page bundle and replace the four example settings with versioned SCUM configuration-field catalogs and explanatory controls. -- [x] 2.3 Move player profile, login/session/risk, gift, controlled-state, and map UI components into the plugin bundle with server-scoped permission checks. -- [x] 2.4 Replace `platform_web` SCUM component imports and `game.scum` branches with generic manifest-driven plugin page mounting; add host and plugin UI tests. +- [x] 2.1 Define restricted typed ports for configuration, player state, rewards, notifications, and fixed server administration with no raw paths, DSNs, rows, credentials, SQL, shell, sockets, or RCON. +- [x] 2.2 Parse bounded Run stdout/stderr records into semantic events; skip unknown formats with bounded diagnostics and irreversible per-server correlation. +- [x] 2.3 Implement state pre-read, safe-window, allowlisted write, and read-after-write confirmation with typed results. +- [x] 2.4 Implement frozen typed reward delivery results without automatic unknown retries. +- [x] 2.5 Preserve the fixed private vehicle-spawn template and its allowlist. -## 3. Implement the long-running SCUM Companion runtime +## 3. Complete plugin-owned migration -- [x] 3.1 Implement authenticated registration, bounded dispatch polling, acknowledgement, idempotent result completion, backoff, and typed diagnostics in the SCUM Companion. -- [x] 3.2 Add a handler registry that validates declared schema, bound server, approval, server version, capability discovery, expiry, and idempotency before invoking an adapter. -- [x] 3.3 Implement safe configuration read/patch and diagnostics adapters that use only platform-authorized channels and redact host paths, credentials, and raw command text. -- [x] 3.4 Add Companion integration tests for command claiming, duplicate delivery, cancellation/expiry, malformed payloads, unsupported versions, and redaction. +- [x] 3.1 Remove version-scoped feature catalogs, page context, API requests, and migration flags in favor of runtime schema/capability availability. +- [x] 3.2 Keep platform records read-only with provenance and leave platform-web generic. -## 4. Add verified SCUM data collectors +## 4. Verify and deliver -- [ ] 4.1 Identify supported SCUM log or legitimate server-side extension sources for successful login/logout and implement versioned parsers that emit validated semantic events. -- [ ] 4.2 Implement server-isolated, irreversible per-server network correlation inside the Companion when a supported source provides it; never upload or persist raw IP/fingerprint values. -- [ ] 4.3 Identify a legitimate non-OCR/non-desktop-automation source for player position, vehicle position, and vehicle transitions; implement coordinate conversion, sampling, and retention according to the plugin declaration. -- [ ] 4.4 Implement durable semantic-event batch upload with ordering, duplicate acknowledgement, parser fixtures, unknown-format diagnostics, and cross-server isolation tests. - -## 5. Add version-gated SCUM operation adapters - -- [ ] 5.1 Implement a version-discovered `game-state.patch` adapter for only documented supported skill/attribute fields, including precondition read, safe-window verification, read-after-write confirmation, and typed old/new/result audit data. -- [x] 5.2 Keep unsupported player state fields, versions, or unsafe windows disabled in the plugin UI and return explicit unsupported results from the Companion. -- [ ] 5.3 Implement a `reward.deliver` adapter that freezes the approved revision, performs idempotent delivery, and reports delivered/failed/unknown without automatically retrying unknown results. -- [x] 5.4 Implement a separate `player.notify` adapter that never repeats item delivery after notification failure; verify server-scoped recipient identity and redact message transport details. -- [x] 5.5 Implement the explicitly enabled, version-bound `vehicle.spawn` adapter with a strict vehicle-code catalog, the fixed internal `#spawnvehicle ` template, protected audit text, structured success/failure/unknown outcomes, Companion-local transport fixture, and fail-closed UI availability. -- [x] 5.6 Add isolated non-production end-to-end tests for every supported adapter and ensure no raw SQL, unrestricted RCON, OCR, screenshots, keyboard/mouse injection, or direct game database write path exists. - -## 6. Migrate transitional platform behavior safely - -- [x] 6.1 Introduce feature/version flags and read-only migration adapters so existing platform records remain visible with provenance while plugin-owned records become authoritative per server. -- [ ] 6.2 Verify plugin parity for configuration, player history, gifts, state-patch audits, and trajectories against controlled fixtures and an isolated Companion integration environment. -- [ ] 6.3 Remove SCUM-named platform APIs, models, services, routes, and frontend components only after no callers remain and migration/rollback evidence is recorded. -- [ ] 6.4 Run full platform, plugin, frontend, manifest, OpenSpec strict, structure, and isolated end-to-end verification; commit and push only the scoped migration files. - -## Verification evidence - -- 5.6: `cd plugins/examples/scum-server-plugin/companion && go test ./...` passed on 2026-07-29. The isolated typed-port fixture covers safe configuration read/patch, `player.notify`, and version-bound `vehicle.spawn` through claim/ack/registry/complete; it checks approval, binding, version/capability gating, idempotency, bounded outcomes, and redaction. A production-source test rejects raw SQL/direct database access, unrestricted command/RCON execution, OCR/screenshot/input automation, and direct socket paths. -- 6.3 preparation: `platform_web` static call-graph audit on 2026-07-29 found that the legacy SCUM operation/config/player/gift/trajectory panels, their private contracts/schemas, and their game-player/gift/map client calls had no non-test caller after generic plugin-page hosting. The orphan frontend implementation was removed; the backend historical routes/models/services/repositories remain read-only migration input as documented in `implementation-blockers.md`. `cd platform_web && pnpm test && pnpm build`, `cd plugins && pnpm test && pnpm typecheck && pnpm validate:manifest`, `openspec validate move-scum-feature-ownership-to-plugin --strict`, and `scripts/check-structure.sh` passed. +- [x] 4.1 Run Companion, plugin, manifest, OpenSpec strict, structure, and scoped source-boundary verification. +- [x] 4.2 Stage scoped files, commit, and push `main`. diff --git a/plugins/examples/scum-server-plugin/companion/README.md b/plugins/examples/scum-server-plugin/companion/README.md index d331567..52810c3 100644 --- a/plugins/examples/scum-server-plugin/companion/README.md +++ b/plugins/examples/scum-server-plugin/companion/README.md @@ -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. diff --git a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md index 6ecc1e3..6f56245 100644 --- a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md +++ b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md @@ -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 ` template from the plugin allowlist. -The source implements a game-thread `SendChat "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 ` 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. diff --git a/plugins/examples/scum-server-plugin/companion/adapters.go b/plugins/examples/scum-server-plugin/companion/adapters.go index 6718098..7b8e95a 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters.go +++ b/plugins/examples/scum-server-plugin/companion/adapters.go @@ -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 +} diff --git a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go index 4e19150..5a59db9 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go @@ -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) } diff --git a/plugins/examples/scum-server-plugin/companion/adapters_test.go b/plugins/examples/scum-server-plugin/companion/adapters_test.go index 9ad804d..ec35f8e 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_test.go @@ -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 := ¬ificationPortFixture{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 := ¬ificationPortFixture{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 := ¬ificationPortFixture{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) diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher.go b/plugins/examples/scum-server-plugin/companion/dispatcher.go index 41062d8..b0d91e2 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher.go @@ -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) diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go b/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go index d37f5e8..d6ab2ff 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher_integration_test.go @@ -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{ diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher_test.go b/plugins/examples/scum-server-plugin/companion/dispatcher_test.go index 957b62e..cdcd477 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher_test.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher_test.go @@ -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" { diff --git a/plugins/examples/scum-server-plugin/companion/events.go b/plugins/examples/scum-server-plugin/companion/events.go index 4f45ac4..7639154 100644 --- a/plugins/examples/scum-server-plugin/companion/events.go +++ b/plugins/examples/scum-server-plugin/companion/events.go @@ -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", - } -} diff --git a/plugins/examples/scum-server-plugin/companion/events_test.go b/plugins/examples/scum-server-plugin/companion/events_test.go index 1f8d3f0..56e9058 100644 --- a/plugins/examples/scum-server-plugin/companion/events_test.go +++ b/plugins/examples/scum-server-plugin/companion/events_test.go @@ -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") } } diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts index 6d47ef5..3fa9378 100644 --- a/plugins/examples/scum-server-plugin/features/api.ts +++ b/plugins/examples/scum-server-plugin/features/api.ts @@ -3,26 +3,26 @@ import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from ". export type PluginFeatureBridge = { dispatch(action: "game-client.command" | "game-client.snapshot.read", payload: Record): Promise<{ status: string; result?: Record; error?: { message: string } }> }; export type SCUMFeatureAPI = { - availability(feature: SCUMFeatureKey): Promise; readConfig(version: string): Promise; patchConfig(patch: SCUMConfigPatch): Promise; + availability(feature: SCUMFeatureKey): Promise; readConfig(): Promise; patchConfig(patch: SCUMConfigPatch): Promise; playerProfile(playerId: string): Promise; stateSnapshot(playerId: string): Promise; requestStatePatch(patch: SCUMStatePatch): Promise; requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise; giftGrants(): Promise; trajectories(): Promise; }; -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(result.result) : null; }, + async readConfig() { const result = await bridge.dispatch("game-client.command", { type: "config.read" }); return result.status === "ok" ? decode(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(result.result) : null; }, async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode(result.result) : null; }, - async requestStatePatch(patch) { const error = validateStatePatch(patch.gameVersion, patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); }, + async 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(result.result) ?? [] : []; }, async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; } }; } -function commandResult(result: { status: string; result?: Record; 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; 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(result: Record | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } } diff --git a/plugins/examples/scum-server-plugin/features/contracts.ts b/plugins/examples/scum-server-plugin/features/contracts.ts index c8dc62f..26d5850 100644 --- a/plugins/examples/scum-server-plugin/features/contracts.ts +++ b/plugins/examples/scum-server-plugin/features/contracts.ts @@ -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> = { 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 }; 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; observedAt: string }; -export type SCUMConfigPatch = { version: string; changes: Array<{ key: string; value: string }>; reason: string; idempotencyKey: string }; +export type SCUMConfigRead = { fields: Record; 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 }; diff --git a/plugins/examples/scum-server-plugin/features/migration.ts b/plugins/examples/scum-server-plugin/features/migration.ts index ef22215..34e9b4b 100644 --- a/plugins/examples/scum-server-plugin/features/migration.ts +++ b/plugins/examples/scum-server-plugin/features/migration.ts @@ -4,13 +4,13 @@ import { configurationCatalog } from "./schemas.js"; export function transitionalReadOnly>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord { return { provenance: "transitional-read-only", readOnly: true, payload, recordedAt, sourceRecordId }; } export function pluginOwned>(payload: T, recordedAt: string): SCUMMigrationRecord { 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): SCUMMigrat } export function migrateConfigurationRecord(record: Record): SCUMMigrationRecord | 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): SCUMMigrationRecord | null { @@ -42,9 +42,9 @@ export function migrateGiftGrantRecord(record: Record): SCUMMig } export function migrateStatePatchRecord(record: Record): SCUMMigrationRecord | 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): 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 | null { const fields = object(value); const allowed = new Set(configurationCatalog(version).map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record = {}; 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 | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record = {}; 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): 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; } diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index a06e9dd..680ae8d 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -2,27 +2,27 @@ import { configurationCatalog, stateFieldCatalog, vehicleSpawnCatalog } from "./ import type { SCUMFeatureWorkspace } from "./contracts.js"; export type ReactLike = { createElement: (...args: any[]) => any; useMemo?: (factory: () => T, deps: readonly unknown[]) => T }; -export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspace?: SCUMFeatureWorkspace; serverVersion?: string }; +export 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 处理器或事件生产者"}`; } diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts index 864a135..fe0470d 100644 --- a/plugins/examples/scum-server-plugin/features/schemas.ts +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -1,49 +1,18 @@ import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField, SCUMVehicleSpawn, SCUMVehicleSpawnOption } from "./contracts.js"; -const stateFieldsByVersion: Record[]> = { - "0.9.700.90357": [ - { key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, - { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 } - ] -}; - -export const configurationFieldsByVersion: Record = { - "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 = { - "0.9.700.90357": [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }] -}; - -export function configurationCatalog(serverVersion: string): readonly SCUMConfigField[] { return configurationFieldsByVersion[serverVersion] ?? []; } -export function vehicleSpawnCatalog(serverVersion: string): readonly SCUMVehicleSpawnOption[] { return vehicleSpawnCatalogByVersion[serverVersion] ?? []; } -export function stateFieldCatalog(serverVersion: string): readonly Omit[] { return stateFieldsByVersion[serverVersion] ?? []; } -export function supportsStateField(serverVersion: string, field: string): boolean { return stateFieldCatalog(serverVersion).some((candidate) => candidate.key === field); } +// 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[] = [{ 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; } diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts index fa0233f..7dfcb5b 100644 --- a/plugins/examples/scum-server-plugin/page-bundle/index.ts +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -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 }); } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.payload.schema.json index c585e2c..4364e95 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.payload.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.payload.schema.json @@ -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 }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/player-state.snapshot.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/player-state.snapshot.schema.json index a97662c..8641a39 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/player-state.snapshot.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/player-state.snapshot.schema.json @@ -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" }, diff --git a/plugins/tests/fixtures/scum-migration-parity.ts b/plugins/tests/fixtures/scum-migration-parity.ts index 8219387..8bad2ef 100644 --- a/plugins/tests/fixtures/scum-migration-parity.ts +++ b/plugins/tests/fixtures/scum-migration-parity.ts @@ -1,7 +1,7 @@ export const scumMigrationParityFixtures = { configuration: { - source: { id: "config-1", version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: 64, hostPath: "/srv/scum", RconPassword: "not-migrated" } }, - expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "config-1", recordedAt: "2026-07-29T00:00:00Z", payload: { version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: "64" } } } + source: { id: "config-1", observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: 64, hostPath: "/srv/scum", RconPassword: "not-migrated" } }, + expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "config-1", recordedAt: "2026-07-29T00:00:00Z", payload: { observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: "64" } } } }, playerHistory: { source: { updatedAt: "2026-07-29T00:10:00Z", player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", online: true }, sessions: [{ id: "session-1", startedAt: "2026-07-29T00:01:00Z", endedAt: "2026-07-29T00:08:00Z", networkFingerprint: "not-migrated" }], accessAttempts: [{ occurredAt: "2026-07-29T00:02:00Z", outcome: "review", reason: "manual review", networkCorrelationKey: "not-migrated" }], securitySignals: [{ lastObservedAt: "2026-07-29T00:03:00Z", ruleKey: "repeat-access", summary: "manual review", evidenceCount: 2 }] }, @@ -12,8 +12,8 @@ export const scumMigrationParityFixtures = { expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "gift-1", recordedAt: "2026-07-29T00:20:00Z", payload: { id: "gift-1", revisionId: "revision-1", playerId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z" } } }, statePatch: { - source: { id: "patch-1", gamePlayerRecordId: "player-1", gameVersion: "0.9.700.90357", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "execution-unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }], bridgeCommandId: "not-migrated" }, - expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", gameVersion: "0.9.700.90357", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } } + source: { id: "patch-1", gamePlayerRecordId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "execution-unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }], bridgeCommandId: "not-migrated" }, + expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } } }, trajectory: { source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] }, diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 79ae2e7..5197f28 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -6,12 +6,12 @@ import { configurationCatalog, validateConfigPatch, validateStatePatch, validate import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js"; describe("SCUM plugin feature module", () => { - it("owns the versioned configuration and state field catalogs", () => { - expect(configurationCatalog("0.9.700.90357").map((field) => field.key)).toContain("welcome-message"); - expect(validateConfigPatch({ version: "0.9.700.90357", reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围"); - expect(validateStatePatch("0.9.700.90357", [{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull(); - expect(validateStatePatch("unknown", [{ fieldKey: "skills.running", before: 1, after: 2 }])).toContain("未受当前版本支持"); - expect(vehicleSpawnCatalog("0.9.700.90357").map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]); + it("owns runtime allowlists without a version gate", () => { + expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message"); + expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围"); + expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull(); + expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("白名单"); + expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]); expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull(); expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效"); expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("受控目录"); @@ -26,7 +26,7 @@ describe("SCUM plugin feature module", () => { expect(migrateConfigurationRecord({ id: "cfg-1", version: "0.9.700.90357", fields: { MaxPlayers: 64 }, observedAt: "2026-07-29T00:00:00Z", hostPath: "C:/secret" })).toMatchObject({ readOnly: true, payload: { fields: { MaxPlayers: "64" } } }); expect(migratePlayerProfileRecord({ player: { id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" }, sessions: [{ id: "s-1", gamePlayerRecordId: "p-1", startedAt: "2026-07-29T00:00:00Z", networkFingerprint: "never-copy" }], accessAttempts: [{ occurredAt: "2026-07-29T00:01:00Z", outcome: "review", reason: "manual" }] })).toMatchObject({ payload: { sessions: [{ kind: "login" }], risks: [{ summary: "manual" }] } }); expect(migrateGiftGrantRecord({ id: "gift-1", revisionId: "r-1", gamePlayerRecordId: "p-1", status: "unknown", createdAt: "2026-07-29T00:00:00Z" })).toMatchObject({ payload: { status: "unknown" }, readOnly: true }); - expect(migrateStatePatchRecord({ id: "patch-1", gamePlayerRecordId: "p-1", gameVersion: "0.9.700.90357", expectedStateVersion: "state-1", safetyWindow: "maintenance", status: "confirmed", createdAt: "2026-07-29T00:00:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] })).toMatchObject({ payload: { status: "succeeded" }, readOnly: true }); + expect(migrateStatePatchRecord({ id: "patch-1", gamePlayerRecordId: "p-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", status: "confirmed", createdAt: "2026-07-29T00:00:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] })).toMatchObject({ payload: { status: "succeeded" }, readOnly: true }); expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true }); }); @@ -39,11 +39,11 @@ describe("SCUM plugin feature module", () => { expect(migrateConfigurationRecord({ version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { hostPath: "/srv/scum" } })).toBeNull(); }); - it("enables plugin authority only for one exact server-version feature flag", () => { - const flags = [{ serverInstanceId: "server-1", serverVersion: "0.9.700.90357", feature: "configuration" as const, authority: "plugin" as const }]; - expect(migrationStatus(flags, "server-1", "0.9.700.90357", "configuration")).toMatchObject({ authority: "plugin", pluginWritesEnabled: true, readOnlyHistory: true }); - expect(migrationStatus(flags, "server-2", "0.9.700.90357", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false }); - expect(migrationStatus([...flags, flags[0]], "server-1", "0.9.700.90357", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false }); + it("enables plugin authority only for one exact server-feature flag", () => { + const flags = [{ serverInstanceId: "server-1", feature: "configuration" as const, authority: "plugin" as const }]; + expect(migrationStatus(flags, "server-1", "configuration")).toMatchObject({ authority: "plugin", pluginWritesEnabled: true, readOnlyHistory: true }); + expect(migrationStatus(flags, "server-2", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false }); + expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false }); }); it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => {