From 98bf944f4cc8673571582f9f875f65df7c5f595a Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 18 Aug 2026 07:01:17 +0800 Subject: [PATCH] Rebuild SCUM plugin-owned data flow --- .../rebuild-scum-plugin-owned-data/design.md | 29 +- .../scum-local-data-baseline-2026-08-18.md | 32 ++ .../proposal.md | 10 + .../specs/plugin-owned-data/spec.md | 51 ++++ .../rebuild-scum-plugin-owned-data/tasks.md | 8 + platform/domain/game_client_bridge.go | 96 +++++- platform/domain/game_client_bridge_test.go | 12 +- platform/dto/resources.go | 170 +++++++++-- platform/dto/resources_test.go | 22 +- platform/service/job_channel.go | 3 + platform/service/log_ingest.go | 24 +- platform/service/plugin_data_projection.go | 48 ++- platform/service/plugin_data_schedule.go | 92 ++++++ platform/service/plugin_data_test.go | 86 ++++++ platform/service/plugin_log_projection.go | 276 ++++++++++++++++++ .../service/plugin_log_projection_test.go | 138 +++++++++ platform/service/resources.go | 3 + .../game_client_bridge_log_projection_test.go | 66 +++++ platform/validator/resources.go | 197 ++++++++++++- platform/validator/resources_test.go | 7 + .../scum-server-plugin/companion/adapters.go | 27 +- .../companion/adapters_test.go | 18 ++ .../scum-server-plugin/features/page-data.ts | 94 +++--- .../scum-server-plugin/features/page.ts | 40 ++- .../examples/scum-server-plugin/manifest.json | 70 ++++- .../bridge/announcement.payload.schema.json | 6 +- .../bridge/event-start.payload.schema.json | 25 +- .../scum-player-profile.result.schema.json | 2 +- .../scum-squad-members.result.schema.json | 2 +- .../bridge/reward-deliver.payload.schema.json | 4 +- .../sql/scum-db-v57/map-points.sql | 5 +- .../sql/scum-db-v57/squad-members.sql | 2 +- .../sql/scum-db-v57/squads.sql | 7 +- .../sql/scum-db-v57/users.sql | 10 +- .../game-plugin.manifest.schema.json | 70 ++++- plugins/scripts/validate-manifest.ts | 103 ++++++- plugins/sdk/index.ts | 40 +++ plugins/tests/manifest-validation.test.ts | 99 ++++++- plugins/tests/scum-feature-module.test.ts | 61 ++-- 39 files changed, 1832 insertions(+), 223 deletions(-) create mode 100644 openspec/changes/rebuild-scum-plugin-owned-data/evidence/scum-local-data-baseline-2026-08-18.md create mode 100644 platform/service/plugin_data_schedule.go create mode 100644 platform/service/plugin_log_projection.go create mode 100644 platform/service/plugin_log_projection_test.go create mode 100644 platform/validator/game_client_bridge_log_projection_test.go diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/design.md b/openspec/changes/rebuild-scum-plugin-owned-data/design.md index 2954484..7517f2d 100644 --- a/openspec/changes/rebuild-scum-plugin-owned-data/design.md +++ b/openspec/changes/rebuild-scum-plugin-owned-data/design.md @@ -10,13 +10,32 @@ The SCUM plugin owns collection names such as `scum_users`, schemas, upsert keys 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/put/delete and atomic put/delete transaction methods to plugin bundles. +SCUM uses stable `scum_*` collection names in the Platform database. Game database versions do not become Platform branches: a new SCUM schema updates the plugin SQL, result schema, mappings, and parser declarations while preserving the normalized collection contract. + ## SCUM Data Flow -1. The plugin declares v57 SQLite `sqlRef` assets, opaque collection row targets, config maps, and log parsers in its own versioned data pack. -2. Platform dispatches the selected declared operation to Run; no browser or plugin supplies a machine path or SQL string at request time. -3. Run returns structured rows; Platform applies only the declared collection, upsert keys, and column mapping before storing them in the scoped generic store. -4. The SCUM page reads those collections through the generic bridge and applies all SCUM-specific presentation and gift logic locally. -5. Gift delivery and activity commands use the existing generic Game Client Bridge queue exposed by the plugin-page host. +1. The plugin declares SQLite `sqlRef` assets, automatic cadence, collection row targets, config maps, and log projections in its own data pack and manifest. `PRAGMA user_version` is diagnostic evidence, not a Platform version switch. +2. When Run polls for work, Platform creates every due declared query before selecting the next job. This naturally follows the existing two-second polling channel and works without a UI or a new background daemon. +3. Run executes the declared SQL asset and returns structured rows. Platform applies only the declared collection, key, mapping, merge/replace mode, and fixed values. +4. Full snapshot targets delete records absent from a successful complete result. Merge targets preserve stdout presence fields while SQLite later adds profile, economy, squad, and coordinate facts. +5. Durable stdout batches are evaluated against plugin-declared ordered regex steps. Named captures with the same name must agree across the sequence; the complete sequence emits one stable event value and resets its bounded per-stream state. +6. The SCUM BattlEye declaration correlates `reported as player N` with `Player N SteamID (assumed)` by slot. Steam ID is the `scum_users` key; names are mutable display fields and are never identity keys. +7. The presence policy reads the existing user before mutation. A missing record receives the new-player announcement. A record whose last login is inside ten minutes is updated at most once and receives no duplicate announcement. An older record receives the returning-player announcement. +8. Announcement text and `#announce` command syntax belong to the SCUM plugin declaration. Platform only renders captured placeholders and queues the already-declared command through the existing Run channel. +9. The SCUM page polls Platform collections for display freshness. It never dispatches SQLite queries and exposes no manual synchronization button. +10. Gift delivery and activity commands use the existing generic Game Client Bridge queue exposed by the plugin-page host. + +## Declared Cadences + +- `scum.player.profile`, `scum.vehicles`, and `scum.positions`: 3 seconds. +- `scum.squads` and `scum.squad-members`: 1800 seconds. +- Flags, native event/task observations, and native timed-gift observations use plugin-owned slower cadences appropriate to those datasets. + +Cadence is measured from the latest matching job attempt. A still-active matching query suppresses another job, and idempotency keys include the server, template, and cadence bucket. + +## Real Data Evidence + +The provided complete database is `/Users/tasia/Downloads/SCUM/SCUM.db`; the similarly named file under `Logs/` is empty. The complete database reports SQLite `user_version=57`, contains 162 tables, and passes `quick_check`. All nine packaged SQL files execute against it. The existing users query starts at `user_profile` and therefore misses one real account; it must start at `user` and left join optional profile/prisoner data so stdout-created Steam identities merge correctly. ## Compatibility diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/evidence/scum-local-data-baseline-2026-08-18.md b/openspec/changes/rebuild-scum-plugin-owned-data/evidence/scum-local-data-baseline-2026-08-18.md new file mode 100644 index 0000000..5face9a --- /dev/null +++ b/openspec/changes/rebuild-scum-plugin-owned-data/evidence/scum-local-data-baseline-2026-08-18.md @@ -0,0 +1,32 @@ +# SCUM Local Data Baseline - 2026-08-18 + +## Scope + +This is redacted structural evidence from the user-authorized local SCUM download and legacy projects. It records no player names, Steam IDs, IP addresses, coordinates, credentials, or row bodies. + +## Database + +- `/Users/tasia/Downloads/SCUM/Logs/SCUM.db` is an empty zero-byte file and is not usable evidence. +- `/Users/tasia/Downloads/SCUM/SCUM.db` is the complete database: SQLite `user_version=57`, 162 tables, `quick_check=ok`. +- Aggregate rows: 74 accounts, 73 profiles, 72 prisoners, 7 squads, 18 squad members, 313 vehicles, 5 bases, 5 flags, 0 native event rounds, and 2 native timed-gift completion records. +- All nine packaged v57 SQL assets execute against the complete database. +- The account/profile cardinality proves that user extraction must start from `user` and left join the optional profile chain. Steam ID is the stable collection identity; profile ID, prisoner ID, and display name are attributes. +- The current map query returns player, vehicle, base, and flag points and the observed coordinates fit the declared SCUM island bounds. + +## Logs + +- The download contains 568 UTF-16LE/LF log files across 19 filename prefixes. Filenames use a server-start timestamp and a file can continue growing for many hours; tailing therefore requires an offset cursor per file rather than a daily filename assumption. +- Each file starts with a blank line and one `Game version:` metadata line that must not become a business event. +- Login file logs contain single-line login/logout records with optional coordinates. +- The user-supplied BattlEye `reported`, `connected`, `SteamID`, and GUID sequence is supervised process stdout and does not occur in the downloaded file logs. It requires a separate ordered stdout projection. + +## Configuration + +- The configuration directory contains `ServerSettings.ini`, list-based access files, `EconomyOverride.json`, `RaidTimes.json`, `Notifications.json`, and engine input/user settings. +- `ServerSettings.ini` reports settings version 7 and contains hundreds of `scum.*` keys. Config reads and patches must preserve unknown keys rather than reconstructing the file from a short allowlist. + +## Legacy Behavior + +- The legacy robot continuously updated players and positions in the background and queued welcome text through the server command channel; browser presence was never the acquisition trigger. +- Legacy welcome behavior distinguishes first registration from a returning player and suppresses rapid repeated observations. This change uses the explicitly requested ten-minute window. +- The legacy implementation contains field-order mistakes in its user creation branch, so only its product behavior and stable field intent are reused, not its SQL/value assignments. diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/proposal.md b/openspec/changes/rebuild-scum-plugin-owned-data/proposal.md index 0b9776d..44925f7 100644 --- a/openspec/changes/rebuild-scum-plugin-owned-data/proposal.md +++ b/openspec/changes/rebuild-scum-plugin-owned-data/proposal.md @@ -4,11 +4,18 @@ 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. +The first rebuild still left collection acquisition behind a page-owned `同步 SCUM.db` action and did not consume supervised SCUM stdout. As a result, opening no UI means no users, squads, vehicles, or coordinates are collected, and a real BattlEye login cannot create a player or produce the expected welcome announcement. + ## 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. +- Let each plugin query template declare its own background cadence. Platform creates due jobs from the authenticated Run polling loop, so collection acquisition continues with every browser closed. +- Add plugin-declared stdout sequence projections. Platform applies the declaration generically when durable Run log batches arrive; it does not hard-code BattlEye or SCUM line formats. +- Use Steam ID as the canonical SCUM user key, merge stdout-created users with later SQLite enrichment, and replace complete snapshot collections so deleted squads, members, vehicles, flags, and map points do not remain forever. +- Remove manual database synchronization controls. The plugin page periodically rereads Platform records only; it never initiates machine collection. +- On an authentic completed login sequence, create a missing user immediately and queue the plugin-declared global new-player announcement. For an existing user, suppress duplicate logins inside ten minutes and otherwise queue the plugin-declared returning-player announcement. ## Success Criteria @@ -16,3 +23,6 @@ The previous SCUM data implementation placed game-specific projections, gift rul - 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. +- Player and vehicle coordinates are acquired every three seconds, while squad and squad-member snapshots are acquired every thirty minutes, without a browser request. +- A supervised stdout BattlEye login creates or updates exactly one `scum_users` record and exactly one eligible announcement according to the ten-minute presence window. +- The SCUM page contains no `同步 SCUM.db`, refresh-projection, or equivalent machine-collection button and never substitutes sample records. 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 index a26e2d6..fec7c58 100644 --- 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 @@ -33,3 +33,54 @@ SCUM machine SQLite, configuration, and log operations SHALL remain plugin-decla - **WHEN** Run completes a declared SQLite query with a structured `rows` result - **THEN** Platform uses only the plugin-declared collection, upsert keys, and column mappings to persist the rows - **AND** Platform does not branch on the game, query key, collection name, or row fields + +### Requirement: Browser-Independent Automatic Collection +Platform SHALL schedule plugin-declared collection queries from the authenticated Run work-poll path without requiring a browser, page load, or manual synchronization action. + +#### Scenario: Coordinate templates become due +- **WHEN** Run polls for work at least three seconds after the latest matching player, vehicle, or map-position query +- **THEN** Platform queues the due plugin-declared SQLite query before selecting work +- **AND** a still-active matching query prevents duplicate queued work + +#### Scenario: Squad templates become due +- **WHEN** Run polls for work at least thirty minutes after the latest matching squad or squad-member query +- **THEN** Platform queues the due declared query +- **AND** no page action or open browser is involved + +#### Scenario: Complete snapshot removes absent records +- **WHEN** a replace-mode query succeeds with a complete row set +- **THEN** Platform upserts the returned mapped rows and deletes prior records in that scoped collection whose keys are absent +- **AND** an empty successful snapshot clears the collection + +### Requirement: Plugin-Declared Login Presence Projection +Platform SHALL apply ordered plugin-declared log projections to durable Run log batches and SHALL NOT hard-code SCUM or BattlEye parsing rules in service code. + +#### Scenario: New player completes the BattlEye login sequence +- **WHEN** one supervised stdout stream reports a player name and slot and later reports the same slot's Steam ID within the declared line gap +- **THEN** Platform creates the Steam-ID-keyed `scum_users` record immediately +- **AND** records the login activity +- **AND** queues exactly one plugin-declared global new-player announcement + +#### Scenario: Existing player reconnects inside ten minutes +- **WHEN** the same Steam ID completes another login sequence within ten minutes of its stored login observation +- **THEN** Platform treats the player as already online +- **AND** queues no additional welcome announcement + +#### Scenario: Existing player returns after ten minutes +- **WHEN** the same Steam ID completes a login sequence after the ten-minute window +- **THEN** Platform updates the stored name and login observation +- **AND** queues exactly one plugin-declared returning-player announcement + +#### Scenario: Database enrichment follows stdout identity +- **WHEN** a later SQLite user query returns the same Steam ID with profile, economy, squad, or coordinate fields +- **THEN** Platform merges those fields into the stdout-created record +- **AND** does not create a second user keyed by profile ID or display name + +### Requirement: Read-Only Collection Page Loading +The SCUM plugin page SHALL read Platform records automatically and SHALL NOT expose a control that dispatches machine collection. + +#### Scenario: User opens a SCUM management page +- **WHEN** any users, squads, activity, gifts, or map page is opened +- **THEN** it reads only the relevant `scum_*` Platform collections and existing command/snapshot results +- **AND** it periodically rereads Platform data for display freshness +- **AND** it contains no `同步 SCUM.db`, projection refresh, audit, or sample-data action diff --git a/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md b/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md index 7893dcc..bd357a7 100644 --- a/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md +++ b/openspec/changes/rebuild-scum-plugin-owned-data/tasks.md @@ -7,3 +7,11 @@ - [x] Rebuild the SCUM plugin page to use only generic collection bridge actions for users, squads, activity, gifts, and map points. - [x] Remove obsolete SCUM-specific Platform/frontend data and gift surfaces that conflict with plugin ownership. - [x] Add focused backend, plugin, and frontend tests; run structure and OpenSpec validation. +- [x] Record redacted evidence from the downloaded v57 database, configuration directory, log directory, and legacy SCUM projects; correct the effective database path and canonical user identity. +- [x] Extend the generic plugin query declaration with automatic cadence and merge/replace row-target semantics, and extend the manifest with ordered log projection plus presence/announcement declarations. +- [x] Schedule due collection queries from authenticated Run work polling, suppress overlapping jobs, and make successful replace snapshots delete absent records including on empty results. +- [x] Project supervised stdout login sequences into Steam-ID-keyed users and activity records, apply the ten-minute presence window, and queue plugin-declared global new/returning-player announcements without a UI session. +- [x] Correct the SCUM user SQL to include account-only users, declare 3-second player/vehicle/position and 30-minute squad/member cadences, and preserve plugin-owned slower cadences for other observations. +- [x] Remove page-owned query dispatch and all manual synchronization/reload controls; reread Platform collections automatically and merge users only by stable IDs. +- [x] Remove unverified gift/activity count ceilings that are not imposed by the SCUM data contract, then update focused backend/plugin/frontend tests. +- [x] Run focused and full verification, `scripts/check-structure.sh`, and `openspec validate rebuild-scum-plugin-owned-data --strict` before marking these tasks complete. diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index a9de924..ea7f3f7 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -61,24 +61,68 @@ type GameClientBridgeSnapshotDeclaration struct { } type GameClientBridgeQueryTemplateDeclaration struct { - Key string - Title string - Permission string - Engine string - TransportKey string - TargetKey string - ParameterSchemaRef string - ResultSchemaRef string - SQLRef string - MaxRows int - TimeoutSeconds int - RowTarget *PluginDataRowTargetDeclaration + Key string + Title string + Permission string + Engine string + TransportKey string + TargetKey string + ParameterSchemaRef string + ResultSchemaRef string + SQLRef string + MaxRows int + TimeoutSeconds int + PollIntervalSeconds int + RowTarget *PluginDataRowTargetDeclaration } +const ( + PluginDataRowWriteModeMerge = "merge" + PluginDataRowWriteModeReplace = "replace" +) + type PluginDataRowTargetDeclaration struct { Collection string UpsertKeys []string ColumnMappings map[string]string + WriteMode string +} + +type GameClientBridgeLogProjectionStepDeclaration struct { + Pattern string +} + +type GameClientBridgeLogProjectionTargetDeclaration struct { + Collection string + UpsertKeys []string + CaptureMappings map[string]string + FixedValues map[string]string + ObservedAtField string +} + +type GameClientBridgeLogProjectionAnnouncementDeclaration struct { + ProfileKey string + CommandType string + TextField string + NewTextTemplate string + ReturningTextTemplate string +} + +type GameClientBridgeLogProjectionPresenceDeclaration struct { + TimestampField string + ActiveWindowSeconds int + ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration + Announcement GameClientBridgeLogProjectionAnnouncementDeclaration +} + +type GameClientBridgeLogProjectionDeclaration struct { + Key string + StreamKeys []string + Steps []GameClientBridgeLogProjectionStepDeclaration + CorrelationFields []string + MaxInterveningLines int + Target GameClientBridgeLogProjectionTargetDeclaration + Presence *GameClientBridgeLogProjectionPresenceDeclaration } type GameClientBridgeDataPackDeclaration struct { @@ -170,6 +214,7 @@ type GameClientBridgeManifest struct { Commands []GameClientBridgeCommandDeclaration Snapshots []GameClientBridgeSnapshotDeclaration QueryTemplates []GameClientBridgeQueryTemplateDeclaration + LogProjections []GameClientBridgeLogProjectionDeclaration DataPacks []GameClientBridgeDataPackDeclaration OperationTemplates []GameClientBridgeOperationTemplateDeclaration Retention GameClientBridgeRetention @@ -475,6 +520,10 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid value.QueryTemplates[index].RowTarget = © } } + value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...) + for index := range value.LogProjections { + value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index]) + } value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...) for index := range value.DataPacks { value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs) @@ -503,6 +552,29 @@ func CopyPluginDataRowTargetDeclaration(value PluginDataRowTargetDeclaration) Pl return value } +func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration { + value.StreamKeys = CopyStringSlice(value.StreamKeys) + value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...) + value.CorrelationFields = CopyStringSlice(value.CorrelationFields) + value.Target = CopyGameClientBridgeLogProjectionTargetDeclaration(value.Target) + if value.Presence != nil { + presence := *value.Presence + if presence.ActivityTarget != nil { + activityTarget := CopyGameClientBridgeLogProjectionTargetDeclaration(*presence.ActivityTarget) + presence.ActivityTarget = &activityTarget + } + value.Presence = &presence + } + return value +} + +func CopyGameClientBridgeLogProjectionTargetDeclaration(value GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclaration { + value.UpsertKeys = CopyStringSlice(value.UpsertKeys) + value.CaptureMappings = CopyStringMap(value.CaptureMappings) + value.FixedValues = CopyStringMap(value.FixedValues) + return value +} + func copyGameClientBridgePayloadValue(value any) any { switch typed := value.(type) { case map[string]any: diff --git a/platform/domain/game_client_bridge_test.go b/platform/domain/game_client_bridge_test.go index 626dee3..c390c12 100644 --- a/platform/domain/game_client_bridge_test.go +++ b/platform/domain/game_client_bridge_test.go @@ -4,7 +4,12 @@ import "testing" func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { manifest := GameClientBridgeManifest{ - QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", RowTarget: &PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}}}}, + QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, RowTarget: &PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: PluginDataRowWriteModeMerge}}}, + LogProjections: []GameClientBridgeLogProjectionDeclaration{{ + Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, + Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, + Presence: &GameClientBridgeLogProjectionPresenceDeclaration{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}}, + }}, DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}}}, OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}}, Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}}, @@ -12,11 +17,14 @@ func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) manifestCopy := CopyGameClientBridgeManifest(manifest) manifestCopy.QueryTemplates[0].Key = "mutated" manifestCopy.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated" + manifestCopy.LogProjections[0].StreamKeys[0] = "mutated" + manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated" + manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated" manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated" manifestCopy.OperationTemplates[0].Key = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" manifestCopy.Pages[0].OperationKeys[0] = "mutated" - if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" { + if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" { t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy) } diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 2b88622..0f465c3 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -290,24 +290,63 @@ 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"` - RowTarget *PluginDataRowTargetDeclarationBody `json:"rowTarget,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"` + SQLRef string `json:"sqlRef,omitempty"` + MaxRows int `json:"maxRows"` + TimeoutSeconds int `json:"timeoutSeconds"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + RowTarget *PluginDataRowTargetDeclarationBody `json:"rowTarget,omitempty"` } type PluginDataRowTargetDeclarationBody struct { Collection string `json:"collection"` UpsertKeys []string `json:"upsertKeys"` ColumnMappings map[string]string `json:"columnMappings"` + WriteMode string `json:"writeMode"` +} + +type GameClientBridgeLogProjectionStepDeclarationBody struct { + Pattern string `json:"pattern"` +} + +type GameClientBridgeLogProjectionTargetDeclarationBody struct { + Collection string `json:"collection"` + UpsertKeys []string `json:"upsertKeys"` + CaptureMappings map[string]string `json:"captureMappings"` + FixedValues map[string]string `json:"fixedValues,omitempty"` + ObservedAtField string `json:"observedAtField,omitempty"` +} + +type GameClientBridgeLogProjectionAnnouncementDeclarationBody struct { + ProfileKey string `json:"profileKey"` + CommandType string `json:"commandType"` + TextField string `json:"textField"` + NewTextTemplate string `json:"newTextTemplate"` + ReturningTextTemplate string `json:"returningTextTemplate"` +} + +type GameClientBridgeLogProjectionPresenceDeclarationBody struct { + TimestampField string `json:"timestampField"` + ActiveWindowSeconds int `json:"activeWindowSeconds"` + ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"` + Announcement GameClientBridgeLogProjectionAnnouncementDeclarationBody `json:"announcement"` +} + +type GameClientBridgeLogProjectionDeclarationBody struct { + Key string `json:"key"` + StreamKeys []string `json:"streamKeys"` + Steps []GameClientBridgeLogProjectionStepDeclarationBody `json:"steps"` + CorrelationFields []string `json:"correlationFields"` + MaxInterveningLines int `json:"maxInterveningLines"` + Target GameClientBridgeLogProjectionTargetDeclarationBody `json:"target"` + Presence *GameClientBridgeLogProjectionPresenceDeclarationBody `json:"presence,omitempty"` } type GameClientBridgeDataPackDeclarationBody struct { @@ -392,6 +431,7 @@ type GameClientBridgeManifestBody struct { Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` + LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"` DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"` OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"` CommandRetentionSeconds int `json:"commandRetentionSeconds"` @@ -1193,10 +1233,14 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif for index, template := range body.QueryTemplates { var rowTarget *domain.PluginDataRowTargetDeclaration if template.RowTarget != nil { - value := domain.PluginDataRowTargetDeclaration{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings)} + value := domain.PluginDataRowTargetDeclaration{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings), WriteMode: template.RowTarget.WriteMode} 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, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, RowTarget: rowTarget} + } + logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections)) + for index, projection := range body.LogProjections { + logProjections[index] = gameClientBridgeLogProjectionToDomain(projection) } dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks)) for index, dataPack := range body.DataPacks { @@ -1218,7 +1262,50 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif if body.Companion != nil { companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds} } - return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, DataPacks: dataPacks, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} + return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} +} + +func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDeclarationBody) domain.GameClientBridgeLogProjectionDeclaration { + steps := make([]domain.GameClientBridgeLogProjectionStepDeclaration, len(value.Steps)) + for index, step := range value.Steps { + steps[index] = domain.GameClientBridgeLogProjectionStepDeclaration{Pattern: step.Pattern} + } + var presence *domain.GameClientBridgeLogProjectionPresenceDeclaration + if value.Presence != nil { + presence = &domain.GameClientBridgeLogProjectionPresenceDeclaration{ + TimestampField: value.Presence.TimestampField, + ActiveWindowSeconds: value.Presence.ActiveWindowSeconds, + ActivityTarget: gameClientBridgeLogProjectionTargetToDomainPointer(value.Presence.ActivityTarget), + Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ + ProfileKey: value.Presence.Announcement.ProfileKey, + CommandType: value.Presence.Announcement.CommandType, + TextField: value.Presence.Announcement.TextField, + NewTextTemplate: value.Presence.Announcement.NewTextTemplate, + ReturningTextTemplate: value.Presence.Announcement.ReturningTextTemplate, + }, + } + } + return domain.GameClientBridgeLogProjectionDeclaration{ + Key: value.Key, + StreamKeys: domain.CopyStringSlice(value.StreamKeys), + Steps: steps, + CorrelationFields: domain.CopyStringSlice(value.CorrelationFields), + MaxInterveningLines: value.MaxInterveningLines, + Target: gameClientBridgeLogProjectionTargetToDomain(value.Target), + Presence: presence, + } +} + +func gameClientBridgeLogProjectionTargetToDomain(value GameClientBridgeLogProjectionTargetDeclarationBody) domain.GameClientBridgeLogProjectionTargetDeclaration { + return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} +} + +func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeLogProjectionTargetDeclarationBody) *domain.GameClientBridgeLogProjectionTargetDeclaration { + if value == nil { + return nil + } + target := gameClientBridgeLogProjectionTargetToDomain(*value) + return &target } func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration { @@ -1633,9 +1720,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G for index, template := range value.QueryTemplates { var rowTarget *PluginDataRowTargetDeclarationBody if template.RowTarget != nil { - rowTarget = &PluginDataRowTargetDeclarationBody{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings)} + rowTarget = &PluginDataRowTargetDeclarationBody{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings), WriteMode: template.RowTarget.WriteMode} } - 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, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, RowTarget: rowTarget} + 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, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, RowTarget: rowTarget} + } + logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections)) + for index, projection := range value.LogProjections { + logProjections[index] = gameClientBridgeLogProjectionFromDomain(projection) } dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks)) for index, dataPack := range value.DataPacks { @@ -1657,7 +1748,50 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G if value.Companion.ProfileKey != "" { companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds} } - return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, DataPacks: dataPacks, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} + return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} +} + +func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclarationBody { + steps := make([]GameClientBridgeLogProjectionStepDeclarationBody, len(value.Steps)) + for index, step := range value.Steps { + steps[index] = GameClientBridgeLogProjectionStepDeclarationBody{Pattern: step.Pattern} + } + var presence *GameClientBridgeLogProjectionPresenceDeclarationBody + if value.Presence != nil { + presence = &GameClientBridgeLogProjectionPresenceDeclarationBody{ + TimestampField: value.Presence.TimestampField, + ActiveWindowSeconds: value.Presence.ActiveWindowSeconds, + ActivityTarget: gameClientBridgeLogProjectionTargetFromDomainPointer(value.Presence.ActivityTarget), + Announcement: GameClientBridgeLogProjectionAnnouncementDeclarationBody{ + ProfileKey: value.Presence.Announcement.ProfileKey, + CommandType: value.Presence.Announcement.CommandType, + TextField: value.Presence.Announcement.TextField, + NewTextTemplate: value.Presence.Announcement.NewTextTemplate, + ReturningTextTemplate: value.Presence.Announcement.ReturningTextTemplate, + }, + } + } + return GameClientBridgeLogProjectionDeclarationBody{ + Key: value.Key, + StreamKeys: domain.CopyStringSlice(value.StreamKeys), + Steps: steps, + CorrelationFields: domain.CopyStringSlice(value.CorrelationFields), + MaxInterveningLines: value.MaxInterveningLines, + Target: gameClientBridgeLogProjectionTargetFromDomain(value.Target), + Presence: presence, + } +} + +func gameClientBridgeLogProjectionTargetFromDomain(value domain.GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclarationBody { + return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} +} + +func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClientBridgeLogProjectionTargetDeclaration) *GameClientBridgeLogProjectionTargetDeclarationBody { + if value == nil { + return nil + } + target := gameClientBridgeLogProjectionTargetFromDomain(*value) + return &target } func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody { diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index 0495d65..26a2882 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -137,8 +137,13 @@ 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/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, - RowTarget: &PluginDataRowTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}}, + ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, PollIntervalSeconds: 3, + RowTarget: &PluginDataRowTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: "merge"}, + }}, + LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{ + Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, + Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, + Presence: &GameClientBridgeLogProjectionPresenceDeclarationBody{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}, Announcement: GameClientBridgeLogProjectionAnnouncementDeclarationBody{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"}}, }}, DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}}}, CommandRetentionSeconds: 86400, @@ -147,7 +152,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { } domainManifest := body.ToDomain() - if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || len(domainManifest.DataPacks) != 1 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { + if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || domainManifest.QueryTemplates[0].RowTarget.WriteMode != "merge" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest) } domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated" @@ -155,6 +160,11 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { t.Fatal("query template row target aliases request DTO data") } domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "user_id" + domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated" + if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" { + t.Fatal("log projection target aliases request DTO data") + } + domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "steamId" domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated" if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatal("query template page keys alias request DTO data") @@ -162,6 +172,10 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { domainManifest.Pages[0].QueryTemplateKeys[0] = "player.lookup" response := gameClientBridgeManifestFromDomain(domainManifest) + response.LogProjections[0].Target.FixedValues["source"] = "mutated" + if domainManifest.LogProjections[0].Target.FixedValues["source"] != "stdout" { + t.Fatal("log projection target aliases domain data") + } response.Pages[0].QueryTemplateKeys[0] = "mutated" if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatal("query template page keys alias domain data") @@ -175,7 +189,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", "maxRows", "timeoutSeconds", "rowTarget"} + expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "pollIntervalSeconds", "rowTarget"} if len(projection) != len(expectedFields) { t.Fatalf("query template projection contains unexpected fields: %s", encoded) } diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index f3d697c..751611c 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -40,6 +40,9 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai if err := svc.sweepExpiredJobs(claim.RunEndpointID, stamp); err != nil { return domain.RunJobClaimResult{}, err } + if err := svc.scheduleDuePluginQueries(claim.RunEndpointID, claim.Capabilities, stamp); err != nil { + return domain.RunJobClaimResult{}, err + } if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs { return emptyJobClaim(claim.RunEndpointID, stamp), nil } diff --git a/platform/service/log_ingest.go b/platform/service/log_ingest.go index 8e688a9..b703328 100644 --- a/platform/service/log_ingest.go +++ b/platform/service/log_ingest.go @@ -22,7 +22,12 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB } lock := svc.logIngestLock(batch.ServerInstanceID) lock.Lock() - defer lock.Unlock() + locked := true + defer func() { + if locked { + lock.Unlock() + } + }() stamp := svc.now() stream, err := svc.store.LogStreams().Get(batch.LogStreamID) @@ -44,6 +49,11 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB return domain.LogBatchIngestResult{}, err } if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) { + locked = false + lock.Unlock() + if err := svc.projectPluginLogBatch(stream, storedLogEntries(batch.Entries)); err != nil { + return domain.LogBatchIngestResult{}, err + } return domain.LogBatchIngestResult{ Accepted: true, LogStreamID: batch.LogStreamID, @@ -76,6 +86,11 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB if err := svc.store.LogStreams().Update(stream); err != nil { return domain.LogBatchIngestResult{}, err } + locked = false + lock.Unlock() + if err := svc.projectPluginLogBatch(stream, storedBatch.Entries); err != nil { + return domain.LogBatchIngestResult{}, err + } svc.publishLogEvents(stream, storedBatch.Entries) return domain.LogBatchIngestResult{ Accepted: true, @@ -87,6 +102,13 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB }, nil } +func storedLogEntries(entries []domain.LogEntry) []domain.LogEntry { + stored := domain.CopyLogEntries(entries) + batch := domain.LogBatchIngest{Entries: stored} + sanitizeLogNetworkFields(&batch) + return batch.Entries +} + func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error { jobID, ok := jobIDFromLogBatch(batch) if !ok { diff --git a/platform/service/plugin_data_projection.go b/platform/service/plugin_data_projection.go index 4ac3565..1fbd0ef 100644 --- a/platform/service/plugin_data_projection.go +++ b/platform/service/plugin_data_projection.go @@ -2,10 +2,13 @@ package service import ( "encoding/json" + "errors" "fmt" + "sort" "strings" "browser.local/platform/domain" + "browser.local/platform/repo" ) type pluginDataQueryResult struct { @@ -42,7 +45,7 @@ func (svc *CoreService) projectPluginDataJobResult(job domain.Job) error { if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &result); err != nil { return validationError("declared query result is not valid JSON") } - mutations := make([]domain.PluginDataMutation, 0, len(result.Rows)) + mutationsByKey := make(map[string]domain.PluginDataMutation, len(result.Rows)) for _, row := range result.Rows { value := make(map[string]any, len(template.RowTarget.ColumnMappings)) for destination, source := range template.RowTarget.ColumnMappings { @@ -52,15 +55,54 @@ func (svc *CoreService) projectPluginDataJobResult(job domain.Job) error { if err != nil { return err } - mutations = append(mutations, domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value}) + if template.RowTarget.WriteMode == domain.PluginDataRowWriteModeMerge { + existing, getErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, template.RowTarget.Collection, key)) + if getErr == nil { + value = mergePluginDataValues(existing.Value, value) + } else if !errors.Is(getErr, repo.ErrNotFound) { + return getErr + } + } + mutationsByKey[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value} } - if len(mutations) == 0 { + if template.RowTarget.WriteMode == domain.PluginDataRowWriteModeReplace { + existing, listErr := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection}) + if listErr != nil { + return listErr + } + for _, record := range existing { + if _, present := mutationsByKey[record.Key]; !present { + mutationsByKey[record.Key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationDelete, Key: record.Key} + } + } + } + if len(mutationsByKey) == 0 { return nil } + keys := make([]string, 0, len(mutationsByKey)) + for key := range mutationsByKey { + keys = append(keys, key) + } + sort.Strings(keys) + mutations := make([]domain.PluginDataMutation, 0, len(keys)) + for _, key := range keys { + mutations = append(mutations, mutationsByKey[key]) + } _, err = svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection, Mutations: mutations}) return err } +func mergePluginDataValues(existing, incoming map[string]any) map[string]any { + merged := domain.CopyGameClientBridgePayload(existing) + if merged == nil { + merged = make(map[string]any, len(incoming)) + } + for key, value := range incoming { + merged[key] = value + } + return merged +} + func pluginDataRowKey(value map[string]any, keys []string) (string, error) { parts := make([]string, len(keys)) for index, key := range keys { diff --git a/platform/service/plugin_data_schedule.go b/platform/service/plugin_data_schedule.go new file mode 100644 index 0000000..4fb11ff --- /dev/null +++ b/platform/service/plugin_data_schedule.go @@ -0,0 +1,92 @@ +package service + +import ( + "fmt" + "strconv" + "strings" + "time" + + "browser.local/platform/domain" +) + +func (svc *CoreService) scheduleDuePluginQueries(runEndpointID string, capabilities []string, stamp time.Time) error { + if !containsString(capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) { + return nil + } + instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: runEndpointID}) + if err != nil { + return err + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: runEndpointID}) + if err != nil { + return err + } + for _, instance := range instances { + if instance.State == domain.ServerInstanceStateDeleted { + continue + } + plugin, getErr := svc.store.GamePlugins().Get(instance.PluginID) + if getErr != nil { + return getErr + } + for _, template := range plugin.GameClientBridge.QueryTemplates { + if template.PollIntervalSeconds <= 0 || template.RowTarget == nil || strings.TrimSpace(template.SQLRef) == "" { + continue + } + if !pluginQueryTemplateDue(jobs, instance.ID, template.Key, time.Duration(template.PollIntervalSeconds)*time.Second, stamp) { + continue + } + bucket := stamp.Unix() / int64(template.PollIntervalSeconds) + idempotencyKey := fmt.Sprintf("plugin-query:%s:%s:%d", instance.ID, template.Key, bucket) + job := domain.Job{ + ID: jobIDFromParts("job-plugin-query", instance.ID, idempotencyKey), + ServerInstanceID: instance.ID, + RunEndpointID: runEndpointID, + Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, + TargetKey: template.TargetKey, + InputRef: "input://plugin-query/" + template.Key, + IdempotencyKey: idempotencyKey, + Progress: domain.JobProgress{Percent: 0, Message: "declared automatic plugin query queued"}, + RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, + ExecutionInput: domain.JobExecutionInput{ + WorkspaceScope: svc.runtimeProfileScope(instance.ID), + RemoteAdapterKey: template.TransportKey, + RemoteAdapterKind: string(domain.RemoteAdapterDatabase), + TimeoutSeconds: template.TimeoutSeconds, + Inputs: map[string]string{ + "templateKey": template.Key, + "sqlRef": template.SQLRef, + "maxRows": strconv.Itoa(template.MaxRows), + "limit": strconv.Itoa(template.MaxRows), + }, + }, + } + created, createErr := svc.CreateJob(job) + if createErr != nil { + return createErr + } + jobs = append(jobs, created) + } + } + return nil +} + +func pluginQueryTemplateDue(jobs []domain.Job, serverInstanceID, templateKey string, interval time.Duration, stamp time.Time) bool { + var latest time.Time + for _, job := range jobs { + if job.ServerInstanceID != serverInstanceID || job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.ExecutionInput.Inputs["templateKey"] != templateKey { + continue + } + if !isTerminalJobState(job.State) { + return false + } + attemptedAt := job.TerminalAt + if attemptedAt.IsZero() { + attemptedAt = job.UpdatedAt + } + if attemptedAt.After(latest) { + latest = attemptedAt + } + } + return latest.IsZero() || !stamp.Before(latest.Add(interval)) +} diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go index 8618299..0374721 100644 --- a/platform/service/plugin_data_test.go +++ b/platform/service/plugin_data_test.go @@ -167,6 +167,92 @@ func TestDeclaredSQLiteQueryProjectionRejectsInvalidBatchAtomically(t *testing.T } } +func TestDeclaredSQLiteQueryProjectionMergesPresenceAndReplacesCompleteSnapshots(t *testing.T) { + svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t) + template := &plugin.GameClientBridge.QueryTemplates[0] + template.RowTarget = &domain.PluginDataRowTargetDeclaration{ + Collection: "users", UpsertKeys: []string{"steamId"}, WriteMode: domain.PluginDataRowWriteModeMerge, + ColumnMappings: map[string]string{"steamId": "steam_id", "displayName": "display_name", "x": "x"}, + } + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update merge target: %v", err) + } + if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "online": true, "lastLoginAt": "2026-07-03T12:00:00Z"}}); err != nil { + t.Fatalf("seed stdout user: %v", err) + } + job := domain.Job{ServerInstanceID: instance.ID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, State: domain.JobStateSucceeded, ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": template.Key}}, ExecutionResult: domain.JobExecutionResult{Content: `{"rows":[{"steam_id":"steam-1","display_name":"Ada","x":12.5}]}`}} + if err := svc.projectPluginDataJobResult(job); err != nil { + t.Fatalf("merge query projection: %v", err) + } + users, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users"}) + if err != nil || len(users) != 1 || users[0].Value["online"] != true || users[0].Value["displayName"] != "Ada" { + t.Fatalf("merged users=%+v err=%v", users, err) + } + + plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ + Collection: "vehicles", UpsertKeys: []string{"vehicleId"}, WriteMode: domain.PluginDataRowWriteModeReplace, + ColumnMappings: map[string]string{"vehicleId": "vehicle_id", "x": "x"}, + } + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update replace target: %v", err) + } + for _, id := range []string{"keep", "gone"} { + if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles", Key: id, Value: map[string]any{"vehicleId": id}}); err != nil { + t.Fatalf("seed vehicle %s: %v", id, err) + } + } + job.ExecutionResult.Content = `{"rows":[{"vehicle_id":"keep","x":7}]}` + if err := svc.projectPluginDataJobResult(job); err != nil { + t.Fatalf("replace query projection: %v", err) + } + vehicles, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles"}) + if err != nil || len(vehicles) != 1 || vehicles[0].Key != "keep" { + t.Fatalf("replaced vehicles=%+v err=%v", vehicles, err) + } + job.ExecutionResult.Content = `{"rows":[]}` + if err := svc.projectPluginDataJobResult(job); err != nil { + t.Fatalf("empty replace query projection: %v", err) + } + vehicles, err = svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles"}) + if err != nil || len(vehicles) != 0 { + t.Fatalf("empty replace did not clear vehicles=%+v err=%v", vehicles, err) + } +} + +func TestRunPollSchedulesDueDeclaredPluginQueryWithoutBrowserSession(t *testing.T) { + svc, plugin, endpoint, _, instance := createSQLiteQueryBridgeFixture(t) + plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3 + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("enable automatic query: %v", err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) + helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register Run: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil { + t.Fatalf("automatic query claim=%+v err=%v", claim, err) + } + if claim.Job.ServerInstanceID != instance.ID || claim.Job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["limit"] != "25" { + t.Fatalf("unexpected automatic query assignment: %+v", claim.Job) + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID}) + if err != nil || len(jobs) != 1 { + t.Fatalf("automatic query jobs=%+v err=%v", jobs, err) + } + second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1, RunningJobs: 1}}) + if err != nil || second.HasJob { + t.Fatalf("overlapping automatic query was not suppressed: %+v err=%v", second, err) + } + jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID}) + if err != nil || len(jobs) != 1 { + t.Fatalf("overlap created duplicate jobs=%+v err=%v", jobs, err) + } +} + func TestDeclaredSQLiteQueryProjectionFailureKeepsJobRetryable(t *testing.T) { svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ diff --git a/platform/service/plugin_log_projection.go b/platform/service/plugin_log_projection.go new file mode 100644 index 0000000..7b43064 --- /dev/null +++ b/platform/service/plugin_log_projection.go @@ -0,0 +1,276 @@ +package service + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +type pluginLogSequenceState struct { + StepIndex int + Captures map[string]string + LastSeq uint64 +} + +func (svc *CoreService) projectPluginLogBatch(stream domain.LogStream, entries []domain.LogEntry) error { + if stream.Source != domain.LogStreamSourceProcess || len(entries) == 0 { + return nil + } + instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID) + if err != nil { + return err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return err + } + for _, projection := range plugin.GameClientBridge.LogProjections { + if !containsString(projection.StreamKeys, stream.StreamKey) { + continue + } + for _, entry := range entries { + captures, complete, matchErr := svc.advancePluginLogProjection(stream, projection, entry) + if matchErr != nil { + return matchErr + } + if complete { + observedAt := entry.Timestamp + if observedAt.IsZero() { + observedAt = svc.now() + } + if err := svc.applyPluginLogProjection(instance, plugin, projection, captures, observedAt); err != nil { + return err + } + } + } + } + return nil +} + +func (svc *CoreService) advancePluginLogProjection(stream domain.LogStream, projection domain.GameClientBridgeLogProjectionDeclaration, entry domain.LogEntry) (map[string]string, bool, error) { + if len(projection.Steps) == 0 { + return nil, false, nil + } + stateKey := strings.Join([]string{stream.ServerInstanceID, stream.ID, stream.LogSessionID, projection.Key}, "\x00") + svc.logProjectionMu.Lock() + defer svc.logProjectionMu.Unlock() + states := svc.logProjectionStates[stateKey] + if states == nil { + states = map[string]pluginLogSequenceState{} + svc.logProjectionStates[stateKey] = states + } + nextStates := make(map[string]pluginLogSequenceState, len(states)+1) + var completed map[string]string + for correlationKey, state := range states { + if state.StepIndex < 1 || state.StepIndex >= len(projection.Steps) { + continue + } + if projection.MaxInterveningLines >= 0 && state.LastSeq > 0 && entry.Seq > state.LastSeq+uint64(projection.MaxInterveningLines)+1 { + continue + } + match, err := matchLogProjectionStep(projection.Steps[state.StepIndex].Pattern, entry.Line) + if err != nil { + return nil, false, err + } + if match == nil { + nextStates[correlationKey] = state + continue + } + merged, ok := mergeLogCaptures(state.Captures, match) + if !ok || !correlationCapturesAgree(state.Captures, match, projection.CorrelationFields) { + continue + } + if state.StepIndex+1 == len(projection.Steps) { + completed = merged + continue + } + nextKey := logCorrelationKey(merged, projection.CorrelationFields) + nextStates[nextKey] = pluginLogSequenceState{StepIndex: state.StepIndex + 1, Captures: merged, LastSeq: entry.Seq} + } + first, err := matchLogProjectionStep(projection.Steps[0].Pattern, entry.Line) + if err != nil { + return nil, false, err + } + if first != nil { + if len(projection.Steps) == 1 { + completed = first + } else { + key := logCorrelationKey(first, projection.CorrelationFields) + nextStates[key] = pluginLogSequenceState{StepIndex: 1, Captures: first, LastSeq: entry.Seq} + } + } + svc.logProjectionStates[stateKey] = nextStates + return completed, completed != nil, nil +} + +func matchLogProjectionStep(pattern, line string) (map[string]string, error) { + expression, err := regexp.Compile(pattern) + if err != nil { + return nil, validationError("declared log projection pattern is invalid") + } + values := expression.FindStringSubmatch(line) + if values == nil { + return nil, nil + } + result := make(map[string]string) + for index, name := range expression.SubexpNames() { + if index > 0 && name != "" && index < len(values) { + result[name] = values[index] + } + } + return result, nil +} + +func mergeLogCaptures(existing, incoming map[string]string) (map[string]string, bool) { + merged := make(map[string]string, len(existing)+len(incoming)) + for key, value := range existing { + merged[key] = value + } + for key, value := range incoming { + if previous, exists := merged[key]; exists && previous != value { + return nil, false + } + merged[key] = value + } + return merged, true +} + +func correlationCapturesAgree(existing, incoming map[string]string, fields []string) bool { + for _, field := range fields { + left, leftExists := existing[field] + right, rightExists := incoming[field] + if leftExists && rightExists && left != right { + return false + } + } + return true +} + +func logCorrelationKey(captures map[string]string, fields []string) string { + parts := make([]string, len(fields)) + for index, field := range fields { + parts[index] = captures[field] + } + return strings.Join(parts, "\x1f") +} + +func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, plugin domain.GamePlugin, projection domain.GameClientBridgeLogProjectionDeclaration, captures map[string]string, observedAt time.Time) error { + value := pluginLogProjectionValue(projection.Target, captures, observedAt) + key, err := pluginDataRowKey(value, projection.Target.UpsertKeys) + if err != nil { + return err + } + existing, getErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, projection.Target.Collection, key)) + isNew := errors.Is(getErr, repo.ErrNotFound) + if getErr != nil && !isNew { + return getErr + } + insideWindow := false + sameObservation := false + if projection.Presence != nil && !isNew { + if previous, ok := pluginDataTimestamp(existing.Value[projection.Presence.TimestampField]); ok { + if observedAt.Before(previous) { + return nil + } + sameObservation = observedAt.Equal(previous) + insideWindow = observedAt.Sub(previous) < time.Duration(projection.Presence.ActiveWindowSeconds)*time.Second + } + } + if insideWindow && !sameObservation { + return nil + } + announcementAlreadyQueued := false + announcementIdempotencyKey := "" + if projection.Presence != nil { + announcementIdempotencyKey = fmt.Sprintf("log-projection:%s:%s:%d", projection.Key, key, observedAt.Unix()/int64(projection.Presence.ActiveWindowSeconds)) + _, commandErr := svc.store.GameClientBridgeCommands().GetByIdempotency(instance.ID, "system:log-projection", projection.Presence.Announcement.CommandType, announcementIdempotencyKey) + if commandErr == nil { + announcementAlreadyQueued = true + } else if !errors.Is(commandErr, repo.ErrNotFound) { + return commandErr + } + } + if !isNew { + value = mergePluginDataValues(existing.Value, value) + } + if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: projection.Target.Collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: key, Value: value}}}); err != nil { + return err + } + if projection.Presence != nil && projection.Presence.ActivityTarget != nil { + activity := pluginLogProjectionValue(*projection.Presence.ActivityTarget, captures, observedAt) + activityKey, keyErr := pluginDataRowKey(activity, projection.Presence.ActivityTarget.UpsertKeys) + if keyErr != nil { + return keyErr + } + if _, applyErr := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: projection.Presence.ActivityTarget.Collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: activityKey, Value: activity}}}); applyErr != nil { + return applyErr + } + } + if projection.Presence != nil && !announcementAlreadyQueued { + announcement := projection.Presence.Announcement + template := announcement.ReturningTextTemplate + if isNew || sameObservation { + template = announcement.NewTextTemplate + } + requestText := renderLogProjectionTemplate(template, captures) + expiresAt := svc.now().Add(gameClientBridgeCommandTimeout(plugin, announcement.CommandType)) + if _, err := svc.queueGameClientBridgeCommand("system:log-projection", domain.GameClientBridgeQueueRequest{ + ServerInstanceID: instance.ID, + PluginID: plugin.ID, + ProfileKey: announcement.ProfileKey, + CommandType: announcement.CommandType, + Payload: map[string]any{announcement.TextField: requestText}, + IdempotencyKey: announcementIdempotencyKey, + Priority: 100, + ExpiresAt: expiresAt, + }); err != nil { + return err + } + } + return nil +} + +func gameClientBridgeCommandTimeout(plugin domain.GamePlugin, commandType string) time.Duration { + for _, declaration := range plugin.GameClientBridge.Commands { + if declaration.Type == commandType && declaration.TimeoutSeconds > 0 { + return time.Duration(declaration.TimeoutSeconds) * time.Second + } + } + return time.Minute +} + +func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any { + value := make(map[string]any, len(target.CaptureMappings)+len(target.FixedValues)+1) + for destination, capture := range target.CaptureMappings { + value[destination] = captures[capture] + } + for key, fixed := range target.FixedValues { + value[key] = renderLogProjectionTemplate(fixed, captures) + } + if target.ObservedAtField != "" { + value[target.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano) + } + return value +} + +func renderLogProjectionTemplate(template string, captures map[string]string) string { + result := template + for key, value := range captures { + result = strings.ReplaceAll(result, "{{"+key+"}}", value) + } + return result +} + +func pluginDataTimestamp(value any) (time.Time, bool) { + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || text == "" { + return time.Time{}, false + } + parsed, err := time.Parse(time.RFC3339Nano, text) + return parsed, err == nil +} diff --git a/platform/service/plugin_log_projection_test.go b/platform/service/plugin_log_projection_test.go new file mode 100644 index 0000000..5787c56 --- /dev/null +++ b/platform/service/plugin_log_projection_test.go @@ -0,0 +1,138 @@ +package service + +import ( + "strings" + "testing" + "time" + + "browser.local/platform/domain" +) + +func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnouncesReturns(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + capability := domain.JobCapabilityRemoteRunProtectedRCON + plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability) + plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{capability}}) + plugin.RuntimeProfiles.ClientManagers = append(plugin.RuntimeProfiles.ClientManagers, domain.RuntimeClientManagerProfile{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}) + plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{ + Type: "presence.announce", Title: "Presence announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, + PayloadSchemaRef: "schemas/presence-announcement.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096, + ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "rcon", TransportKey: "scum-management", TargetKey: "scum-management", TextField: "requestText", MaxTextBytes: 1024}, + }} + plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{ + Key: "player.login", StreamKeys: []string{"stdout"}, CorrelationFields: []string{"playerSlot"}, MaxInterveningLines: 4, + Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{ + {Pattern: `Player "(?P[^"]+)" reported as player (?P[0-9]+)`}, + {Pattern: `Player (?P[0-9]+) SteamID \(assumed\): (?P[0-9]+)`}, + }, + Target: domain.GameClientBridgeLogProjectionTargetDeclaration{ + Collection: "scum_users", UpsertKeys: []string{"steamId"}, + CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName", "playerSlot": "playerSlot"}, + FixedValues: map[string]string{"online": "true", "source": "supervised-stdout"}, ObservedAtField: "lastLoginAt", + }, + Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{ + TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, + ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{ + Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt"}, + CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "login"}, ObservedAtField: "observedAt", + }, + Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ + ProfileKey: "scum-client", CommandType: "presence.announce", TextField: "requestText", + NewTextTemplate: "#announce Welcome {{displayName}}", ReturningTextTemplate: "#announce Welcome back {{displayName}}", + }, + }, + }} + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update plugin projection: %v", err) + } + endpoint.Capabilities = append(endpoint.Capabilities, capability) + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatalf("update Run capability: %v", err) + } + instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-log-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM projection", State: domain.ServerInstanceStateRunning}) + if err != nil { + t.Fatalf("create server: %v", err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability) + helloRequest.CapabilityReport.Fingerprint = "cap-log-projection" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register Run: %v", err) + } + stream, err := svc.CreateLogStream(domain.LogStream{ID: "log-projection", ServerInstanceID: instance.ID, Source: domain.LogStreamSourceProcess, StreamKey: "stdout", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"}) + if err != nil { + t.Fatalf("create stdout stream: %v", err) + } + + base := time.Date(2026, 8, 18, 23, 25, 12, 0, time.UTC) + ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 1, base, []string{ + `LogBattlEye: Display: Player "love_fitting" reported as player 0`, + `LogBattlEye: Display: Player #0 love_fitting (redacted) connected`, + }) + ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 3, base.Add(2*time.Second), []string{ + `LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`, + }) + assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1) + + ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 4, base.Add(5*time.Minute), []string{ + `LogBattlEye: Display: Player "love_fitting" reported as player 0`, + `LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`, + }) + assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1) + + ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 6, base.Add(11*time.Minute), []string{ + `LogBattlEye: Display: Player "love_fitting" reported as player 0`, + `LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`, + }) + assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 2) + + svc.protectedRequests.mu.Lock() + texts := make([]string, 0, len(svc.protectedRequests.payloads)) + for _, payload := range svc.protectedRequests.payloads { + texts = append(texts, payload.requestText) + } + svc.protectedRequests.mu.Unlock() + if len(texts) != 2 || !containsText(texts, "#announce Welcome love_fitting") || !containsText(texts, "#announce Welcome back love_fitting") { + t.Fatalf("unexpected plugin-declared announcement requests: %v", texts) + } +} + +func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) { + t.Helper() + entries := make([]domain.LogEntry, len(lines)) + for index, line := range lines { + entries[index] = domain.LogEntry{Seq: firstSeq + uint64(index), Timestamp: observedAt.Add(time.Duration(index) * time.Second), Level: "display", Line: line} + } + lastSeq := firstSeq + uint64(len(entries)) - 1 + batch := domain.LogBatchIngest{RunEndpointID: endpointID, SessionToken: sessionToken, LogStreamID: streamID, ServerInstanceID: serverID, StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: firstSeq, LastSeq: lastSeq, Compression: "none", Checksum: checksumForEntries(t, entries), Entries: entries} + if result, err := svc.IngestLogBatch(batch); err != nil || !result.Accepted { + t.Fatalf("ingest projection lines result=%+v err=%v", result, err) + } +} + +func assertPresenceProjectionCounts(t *testing.T, svc *CoreService, pluginID, serverID string, users, activities, commands int) { + t.Helper() + userRows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: pluginID, ServerInstanceID: serverID, Collection: "scum_users"}) + if err != nil || len(userRows) != users { + t.Fatalf("projected users=%+v err=%v", userRows, err) + } + activityRows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: pluginID, ServerInstanceID: serverID, Collection: "scum_activity_events"}) + if err != nil || len(activityRows) != activities { + t.Fatalf("projected activities=%+v err=%v", activityRows, err) + } + queued, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverID, PluginID: pluginID}) + if err != nil || len(queued) != commands { + t.Fatalf("presence announcements=%+v err=%v", queued, err) + } +} + +func containsText(values []string, expected string) bool { + for _, value := range values { + if strings.Contains(value, expected) { + return true + } + } + return false +} diff --git a/platform/service/resources.go b/platform/service/resources.go index 43f6ea2..4ca8057 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -234,6 +234,8 @@ type CoreService struct { bridgeSeq uint64 logStore LogBodyStore logIngestMu [64]sync.Mutex + logProjectionMu sync.Mutex + logProjectionStates map[string]map[string]pluginLogSequenceState logEventMu sync.Mutex logEventSubscribers map[uint64]logEventSubscriber logEventSubscriberSeq uint64 @@ -280,6 +282,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun authSessions: map[string]string{}, runSessions: map[string]domain.RunControlSession{}, logStore: logStore, + logProjectionStates: map[string]map[string]pluginLogSequenceState{}, logEventSubscribers: map[uint64]logEventSubscriber{}, artifactStore: artifactStore, artifactTransfers: map[string]domain.ArtifactTransferSession{}, diff --git a/platform/validator/game_client_bridge_log_projection_test.go b/platform/validator/game_client_bridge_log_projection_test.go new file mode 100644 index 0000000..910ec70 --- /dev/null +++ b/platform/validator/game_client_bridge_log_projection_test.go @@ -0,0 +1,66 @@ +package validator + +import ( + "strings" + "testing" + + "browser.local/platform/domain" +) + +func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) { + bridge := domain.GameClientBridgeManifest{ + Commands: []domain.GameClientBridgeCommandDeclaration{{ + Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, + PayloadSchemaRef: "schemas/bridge/announcement.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096, + }}, + LogProjections: []domain.GameClientBridgeLogProjectionDeclaration{{ + Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16, + Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{ + {Pattern: `Player "(?[^"]+)" reported as player (?\d+)`}, + {Pattern: `Player (?\d+) SteamID \(assumed\): (?\d+)`}, + }, + Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId", "name": "name"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, + Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{ + TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, + ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, ObservedAtField: "observedAt"}, + Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"}, + }, + }}, + Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}, + } + profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}}} + if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, profiles); len(violations) != 0 { + t.Fatalf("expected repeated named captures across steps to validate, got %v", violations) + } + + tests := []struct { + name string + expected string + mutate func(*domain.GameClientBridgeManifest, *domain.GamePluginRuntimeProfiles) + }{ + {name: "invalid regex", expected: "valid regular expression", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) { + value.LogProjections[0].Steps[0].Pattern = "(" + }}, + {name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) { + value.LogProjections[0].Target.CaptureMappings["steamId"] = "missing" + }}, + {name: "missing profile", expected: "must reference a declared game-client bridge profile", mutate: func(_ *domain.GameClientBridgeManifest, value *domain.GamePluginRuntimeProfiles) { + value.ClientManagers = nil + }}, + {name: "missing command", expected: "must reference a declared command", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) { + value.LogProjections[0].Presence.Announcement.CommandType = "missing.command" + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := domain.CopyGameClientBridgeManifest(bridge) + candidateProfiles := profiles + candidateProfiles.ClientManagers = append([]domain.RuntimeClientManagerProfile(nil), profiles.ClientManagers...) + test.mutate(&candidate, &candidateProfiles) + violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, candidateProfiles) + if !strings.Contains(strings.Join(violations, "; "), test.expected) { + t.Fatalf("expected %q violation, got %v", test.expected, violations) + } + }) + } +} diff --git a/platform/validator/resources.go b/platform/validator/resources.go index e755e3d..970a8f0 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -2,6 +2,7 @@ package validator import ( "fmt" + "regexp" "strconv" "strings" @@ -24,6 +25,12 @@ const ( maxProductionMessageLength = 320 ) +var ( + gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`) + gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`) + gameClientBridgeCaptureNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,79}$`) +) + type ValidationError struct { Violations []string } @@ -427,7 +434,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string { companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) - if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.DataPacks) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { + if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { return nil } var violations []string @@ -566,6 +573,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 { violations = append(violations, prefix+".timeoutSeconds is invalid") } + if template.PollIntervalSeconds < 0 || template.PollIntervalSeconds > 86400 { + violations = append(violations, prefix+".pollIntervalSeconds is invalid") + } projectsRows := template.SQLRef != "" || template.RowTarget != nil if projectsRows { if !safeRelativeSQLRef(template.SQLRef) { @@ -578,6 +588,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if !clientManagerIdentifierPattern.MatchString(target.Collection) || len(target.UpsertKeys) == 0 || len(target.ColumnMappings) == 0 { violations = append(violations, prefix+".rowTarget must declare a collection, upsert keys, and column mappings") } + if target.WriteMode != "" && target.WriteMode != domain.PluginDataRowWriteModeMerge && target.WriteMode != domain.PluginDataRowWriteModeReplace { + violations = append(violations, prefix+".rowTarget.writeMode must be merge or replace") + } for _, key := range target.UpsertKeys { if !clientManagerIdentifierPattern.MatchString(key) { violations = append(violations, prefix+".rowTarget upsert key is invalid") @@ -602,6 +615,18 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability") } } + logProjectionKeys := map[string]struct{}{} + for index, projection := range bridge.LogProjections { + prefix := fmt.Sprintf("%s.logProjections[%d]", field, index) + if !clientManagerIdentifierPattern.MatchString(projection.Key) { + violations = append(violations, prefix+".key is invalid") + } + if _, exists := logProjectionKeys[projection.Key]; exists { + violations = append(violations, prefix+".key is duplicated") + } + logProjectionKeys[projection.Key] = struct{}{} + violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection, bridge.Commands, runtimeProfiles.ClientManagers)...) + } dataPackKeys := map[string]struct{}{} for index, dataPack := range bridge.DataPacks { prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index) @@ -795,6 +820,176 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid return violations } +func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration, commands []domain.GameClientBridgeCommandDeclaration, clientManagers []domain.RuntimeClientManagerProfile) []string { + var violations []string + if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 { + violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams") + } + for _, streamKey := range projection.StreamKeys { + if !clientManagerIdentifierPattern.MatchString(streamKey) { + violations = append(violations, prefix+".streamKeys contains an invalid stream key") + } + } + violations = append(violations, duplicateViolations(prefix+".streamKeys", projection.StreamKeys)...) + + captures := map[string]struct{}{} + if len(projection.Steps) < 1 || len(projection.Steps) > 64 { + violations = append(violations, prefix+".steps must contain between 1 and 64 patterns") + } + for index, step := range projection.Steps { + stepPrefix := fmt.Sprintf("%s.steps[%d].pattern", prefix, index) + if strings.TrimSpace(step.Pattern) == "" || len([]rune(step.Pattern)) > 16384 { + violations = append(violations, stepPrefix+" is empty or too large") + continue + } + compiled, err := regexp.Compile(step.Pattern) + if err != nil { + violations = append(violations, stepPrefix+" must be a valid regular expression") + continue + } + for _, capture := range compiled.SubexpNames() { + if capture != "" { + captures[capture] = struct{}{} + } + } + } + + if len(projection.CorrelationFields) < 1 || len(projection.CorrelationFields) > 64 { + violations = append(violations, prefix+".correlationFields must contain between 1 and 64 captures") + } + for _, field := range projection.CorrelationFields { + if !gameClientBridgeCaptureNamePattern.MatchString(field) { + violations = append(violations, prefix+".correlationFields contains an invalid capture name") + continue + } + if _, exists := captures[field]; !exists { + violations = append(violations, prefix+".correlationFields references undeclared capture "+field) + } + } + violations = append(violations, duplicateViolations(prefix+".correlationFields", projection.CorrelationFields)...) + if projection.MaxInterveningLines < 0 || projection.MaxInterveningLines > 100000 { + violations = append(violations, prefix+".maxInterveningLines is invalid") + } + violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".target", projection.Target, captures)...) + + if projection.Presence == nil { + return violations + } + presence := projection.Presence + if !gameClientBridgeFieldPattern.MatchString(presence.TimestampField) || !gameClientBridgeLogProjectionTargetDeclaresField(projection.Target, presence.TimestampField) { + violations = append(violations, prefix+".presence.timestampField must reference a projected target field") + } + if presence.ActiveWindowSeconds < 1 || presence.ActiveWindowSeconds > 31536000 { + violations = append(violations, prefix+".presence.activeWindowSeconds is invalid") + } + if presence.ActivityTarget != nil { + violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...) + } + + announcement := presence.Announcement + if !clientManagerIdentifierPattern.MatchString(announcement.ProfileKey) { + violations = append(violations, prefix+".presence.announcement.profileKey is invalid") + } else { + profileFound := false + for _, profile := range clientManagers { + if profile.Key == announcement.ProfileKey && containsString(profile.Health.RequiredCapabilities, "game-client.bridge") { + profileFound = true + break + } + } + if !profileFound { + violations = append(violations, prefix+".presence.announcement.profileKey must reference a declared game-client bridge profile") + } + } + var command *domain.GameClientBridgeCommandDeclaration + for index := range commands { + if commands[index].Type == announcement.CommandType { + command = &commands[index] + break + } + } + if command == nil { + violations = append(violations, prefix+".presence.announcement.commandType must reference a declared command") + } + if !gameClientBridgeFieldPattern.MatchString(announcement.TextField) { + violations = append(violations, prefix+".presence.announcement.textField is invalid") + } else if command != nil && command.ProtectedRequest != nil && command.ProtectedRequest.TextField != announcement.TextField { + violations = append(violations, prefix+".presence.announcement.textField must match the command protected request") + } + if strings.TrimSpace(announcement.NewTextTemplate) == "" || len([]rune(announcement.NewTextTemplate)) > 4096 { + violations = append(violations, prefix+".presence.announcement.newTextTemplate is empty or too large") + } + if strings.TrimSpace(announcement.ReturningTextTemplate) == "" || len([]rune(announcement.ReturningTextTemplate)) > 4096 { + violations = append(violations, prefix+".presence.announcement.returningTextTemplate is empty or too large") + } + return violations +} + +func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]struct{}) []string { + var violations []string + if !gameClientBridgeCollectionPattern.MatchString(target.Collection) { + violations = append(violations, prefix+".collection is invalid") + } + if len(target.UpsertKeys) < 1 || len(target.UpsertKeys) > 8 { + violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields") + } + for _, key := range target.UpsertKeys { + if !gameClientBridgeFieldPattern.MatchString(key) { + violations = append(violations, prefix+".upsertKeys contains an invalid field") + } + if !gameClientBridgeLogProjectionTargetDeclaresField(target, key) { + violations = append(violations, prefix+".upsertKeys field "+key+" is not projected") + } + } + violations = append(violations, duplicateViolations(prefix+".upsertKeys", target.UpsertKeys)...) + + if len(target.CaptureMappings) < 1 || len(target.CaptureMappings) > 64 { + violations = append(violations, prefix+".captureMappings must contain between 1 and 64 mappings") + } + projectedFields := map[string]struct{}{} + for destination, capture := range target.CaptureMappings { + if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) { + violations = append(violations, prefix+".captureMappings contains an invalid field or capture") + } + if _, exists := captures[capture]; !exists { + violations = append(violations, prefix+".captureMappings references undeclared capture "+capture) + } + projectedFields[destination] = struct{}{} + } + if len(target.FixedValues) > 64 { + violations = append(violations, prefix+".fixedValues contains too many fields") + } + for destination, value := range target.FixedValues { + if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 { + violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value") + } + if _, exists := projectedFields[destination]; exists { + violations = append(violations, prefix+" declares field "+destination+" more than once") + } + projectedFields[destination] = struct{}{} + } + if target.ObservedAtField != "" { + if !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) { + violations = append(violations, prefix+".observedAtField is invalid") + } + if _, exists := projectedFields[target.ObservedAtField]; exists { + violations = append(violations, prefix+" declares field "+target.ObservedAtField+" more than once") + } + } + return violations +} + +func gameClientBridgeLogProjectionTargetDeclaresField(target domain.GameClientBridgeLogProjectionTargetDeclaration, field string) bool { + if target.ObservedAtField == field { + return true + } + if _, exists := target.CaptureMappings[field]; exists { + return true + } + _, exists := target.FixedValues[field] + return exists +} + func validCompanionProofEnvironment(value string) bool { if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' { return false diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index 71af29a..042e32c 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -204,6 +204,13 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog( {name: "timeout bound", expected: "timeoutSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) { value.Manifest.GameClientBridge.QueryTemplates[0].TimeoutSeconds = 61 }}, + {name: "poll interval bound", expected: "pollIntervalSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 86401 + }}, + {name: "write mode", expected: "writeMode must be merge or replace", mutate: func(value *domain.GamePluginManifestRegistration) { + value.Manifest.GameClientBridge.QueryTemplates[0].SQLRef = "sql/player-lookup.sql" + value.Manifest.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: "append"} + }}, {name: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) { value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing" }}, diff --git a/plugins/examples/scum-server-plugin/companion/adapters.go b/plugins/examples/scum-server-plugin/companion/adapters.go index bced9b5..52bf874 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters.go +++ b/plugins/examples/scum-server-plugin/companion/adapters.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "sort" "strings" "unicode/utf8" @@ -416,7 +417,7 @@ func rewardGrant(payload map[string]any) (RewardGrant, error) { playerID, playerOK := payload["playerId"].(string) rawItems, itemsOK := payload["items"].([]any) rawOperations, operationsOK := payload["operations"].([]any) - if !grantOK || !playerOK || !itemsOK || !operationsOK || (len(rawItems) == 0 && len(rawOperations) == 0) || len(rawItems) > 8 { + if !grantOK || !playerOK || !itemsOK || !operationsOK || (len(rawItems) == 0 && len(rawOperations) == 0) { return RewardGrant{}, fmt.Errorf("reward payload is invalid") } items := make([]RewardItem, 0, len(rawItems)) @@ -427,7 +428,7 @@ func rewardGrant(payload map[string]any) (RewardGrant, error) { } code, codeOK := item["catalogCode"].(string) quantity, quantityOK := integerPayloadValue(item["quantity"]) - if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 || quantity > 100 { + if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 { return RewardGrant{}, fmt.Errorf("reward payload is invalid") } items = append(items, RewardItem{CatalogCode: code, Quantity: quantity}) @@ -456,7 +457,7 @@ func eventStartRequest(payload map[string]any) (EventStartRequest, error) { for index, key := range []string{"npc", "item", "zombie", "animal"} { if value, exists := payload[key]; exists { count, ok := integerPayloadValue(value) - if !ok || count < 0 || count > 10000 { + if !ok || count < 0 { return EventStartRequest{}, fmt.Errorf("event start payload is invalid") } counts[index] = count @@ -466,7 +467,7 @@ func eventStartRequest(payload map[string]any) (EventStartRequest, error) { if value, exists := payload["maxParticipants"]; exists { var ok bool participants, ok = integerPayloadValue(value) - if !ok || participants < 1 || participants > 1000 { + if !ok || participants < 1 { return EventStartRequest{}, fmt.Errorf("event start payload is invalid") } } @@ -478,7 +479,7 @@ func eventStartRequest(payload map[string]any) (EventStartRequest, error) { return EventStartRequest{}, fmt.Errorf("event start payload is invalid") } } - if !eventIDOK || !eventTypeOK || !classOK || !titleOK || !durationOK || !percentOK || !placardOK || !producesOK || !supportedEventType(eventType) || eventClass < 1 || eventClass > 2 || (eventClass == 1) != (eventType == "range") || strings.TrimSpace(eventID) == "" || strings.TrimSpace(title) == "" || len(placard) > 500 || percent < 0 || percent > 100 || duration < 30 || duration > 86400 { + if !eventIDOK || !eventTypeOK || !classOK || !titleOK || !durationOK || !percentOK || !placardOK || !producesOK || !supportedEventType(eventType) || eventClass < 1 || eventClass > 2 || (eventClass == 1) != (eventType == "range") || strings.TrimSpace(eventID) == "" || strings.TrimSpace(title) == "" || len(placard) > 500 || percent < 0 || percent > 100 || duration < 1 { return EventStartRequest{}, fmt.Errorf("event start payload is invalid") } return EventStartRequest{EventID: eventID, EventType: eventType, Class: eventClass, Title: title, Placard: placard, Percent: percent, NPC: counts[0], Item: counts[1], Zombie: counts[2], Animal: counts[3], Produces: produces, DurationSeconds: duration, MaxParticipants: participants, Announce: announce}, nil @@ -486,7 +487,7 @@ func eventStartRequest(payload map[string]any) (EventStartRequest, error) { func eventProduceRequests(value any) ([]EventProduceRequest, bool) { raw, ok := value.([]any) - if !ok || len(raw) > 100 { + if !ok { return nil, false } result := make([]EventProduceRequest, 0, len(raw)) @@ -502,7 +503,7 @@ func eventProduceRequests(value any) ([]EventProduceRequest, bool) { x, xOK := numberPayloadValue(produce["x"]) y, yOK := numberPayloadValue(produce["y"]) z, zOK := numberPayloadValue(produce["z"]) - if !idOK || strings.TrimSpace(tradeGoodsID) == "" || len(tradeGoodsID) > 128 || !percentOK || percent < 0 || percent > 100 || !quantityOK || quantity < 1 || quantity > 10000 || !radiusOK || radius < 0 || radius > 2000000 || !xOK || !yOK || !zOK || x < -2000000 || x > 2000000 || y < -2000000 || y > 2000000 || z < -2000000 || z > 2000000 { + if !idOK || strings.TrimSpace(tradeGoodsID) == "" || len(tradeGoodsID) > 128 || !percentOK || percent < 0 || percent > 100 || !quantityOK || quantity < 1 || !radiusOK || radius < 0 || !xOK || !yOK || !zOK { return nil, false } result = append(result, EventProduceRequest{TradeGoodsID: tradeGoodsID, Percent: percent, Value: quantity, Radius: radius, X: x, Y: y, Z: z}) @@ -529,20 +530,22 @@ func integerPayloadValue(value any) (int, bool) { } func numberPayloadValue(value any) (float64, bool) { + var result float64 switch number := value.(type) { case float64: - return number, true + result = number case float32: - return float64(number), true + result = float64(number) case int: - return float64(number), true + result = float64(number) case int32: - return float64(number), true + result = float64(number) case int64: - return float64(number), true + result = float64(number) default: return 0, false } + return result, !math.IsNaN(result) && !math.IsInf(result, 0) } func supportedCatalogCode(value string) bool { diff --git a/plugins/examples/scum-server-plugin/companion/adapters_test.go b/plugins/examples/scum-server-plugin/companion/adapters_test.go index d12aac8..bf2d5c8 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_test.go @@ -104,6 +104,13 @@ func TestRewardDeliveryAcceptsRealSCUMCatalogCodesAndReturnsDeclaredResult(t *te if len(port.grants) != 1 || port.grants[0].Items[0] != (RewardItem{CatalogCode: "BPC_Improvised_Backpack.01", Quantity: 2}) || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SpawnItem BPC_Improvised_Backpack.01 2" { t.Fatalf("reward items or operations did not reach the typed reward port: %+v", port.grants) } + manyItems := make([]any, 9) + for index := range manyItems { + manyItems[index] = map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(101 + index)} + } + if _, err := adapter.DeliverReward(context.Background(), map[string]any{"grantId": "grant-many", "playerId": "76561198000000001", "items": manyItems, "operations": []any{}}); err != nil || len(port.grants) != 2 || len(port.grants[1].Items) != 9 || port.grants[1].Items[0].Quantity != 101 { + t.Fatalf("valid reward count or quantity was rejected: grants=%+v err=%v", port.grants, err) + } before := len(port.grants) if _, err := adapter.DeliverReward(context.Background(), map[string]any{ "grantId": "grant-2", "playerId": "76561198000000001", @@ -114,6 +121,17 @@ func TestRewardDeliveryAcceptsRealSCUMCatalogCodesAndReturnsDeclaredResult(t *te } } +func TestEventStartAcceptsPositiveCountsAndDurationWithoutInventedUpperLimits(t *testing.T) { + produces := make([]any, 101) + for index := range produces { + produces[index] = map[string]any{"tradeGoodsId": "cargo-drop", "percent": float64(80), "value": float64(10001 + index), "r": float64(2000001 + index), "x": float64(3000000 + index), "y": float64(-3000000 - index), "z": float64(index)} + } + request, err := eventStartRequest(map[string]any{"eventId": "event-large", "eventType": "range", "class": float64(1), "title": "Large Event", "placard": "", "percent": float64(100), "npc": float64(10001), "item": float64(10002), "zombie": float64(10003), "animal": float64(10004), "produces": produces, "durationSeconds": float64(86401), "maxParticipants": float64(1001)}) + if err != nil || request.DurationSeconds != 86401 || request.MaxParticipants != 1001 || request.NPC != 10001 || len(request.Produces) != 101 || request.Produces[0].Value != 10001 || request.Produces[0].Radius != 2000001 { + t.Fatalf("valid event values above old limits were rejected: request=%+v err=%v", request, err) + } +} + func TestRewardDeliverySupportsOperationsWithoutItemsAndRejectsEmptyGrant(t *testing.T) { port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered"}} adapter := RuntimeAdapter{BoundServerID: "server-1", Rewards: port} diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 41f2327..01fd382 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -17,9 +17,6 @@ export type PluginGameClientQueueRequest = { expiresAt: string; }; -export type PluginDispatchEnvelope = { requestId: string; action: "remote.access.request"; payload: Record }; -export type PluginDispatchResult = { requestId: string; action: "remote.access.request"; status: string; result?: Record; error?: { code: string; message: string; details?: string[] } }; - export type SCUMWorkspaceActions = { pluginData?: PluginDataActions; gameClient?: { @@ -28,7 +25,6 @@ export type SCUMWorkspaceActions = { list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise; snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise; }; - dispatch?: (envelope: PluginDispatchEnvelope, signal?: AbortSignal) => Promise; }; export type SCUMSurfaceData = { @@ -91,14 +87,6 @@ const pageCollections: Record = { workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"] }; -const pageQueries: Record = { - players: ["scum.player.profile", "scum.positions"], - squads: ["scum.squads", "scum.squad-members", "scum.flags"], - "live-map": ["scum.player.profile", "scum.vehicles", "scum.flags", "scum.positions"], - gifts: ["scum.native-timed-gifts"], - workflows: ["scum.tasks", "scum.events"] -}; - export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise { if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); const data: SCUMSurfaceData = { ...emptySCUMSurfaceData }; @@ -106,52 +94,32 @@ export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: st const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const)); for (const [key, response] of records) data[key] = collectionRecords(response); if (keys.includes("players") && actions.gameClient) { - const [playersSnapshot, sessionsSnapshot] = await Promise.all([ - actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined), - actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "online.sessions", streamKey: "current", limit: 1 }).catch(() => undefined) - ]); - data.players = mergePlayerSnapshots(data.players, playersSnapshot, sessionsSnapshot); + const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined); + data.players = mergePlayerSnapshots(data.players, playersSnapshot); } return data; } -export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown, sessionsResponse: unknown): RecordMap[] { +export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] { const playerSnapshot = latestSnapshotPayload(playersResponse); - const sessionSnapshot = latestSnapshotPayload(sessionsResponse); - let merged = players.map((player) => ({ ...player })); + const merged = players.map((player) => ({ ...player })); const snapshotPlayers = Array.isArray(playerSnapshot?.players) ? playerSnapshot.players.filter(isRecord) : []; if (snapshotPlayers.length) { const byIdentity = playerIndex(merged); for (const snapshotPlayer of snapshotPlayers) { - const match = findPlayer(merged, byIdentity, snapshotPlayer); - const value = { ...snapshotPlayer, ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) }; + const match = findPlayer(byIdentity, snapshotPlayer); + const value = { ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) }; if (match) merged[match.index] = value; - else merged.push({ ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") }); + else { + const created = { ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") }; + merged.push(created); + addPlayerToIndex(byIdentity, created, merged.length - 1); + } } } - const sessions = Array.isArray(sessionSnapshot?.sessions) ? sessionSnapshot.sessions.filter(isRecord) : []; - if (sessionSnapshot && Array.isArray(sessionSnapshot.sessions)) { - const onlineNames = new Set(sessions.map((session) => firstText(session, "playerName", "displayName", "name").toLowerCase()).filter(Boolean)); - merged = merged.map((player) => { - const name = firstText(player, "displayName", "playerName", "name").toLowerCase(); - const session = sessions.find((candidate) => firstText(candidate, "playerName", "displayName", "name").toLowerCase() === name); - return { ...player, online: Boolean(name && onlineNames.has(name)), ...(session ? { onlineSession: session } : {}), onlineObservedAt: textValue(sessionSnapshot.observedAt) || textValue(player.onlineObservedAt) }; - }); - } return merged; } -export function hasSCUMPageQueries(pageKey: string): boolean { return pageQueries[canonicalPageKey(pageKey)].length > 0; } - -export async function requestSCUMPageQueries(actions: SCUMWorkspaceActions, pageKey: string): Promise { - if (!actions.dispatch) throw new Error("通用机器动作 dispatch 能力不可用。"); - return Promise.all(pageQueries[canonicalPageKey(pageKey)].map((queryKey) => actions.dispatch!({ - requestId: requestKey("scum-query", queryKey), - action: "remote.access.request", - payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database", "input.templateKey": queryKey } - }))); -} - export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise { const key = requiredKey(gift, "code", "礼包编号"); return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]); @@ -234,9 +202,9 @@ export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap payload: { eventId, eventType, class: eventClass, title: textValue(event.name) || eventId, placard: firstText(event, "placard", "announcement"), percent: boundedInteger(event.percent ?? event.probability, 0, 100, 100), - npc: boundedInteger(event.npc, 0, 10000, 0), item: boundedInteger(event.item, 0, 10000, 0), zombie: boundedInteger(event.zombie, 0, 10000, 0), animal: boundedInteger(event.animal, 0, 10000, 0), + npc: minimumInteger(event.npc, 0, 0), item: minimumInteger(event.item, 0, 0), zombie: minimumInteger(event.zombie, 0, 0), animal: minimumInteger(event.animal, 0, 0), produces: queuedProduces, - durationSeconds: boundedInteger(event.durationSeconds, 30, 86400, 1800), announce: event.announce !== false + durationSeconds: minimumInteger(event.durationSeconds, 1, 1800), announce: event.announce !== false }, idempotencyKey: runId, expiresAt: new Date(now + 5 * 60_000).toISOString() @@ -250,10 +218,9 @@ export function parseGiftItems(input: string): Array<{ catalogCode: string; quan const items = input.split(",").map((part) => { const [rawKey, rawQuantity, ...extra] = part.split(":").map((value) => value.trim()); const quantity = Number(rawQuantity); - if (!/^[A-Za-z0-9_.-]{1,128}$/.test(rawKey) || !rawQuantity || extra.length || !Number.isInteger(quantity) || quantity < 1 || quantity > 100) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:数量,数量范围 1-100。"); + if (!/^[A-Za-z0-9_.-]{1,128}$/.test(rawKey) || !rawQuantity || extra.length || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:正整数数量。"); return { catalogCode: rawKey, quantity }; }); - if (items.length > 8) throw new Error("单个礼包最多包含 8 项物品。"); return items; } @@ -308,9 +275,15 @@ function latestSnapshotPayload(response: unknown): RecordMap | undefined { } function snapshotOrder(snapshot: RecordMap): number { const observed = Date.parse(textValue(snapshot.observedAt)); return Number.isNaN(observed) ? Number(snapshot.sequence) || 0 : observed; } -function playerIndex(players: RecordMap[]): Map { const result = new Map(); players.forEach((player, index) => playerIdentities(player).forEach((identity) => result.set(identity, index))); return result; } -function findPlayer(players: RecordMap[], index: Map, player: RecordMap): { index: number } | undefined { for (const identity of playerIdentities(player)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } const name = firstText(player, "displayName", "playerName", "name").toLowerCase(); const found = players.findIndex((candidate) => firstText(candidate, "displayName", "playerName", "name").toLowerCase() === name); return found >= 0 && name ? { index: found } : undefined; } -function playerIdentities(player: RecordMap): string[] { return ["gamePlayerId", "playerId", "steamId", "userProfileId", "profileId", "id"].map((key) => textValue(player[key])).filter(Boolean); } +function playerIndex(players: RecordMap[]): Map { const result = new Map(); players.forEach((player, index) => addPlayerToIndex(result, player, index)); return result; } +function addPlayerToIndex(index: Map, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); } +function findPlayer(index: Map, player: RecordMap): { index: number } | undefined { for (const identity of playerIdentities(player)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } return undefined; } +function playerIdentities(player: RecordMap): string[] { + const identities = new Set(); + for (const key of ["gamePlayerId", "playerId", "steamId", "id"]) { const value = textValue(player[key]); if (value) identities.add(`player:${value}`); } + for (const key of ["userProfileId", "profileId"]) { const value = textValue(player[key]); if (value) identities.add(`profile:${value}`); } + return [...identities]; +} function onlineValue(player: RecordMap): boolean { const status = firstText(player, "status", "state").toLowerCase(); return booleanValue(player.online) || ["online", "active", "connected"].includes(status); } function booleanValue(value: unknown): boolean { return value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true"; } @@ -329,17 +302,19 @@ function requiredRecordKey(value: RecordMap, label: string): string { const key function boundedInteger(value: unknown, min: number, max: number, fallback: number): number { const number = Number(value); - return Number.isInteger(number) && number >= min && number <= max ? number : fallback; + return Number.isSafeInteger(number) && number >= min && number <= max ? number : fallback; } +function minimumInteger(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isSafeInteger(number) && number >= min ? number : fallback; } + function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> { if (value === undefined || value === null) return []; - if (!Array.isArray(value) || value.length > 8) throw new Error("礼包物品最多包含 8 项。"); + if (!Array.isArray(value)) throw new Error("礼包物品格式无效。"); return value.map((item) => { if (!isRecord(item)) throw new Error("礼包物品格式无效。"); const catalogCode = firstText(item, "catalogCode", "key"); const quantity = Number(item.quantity); - if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isInteger(quantity) || quantity < 1 || quantity > 100) throw new Error("礼包物品不符合 SCUM 目录代码或数量约束。"); + if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品不符合 SCUM 目录代码或正整数数量约束。"); return { catalogCode, quantity }; }); } @@ -350,15 +325,16 @@ function normalizeEventProduces(produces: RecordMap[]): RecordMap[] { return produces.map((produce) => ({ tradeGoodsId: firstText(produce, "tradeGoodsId"), percent: boundedInteger(produce.percent, 0, 100, 100), - value: boundedInteger(produce.value, 1, 10000, 1), - r: boundedNumber(produce.r, 0, 2000000, 0), - x: boundedNumber(produce.x, -2000000, 2000000, 0), - y: boundedNumber(produce.y, -2000000, 2000000, 0), - z: boundedNumber(produce.z, -2000000, 2000000, 0) + value: minimumInteger(produce.value, 1, 1), + r: minimumNumber(produce.r, 0, 0), + x: finiteNumber(produce.x, 0), + y: finiteNumber(produce.y, 0), + z: finiteNumber(produce.z, 0) })); } -function boundedNumber(value: unknown, min: number, max: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min && number <= max ? number : fallback; } +function minimumNumber(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min ? number : fallback; } +function finiteNumber(value: unknown, fallback: number): number { const number = Number(value); return Number.isFinite(number) ? number : fallback; } function safeCommandId(value: string): string { return value.replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 96); } function firstText(value: RecordMap, ...keys: string[]): string { for (const key of keys) { const result = textValue(value[key]); if (result) return result; } return ""; } diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index bcb59d8..963ee52 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -3,12 +3,10 @@ import { deleteEventDefinition, deleteEventProduce, emptySCUMSurfaceData, - hasSCUMPageQueries, loadSCUMSurface, parseGiftItems, parseGiftCommands, queueGiftDelivery, - requestSCUMPageQueries, resetGiftClaim, resetPendingGift, resolveMapBounds, @@ -63,6 +61,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [eventClass, setEventClass] = usePluginState(react, "1"); const [eventPlacard, setEventPlacard] = usePluginState(react, ""); const [eventPercent, setEventPercent] = usePluginState(react, "100"); + const [eventDuration, setEventDuration] = usePluginState(react, "1800"); const [eventNpc, setEventNpc] = usePluginState(react, "0"); const [eventItem, setEventItem] = usePluginState(react, "0"); const [eventZombie, setEventZombie] = usePluginState(react, "0"); @@ -103,32 +102,23 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" }); return; } - setState({ status: "loading" }); void loadSCUMSurface(input.workspaceActions, pageKey) .then((data) => setState({ status: "ready", data })) .catch((error) => setState({ status: "error", reason: errorMessage(error, "SCUM 插件数据读取失败。") })); }; - const syncMachine = () => { - if (!input.workspaceActions) return; - runAction(setAction, "正在提交声明式 SQLite 查询…", async () => { - const results = await requestSCUMPageQueries(input.workspaceActions!, pageKey); - const failed = results.find((result) => !["ok", "queued"].includes(result.status)); - if (failed) throw new Error(failed.error?.message || `机器查询状态:${failed.status}`); - return `已提交 ${results.length} 个声明式查询;结果写入集合后可重新读取。`; - }); - }; - - if (react.useEffect) react.useEffect(() => { refresh(); return undefined; }, [input.serverInstanceId, pageKey, input.workspaceActions]); + if (react.useEffect) react.useEffect(() => { + refresh(); + const interval = setInterval(refresh, 3000); + return () => clearInterval(interval); + }, [input.serverInstanceId, pageKey, input.workspaceActions]); const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))), e("div", { className: "console-row-actions" }, - e("span", { className: "page-status" }, input.availability.available ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion"), - e("button", { type: "button", className: "icon-command", onClick: refresh }, "重新读取"), - hasSCUMPageQueries(pageKey) ? e("button", { type: "button", className: "primary-command", disabled: !input.workspaceActions?.dispatch, onClick: syncMachine }, "同步 SCUM.db") : null + e("span", { className: "page-status" }, input.availability.available ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion") ) ), action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, @@ -137,7 +127,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, - eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, + eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber, deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapSearch, setMapSearch, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint, @@ -154,6 +144,7 @@ type ViewState = { eventId: string; setEventId: StateSetter; eventName: string; setEventName: StateSetter; eventType: string; setEventType: StateSetter; eventSchedule: string; setEventSchedule: StateSetter; eventClass: string; setEventClass: StateSetter; eventPlacard: string; setEventPlacard: StateSetter; eventPercent: string; setEventPercent: StateSetter; + eventDuration: string; setEventDuration: StateSetter; eventNpc: string; setEventNpc: StateSetter; eventItem: string; setEventItem: StateSetter; eventZombie: string; setEventZombie: StateSetter; eventAnimal: string; setEventAnimal: StateSetter; produceEventId: string; setProduceEventId: StateSetter; produceId: string; setProduceId: StateSetter; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter; producePercent: string; setProducePercent: StateSetter; produceValue: string; setProduceValue: StateSetter; produceRadius: string; setProduceRadius: StateSetter; produceX: string; setProduceX: StateSetter; produceY: string; setProduceY: StateSetter; produceZ: string; setProduceZ: StateSetter; @@ -238,8 +229,8 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, await saveEventDefinition(actions ?? {}, { id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(), placard: view.eventPlacard.trim(), announcement: view.eventPlacard.trim(), percent: integerInput(view.eventPercent, 100), probability: integerInput(view.eventPercent, 100), - npc: integerInput(view.eventNpc, 0), item: integerInput(view.eventItem, 0), zombie: integerInput(view.eventZombie, 0), animal: integerInput(view.eventAnimal, 0), - status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: 1800, updatedAt: new Date().toISOString() + npc: minimumIntegerInput(view.eventNpc, 0, "NPC 数量"), item: minimumIntegerInput(view.eventItem, 0, "物品数量"), zombie: minimumIntegerInput(view.eventZombie, 0, "僵尸数量"), animal: minimumIntegerInput(view.eventAnimal, 0, "动物数量"), + status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: minimumIntegerInput(view.eventDuration, 1, "活动持续秒数"), updatedAt: new Date().toISOString() }); view.refresh(); return `活动 ${name} 已保存。`; @@ -266,6 +257,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, e("input", { value: view.eventSchedule, "aria-label": "活动计划", placeholder: "Cron", onChange: (event: InputEvent) => view.setEventSchedule(inputValue(event)) }), e("input", { value: view.eventPlacard, "aria-label": "活动公告", placeholder: "活动开始公告", onChange: (event: InputEvent) => view.setEventPlacard(inputValue(event)) }), e("input", { value: view.eventPercent, "aria-label": "活动概率", type: "number", placeholder: "触发概率 %", onChange: (event: InputEvent) => view.setEventPercent(inputValue(event)) }), + e("input", { value: view.eventDuration, "aria-label": "活动持续秒数", type: "number", min: 1, placeholder: "持续秒数", onChange: (event: InputEvent) => view.setEventDuration(inputValue(event)) }), e("div", { className: "console-row-actions" }, e("input", { value: view.eventNpc, "aria-label": "NPC 数量", type: "number", placeholder: "NPC", onChange: (event: InputEvent) => view.setEventNpc(inputValue(event)) }), e("input", { value: view.eventItem, "aria-label": "物品数量", type: "number", placeholder: "物品", onChange: (event: InputEvent) => view.setEventItem(inputValue(event)) }), @@ -307,7 +299,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")), e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "primary-command", disabled: !actions?.gameClient || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"), - e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"), + e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除") ) ); @@ -436,14 +428,15 @@ function resettableGiftPanel(e: ReactLike["createElement"], title: string, rows: export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] { const direct = data.mapPoints.map((point) => ({ ...point, layer: textField(point, "layer", "subjectType", "type") || "other" })); - const players = data.players.flatMap((player) => withPosition(player, "players", textField(player, "displayName"), textField(player, "gamePlayerId", "steamId", "id"))); + const players = data.players.flatMap((player) => withPosition(player, "players", textField(player, "displayName"), textField(player, "steamId", "gamePlayerId", "id"))); const vehicles = data.vehicles.flatMap((vehicle) => withPosition(vehicle, "vehicles", textField(vehicle, "label", "name"), textField(vehicle, "vehicleId", "id"))); const flags = data.flags.flatMap((flag) => withPosition(flag, "flags", textField(flag, "name"), textField(flag, "flagId", "id"))); const regions = data.mapRegions.flatMap((region) => withPosition(region, "regions", textField(region, "name"), textField(region, "id", "regionId"))); const uniquePoints = new Map(); for (const point of [...direct, ...players, ...vehicles, ...flags, ...regions].filter(hasCoordinates)) { const key = mapPointIdentity(point); - if (!uniquePoints.has(key)) uniquePoints.set(key, point); + const current = uniquePoints.get(key); + uniquePoints.set(key, current ? { ...point, ...current, name: textField(point, "name") || textField(current, "name") } : point); } return [...uniquePoints.values()]; } @@ -485,6 +478,7 @@ function giftItemsInput(gift: RecordMap): string { const items = field(gift, "it function giftCommandsInput(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) ? commands.map((item) => isRecord(item) ? textField(item, "command", "value") : String(item)).filter(Boolean).join("\n") : ""; } function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record)[value] ?? value; } function integerInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : fallback; } +function minimumIntegerInput(value: string, minimum: number, label: string): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error(`${label}必须是不小于 ${minimum} 的整数。`); return parsed; } function numberInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function isRecord(value: unknown): value is RecordMap { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function errorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index de5c9ab..7306d66 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -127,11 +127,18 @@ "type": "announcement.send", "title": "Send SCUM announcement", "permission": "server.game-client.command", - "approvalLevel": "none", + "approvalLevel": "operator", "payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json", "resultSchemaRef": "schemas/bridge/announcement.result.schema.json", "timeoutSeconds": 60, - "maxPayloadBytes": 4096 + "maxPayloadBytes": 4096, + "protectedRequest": { + "kind": "rcon", + "transportKey": "scum-management", + "targetKey": "scum-management", + "textField": "requestText", + "maxTextBytes": 2048 + } }, { "type": "companion.diagnostics", @@ -293,9 +300,11 @@ "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", + "pollIntervalSeconds": 3, "rowTarget": { "collection": "scum_users", - "upsertKeys": ["userProfileId"], + "upsertKeys": ["steamId"], + "writeMode": "merge", "columnMappings": { "userProfileId": "userProfileId", "steamId": "steamId", "gamePlayerId": "gamePlayerId", "displayName": "displayName", "squadId": "squadId", "squadName": "squadName", "famePoints": "famePoints", "normalBalance": "normalBalance", "goldBalance": "goldBalance", "x": "x", "y": "y", "z": "z", "lastLoginTime": "lastLoginTime", "lastSaveTime": "lastSaveTime" } }, "maxRows": 500, @@ -311,9 +320,11 @@ "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", + "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_squads", "upsertKeys": ["squadId"], + "writeMode": "replace", "columnMappings": { "squadId": "squadId", "name": "name", "leaderProfileId": "leaderProfileId", "leaderPlayerId": "leaderPlayerId", "memberCount": "memberCount", "score": "score", "memberLimit": "memberLimit", "message": "message", "info": "info", "lastMemberLoginTime": "lastMemberLoginTime" } }, "maxRows": 500, @@ -329,9 +340,11 @@ "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", + "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_squad_members", - "upsertKeys": ["squadId", "userProfileId"], + "upsertKeys": ["squadId", "steamId"], + "writeMode": "replace", "columnMappings": { "squadId": "squadId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "steamId": "steamId", "displayName": "displayName", "rank": "rank", "isLeader": "isLeader" } }, "maxRows": 500, @@ -347,9 +360,11 @@ "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", + "pollIntervalSeconds": 3, "rowTarget": { "collection": "scum_vehicles", "upsertKeys": ["vehicleId"], + "writeMode": "replace", "columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" } }, "maxRows": 500, @@ -365,9 +380,11 @@ "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", + "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_flags", "upsertKeys": ["flagId"], + "writeMode": "replace", "columnMappings": { "flagId": "flagId", "entityId": "entityId", "baseId": "baseId", "ownerProfileId": "ownerProfileId", "ownerPlayerId": "ownerPlayerId", "ownerSquadId": "ownerSquadId", "ownerSquadName": "ownerSquadName", "overtakerProfileId": "overtakerProfileId", "overtakeEndTime": "overtakeEndTime", "ownershipConfidence": "ownershipConfidence", "x": "x", "y": "y", "z": "z" } }, "maxRows": 500, @@ -383,9 +400,11 @@ "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", + "pollIntervalSeconds": 3, "rowTarget": { "collection": "scum_map_points", "upsertKeys": ["subjectType", "subjectId"], + "writeMode": "replace", "columnMappings": { "subjectType": "subjectType", "subjectId": "subjectId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "vehicleId": "vehicleId", "entityId": "entityId", "baseId": "baseId", "x": "x", "y": "y", "z": "z", "observedAt": "observedAt" } }, "maxRows": 500, @@ -401,9 +420,11 @@ "parameterSchemaRef": "schemas/bridge/queries/scum-tasks.parameters.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-tasks.result.schema.json", "sqlRef": "sql/scum-db-v57/tasks.sql", + "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_tasks", "upsertKeys": ["taskRecordId"], + "writeMode": "replace", "columnMappings": { "taskRecordId": "taskRecordId", "taskKind": "taskKind", "userProfileId": "userProfileId", "mapId": "mapId", "trackingDataSetId": "trackingDataSetId", "dataAssetPath": "dataAssetPath", "sequenceIndex": "sequenceIndex", "isTracked": "isTracked", "state": "state", "completionDeadline": "completionDeadline" } }, "maxRows": 500, @@ -419,9 +440,11 @@ "parameterSchemaRef": "schemas/bridge/queries/scum-events.parameters.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-events.result.schema.json", "sqlRef": "sql/scum-db-v57/events.sql", + "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_native_event_rounds", "upsertKeys": ["eventRecordId"], + "writeMode": "replace", "columnMappings": { "eventRecordId": "eventRecordId", "eventId": "eventId", "roundId": "roundId", "userProfileId": "userProfileId", "startTime": "startTime", "endTime": "endTime", "state": "state", "score": "score", "enemyKills": "enemyKills", "teamKills": "teamKills", "deaths": "deaths", "assists": "assists", "headshots": "headshots" } }, "maxRows": 500, @@ -437,15 +460,54 @@ "parameterSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.result.schema.json", "sqlRef": "sql/scum-db-v57/native-timed-gifts.sql", + "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_timed_gift_events", "upsertKeys": ["timedGiftId"], + "writeMode": "replace", "columnMappings": { "timedGiftId": "timedGiftId", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt" } }, "maxRows": 500, "timeoutSeconds": 15 } ], + "logProjections": [ + { + "key": "scum.battleye.login", + "streamKeys": ["scum.console.stdout"], + "steps": [ + { "pattern": "Player \"(?P[^\"]+)\" reported as player (?P\\d+)" }, + { "pattern": "Player (?P\\d+) SteamID \\(assumed\\): (?P\\d+)" } + ], + "correlationFields": ["slot"], + "maxInterveningLines": 8, + "target": { + "collection": "scum_users", + "upsertKeys": ["steamId"], + "captureMappings": { "steamId": "steamId", "displayName": "displayName", "slot": "slot" }, + "fixedValues": { "online": "true", "source": "process.stdout" }, + "observedAtField": "lastLoginObservedAt" + }, + "presence": { + "timestampField": "lastLoginObservedAt", + "activeWindowSeconds": 600, + "activityTarget": { + "collection": "scum_activity_events", + "upsertKeys": ["steamId", "observedAt"], + "captureMappings": { "steamId": "steamId", "displayName": "displayName" }, + "fixedValues": { "eventType": "login", "source": "process.stdout" }, + "observedAtField": "observedAt" + }, + "announcement": { + "profileKey": "scum-client-manager", + "commandType": "announcement.send", + "textField": "requestText", + "newTextTemplate": "#announce 欢迎新玩家 {{displayName}} 加入服务器!", + "returningTextTemplate": "#announce 欢迎 {{displayName}} 继续游戏!" + } + } + } + ], "dataPacks": [ { "key": "scum-db-v57", diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/announcement.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/announcement.payload.schema.json index 3774c54..cc7c7bd 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/announcement.payload.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/announcement.payload.schema.json @@ -3,12 +3,12 @@ "title": "SCUMAnnouncementPayload", "type": "object", "additionalProperties": false, - "required": ["message"], + "required": ["requestText"], "properties": { - "message": { + "requestText": { "type": "string", "minLength": 1, - "maxLength": 500 + "maxLength": 2048 } } } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/event-start.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/event-start.payload.schema.json index 2c222ba..e13b5d9 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/event-start.payload.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/event-start.payload.schema.json @@ -23,13 +23,11 @@ }, "durationSeconds": { "type": "integer", - "minimum": 30, - "maximum": 86400 + "minimum": 1 }, "maxParticipants": { "type": "integer", - "minimum": 1, - "maximum": 1000 + "minimum": 1 }, "announce": { "type": "boolean" @@ -43,13 +41,12 @@ "minimum": 0, "maximum": 100 }, - "npc": { "type": "integer", "minimum": 0, "maximum": 10000 }, - "item": { "type": "integer", "minimum": 0, "maximum": 10000 }, - "zombie": { "type": "integer", "minimum": 0, "maximum": 10000 }, - "animal": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "npc": { "type": "integer", "minimum": 0 }, + "item": { "type": "integer", "minimum": 0 }, + "zombie": { "type": "integer", "minimum": 0 }, + "animal": { "type": "integer", "minimum": 0 }, "produces": { "type": "array", - "maxItems": 100, "items": { "type": "object", "additionalProperties": false, @@ -57,11 +54,11 @@ "properties": { "tradeGoodsId": { "type": "string", "minLength": 1, "maxLength": 128 }, "percent": { "type": "integer", "minimum": 0, "maximum": 100 }, - "value": { "type": "integer", "minimum": 1, "maximum": 10000 }, - "r": { "type": "number", "minimum": 0, "maximum": 2000000 }, - "x": { "type": "number", "minimum": -2000000, "maximum": 2000000 }, - "y": { "type": "number", "minimum": -2000000, "maximum": 2000000 }, - "z": { "type": "number", "minimum": -2000000, "maximum": 2000000 } + "value": { "type": "integer", "minimum": 1 }, + "r": { "type": "number", "minimum": 0 }, + "x": { "type": "number" }, + "y": { "type": "number" }, + "z": { "type": "number" } } } }, 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 4e23f59..bf36882 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 @@ -14,7 +14,7 @@ "required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"], "properties": { "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, "squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json index 1fe07b7..9aebe41 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/scum-squad-members.result.schema.json @@ -16,7 +16,7 @@ "squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, - "steamId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, + "steamId": { "type": "string", "minLength": 1, "maxLength": 96 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, "rank": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 }, "isLeader": { "type": "integer", "minimum": 0, "maximum": 1 } diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json index 58c0a0c..6fa8d2d 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/bridge/reward-deliver.payload.schema.json @@ -17,20 +17,18 @@ }, "items": { "type": "array", - "maxItems": 8, "items": { "type": "object", "additionalProperties": false, "required": ["catalogCode", "quantity"], "properties": { "catalogCode": { "type": "string", "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]{1,128}$" }, - "quantity": { "type": "integer", "minimum": 1, "maximum": 100 } + "quantity": { "type": "integer", "minimum": 1 } } } }, "operations": { "type": "array", - "maxItems": 1000, "items": { "type": "string", "minLength": 1, 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 index d33132b..ba6da71 100644 --- 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 @@ -1,6 +1,6 @@ SELECT 'player' AS subjectType, - CAST(profile.id AS TEXT) AS subjectId, + account.id AS subjectId, CAST(profile.id AS TEXT) AS userProfileId, CAST(prisoner.id AS TEXT) AS gamePlayerId, NULL AS vehicleId, @@ -11,11 +11,12 @@ SELECT entity.location_z AS z, strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt FROM user_profile profile +JOIN user account ON account.id = profile.user_id 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') - AND (:subjectId IS NULL OR CAST(profile.id AS TEXT) = :subjectId) + AND (:subjectId IS NULL OR account.id = :subjectId) UNION ALL SELECT 'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL, 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 index 9cbcadb..aae3310 100644 --- 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 @@ -8,7 +8,7 @@ SELECT 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 account ON account.id = profile.user_id +JOIN user account ON account.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 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 index dfc9b89..f842320 100644 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/squads.sql @@ -1,8 +1,8 @@ SELECT CAST(squad.id AS TEXT) AS squadId, COALESCE(squad.name, '') AS name, - CAST(leader.user_profile_id AS TEXT) AS leaderProfileId, - CAST(leader_profile.prisoner_id AS TEXT) AS leaderPlayerId, + MAX(CASE WHEN member.rank = 4 THEN CAST(member.user_profile_id AS TEXT) END) AS leaderProfileId, + MAX(CASE WHEN member.rank = 4 THEN CAST(member_profile.prisoner_id AS TEXT) END) AS leaderPlayerId, COUNT(member.id) AS memberCount, squad.score AS score, squad.member_limit AS memberLimit, @@ -11,8 +11,7 @@ SELECT squad.last_member_login_time AS lastMemberLoginTime FROM squad LEFT JOIN squad_member member ON member.squad_id = squad.id -LEFT JOIN squad_member leader ON leader.squad_id = squad.id AND leader.rank = 4 -LEFT JOIN user_profile leader_profile ON leader_profile.id = leader.user_profile_id +LEFT JOIN user_profile member_profile ON member_profile.id = member.user_profile_id WHERE (:squadId IS NULL OR CAST(squad.id AS TEXT) = :squadId) AND (:search IS NULL OR COALESCE(squad.name, '') LIKE '%' || :search || '%') GROUP BY squad.id 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 index fcbc7a5..43b9c91 100644 --- a/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql +++ b/plugins/examples/scum-server-plugin/sql/scum-db-v57/users.sql @@ -2,7 +2,7 @@ SELECT CAST(profile.id AS TEXT) AS userProfileId, account.id AS steamId, CAST(prisoner.id AS TEXT) AS gamePlayerId, - COALESCE(profile.name, account.name, '') AS displayName, + COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) AS displayName, CAST(member.squad_id AS TEXT) AS squadId, squad.name AS squadName, profile.fame_points AS famePoints, @@ -13,8 +13,8 @@ SELECT entity.location_z AS z, profile.last_login_time AS lastLoginTime, strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS lastSaveTime -FROM user_profile profile -JOIN user account ON account.id = profile.user_id +FROM user account +LEFT JOIN user_profile profile ON profile.user_id = account.id LEFT JOIN prisoner ON prisoner.id = profile.prisoner_id LEFT JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id LEFT JOIN entity ON entity.id = prisoner_entity.entity_id @@ -24,7 +24,7 @@ LEFT JOIN bank_account_registry bank ON bank.account_owner_user_profile_id = pro LEFT JOIN bank_account_registry_currencies currency ON currency.bank_account_id = bank.id WHERE (:userProfileId IS NULL OR CAST(profile.id AS TEXT) = :userProfileId) AND (:steamId IS NULL OR account.id = :steamId) - AND (:search IS NULL OR COALESCE(profile.name, account.name, '') LIKE '%' || :search || '%') -GROUP BY profile.id + AND (:search IS NULL OR COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) LIKE '%' || :search || '%') +GROUP BY account.id ORDER BY profile.last_login_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 132c78a..9ce551d 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -259,6 +259,11 @@ "items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" }, "maxItems": 128 }, + "logProjections": { + "type": "array", + "items": { "$ref": "#/$defs/gameClientBridgeLogProjection" }, + "maxItems": 128 + }, "dataPacks": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeDataPack" }, @@ -356,17 +361,76 @@ "sqlRef": { "$ref": "#/$defs/relativeSqlRef" }, "rowTarget": { "$ref": "#/$defs/pluginDataRowTarget" }, "maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, - "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 } + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }, + "pollIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 } } }, "pluginDataRowTarget": { "type": "object", - "required": ["collection", "upsertKeys", "columnMappings"], + "required": ["collection", "upsertKeys", "columnMappings", "writeMode"], "additionalProperties": false, "properties": { "collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" }, "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}$" } } + "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}$" } }, + "writeMode": { "enum": ["merge", "replace"] } + } + }, + "gameClientBridgeLogProjection": { + "type": "object", + "required": ["key", "streamKeys", "steps", "correlationFields", "maxInterveningLines", "target"], + "additionalProperties": false, + "properties": { + "key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, + "streamKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 }, + "steps": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeLogProjectionStep" }, "minItems": 1, "maxItems": 64 }, + "correlationFields": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 }, + "maxInterveningLines": { "type": "integer", "minimum": 0, "maximum": 100000 }, + "target": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" }, + "presence": { "$ref": "#/$defs/gameClientBridgeLogProjectionPresence" } + } + }, + "gameClientBridgeLogProjectionStep": { + "type": "object", + "required": ["pattern"], + "additionalProperties": false, + "properties": { + "pattern": { "type": "string", "minLength": 1, "maxLength": 16384 } + } + }, + "gameClientBridgeLogProjectionTarget": { + "type": "object", + "required": ["collection", "upsertKeys", "captureMappings"], + "additionalProperties": false, + "properties": { + "collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" }, + "upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 }, + "captureMappings": { "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}$" } }, + "fixedValues": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } }, + "observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } + } + }, + "gameClientBridgeLogProjectionPresence": { + "type": "object", + "required": ["timestampField", "activeWindowSeconds", "announcement"], + "additionalProperties": false, + "properties": { + "timestampField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, + "activeWindowSeconds": { "type": "integer", "minimum": 1, "maximum": 31536000 }, + "activityTarget": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" }, + "announcement": { "$ref": "#/$defs/gameClientBridgeLogProjectionAnnouncement" } + } + }, + "gameClientBridgeLogProjectionAnnouncement": { + "type": "object", + "required": ["profileKey", "commandType", "textField", "newTextTemplate", "returningTextTemplate"], + "additionalProperties": false, + "properties": { + "profileKey": { "$ref": "#/$defs/logicalKey" }, + "commandType": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, + "textField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, + "newTextTemplate": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "returningTextTemplate": { "type": "string", "minLength": 1, "maxLength": 4096 } } }, "gameClientBridgeDataPack": { diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index bf616b9..f5d287c 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -17,6 +17,19 @@ function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined): return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`); } +function extractNamedCaptureNames(pattern: string): string[] { + const captures: string[] = []; + const capturePattern = /\(\?(?:P)?<([A-Za-z][A-Za-z0-9_]*)>/g; + for (const match of pattern.matchAll(capturePattern)) { + captures.push(match[1]); + } + return captures; +} + +function normalizeNamedCaptureSyntax(pattern: string): string { + return pattern.replace(/\(\?P<([A-Za-z][A-Za-z0-9_]*)>/g, "(?<$1>"); +} + function unsafeFieldReason(fieldName: string): string | undefined { const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, ""); if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) { @@ -673,9 +686,25 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { parameterSchemaRef?: string; resultSchemaRef?: string; sqlRef?: string; - rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record }; + rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record; writeMode?: string }; maxRows?: number; timeoutSeconds?: number; + pollIntervalSeconds?: number; + }; + type BridgeLogProjectionTarget = { collection?: string; upsertKeys?: string[]; captureMappings?: Record; fixedValues?: Record; observedAtField?: string }; + type BridgeLogProjection = { + key?: string; + streamKeys?: string[]; + steps?: Array<{ pattern?: string }>; + correlationFields?: string[]; + maxInterveningLines?: number; + target?: BridgeLogProjectionTarget; + presence?: { + timestampField?: string; + activeWindowSeconds?: number; + activityTarget?: BridgeLogProjectionTarget; + announcement?: { profileKey?: string; commandType?: string; textField?: string; newTextTemplate?: string; returningTextTemplate?: string }; + }; }; type BridgeOperationSafety = { requiresApproval?: boolean; requiresOfflinePlayer?: boolean; requiresMaintenanceWindow?: boolean; requiresBeforeValue?: boolean; requiresConfirmation?: boolean; backupRequired?: boolean }; type BridgeOperationMutation = { fieldKey?: string; tableKey?: string; identityKey?: string; valueKey?: string; confirmationQueryKey?: string; allowedValueType?: string; minValue?: number; maxValue?: number }; @@ -718,7 +747,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; pages?: PluginPage[]; runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] }; - gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion }; + gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; logProjections?: BridgeLogProjection[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion }; }; const bridge = declaration.gameClientBridge; if (!bridge) { @@ -728,6 +757,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { const commands = new Set(); const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? "")); const queryTemplates = new Map(); + const logProjections = new Set(); const operationTemplates = new Map(); const declaredPermissions = new Set(declaration.permissions ?? []); const declaredCapabilities = new Set(declaration.capabilities ?? []); @@ -851,6 +881,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { const mappings = target?.columnMappings; if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([destination, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(destination) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) errors.push(`${location}.rowTarget.columnMappings: projected queries require safe field mappings`); if (mappings && Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => key in mappings)) errors.push(`${location}.rowTarget.upsertKeys: every upsert key must be declared in columnMappings`); + if (!new Set(["merge", "replace"]).has(target?.writeMode ?? "")) errors.push(`${location}.rowTarget.writeMode: projected queries require merge or replace`); } 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`); @@ -858,6 +889,9 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { if (!Number.isInteger(queryTemplate.timeoutSeconds) || (queryTemplate.timeoutSeconds ?? 0) < 1 || (queryTemplate.timeoutSeconds ?? 0) > 60) { errors.push(`${location}.timeoutSeconds: must be an integer between 1 and 60`); } + if (!Number.isInteger(queryTemplate.pollIntervalSeconds ?? 0) || (queryTemplate.pollIntervalSeconds ?? 0) < 0 || (queryTemplate.pollIntervalSeconds ?? 0) > 86400) { + errors.push(`${location}.pollIntervalSeconds: must be 0 or an integer between 1 and 86400`); + } const transportProfile = transportProfiles.find((profile) => profile.key === queryTemplate.transportKey); if (!transportProfile) { errors.push(`${location}.transportKey: undeclared transport profile ${queryTemplate.transportKey ?? ""}`); @@ -876,6 +910,71 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`); } } + const captureNamePattern = /^[A-Za-z][A-Za-z0-9_]{0,79}$/; + const fieldNamePattern = /^[A-Za-z][A-Za-z0-9._-]{0,79}$/; + const collectionPattern = /^[A-Za-z][A-Za-z0-9._-]{0,119}$/; + const validateProjectionTarget = (location: string, target: BridgeLogProjectionTarget | undefined, captures: Set): string[] => { + const targetErrors: string[] = []; + if (!target || !collectionPattern.test(target.collection ?? "")) targetErrors.push(`${location}.collection: must be a safe collection`); + if (!Array.isArray(target?.upsertKeys) || target.upsertKeys.length < 1 || target.upsertKeys.length > 8 || !target.upsertKeys.every((key) => fieldNamePattern.test(key))) targetErrors.push(`${location}.upsertKeys: must contain 1 to 8 safe fields`); + const mappings = target?.captureMappings; + if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length < 1 || Object.keys(mappings).length > 64) { + targetErrors.push(`${location}.captureMappings: must contain 1 to 64 mappings`); + } else { + for (const [destination, capture] of Object.entries(mappings)) { + if (!fieldNamePattern.test(destination) || typeof capture !== "string" || !captureNamePattern.test(capture)) targetErrors.push(`${location}.captureMappings: contains an invalid field or capture`); + if (!captures.has(capture)) targetErrors.push(`${location}.captureMappings: references undeclared capture ${capture}`); + } + } + const fixedValues = target?.fixedValues ?? {}; + if (Array.isArray(fixedValues) || Object.keys(fixedValues).length > 64 || !Object.entries(fixedValues).every(([destination, value]) => fieldNamePattern.test(destination) && typeof value === "string" && value.length <= 4096)) targetErrors.push(`${location}.fixedValues: contains an invalid field or value`); + const declaredFields = new Set([...Object.keys(mappings ?? {}), ...Object.keys(fixedValues)]); + if (Object.keys(mappings ?? {}).some((field) => Object.prototype.hasOwnProperty.call(fixedValues, field))) targetErrors.push(`${location}: a field cannot be declared by both captureMappings and fixedValues`); + if (target?.observedAtField && (!fieldNamePattern.test(target.observedAtField) || declaredFields.has(target.observedAtField))) targetErrors.push(`${location}.observedAtField: must be a safe unique field`); + if (target?.observedAtField) declaredFields.add(target.observedAtField); + if (Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => declaredFields.has(key))) targetErrors.push(`${location}.upsertKeys: every key must be projected`); + return targetErrors; + }; + for (const [index, projection] of (bridge.logProjections ?? []).entries()) { + const location = `manifest.gameClientBridge.logProjections[${index}]`; + const key = projection.key ?? ""; + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key)) errors.push(`${location}.key: log projection key is unsafe`); + if (logProjections.has(key)) errors.push(`${location}.key: duplicate log projection ${key}`); + logProjections.add(key); + if (!Array.isArray(projection.streamKeys) || projection.streamKeys.length < 1 || projection.streamKeys.length > 64 || new Set(projection.streamKeys).size !== projection.streamKeys.length || !projection.streamKeys.every((streamKey) => /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(streamKey))) errors.push(`${location}.streamKeys: must contain 1 to 64 unique safe streams`); + const captures = new Set(); + if (!Array.isArray(projection.steps) || projection.steps.length < 1 || projection.steps.length > 64) { + errors.push(`${location}.steps: must contain 1 to 64 regular expressions`); + } else { + for (const [stepIndex, step] of projection.steps.entries()) { + try { + if (!step.pattern || step.pattern.length > 16384) throw new Error("invalid"); + new RegExp(normalizeNamedCaptureSyntax(step.pattern)); + for (const capture of extractNamedCaptureNames(step.pattern)) captures.add(capture); + } catch { + errors.push(`${location}.steps[${stepIndex}].pattern: must be a valid bounded regular expression`); + } + } + } + if (!Array.isArray(projection.correlationFields) || projection.correlationFields.length < 1 || projection.correlationFields.length > 64 || new Set(projection.correlationFields).size !== projection.correlationFields.length || !projection.correlationFields.every((field) => captureNamePattern.test(field) && captures.has(field))) errors.push(`${location}.correlationFields: must reference unique named captures`); + if (!Number.isInteger(projection.maxInterveningLines) || (projection.maxInterveningLines ?? -1) < 0 || (projection.maxInterveningLines ?? 0) > 100000) errors.push(`${location}.maxInterveningLines: must be between 0 and 100000`); + errors.push(...validateProjectionTarget(`${location}.target`, projection.target, captures)); + const presence = projection.presence; + if (!presence) continue; + const target = projection.target; + const targetFields = new Set([...Object.keys(target?.captureMappings ?? {}), ...Object.keys(target?.fixedValues ?? {}), ...(target?.observedAtField ? [target.observedAtField] : [])]); + if (!fieldNamePattern.test(presence.timestampField ?? "") || !targetFields.has(presence.timestampField ?? "")) errors.push(`${location}.presence.timestampField: must reference a projected target field`); + if (!Number.isInteger(presence.activeWindowSeconds) || (presence.activeWindowSeconds ?? 0) < 1 || (presence.activeWindowSeconds ?? 0) > 31536000) errors.push(`${location}.presence.activeWindowSeconds: must be between 1 and 31536000`); + if (presence.activityTarget) errors.push(...validateProjectionTarget(`${location}.presence.activityTarget`, presence.activityTarget, captures)); + const announcement = presence.announcement; + const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === announcement?.profileKey && candidate.health?.requiredCapabilities?.includes("game-client.bridge")); + if (!manager) errors.push(`${location}.presence.announcement.profileKey: must reference a declared game-client bridge profile`); + const command = (bridge.commands ?? []).find((candidate) => candidate.type === announcement?.commandType); + if (!command) errors.push(`${location}.presence.announcement.commandType: must reference a declared command`); + if (!fieldNamePattern.test(announcement?.textField ?? "") || (command?.protectedRequest && command.protectedRequest.textField !== announcement?.textField)) errors.push(`${location}.presence.announcement.textField: must be safe and match the command protected request`); + if (!announcement?.newTextTemplate || announcement.newTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.newTextTemplate: must be a non-empty bounded template`); + if (!announcement?.returningTextTemplate || announcement.returningTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.returningTextTemplate: must be a non-empty bounded template`); + } for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) { const location = `manifest.gameClientBridge.operationTemplates[${index}]`; const key = operationTemplate.key ?? ""; diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index f4e7f62..1966a28 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -265,6 +265,7 @@ export interface GameClientBridgeQueryTemplateDeclaration { sqlRef?: string; maxRows: number; timeoutSeconds: number; + pollIntervalSeconds?: number; rowTarget?: PluginDataRowTargetDeclaration; } @@ -272,6 +273,44 @@ export interface PluginDataRowTargetDeclaration { collection: string; upsertKeys: string[]; columnMappings: Record; + writeMode: "merge" | "replace"; +} + +export interface GameClientBridgeLogProjectionStepDeclaration { + pattern: string; +} + +export interface GameClientBridgeLogProjectionTargetDeclaration { + collection: string; + upsertKeys: string[]; + captureMappings: Record; + fixedValues?: Record; + observedAtField?: string; +} + +export interface GameClientBridgeLogProjectionAnnouncementDeclaration { + profileKey: string; + commandType: string; + textField: string; + newTextTemplate: string; + returningTextTemplate: string; +} + +export interface GameClientBridgeLogProjectionPresenceDeclaration { + timestampField: string; + activeWindowSeconds: number; + activityTarget?: GameClientBridgeLogProjectionTargetDeclaration; + announcement: GameClientBridgeLogProjectionAnnouncementDeclaration; +} + +export interface GameClientBridgeLogProjectionDeclaration { + key: string; + streamKeys: string[]; + steps: GameClientBridgeLogProjectionStepDeclaration[]; + correlationFields: string[]; + maxInterveningLines: number; + target: GameClientBridgeLogProjectionTargetDeclaration; + presence?: GameClientBridgeLogProjectionPresenceDeclaration; } export interface GameClientBridgeDataPackDeclaration { @@ -352,6 +391,7 @@ export interface GameClientBridgeManifest { commands: GameClientBridgeCommandDeclaration[]; snapshots: GameClientBridgeSnapshotDeclaration[]; queryTemplates?: GameClientBridgeQueryTemplateDeclaration[]; + logProjections?: GameClientBridgeLogProjectionDeclaration[]; dataPacks?: GameClientBridgeDataPackDeclaration[]; operationTemplates?: GameClientBridgeOperationTemplateDeclaration[]; commandRetentionSeconds: number; diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index b747696..c283332 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -26,6 +26,7 @@ import { parseBridgeExecutionResponse, parseAIInvocationResponse, type GameClientBridgeQueryTemplateDeclaration, + type GameClientBridgeLogProjectionDeclaration, type GameClientBridgeOperationTemplateDeclaration, type GameClientBridgeProtectedRequestDeclaration, type GameClientBridgeCompanionDeclaration, @@ -35,7 +36,7 @@ import { type PluginLifecycleActionDeclaration, type PluginBridgeContext } from "../sdk/index.js"; -import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js"; +import { validateGameClientBridgeCatalog, validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js"; const pluginsRoot = fileURLToPath(new URL("..", import.meta.url)); @@ -190,11 +191,13 @@ describe("plugin manifest validation", () => { expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]); }); - it("removes raw protected SQL and management request command surfaces", () => { + it("removes raw SQL command surfaces and keeps announcements as a typed protected RCON request", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); - const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; protectedRequest?: { kind: string } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } }; + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; protectedRequest?: { kind: string; transportKey: string; targetKey: string; textField: string; maxTextBytes: number } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } }; const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest); - expect(commands).toEqual([]); + expect(commands).toEqual([expect.objectContaining({ type: "announcement.send", protectedRequest: { kind: "rcon", transportKey: "scum-management", targetKey: "scum-management", textField: "requestText", maxTextBytes: 2048 } })]); + const announcementPayload = JSON.parse(fs.readFileSync(path.join(pluginDir, commands[0].payloadSchemaRef), "utf8")); + expect(announcementPayload).toMatchObject({ required: ["requestText"], properties: { requestText: { type: "string", minLength: 1, maxLength: 2048 } } }); expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"])); expect(manifest.gameClientBridge.queryTemplates.map((query) => query.key)).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"])); expect(manifest.gameClientBridge.operationTemplates.map((operation) => operation.key)).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver", "player.attribute.855.set"])); @@ -202,6 +205,23 @@ describe("plugin manifest validation", () => { expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true); }); + it("declares BattlEye login projection, presence deduplication, and plugin-owned welcome messages", () => { + const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any; + const projection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.battleye.login"); + expect(projection).toMatchObject({ + streamKeys: ["scum.console.stdout"], correlationFields: ["slot"], maxInterveningLines: 8, + target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", displayName: "displayName", slot: "slot" }, fixedValues: { online: "true", source: "process.stdout" }, observedAtField: "lastLoginObservedAt" }, + presence: { timestampField: "lastLoginObservedAt", activeWindowSeconds: 600, activityTarget: { collection: "scum_activity_events", upsertKeys: ["steamId", "observedAt"], captureMappings: { steamId: "steamId", displayName: "displayName" }, fixedValues: { eventType: "login", source: "process.stdout" }, observedAtField: "observedAt" }, announcement: { profileKey: "scum-client-manager", commandType: "announcement.send", textField: "requestText", newTextTemplate: "#announce 欢迎新玩家 {{displayName}} 加入服务器!", returningTextTemplate: "#announce 欢迎 {{displayName}} 继续游戏!" } } + }); + expect(projection.steps.map((step: { pattern: string }) => step.pattern)).toEqual([ + 'Player "(?P[^\"]+)" reported as player (?P\\d+)', + "Player (?P\\d+) SteamID \\(assumed\\): (?P\\d+)" + ]); + const compile = (pattern: string) => new RegExp(pattern.replaceAll("(?P<", "(?<")); + expect(compile(projection.steps[0].pattern).exec('LogBattlEye: Display: Player "love_fitting" reported as player 0')?.groups).toMatchObject({ displayName: "love_fitting", slot: "0" }); + expect(compile(projection.steps[1].pattern).exec("LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111")?.groups).toMatchObject({ slot: "0", steamId: "76561199510658111" }); + }); + it("declares SCUM install/update and start lifecycle through plugin assets", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any; @@ -525,7 +545,7 @@ describe("plugin manifest validation", () => { }; }; const expected = { - "announcement.send": { permission: "server.game-client.command", approvalLevel: "none" }, + "announcement.send": { permission: "server.game-client.command", approvalLevel: "operator" }, "companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" }, "player.lookup": { permission: "server.game-client.read", approvalLevel: "none" }, "reward.deliver": { permission: "server.game-client.command", approvalLevel: "none" }, @@ -552,6 +572,7 @@ describe("plugin manifest validation", () => { const schemaRefs = manifest.gameClientBridge.commands.flatMap((command) => [command.payloadSchemaRef, command.resultSchemaRef].filter((ref): ref is string => Boolean(ref))); for (const schemaRef of schemaRefs) { const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record; + const hasPluginInventedCountLimitsRemoved = ["schemas/bridge/reward-deliver.payload.schema.json", "schemas/bridge/event-start.payload.schema.json"].includes(schemaRef); const visit = (value: unknown): void => { if (Array.isArray(value)) { value.forEach(visit); @@ -565,13 +586,15 @@ describe("plugin manifest validation", () => { expect(record.additionalProperties).toBe(false); } if (record.type === "array") { - expect(record.maxItems).toBeGreaterThan(0); + expect(record.items).toBeDefined(); + if (!hasPluginInventedCountLimitsRemoved) expect(record.maxItems).toBeGreaterThan(0); } if (record.type === "string") { expect(record.maxLength).toBeGreaterThan(0); } if (record.type === "integer" || record.type === "number") { - expect(record.maximum).toBeDefined(); + if (!hasPluginInventedCountLimitsRemoved) expect(record.maximum).toBeDefined(); + if (typeof record.minimum === "number" && typeof record.maximum === "number") expect(record.minimum).toBeLessThanOrEqual(record.maximum); } Object.values(record).forEach(visit); }; @@ -634,7 +657,8 @@ describe("plugin manifest validation", () => { parameterSchemaRef: string; resultSchemaRef: string; sqlRef: string; - rowTarget: { collection: string; upsertKeys: string[]; columnMappings: Record }; + pollIntervalSeconds: number; + rowTarget: { collection: string; upsertKeys: string[]; writeMode: "merge" | "replace"; columnMappings: Record }; maxRows: number; timeoutSeconds: number; }>; @@ -655,6 +679,7 @@ describe("plugin manifest validation", () => { "scum.events": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"], "scum.native-timed-gifts": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"] }; + const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "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"); @@ -670,6 +695,8 @@ describe("plugin manifest validation", () => { expect(template.targetKey).toBe("scum-database"); expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/); expect(template.rowTarget.collection).toMatch(/^scum_/); + expect(template.pollIntervalSeconds).toBe(fastTemplates.has(key) ? 3 : 1800); + expect(template.rowTarget.writeMode).toBe(key === "scum.player.profile" ? "merge" : "replace"); expect(template.rowTarget.upsertKeys.length).toBeGreaterThan(0); expect(template.rowTarget.upsertKeys.every((upsertKey) => upsertKey in template.rowTarget.columnMappings)).toBe(true); expect(fs.existsSync(path.join(pluginDir, template.sqlRef))).toBe(true); @@ -688,6 +715,12 @@ describe("plugin manifest validation", () => { expect(sql).toMatch(new RegExp(`\\bAS\\s+${column}\\b`, "i")); } } + expect(templatesByKey.get("scum.player.profile")?.rowTarget.upsertKeys).toEqual(["steamId"]); + expect(templatesByKey.get("scum.squad-members")?.rowTarget.upsertKeys).toEqual(["squadId", "steamId"]); + const userSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.player.profile")!.sqlRef), "utf8"); + const positionSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.positions")!.sqlRef), "utf8"); + expect(userSQL).toMatch(/FROM user account\s+LEFT JOIN user_profile profile/i); + expect(positionSQL).toMatch(/account\.id AS subjectId/i); 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"); @@ -884,6 +917,7 @@ describe("plugin manifest validation", () => { manifest.gameClientBridge = { commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }], snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }], + logProjections: [{ key: "player.login", streamKeys: ["process.stdout"], steps: [{ pattern: "Player \\\"(?[^\\\"]+)\\\" reported as player (?\\\\d+)" }, { pattern: "Player (?\\\\d+) SteamID: (?\\\\d+)" }], correlationFields: ["slot"], maxInterveningLines: 16, target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" }, presence: { timestampField: "lastLoginAt", activeWindowSeconds: 600, announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" } } }], commandRetentionSeconds: 86400, maxCommands: 1000, pages: [] @@ -894,6 +928,40 @@ describe("plugin manifest validation", () => { expect(validate(manifest)).toBe(false); }); + it("validates ordered log projections and repeated correlation captures", () => { + const projection = { + key: "player.login", + streamKeys: ["process.stdout"], + steps: [ + { pattern: "Player \\\"(?[^\\\"]+)\\\" reported as player (?\\\\d+)" }, + { pattern: "Player (?\\\\d+) SteamID: (?\\\\d+)" } + ], + correlationFields: ["slot"], + maxInterveningLines: 16, + target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" }, + presence: { + timestampField: "lastLoginAt", + activeWindowSeconds: 600, + activityTarget: { collection: "activity", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "observedAt" }, + announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" } + } + }; + const manifest = { + permissions: ["server.game-client.command"], + runtimeProfiles: { clientManagers: [{ key: "scum-client", health: { requiredCapabilities: ["game-client.bridge"] } }] }, + gameClientBridge: { + commands: [{ type: "announcement.send", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/announcement.schema.json" }], + snapshots: [], + logProjections: [projection] + } + }; + expect(validateGameClientBridgeCatalog(manifest)).toEqual([]); + + projection.target.captureMappings.steamId = "missing"; + const errors = validateGameClientBridgeCatalog(manifest); + expect(errors.some((error) => error.includes("references undeclared capture missing"))).toBe(true); + }); + it("loads and validates every schema referenced by a safe game-client bridge manifest", () => { expect(validateTemporaryBridgeManifest()).toEqual([]); }); @@ -1140,12 +1208,25 @@ describe("plugin SDK", () => { parameterSchemaRef: "schemas/bridge/queries/player-by-id.parameters.schema.json", resultSchemaRef: "schemas/bridge/queries/player-by-id.result.schema.json", maxRows: 1, - timeoutSeconds: 10 + timeoutSeconds: 10, + pollIntervalSeconds: 0 }; expect(declaration).toMatchObject({ engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", maxRows: 1 }); expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/); }); + it("types plugin-declared ordered log projections", () => { + const declaration: GameClientBridgeLogProjectionDeclaration = { + key: "scum.player.login", + streamKeys: ["process.stdout"], + steps: [{ pattern: "Player (?\\d+) SteamID: (?\\d+)" }], + correlationFields: ["slot"], + maxInterveningLines: 16, + target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "lastLoginAt" } + }; + expect(declaration).toMatchObject({ key: "scum.player.login", correlationFields: ["slot"] }); + }); + it("types controlled operation template declarations", () => { const declaration: GameClientBridgeOperationTemplateDeclaration = { key: "player.attribute.855.set", diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 25ca4f5..03e51b4 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; -import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, requestSCUMPageQueries, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; +import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; @@ -84,34 +84,25 @@ describe("SCUM plugin feature module", () => { expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` }); }); - it("merges the latest typed player and online-session snapshots into database users", async () => { + it("merges player snapshots only by stable identifiers and ignores name-only online sessions", async () => { const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } }); const gameClient = gameClientActions(); - gameClient.snapshots.mockImplementation(async (query) => query?.type === "players" ? { items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] } : { items: [{ sequence: 3, observedAt: "2026-08-10T00:01:00Z", payload: { sessions: [{ sessionId: "session-1", playerName: "Mira" }] } }] }); + gameClient.snapshots.mockResolvedValue({ items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] }); const data = await loadSCUMSurface({ pluginData, gameClient }, "players"); - expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "online.sessions"]); - expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:01:00Z" }); - expect(mergePlayerSnapshots([{ gamePlayerId: "steam-2", displayName: "Noah" }], { items: [] }, { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { sessions: [] } }] })[0]).toMatchObject({ online: false }); + expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players"]); + expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:00:00Z" }); + const sameName = mergePlayerSnapshots([{ steamId: "steam-2", displayName: "Noah", online: false }], { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { players: [{ playerId: "steam-3", playerName: "Noah", status: "online" }] } }] }); + expect(sameName).toHaveLength(2); + expect(sameName.find((player) => player.steamId === "steam-2")).toMatchObject({ online: false }); + expect(dataClientSource).not.toContain('type: "online.sessions"'); }); it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => { const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows"); expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]); - const dispatch = dispatchAction(); - await requestSCUMPageQueries({ dispatch }, "workflows"); - expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]); - dispatch.mockClear(); - await requestSCUMPageQueries({ dispatch }, "activity"); - expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]); - }); - - it("dispatches only declared SQLite query envelopes for machine refresh", async () => { - const dispatch = dispatchAction(); - await requestSCUMPageQueries({ dispatch }, "squads"); - expect(dispatch).toHaveBeenCalledTimes(3); - expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.squads", "scum.squad-members", "scum.flags"]); - for (const [envelope] of dispatch.mock.calls) expect(envelope).toMatchObject({ action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database" } }); + await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "activity"); + expect(list.mock.calls.slice(-6).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]); }); it("uses transaction, put, and delete for plugin-owned gift data", async () => { @@ -120,7 +111,7 @@ describe("SCUM plugin feature module", () => { expect(parseGiftItems("BP_Cash_01:2, Water-Bottle.01:1")).toEqual([{ catalogCode: "BP_Cash_01", quantity: 2 }, { catalogCode: "Water-Bottle.01", quantity: 1 }]); expect(parseGiftCommands("#announce Hello\n#spawnitem BP_Cash_01 2")).toEqual([{ command: "#announce Hello" }, { command: "#spawnitem BP_Cash_01 2" }]); expect(() => parseGiftItems("cash:0")).toThrow("格式无效"); - expect(() => parseGiftItems("a:1,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toThrow("最多包含 8 项"); + expect(parseGiftItems("a:101,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toHaveLength(9); await saveGiftDefinition(actions, { code: "starter", name: "Starter", items: [] }); await createGiftDelivery(actions, { id: "delivery-1", giftCode: "starter", playerId: "steam-1" }); await deleteGiftDefinition(actions, "starter"); @@ -172,12 +163,21 @@ describe("SCUM plugin feature module", () => { }) })); }); + it("keeps positive event duration and counts above the removed arbitrary limits", async () => { + const gameClient = gameClientActions(); + await startEvent({ pluginData: pluginDataActions(), gameClient }, { id: "event-large", name: "Large Event", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]); + expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ + durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004, + produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }] + }) })); + }); + it("renders searchable user management from real collection values", () => { const view = renderAndCollect(); expect(view.nodes).toContain("section:用户管理"); expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步"); expect(view.texts).toContain("通用数据/机器动作可用"); - expect(view.buttons.find((button) => button.label === "同步 SCUM.db")?.disabled).toBe(false); + expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"])); expect(view.inputs.map((input) => input.label)).toContain("搜索用户"); expect(view.texts).toContain("Mira"); expect(view.texts.join("\n")).toContain("Steam 76561198000000001"); @@ -204,7 +204,7 @@ describe("SCUM plugin feature module", () => { it("renders activity definitions, status filters, runs, and records", () => { const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" }); expect(view.inputs.map((input) => input.label)).toContain("活动状态"); - expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"])); + expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"])); expect(view.texts).toContain("Friday Range"); expect(view.texts).toContain("running"); expect(view.texts).toContain("最近活动记录"); @@ -241,7 +241,7 @@ describe("SCUM plugin feature module", () => { }); it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => { - const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "steam-1", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] }; + const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "76561198000000001", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] }; expect(collectMapPoints(duplicateData)).toHaveLength(264); const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 }); expect(bounds).toEqual({ worldMinX: -100000, worldMinY: 100000, worldMaxX: 300000, worldMaxY: 300000 }); @@ -256,8 +256,11 @@ describe("SCUM plugin feature module", () => { const source = `${pageSource}\n${dataClientSource}`; for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "SELECT ", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden); expect(source).toContain("pluginData"); - expect(source).toContain("remote.access.request"); - expect(source).toContain("input.templateKey"); + expect(source).not.toContain("remote.access.request"); + expect(source).not.toContain("input.templateKey"); + expect(source).not.toContain("requestSCUMPageQueries"); + expect(pageSource).toContain("setInterval(refresh, 3000)"); + expect(pageSource).toContain("clearInterval(interval)"); }); }); @@ -270,10 +273,6 @@ function pluginDataActions(overrides: Partial<{ list: (collection: string, key?: }; } -function dispatchAction() { - return vi.fn>(async (envelope) => ({ requestId: envelope.requestId, action: envelope.action, status: "queued" })); -} - function gameClientActions() { const queue = vi.fn["queue"]>(async () => ({ id: "command-1", state: "pending" })); const get = vi.fn["get"]>(async () => ({ id: "command-1", state: "pending" })); @@ -310,7 +309,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin return [value, () => undefined]; } }; - const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: dispatchAction() }; + const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions() }; renderPluginPage(react, { page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },