Rebuild SCUM plugin data ownership

This commit is contained in:
npc0-hue
2026-08-14 10:03:58 +08:00
parent c8b49c711c
commit a6c4cdac5d
79 changed files with 532 additions and 1842 deletions
@@ -1,28 +0,0 @@
## Context
`scum_new_web` and `scum_robot` establish the useful operator shape: players can be searched and narrowed by squad, squads expose their rosters, maps use layers and marker inspection, and gifts distinguish definitions from a player's receipt history. The existing browser repository already has typed SCUM observations and gift workflows, so the design extends those contracts instead of importing the old direct-access path.
## Decisions
### Projection views compose existing facts
The frontend reads users, squads, squad members, vehicles, flags, map points, observed timed-gift events, and platform gift APIs. It joins those responses only for display. A user action creates existing typed operations or gift grants; it never mutates a projection in the browser.
### Plugin data packs own version-sensitive game data
The SCUM manifest names a v57 gift-item catalog and map geometry data asset. The platform resolves a requested gift item through the installed server plugin and game version. Updating SCUM changes the plugin package, not platform code.
### Observed events have a distinct target table
`finished_timed_gift_spawner` represents a completed in-game timed-gift event. It is stored as `scum_gift_events`; `game_gift_catalogs`, revisions, and grants remain platform-owned operational data. This removes the ambiguous old `scum_gift_catalogs` observation table.
### Map is geometric, not a copied game asset
The plugin supplies world bounds and axis direction. The web client projects points into that normalized frame and exposes player, vehicle, base, and flag layers. No copyrighted tile bundle is copied. Trajectory playback remains dependent on independently collected track points.
## Verification
- Go tests covering observed-gift target and plugin-resolved gift items.
- Plugin typecheck, tests, and manifest validation.
- Frontend typecheck, tests, and build.
- `scripts/check-structure.sh` and `openspec validate align-scum-operations-with-reference-tools --strict`.
@@ -1,23 +0,0 @@
## Why
The SCUM projection pipeline now reads real v57 facts, but the five SCUM pages still present those facts as disconnected tables. The established `scum_new_web` and `scum_robot` tools have useful operator workflows for player search, squad rosters, map overlays, and gifts. Their direct database, FTP, and SQL-transport design does not fit this platform.
## What Changes
- Turn SCUM users into a searchable, squad-filterable projection view with player details and typed-operation entry points.
- Show each squad with its linked roster and flags rather than a disconnected aggregate table.
- Render current player, vehicle, base, and flag points in a versioned plugin-declared coordinate frame with independent layers and a selected-marker inspector.
- Separate game-observed completed timed-gift events from platform-owned gift definitions, revisions, and grants.
- Move SCUM gift item catalogs and map geometry out of platform code into versioned SCUM plugin data packs.
## Boundaries
- Platform remains the durable owner of projections, gift definitions, revisions, and grants; the plugin owns version-specific SQL, item keys, and map geometry; Run only executes declared work.
- Do not reintroduce FTP, direct platform/browser access to `SCUM.db`, arbitrary SQL, raw RCON, host paths, or a `run/` source tree.
- Do not copy third-party map tiles into this repository. The map is an operator coordinate board until a plugin-declared asset reference is available.
## Impact
- `platform/`: separates observed timed-gift events from platform gift catalog data and resolves gift item definitions from the registered plugin.
- `platform_web/`: adds projection-oriented user, squad, map, and gift views within the existing themed console.
- `plugins/examples/scum-server-plugin/`: declares SCUM v57 gift items and map geometry as versioned data-pack assets.
@@ -1,33 +0,0 @@
## ADDED Requirements
### Requirement: Reference-aligned SCUM projection workspaces
The system SHALL present SCUM users, squads, map points, and gifts as connected operator workspaces backed by platform projections and typed workflows.
#### Scenario: Operator filters users by squad
- **WHEN** an operator searches or selects a squad in the SCUM users workspace
- **THEN** the page filters real local player projections by the selected name, Steam identifier, or squad without querying `SCUM.db` from the browser
#### Scenario: Operator inspects a squad
- **WHEN** an operator selects a squad
- **THEN** the page shows its observed members, leader markers, and related flag observations without fabricating missing values
### Requirement: Plugin-declared SCUM map geometry
The system SHALL use SCUM plugin package geometry to project current map-point overlays.
#### Scenario: Operator changes map layers
- **WHEN** an operator enables or disables players, vehicles, bases, or flags
- **THEN** the map renders only the selected real projection classes using the installed plugin's declared coordinate frame
### Requirement: Distinct observed and operational gifts
The system SHALL keep observed SCUM timed-gift completion events separate from platform-owned gift definitions and grant state.
#### Scenario: Operator opens gifts
- **WHEN** an operator opens SCUM gift management
- **THEN** the workspace distinguishes platform gift definitions and grants from completed in-game timed-gift observations
### Requirement: Versioned plugin game data
The system SHALL resolve SCUM gift item definitions from versioned plugin package data.
#### Scenario: SCUM version changes
- **WHEN** the server uses a plugin package with a different declared SCUM item catalog
- **THEN** gift validation uses that plugin package data without changing platform source code
@@ -1,12 +0,0 @@
## 1. Reference-Aligned SCUM Operations
- [x] 1.1 Positive prompt: Deliver practical SCUM user, squad, map, and gift operations shaped by the mature reference tools while retaining the current plugin/platform/Run ownership model.
- [x] 1.2 Directional prompt: Update `platform/`, `platform_web/`, and the SCUM plugin package using existing projection, gift, manifest, and themed-console patterns; verify backend, frontend, plugin, structure, and OpenSpec checks.
- [x] 1.3 Boundary prompt: Do not reintroduce direct SQLite/FTP/browser SQL access, raw RCON, host paths, copied map tiles, unrelated product areas, or a `run/` tree.
- [x] 1.4 Separate observed timed-gift completion events from platform gift catalog/revision/grant records.
- [x] 1.5 Resolve SCUM gift item definitions and map geometry from the versioned installed plugin package.
- [x] 1.6 Implement searchable player and squad-filtered user projections plus roster/flag-aware squad detail.
- [x] 1.7 Implement layered projection map with plugin-declared coordinate conversion and marker inspection.
- [x] 1.8 Implement a gift workspace that clearly separates definitions/grants from observed completed timed-gift events.
- [x] 1.9 Add focused tests and run the required validation suite.
- [x] 1.10 Stage only task files, commit on `main`, and push the configured remote after verification succeeds.
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-08-13
@@ -1,57 +0,0 @@
## Context
SCUM database schema, log grammar, and configuration files change with the game. The present Platform implementation identifies SCUM row types from query-key substrings and shapes data into fixed projection structs. That puts version-specific knowledge in the wrong component and forces a Platform update for routine game changes.
The supplied SCUM v57 sample confirms the required real sources: SQLite tables (`user_profile`, `prisoner`, `squad`, `squad_member`, `vehicle_spawner`, `base`, timed gifts/tasks), UTF-16LE server logs, and INI/JSON configuration. Platform needs durable, queryable `scum_*` records; Run remains an independent generic executor and is not changed in this repository.
## Goals / Non-Goals
**Goals:**
- Package SCUM v57 SQL, log grammar, and configuration mappings with the SCUM plugin.
- Make manifest validation accept safe package-relative SQL references and declarative row destinations while rejecting browser-provided or inline SQL.
- Make Platform ingest typed result rows through declaration metadata and upsert relational `scum_*` tables without SCUM query-name dispatch.
- Provide users, squads, activity, gifts, and map data to the management console from those persisted tables.
**Non-Goals:**
- Adding a `run/` tree, implementing Run-side executors, or adding SCUM game details to Run.
- Parsing a SCUM database from the browser or exposing host paths/raw SQL to browser users.
- Supporting every historical SCUM database version in the initial pack, changing lifecycle management, or adding unrelated product areas.
## Decisions
### Plugin package owns version-specific declarations
Each SCUM query template declares a package-relative `sqlRef`, `targetTable`, `upsertKeys`, and optional column mapping. SQL source is a `.sql` package asset named for SCUM database version. Logs/configuration use package assets with explicit encoding/format metadata.
This gives a SCUM version update one editable location: the plugin package. Inline SQL remains invalid so the browser continues to request only named templates and typed parameters. Storing SQL in Platform would reproduce the current coupling; accepting arbitrary browser SQL would make the contract unauditable.
### Platform performs generic declaration-driven ingestion
The execution path resolves a registered plugin declaration, validates the reported columns/keys, and applies its rows to the named `scum_*` relation. The generic layer never infers destination type by matching `player`, `squad`, or other SCUM words in a query key.
Tables are deliberately shaped for the first console features: sync runs, users, squads, squad members, vehicles, flags, map points, activity events, gift catalogs/grants, and configuration files. A generic JSON payload retains plugin-version fields not promoted to columns, avoiding a Platform release for every added SCUM field.
### APIs and console read persisted projections only
Platform dataset endpoints list scoped persisted rows. The console renders the five requested real datasets and uses empty states until a sync exists; it does not invent demo state or query SQLite directly.
## Risks / Trade-offs
- [SCUM schema drift invalidates a SQL asset] -> Version the asset directory, identify its schema version in the plugin manifest, and return a typed sync error rather than corrupting a table.
- [A mapping declares an unexpected relation] -> Manifest validation limits targets to `scum_*`, validates relative asset paths, key names, and declared columns before plugin registration.
- [Initial normalized tables omit a future game field] -> Preserve unmapped source values in row payload JSON and add columns only when they become a first-class console field.
- [Existing fixed projections have callers] -> Retain compatibility responses while migrating callers, then remove string-key dispatch in the same change after tests cover declaration-driven ingestion.
## Migration Plan
1. Register the new manifest schema and v57 package assets alongside existing declarations.
2. Add relational `scum_*` repository models/migrations and generic ingest APIs.
3. Switch SCUM sync result handling and console APIs to the declarations.
4. Verify against fixture rows derived from the supplied SCUM v57 database/log/config corpus.
5. Roll back by deploying the previous Platform and plugin version; existing `scum_*` rows are additive and can be ignored by the old runtime.
## Open Questions
- The first pack uses `pragma user_version = 57`; future versions will be added as plugin package assets when their actual schema is supplied.
@@ -1,29 +0,0 @@
## Why
The current SCUM integration keeps game-version SQL and data-shaping knowledge in Platform projections, which makes a SCUM update require coordinated changes across components. The management console needs to operate on actual SCUM v57 users, squads, activity, gifts, and map data with game-specific knowledge packaged alongside the SCUM plugin.
## What Changes
- Add a plugin-packaged SCUM data plane: versioned SQL files, log parser definitions, and configuration mappings referenced from the plugin manifest.
- Let Platform validate and distribute those declarations, accept typed result rows, and persist them in relational `scum_*` tables without dispatching on SCUM query-name substrings.
- Expose persisted SCUM user, squad, activity, gift, and map datasets through Platform APIs and the first-party game operations console.
- Replace the old hard-coded SCUM projection pathway with declaration-driven target-table and upsert metadata.
- **BREAKING** Plugin query-template declarations gain package-relative SQL references and row-target metadata; inline SQL remains invalid.
## Capabilities
### New Capabilities
- `scum-direct-data-plane`: Plugin-owned versioned SCUM data declarations, generic Platform ingestion, and `scum_*` persistence.
- `scum-operations-console`: First-party views and APIs for persisted SCUM users, squads, activity, gifts, and map points.
### Modified Capabilities
<!-- None. -->
## Impact
- `plugins/`: SCUM manifest/schema/validator/tests and versioned SQL, log, and config packages.
- `platform/`: plugin contracts, generic ingest service, relational repository models/migrations, and SCUM dataset APIs.
- `platform_web/`: server management SCUM data views using existing themed console conventions.
- No `run/` source is added; Run continues to perform only generic plugin-declared SQL and file jobs supplied by Platform.
@@ -1,31 +0,0 @@
## ADDED Requirements
### Requirement: Plugin-declared SCUM data packs
The system SHALL allow a SCUM plugin manifest to reference package-relative versioned SQL, log parser, and configuration mapping assets. Every query declaration that produces persisted SCUM rows MUST declare a `scum_*` target table and non-empty upsert keys; inline SQL and browser-supplied SQL MUST be rejected.
#### Scenario: v57 query declaration resolves a packaged statement
- **WHEN** the SCUM v57 plugin package declares a query with a relative `.sql` reference and row-target metadata
- **THEN** manifest validation accepts the declaration and distribution includes the referenced asset
#### Scenario: inline statement is rejected
- **WHEN** a manifest contains SQL text instead of a package-relative SQL reference
- **THEN** manifest validation rejects the manifest with a declaration error
### Requirement: Generic SCUM row ingestion
The system SHALL ingest rows returned for a registered SCUM data template according to that template's declared target table, upsert keys, and column mappings, without routing by a query-key substring.
#### Scenario: user rows are ingested
- **WHEN** a registered user template returns rows containing its declared key and columns
- **THEN** Platform upserts those rows into `scum_users` and records their plugin payload
#### Scenario: unsupported row target is rejected
- **WHEN** a registered data template names a target outside the allowed `scum_*` tables
- **THEN** Platform rejects the result before any dataset row is written
### Requirement: SCUM v57 source coverage
The initial SCUM pack SHALL include data declarations for users, squads and members, activity, gifts, and map points, plus parser/mapping declarations for supplied SCUM logs and configuration formats.
#### Scenario: operators inspect supplied source families
- **WHEN** the SCUM plugin package is assembled for database version 57
- **THEN** it contains SQL assets and declarations for all five console datasets and log/config parser assets
@@ -1,19 +0,0 @@
## ADDED Requirements
### Requirement: Persisted SCUM operations datasets
The system SHALL expose persisted SCUM users, squads, activity events, gifts, and map points through Platform APIs scoped to a server instance.
#### Scenario: a completed sync is queried
- **WHEN** an operator requests a SCUM dataset for an instance with persisted records
- **THEN** Platform returns only the corresponding persisted `scum_*` rows for that instance
### Requirement: SCUM operations console views
The first-party management console SHALL provide themed views for 用户, 队伍, 活动, 礼包, and 地图 using Platform's persisted SCUM dataset APIs, with an explicit empty state when no sync has completed.
#### Scenario: data is available
- **WHEN** an operator opens a SCUM-enabled server after a data sync
- **THEN** the console displays the returned persisted dataset without browser-side SQLite or log access
#### Scenario: no data is available
- **WHEN** an operator opens a SCUM-enabled server with no completed data sync
- **THEN** each dataset view displays an empty state rather than synthetic records
@@ -1,20 +0,0 @@
## 1. Plugin Data Pack
- [x] 1.1 Extend plugin manifest schema and validator with package-relative SQL references and declared SCUM row targets, keys, and mappings.
- [x] 1.2 Add tested SCUM v57 SQL assets for users, squads/members, activity, gifts, and map data.
- [x] 1.3 Add plugin-declared UTF-16LE log parser and INI/JSON configuration mapping assets for the supplied SCUM formats.
## 2. Platform Data Plane
- [x] 2.1 Replace SCUM query-key projection dispatch with declaration-driven row ingestion contracts and tests.
- [x] 2.2 Add scoped relational `scum_*` repository models/migrations and generic upsert/list operations for the first five datasets.
- [x] 2.3 Expose server-instance APIs for persisted users, squads, activity, gifts, and map points.
## 3. Operations Console
- [x] 3.1 Add themed server-management views and API clients for the five persisted SCUM datasets with real empty states.
## 4. Verification and Delivery
- [x] 4.1 Run manifest, Platform, frontend, OpenSpec strict, and structure checks; record evidence.
- [ ] 4.2 Stage only this change, commit on `main`, and push the configured remote.
@@ -1,2 +1,2 @@
schema: spec-driven
created: 2026-08-13
created: 2026-08-14
@@ -0,0 +1,22 @@
# Design
## Ownership
Platform owns authentication, server-instance authorization, durable storage mechanics, collection scoping, and transaction boundaries. It does not interpret collection payloads.
The SCUM plugin owns collection names such as `scum_users`, schemas, upsert keys, data transformations, gifts, map geometry, and UI behavior. It declares database/file/log work in the manifest; Platform dispatches those declarations to Run, and Run executes on the machine.
## Generic Platform Data Contract
The generic record is scoped by `pluginId`, `serverInstanceId`, `collection`, and `key`, with an opaque JSON `value` and timestamps. The platform validates scope and authorization only. A page bridge exposes list/get/put/delete generic collection methods to plugin bundles.
## SCUM Data Flow
1. The plugin declares v57 SQLite queries plus config/log parsers in its own versioned assets.
2. Platform dispatches the selected declared operation to Run; no browser or plugin supplies a machine path or SQL string at request time.
3. Plugin-shaped results are stored in scoped `scum_*` collections through the generic store.
4. The SCUM page reads those collections through the generic bridge and applies all SCUM-specific presentation and gift logic locally.
## Compatibility
Recent SCUM-specific Platform routes, types, repository tables, and game-gift APIs are removed. The retained generic bridge remains usable by other game plugins without SCUM imports or switches.
@@ -0,0 +1,18 @@
# Rebuild SCUM Plugin-Owned Data
## Why
The previous SCUM data implementation placed game-specific projections, gift rules, and browser callbacks in Platform. That couples every SCUM version change to Platform releases and makes the implementation larger than the required relay role.
## What Changes
- Replace the recent SCUM direct-data and game-gift additions with a generic plugin data store and generic plugin-page data bridge.
- Keep SCUM SQLite, configuration, and log access plugin-declared and dispatched through Platform to Run.
- Put SCUM `scum_*` collection names, record shapes, gift catalog/grant behavior, map rendering inputs, and feature UI in the SCUM plugin package.
## Success Criteria
- Platform exposes no SCUM-, squad-, map-, or gift-specific data API/service/model added by this change.
- The SCUM plugin can read and write its scoped platform collections for users, squads, activity, gifts, and map points through generic bridge calls.
- Version-specific SQL/config/log declarations remain in the SCUM manifest and assets.
- Existing generic lifecycle and machine-job dispatch behavior remains intact.
@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Generic Plugin Collection Storage
Platform SHALL persist opaque plugin records scoped by plugin identifier, server instance identifier, collection name, and record key.
#### Scenario: SCUM stores a user projection
- **WHEN** the SCUM plugin writes key `7656119...` to its `scum_users` collection for an authorized server instance
- **THEN** Platform stores the opaque record without interpreting SCUM fields
- **AND** another plugin or server instance cannot read the record through the scoped API
### Requirement: Plugin-Owned SCUM Domain
The SCUM plugin SHALL own its collection names, record schemas, gift behavior, map behavior, and version-specific data extraction assets.
#### Scenario: SCUM version changes
- **WHEN** a SCUM database or log format changes
- **THEN** the SCUM plugin updates its versioned query/parser assets
- **AND** Platform does not require a SCUM business-logic change
### Requirement: Machine Data Relay
SCUM machine SQLite, configuration, and log operations SHALL remain plugin-declared and Platform-dispatched to Run.
#### Scenario: Declared SQLite read
- **WHEN** a SCUM page requests a declared data refresh
- **THEN** Platform routes the declared operation through Run
- **AND** neither the page nor Platform's generic collection API accepts a raw host path or arbitrary SQLite statement
@@ -0,0 +1,9 @@
# Tasks
- [x] Revert the direct-data and reference-alignment commits while retaining unrelated local-debug fixes.
- [x] Add a generic scoped plugin data record model, repository, service, DTO, and HTTP API in Platform.
- [x] Add generic collection actions to the plugin-page host and browser API client.
- [ ] Restore SCUM v57 SQL, config, log, and gift assets in the plugin package.
- [ ] Rebuild the SCUM plugin page to use only generic collection bridge actions for users, squads, activity, gifts, and map points.
- [ ] Remove obsolete SCUM-specific Platform/frontend data and gift surfaces that conflict with plugin ownership.
- [ ] Add focused backend, plugin, and frontend tests; run structure and OpenSpec validation.
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"net/http"
"strconv"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
// serverPluginDataCollection provides scoped, opaque plugin-owned records.
func (h *coreHandlers) serverPluginDataCollection(w http.ResponseWriter, r *http.Request) {
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
collection := r.PathValue("collection")
switch r.Method {
case http.MethodGet:
limit, err := optionalPositiveInt(r.URL.Query().Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid plugin data limit", nil)
return
}
items, err := h.core.ListPluginDataForSession(bearerToken(r), domain.PluginDataFilter{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Collection: collection, Key: r.URL.Query().Get("key"), Limit: limit})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.PluginDataRecordsFromDomain(items))
case http.MethodPut:
request, err := decodeJSON[dto.PluginDataPutRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
value, err := h.core.PutPluginDataForSession(bearerToken(r), domain.PluginDataRecord{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Collection: collection, Key: request.Key, Value: request.Value})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.PluginDataRecordFromDomain(value))
default:
w.Header().Set("Allow", http.MethodGet+", "+http.MethodPut)
writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil)
}
}
var _ = strconv.IntSize
+1 -5
View File
@@ -92,6 +92,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}/cancel", h.serverGameClientBridgeCommandCancel)
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}", h.serverGameClientBridgeCommandDetail)
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots)
mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}", h.serverPluginDataCollection)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players", h.serverGamePlayers)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}", h.serverGamePlayerDetail)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state", h.serverGamePlayerState)
@@ -104,12 +105,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/players", h.serverSCUMPlayers)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/users", h.serverSCUMUsers)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squads", h.serverSCUMSquads)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/datasets/squads", h.serverSCUMDataSquads)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/activity", h.serverSCUMActivity)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/gifts", h.serverSCUMGifts)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/map-points", h.serverSCUMMapPoints)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squad-members", h.serverSCUMSquadMembers)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/flags", h.serverSCUMFlags)
-35
View File
@@ -21,41 +21,6 @@ func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, dto.SCUMPlayerLiveStatesFromDomain(items))
}
func (h *coreHandlers) serverSCUMUsers(w http.ResponseWriter, r *http.Request) {
h.serverSCUMDataSet(w, r, domain.SCUMDataSetUsers)
}
func (h *coreHandlers) serverSCUMDataSquads(w http.ResponseWriter, r *http.Request) {
h.serverSCUMDataSet(w, r, domain.SCUMDataSetSquads)
}
func (h *coreHandlers) serverSCUMActivity(w http.ResponseWriter, r *http.Request) {
h.serverSCUMDataSet(w, r, domain.SCUMDataSetActivity)
}
func (h *coreHandlers) serverSCUMGifts(w http.ResponseWriter, r *http.Request) {
h.serverSCUMDataSet(w, r, domain.SCUMDataSetGiftEvents)
}
func (h *coreHandlers) serverSCUMMapPoints(w http.ResponseWriter, r *http.Request) {
h.serverSCUMDataSet(w, r, domain.SCUMDataSetMapPoints)
}
func (h *coreHandlers) serverSCUMDataSet(w http.ResponseWriter, r *http.Request, target domain.SCUMDataSet) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
filter := scumProjectionFilterFromRequest(r, r.PathValue("id"))
filter.TargetTable = target
items, err := h.core.ListSCUMDataRowsForSession(bearerToken(r), filter)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMDataRowsFromDomain(items))
}
func (h *coreHandlers) serverSCUMSquads(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
+2 -10
View File
@@ -21,9 +21,8 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
}
plugin := validGamePluginRequest().ToDomain()
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunDBSQLiteQuery)
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}, {Key: "scum-data", Kind: "sqlite", TargetKey: "scum-data", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
plugin.GameClientBridge.QueryTemplates = []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "v57.directory.people", Title: "SCUM users", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "scum-data", TargetKey: "scum-data", ParameterSchemaRef: "schemas/bridge/queries/scum-player-profile.parameters.schema.json", ResultSchemaRef: "schemas/bridge/queries/scum-player-profile.result.schema.json", MaxRows: 200, TimeoutSeconds: 15, RowTarget: &domain.SCUMRowTargetDeclaration{TargetTable: string(domain.SCUMDataSetUsers), UpsertKeys: []string{"profileId"}, ColumnMappings: map[string]string{"profileId": "user_profile_id", "displayName": "display_name"}}}}
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON)
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}}
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set", Title: "Set fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-management", TargetKey: "scum-management", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}}
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
if _, err := core.CreateGamePlugin(plugin); err != nil {
@@ -41,9 +40,6 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:api-profile", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"gamePlayerId": "steam-api", "displayName": "API Player", "normalBalance": 25, "x": 1, "y": 2, "z": 3}}}); err != nil {
t.Fatalf("seed projection: %v", err)
}
if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "v57.directory.people", Sequence: 1, Checksum: "sha256:api-users", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"user_profile_id": "profile-api", "display_name": "API User"}}}); err != nil {
t.Fatalf("seed SCUM users: %v", err)
}
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
@@ -53,10 +49,6 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
if players.Count != 1 || players.Items[0].GamePlayerID != "steam-api" || players.Items[0].Position.X != 1 {
t.Fatalf("unexpected SCUM players response: %+v", players)
}
users := getJSONWithAuth[dto.SCUMDataRowListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/users", auth.SessionID)
if users.Count != 1 || users.Items[0].Fields["profileId"] != "profile-api" || users.Items[0].Payload["display_name"] != "API User" {
t.Fatalf("unexpected persisted SCUM users response: %+v", users)
}
operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.fame.set", PlayerID: "steam-api", Payload: map[string]any{"fame": 12}, Reason: "api typed op", IdempotencyKey: "api-fame-1"}, auth.SessionID)
if operation.Status != string(domain.SCUMWorkflowStepWaiting) || operation.TemplateKey != "player.fame.set" {
t.Fatalf("unexpected SCUM operation response: %+v", operation)
-16
View File
@@ -69,18 +69,8 @@ type GameClientBridgeQueryTemplateDeclaration struct {
TargetKey string
ParameterSchemaRef string
ResultSchemaRef string
SQLRef string
MaxRows int
TimeoutSeconds int
RowTarget *SCUMRowTargetDeclaration
}
// SCUMRowTargetDeclaration is plugin-owned data-shaping metadata. The platform
// only applies this declaration; it does not infer a destination from a query key.
type SCUMRowTargetDeclaration struct {
TargetTable string
UpsertKeys []string
ColumnMappings map[string]string
}
type GameClientBridgeOperationKind string
@@ -463,12 +453,6 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
}
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
for index := range value.QueryTemplates {
if value.QueryTemplates[index].RowTarget != nil {
copy := CopySCUMRowTargetDeclaration(*value.QueryTemplates[index].RowTarget)
value.QueryTemplates[index].RowTarget = &copy
}
}
value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
+22 -31
View File
@@ -1,10 +1,6 @@
package domain
import (
"encoding/json"
"strings"
"time"
)
import "time"
const SCUMRewardDeliverCommandType = "reward.deliver"
const SCUMGiftNotificationCommandType = "player.notify"
@@ -19,32 +15,7 @@ type SCUMGiftItemCatalog struct {
Items []SCUMGiftItemDefinition
}
func ParseSCUMGiftItemCatalog(content string) (SCUMGiftItemCatalog, bool) {
var catalog struct {
GameVersion string `json:"gameVersion"`
Items []struct {
Key string `json:"key"`
Label string `json:"label"`
MaximumQuantity int `json:"maximumQuantity"`
} `json:"items"`
}
if json.Unmarshal([]byte(content), &catalog) != nil || strings.TrimSpace(catalog.GameVersion) == "" || len(catalog.Items) == 0 {
return SCUMGiftItemCatalog{}, false
}
result := SCUMGiftItemCatalog{GameVersion: catalog.GameVersion, Items: make([]SCUMGiftItemDefinition, 0, len(catalog.Items))}
seen := map[string]struct{}{}
for _, item := range catalog.Items {
if strings.TrimSpace(item.Key) == "" || strings.TrimSpace(item.Label) == "" || item.MaximumQuantity < 1 {
return SCUMGiftItemCatalog{}, false
}
if _, ok := seen[item.Key]; ok {
return SCUMGiftItemCatalog{}, false
}
seen[item.Key] = struct{}{}
result.Items = append(result.Items, SCUMGiftItemDefinition{Key: item.Key, Label: item.Label, MaximumQuantity: item.MaximumQuantity})
}
return result, true
}
var SCUMGiftItemCatalogs = []SCUMGiftItemCatalog{{GameVersion: "0.9.700.90357", Items: []SCUMGiftItemDefinition{{Key: "bandage", Label: "绷带", MaximumQuantity: 20}, {Key: "water-bottle", Label: "饮用水", MaximumQuantity: 10}, {Key: "improvised-spear", Label: "简易长矛", MaximumQuantity: 2}}}}
type GameGiftItem struct {
CatalogItemKey string
@@ -151,3 +122,23 @@ func CopyGameGiftGrant(value GameGiftGrant) GameGiftGrant {
value.Items = CopyGameGiftItems(value.Items)
return value
}
func SCUMGiftCatalogForVersion(version string) (SCUMGiftItemCatalog, bool) {
for _, catalog := range SCUMGiftItemCatalogs {
if catalog.GameVersion == version {
return catalog, true
}
}
return SCUMGiftItemCatalog{}, false
}
func SCUMGiftItemForVersion(version, key string) (SCUMGiftItemDefinition, bool) {
catalog, ok := SCUMGiftCatalogForVersion(version)
if !ok {
return SCUMGiftItemDefinition{}, false
}
for _, item := range catalog.Items {
if item.Key == key {
return item, true
}
}
return SCUMGiftItemDefinition{}, false
}
+29
View File
@@ -0,0 +1,29 @@
package domain
import "time"
// PluginDataRecord is an opaque plugin-owned platform record. Platform scopes
// it but does not interpret the collection name or payload fields.
type PluginDataRecord struct {
ID string
PluginID string
ServerInstanceID string
Collection string
Key string
Value map[string]any
CreatedAt time.Time
UpdatedAt time.Time
}
type PluginDataFilter struct {
PluginID string
ServerInstanceID string
Collection string
Key string
Limit int
}
func CopyPluginDataRecord(value PluginDataRecord) PluginDataRecord {
value.Value = CopyGameClientBridgePayload(value.Value)
return value
}
-1
View File
@@ -25,7 +25,6 @@ type SCUMProjectionFilter struct {
FlagID string
SubjectType SCUMProjectionSubject
QueryKey string
TargetTable SCUMDataSet
Freshness SCUMProjectionFreshness
Search string
Limit int
-42
View File
@@ -85,35 +85,6 @@ type SCUMObservationResult struct {
Rows []map[string]any
}
type SCUMDataSet string
const (
SCUMDataSetUsers SCUMDataSet = "scum_users"
SCUMDataSetSquads SCUMDataSet = "scum_squads"
SCUMDataSetMembers SCUMDataSet = "scum_squad_members"
SCUMDataSetVehicles SCUMDataSet = "scum_vehicles"
SCUMDataSetFlags SCUMDataSet = "scum_flags"
SCUMDataSetActivity SCUMDataSet = "scum_activity_events"
// Gift events are facts observed in SCUM's finished_timed_gift_spawner table.
// Platform-owned gift catalogs, revisions, and grants use their own repositories.
SCUMDataSetGiftEvents SCUMDataSet = "scum_gift_events"
SCUMDataSetMapPoints SCUMDataSet = "scum_map_points"
)
type SCUMDataRow struct {
ID string
ServerInstanceID string
TargetTable SCUMDataSet
UpsertKey string
Fields map[string]any
Payload map[string]any
PluginID string
QueryKey string
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMProjectionFreshnessState struct {
Status SCUMProjectionFreshness
ObservationID string
@@ -261,19 +232,6 @@ func CopySCUMObservationResult(value SCUMObservationResult) SCUMObservationResul
return value
}
func CopySCUMRowTargetDeclaration(value SCUMRowTargetDeclaration) SCUMRowTargetDeclaration {
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
value.ColumnMappings = CopyStringMap(value.ColumnMappings)
return value
}
func CopySCUMDataRow(value SCUMDataRow) SCUMDataRow {
value.Fields = CopyGameClientBridgePayload(value.Fields)
value.Payload = CopyGameClientBridgePayload(value.Payload)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopyGameClientBridgeRows(values []map[string]any) []map[string]any {
if values == nil {
return nil
+2 -1
View File
@@ -74,7 +74,8 @@ type GameGiftGrantListResponse struct {
func (r GameGiftCatalogRequest) ToDomain() domain.GameGiftCatalogRequest {
items := make([]domain.GameGiftItem, len(r.Items))
for i, item := range r.Items {
items[i] = domain.GameGiftItem{CatalogItemKey: item.CatalogItemKey, Quantity: item.Quantity}
def, _ := domain.SCUMGiftItemForVersion(r.GameVersion, item.CatalogItemKey)
items[i] = domain.GameGiftItem{CatalogItemKey: item.CatalogItemKey, Label: def.Label, Quantity: item.Quantity}
}
return domain.GameGiftCatalogRequest{ID: r.ID, Name: r.Name, GameVersion: r.GameVersion, Items: items}
}
+37
View File
@@ -0,0 +1,37 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type PluginDataPutRequest struct {
Key string `json:"key"`
Value map[string]any `json:"value"`
}
type PluginDataRecordResponse struct {
Key string `json:"key"`
Value map[string]any `json:"value"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PluginDataListResponse struct {
Items []PluginDataRecordResponse `json:"items"`
Count int `json:"count"`
}
func PluginDataRecordFromDomain(value domain.PluginDataRecord) PluginDataRecordResponse {
value = domain.CopyPluginDataRecord(value)
return PluginDataRecordResponse{Key: value.Key, Value: value.Value, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
}
func PluginDataRecordsFromDomain(values []domain.PluginDataRecord) PluginDataListResponse {
items := make([]PluginDataRecordResponse, len(values))
for index, value := range values {
items[index] = PluginDataRecordFromDomain(value)
}
return PluginDataListResponse{Items: items, Count: len(items)}
}
+12 -27
View File
@@ -290,20 +290,16 @@ type GameClientBridgeSnapshotDeclarationBody struct {
}
type GameClientBridgeQueryTemplateDeclarationBody struct {
Key string `json:"key"`
Title string `json:"title"`
Permission string `json:"permission"`
Engine string `json:"engine"`
TransportKey string `json:"transportKey"`
TargetKey string `json:"targetKey"`
ParameterSchemaRef string `json:"parameterSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef"`
SQLRef string `json:"sqlRef,omitempty"`
MaxRows int `json:"maxRows"`
TimeoutSeconds int `json:"timeoutSeconds"`
TargetTable string `json:"targetTable,omitempty"`
UpsertKeys []string `json:"upsertKeys,omitempty"`
ColumnMappings map[string]string `json:"columnMappings,omitempty"`
Key string `json:"key"`
Title string `json:"title"`
Permission string `json:"permission"`
Engine string `json:"engine"`
TransportKey string `json:"transportKey"`
TargetKey string `json:"targetKey"`
ParameterSchemaRef string `json:"parameterSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef"`
MaxRows int `json:"maxRows"`
TimeoutSeconds int `json:"timeoutSeconds"`
}
type GameClientBridgeOperationSafetyBody struct {
@@ -1209,12 +1205,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
}
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
for index, template := range body.QueryTemplates {
var rowTarget *domain.SCUMRowTargetDeclaration
if template.TargetTable != "" || len(template.UpsertKeys) > 0 || len(template.ColumnMappings) > 0 {
value := domain.SCUMRowTargetDeclaration{TargetTable: template.TargetTable, UpsertKeys: domain.CopyStringSlice(template.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.ColumnMappings)}
rowTarget = &value
}
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, RowTarget: rowTarget}
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
}
operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates))
for index, template := range body.OperationTemplates {
@@ -1646,13 +1637,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
}
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
for index, template := range value.QueryTemplates {
body := GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
if template.RowTarget != nil {
body.TargetTable = template.RowTarget.TargetTable
body.UpsertKeys = domain.CopyStringSlice(template.RowTarget.UpsertKeys)
body.ColumnMappings = domain.CopyStringMap(template.RowTarget.ColumnMappings)
}
queryTemplates[index] = body
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
}
operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates))
for index, template := range value.OperationTemplates {
+3 -3
View File
@@ -137,7 +137,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
body := GameClientBridgeManifestBody{
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite",
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/scum-db-v57/users.sql", TargetTable: "scum_users", UpsertKeys: []string{"userProfileId"}, ColumnMappings: map[string]string{"userProfileId": "userProfileId"}, MaxRows: 50, TimeoutSeconds: 10,
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10,
}},
CommandRetentionSeconds: 86400,
MaxCommands: 1000,
@@ -145,7 +145,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
}
domainManifest := body.ToDomain()
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].TransportKey != "sqlite-db" || domainManifest.QueryTemplates[0].SQLRef != "sql/scum-db-v57/users.sql" || domainManifest.QueryTemplates[0].RowTarget == nil || domainManifest.QueryTemplates[0].RowTarget.TargetTable != "scum_users" || domainManifest.QueryTemplates[0].MaxRows != 50 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].TransportKey != "sqlite-db" || domainManifest.QueryTemplates[0].MaxRows != 50 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
}
domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
@@ -168,7 +168,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
if err := json.Unmarshal(encoded, &projection); err != nil {
t.Fatalf("decode safe query template projection: %v", err)
}
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "targetTable", "upsertKeys", "columnMappings", "maxRows", "timeoutSeconds"}
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutSeconds"}
if len(projection) != len(expectedFields) {
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
}
+1 -33
View File
@@ -1,10 +1,6 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
import "browser.local/platform/domain"
type SCUMPlayerLiveStateListResponse struct {
Items []domain.SCUMPlayerLiveState `json:"items"`
@@ -36,25 +32,6 @@ type SCUMCurrentPositionListResponse struct {
Count int `json:"count"`
}
type SCUMDataRowResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
TargetTable string `json:"targetTable"`
UpsertKey string `json:"upsertKey"`
Fields map[string]any `json:"fields"`
Payload map[string]any `json:"payload"`
PluginID string `json:"pluginId"`
QueryKey string `json:"queryKey"`
Freshness domain.SCUMProjectionFreshnessState `json:"freshness"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SCUMDataRowListResponse struct {
Items []SCUMDataRowResponse `json:"items"`
Count int `json:"count"`
}
func SCUMPlayerLiveStatesFromDomain(values []domain.SCUMPlayerLiveState) SCUMPlayerLiveStateListResponse {
out := make([]domain.SCUMPlayerLiveState, len(values))
for index, value := range values {
@@ -102,12 +79,3 @@ func SCUMCurrentPositionsFromDomain(values []domain.SCUMCurrentPosition) SCUMCur
}
return SCUMCurrentPositionListResponse{Items: out, Count: len(out)}
}
func SCUMDataRowsFromDomain(values []domain.SCUMDataRow) SCUMDataRowListResponse {
items := make([]SCUMDataRowResponse, len(values))
for index, value := range values {
value = domain.CopySCUMDataRow(value)
items[index] = SCUMDataRowResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, TargetTable: string(value.TargetTable), UpsertKey: value.UpsertKey, Fields: value.Fields, Payload: value.Payload, PluginID: value.PluginID, QueryKey: value.QueryKey, Freshness: value.Freshness, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
}
return SCUMDataRowListResponse{Items: items, Count: len(items)}
}
+3 -3
View File
@@ -14,7 +14,7 @@ type GameGiftCatalog struct {
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (GameGiftCatalog) TableName() string { return "scum_gift_catalogs" }
func (GameGiftCatalog) TableName() string { return "game_gift_catalogs" }
// GameGiftRevision is an immutable gift item snapshot.
type GameGiftRevision struct {
@@ -27,7 +27,7 @@ type GameGiftRevision struct {
PublishedAt time.Time `json:"publishedAt" db:"published_at"`
}
func (GameGiftRevision) TableName() string { return "scum_gift_revisions" }
func (GameGiftRevision) TableName() string { return "game_gift_revisions" }
// GameGiftGrant records a directed frozen gift lifecycle without raw game commands.
type GameGiftGrant struct {
@@ -42,4 +42,4 @@ type GameGiftGrant struct {
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (GameGiftGrant) TableName() string { return "scum_gift_grants" }
func (GameGiftGrant) TableName() string { return "game_gift_grants" }
+20
View File
@@ -0,0 +1,20 @@
package model
import (
"time"
)
// PluginDataRecord is the generic storage model for plugin-owned collections.
// Collection payload schemas remain in the plugin package.
type PluginDataRecord struct {
ID string `json:"id" db:"id"`
PluginID string `json:"pluginId" db:"plugin_id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
Collection string `json:"collection" db:"collection"`
Key string `json:"key" db:"record_key"`
Value map[string]any `json:"value" db:"value"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (PluginDataRecord) TableName() string { return "plugin_data_records" }
+7 -6
View File
@@ -42,6 +42,7 @@ type StoreSnapshot struct {
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"`
GamePlayers []domain.GamePlayer `json:"gamePlayers"`
GamePlayerAliases []domain.GamePlayerAlias `json:"gamePlayerAliases"`
GamePlayerSessions []domain.GamePlayerSession `json:"gamePlayerSessions"`
@@ -54,7 +55,6 @@ type StoreSnapshot struct {
GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"`
GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"`
SCUMDataObservations []domain.SCUMDataObservation `json:"scumDataObservations"`
SCUMDataRows []domain.SCUMDataRow `json:"scumDataRows"`
SCUMPlayerLiveStates []domain.SCUMPlayerLiveState `json:"scumPlayerLiveStates"`
SCUMSquads []domain.SCUMSquad `json:"scumSquads"`
SCUMSquadMembers []domain.SCUMSquadMember `json:"scumSquadMembers"`
@@ -212,6 +212,9 @@ func (store *FileStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepo
func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
}
func (store *FileStore) PluginDataRecords() PluginDataRecordRepository {
return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist}
}
func (store *FileStore) GamePlayers() GamePlayerRepository {
return &persistentRepository[domain.GamePlayer, domain.GamePlayerFilter]{repository: store.MemoryStore.gamePlayers, persist: store.persist}
}
@@ -248,9 +251,6 @@ func (store *FileStore) GameGiftGrants() GameGiftGrantRepository {
func (store *FileStore) SCUMDataObservations() SCUMDataObservationRepository {
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
}
func (store *FileStore) SCUMDataRows() SCUMDataRowRepository {
return &persistentRepository[domain.SCUMDataRow, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataRows, persist: store.persist}
}
func (store *FileStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
}
@@ -351,7 +351,8 @@ func (store *FileStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMDataRows: snapshotRepository(store.MemoryStore.scumDataRows), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
}
}
@@ -385,6 +386,7 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords)
loadRepository(store.MemoryStore.gamePlayers, snapshot.GamePlayers)
loadRepository(store.MemoryStore.gamePlayerAliases, snapshot.GamePlayerAliases)
loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions)
@@ -397,7 +399,6 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
loadRepository(store.MemoryStore.scumDataRows, snapshot.SCUMDataRows)
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
+7 -155
View File
@@ -171,6 +171,9 @@ func (store *MySQLStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRep
func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
}
func (store *MySQLStore) PluginDataRecords() PluginDataRecordRepository {
return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist}
}
func (store *MySQLStore) GamePlayers() GamePlayerRepository {
return &persistentRepository[domain.GamePlayer, domain.GamePlayerFilter]{repository: store.MemoryStore.gamePlayers, persist: store.persist}
}
@@ -205,10 +208,7 @@ func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository {
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
}
func (store *MySQLStore) SCUMDataObservations() SCUMDataObservationRepository {
return &mysqlSCUMObservationRepository{repository: store.MemoryStore.scumDataObservations, store: store}
}
func (store *MySQLStore) SCUMDataRows() SCUMDataRowRepository {
return &mysqlSCUMDataRowRepository{repository: store.MemoryStore.scumDataRows, store: store}
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
}
func (store *MySQLStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
@@ -253,158 +253,9 @@ CREATE TABLE IF NOT EXISTS platform_metadata_snapshots (
if err != nil {
return fmt.Errorf("create mysql metadata snapshot table: %w", err)
}
for _, table := range []string{"scum_sync_runs", "scum_users", "scum_squads", "scum_squad_members", "scum_vehicles", "scum_flags", "scum_activity_events", "scum_gift_events", "scum_map_points"} {
statement := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
id VARCHAR(191) PRIMARY KEY,
server_instance_id VARCHAR(191) NOT NULL,
upsert_key VARCHAR(512) NOT NULL,
fields_json JSON NOT NULL,
payload_json JSON NOT NULL,
plugin_id VARCHAR(191) NOT NULL,
query_key VARCHAR(191) NOT NULL,
freshness_json JSON NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
INDEX %s_server_updated (server_instance_id, updated_at)
)`, table, table)
if _, err := store.db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("create mysql %s table: %w", table, err)
}
}
return nil
}
type mysqlSCUMDataRowRepository struct {
repository mutableRepository[domain.SCUMDataRow, domain.SCUMProjectionFilter]
store *MySQLStore
}
func (repository *mysqlSCUMDataRowRepository) Create(value domain.SCUMDataRow) error {
if err := repository.repository.Create(value); err != nil {
return err
}
if err := repository.store.persistSCUMDataRow(value); err != nil {
return err
}
return repository.store.persist()
}
func (repository *mysqlSCUMDataRowRepository) Get(id string) (domain.SCUMDataRow, error) {
return repository.repository.Get(id)
}
func (repository *mysqlSCUMDataRowRepository) List(filter domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error) {
return repository.repository.List(filter)
}
func (repository *mysqlSCUMDataRowRepository) Update(value domain.SCUMDataRow) error {
if err := repository.repository.Update(value); err != nil {
return err
}
if err := repository.store.persistSCUMDataRow(value); err != nil {
return err
}
return repository.store.persist()
}
type mysqlSCUMObservationRepository struct {
repository mutableRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]
store *MySQLStore
}
func (repository *mysqlSCUMObservationRepository) Create(value domain.SCUMDataObservation) error {
if err := repository.repository.Create(value); err != nil {
return err
}
if err := repository.store.persistSCUMSyncRun(value); err != nil {
return err
}
return repository.store.persist()
}
func (repository *mysqlSCUMObservationRepository) Get(id string) (domain.SCUMDataObservation, error) {
return repository.repository.Get(id)
}
func (repository *mysqlSCUMObservationRepository) List(filter domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error) {
return repository.repository.List(filter)
}
func (repository *mysqlSCUMObservationRepository) Update(value domain.SCUMDataObservation) error {
if err := repository.repository.Update(value); err != nil {
return err
}
if err := repository.store.persistSCUMSyncRun(value); err != nil {
return err
}
return repository.store.persist()
}
func (store *MySQLStore) persistSCUMDataRow(value domain.SCUMDataRow) error {
table, ok := mysqlSCUMTable(value.TargetTable)
if !ok {
return fmt.Errorf("unsupported SCUM data target %q", value.TargetTable)
}
fields, err := json.Marshal(value.Fields)
if err != nil {
return fmt.Errorf("encode SCUM fields: %w", err)
}
payload, err := json.Marshal(value.Payload)
if err != nil {
return fmt.Errorf("encode SCUM payload: %w", err)
}
freshness, err := json.Marshal(value.Freshness)
if err != nil {
return fmt.Errorf("encode SCUM freshness: %w", err)
}
return store.upsertSCUMPhysicalRow(table, value.ID, value.ServerInstanceID, value.UpsertKey, fields, payload, value.PluginID, value.QueryKey, freshness, value.CreatedAt, value.UpdatedAt)
}
func (store *MySQLStore) persistSCUMSyncRun(value domain.SCUMDataObservation) error {
freshness, err := json.Marshal(domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFreshness(value.Status), Source: value.Source, QueryKey: value.QueryKey, Sequence: value.Sequence, Checksum: value.Checksum, ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt})
if err != nil {
return fmt.Errorf("encode SCUM sync freshness: %w", err)
}
fields, _ := json.Marshal(map[string]any{"status": value.Status, "errorCode": value.ErrorCode, "observedAt": value.ObservedAt, "receivedAt": value.ReceivedAt})
payload, _ := json.Marshal(value.SafeSummary)
return store.upsertSCUMPhysicalRow("scum_sync_runs", value.ID, value.ServerInstanceID, value.QueryKey, fields, payload, value.PluginID, value.QueryKey, freshness, value.ReceivedAt, value.ReceivedAt)
}
func (store *MySQLStore) upsertSCUMPhysicalRow(table, id, serverInstanceID, upsertKey string, fields, payload []byte, pluginID, queryKey string, freshness []byte, createdAt, updatedAt time.Time) error {
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
if updatedAt.IsZero() {
updatedAt = createdAt
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
statement := fmt.Sprintf(`INSERT INTO %s (id, server_instance_id, upsert_key, fields_json, payload_json, plugin_id, query_key, freshness_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE server_instance_id=VALUES(server_instance_id), upsert_key=VALUES(upsert_key), fields_json=VALUES(fields_json), payload_json=VALUES(payload_json), plugin_id=VALUES(plugin_id), query_key=VALUES(query_key), freshness_json=VALUES(freshness_json), updated_at=VALUES(updated_at)`, table)
if _, err := store.db.ExecContext(ctx, statement, id, serverInstanceID, upsertKey, string(fields), string(payload), pluginID, queryKey, string(freshness), createdAt, updatedAt); err != nil {
return fmt.Errorf("write mysql %s row: %w", table, err)
}
return nil
}
func mysqlSCUMTable(target domain.SCUMDataSet) (string, bool) {
switch target {
case domain.SCUMDataSetUsers:
return "scum_users", true
case domain.SCUMDataSetSquads:
return "scum_squads", true
case domain.SCUMDataSetMembers:
return "scum_squad_members", true
case domain.SCUMDataSetVehicles:
return "scum_vehicles", true
case domain.SCUMDataSetFlags:
return "scum_flags", true
case domain.SCUMDataSetActivity:
return "scum_activity_events", true
case domain.SCUMDataSetGiftEvents:
return "scum_gift_events", true
case domain.SCUMDataSetMapPoints:
return "scum_map_points", true
default:
return "", false
}
}
func (store *MySQLStore) load() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -476,7 +327,8 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMDataRows: snapshotRepository(store.MemoryStore.scumDataRows), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
}
}
@@ -510,6 +362,7 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords)
loadRepository(store.MemoryStore.gamePlayers, snapshot.GamePlayers)
loadRepository(store.MemoryStore.gamePlayerAliases, snapshot.GamePlayerAliases)
loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions)
@@ -522,7 +375,6 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
loadRepository(store.MemoryStore.scumDataRows, snapshot.SCUMDataRows)
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
+17 -20
View File
@@ -2,7 +2,6 @@ package repo
import (
"errors"
"fmt"
"sort"
"strings"
"sync"
@@ -226,6 +225,13 @@ type GameClientBridgeSnapshotStreamRepository interface {
Delete(id string) error
}
type PluginDataRecordRepository interface {
Create(domain.PluginDataRecord) error
Get(string) (domain.PluginDataRecord, error)
List(domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
Update(domain.PluginDataRecord) error
}
type GamePlayerRepository interface {
Create(domain.GamePlayer) error
Get(string) (domain.GamePlayer, error)
@@ -302,12 +308,6 @@ type SCUMDataObservationRepository interface {
List(domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error)
Update(domain.SCUMDataObservation) error
}
type SCUMDataRowRepository interface {
Create(domain.SCUMDataRow) error
Get(string) (domain.SCUMDataRow, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error)
Update(domain.SCUMDataRow) error
}
type SCUMPlayerLiveStateRepository interface {
Create(domain.SCUMPlayerLiveState) error
Get(string) (domain.SCUMPlayerLiveState, error)
@@ -393,6 +393,7 @@ type Store interface {
GameClientBridgeCommands() GameClientBridgeCommandRepository
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
PluginDataRecords() PluginDataRecordRepository
GamePlayers() GamePlayerRepository
GamePlayerAliases() GamePlayerAliasRepository
GamePlayerSessions() GamePlayerSessionRepository
@@ -405,7 +406,6 @@ type Store interface {
GameGiftRevisions() GameGiftRevisionRepository
GameGiftGrants() GameGiftGrantRepository
SCUMDataObservations() SCUMDataObservationRepository
SCUMDataRows() SCUMDataRowRepository
SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository
SCUMSquads() SCUMSquadRepository
SCUMSquadMembers() SCUMSquadMemberRepository
@@ -447,6 +447,7 @@ type MemoryStore struct {
bridgeCommands *memoryGameClientBridgeCommandRepository
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
pluginDataRecords *memoryRepository[domain.PluginDataRecord, domain.PluginDataFilter]
gamePlayers *memoryRepository[domain.GamePlayer, domain.GamePlayerFilter]
gamePlayerAliases *memoryRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]
gamePlayerSessions *memoryRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]
@@ -459,7 +460,6 @@ type MemoryStore struct {
gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]
gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]
scumDataObservations *memoryRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]
scumDataRows *memoryRepository[domain.SCUMDataRow, domain.SCUMProjectionFilter]
scumPlayerLiveStates *memoryRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]
scumSquads *memoryRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]
scumSquadMembers *memoryRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]
@@ -606,6 +606,7 @@ func NewMemoryStore() *MemoryStore {
domain.CopyGameClientBridgeSnapshotStream,
matchGameClientBridgeSnapshotStream,
),
pluginDataRecords: newMemoryRepository(func(value domain.PluginDataRecord) string { return value.ID }, domain.CopyPluginDataRecord, matchPluginDataRecord),
gamePlayers: newMemoryRepository(func(v domain.GamePlayer) string { return v.ID }, domain.CopyGamePlayer, matchGamePlayer),
gamePlayerAliases: newMemoryRepository(func(v domain.GamePlayerAlias) string { return v.ID }, domain.CopyGamePlayerAlias, matchGamePlayerAlias),
gamePlayerSessions: newMemoryRepository(func(v domain.GamePlayerSession) string { return v.ID }, domain.CopyGamePlayerSession, matchGamePlayerSession),
@@ -618,7 +619,6 @@ func NewMemoryStore() *MemoryStore {
gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision),
gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant),
scumDataObservations: newMemoryRepository(func(v domain.SCUMDataObservation) string { return v.ID }, domain.CopySCUMDataObservation, matchSCUMDataObservation),
scumDataRows: newMemoryRepository(func(v domain.SCUMDataRow) string { return v.ID }, domain.CopySCUMDataRow, matchSCUMDataRow),
scumPlayerLiveStates: newMemoryRepository(func(v domain.SCUMPlayerLiveState) string { return v.ID }, domain.CopySCUMPlayerLiveState, matchSCUMPlayerLiveState),
scumSquads: newMemoryRepository(func(v domain.SCUMSquad) string { return v.ID }, domain.CopySCUMSquad, matchSCUMSquad),
scumSquadMembers: newMemoryRepository(func(v domain.SCUMSquadMember) string { return v.ID }, domain.CopySCUMSquadMember, matchSCUMSquadMember),
@@ -680,6 +680,9 @@ func (store *MemoryStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRe
func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return store.bridgeStreams
}
func (store *MemoryStore) PluginDataRecords() PluginDataRecordRepository {
return store.pluginDataRecords
}
func (store *MemoryStore) GamePlayers() GamePlayerRepository { return store.gamePlayers }
func (store *MemoryStore) GamePlayerAliases() GamePlayerAliasRepository {
return store.gamePlayerAliases
@@ -710,7 +713,6 @@ func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return stor
func (store *MemoryStore) SCUMDataObservations() SCUMDataObservationRepository {
return store.scumDataObservations
}
func (store *MemoryStore) SCUMDataRows() SCUMDataRowRepository { return store.scumDataRows }
func (store *MemoryStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return store.scumPlayerLiveStates
}
@@ -1033,6 +1035,10 @@ func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotS
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
}
func matchPluginDataRecord(value domain.PluginDataRecord, filter domain.PluginDataFilter) bool {
return (filter.PluginID == "" || value.PluginID == filter.PluginID) && (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) && (filter.Collection == "" || value.Collection == filter.Collection) && (filter.Key == "" || value.Key == filter.Key)
}
func matchGamePlayer(v domain.GamePlayer, f domain.GamePlayerFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.Search == "" || strings.Contains(strings.ToLower(v.DisplayName), strings.ToLower(f.Search)) || strings.Contains(strings.ToLower(v.GamePlayerID), strings.ToLower(f.Search)))
}
@@ -1075,15 +1081,6 @@ func matchSCUMDataObservation(v domain.SCUMDataObservation, f domain.SCUMProject
(f.Freshness == "" || domain.SCUMProjectionFreshness(v.Status) == f.Freshness)
}
func matchSCUMDataRow(v domain.SCUMDataRow, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.QueryKey == "" || v.QueryKey == f.QueryKey) &&
(f.TargetTable == "" || v.TargetTable == f.TargetTable) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
(search == "" || strings.Contains(strings.ToLower(v.UpsertKey), search) || strings.Contains(strings.ToLower(fmt.Sprint(v.Fields)), search))
}
func matchSCUMPlayerLiveState(v domain.SCUMPlayerLiveState, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
+10 -42
View File
@@ -23,7 +23,7 @@ func (svc *CoreService) SaveGameGiftCatalogForSession(sessionID, serverID string
if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.GameGiftCatalog{}, err
}
if err = svc.validateGiftItems(serverID, request.GameVersion, request.Items); err != nil {
if err = validateGiftItems(request.GameVersion, request.Items); err != nil {
return domain.GameGiftCatalog{}, err
}
stamp := svc.now()
@@ -60,7 +60,7 @@ func (svc *CoreService) PublishGameGiftCatalogForSession(sessionID, catalogID st
if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil {
return domain.GameGiftRevision{}, err
}
if err = svc.validateGiftItems(catalog.ServerInstanceID, catalog.GameVersion, catalog.DraftItems); err != nil {
if err = validateGiftItems(catalog.GameVersion, catalog.DraftItems); err != nil {
return domain.GameGiftRevision{}, err
}
revisions, err := svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalog.ID})
@@ -111,7 +111,7 @@ func (svc *CoreService) RequestGameGiftGrantForSession(sessionID, serverID strin
if err != nil || revision.ServerInstanceID != serverID {
return domain.GameGiftGrant{}, repo.ErrNotFound
}
if err = svc.validateGiftItems(serverID, revision.GameVersion, revision.Items); err != nil {
if err = validateGiftItems(revision.GameVersion, revision.Items); err != nil {
return domain.GameGiftGrant{}, err
}
player, err := svc.store.GamePlayers().Get(request.GamePlayerRecordID)
@@ -148,7 +148,7 @@ func (svc *CoreService) ApproveGameGiftGrantForSession(sessionID, grantID string
if grant.Status != domain.GameGiftGrantPendingApproval {
return domain.GameGiftGrant{}, validationError("gift grant is not awaiting approval")
}
if err = svc.validateGiftItems(grant.ServerInstanceID, grant.GameVersion, grant.Items); err != nil {
if err = validateGiftItems(grant.GameVersion, grant.Items); err != nil {
return domain.GameGiftGrant{}, err
}
sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: grant.GamePlayerRecordID, OpenOnly: true})
@@ -198,55 +198,23 @@ func (svc *CoreService) ListGameGiftGrantsForSession(sessionID, serverID string)
sort.Slice(grants, func(i, j int) bool { return grants[i].CreatedAt.After(grants[j].CreatedAt) })
return grants, nil
}
func (svc *CoreService) validateGiftItems(serverID, version string, items []domain.GameGiftItem) error {
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return err
}
catalog, ok := scumGiftCatalogFromPlugin(plugin, version)
if !ok {
return validationError("installed SCUM plugin has no verified gift item catalog for this game version")
func validateGiftItems(version string, items []domain.GameGiftItem) error {
if _, ok := domain.SCUMGiftCatalogForVersion(version); !ok {
return validationError("SCUM game version has no verified gift item catalog")
}
if len(items) == 0 || len(items) > 8 {
return validationError("gift requires 1 to 8 catalog items")
}
seen := map[string]bool{}
for index, item := range items {
def, ok := scumGiftItem(catalog, item.CatalogItemKey)
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity {
for _, item := range items {
def, ok := domain.SCUMGiftItemForVersion(version, item.CatalogItemKey)
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity || item.Label != def.Label {
return validationError("gift item is not valid for this SCUM version")
}
items[index].Label = def.Label
seen[item.CatalogItemKey] = true
}
return nil
}
func scumGiftCatalogFromPlugin(plugin domain.GamePlugin, version string) (domain.SCUMGiftItemCatalog, bool) {
for _, asset := range plugin.LifecycleAssets {
if !strings.HasPrefix(asset.Path, "data-packs/") || !strings.HasSuffix(asset.Path, "/gift-items.json") || strings.TrimSpace(asset.Content) == "" {
continue
}
catalog, ok := domain.ParseSCUMGiftItemCatalog(asset.Content)
if ok && catalog.GameVersion == version {
return catalog, true
}
}
return domain.SCUMGiftItemCatalog{}, false
}
func scumGiftItem(catalog domain.SCUMGiftItemCatalog, key string) (domain.SCUMGiftItemDefinition, bool) {
for _, item := range catalog.Items {
if item.Key == key {
return item, true
}
}
return domain.SCUMGiftItemDefinition{}, false
}
func giftDeliveryPayload(grant domain.GameGiftGrant) map[string]any {
items := make([]any, len(grant.Items))
for i, item := range grant.Items {
-1
View File
@@ -100,7 +100,6 @@ func gameGiftFixture(t *testing.T, online bool) (*CoreService, string, domain.Ga
svc, clock := newGameClientBridgeService(t)
plugin, _ := svc.store.GamePlugins().Get("game.scum")
plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{Type: domain.SCUMRewardDeliverCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 4096}, {Type: domain.SCUMGiftNotificationCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 60, MaxPayloadBytes: 2048}}
plugin.LifecycleAssets = append(plugin.LifecycleAssets, domain.PluginAssetFile{Path: "data-packs/scum-db-v57/gift-items.json", Content: `{"gameVersion":"0.9.700.90357","items":[{"key":"bandage","label":"绷带","maximumQuantity":20},{"key":"water-bottle","label":"饮用水","maximumQuantity":10},{"key":"improvised-spear","label":"简易长矛","maximumQuantity":2}]}`})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
+73
View File
@@ -0,0 +1,73 @@
package service
import (
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func (svc *CoreService) ListPluginDataForSession(sessionID string, filter domain.PluginDataFilter) ([]domain.PluginDataRecord, error) {
if err := svc.authorizePluginData(sessionID, filter.PluginID, filter.ServerInstanceID, filter.Collection); err != nil {
return nil, err
}
values, err := svc.store.PluginDataRecords().List(filter)
if err != nil {
return nil, err
}
if filter.Limit > 0 && len(values) > filter.Limit {
values = values[:filter.Limit]
}
return values, nil
}
func (svc *CoreService) PutPluginDataForSession(sessionID string, value domain.PluginDataRecord) (domain.PluginDataRecord, error) {
if err := svc.authorizePluginData(sessionID, value.PluginID, value.ServerInstanceID, value.Collection); err != nil {
return domain.PluginDataRecord{}, err
}
if strings.TrimSpace(value.Key) == "" {
return domain.PluginDataRecord{}, validationError("plugin data key is required")
}
if value.Value == nil {
return domain.PluginDataRecord{}, validationError("plugin data value is required")
}
value.ID = pluginDataID(value.ServerInstanceID, value.PluginID, value.Collection, value.Key)
stamp := svc.now()
existing, err := svc.store.PluginDataRecords().Get(value.ID)
if err == repo.ErrNotFound {
value.CreatedAt, value.UpdatedAt = stamp, stamp
if err := svc.store.PluginDataRecords().Create(value); err != nil {
return domain.PluginDataRecord{}, err
}
return domain.CopyPluginDataRecord(value), nil
}
if err != nil {
return domain.PluginDataRecord{}, err
}
existing.Value, existing.UpdatedAt = domain.CopyGameClientBridgePayload(value.Value), stamp
if err := svc.store.PluginDataRecords().Update(existing); err != nil {
return domain.PluginDataRecord{}, err
}
return domain.CopyPluginDataRecord(existing), nil
}
func (svc *CoreService) authorizePluginData(sessionID, pluginID, serverInstanceID, collection string) error {
if strings.TrimSpace(pluginID) == "" || strings.TrimSpace(serverInstanceID) == "" || strings.TrimSpace(collection) == "" {
return validationError("pluginId, serverInstanceId, and collection are required")
}
if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil {
return err
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return err
}
if instance.PluginID != pluginID {
return ErrForbidden
}
return nil
}
func pluginDataID(serverID, pluginID, collection, key string) string {
return "plugin-data-" + fingerprintID(serverID, pluginID+"\x00"+collection+"\x00"+key)
}
+31
View File
@@ -0,0 +1,31 @@
package service
import (
"testing"
"browser.local/platform/domain"
)
func TestPluginDataIsOpaqueAndScopedToItsServerPlugin(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
owner, err := svc.RegisterUser(domain.UserRegistration{DisplayName: "Plugin data owner", Email: "plugin-data@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("register owner: %v", err)
}
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: endpoint.ID, OwnerUserID: owner.User.ID, Name: "SCUM"}); err != nil {
t.Fatalf("create server: %v", err)
}
sessionID := owner.SessionID
stored, err := svc.PutPluginDataForSession(sessionID, domain.PluginDataRecord{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "futureField": true}})
if err != nil || stored.Value["futureField"] != true {
t.Fatalf("put plugin data=%+v err=%v", stored, err)
}
items, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "scum_users"})
if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["steamId"] != "steam-1" {
t.Fatalf("list plugin data=%+v err=%v", items, err)
}
if _, err := svc.ListPluginDataForSession(sessionID, domain.PluginDataFilter{PluginID: "other.plugin", ServerInstanceID: "server-1", Collection: "scum_users"}); err != ErrForbidden {
t.Fatalf("expected plugin isolation error, got %v", err)
}
}
+2 -1
View File
@@ -191,6 +191,8 @@ type Core interface {
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
PutPluginDataForSession(string, domain.PluginDataRecord) (domain.PluginDataRecord, error)
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
@@ -231,7 +233,6 @@ type Core interface {
ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
ListSCUMDataRowsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error)
RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error)
ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error)
+34 -133
View File
@@ -59,28 +59,12 @@ func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservation
return observation, nil
}
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt}
target, err := svc.scumRowTarget(result.PluginID, result.QueryKey)
if err != nil {
return domain.SCUMDataObservation{}, err
}
if err := svc.applySCUMRows(target, result.PluginID, result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
return domain.SCUMDataObservation{}, err
}
return observation, nil
}
func (svc *CoreService) ListSCUMDataRowsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMDataRow, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMDataRows().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
@@ -190,39 +174,44 @@ func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObserva
return svc.store.SCUMDataObservations().Create(observation)
}
func (svc *CoreService) applySCUMRows(target domain.SCUMRowTargetDeclaration, pluginID, queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
if target.TargetTable != "" {
func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
lower := strings.ToLower(queryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
for _, row := range rows {
if err := svc.upsertSCUMDataRow(target, pluginID, queryKey, serverID, row, freshness); err != nil {
return err
}
}
return nil
}
// Compatibility declarations retain the old projections without guessing from substrings.
for _, row := range rows {
switch queryKey {
case "scum.player.profile":
if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil {
return err
}
case "scum.squads":
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
return err
}
case "scum.squad-members":
}
}
if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") {
for _, row := range rows {
if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil {
return err
}
case "scum.vehicles":
}
} else if strings.Contains(lower, "squad") {
for _, row := range rows {
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "vehicle") {
for _, row := range rows {
if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil {
return err
}
case "scum.flags":
}
}
if strings.Contains(lower, "flag") {
for _, row := range rows {
if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil {
return err
}
case "scum.positions":
}
}
if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") {
for _, row := range rows {
if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil {
return err
}
@@ -231,78 +220,6 @@ func (svc *CoreService) applySCUMRows(target domain.SCUMRowTargetDeclaration, pl
return nil
}
func (svc *CoreService) scumRowTarget(pluginID, queryKey string) (domain.SCUMRowTargetDeclaration, error) {
plugin, err := svc.store.GamePlugins().Get(pluginID)
if err != nil {
return domain.SCUMRowTargetDeclaration{}, err
}
for _, template := range plugin.GameClientBridge.QueryTemplates {
if template.Key == queryKey && template.RowTarget != nil {
return domain.CopySCUMRowTargetDeclaration(*template.RowTarget), nil
}
}
if _, ok := legacySCUMQueryKeys[queryKey]; ok {
return domain.SCUMRowTargetDeclaration{}, nil
}
return domain.SCUMRowTargetDeclaration{}, validationError("queryKey does not declare a SCUM row target")
}
var legacySCUMQueryKeys = map[string]struct{}{
"scum.player.profile": {}, "scum.squads": {}, "scum.squad-members": {}, "scum.vehicles": {}, "scum.flags": {}, "scum.positions": {},
}
func (svc *CoreService) upsertSCUMDataRow(target domain.SCUMRowTargetDeclaration, pluginID, queryKey, serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
table := domain.SCUMDataSet(strings.TrimSpace(target.TargetTable))
if !validSCUMDataSet(table) || len(target.UpsertKeys) == 0 {
return validationError("SCUM row target is invalid")
}
fields := map[string]any{}
for destination, source := range target.ColumnMappings {
if value, ok := row[source]; ok {
fields[destination] = value
}
}
keyValues := make([]string, 0, len(target.UpsertKeys))
for _, key := range target.UpsertKeys {
value, ok := fields[key]
if !ok {
value, ok = row[key]
}
text := firstString(map[string]any{"value": value}, "value")
if !ok || text == "" {
return validationError("SCUM row is missing declared upsert key " + key)
}
keyValues = append(keyValues, text)
}
upsertKey := strings.Join(keyValues, "\x00")
id := scumProjectionID(string(table), serverID, upsertKey)
value, err := svc.store.SCUMDataRows().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMDataRow{ID: id, ServerInstanceID: serverID, TargetTable: table, UpsertKey: upsertKey, CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.Fields = domain.CopyGameClientBridgePayload(fields)
value.Payload = domain.CopyGameClientBridgePayload(row)
value.PluginID, value.QueryKey, value.Freshness, value.UpdatedAt = pluginID, queryKey, freshness, svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMDataRows().Create(value)
}
return svc.store.SCUMDataRows().Update(value)
}
func validSCUMDataSet(value domain.SCUMDataSet) bool {
switch value {
case domain.SCUMDataSetUsers, domain.SCUMDataSetSquads, domain.SCUMDataSetMembers, domain.SCUMDataSetVehicles, domain.SCUMDataSetFlags, domain.SCUMDataSetActivity, domain.SCUMDataSetGiftEvents, domain.SCUMDataSetMapPoints:
return true
default:
return false
}
}
func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
@@ -641,27 +558,8 @@ func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition)
func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error {
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
target, err := svc.scumRowTarget(result.PluginID, result.QueryKey)
if err != nil {
return err
}
if target.TargetTable != "" {
values, err := svc.store.SCUMDataRows().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID, TargetTable: domain.SCUMDataSet(target.TargetTable)})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness, value.UpdatedAt = freshness, svc.now()
if err := svc.store.SCUMDataRows().Update(value); err != nil {
return err
}
}
}
return nil
}
switch result.QueryKey {
case "scum.player.profile":
lower := strings.ToLower(result.QueryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
@@ -675,7 +573,8 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
}
}
}
case "scum.squads", "scum.squad-members":
}
if strings.Contains(lower, "squad") {
values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
@@ -689,7 +588,8 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
}
}
}
case "scum.vehicles":
}
if strings.Contains(lower, "vehicle") {
values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
@@ -703,7 +603,8 @@ func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult,
}
}
}
case "scum.flags":
}
if strings.Contains(lower, "flag") {
values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
-26
View File
@@ -108,29 +108,3 @@ func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *t
t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0])
}
}
func TestSCUMObservationUsesDeclaredRowTargetInsteadOfQueryKeyName(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
plugin, err := svc.store.GamePlugins().Get("server.scum")
if err != nil {
t.Fatalf("get plugin: %v", err)
}
plugin.GameClientBridge.QueryTemplates = append(plugin.GameClientBridge.QueryTemplates, domain.GameClientBridgeQueryTemplateDeclaration{Key: "v57.catalog.people", RowTarget: &domain.SCUMRowTargetDeclaration{TargetTable: string(domain.SCUMDataSetUsers), UpsertKeys: []string{"profileId"}, ColumnMappings: map[string]string{"profileId": "user_profile_id", "name": "display_name"}}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin: %v", err)
}
_, err = svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "v57.catalog.people", Sequence: 1, ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"user_profile_id": "profile-1", "display_name": "Moon", "unmapped": "kept"}}})
if err != nil {
t.Fatalf("apply declared data row: %v", err)
}
rows, err := svc.store.SCUMDataRows().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", TargetTable: domain.SCUMDataSetUsers})
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if rows[0].Fields["profileId"] != "profile-1" || rows[0].Payload["unmapped"] != "kept" {
t.Fatalf("unexpected declared row: %+v", rows[0])
}
if states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1"}); err != nil || len(states) != 0 {
t.Fatalf("query key leaked into legacy projection dispatch: states=%+v err=%v", states, err)
}
}
-25
View File
@@ -578,22 +578,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
violations = append(violations, prefix+".timeoutSeconds is invalid")
}
if template.RowTarget != nil {
rowTarget := template.RowTarget
if !validSCUMTargetTable(rowTarget.TargetTable) || len(rowTarget.UpsertKeys) == 0 {
violations = append(violations, prefix+".rowTarget must declare an allowed scum_* table and upsert keys")
}
for _, key := range rowTarget.UpsertKeys {
if !clientManagerIdentifierPattern.MatchString(key) {
violations = append(violations, prefix+".rowTarget upsert key is invalid")
}
}
for destination, source := range rowTarget.ColumnMappings {
if !clientManagerIdentifierPattern.MatchString(destination) || !clientManagerIdentifierPattern.MatchString(source) {
violations = append(violations, prefix+".rowTarget column mapping is invalid")
}
}
}
transport, exists := transports[template.TransportKey]
if !exists {
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
@@ -780,15 +764,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
return violations
}
func validSCUMTargetTable(value string) bool {
switch value {
case "scum_users", "scum_squads", "scum_activity_events", "scum_gift_events", "scum_map_points":
return true
default:
return false
}
}
func validCompanionProofEnvironment(value string) bool {
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
return false
+2 -10
View File
@@ -245,11 +245,7 @@ describe("PlatformApiClient AI providers", () => {
});
}
if (url.endsWith("/api/v1/server-instances/server-1/scum/players")) return jsonResponse({ items: [{ id: "scum-player-1", gamePlayerId: "steam-1", displayName: "Prisoner One", online: true }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/users")) return jsonResponse({ items: [{ id: "scum-user-1", serverInstanceId: server.id, displayName: "Prisoner One", steamId: "steam-1" }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/datasets/squads")) return jsonResponse({ items: [{ id: "squad-1", serverInstanceId: server.id, fields: { squadId: "squad-1", name: "Alpha" } }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/activity")) return jsonResponse({ items: [{ id: "activity-1", serverInstanceId: server.id, kind: "login" }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/gifts")) return jsonResponse({ items: [{ id: "gift-1", serverInstanceId: server.id, name: "Starter" }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/map-points")) return jsonResponse({ items: [{ id: "point-1", serverInstanceId: server.id, kind: "base" }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/squads")) return jsonResponse({ items: [{ id: "squad-1", squadId: "squad-1", name: "Alpha" }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/squad-members")) return jsonResponse({ items: [{ id: "member-1", squadId: "squad-1", gamePlayerId: "steam-1" }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/flags")) return jsonResponse({ items: [{ id: "flag-1", flagId: "flag-1", ownerSquadId: "squad-1" }], count: 1 });
@@ -559,11 +555,7 @@ describe("PlatformApiClient AI providers", () => {
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
await expect(client.listSCUMPlayers(server.id)).resolves.toMatchObject({ count: 1, items: [{ gamePlayerId: "steam-1" }] });
await expect(client.listSCUMUsers(server.id)).resolves.toMatchObject({ count: 1, items: [{ steamId: "steam-1" }] });
await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.listSCUMActivity(server.id)).resolves.toMatchObject({ count: 1, items: [{ kind: "login" }] });
await expect(client.listSCUMGifts(server.id)).resolves.toMatchObject({ count: 1, items: [{ name: "Starter" }] });
await expect(client.listSCUMMapPoints(server.id)).resolves.toMatchObject({ count: 1, items: [{ kind: "base" }] });
await expect(client.listSCUMSquadMembers(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 });
@@ -632,7 +624,7 @@ describe("PlatformApiClient AI providers", () => {
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(52);
expect(fetchMock).toHaveBeenCalledTimes(48);
});
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
+13 -59
View File
@@ -47,14 +47,6 @@ import type {
GameClientBridgeSnapshotListResponse,
GameClientBridgeSnapshotQuery,
GameClientBridgeStatusResponse,
GameGiftCatalogListResponse,
GameGiftCatalogRequest,
GameGiftCatalogResponse,
GameGiftGrantListResponse,
GameGiftGrantRequest,
GameGiftGrantResponse,
GameGiftRevisionListResponse,
GameGiftRevisionResponse,
GamePluginListResponse,
HealthResponse,
JobCreateRequest,
@@ -110,14 +102,9 @@ import type {
RemoteAdapterRequest,
RemoteAdapterResponse,
SCUMListResponse,
SCUMActivityListResponse,
SCUMGiftsListResponse,
SCUMMapPointsListResponse,
SCUMOperationListResponse,
SCUMOperationRequest,
SCUMOperationResponse,
SCUMSquadsListResponse,
SCUMUsersListResponse,
SCUMWorkflowCreateRequest,
SCUMWorkflowListResponse,
SCUMWorkflowResponse,
@@ -592,6 +579,17 @@ export class PlatformApiClient {
return this.request<RemoteAdapterDeclarationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`);
}
async listPluginData(serverInstanceId: string, collection: string, key?: string): Promise<{ items: Array<{ key: string; value: Record<string, unknown> }>; count: number }> {
const params = new URLSearchParams();
if (key) params.set("key", key);
const query = params.toString();
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}${query ? `?${query}` : ""}`);
}
async putPluginData(serverInstanceId: string, collection: string, key: string, value: Record<string, unknown>): Promise<{ key: string; value: Record<string, unknown> }> {
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}`, { method: "PUT", body: { key, value } });
}
async requestRemoteAdapter(serverInstanceId: string, request: RemoteAdapterRequest): Promise<RemoteAdapterResponse> {
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
}
@@ -600,24 +598,8 @@ export class PlatformApiClient {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`);
}
async listSCUMUsers(serverInstanceId: string): Promise<SCUMUsersListResponse> {
return this.request<SCUMUsersListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/users`);
}
async listSCUMSquads(serverInstanceId: string): Promise<SCUMSquadsListResponse> {
return this.request<SCUMSquadsListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/datasets/squads`);
}
async listSCUMActivity(serverInstanceId: string): Promise<SCUMActivityListResponse> {
return this.request<SCUMActivityListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/activity`);
}
async listSCUMGifts(serverInstanceId: string): Promise<SCUMGiftsListResponse> {
return this.request<SCUMGiftsListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/gifts`);
}
async listSCUMMapPoints(serverInstanceId: string): Promise<SCUMMapPointsListResponse> {
return this.request<SCUMMapPointsListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/map-points`);
async listSCUMSquads(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`);
}
async listSCUMSquadMembers(serverInstanceId: string): Promise<SCUMListResponse> {
@@ -636,34 +618,6 @@ export class PlatformApiClient {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`);
}
async listGameGiftCatalogs(serverInstanceId: string): Promise<GameGiftCatalogListResponse> {
return this.request<GameGiftCatalogListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`);
}
async saveGameGiftCatalog(serverInstanceId: string, request: GameGiftCatalogRequest): Promise<GameGiftCatalogResponse> {
return this.request<GameGiftCatalogResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`, { method: "POST", body: request });
}
async publishGameGiftCatalog(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionResponse> {
return this.request<GameGiftRevisionResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/publish`, { method: "POST", body: {} });
}
async listGameGiftRevisions(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionListResponse> {
return this.request<GameGiftRevisionListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/revisions`);
}
async listGameGiftGrants(serverInstanceId: string): Promise<GameGiftGrantListResponse> {
return this.request<GameGiftGrantListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`);
}
async requestGameGiftGrant(serverInstanceId: string, request: GameGiftGrantRequest): Promise<GameGiftGrantResponse> {
return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`, { method: "POST", body: request });
}
async approveGameGiftGrant(serverInstanceId: string, grantId: string): Promise<GameGiftGrantResponse> {
return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants/${encodeURIComponent(grantId)}/approve`, { method: "POST", body: {} });
}
async listSCUMOperations(serverInstanceId: string): Promise<SCUMOperationListResponse> {
return this.request<SCUMOperationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`);
}
-23
View File
@@ -388,12 +388,9 @@ export interface GamePluginResponse {
validationViolations?: string[];
runtimeProfiles?: GamePluginRuntimeProfilesResponse;
gameClientBridge?: GameClientBridgeManifestResponse;
mapTrajectories?: GameMapGeometryResponse;
status: GamePluginStatus;
}
export interface GameMapGeometryResponse { mapId: string; mapVersion: string; worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number; imageWidth: number; imageHeight: number; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number; }
export type PluginCreateFieldType = "text" | "number" | "boolean" | "select" | "port";
export interface PluginCreateFieldResponse {
@@ -1378,26 +1375,6 @@ export interface RemoteAdapterResponse {
export type SCUMJsonRecord = Record<string, unknown>;
export interface SCUMListResponse<T = SCUMJsonRecord> { items: T[]; count: number; }
export interface SCUMPersistedRecord { id: string; serverInstanceId: string; observedAt?: string; syncedAt?: string; payload?: SCUMJsonRecord; }
export interface SCUMUserRecord extends SCUMPersistedRecord { userProfileId?: string; steamId?: string; displayName?: string; famePoints?: number; normalBalance?: number; goldBalance?: number; lastLoginTime?: string; lastLogoutTime?: string; isAlive?: boolean; }
export interface SCUMSquadRecord extends SCUMPersistedRecord { squadId?: string; name?: string; message?: string; score?: number; memberLimit?: number; memberCount?: number; }
export interface SCUMActivityEventRecord extends SCUMPersistedRecord { eventId?: string; kind?: string; subjectId?: string; subjectName?: string; summary?: string; occurredAt?: string; locationX?: number; locationY?: number; locationZ?: number; }
export interface SCUMGiftRecord extends SCUMPersistedRecord { giftId?: string; name?: string; status?: string; recipientUserProfileId?: string; recipientName?: string; availableAt?: string; expiresAt?: string; }
export interface SCUMMapPointRecord extends SCUMPersistedRecord { pointId?: string; kind?: string; label?: string; locationX?: number; locationY?: number; locationZ?: number; mapId?: string; }
export type SCUMUsersListResponse = SCUMListResponse<SCUMUserRecord>;
export type SCUMSquadsListResponse = SCUMListResponse<SCUMSquadRecord>;
export type SCUMActivityListResponse = SCUMListResponse<SCUMActivityEventRecord>;
export type SCUMGiftsListResponse = SCUMListResponse<SCUMGiftRecord>;
export type SCUMMapPointsListResponse = SCUMListResponse<SCUMMapPointRecord>;
export interface GameGiftItemResponse { catalogItemKey: string; label: string; quantity: number; }
export interface GameGiftCatalogResponse { id: string; name: string; gameVersion: string; draftItems: GameGiftItemResponse[]; latestRevisionId?: string; updatedAt: string; }
export interface GameGiftCatalogListResponse { items: GameGiftCatalogResponse[]; }
export interface GameGiftRevisionResponse { id: string; catalogId: string; revision: number; gameVersion: string; items: GameGiftItemResponse[]; publishedBy: string; publishedAt: string; }
export interface GameGiftRevisionListResponse { items: GameGiftRevisionResponse[]; }
export interface GameGiftGrantResponse { id: string; revisionId: string; revisionNumber: number; gameVersion: string; items: GameGiftItemResponse[]; gamePlayerRecordId: string; playerDisplayName: string; notice: string; requesterId: string; approverId?: string; status: string; deliverySummary?: string; notificationSummary?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
export interface GameGiftGrantListResponse { items: GameGiftGrantResponse[]; }
export interface GameGiftCatalogRequest { id?: string; name: string; gameVersion: string; items: Array<{ catalogItemKey: string; quantity: number }>; }
export interface GameGiftGrantRequest { revisionId: string; gamePlayerRecordId: string; notice: string; idempotencyKey: string; }
export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; }
export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; }
export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
@@ -1,20 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { SCUMPersistedDataView, scumDatasetForPluginRoute } from "./SCUMPersistedDataView";
describe("SCUMPersistedDataView", () => {
it("maps the five declared SCUM plugin routes to persisted datasets", () => {
expect(scumDatasetForPluginRoute("players")).toBe("users");
expect(scumDatasetForPluginRoute("squads")).toBe("squads");
expect(scumDatasetForPluginRoute("workflows")).toBe("activity");
expect(scumDatasetForPluginRoute("gifts")).toBe("gifts");
expect(scumDatasetForPluginRoute("live-map")).toBe("map");
});
it("renders a themed loading state without synthetic records", () => {
const html = renderToStaticMarkup(<SCUMPersistedDataView serverInstanceId="server-1" dataset="users" />);
expect(html).toContain("正在读取用户同步数据");
expect(html).not.toContain("Prisoner One");
});
});
@@ -1,90 +0,0 @@
import { Activity, Gift, MapPinned, RefreshCw, ShieldAlert, UsersRound } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { SCUMActivityEventRecord, SCUMGiftRecord, SCUMMapPointRecord, SCUMSquadRecord, SCUMUserRecord } from "../api/types";
import { EmptyState, ErrorState, LoadingState } from "./StateViews";
export type SCUMPersistedDataset = "users" | "squads" | "activity" | "gifts" | "map";
interface SCUMPersistedDataViewProps {
serverInstanceId: string;
dataset: SCUMPersistedDataset;
}
type DatasetRecord = (SCUMUserRecord | SCUMSquadRecord | SCUMActivityEventRecord | SCUMGiftRecord | SCUMMapPointRecord) & { fields?: Record<string, unknown>; updatedAt?: string; freshness?: { observedAt?: string } };
type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: DatasetRecord[]; count: number };
const datasetCopy: Record<SCUMPersistedDataset, { title: string; summary: string; empty: string; icon: typeof UsersRound }> = {
users: { title: "用户", summary: "来自 SCUM 用户、角色和账户记录的已同步数据。", empty: "还没有完成的用户同步记录。", icon: UsersRound },
squads: { title: "队伍", summary: "来自 SCUM 队伍和成员记录的已同步数据。", empty: "还没有完成的队伍同步记录。", icon: UsersRound },
activity: { title: "活动", summary: "来自已解析日志和活动记录的持久化事件。", empty: "还没有完成的活动同步记录。", icon: Activity },
gifts: { title: "礼包", summary: "来自 SCUM 礼包目录和发放记录的已同步数据。", empty: "还没有完成的礼包同步记录。", icon: Gift },
map: { title: "地图", summary: "来自基地、旗帜、载具和实体坐标的已同步地图点。", empty: "还没有完成的地图同步记录。", icon: MapPinned }
};
export function scumDatasetForPluginRoute(routeKey: string): SCUMPersistedDataset | null {
if (routeKey === "players") return "users";
if (routeKey === "squads") return "squads";
if (routeKey === "workflows") return "activity";
if (routeKey === "gifts") return "gifts";
if (routeKey === "live-map") return "map";
return null;
}
export function SCUMPersistedDataView({ serverInstanceId, dataset }: SCUMPersistedDataViewProps) {
const copy = datasetCopy[dataset];
const load = useCallback(async () => {
if (!serverInstanceId) return { status: "error" as const, reason: "插件页面没有绑定服务器。" };
try {
const response = dataset === "users" ? await platformApiClient.listSCUMUsers(serverInstanceId)
: dataset === "squads" ? await platformApiClient.listSCUMSquads(serverInstanceId)
: dataset === "activity" ? await platformApiClient.listSCUMActivity(serverInstanceId)
: dataset === "gifts" ? await platformApiClient.listSCUMGifts(serverInstanceId)
: await platformApiClient.listSCUMMapPoints(serverInstanceId);
return { status: "ready" as const, items: response.items.map(normalizePersistedRow) as DatasetRecord[], count: response.count };
} catch (error) {
return { status: "error" as const, reason: error instanceof Error ? error.message : `${copy.title}数据加载失败。` };
}
}, [copy.title, dataset, serverInstanceId]);
const [state, setState] = useState<LoadState>({ status: "loading" });
const refresh = useCallback(() => { setState({ status: "loading" }); void load().then(setState); }, [load]);
useEffect(() => { refresh(); }, [refresh]);
return <section className="console-panel" aria-label={`SCUM ${copy.title}`}>
<div className="panel-header">
<div><h2><copy.icon size={16} aria-hidden="true" /> {copy.title}</h2><p className="provider-id">{copy.summary}</p></div>
<button type="button" className="icon-command" onClick={refresh} disabled={state.status === "loading"}><RefreshCw size={14} aria-hidden="true" /><span></span></button>
</div>
{state.status === "loading" && <LoadingState compact label={`正在读取${copy.title}同步数据…`} />}
{state.status === "error" && <ErrorState compact title={`${copy.title}数据不可用`} reason={state.reason} onRetry={refresh} />}
{state.status === "ready" && (state.items.length ? <SCUMDatasetTable dataset={dataset} items={state.items} count={state.count} /> : <EmptyState title={`暂无${copy.title}数据`} description={copy.empty} icon={<ShieldAlert size={26} />} />)}
</section>;
}
function SCUMDatasetTable({ dataset, items, count }: { dataset: SCUMPersistedDataset; items: DatasetRecord[]; count: number }) {
const columns = useMemo(() => tableColumns(dataset), [dataset]);
return <div className="console-record-list">
<dl className="console-stat-strip"><div><dt></dt><dd>{count}</dd></div><div><dt></dt><dd>{Math.min(items.length, 100)}</dd></div><div><dt></dt><dd></dd></div></dl>
<div className="resource-table-wrap"><table className="resource-table"><thead><tr>{columns.map((column) => <th key={column.label}>{column.label}</th>)}</tr></thead><tbody>{items.slice(0, 100).map((item) => <tr key={item.id}>{columns.map((column) => <td key={column.label}>{column.value(item)}</td>)}</tr>)}</tbody></table></div>
</div>;
}
type TableColumn = { label: string; value: (record: DatasetRecord) => string };
function tableColumns(dataset: SCUMPersistedDataset): TableColumn[] {
if (dataset === "users") return [column("名称", (value) => (value as SCUMUserRecord).displayName || "未命名"), column("Steam", (value) => (value as SCUMUserRecord).steamId), column("声望", (value) => numberText((value as SCUMUserRecord).famePoints)), column("余额", (value) => numberText((value as SCUMUserRecord).normalBalance)), column("最后登录", (value) => dateText((value as SCUMUserRecord).lastLoginTime || value.observedAt))];
if (dataset === "squads") return [column("队伍", (value) => (value as SCUMSquadRecord).name || "未命名"), column("成员", (value) => numberText((value as SCUMSquadRecord).memberCount)), column("上限", (value) => numberText((value as SCUMSquadRecord).memberLimit)), column("分数", (value) => numberText((value as SCUMSquadRecord).score)), column("同步时间", (value) => dateText(value.syncedAt || value.observedAt))];
if (dataset === "activity") return [column("事件", (value) => (value as SCUMActivityEventRecord).kind || "活动"), column("对象", (value) => (value as SCUMActivityEventRecord).subjectName || (value as SCUMActivityEventRecord).subjectId), column("摘要", (value) => (value as SCUMActivityEventRecord).summary), column("发生时间", (value) => dateText((value as SCUMActivityEventRecord).occurredAt || value.observedAt)), column("坐标", (value) => coordinates(value as SCUMActivityEventRecord))];
if (dataset === "gifts") return [column("礼包", (value) => (value as SCUMGiftRecord).name || "未命名礼包"), column("状态", (value) => (value as SCUMGiftRecord).status || "未知"), column("接收者", (value) => (value as SCUMGiftRecord).recipientName || (value as SCUMGiftRecord).recipientUserProfileId), column("可用时间", (value) => dateText((value as SCUMGiftRecord).availableAt)), column("过期时间", (value) => dateText((value as SCUMGiftRecord).expiresAt))];
return [column("标记", (value) => (value as SCUMMapPointRecord).label || (value as SCUMMapPointRecord).kind || "地图点"), column("类型", (value) => (value as SCUMMapPointRecord).kind), column("地图", (value) => (value as SCUMMapPointRecord).mapId), column("坐标", (value) => coordinates(value as SCUMMapPointRecord)), column("同步时间", (value) => dateText(value.syncedAt || value.observedAt))];
}
function column(label: string, getValue: (record: DatasetRecord) => unknown): TableColumn { return { label, value: (record) => text(getValue(record)) }; }
function normalizePersistedRow(row: DatasetRecord): DatasetRecord {
const fields = row.fields ?? {};
return { ...row, ...fields, syncedAt: row.updatedAt ?? row.syncedAt, observedAt: row.freshness?.observedAt ?? row.observedAt };
}
function text(value: unknown): string { return value === undefined || value === null || value === "" ? "--" : String(value); }
function numberText(value: number | undefined): string { return value === undefined || value === null ? "--" : new Intl.NumberFormat("zh-CN").format(value); }
function dateText(value: string | undefined): string { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
function coordinates(value: Pick<SCUMActivityEventRecord, "locationX" | "locationY" | "locationZ"> | SCUMMapPointRecord): string { return value.locationX === undefined || value.locationY === undefined ? "--" : `X ${value.locationX} / Y ${value.locationY}${value.locationZ === undefined ? "" : ` / Z ${value.locationZ}`}`; }
+4 -23
View File
@@ -1,25 +1,6 @@
export interface PluginPageWorkspaceActions {
listSCUMUsers?: () => Promise<unknown>;
listSCUMPlayers?: () => Promise<unknown>;
listSCUMSquads?: () => Promise<unknown>;
listSCUMActivity?: () => Promise<unknown>;
listSCUMGifts?: () => Promise<unknown>;
listSCUMMapPoints?: () => Promise<unknown>;
getSCUMMapGeometry?: () => unknown;
listSCUMSquadMembers?: () => Promise<unknown>;
listSCUMVehicles?: () => Promise<unknown>;
listSCUMFlags?: () => Promise<unknown>;
listSCUMPositions?: () => Promise<unknown>;
listSCUMOperations?: () => Promise<unknown>;
createSCUMOperation?: (request: unknown) => Promise<unknown>;
approveSCUMOperation?: (operationId: string) => Promise<unknown>;
listSCUMWorkflows?: () => Promise<unknown>;
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
listGameGiftCatalogs?: () => Promise<unknown>;
saveGameGiftCatalog?: (request: unknown) => Promise<unknown>;
publishGameGiftCatalog?: (catalogId: string) => Promise<unknown>;
listGameGiftGrants?: () => Promise<unknown>;
requestGameGiftGrant?: (request: unknown) => Promise<unknown>;
approveGameGiftGrant?: (grantId: string) => Promise<unknown>;
pluginData?: {
list: (collection: string, key?: string) => Promise<unknown>;
put: (collection: string, key: string, value: Record<string, unknown>) => Promise<unknown>;
};
}
+5 -11
View File
@@ -85,7 +85,7 @@ describe("PluginPageHostPage", () => {
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
});
it("loads the declared SCUM bundle without a browser-side game branch", () => {
it("does not mount a bundle without client-side availability validation", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
expect(html).toContain("未绑定服务器");
expect(html).toContain("正在校验并加载插件页面 bundle");
@@ -96,18 +96,12 @@ describe("PluginPageHostPage", () => {
expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/);
});
it("keeps typed SCUM workspace callbacks stable across parent operational refreshes", () => {
it("keeps generic plugin data callbacks stable across parent operational refreshes", () => {
expect(hostSource).toContain("readyPluginRef.current = readyPlugin");
expect(hostSource).toContain("hostContextRef.current = hostContext");
expect(hostSource).toContain("listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId)");
expect(hostSource).toContain("listSCUMUsers: () => platformApiClient.listSCUMUsers(serverId)");
expect(hostSource).toContain("listSCUMActivity: () => platformApiClient.listSCUMActivity(serverId)");
expect(hostSource).toContain("listSCUMGifts: () => platformApiClient.listSCUMGifts(serverId)");
expect(hostSource).toContain("listSCUMMapPoints: () => platformApiClient.listSCUMMapPoints(serverId)");
expect(hostSource).not.toContain("SCUMPersistedDataView");
expect(hostSource).toContain("createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never)");
expect(hostSource).toContain("createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never)");
expect(hostSource).toContain("listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)");
expect(hostSource).toContain("list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key)");
expect(hostSource).toContain("put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value)");
expect(hostSource).not.toContain("listSCUMPlayers:");
expect(hostSource).not.toContain("refreshWorkspace");
expect(hostSource).not.toContain("requestFile");
expect(hostSource).not.toContain("writeFile");
+4 -23
View File
@@ -69,29 +69,10 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
if (!pluginId) return undefined;
return {
listSCUMUsers: () => platformApiClient.listSCUMUsers(serverId),
listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId),
listSCUMSquads: () => platformApiClient.listSCUMSquads(serverId),
listSCUMActivity: () => platformApiClient.listSCUMActivity(serverId),
listSCUMGifts: () => platformApiClient.listSCUMGifts(serverId),
listSCUMMapPoints: () => platformApiClient.listSCUMMapPoints(serverId),
getSCUMMapGeometry: () => readyPluginRef.current?.mapTrajectories,
listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId),
listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId),
listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId),
listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId),
listSCUMOperations: () => platformApiClient.listSCUMOperations(serverId),
createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never),
approveSCUMOperation: (operationId) => platformApiClient.approveSCUMOperation(serverId, operationId),
listSCUMWorkflows: () => platformApiClient.listSCUMWorkflows(serverId),
createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never),
listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId),
listGameGiftCatalogs: () => platformApiClient.listGameGiftCatalogs(serverId),
saveGameGiftCatalog: (request) => platformApiClient.saveGameGiftCatalog(serverId, request as never),
publishGameGiftCatalog: (catalogId) => platformApiClient.publishGameGiftCatalog(serverId, catalogId),
listGameGiftGrants: () => platformApiClient.listGameGiftGrants(serverId),
requestGameGiftGrant: (request) => platformApiClient.requestGameGiftGrant(serverId, request as never),
approveGameGiftGrant: (grantId) => platformApiClient.approveGameGiftGrant(serverId, grantId)
pluginData: {
list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key),
put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value)
}
};
}, [pluginId, serverId]);
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
@@ -1,12 +0,0 @@
{
"version": 1,
"maps": [
{ "key": "server-settings", "format": "ini", "encoding": "utf-8", "fileName": "ServerSettings.ini", "section": "General", "fields": { "scum.ServerName": "serverName", "scum.ServerDescription": "serverDescription", "scum.WelcomeMessage": "welcomeMessage", "scum.MessageOfTheDay": "motd", "scum.MaxPlayers": "maxPlayers", "scum.ServerPlaystyle": "playstyle" } },
{ "key": "admin-users", "format": "ini-list", "encoding": "utf-8", "fileName": "AdminUsers.ini", "fields": { "steamId": "steamId", "permissions": "permissions" } },
{ "key": "banned-users", "format": "ini-list", "encoding": "utf-8", "fileName": "BannedUsers.ini", "fields": { "steamId": "steamId" } },
{ "key": "whitelisted-users", "format": "ini-list", "encoding": "utf-8", "fileName": "WhitelistedUsers.ini", "fields": { "steamId": "steamId" } },
{ "key": "economy-override", "format": "json", "encoding": "utf-8", "fileName": "EconomyOverride.json", "rootPath": "economy-override", "fields": { "traders": "traders", "tradeable-code-prices": "tradeable-code-prices" } },
{ "key": "raid-times", "format": "json", "encoding": "utf-8", "fileName": "RaidTimes.json", "rootPath": "raiding-times", "fields": { "Weekdays": "weekdays", "Weekend": "weekend" } },
{ "key": "notifications", "format": "json", "encoding": "utf-8", "fileName": "Notifications.json", "rootPath": "Notifications", "fields": { "Notifications": "notifications" } }
]
}
@@ -1,8 +0,0 @@
{
"gameVersion": "0.9.700.90357",
"items": [
{ "key": "bandage", "label": "绷带", "maximumQuantity": 20 },
{ "key": "water-bottle", "label": "饮用水", "maximumQuantity": 10 },
{ "key": "improvised-spear", "label": "简易长矛", "maximumQuantity": 2 }
]
}
@@ -1,13 +0,0 @@
{
"version": 1,
"encoding": "utf-16le",
"lineEnding": "crlf",
"timestampFormat": "yyyy.MM.dd-HH.mm.ss",
"parsers": [
{ "key": "login", "eventType": "scum.login", "pattern": "^([^:]+): '([^ ]+) ([^:]+):(.+)\\([0-9]+\\)' logged in at: X=([-0-9.]+) Y=([-0-9.]+) Z=([-0-9.]+)$", "fields": ["occurredAt", "ip", "steamId", "displayName", "session", "x", "y", "z"] },
{ "key": "logout", "eventType": "scum.logout", "pattern": "^([^:]+): '([^ ]+) ([^:]+):(.+)\\([0-9]+\\)' logged out at: X=([-0-9.]+) Y=([-0-9.]+) Z=([-0-9.]+)$", "fields": ["occurredAt", "ip", "steamId", "displayName", "session", "x", "y", "z"] },
{ "key": "chat", "eventType": "scum.chat", "pattern": "^([^:]+): '([^:]+):(.+)\\([0-9]+\\)' '([^:]+): (.*)'$", "fields": ["occurredAt", "steamId", "displayName", "channel", "message"] },
{ "key": "admin", "eventType": "scum.admin", "pattern": "^([^:]+): '([^:]+):(.+)\\([0-9]+\\)' Command: '(.*)'$", "fields": ["occurredAt", "steamId", "displayName", "command"] },
{ "key": "vehicle-destruction", "eventType": "scum.vehicle.destruction", "pattern": "^([^:]+): \\[VehicleInactiveTimerReached\\] ([^.]+)\\. VehicleId: ([0-9]+)\\. Owner: (.+)\\. Location: X=([-0-9.]+) Y=([-0-9.]+) Z=([-0-9.]+)$", "fields": ["occurredAt", "vehicleClass", "vehicleId", "owner", "x", "y", "z"] }
]
}
@@ -17,46 +17,24 @@ export type SCUMPageContext = {
};
type SCUMWorkspaceActions = {
listSCUMUsers?: () => Promise<unknown>;
listSCUMPlayers?: () => Promise<unknown>;
listSCUMSquads?: () => Promise<unknown>;
listSCUMSquadMembers?: () => Promise<unknown>;
listSCUMVehicles?: () => Promise<unknown>;
listSCUMFlags?: () => Promise<unknown>;
listSCUMPositions?: () => Promise<unknown>;
listSCUMMapPoints?: () => Promise<unknown>;
listSCUMGifts?: () => Promise<unknown>;
getSCUMMapGeometry?: () => unknown;
listSCUMOperations?: () => Promise<unknown>;
pluginData?: { list: (collection: string, key?: string) => Promise<unknown>; put: (collection: string, key: string, value: RecordMap) => Promise<unknown> };
createSCUMOperation?: (request: unknown) => Promise<unknown>;
listSCUMWorkflows?: () => Promise<unknown>;
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
listGameGiftCatalogs?: () => Promise<unknown>;
saveGameGiftCatalog?: (request: unknown) => Promise<unknown>;
publishGameGiftCatalog?: (catalogId: string) => Promise<unknown>;
listGameGiftGrants?: () => Promise<unknown>;
requestGameGiftGrant?: (request: unknown) => Promise<unknown>;
approveGameGiftGrant?: (grantId: string) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
};
type RecordMap = Record<string, unknown>;
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string };
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; mapPoints: RecordMap[]; giftEvents: RecordMap[]; catalogs: RecordMap[]; grants: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[]; mapGeometry?: RecordMap };
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[] };
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], mapPoints: [], giftEvents: [], catalogs: [], grants: [], operations: [], workflows: [], steps: [] };
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], operations: [], workflows: [], steps: [] };
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement;
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const [playerQuery, setPlayerQuery] = usePluginState(react, "");
const [squadFilter, setSquadFilter] = usePluginState(react, "");
const [selectedSquad, setSelectedSquad] = usePluginState(react, "");
const [layers, setLayers] = usePluginState<Record<string, boolean>>(react, { player: true, vehicle: true, base: true, flag: true });
const [selectedMarker, setSelectedMarker] = usePluginState<RecordMap | undefined>(react, undefined);
const [giftTab, setGiftTab] = usePluginState(react, "catalogs");
const pageKey = input.pageKey ?? "players";
const refresh = () => {
@@ -67,9 +45,9 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
}
setState({ status: "loading" });
void Promise.all([
safeList(actions.listSCUMPlayers), safeList(actions.listSCUMSquads), safeList(actions.listSCUMSquadMembers), safeList(actions.listSCUMVehicles), safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions),
safeList(actions.listSCUMMapPoints), safeList(actions.listSCUMGifts), safeList(actions.listGameGiftCatalogs), safeList(actions.listGameGiftGrants), safeList(actions.listSCUMOperations), safeList(actions.listSCUMWorkflows), safeList(actions.listSCUMWorkflowSteps)
]).then(([players, squads, members, vehicles, flags, positions, mapPoints, giftEvents, catalogs, grants, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, mapPoints, giftEvents, catalogs, grants, operations, workflows, steps, mapGeometry: asRecord(actions.getSCUMMapGeometry?.()) } }))
pluginCollection(actions, "scum_users"), pluginCollection(actions, "scum_squads"), pluginCollection(actions, "scum_squad_members"), pluginCollection(actions, "scum_vehicles"),
pluginCollection(actions, "scum_flags"), pluginCollection(actions, "scum_map_points"), pluginCollection(actions, "scum_operations"), pluginCollection(actions, "scum_workflows"), pluginCollection(actions, "scum_workflow_steps")
]).then(([players, squads, members, vehicles, flags, positions, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, operations, workflows, steps } }))
.catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 投影读取失败。" }));
};
@@ -88,30 +66,25 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影…") : null,
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh, { playerQuery, setPlayerQuery, squadFilter, setSquadFilter, selectedSquad, setSelectedSquad, layers, setLayers, selectedMarker, setSelectedMarker, giftTab, setGiftTab }) : null
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh) : null
);
}
type SurfaceControls = { playerQuery: string; setPlayerQuery: StateSetter<string>; squadFilter: string; setSquadFilter: StateSetter<string>; selectedSquad: string; setSelectedSquad: StateSetter<string>; layers: Record<string, boolean>; setLayers: StateSetter<Record<string, boolean>>; selectedMarker?: RecordMap; setSelectedMarker: StateSetter<RecordMap | undefined>; giftTab: string; setGiftTab: StateSetter<string> };
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, controls: SurfaceControls) {
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
switch (pageKey) {
case "players": return playersSurface(e, data, input, setAction, refresh, controls);
case "squads": return squadsSurface(e, data, controls);
case "live-map": return mapSurface(e, data, controls);
case "gifts": return giftsSurface(e, data, input, setAction, refresh, controls);
case "players": return playersSurface(e, data, input, setAction, refresh);
case "squads": return squadsSurface(e, data);
case "live-map": return mapSurface(e, data);
case "gifts": return giftsSurface(e, data, input, setAction, refresh);
case "workflows": return workflowsSurface(e, data);
default: return playersSurface(e, data, input, setAction, refresh, controls);
default: return playersSurface(e, data, input, setAction, refresh);
}
}
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, controls: SurfaceControls) {
const squads = uniqueStrings(data.players.map((player) => textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId")));
const query = controls.playerQuery.trim().toLowerCase();
const players = data.players.filter((player) => (!controls.squadFilter || controls.squadFilter === (textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId"))) && (!query || ["DisplayName", "displayName", "SteamID", "steamId", "SquadName", "squadName"].some((key) => textField(player, key).toLowerCase().includes(query))));
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
return e("div", { className: "console-record-list" },
statsStrip(e, [["玩家投影", data.players.length], ["在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标", data.positions.length], ["待审操作", data.operations.filter((op) => field(op, "Status", "status") === "waiting").length]]),
e("div", { className: "server-toolbar" }, e("input", { type: "search", value: controls.playerQuery, placeholder: "搜索昵称、Steam 或队伍", onChange: (event: { target: { value: string } }) => controls.setPlayerQuery(event.target.value) }), e("select", { value: controls.squadFilter, onChange: (event: { target: { value: string } }) => controls.setSquadFilter(event.target.value) }, e("option", { value: "" }, "全部队伍"), squads.map((squad) => e("option", { key: squad, value: squad }, squad)))),
players.length ? players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")),
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, freshness(player))),
e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`),
@@ -120,57 +93,37 @@ function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, in
operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000),
operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true)
)
)) : e("p", { className: "page-status" }, data.players.length ? "没有符合筛选条件的真实玩家投影。" : "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
)) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
);
}
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, controls: SurfaceControls) {
const selected = controls.selectedSquad || textField(data.squads[0], "SquadID", "squadId");
const roster = data.members.filter((member) => textField(member, "SquadID", "squadId") === selected);
const flags = data.flags.filter((flag) => textField(flag, "OwnerSquadID", "ownerSquadId") === selected);
const squadRows = e("div", { className: "console-row-list" }, data.squads.map((squad) => {
const id = textField(squad, "SquadID", "squadId");
return e("button", { type: "button", key: idOf(squad), className: `console-row ${selected === id ? "console-row-selected" : ""}`, onClick: () => controls.setSelectedSquad(id) }, e("span", null, textField(squad, "Name", "name") || id), e("strong", null, `成员 ${numField(squad, "MemberCount", "memberCount")}`), e("strong", null, `分数 ${numField(squad, "Score", "score")}`), e("strong", null, freshness(squad)));
}));
const rosterRows = roster.length ? e("div", { className: "console-row-list" }, roster.map((member) => e("div", { key: idOf(member), className: "console-row" }, e("span", null, textField(member, "DisplayName", "displayName") || "未知成员"), e("strong", null, textField(member, "IsLeader", "isLeader") === "true" ? "队长" : `军衔 ${textField(member, "Rank", "rank") || "--"}`), e("strong", null, `Steam ${textField(member, "SteamID", "steamId") || "--"}`), e("strong", null, freshness(member))))) : e("p", { className: "page-status" }, "此队伍暂无成员投影。");
const flagRows = e("div", { className: "console-row-list" }, flags.map((flag) => e("div", { key: idOf(flag), className: "console-row" }, e("span", null, `旗帜 ${textField(flag, "FlagID", "flagId")}`), e("strong", null, textField(flag, "OwnershipConfidence", "ownershipConfidence") || "verified"), e("strong", null, coords(field(flag, "Position", "position") as RecordMap)), e("strong", null, freshness(flag)))));
return e("div", { className: "overview-two-col" }, e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "队伍"), e("span", { className: "page-status" }, `${data.squads.length}`)), squadRows), e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "成员 / 旗帜"), e("span", { className: "page-status" }, selected || "未选择队伍")), rosterRows, flagRows));
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
return e("div", { className: "overview-two-col" },
tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, freshness(squad)]),
tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", freshness(item)])
);
}
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, controls: SurfaceControls) {
const overlays = mapOverlays(data);
const visible = overlays.filter((point) => controls.layers[textField(point, "SubjectType", "subjectType")] !== false);
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
const overlays = [...data.positions, ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(Boolean), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(Boolean)];
return e("div", { className: "console-record-list" },
statsStrip(e, [["玩家", overlays.filter((point) => textField(point, "SubjectType", "subjectType") === "player").length], ["载具", overlays.filter((point) => textField(point, "SubjectType", "subjectType") === "vehicle").length], ["旗帜", overlays.filter((point) => textField(point, "SubjectType", "subjectType") === "flag").length], ["坐标点", visible.length]]),
e("div", { className: "map-layer-controls" }, ["player", "vehicle", "base", "flag"].map((kind) => e("label", { key: kind }, e("input", { type: "checkbox", checked: controls.layers[kind] !== false, onChange: () => controls.setLayers((previous) => ({ ...previous, [kind]: previous[kind] === false })) }), mapKindLabel(kind)))),
e("div", { className: "map-projection-board" }, visible.slice(0, 500).map((point, index) => e("button", { type: "button", key: `${idOf(point)}:${index}`, className: `map-projection-dot map-projection-dot-${textField(point, "SubjectType", "subjectType") || "unknown"}`, title: `${mapKindLabel(textField(point, "SubjectType", "subjectType"))} ${coords(point)}`, style: dotStyle(point, data.mapGeometry), onClick: () => controls.setSelectedMarker(point) }, ""))),
controls.selectedMarker ? e("article", { className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, mapKindLabel(textField(controls.selectedMarker, "SubjectType", "subjectType"))), e("span", { className: "status-pill status-active" }, freshness(controls.selectedMarker))), e("div", { className: "console-record-meta" }, e("span", null, `对象 ${textField(controls.selectedMarker, "SubjectID", "subjectId") || "--"}`), e("span", null, coords(controls.selectedMarker)), e("span", null, `观察 ${dateField(controls.selectedMarker, "ObservedAt", "observedAt")}`))) : null,
tablePanel(e, "地图覆盖物", visible, (point) => [mapKindLabel(textField(point, "SubjectType", "subjectType")), textField(point, "Label", "label") || textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]),
e("div", { className: "map-projection-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-projection-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))),
tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
);
}
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, controls: SurfaceControls) {
const tab = controls.giftTab;
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
return e("div", { className: "console-record-list" },
statsStrip(e, [["礼包定义", (data.catalogs ?? []).length], ["发放", (data.grants ?? []).length], ["定时礼包完成", (data.giftEvents ?? []).length], ["可选玩家", data.players.length]]),
e("p", { className: "page-status" }, "礼包只 typed delivery workflow;结果未知时不会重复发放。"),
e("div", { className: "section-tabs" }, [["catalogs", "礼包定义"], ["grants", "发放记录"], ["events", "定时礼包完成"]].map(([key, label]) => e("button", { type: "button", key, className: `section-tab ${tab === key ? "section-tab-active" : ""}`, onClick: () => controls.setGiftTab(key) }, label))),
tab === "catalogs" ? giftCatalogSurface(e, data, input, setAction, refresh) : null,
tab === "grants" ? giftGrantSurface(e, data, input, setAction, refresh) : null,
tab === "events" ? tablePanel(e, "游戏内已完成定时礼包", data.giftEvents ?? [], (event) => [textField(event, "displayName", "DisplayName") || "未知玩家", textField(event, "giftType", "GiftType") || "finished-timed-gift", dateField(event, "spawnAt", "SpawnAt"), textField(event, "mapId", "MapID") || "--"]) : null
statsStrip(e, [["可选玩家", data.players.length], ["发放操作", data.operations.filter((op) => textField(op, "TemplateKey", "templateKey") === "reward.deliver").length], ["未知态", data.operations.filter((op) => field(op, "Status", "status") === "unknown").length]]),
e("p", { className: "page-status" }, "礼包只创建 typed delivery workflow确认结果未知时不会重复发放。"),
data.players.slice(0, 40).map((player) => e("article", { key: idOf(player), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || idOf(player)), e("span", { className: "status-pill status-disabled" }, freshness(player))),
e("div", { className: "console-row-actions" }, operationButton(e, input, setAction, refresh, player, "reward.deliver", "rewardKey", "创建礼包发放", "starter-pack"), operationButton(e, input, setAction, refresh, player, "player.notify", "message", "发送通知", "你的礼包正在审核发放。"))
))
);
}
function giftCatalogSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
const catalogs = data.catalogs ?? [];
return e("div", { className: "console-row-list" }, catalogs.length ? catalogs.map((catalog) => e("article", { key: idOf(catalog), className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, textField(catalog, "Name", "name")), e("span", { className: "status-pill status-active" }, textField(catalog, "GameVersion", "gameVersion"))), e("span", { className: "provider-id" }, giftItemsText(field(catalog, "DraftItems", "draftItems") as RecordMap[])), e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => publishCatalog(input, setAction, refresh, idOf(catalog)) }, "发布版本")))) : e("p", { className: "page-status" }, "暂无平台运营礼包定义。"));
}
function giftGrantSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
const grants = data.grants ?? [];
return e("div", { className: "console-row-list" }, grants.length ? grants.map((grant) => e("article", { key: idOf(grant), className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, textField(grant, "PlayerDisplayName", "playerDisplayName") || "未知玩家"), e("span", { className: "status-pill status-disabled" }, textField(grant, "Status", "status"))), e("span", { className: "provider-id" }, giftItemsText(field(grant, "Items", "items") as RecordMap[])), textField(grant, "Status", "status") === "pending-approval" ? e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => approveGrant(input, setAction, refresh, idOf(grant)) }, "批准发放")) : null)) : e("p", { className: "page-status" }, "暂无礼包发放记录。"));
}
function workflowsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
return e("div", { className: "console-record-list" },
data.workflows.length ? data.workflows.map((wf) => e("article", { key: idOf(wf), className: "console-record" },
@@ -225,26 +178,8 @@ function workflowLabel(templateKey: string): string { return templateKey.include
function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); }
function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length}`)), e("div", { className: "console-row-list" }, rows.length ? rows.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实投影数据。"))); }
function mapOverlays(data: SCUMSurfaceData): RecordMap[] {
const mapPoints = (data.mapPoints ?? data.positions ?? []).map((point) => ({ ...(field(point, "Fields", "fields") as RecordMap), ...point, SubjectType: textField(field(point, "Fields", "fields") as RecordMap, "subjectType", "SubjectType") || textField(point, "SubjectType", "subjectType") || "player" }));
if (mapPoints.length) return mapPoints;
const vehicles = data.vehicles.map((vehicle) => ({ ...(field(vehicle, "Position", "position") as RecordMap), SubjectType: "vehicle", Label: textField(vehicle, "Label", "label") || textField(vehicle, "ClassName", "className"), VehicleID: textField(vehicle, "VehicleID", "vehicleId") })).filter((point) => Boolean(point));
const flags = data.flags.map((flag) => ({ ...(field(flag, "Position", "position") as RecordMap), SubjectType: "flag", Label: textField(flag, "OwnerSquadName", "ownerSquadName"), FlagID: textField(flag, "FlagID", "flagId") })).filter((point) => Boolean(point));
return [...mapPoints, ...vehicles, ...flags];
}
function dotStyle(point: RecordMap, geometry?: RecordMap): Record<string, string> {
const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0);
const minX = Number(field(geometry, "WorldMinX", "worldMinX")); const maxX = Number(field(geometry, "WorldMaxX", "worldMaxX")); const minY = Number(field(geometry, "WorldMinY", "worldMinY")); const maxY = Number(field(geometry, "WorldMaxY", "worldMaxY"));
if ([minX, maxX, minY, maxY].every(Number.isFinite) && maxX > minX && maxY > minY) return { left: `${Math.max(1, Math.min(99, (x - minX) / (maxX - minX) * 100))}%`, top: `${Math.max(1, Math.min(99, 100 - (y - minY) / (maxY - minY) * 100))}%` };
return { left: "50%", top: "50%" };
}
function mapKindLabel(kind: string): string { return kind === "player" ? "玩家" : kind === "vehicle" ? "载具" : kind === "base" ? "基地" : kind === "flag" ? "旗帜" : "实体"; }
function uniqueStrings(values: string[]): string[] { return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh-CN")); }
function asRecord(value: unknown): RecordMap | undefined { return value && typeof value === "object" ? value as RecordMap : undefined; }
function giftItemsText(items: RecordMap[] | undefined): string { return Array.isArray(items) && items.length ? items.map((item) => `${textField(item, "Label", "label", "CatalogItemKey", "catalogItemKey")} x${textField(item, "Quantity", "quantity")}`).join(" · ") : "未声明物品"; }
function publishCatalog(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, catalogId: string) { setAction({ status: "pending", message: "正在发布礼包版本…" }); void input.workspaceActions?.publishGameGiftCatalog?.(catalogId).then(() => { setAction({ status: "ok", message: "礼包版本已发布。" }); refresh(); }).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "礼包发布失败。" })); }
function approveGrant(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, grantId: string) { setAction({ status: "pending", message: "正在批准礼包发放…" }); void input.workspaceActions?.approveGameGiftGrant?.(grantId).then(() => { setAction({ status: "ok", message: "礼包已进入发放队列。" }); refresh(); }).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "礼包批准失败。" })); }
function safeList(fn?: () => Promise<unknown>): Promise<RecordMap[]> { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); }
function dotStyle(point: RecordMap): Record<string, string> { const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; }
function pluginCollection(actions: SCUMWorkspaceActions, collection: string): Promise<RecordMap[]> { return actions.pluginData?.list(collection).then((value) => Array.isArray((value as RecordMap)?.items) ? ((value as { items: Array<{ value: RecordMap }> }).items.map((item) => item.value)) : []) ?? Promise.resolve([]); }
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; }
function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(value); }
+10 -124
View File
@@ -292,10 +292,6 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json",
"sqlRef": "sql/scum-db-v57/users.sql",
"targetTable": "scum_users",
"upsertKeys": ["userProfileId"],
"columnMappings": { "userProfileId": "userProfileId", "steamId": "steamId", "gamePlayerId": "gamePlayerId", "displayName": "displayName", "famePoints": "famePoints", "normalBalance": "normalBalance", "goldBalance": "goldBalance", "x": "x", "y": "y", "z": "z", "lastSaveTime": "lastSaveTime", "lastLoginTime": "lastLoginTime", "lastLogoutTime": "lastLogoutTime", "isAlive": "isAlive" },
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -308,10 +304,6 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json",
"sqlRef": "sql/scum-db-v57/squads.sql",
"targetTable": "scum_squads",
"upsertKeys": ["squadId"],
"columnMappings": { "squadId": "squadId", "name": "name", "leaderProfileId": "leaderProfileId", "leaderPlayerId": "leaderPlayerId", "memberCount": "memberCount", "score": "score", "memberLimit": "memberLimit", "lastMemberLoginTime": "lastMemberLoginTime", "lastMemberLogoutTime": "lastMemberLogoutTime" },
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -324,10 +316,6 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json",
"sqlRef": "sql/scum-db-v57/squad-members.sql",
"targetTable": "scum_squad_members",
"upsertKeys": ["squadId", "userProfileId"],
"columnMappings": { "squadId": "squadId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "steamId": "steamId", "displayName": "displayName", "rank": "rank", "isLeader": "isLeader" },
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -340,10 +328,6 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json",
"sqlRef": "sql/scum-db-v57/vehicles.sql",
"targetTable": "scum_vehicles",
"upsertKeys": ["vehicleId"],
"columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" },
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -356,68 +340,20 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json",
"sqlRef": "sql/scum-db-v57/flags.sql",
"targetTable": "scum_flags",
"upsertKeys": ["flagId"],
"columnMappings": { "flagId": "flagId", "entityId": "entityId", "baseId": "baseId", "ownerProfileId": "ownerProfileId", "ownerPlayerId": "ownerPlayerId", "overtakerProfileId": "overtakerProfileId", "overtakeEndTime": "overtakeEndTime", "x": "x", "y": "y", "z": "z" },
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.positions",
"title": "Read SCUM v57 player, vehicle, and base map points",
"title": "Read SCUM current player, vehicle, and flag coordinates",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json",
"sqlRef": "sql/scum-db-v57/map-points.sql",
"targetTable": "scum_map_points",
"upsertKeys": ["subjectType", "subjectId"],
"columnMappings": { "subjectType": "subjectType", "subjectId": "subjectId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "vehicleId": "vehicleId", "baseId": "baseId", "x": "x", "y": "y", "z": "z", "observedAt": "observedAt" },
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.activity",
"title": "Read SCUM v57 active tasks",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-activity.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-activity.result.schema.json",
"sqlRef": "sql/scum-db-v57/activity.sql",
"targetTable": "scum_activity_events",
"upsertKeys": ["activityId"],
"columnMappings": { "activityId": "activityId", "activityType": "activityType", "userProfileId": "userProfileId", "mapId": "mapId", "subject": "subject", "sequenceIndex": "sequenceIndex", "occurredAt": "occurredAt", "state": "state" },
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.gifts",
"title": "Read SCUM v57 finished_timed_gift_spawner records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-gifts.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-gifts.result.schema.json",
"sqlRef": "sql/scum-db-v57/gifts.sql",
"targetTable": "scum_gift_events",
"upsertKeys": ["giftId"],
"columnMappings": { "giftId": "giftId", "giftType": "giftType", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt", "displayName": "displayName" },
"maxRows": 500,
"timeoutSeconds": 15
}
],
"dataPacks": [
{
"key": "scum-db-v57",
"databaseUserVersion": 57,
"logParserRefs": ["data-packs/scum-db-v57/log-parsers.json"],
"configMapRefs": ["data-packs/scum-db-v57/config-maps.json", "data-packs/scum-db-v57/gift-items.json"]
}
],
"operationTemplates": [
@@ -602,8 +538,7 @@
],
"queryTemplateKeys": [
"scum.player.profile",
"scum.positions",
"scum.activity"
"scum.positions"
],
"operationKeys": [
"player.fame.set",
@@ -640,9 +575,10 @@
"flags"
],
"queryTemplateKeys": [
"scum.positions",
"scum.player.profile",
"scum.vehicles",
"scum.flags"
"scum.flags",
"scum.positions"
],
"featureKeys": [
"trajectory.collect"
@@ -653,9 +589,6 @@
"snapshotTypes": [
"players"
],
"queryTemplateKeys": [
"scum.gifts"
],
"operationKeys": [
"reward.deliver",
"player.notify"
@@ -672,9 +605,7 @@
"scum.squad-members",
"scum.vehicles",
"scum.flags",
"scum.positions",
"scum.activity",
"scum.gifts"
"scum.positions"
],
"operationKeys": [
"player.fame.set",
@@ -762,50 +693,6 @@
{
"path": "bin/scum-start.cmd",
"mode": 448
},
{
"path": "sql/scum-db-v57/users.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/squads.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/squad-members.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/vehicles.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/flags.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/activity.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/gifts.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/map-points.sql",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/log-parsers.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/config-maps.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/gift-items.json",
"mode": 384
}
],
"productionLifecycle": {
@@ -886,7 +773,7 @@
"trajectory.collect"
]
},
{
{
"key": "gifts",
"title": "礼包管理",
"path": "/gifts",
@@ -898,10 +785,9 @@
"server.game-client.read",
"server.game-client.command"
],
"bridgeActions": [
"server.instances.read",
"remote.access.request"
],
"bridgeActions": [
"server.instances.read"
],
"featureKeys": [
"reward.delivery"
]
@@ -1,10 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMActivityParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -1,29 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMActivityResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["activityId", "activityType", "state"],
"properties": {
"activityId": { "type": "string", "minLength": 1, "maxLength": 160 },
"activityType": { "type": "string", "minLength": 1, "maxLength": 64 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"mapId": { "type": "string", "minLength": 1, "maxLength": 96 },
"subject": { "type": "string", "minLength": 1, "maxLength": 240 },
"sequenceIndex": { "type": "integer" },
"occurredAt": { "type": "string", "format": "date-time" },
"state": { "type": "string", "minLength": 1, "maxLength": 64 }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -17,9 +17,9 @@
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"baseId": { "type": "string", "minLength": 1, "maxLength": 96 },
"overtakerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"overtakeEndTime": { "type": "string", "format": "date-time" },
"ownerSquadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerSquadName": { "type": "string", "minLength": 1, "maxLength": 80 },
"ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" }
@@ -1,10 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMGiftsParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -1,28 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMGiftsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["giftId", "giftType", "spawnTime"],
"properties": {
"giftId": { "type": "string", "minLength": 1, "maxLength": 160 },
"giftType": { "type": "string", "minLength": 1, "maxLength": 64 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"mapId": { "type": "string", "minLength": 1, "maxLength": 96 },
"spawnTime": { "type": "integer" },
"spawnAt": { "type": "string", "format": "date-time" },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -25,10 +25,7 @@
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastSaveTime": { "type": "string", "format": "date-time" },
"lastLoginTime": { "type": "string", "format": "date-time" },
"lastLogoutTime": { "type": "string", "format": "date-time" },
"isAlive": { "type": "integer", "minimum": 0, "maximum": 1 }
"lastSaveTime": { "type": "string", "format": "date-time" }
}
}
},
@@ -4,7 +4,7 @@
"type": "object",
"additionalProperties": false,
"properties": {
"subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
@@ -13,16 +13,15 @@
"additionalProperties": false,
"required": ["subjectType", "subjectId", "x", "y"],
"properties": {
"subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"baseId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"observedAt": { "type": "string", "format": "date-time" }
"lastSaveTime": { "type": "string", "format": "date-time" }
}
}
},
@@ -18,10 +18,7 @@
"leaderProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"leaderPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"score": { "type": "number" },
"memberLimit": { "type": "integer", "minimum": 0, "maximum": 1000 },
"lastMemberLoginTime": { "type": "string", "format": "date-time" },
"lastMemberLogoutTime": { "type": "string", "format": "date-time" }
"score": { "type": "number" }
}
}
},
@@ -22,9 +22,7 @@
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastAccessTime": { "type": "string", "format": "date-time" },
"isFunctional": { "type": "integer", "minimum": 0, "maximum": 1 }
"z": { "type": "number" }
}
}
},
@@ -1,15 +0,0 @@
SELECT
'active-task' AS activityType,
CAST(task.id AS TEXT) AS activityId,
CAST(task.user_profile_id AS TEXT) AS userProfileId,
CAST(task.map_id AS TEXT) AS mapId,
available.task_data_asset_path AS subject,
task.sequence_index AS sequenceIndex,
NULL AS occurredAt,
CASE WHEN available.was_ever_completed = 1 THEN 'completed-before' ELSE 'active' END AS state
FROM active_task active
JOIN tracking_data_set task ON task.id = active.id
JOIN available_task available ON available.id = active.available_task_id
WHERE (:userProfileId IS NULL OR CAST(task.user_profile_id AS TEXT) = :userProfileId)
ORDER BY task.id DESC
LIMIT COALESCE(:limit, 500)
@@ -1,18 +0,0 @@
SELECT
CAST(flag.element_id AS TEXT) AS flagId,
CAST(flag.element_id AS TEXT) AS entityId,
CAST(element.base_id AS TEXT) AS baseId,
CAST(element.owner_profile_id AS TEXT) AS ownerProfileId,
CAST(owner.prisoner_id AS TEXT) AS ownerPlayerId,
CAST(flag.overtaker_user_profile_id AS TEXT) AS overtakerProfileId,
datetime(flag.overtake_end_time, 'unixepoch') AS overtakeEndTime,
element.location_x AS x,
element.location_y AS y,
element.location_z AS z
FROM base_element_flag flag
JOIN base_element element ON element.element_id = flag.element_id
LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id
WHERE (:flagId IS NULL OR CAST(flag.element_id AS TEXT) = :flagId)
AND (:ownerProfileId IS NULL OR CAST(element.owner_profile_id AS TEXT) = :ownerProfileId)
ORDER BY flag.element_id
LIMIT COALESCE(:limit, 500)
@@ -1,14 +0,0 @@
SELECT
'finished-timed-gift' AS giftType,
CAST(gift.user_profile_id AS TEXT) || ':' || CAST(gift.map_id AS TEXT) || ':' || CAST(gift.spawn_time AS TEXT) AS giftId,
CAST(gift.user_profile_id AS TEXT) AS userProfileId,
CAST(gift.map_id AS TEXT) AS mapId,
gift.spawn_time AS spawnTime,
datetime(gift.spawn_time, 'unixepoch') AS spawnAt,
COALESCE(profile.name, user.name, '') AS displayName
FROM finished_timed_gift_spawner gift
LEFT JOIN user_profile profile ON profile.id = gift.user_profile_id
LEFT JOIN user ON user.id = profile.user_id
WHERE (:userProfileId IS NULL OR CAST(gift.user_profile_id AS TEXT) = :userProfileId)
ORDER BY gift.spawn_time DESC
LIMIT COALESCE(:limit, 500)
@@ -1,62 +0,0 @@
SELECT
'player' AS subjectType,
CAST(profile.id AS TEXT) AS subjectId,
CAST(profile.id AS TEXT) AS userProfileId,
CAST(prisoner.id AS TEXT) AS gamePlayerId,
NULL AS vehicleId,
NULL AS baseId,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
datetime(prisoner.last_save_time, 'unixepoch') AS observedAt
FROM user_profile profile
JOIN prisoner ON prisoner.id = profile.prisoner_id
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
JOIN entity ON entity.id = prisoner_entity.entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'player')
UNION ALL
SELECT
'vehicle' AS subjectType,
CAST(spawner.vehicle_entity_id AS TEXT) AS subjectId,
NULL AS userProfileId,
NULL AS gamePlayerId,
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
NULL AS baseId,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
datetime(spawner.vehicle_last_access_time, 'unixepoch') AS observedAt
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
UNION ALL
SELECT
'base' AS subjectType,
CAST(base.id AS TEXT) AS subjectId,
CAST(base.owner_user_profile_id AS TEXT) AS userProfileId,
NULL AS gamePlayerId,
NULL AS vehicleId,
CAST(base.id AS TEXT) AS baseId,
base.location_x AS x,
base.location_y AS y,
0 AS z,
NULL AS observedAt
FROM base
WHERE (:subjectType IS NULL OR :subjectType = 'base')
UNION ALL
SELECT
'flag' AS subjectType,
CAST(flag.element_id AS TEXT) AS subjectId,
CAST(element.owner_profile_id AS TEXT) AS userProfileId,
CAST(owner.prisoner_id AS TEXT) AS gamePlayerId,
NULL AS vehicleId,
CAST(element.base_id AS TEXT) AS baseId,
element.location_x AS x,
element.location_y AS y,
element.location_z AS z,
datetime(flag.overtake_end_time, 'unixepoch') AS observedAt
FROM base_element_flag flag
JOIN base_element element ON element.element_id = flag.element_id
LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id
WHERE (:subjectType IS NULL OR :subjectType = 'flag')
LIMIT COALESCE(:limit, 500)
@@ -1,15 +0,0 @@
SELECT
CAST(member.squad_id AS TEXT) AS squadId,
CAST(member.user_profile_id AS TEXT) AS userProfileId,
CAST(profile.prisoner_id AS TEXT) AS gamePlayerId,
user.id AS steamId,
COALESCE(profile.name, user.name, '') AS displayName,
CAST(member.rank AS TEXT) AS rank,
CASE WHEN member.rank = 4 THEN 1 ELSE 0 END AS isLeader
FROM squad_member member
JOIN user_profile profile ON profile.id = member.user_profile_id
LEFT JOIN user ON user.id = profile.user_id
WHERE (:squadId IS NULL OR CAST(member.squad_id AS TEXT) = :squadId)
AND (:userProfileId IS NULL OR CAST(member.user_profile_id AS TEXT) = :userProfileId)
ORDER BY member.squad_id, member.rank DESC, profile.name
LIMIT COALESCE(:limit, 500)
@@ -1,19 +0,0 @@
SELECT
CAST(s.id AS TEXT) AS squadId,
COALESCE(s.name, '') AS name,
CAST(leader.user_profile_id AS TEXT) AS leaderProfileId,
CAST(leader_profile.prisoner_id AS TEXT) AS leaderPlayerId,
COUNT(member.id) AS memberCount,
s.score AS score,
s.member_limit AS memberLimit,
s.last_member_login_time AS lastMemberLoginTime,
s.last_member_logout_time AS lastMemberLogoutTime
FROM squad s
LEFT JOIN squad_member member ON member.squad_id = s.id
LEFT JOIN squad_member leader ON leader.squad_id = s.id AND leader.rank = 4
LEFT JOIN user_profile leader_profile ON leader_profile.id = leader.user_profile_id
WHERE (:squadId IS NULL OR CAST(s.id AS TEXT) = :squadId)
AND (:search IS NULL OR COALESCE(s.name, '') LIKE '%' || :search || '%')
GROUP BY s.id
ORDER BY s.score DESC, s.id
LIMIT COALESCE(:limit, 500)
@@ -1,28 +0,0 @@
SELECT
CAST(up.id AS TEXT) AS userProfileId,
u.id AS steamId,
CAST(p.id AS TEXT) AS gamePlayerId,
COALESCE(up.name, u.name, '') AS displayName,
up.fame_points AS famePoints,
MAX(CASE WHEN currency.currency_type = 1 THEN currency.account_balance END) AS normalBalance,
MAX(CASE WHEN currency.currency_type = 2 THEN currency.account_balance END) AS goldBalance,
e.location_x AS x,
e.location_y AS y,
e.location_z AS z,
datetime(p.last_save_time, 'unixepoch') AS lastSaveTime,
up.last_login_time AS lastLoginTime,
up.last_logout_time AS lastLogoutTime,
p.is_alive AS isAlive
FROM user_profile up
JOIN user u ON u.id = up.user_id
LEFT JOIN prisoner p ON p.id = up.prisoner_id
LEFT JOIN prisoner_entity pe ON pe.prisoner_id = p.id
LEFT JOIN entity e ON e.id = pe.entity_id
LEFT JOIN bank_account_registry account ON account.account_owner_user_profile_id = up.id
LEFT JOIN bank_account_registry_currencies currency ON currency.bank_account_id = account.id
WHERE (:userProfileId IS NULL OR CAST(up.id AS TEXT) = :userProfileId)
AND (:steamId IS NULL OR u.id = :steamId)
AND (:search IS NULL OR COALESCE(up.name, u.name, '') LIKE '%' || :search || '%')
GROUP BY up.id
ORDER BY up.last_login_time DESC
LIMIT COALESCE(:limit, 500)
@@ -1,16 +0,0 @@
SELECT
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
entity.class AS className,
spawner.vehicle_alias AS label,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
datetime(spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
spawner.is_vehicle_functional AS isFunctional
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
ORDER BY spawner.vehicle_last_access_time DESC
LIMIT COALESCE(:limit, 500)
@@ -271,11 +271,6 @@
"items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" },
"maxItems": 128
},
"dataPacks": {
"type": "array",
"items": { "$ref": "#/$defs/gameClientBridgeDataPack" },
"maxItems": 32
},
"operationTemplates": {
"type": "array",
"items": { "$ref": "#/$defs/gameClientBridgeOperationTemplate" },
@@ -365,25 +360,10 @@
"targetKey": { "$ref": "#/$defs/logicalKey" },
"parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
"targetTable": { "type": "string", "pattern": "^scum_[a-z][a-z0-9_]{0,62}$" },
"upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 },
"columnMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } },
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
}
},
"gameClientBridgeDataPack": {
"type": "object",
"required": ["key", "databaseUserVersion", "logParserRefs", "configMapRefs"],
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"databaseUserVersion": { "type": "integer", "minimum": 1, "maximum": 1000000 },
"logParserRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 },
"configMapRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 }
}
},
"gameClientBridgeOperationSafety": {
"type": "object",
"additionalProperties": false,
@@ -460,10 +440,6 @@
"type": "string",
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.json$"
},
"relativeSqlRef": {
"type": "string",
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)sql/[a-zA-Z0-9_./-]+\\.sql$"
},
"runCapability": {
"enum": [
"process.install",
-81
View File
@@ -134,10 +134,6 @@ function isSafeRelativeJsonRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
}
function isSafeRelativeSQLRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)sql\/[a-zA-Z0-9_./-]+\.sql$/.test(value);
}
function isSafeRelativePathRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+$/.test(value);
}
@@ -676,10 +672,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
targetKey?: string;
parameterSchemaRef?: string;
resultSchemaRef?: string;
sqlRef?: string;
targetTable?: string;
upsertKeys?: string[];
columnMappings?: Record<string, string>;
maxRows?: number;
timeoutSeconds?: number;
};
@@ -848,24 +840,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
}
}
const persistsSCUMRows = queryTemplate.targetTable !== undefined || queryTemplate.sqlRef !== undefined || queryTemplate.upsertKeys !== undefined || queryTemplate.columnMappings !== undefined;
if (persistsSCUMRows) {
if (!queryTemplate.sqlRef || !isSafeRelativeSQLRef(queryTemplate.sqlRef)) {
errors.push(`${location}.sqlRef: persisted SCUM queries must reference a package-relative .sql asset`);
}
if (!/^scum_[a-z][a-z0-9_]{0,62}$/.test(queryTemplate.targetTable ?? "")) {
errors.push(`${location}.targetTable: persisted SCUM queries must target a scum_* table`);
}
if (!Array.isArray(queryTemplate.upsertKeys) || queryTemplate.upsertKeys.length === 0 || !queryTemplate.upsertKeys.every((key) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(key))) {
errors.push(`${location}.upsertKeys: persisted SCUM queries require non-empty safe keys`);
}
const mappings = queryTemplate.columnMappings;
if (!mappings || typeof mappings !== "object" || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([target, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(target) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) {
errors.push(`${location}.columnMappings: persisted SCUM queries require safe target-to-source mappings`);
} else if (Array.isArray(queryTemplate.upsertKeys) && !queryTemplate.upsertKeys.every((key) => key in mappings)) {
errors.push(`${location}.upsertKeys: every upsert key must be declared in columnMappings`);
}
}
if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) {
errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
}
@@ -1016,59 +990,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
return errors;
}
function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
if (typeof manifest !== "object" || manifest === null) return [];
const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; logParserRefs?: string[]; configMapRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? [];
const errors: string[] = [];
const keys = new Set<string>();
for (const [index, dataPack] of dataPacks.entries()) {
const location = `manifest.gameClientBridge.dataPacks[${index}]`;
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(dataPack.key ?? "") || keys.has(dataPack.key ?? "")) errors.push(`${location}.key: must be a unique safe data-pack key`);
keys.add(dataPack.key ?? "");
if (!Number.isInteger(dataPack.databaseUserVersion) || (dataPack.databaseUserVersion ?? 0) < 1) errors.push(`${location}.databaseUserVersion: must be a positive SQLite user_version`);
for (const field of ["logParserRefs", "configMapRefs"] as const) {
const refs = dataPack[field];
if (!Array.isArray(refs) || refs.length === 0) {
errors.push(`${location}.${field}: must declare at least one package mapping asset`);
continue;
}
for (const ref of refs) {
if (!isSafeRelativeJsonRef(ref)) {
errors.push(`${location}.${field}: must use package-relative JSON assets`);
continue;
}
if (!declaredAssets.has(ref)) errors.push(`${location}.${field}: ${ref} must be declared in manifest.assetFiles`);
const target = path.resolve(manifestDir, ref);
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) errors.push(`${location}.${field}: missing package mapping asset ${ref}`);
}
}
}
return errors;
}
function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
if (typeof manifest !== "object" || manifest === null) return [];
const templates = (manifest as { gameClientBridge?: { queryTemplates?: Array<{ sqlRef?: string }> } }).gameClientBridge?.queryTemplates ?? [];
const errors: string[] = [];
for (const [index, template] of templates.entries()) {
if (!template.sqlRef) continue;
const location = `manifest.gameClientBridge.queryTemplates[${index}].sqlRef`;
if (!isSafeRelativeSQLRef(template.sqlRef)) {
errors.push(`${location}: must be a package-relative .sql asset`);
continue;
}
if (!declaredAssets.has(template.sqlRef)) errors.push(`${location}: ${template.sqlRef} must be declared in manifest.assetFiles`);
const assetPath = path.resolve(manifestDir, template.sqlRef);
if (!fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) {
errors.push(`${location}: missing SQL asset ${template.sqlRef}`);
continue;
}
const body = fs.readFileSync(assetPath, "utf8").trim();
if (!/^select\b/i.test(body) || /;\s*\S/.test(body) || /\b(?:insert|update|delete|drop|alter|create|attach|pragma)\b/i.test(body)) errors.push(`${location}: SQL assets must contain one read-only SELECT statement`);
}
return errors;
}
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) {
return [];
@@ -1457,8 +1378,6 @@ export function validateManifestFile(manifestPath: string): string[] {
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
errors.push(...assetValidation.errors);
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
errors.push(...validateGameClientBridgeDataPacks(manifest, manifestDir, assetValidation.declared));
for (const declaration of referencedLifecycleActions(manifest)) {
if (!isSafeRelativeJsonRef(declaration.ref)) {
-7
View File
@@ -56,10 +56,3 @@ The bridge must not expose:
# Client Manager lifecycle bridge
The bridge may request typed `deploy`, `start`, `stop`, `restart`, `status`, `update`, `rollback`, `revoke`, `retry`, or `uninstall` intents when Platform action gating says they are available. Results are safe logical projections with real job phase/progress and redacted recovery guidance. The bridge is not a transport for Run sessions, component keys, artifact bytes, machine paths, process IDs, sockets, or credentials; component registration and heartbeat remain component-to-Platform contracts outside the plugin page.
## Plugin-packaged database data packs
Database-backed game data stays with the game plugin. A browser request names a declared query template and supplies only values accepted by its parameter schema; it never supplies a SQL statement or a database path.
For a persisted game dataset, a query template declares package-relative `sqlRef`, `targetTable`, `upsertKeys`, and `columnMappings`. `sqlRef` must name a declared `sql/.../*.sql` asset containing one read-only `SELECT` statement. `targetTable` is a `scum_*` relation and every upsert key must have a mapping. The Platform resolves and distributes the plugin asset, while Run executes the declared statement through the template's SQLite transport.
Version-specific log parsers and configuration maps are declared in `gameClientBridge.dataPacks`. Each pack names the SQLite `databaseUserVersion` plus JSON parser/map assets. These assets describe the game format; they do not contain host paths, credentials, or browser-provided SQL.
-12
View File
@@ -262,21 +262,10 @@ export interface GameClientBridgeQueryTemplateDeclaration {
targetKey: string;
parameterSchemaRef: string;
resultSchemaRef: string;
sqlRef?: string;
targetTable?: string;
upsertKeys?: string[];
columnMappings?: Record<string, string>;
maxRows: number;
timeoutSeconds: number;
}
export interface GameClientBridgeDataPackDeclaration {
key: string;
databaseUserVersion: number;
logParserRefs: string[];
configMapRefs: string[];
}
export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation";
export interface GameClientBridgeOperationSafety {
@@ -348,7 +337,6 @@ export interface GameClientBridgeManifest {
commands: GameClientBridgeCommandDeclaration[];
snapshots: GameClientBridgeSnapshotDeclaration[];
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
dataPacks?: GameClientBridgeDataPackDeclaration[];
operationTemplates?: GameClientBridgeOperationTemplateDeclaration[];
commandRetentionSeconds: number;
maxCommands: number;
+4 -42
View File
@@ -617,7 +617,7 @@ describe("plugin manifest validation", () => {
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
});
it("declares packaged SCUM v57 query templates without browser-provided SQL", () => {
it("declares typed SCUM.db query templates without browser-visible SQL", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
permissions: string[];
@@ -633,10 +633,6 @@ describe("plugin manifest validation", () => {
targetKey: string;
parameterSchemaRef: string;
resultSchemaRef: string;
sqlRef: string;
targetTable: string;
upsertKeys: string[];
columnMappings: Record<string, string>;
maxRows: number;
timeoutSeconds: number;
}>;
@@ -645,7 +641,7 @@ describe("plugin manifest validation", () => {
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
};
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.activity", "scum.gifts"];
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"];
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
@@ -659,14 +655,7 @@ describe("plugin manifest validation", () => {
expect(template.engine).toBe("sqlite");
expect(template.transportKey).toBe("scum-database");
expect(template.targetKey).toBe("scum-database");
expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/);
expect(template.targetTable).toMatch(/^scum_/);
expect(template.upsertKeys.length).toBeGreaterThan(0);
expect(Object.keys(template.columnMappings).length).toBeGreaterThan(0);
expect(template.upsertKeys.every((key) => key in template.columnMappings)).toBe(true);
const sql = fs.readFileSync(path.join(pluginDir, template.sqlRef), "utf8");
expect(sql).toMatch(/^SELECT\b/i);
expect(sql).not.toMatch(/\b(?:INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH|PRAGMA)\b/i);
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/);
const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8"));
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8"));
expect(parameters).toMatchObject({ type: "object", additionalProperties: false });
@@ -676,7 +665,7 @@ describe("plugin manifest validation", () => {
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions", "scum.activity"]));
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"]));
expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"]));
expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"]));
for (const pageKey of ["players", "squads", "live-map"]) {
@@ -686,19 +675,6 @@ describe("plugin manifest validation", () => {
}
});
it("packages SCUM v57 UTF-16LE log and config maps", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[] }> } };
const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-db-v57");
expect(pack).toMatchObject({ databaseUserVersion: 57 });
const logParsers = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.logParserRefs[0]), "utf8"));
const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8"));
expect(logParsers.encoding).toBe("utf-16le");
expect(logParsers.parsers.map((parser: { key: string }) => parser.key)).toEqual(expect.arrayContaining(["login", "logout", "chat", "admin", "vehicle-destruction"]));
const serverSettings = configMaps.maps.find((map: { key: string }) => map.key === "server-settings");
expect(serverSettings.fields).toMatchObject({ "scum.WelcomeMessage": "welcomeMessage", "scum.MessageOfTheDay": "motd" });
});
it("declares typed SCUM RCON operations without arbitrary command inputs", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
@@ -929,20 +905,6 @@ describe("plugin manifest validation", () => {
expect(errors.some((error) => error.includes("timeoutSeconds"))).toBe(true);
});
it("rejects inline SQL and incomplete SCUM row declarations", () => {
const inlineErrors = validateTemporaryBridgeManifest((manifest) => {
Object.assign(manifest.gameClientBridge.queryTemplates![0], { sqlRef: "SELECT * FROM user_profile", targetTable: "scum_users", upsertKeys: ["userProfileId"], columnMappings: { userProfileId: "userProfileId" } });
});
expect(inlineErrors.some((error) => error.includes("sqlRef"))).toBe(true);
const incompleteErrors = validateTemporaryBridgeManifest((manifest) => {
Object.assign(manifest.gameClientBridge.queryTemplates![0], { sqlRef: "sql/scum-db-v57/users.sql", targetTable: "users", upsertKeys: [], columnMappings: {} });
});
expect(incompleteErrors.some((error) => error.includes("targetTable"))).toBe(true);
expect(incompleteErrors.some((error) => error.includes("upsertKeys"))).toBe(true);
expect(incompleteErrors.some((error) => error.includes("columnMappings"))).toBe(true);
});
it("requires query templates to match a declared sqlite transport target and capability", () => {
const targetErrors = validateTemporaryBridgeManifest((manifest) => {
manifest.gameClientBridge.queryTemplates![0].targetKey = "db/other";
+7 -6
View File
@@ -112,8 +112,8 @@ describe("SCUM plugin feature module", () => {
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(gifts.nodes).toContain("section:礼包管理");
expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
expect(gifts.texts).toContain("礼包定义");
expect(gifts.texts).toContain("定时礼包完成");
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
expect(workflows.nodes).toContain("section:Workflow 状态");
@@ -121,10 +121,11 @@ describe("SCUM plugin feature module", () => {
expect(workflows.texts.join("\n")).toContain("read-positions");
});
it("loads projections through typed workspace actions instead of file snapshots", () => {
expect(pageSource).toContain("listSCUMPlayers");
expect(pageSource).toContain("createSCUMOperation");
expect(pageSource).toContain("createSCUMWorkflow");
it("loads plugin-owned projections through generic platform collections instead of file snapshots", () => {
expect(pageSource).toContain("pluginData");
expect(pageSource).toContain('"scum_users"');
expect(pageSource).toContain('"scum_squads"');
expect(pageSource).toContain('"scum_map_points"');
expect(pageSource).not.toContain("getFileSnapshot");
expect(pageSource).not.toContain("requestFile");
expect(pageSource).not.toContain("writeFile");