diff --git a/openspec/changes/align-scum-operations-with-reference-tools/design.md b/openspec/changes/align-scum-operations-with-reference-tools/design.md deleted file mode 100644 index 40824d0..0000000 --- a/openspec/changes/align-scum-operations-with-reference-tools/design.md +++ /dev/null @@ -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`. diff --git a/openspec/changes/align-scum-operations-with-reference-tools/proposal.md b/openspec/changes/align-scum-operations-with-reference-tools/proposal.md deleted file mode 100644 index 6d257a2..0000000 --- a/openspec/changes/align-scum-operations-with-reference-tools/proposal.md +++ /dev/null @@ -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. diff --git a/openspec/changes/align-scum-operations-with-reference-tools/specs/scum-operations-reference-alignment/spec.md b/openspec/changes/align-scum-operations-with-reference-tools/specs/scum-operations-reference-alignment/spec.md deleted file mode 100644 index 0f813cf..0000000 --- a/openspec/changes/align-scum-operations-with-reference-tools/specs/scum-operations-reference-alignment/spec.md +++ /dev/null @@ -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 diff --git a/openspec/changes/align-scum-operations-with-reference-tools/tasks.md b/openspec/changes/align-scum-operations-with-reference-tools/tasks.md deleted file mode 100644 index d0dd002..0000000 --- a/openspec/changes/align-scum-operations-with-reference-tools/tasks.md +++ /dev/null @@ -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. diff --git a/openspec/changes/implement-scum-direct-data-plane/.openspec.yaml b/openspec/changes/implement-scum-direct-data-plane/.openspec.yaml deleted file mode 100644 index b6b2d1f..0000000 --- a/openspec/changes/implement-scum-direct-data-plane/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-08-13 diff --git a/openspec/changes/implement-scum-direct-data-plane/design.md b/openspec/changes/implement-scum-direct-data-plane/design.md deleted file mode 100644 index 93705ed..0000000 --- a/openspec/changes/implement-scum-direct-data-plane/design.md +++ /dev/null @@ -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. diff --git a/openspec/changes/implement-scum-direct-data-plane/proposal.md b/openspec/changes/implement-scum-direct-data-plane/proposal.md deleted file mode 100644 index 5a106fa..0000000 --- a/openspec/changes/implement-scum-direct-data-plane/proposal.md +++ /dev/null @@ -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 - - - -## 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. diff --git a/openspec/changes/implement-scum-direct-data-plane/specs/scum-direct-data-plane/spec.md b/openspec/changes/implement-scum-direct-data-plane/specs/scum-direct-data-plane/spec.md deleted file mode 100644 index 0b7e370..0000000 --- a/openspec/changes/implement-scum-direct-data-plane/specs/scum-direct-data-plane/spec.md +++ /dev/null @@ -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 - diff --git a/openspec/changes/implement-scum-direct-data-plane/specs/scum-operations-console/spec.md b/openspec/changes/implement-scum-direct-data-plane/specs/scum-operations-console/spec.md deleted file mode 100644 index 2bec025..0000000 --- a/openspec/changes/implement-scum-direct-data-plane/specs/scum-operations-console/spec.md +++ /dev/null @@ -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 diff --git a/openspec/changes/implement-scum-direct-data-plane/tasks.md b/openspec/changes/implement-scum-direct-data-plane/tasks.md deleted file mode 100644 index 50d3373..0000000 --- a/openspec/changes/implement-scum-direct-data-plane/tasks.md +++ /dev/null @@ -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. diff --git a/openspec/changes/align-scum-operations-with-reference-tools/.openspec.yaml b/openspec/changes/rebuild-scum-plugin-owned-data/.openspec.yaml similarity index 50% rename from openspec/changes/align-scum-operations-with-reference-tools/.openspec.yaml rename to openspec/changes/rebuild-scum-plugin-owned-data/.openspec.yaml index b6b2d1f..4af8641 100644 --- a/openspec/changes/align-scum-operations-with-reference-tools/.openspec.yaml +++ b/openspec/changes/rebuild-scum-plugin-owned-data/.openspec.yaml @@ -1,2 +1,2 @@ schema: spec-driven -created: 2026-08-13 +created: 2026-08-14 diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/design.md b/openspec/changes/rebuild-scum-plugin-owned-data/design.md new file mode 100644 index 0000000..2f521e3 --- /dev/null +++ b/openspec/changes/rebuild-scum-plugin-owned-data/design.md @@ -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. diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/proposal.md b/openspec/changes/rebuild-scum-plugin-owned-data/proposal.md new file mode 100644 index 0000000..0b9776d --- /dev/null +++ b/openspec/changes/rebuild-scum-plugin-owned-data/proposal.md @@ -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. diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/specs/plugin-owned-data/spec.md b/openspec/changes/rebuild-scum-plugin-owned-data/specs/plugin-owned-data/spec.md new file mode 100644 index 0000000..22c156c --- /dev/null +++ b/openspec/changes/rebuild-scum-plugin-owned-data/specs/plugin-owned-data/spec.md @@ -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 diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md b/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md new file mode 100644 index 0000000..dd4ca04 --- /dev/null +++ b/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md @@ -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. diff --git a/platform/api/plugin_data_handlers.go b/platform/api/plugin_data_handlers.go new file mode 100644 index 0000000..04c7f4c --- /dev/null +++ b/platform/api/plugin_data_handlers.go @@ -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 diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index cde55e9..c296086 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -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) diff --git a/platform/api/scum_handlers.go b/platform/api/scum_handlers.go index 21fb16e..47ba825 100644 --- a/platform/api/scum_handlers.go +++ b/platform/api/scum_handlers.go @@ -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) diff --git a/platform/api/scum_handlers_test.go b/platform/api/scum_handlers_test.go index d76aceb..e2eee4d 100644 --- a/platform/api/scum_handlers_test.go +++ b/platform/api/scum_handlers_test.go @@ -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) diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index bd21a49..319d3b8 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -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 = © - } - } value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...) diff --git a/platform/domain/game_gifts.go b/platform/domain/game_gifts.go index a818af4..5640ade 100644 --- a/platform/domain/game_gifts.go +++ b/platform/domain/game_gifts.go @@ -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 +} diff --git a/platform/domain/plugin_data.go b/platform/domain/plugin_data.go new file mode 100644 index 0000000..4aa3e02 --- /dev/null +++ b/platform/domain/plugin_data.go @@ -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 +} diff --git a/platform/domain/scum_projections.go b/platform/domain/scum_projections.go index d67f18c..df7eebc 100644 --- a/platform/domain/scum_projections.go +++ b/platform/domain/scum_projections.go @@ -25,7 +25,6 @@ type SCUMProjectionFilter struct { FlagID string SubjectType SCUMProjectionSubject QueryKey string - TargetTable SCUMDataSet Freshness SCUMProjectionFreshness Search string Limit int diff --git a/platform/domain/scum_workflows.go b/platform/domain/scum_workflows.go index 39f07ea..d882614 100644 --- a/platform/domain/scum_workflows.go +++ b/platform/domain/scum_workflows.go @@ -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 diff --git a/platform/dto/game_gifts.go b/platform/dto/game_gifts.go index 38ed909..73fa3ed 100644 --- a/platform/dto/game_gifts.go +++ b/platform/dto/game_gifts.go @@ -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} } diff --git a/platform/dto/plugin_data.go b/platform/dto/plugin_data.go new file mode 100644 index 0000000..2c9b4b6 --- /dev/null +++ b/platform/dto/plugin_data.go @@ -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)} +} diff --git a/platform/dto/resources.go b/platform/dto/resources.go index d990332..be89354 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -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 { diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index 6a9dc78..f7a64b0 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -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) } diff --git a/platform/dto/scum_projections.go b/platform/dto/scum_projections.go index 3ff9c23..56feff6 100644 --- a/platform/dto/scum_projections.go +++ b/platform/dto/scum_projections.go @@ -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)} -} diff --git a/platform/model/game_gifts.go b/platform/model/game_gifts.go index f582f25..47c82c8 100644 --- a/platform/model/game_gifts.go +++ b/platform/model/game_gifts.go @@ -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" } diff --git a/platform/model/plugin_data.go b/platform/model/plugin_data.go new file mode 100644 index 0000000..2cee2ea --- /dev/null +++ b/platform/model/plugin_data.go @@ -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" } diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index 132263a..a1000a5 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -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) diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index 32a89f7..cb8a2b3 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -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) diff --git a/platform/repo/resources.go b/platform/repo/resources.go index e940835..738ed99 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -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) && diff --git a/platform/service/game_gifts.go b/platform/service/game_gifts.go index a806760..0d4d9b4 100644 --- a/platform/service/game_gifts.go +++ b/platform/service/game_gifts.go @@ -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 { diff --git a/platform/service/game_gifts_test.go b/platform/service/game_gifts_test.go index 1940fab..e06618f 100644 --- a/platform/service/game_gifts_test.go +++ b/platform/service/game_gifts_test.go @@ -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) } diff --git a/platform/service/plugin_data.go b/platform/service/plugin_data.go new file mode 100644 index 0000000..8abae73 --- /dev/null +++ b/platform/service/plugin_data.go @@ -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) +} diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go new file mode 100644 index 0000000..fbbc047 --- /dev/null +++ b/platform/service/plugin_data_test.go @@ -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) + } +} diff --git a/platform/service/resources.go b/platform/service/resources.go index b1cec56..f3f1462 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -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) diff --git a/platform/service/scum_projections.go b/platform/service/scum_projections.go index d44f775..b85f308 100644 --- a/platform/service/scum_projections.go +++ b/platform/service/scum_projections.go @@ -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 diff --git a/platform/service/scum_projections_test.go b/platform/service/scum_projections_test.go index f228596..7b510be 100644 --- a/platform/service/scum_projections_test.go +++ b/platform/service/scum_projections_test.go @@ -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) - } -} diff --git a/platform/validator/resources.go b/platform/validator/resources.go index b103afe..b78fe3a 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -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 diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index c6cbe45..2332ca3 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -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 () => { diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 0bf8897..fda21f8 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -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(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`); } + async listPluginData(serverInstanceId: string, collection: string, key?: string): Promise<{ items: Array<{ key: string; value: Record }>; 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): Promise<{ key: string; value: Record }> { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}`, { method: "PUT", body: { key, value } }); + } + async requestRemoteAdapter(serverInstanceId: string, request: RemoteAdapterRequest): Promise { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request }); } @@ -600,24 +598,8 @@ export class PlatformApiClient { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`); } - async listSCUMUsers(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/users`); - } - - async listSCUMSquads(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/datasets/squads`); - } - - async listSCUMActivity(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/activity`); - } - - async listSCUMGifts(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/gifts`); - } - - async listSCUMMapPoints(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/map-points`); + async listSCUMSquads(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`); } async listSCUMSquadMembers(serverInstanceId: string): Promise { @@ -636,34 +618,6 @@ export class PlatformApiClient { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`); } - async listGameGiftCatalogs(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`); - } - - async saveGameGiftCatalog(serverInstanceId: string, request: GameGiftCatalogRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`, { method: "POST", body: request }); - } - - async publishGameGiftCatalog(serverInstanceId: string, catalogId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/publish`, { method: "POST", body: {} }); - } - - async listGameGiftRevisions(serverInstanceId: string, catalogId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/revisions`); - } - - async listGameGiftGrants(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`); - } - - async requestGameGiftGrant(serverInstanceId: string, request: GameGiftGrantRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`, { method: "POST", body: request }); - } - - async approveGameGiftGrant(serverInstanceId: string, grantId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants/${encodeURIComponent(grantId)}/approve`, { method: "POST", body: {} }); - } - async listSCUMOperations(serverInstanceId: string): Promise { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`); } diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index de1cc25..b3afca7 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -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; export interface SCUMListResponse { 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; -export type SCUMSquadsListResponse = SCUMListResponse; -export type SCUMActivityListResponse = SCUMListResponse; -export type SCUMGiftsListResponse = SCUMListResponse; -export type SCUMMapPointsListResponse = SCUMListResponse; -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; } diff --git a/platform_web/components/SCUMPersistedDataView.test.tsx b/platform_web/components/SCUMPersistedDataView.test.tsx deleted file mode 100644 index de87784..0000000 --- a/platform_web/components/SCUMPersistedDataView.test.tsx +++ /dev/null @@ -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(); - expect(html).toContain("正在读取用户同步数据"); - expect(html).not.toContain("Prisoner One"); - }); -}); diff --git a/platform_web/components/SCUMPersistedDataView.tsx b/platform_web/components/SCUMPersistedDataView.tsx deleted file mode 100644 index a248fea..0000000 --- a/platform_web/components/SCUMPersistedDataView.tsx +++ /dev/null @@ -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; updatedAt?: string; freshness?: { observedAt?: string } }; -type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: DatasetRecord[]; count: number }; - -const datasetCopy: Record = { - 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({ status: "loading" }); - const refresh = useCallback(() => { setState({ status: "loading" }); void load().then(setState); }, [load]); - useEffect(() => { refresh(); }, [refresh]); - - return
-
-

{copy.summary}

- -
- {state.status === "loading" && } - {state.status === "error" && } - {state.status === "ready" && (state.items.length ? : } />)} -
; -} - -function SCUMDatasetTable({ dataset, items, count }: { dataset: SCUMPersistedDataset; items: DatasetRecord[]; count: number }) { - const columns = useMemo(() => tableColumns(dataset), [dataset]); - return
-
已同步
{count}
当前展示
{Math.min(items.length, 100)}
数据源
平台数据库
-
{columns.map((column) => )}{items.slice(0, 100).map((item) => {columns.map((column) => )})}
{column.label}
{column.value(item)}
-
; -} - -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 | SCUMMapPointRecord): string { return value.locationX === undefined || value.locationY === undefined ? "--" : `X ${value.locationX} / Y ${value.locationY}${value.locationZ === undefined ? "" : ` / Z ${value.locationZ}`}`; } diff --git a/platform_web/contracts/pluginPageHost.ts b/platform_web/contracts/pluginPageHost.ts index 41968f7..48d3d04 100644 --- a/platform_web/contracts/pluginPageHost.ts +++ b/platform_web/contracts/pluginPageHost.ts @@ -1,25 +1,6 @@ export interface PluginPageWorkspaceActions { - listSCUMUsers?: () => Promise; - listSCUMPlayers?: () => Promise; - listSCUMSquads?: () => Promise; - listSCUMActivity?: () => Promise; - listSCUMGifts?: () => Promise; - listSCUMMapPoints?: () => Promise; - getSCUMMapGeometry?: () => unknown; - listSCUMSquadMembers?: () => Promise; - listSCUMVehicles?: () => Promise; - listSCUMFlags?: () => Promise; - listSCUMPositions?: () => Promise; - listSCUMOperations?: () => Promise; - createSCUMOperation?: (request: unknown) => Promise; - approveSCUMOperation?: (operationId: string) => Promise; - listSCUMWorkflows?: () => Promise; - createSCUMWorkflow?: (request: unknown) => Promise; - listSCUMWorkflowSteps?: (workflowId?: string) => Promise; - listGameGiftCatalogs?: () => Promise; - saveGameGiftCatalog?: (request: unknown) => Promise; - publishGameGiftCatalog?: (catalogId: string) => Promise; - listGameGiftGrants?: () => Promise; - requestGameGiftGrant?: (request: unknown) => Promise; - approveGameGiftGrant?: (grantId: string) => Promise; + pluginData?: { + list: (collection: string, key?: string) => Promise; + put: (collection: string, key: string, value: Record) => Promise; + }; } diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index 0e7e976..0dac1a9 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -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(); 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"); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 7c7932f..57269e9 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -69,29 +69,10 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded const workspaceActions = useMemo(() => { 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(":") : ""; diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json deleted file mode 100644 index 9a6b424..0000000 --- a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/config-maps.json +++ /dev/null @@ -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" } } - ] -} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/gift-items.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/gift-items.json deleted file mode 100644 index aa3e27e..0000000 --- a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/gift-items.json +++ /dev/null @@ -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 } - ] -} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json deleted file mode 100644 index e0679ad..0000000 --- a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json +++ /dev/null @@ -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"] } - ] -} diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index aef3027..b9a1cd3 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -17,46 +17,24 @@ export type SCUMPageContext = { }; type SCUMWorkspaceActions = { - listSCUMUsers?: () => Promise; - listSCUMPlayers?: () => Promise; - listSCUMSquads?: () => Promise; - listSCUMSquadMembers?: () => Promise; - listSCUMVehicles?: () => Promise; - listSCUMFlags?: () => Promise; - listSCUMPositions?: () => Promise; - listSCUMMapPoints?: () => Promise; - listSCUMGifts?: () => Promise; - getSCUMMapGeometry?: () => unknown; - listSCUMOperations?: () => Promise; + pluginData?: { list: (collection: string, key?: string) => Promise; put: (collection: string, key: string, value: RecordMap) => Promise }; createSCUMOperation?: (request: unknown) => Promise; listSCUMWorkflows?: () => Promise; createSCUMWorkflow?: (request: unknown) => Promise; - listSCUMWorkflowSteps?: (workflowId?: string) => Promise; - listGameGiftCatalogs?: () => Promise; - saveGameGiftCatalog?: (request: unknown) => Promise; - publishGameGiftCatalog?: (catalogId: string) => Promise; - listGameGiftGrants?: () => Promise; - requestGameGiftGrant?: (request: unknown) => Promise; - approveGameGiftGrant?: (grantId: string) => Promise; + listSCUMWorkflowSteps?: (workflowId?: string) => Promise; }; type RecordMap = Record; 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(react, { status: "loading" }); const [action, setAction] = usePluginState(react, { status: "idle" }); - const [playerQuery, setPlayerQuery] = usePluginState(react, ""); - const [squadFilter, setSquadFilter] = usePluginState(react, ""); - const [selectedSquad, setSelectedSquad] = usePluginState(react, ""); - const [layers, setLayers] = usePluginState>(react, { player: true, vehicle: true, base: true, flag: true }); - const [selectedMarker, setSelectedMarker] = usePluginState(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; squadFilter: string; setSquadFilter: StateSetter; selectedSquad: string; setSelectedSquad: StateSetter; layers: Record; setLayers: StateSetter>; selectedMarker?: RecordMap; setSelectedMarker: StateSetter; giftTab: string; setGiftTab: StateSetter }; -function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter, refresh: () => void, controls: SurfaceControls) { +function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter, 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, 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, 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, refresh: () => void, controls: SurfaceControls) { - const tab = controls.giftTab; +function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter, 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, 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, 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 { - 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, 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, 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): Promise { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); } +function dotStyle(point: RecordMap): Record { 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 { 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(react: ReactLike, initial: T): [T, StateSetter] { return react.useState ? react.useState(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); } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 21197d1..cbbec6b 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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" ] diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.parameters.schema.json deleted file mode 100644 index 1cefcee..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.parameters.schema.json +++ /dev/null @@ -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 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.result.schema.json deleted file mode 100644 index 0e4381c..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-activity.result.schema.json +++ /dev/null @@ -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" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json index 5f1866d..b30e349 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-flags.result.schema.json @@ -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" } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.parameters.schema.json deleted file mode 100644 index f7da70e..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.parameters.schema.json +++ /dev/null @@ -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 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.result.schema.json deleted file mode 100644 index 6acd4d7..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-gifts.result.schema.json +++ /dev/null @@ -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" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json index b765f98..44a068d 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-player-profile.result.schema.json @@ -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" } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json index 35bc0a2..84f3eaf 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.parameters.schema.json @@ -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 } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json index 5388acc..6a47e18 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-positions.result.schema.json @@ -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" } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json index 5c4a15f..6e3da4a 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squads.result.schema.json @@ -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" } } } }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json index 3f1efa3..ecef9ca 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-vehicles.result.schema.json @@ -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" } } } }, diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/activity.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/activity.sql deleted file mode 100644 index 80e7617..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/activity.sql +++ /dev/null @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql deleted file mode 100644 index 54ec45e..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/flags.sql +++ /dev/null @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/gifts.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/gifts.sql deleted file mode 100644 index 6564f78..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/gifts.sql +++ /dev/null @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql deleted file mode 100644 index b3dfe3a..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/map-points.sql +++ /dev/null @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql deleted file mode 100644 index 556a56c..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squad-members.sql +++ /dev/null @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql deleted file mode 100644 index 6c92c2e..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql +++ /dev/null @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql deleted file mode 100644 index 08dfb07..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql +++ /dev/null @@ -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) diff --git a/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql b/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql deleted file mode 100644 index 5efa7e2..0000000 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/vehicles.sql +++ /dev/null @@ -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) diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 2462c10..45ede5b 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -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", diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 9e24393..b213ecd 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -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; 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[] { - 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(); - 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[] { - 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)) { diff --git a/plugins/sdk/bridge-contract.md b/plugins/sdk/bridge-contract.md index 2e61e7f..25771bb 100644 --- a/plugins/sdk/bridge-contract.md +++ b/plugins/sdk/bridge-contract.md @@ -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. diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index a9bfe6e..aad4070 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -262,21 +262,10 @@ export interface GameClientBridgeQueryTemplateDeclaration { targetKey: string; parameterSchemaRef: string; resultSchemaRef: string; - sqlRef?: string; - targetTable?: string; - upsertKeys?: string[]; - columnMappings?: Record; 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; diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index fe86574..e2a1335 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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; 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"; diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index e3f972a..93c8e61 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -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");