Rebuild SCUM plugin-owned data flow

This commit is contained in:
npc0-hue
2026-08-18 07:01:17 +08:00
parent 302f1f64b7
commit 98bf944f4c
39 changed files with 1832 additions and 223 deletions
@@ -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. 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 ## 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. 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. Platform dispatches the selected declared operation to Run; no browser or plugin supplies a machine path or SQL string at request time. 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 returns structured rows; Platform applies only the declared collection, upsert keys, and column mapping before storing them in the scoped generic store. 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. The SCUM page reads those collections through the generic bridge and applies all SCUM-specific presentation and gift logic locally. 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. Gift delivery and activity commands use the existing generic Game Client Bridge queue exposed by the plugin-page host. 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 ## Compatibility
@@ -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.
@@ -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 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 ## What Changes
- Replace the recent SCUM direct-data and game-gift additions with a generic plugin data store and generic plugin-page data bridge. - 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. - 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. - 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 ## 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. - 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. - Version-specific SQL/config/log declarations remain in the SCUM manifest and assets.
- Existing generic lifecycle and machine-job dispatch behavior remains intact. - 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.
@@ -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 - **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 - **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 - **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
@@ -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] 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] 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] 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.
+84 -12
View File
@@ -61,24 +61,68 @@ type GameClientBridgeSnapshotDeclaration struct {
} }
type GameClientBridgeQueryTemplateDeclaration struct { type GameClientBridgeQueryTemplateDeclaration struct {
Key string Key string
Title string Title string
Permission string Permission string
Engine string Engine string
TransportKey string TransportKey string
TargetKey string TargetKey string
ParameterSchemaRef string ParameterSchemaRef string
ResultSchemaRef string ResultSchemaRef string
SQLRef string SQLRef string
MaxRows int MaxRows int
TimeoutSeconds int TimeoutSeconds int
RowTarget *PluginDataRowTargetDeclaration PollIntervalSeconds int
RowTarget *PluginDataRowTargetDeclaration
} }
const (
PluginDataRowWriteModeMerge = "merge"
PluginDataRowWriteModeReplace = "replace"
)
type PluginDataRowTargetDeclaration struct { type PluginDataRowTargetDeclaration struct {
Collection string Collection string
UpsertKeys []string UpsertKeys []string
ColumnMappings map[string]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 { type GameClientBridgeDataPackDeclaration struct {
@@ -170,6 +214,7 @@ type GameClientBridgeManifest struct {
Commands []GameClientBridgeCommandDeclaration Commands []GameClientBridgeCommandDeclaration
Snapshots []GameClientBridgeSnapshotDeclaration Snapshots []GameClientBridgeSnapshotDeclaration
QueryTemplates []GameClientBridgeQueryTemplateDeclaration QueryTemplates []GameClientBridgeQueryTemplateDeclaration
LogProjections []GameClientBridgeLogProjectionDeclaration
DataPacks []GameClientBridgeDataPackDeclaration DataPacks []GameClientBridgeDataPackDeclaration
OperationTemplates []GameClientBridgeOperationTemplateDeclaration OperationTemplates []GameClientBridgeOperationTemplateDeclaration
Retention GameClientBridgeRetention Retention GameClientBridgeRetention
@@ -475,6 +520,10 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
value.QueryTemplates[index].RowTarget = &copy value.QueryTemplates[index].RowTarget = &copy
} }
} }
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...) value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...)
for index := range value.DataPacks { for index := range value.DataPacks {
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs) value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
@@ -503,6 +552,29 @@ func CopyPluginDataRowTargetDeclaration(value PluginDataRowTargetDeclaration) Pl
return value 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 { func copyGameClientBridgePayloadValue(value any) any {
switch typed := value.(type) { switch typed := value.(type) {
case map[string]any: case map[string]any:
+10 -2
View File
@@ -4,7 +4,12 @@ import "testing"
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
manifest := GameClientBridgeManifest{ 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 (?<slot>\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"}}}, DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}}},
OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}}, OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"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 := CopyGameClientBridgeManifest(manifest)
manifestCopy.QueryTemplates[0].Key = "mutated" manifestCopy.QueryTemplates[0].Key = "mutated"
manifestCopy.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "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.DataPacks[0].LogParserRefs[0] = "mutated"
manifestCopy.OperationTemplates[0].Key = "mutated" manifestCopy.OperationTemplates[0].Key = "mutated"
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
manifestCopy.Pages[0].OperationKeys[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) t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
} }
+152 -18
View File
@@ -290,24 +290,63 @@ type GameClientBridgeSnapshotDeclarationBody struct {
} }
type GameClientBridgeQueryTemplateDeclarationBody struct { type GameClientBridgeQueryTemplateDeclarationBody struct {
Key string `json:"key"` Key string `json:"key"`
Title string `json:"title"` Title string `json:"title"`
Permission string `json:"permission"` Permission string `json:"permission"`
Engine string `json:"engine"` Engine string `json:"engine"`
TransportKey string `json:"transportKey"` TransportKey string `json:"transportKey"`
TargetKey string `json:"targetKey"` TargetKey string `json:"targetKey"`
ParameterSchemaRef string `json:"parameterSchemaRef"` ParameterSchemaRef string `json:"parameterSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef"` ResultSchemaRef string `json:"resultSchemaRef"`
SQLRef string `json:"sqlRef,omitempty"` SQLRef string `json:"sqlRef,omitempty"`
MaxRows int `json:"maxRows"` MaxRows int `json:"maxRows"`
TimeoutSeconds int `json:"timeoutSeconds"` TimeoutSeconds int `json:"timeoutSeconds"`
RowTarget *PluginDataRowTargetDeclarationBody `json:"rowTarget,omitempty"` PollIntervalSeconds int `json:"pollIntervalSeconds"`
RowTarget *PluginDataRowTargetDeclarationBody `json:"rowTarget,omitempty"`
} }
type PluginDataRowTargetDeclarationBody struct { type PluginDataRowTargetDeclarationBody struct {
Collection string `json:"collection"` Collection string `json:"collection"`
UpsertKeys []string `json:"upsertKeys"` UpsertKeys []string `json:"upsertKeys"`
ColumnMappings map[string]string `json:"columnMappings"` 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 { type GameClientBridgeDataPackDeclarationBody struct {
@@ -392,6 +431,7 @@ type GameClientBridgeManifestBody struct {
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"` DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"` OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"`
CommandRetentionSeconds int `json:"commandRetentionSeconds"` CommandRetentionSeconds int `json:"commandRetentionSeconds"`
@@ -1193,10 +1233,14 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
for index, template := range body.QueryTemplates { for index, template := range body.QueryTemplates {
var rowTarget *domain.PluginDataRowTargetDeclaration var rowTarget *domain.PluginDataRowTargetDeclaration
if template.RowTarget != nil { 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 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)) dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks))
for index, dataPack := range body.DataPacks { for index, dataPack := range body.DataPacks {
@@ -1218,7 +1262,50 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
if body.Companion != nil { 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} 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 { func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration {
@@ -1633,9 +1720,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
for index, template := range value.QueryTemplates { for index, template := range value.QueryTemplates {
var rowTarget *PluginDataRowTargetDeclarationBody var rowTarget *PluginDataRowTargetDeclarationBody
if template.RowTarget != nil { 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)) dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks))
for index, dataPack := range value.DataPacks { for index, dataPack := range value.DataPacks {
@@ -1657,7 +1748,50 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
if value.Companion.ProfileKey != "" { 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} 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 { func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody {
+18 -4
View File
@@ -137,8 +137,13 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
body := GameClientBridgeManifestBody{ body := GameClientBridgeManifestBody{
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{ QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", 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, 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"}}, 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 (?<slot>\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"}}}, DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}}},
CommandRetentionSeconds: 86400, CommandRetentionSeconds: 86400,
@@ -147,7 +152,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
} }
domainManifest := body.ToDomain() 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) t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
} }
domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated" 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") t.Fatal("query template row target aliases request DTO data")
} }
domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "user_id" 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" domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" { if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatal("query template page keys alias request DTO data") 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" domainManifest.Pages[0].QueryTemplateKeys[0] = "player.lookup"
response := gameClientBridgeManifestFromDomain(domainManifest) 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" response.Pages[0].QueryTemplateKeys[0] = "mutated"
if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatal("query template page keys alias domain data") 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 { if err := json.Unmarshal(encoded, &projection); err != nil {
t.Fatalf("decode safe query template projection: %v", err) 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) { if len(projection) != len(expectedFields) {
t.Fatalf("query template projection contains unexpected fields: %s", encoded) t.Fatalf("query template projection contains unexpected fields: %s", encoded)
} }
+3
View File
@@ -40,6 +40,9 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
if err := svc.sweepExpiredJobs(claim.RunEndpointID, stamp); err != nil { if err := svc.sweepExpiredJobs(claim.RunEndpointID, stamp); err != nil {
return domain.RunJobClaimResult{}, err 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 { if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs {
return emptyJobClaim(claim.RunEndpointID, stamp), nil return emptyJobClaim(claim.RunEndpointID, stamp), nil
} }
+23 -1
View File
@@ -22,7 +22,12 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
} }
lock := svc.logIngestLock(batch.ServerInstanceID) lock := svc.logIngestLock(batch.ServerInstanceID)
lock.Lock() lock.Lock()
defer lock.Unlock() locked := true
defer func() {
if locked {
lock.Unlock()
}
}()
stamp := svc.now() stamp := svc.now()
stream, err := svc.store.LogStreams().Get(batch.LogStreamID) 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 return domain.LogBatchIngestResult{}, err
} }
if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) { 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{ return domain.LogBatchIngestResult{
Accepted: true, Accepted: true,
LogStreamID: batch.LogStreamID, 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 { if err := svc.store.LogStreams().Update(stream); err != nil {
return domain.LogBatchIngestResult{}, err 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) svc.publishLogEvents(stream, storedBatch.Entries)
return domain.LogBatchIngestResult{ return domain.LogBatchIngestResult{
Accepted: true, Accepted: true,
@@ -87,6 +102,13 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
}, nil }, 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 { func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
jobID, ok := jobIDFromLogBatch(batch) jobID, ok := jobIDFromLogBatch(batch)
if !ok { if !ok {
+45 -3
View File
@@ -2,10 +2,13 @@ package service
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"browser.local/platform/domain" "browser.local/platform/domain"
"browser.local/platform/repo"
) )
type pluginDataQueryResult struct { 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 { if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &result); err != nil {
return validationError("declared query result is not valid JSON") 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 { for _, row := range result.Rows {
value := make(map[string]any, len(template.RowTarget.ColumnMappings)) value := make(map[string]any, len(template.RowTarget.ColumnMappings))
for destination, source := range 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 { if err != nil {
return err 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 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}) _, err = svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection, Mutations: mutations})
return err 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) { func pluginDataRowKey(value map[string]any, keys []string) (string, error) {
parts := make([]string, len(keys)) parts := make([]string, len(keys))
for index, key := range keys { for index, key := range keys {
+92
View File
@@ -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))
}
+86
View File
@@ -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) { func TestDeclaredSQLiteQueryProjectionFailureKeepsJobRetryable(t *testing.T) {
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{ plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
+276
View File
@@ -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 == "<nil>" {
return time.Time{}, false
}
parsed, err := time.Parse(time.RFC3339Nano, text)
return parsed, err == nil
}
@@ -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<displayName>[^"]+)" reported as player (?P<playerSlot>[0-9]+)`},
{Pattern: `Player (?P<playerSlot>[0-9]+) SteamID \(assumed\): (?P<steamId>[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
}
+3
View File
@@ -234,6 +234,8 @@ type CoreService struct {
bridgeSeq uint64 bridgeSeq uint64
logStore LogBodyStore logStore LogBodyStore
logIngestMu [64]sync.Mutex logIngestMu [64]sync.Mutex
logProjectionMu sync.Mutex
logProjectionStates map[string]map[string]pluginLogSequenceState
logEventMu sync.Mutex logEventMu sync.Mutex
logEventSubscribers map[uint64]logEventSubscriber logEventSubscribers map[uint64]logEventSubscriber
logEventSubscriberSeq uint64 logEventSubscriberSeq uint64
@@ -280,6 +282,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
authSessions: map[string]string{}, authSessions: map[string]string{},
runSessions: map[string]domain.RunControlSession{}, runSessions: map[string]domain.RunControlSession{},
logStore: logStore, logStore: logStore,
logProjectionStates: map[string]map[string]pluginLogSequenceState{},
logEventSubscribers: map[uint64]logEventSubscriber{}, logEventSubscribers: map[uint64]logEventSubscriber{},
artifactStore: artifactStore, artifactStore: artifactStore,
artifactTransfers: map[string]domain.ArtifactTransferSession{}, artifactTransfers: map[string]domain.ArtifactTransferSession{},
@@ -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 "(?<name>[^"]+)" reported as player (?<slot>\d+)`},
{Pattern: `Player (?<slot>\d+) SteamID \(assumed\): (?<steamId>\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)
}
})
}
}
+196 -1
View File
@@ -2,6 +2,7 @@ package validator
import ( import (
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
@@ -24,6 +25,12 @@ const (
maxProductionMessageLength = 320 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 { type ValidationError struct {
Violations []string 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 { func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) 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 return nil
} }
var violations []string var violations []string
@@ -566,6 +573,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 { if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
violations = append(violations, prefix+".timeoutSeconds is invalid") 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 projectsRows := template.SQLRef != "" || template.RowTarget != nil
if projectsRows { if projectsRows {
if !safeRelativeSQLRef(template.SQLRef) { 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 { 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") 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 { for _, key := range target.UpsertKeys {
if !clientManagerIdentifierPattern.MatchString(key) { if !clientManagerIdentifierPattern.MatchString(key) {
violations = append(violations, prefix+".rowTarget upsert key is invalid") 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") 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{}{} dataPackKeys := map[string]struct{}{}
for index, dataPack := range bridge.DataPacks { for index, dataPack := range bridge.DataPacks {
prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index) prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index)
@@ -795,6 +820,176 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
return violations 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 { func validCompanionProofEnvironment(value string) bool {
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' { if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
return false return false
+7
View File
@@ -204,6 +204,13 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(
{name: "timeout bound", expected: "timeoutSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) { {name: "timeout bound", expected: "timeoutSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.QueryTemplates[0].TimeoutSeconds = 61 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) { {name: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing" value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing"
}}, }},
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math"
"sort" "sort"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -416,7 +417,7 @@ func rewardGrant(payload map[string]any) (RewardGrant, error) {
playerID, playerOK := payload["playerId"].(string) playerID, playerOK := payload["playerId"].(string)
rawItems, itemsOK := payload["items"].([]any) rawItems, itemsOK := payload["items"].([]any)
rawOperations, operationsOK := payload["operations"].([]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") return RewardGrant{}, fmt.Errorf("reward payload is invalid")
} }
items := make([]RewardItem, 0, len(rawItems)) items := make([]RewardItem, 0, len(rawItems))
@@ -427,7 +428,7 @@ func rewardGrant(payload map[string]any) (RewardGrant, error) {
} }
code, codeOK := item["catalogCode"].(string) code, codeOK := item["catalogCode"].(string)
quantity, quantityOK := integerPayloadValue(item["quantity"]) 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") return RewardGrant{}, fmt.Errorf("reward payload is invalid")
} }
items = append(items, RewardItem{CatalogCode: code, Quantity: quantity}) 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"} { for index, key := range []string{"npc", "item", "zombie", "animal"} {
if value, exists := payload[key]; exists { if value, exists := payload[key]; exists {
count, ok := integerPayloadValue(value) count, ok := integerPayloadValue(value)
if !ok || count < 0 || count > 10000 { if !ok || count < 0 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid") return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
} }
counts[index] = count counts[index] = count
@@ -466,7 +467,7 @@ func eventStartRequest(payload map[string]any) (EventStartRequest, error) {
if value, exists := payload["maxParticipants"]; exists { if value, exists := payload["maxParticipants"]; exists {
var ok bool var ok bool
participants, ok = integerPayloadValue(value) participants, ok = integerPayloadValue(value)
if !ok || participants < 1 || participants > 1000 { if !ok || participants < 1 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid") 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") 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{}, 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 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) { func eventProduceRequests(value any) ([]EventProduceRequest, bool) {
raw, ok := value.([]any) raw, ok := value.([]any)
if !ok || len(raw) > 100 { if !ok {
return nil, false return nil, false
} }
result := make([]EventProduceRequest, 0, len(raw)) result := make([]EventProduceRequest, 0, len(raw))
@@ -502,7 +503,7 @@ func eventProduceRequests(value any) ([]EventProduceRequest, bool) {
x, xOK := numberPayloadValue(produce["x"]) x, xOK := numberPayloadValue(produce["x"])
y, yOK := numberPayloadValue(produce["y"]) y, yOK := numberPayloadValue(produce["y"])
z, zOK := numberPayloadValue(produce["z"]) 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 return nil, false
} }
result = append(result, EventProduceRequest{TradeGoodsID: tradeGoodsID, Percent: percent, Value: quantity, Radius: radius, X: x, Y: y, Z: z}) 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) { func numberPayloadValue(value any) (float64, bool) {
var result float64
switch number := value.(type) { switch number := value.(type) {
case float64: case float64:
return number, true result = number
case float32: case float32:
return float64(number), true result = float64(number)
case int: case int:
return float64(number), true result = float64(number)
case int32: case int32:
return float64(number), true result = float64(number)
case int64: case int64:
return float64(number), true result = float64(number)
default: default:
return 0, false return 0, false
} }
return result, !math.IsNaN(result) && !math.IsInf(result, 0)
} }
func supportedCatalogCode(value string) bool { func supportedCatalogCode(value string) bool {
@@ -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" { 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) 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) before := len(port.grants)
if _, err := adapter.DeliverReward(context.Background(), map[string]any{ if _, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-2", "playerId": "76561198000000001", "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) { func TestRewardDeliverySupportsOperationsWithoutItemsAndRejectsEmptyGrant(t *testing.T) {
port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered"}} port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered"}}
adapter := RuntimeAdapter{BoundServerID: "server-1", Rewards: port} adapter := RuntimeAdapter{BoundServerID: "server-1", Rewards: port}
@@ -17,9 +17,6 @@ export type PluginGameClientQueueRequest = {
expiresAt: string; expiresAt: string;
}; };
export type PluginDispatchEnvelope = { requestId: string; action: "remote.access.request"; payload: Record<string, string> };
export type PluginDispatchResult = { requestId: string; action: "remote.access.request"; status: string; result?: Record<string, string>; error?: { code: string; message: string; details?: string[] } };
export type SCUMWorkspaceActions = { export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions; pluginData?: PluginDataActions;
gameClient?: { gameClient?: {
@@ -28,7 +25,6 @@ export type SCUMWorkspaceActions = {
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>; list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>;
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>; snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
}; };
dispatch?: (envelope: PluginDispatchEnvelope, signal?: AbortSignal) => Promise<PluginDispatchResult>;
}; };
export type SCUMSurfaceData = { export type SCUMSurfaceData = {
@@ -91,14 +87,6 @@ const pageCollections: Record<PageKey, SurfaceKey[]> = {
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"] workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"]
}; };
const pageQueries: Record<PageKey, string[]> = {
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<SCUMSurfaceData> { export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData }; 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)); 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); for (const [key, response] of records) data[key] = collectionRecords(response);
if (keys.includes("players") && actions.gameClient) { if (keys.includes("players") && actions.gameClient) {
const [playersSnapshot, sessionsSnapshot] = await Promise.all([ const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined);
actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined), data.players = mergePlayerSnapshots(data.players, playersSnapshot);
actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "online.sessions", streamKey: "current", limit: 1 }).catch(() => undefined)
]);
data.players = mergePlayerSnapshots(data.players, playersSnapshot, sessionsSnapshot);
} }
return data; 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 playerSnapshot = latestSnapshotPayload(playersResponse);
const sessionSnapshot = latestSnapshotPayload(sessionsResponse); const merged = players.map((player) => ({ ...player }));
let merged = players.map((player) => ({ ...player }));
const snapshotPlayers = Array.isArray(playerSnapshot?.players) ? playerSnapshot.players.filter(isRecord) : []; const snapshotPlayers = Array.isArray(playerSnapshot?.players) ? playerSnapshot.players.filter(isRecord) : [];
if (snapshotPlayers.length) { if (snapshotPlayers.length) {
const byIdentity = playerIndex(merged); const byIdentity = playerIndex(merged);
for (const snapshotPlayer of snapshotPlayers) { for (const snapshotPlayer of snapshotPlayers) {
const match = findPlayer(merged, byIdentity, snapshotPlayer); const match = findPlayer(byIdentity, snapshotPlayer);
const value = { ...snapshotPlayer, ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) }; const value = { ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) };
if (match) merged[match.index] = value; 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; return merged;
} }
export function hasSCUMPageQueries(pageKey: string): boolean { return pageQueries[canonicalPageKey(pageKey)].length > 0; }
export async function requestSCUMPageQueries(actions: SCUMWorkspaceActions, pageKey: string): Promise<PluginDispatchResult[]> {
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<unknown> { export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise<unknown> {
const key = requiredKey(gift, "code", "礼包编号"); const key = requiredKey(gift, "code", "礼包编号");
return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]); return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]);
@@ -234,9 +202,9 @@ export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap
payload: { payload: {
eventId, eventType, class: eventClass, title: textValue(event.name) || eventId, eventId, eventType, class: eventClass, title: textValue(event.name) || eventId,
placard: firstText(event, "placard", "announcement"), percent: boundedInteger(event.percent ?? event.probability, 0, 100, 100), 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, 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, idempotencyKey: runId,
expiresAt: new Date(now + 5 * 60_000).toISOString() 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 items = input.split(",").map((part) => {
const [rawKey, rawQuantity, ...extra] = part.split(":").map((value) => value.trim()); const [rawKey, rawQuantity, ...extra] = part.split(":").map((value) => value.trim());
const quantity = Number(rawQuantity); 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 }; return { catalogCode: rawKey, quantity };
}); });
if (items.length > 8) throw new Error("单个礼包最多包含 8 项物品。");
return items; 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 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<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => playerIdentities(player).forEach((identity) => result.set(identity, index))); return result; } function playerIndex(players: RecordMap[]): Map<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => addPlayerToIndex(result, player, index)); return result; }
function findPlayer(players: RecordMap[], index: Map<string, number>, 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 addPlayerToIndex(index: Map<string, number>, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); }
function playerIdentities(player: RecordMap): string[] { return ["gamePlayerId", "playerId", "steamId", "userProfileId", "profileId", "id"].map((key) => textValue(player[key])).filter(Boolean); } function findPlayer(index: Map<string, number>, 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<string>();
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 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"; } 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 { function boundedInteger(value: unknown, min: number, max: number, fallback: number): number {
const number = Number(value); 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 }> { function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> {
if (value === undefined || value === null) return []; 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) => { return value.map((item) => {
if (!isRecord(item)) throw new Error("礼包物品格式无效。"); if (!isRecord(item)) throw new Error("礼包物品格式无效。");
const catalogCode = firstText(item, "catalogCode", "key"); const catalogCode = firstText(item, "catalogCode", "key");
const quantity = Number(item.quantity); 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 }; return { catalogCode, quantity };
}); });
} }
@@ -350,15 +325,16 @@ function normalizeEventProduces(produces: RecordMap[]): RecordMap[] {
return produces.map((produce) => ({ return produces.map((produce) => ({
tradeGoodsId: firstText(produce, "tradeGoodsId"), tradeGoodsId: firstText(produce, "tradeGoodsId"),
percent: boundedInteger(produce.percent, 0, 100, 100), percent: boundedInteger(produce.percent, 0, 100, 100),
value: boundedInteger(produce.value, 1, 10000, 1), value: minimumInteger(produce.value, 1, 1),
r: boundedNumber(produce.r, 0, 2000000, 0), r: minimumNumber(produce.r, 0, 0),
x: boundedNumber(produce.x, -2000000, 2000000, 0), x: finiteNumber(produce.x, 0),
y: boundedNumber(produce.y, -2000000, 2000000, 0), y: finiteNumber(produce.y, 0),
z: boundedNumber(produce.z, -2000000, 2000000, 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 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 ""; } function firstText(value: RecordMap, ...keys: string[]): string { for (const key of keys) { const result = textValue(value[key]); if (result) return result; } return ""; }
@@ -3,12 +3,10 @@ import {
deleteEventDefinition, deleteEventDefinition,
deleteEventProduce, deleteEventProduce,
emptySCUMSurfaceData, emptySCUMSurfaceData,
hasSCUMPageQueries,
loadSCUMSurface, loadSCUMSurface,
parseGiftItems, parseGiftItems,
parseGiftCommands, parseGiftCommands,
queueGiftDelivery, queueGiftDelivery,
requestSCUMPageQueries,
resetGiftClaim, resetGiftClaim,
resetPendingGift, resetPendingGift,
resolveMapBounds, resolveMapBounds,
@@ -63,6 +61,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [eventClass, setEventClass] = usePluginState(react, "1"); const [eventClass, setEventClass] = usePluginState(react, "1");
const [eventPlacard, setEventPlacard] = usePluginState(react, ""); const [eventPlacard, setEventPlacard] = usePluginState(react, "");
const [eventPercent, setEventPercent] = usePluginState(react, "100"); const [eventPercent, setEventPercent] = usePluginState(react, "100");
const [eventDuration, setEventDuration] = usePluginState(react, "1800");
const [eventNpc, setEventNpc] = usePluginState(react, "0"); const [eventNpc, setEventNpc] = usePluginState(react, "0");
const [eventItem, setEventItem] = usePluginState(react, "0"); const [eventItem, setEventItem] = usePluginState(react, "0");
const [eventZombie, setEventZombie] = 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 能力。" }); setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" });
return; return;
} }
setState({ status: "loading" });
void loadSCUMSurface(input.workspaceActions, pageKey) void loadSCUMSurface(input.workspaceActions, pageKey)
.then((data) => setState({ status: "ready", data })) .then((data) => setState({ status: "ready", data }))
.catch((error) => setState({ status: "error", reason: errorMessage(error, "SCUM 插件数据读取失败。") })); .catch((error) => setState({ status: "error", reason: errorMessage(error, "SCUM 插件数据读取失败。") }));
}; };
const syncMachine = () => { if (react.useEffect) react.useEffect(() => {
if (!input.workspaceActions) return; refresh();
runAction(setAction, "正在提交声明式 SQLite 查询…", async () => { const interval = setInterval(refresh, 3000);
const results = await requestSCUMPageQueries(input.workspaceActions!, pageKey); return () => clearInterval(interval);
const failed = results.find((result) => !["ok", "queued"].includes(result.status)); }, [input.serverInstanceId, pageKey, input.workspaceActions]);
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]);
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
e("div", { className: "panel-header" }, e("div", { className: "panel-header" },
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))), e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))),
e("div", { className: "console-row-actions" }, e("div", { className: "console-row-actions" },
e("span", { className: "page-status" }, input.availability.available ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion"), 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
) )
), ),
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, 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, { state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, {
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, 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, 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, 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, deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapSearch, setMapSearch, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint,
@@ -154,6 +144,7 @@ type ViewState = {
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>; eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
eventType: string; setEventType: StateSetter<string>; eventSchedule: string; setEventSchedule: StateSetter<string>; eventType: string; setEventType: StateSetter<string>; eventSchedule: string; setEventSchedule: StateSetter<string>;
eventClass: string; setEventClass: StateSetter<string>; eventPlacard: string; setEventPlacard: StateSetter<string>; eventPercent: string; setEventPercent: StateSetter<string>; eventClass: string; setEventClass: StateSetter<string>; eventPlacard: string; setEventPlacard: StateSetter<string>; eventPercent: string; setEventPercent: StateSetter<string>;
eventDuration: string; setEventDuration: StateSetter<string>;
eventNpc: string; setEventNpc: StateSetter<string>; eventItem: string; setEventItem: StateSetter<string>; eventZombie: string; setEventZombie: StateSetter<string>; eventAnimal: string; setEventAnimal: StateSetter<string>; eventNpc: string; setEventNpc: StateSetter<string>; eventItem: string; setEventItem: StateSetter<string>; eventZombie: string; setEventZombie: StateSetter<string>; eventAnimal: string; setEventAnimal: StateSetter<string>;
produceEventId: string; setProduceEventId: StateSetter<string>; produceId: string; setProduceId: StateSetter<string>; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter<string>; produceEventId: string; setProduceEventId: StateSetter<string>; produceId: string; setProduceId: StateSetter<string>; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter<string>;
producePercent: string; setProducePercent: StateSetter<string>; produceValue: string; setProduceValue: StateSetter<string>; produceRadius: string; setProduceRadius: StateSetter<string>; produceX: string; setProduceX: StateSetter<string>; produceY: string; setProduceY: StateSetter<string>; produceZ: string; setProduceZ: StateSetter<string>; producePercent: string; setProducePercent: StateSetter<string>; produceValue: string; setProduceValue: StateSetter<string>; produceRadius: string; setProduceRadius: StateSetter<string>; produceX: string; setProduceX: StateSetter<string>; produceY: string; setProduceY: StateSetter<string>; produceZ: string; setProduceZ: StateSetter<string>;
@@ -238,8 +229,8 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
await saveEventDefinition(actions ?? {}, { await saveEventDefinition(actions ?? {}, {
id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(), 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), 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), 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: 1800, updatedAt: new Date().toISOString() status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: minimumIntegerInput(view.eventDuration, 1, "活动持续秒数"), updatedAt: new Date().toISOString()
}); });
view.refresh(); view.refresh();
return `活动 ${name} 已保存。`; 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.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.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.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("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.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)) }), 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-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("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: "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 "活动定义已删除。"; }) }, "删除") 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[] { export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] {
const direct = data.mapPoints.map((point) => ({ ...point, layer: textField(point, "layer", "subjectType", "type") || "other" })); 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 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 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 regions = data.mapRegions.flatMap((region) => withPosition(region, "regions", textField(region, "name"), textField(region, "id", "regionId")));
const uniquePoints = new Map<string, RecordMap>(); const uniquePoints = new Map<string, RecordMap>();
for (const point of [...direct, ...players, ...vehicles, ...flags, ...regions].filter(hasCoordinates)) { for (const point of [...direct, ...players, ...vehicles, ...flags, ...regions].filter(hasCoordinates)) {
const key = mapPointIdentity(point); 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()]; 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 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<string, string>)[value] ?? value; } function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record<string, string>)[value] ?? value; }
function integerInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : fallback; } 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 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 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; } function errorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; }
@@ -127,11 +127,18 @@
"type": "announcement.send", "type": "announcement.send",
"title": "Send SCUM announcement", "title": "Send SCUM announcement",
"permission": "server.game-client.command", "permission": "server.game-client.command",
"approvalLevel": "none", "approvalLevel": "operator",
"payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json", "payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json",
"resultSchemaRef": "schemas/bridge/announcement.result.schema.json", "resultSchemaRef": "schemas/bridge/announcement.result.schema.json",
"timeoutSeconds": 60, "timeoutSeconds": 60,
"maxPayloadBytes": 4096 "maxPayloadBytes": 4096,
"protectedRequest": {
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"textField": "requestText",
"maxTextBytes": 2048
}
}, },
{ {
"type": "companion.diagnostics", "type": "companion.diagnostics",
@@ -293,9 +300,11 @@
"parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json",
"sqlRef": "sql/scum-db-v57/users.sql", "sqlRef": "sql/scum-db-v57/users.sql",
"pollIntervalSeconds": 3,
"rowTarget": { "rowTarget": {
"collection": "scum_users", "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" } "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, "maxRows": 500,
@@ -311,9 +320,11 @@
"parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json",
"sqlRef": "sql/scum-db-v57/squads.sql", "sqlRef": "sql/scum-db-v57/squads.sql",
"pollIntervalSeconds": 1800,
"rowTarget": { "rowTarget": {
"collection": "scum_squads", "collection": "scum_squads",
"upsertKeys": ["squadId"], "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" } "columnMappings": { "squadId": "squadId", "name": "name", "leaderProfileId": "leaderProfileId", "leaderPlayerId": "leaderPlayerId", "memberCount": "memberCount", "score": "score", "memberLimit": "memberLimit", "message": "message", "info": "info", "lastMemberLoginTime": "lastMemberLoginTime" }
}, },
"maxRows": 500, "maxRows": 500,
@@ -329,9 +340,11 @@
"parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json",
"sqlRef": "sql/scum-db-v57/squad-members.sql", "sqlRef": "sql/scum-db-v57/squad-members.sql",
"pollIntervalSeconds": 1800,
"rowTarget": { "rowTarget": {
"collection": "scum_squad_members", "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" } "columnMappings": { "squadId": "squadId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "steamId": "steamId", "displayName": "displayName", "rank": "rank", "isLeader": "isLeader" }
}, },
"maxRows": 500, "maxRows": 500,
@@ -347,9 +360,11 @@
"parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json",
"sqlRef": "sql/scum-db-v57/vehicles.sql", "sqlRef": "sql/scum-db-v57/vehicles.sql",
"pollIntervalSeconds": 3,
"rowTarget": { "rowTarget": {
"collection": "scum_vehicles", "collection": "scum_vehicles",
"upsertKeys": ["vehicleId"], "upsertKeys": ["vehicleId"],
"writeMode": "replace",
"columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" } "columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" }
}, },
"maxRows": 500, "maxRows": 500,
@@ -365,9 +380,11 @@
"parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json",
"sqlRef": "sql/scum-db-v57/flags.sql", "sqlRef": "sql/scum-db-v57/flags.sql",
"pollIntervalSeconds": 1800,
"rowTarget": { "rowTarget": {
"collection": "scum_flags", "collection": "scum_flags",
"upsertKeys": ["flagId"], "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" } "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, "maxRows": 500,
@@ -383,9 +400,11 @@
"parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json",
"sqlRef": "sql/scum-db-v57/map-points.sql", "sqlRef": "sql/scum-db-v57/map-points.sql",
"pollIntervalSeconds": 3,
"rowTarget": { "rowTarget": {
"collection": "scum_map_points", "collection": "scum_map_points",
"upsertKeys": ["subjectType", "subjectId"], "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" } "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, "maxRows": 500,
@@ -401,9 +420,11 @@
"parameterSchemaRef": "schemas/bridge/queries/scum-tasks.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-tasks.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-tasks.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-tasks.result.schema.json",
"sqlRef": "sql/scum-db-v57/tasks.sql", "sqlRef": "sql/scum-db-v57/tasks.sql",
"pollIntervalSeconds": 1800,
"rowTarget": { "rowTarget": {
"collection": "scum_tasks", "collection": "scum_tasks",
"upsertKeys": ["taskRecordId"], "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" } "columnMappings": { "taskRecordId": "taskRecordId", "taskKind": "taskKind", "userProfileId": "userProfileId", "mapId": "mapId", "trackingDataSetId": "trackingDataSetId", "dataAssetPath": "dataAssetPath", "sequenceIndex": "sequenceIndex", "isTracked": "isTracked", "state": "state", "completionDeadline": "completionDeadline" }
}, },
"maxRows": 500, "maxRows": 500,
@@ -419,9 +440,11 @@
"parameterSchemaRef": "schemas/bridge/queries/scum-events.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-events.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-events.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-events.result.schema.json",
"sqlRef": "sql/scum-db-v57/events.sql", "sqlRef": "sql/scum-db-v57/events.sql",
"pollIntervalSeconds": 1800,
"rowTarget": { "rowTarget": {
"collection": "scum_native_event_rounds", "collection": "scum_native_event_rounds",
"upsertKeys": ["eventRecordId"], "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" } "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, "maxRows": 500,
@@ -437,15 +460,54 @@
"parameterSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json", "parameterSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.result.schema.json", "resultSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.result.schema.json",
"sqlRef": "sql/scum-db-v57/native-timed-gifts.sql", "sqlRef": "sql/scum-db-v57/native-timed-gifts.sql",
"pollIntervalSeconds": 1800,
"rowTarget": { "rowTarget": {
"collection": "scum_timed_gift_events", "collection": "scum_timed_gift_events",
"upsertKeys": ["timedGiftId"], "upsertKeys": ["timedGiftId"],
"writeMode": "replace",
"columnMappings": { "timedGiftId": "timedGiftId", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt" } "columnMappings": { "timedGiftId": "timedGiftId", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt" }
}, },
"maxRows": 500, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15
} }
], ],
"logProjections": [
{
"key": "scum.battleye.login",
"streamKeys": ["scum.console.stdout"],
"steps": [
{ "pattern": "Player \"(?P<displayName>[^\"]+)\" reported as player (?P<slot>\\d+)" },
{ "pattern": "Player (?P<slot>\\d+) SteamID \\(assumed\\): (?P<steamId>\\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": [ "dataPacks": [
{ {
"key": "scum-db-v57", "key": "scum-db-v57",
@@ -3,12 +3,12 @@
"title": "SCUMAnnouncementPayload", "title": "SCUMAnnouncementPayload",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["message"], "required": ["requestText"],
"properties": { "properties": {
"message": { "requestText": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"maxLength": 500 "maxLength": 2048
} }
} }
} }
@@ -23,13 +23,11 @@
}, },
"durationSeconds": { "durationSeconds": {
"type": "integer", "type": "integer",
"minimum": 30, "minimum": 1
"maximum": 86400
}, },
"maxParticipants": { "maxParticipants": {
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1
"maximum": 1000
}, },
"announce": { "announce": {
"type": "boolean" "type": "boolean"
@@ -43,13 +41,12 @@
"minimum": 0, "minimum": 0,
"maximum": 100 "maximum": 100
}, },
"npc": { "type": "integer", "minimum": 0, "maximum": 10000 }, "npc": { "type": "integer", "minimum": 0 },
"item": { "type": "integer", "minimum": 0, "maximum": 10000 }, "item": { "type": "integer", "minimum": 0 },
"zombie": { "type": "integer", "minimum": 0, "maximum": 10000 }, "zombie": { "type": "integer", "minimum": 0 },
"animal": { "type": "integer", "minimum": 0, "maximum": 10000 }, "animal": { "type": "integer", "minimum": 0 },
"produces": { "produces": {
"type": "array", "type": "array",
"maxItems": 100,
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -57,11 +54,11 @@
"properties": { "properties": {
"tradeGoodsId": { "type": "string", "minLength": 1, "maxLength": 128 }, "tradeGoodsId": { "type": "string", "minLength": 1, "maxLength": 128 },
"percent": { "type": "integer", "minimum": 0, "maximum": 100 }, "percent": { "type": "integer", "minimum": 0, "maximum": 100 },
"value": { "type": "integer", "minimum": 1, "maximum": 10000 }, "value": { "type": "integer", "minimum": 1 },
"r": { "type": "number", "minimum": 0, "maximum": 2000000 }, "r": { "type": "number", "minimum": 0 },
"x": { "type": "number", "minimum": -2000000, "maximum": 2000000 }, "x": { "type": "number" },
"y": { "type": "number", "minimum": -2000000, "maximum": 2000000 }, "y": { "type": "number" },
"z": { "type": "number", "minimum": -2000000, "maximum": 2000000 } "z": { "type": "number" }
} }
} }
}, },
@@ -14,7 +14,7 @@
"required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"], "required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"],
"properties": { "properties": {
"gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "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 }, "steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 }, "squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
@@ -16,7 +16,7 @@
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 }, "squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 }, "userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": ["string", "null"], "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 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"rank": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 }, "rank": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 },
"isLeader": { "type": "integer", "minimum": 0, "maximum": 1 } "isLeader": { "type": "integer", "minimum": 0, "maximum": 1 }
@@ -17,20 +17,18 @@
}, },
"items": { "items": {
"type": "array", "type": "array",
"maxItems": 8,
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["catalogCode", "quantity"], "required": ["catalogCode", "quantity"],
"properties": { "properties": {
"catalogCode": { "type": "string", "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]{1,128}$" }, "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": { "operations": {
"type": "array", "type": "array",
"maxItems": 1000,
"items": { "items": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
@@ -1,6 +1,6 @@
SELECT SELECT
'player' AS subjectType, 'player' AS subjectType,
CAST(profile.id AS TEXT) AS subjectId, account.id AS subjectId,
CAST(profile.id AS TEXT) AS userProfileId, CAST(profile.id AS TEXT) AS userProfileId,
CAST(prisoner.id AS TEXT) AS gamePlayerId, CAST(prisoner.id AS TEXT) AS gamePlayerId,
NULL AS vehicleId, NULL AS vehicleId,
@@ -11,11 +11,12 @@ SELECT
entity.location_z AS z, entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt
FROM user_profile profile FROM user_profile profile
JOIN user account ON account.id = profile.user_id
JOIN prisoner ON prisoner.id = profile.prisoner_id JOIN prisoner ON prisoner.id = profile.prisoner_id
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
JOIN entity ON entity.id = prisoner_entity.entity_id JOIN entity ON entity.id = prisoner_entity.entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'player') 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 UNION ALL
SELECT SELECT
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL, 'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL,
@@ -8,7 +8,7 @@ SELECT
CASE WHEN member.rank = 4 THEN 1 ELSE 0 END AS isLeader CASE WHEN member.rank = 4 THEN 1 ELSE 0 END AS isLeader
FROM squad_member member FROM squad_member member
JOIN user_profile profile ON profile.id = member.user_profile_id 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) 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) AND (:userProfileId IS NULL OR CAST(member.user_profile_id AS TEXT) = :userProfileId)
ORDER BY member.squad_id, member.rank DESC, profile.name ORDER BY member.squad_id, member.rank DESC, profile.name
@@ -1,8 +1,8 @@
SELECT SELECT
CAST(squad.id AS TEXT) AS squadId, CAST(squad.id AS TEXT) AS squadId,
COALESCE(squad.name, '') AS name, COALESCE(squad.name, '') AS name,
CAST(leader.user_profile_id AS TEXT) AS leaderProfileId, MAX(CASE WHEN member.rank = 4 THEN CAST(member.user_profile_id AS TEXT) END) AS leaderProfileId,
CAST(leader_profile.prisoner_id AS TEXT) AS leaderPlayerId, MAX(CASE WHEN member.rank = 4 THEN CAST(member_profile.prisoner_id AS TEXT) END) AS leaderPlayerId,
COUNT(member.id) AS memberCount, COUNT(member.id) AS memberCount,
squad.score AS score, squad.score AS score,
squad.member_limit AS memberLimit, squad.member_limit AS memberLimit,
@@ -11,8 +11,7 @@ SELECT
squad.last_member_login_time AS lastMemberLoginTime squad.last_member_login_time AS lastMemberLoginTime
FROM squad FROM squad
LEFT JOIN squad_member member ON member.squad_id = squad.id 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 member_profile ON member_profile.id = member.user_profile_id
LEFT JOIN user_profile leader_profile ON leader_profile.id = leader.user_profile_id
WHERE (:squadId IS NULL OR CAST(squad.id AS TEXT) = :squadId) WHERE (:squadId IS NULL OR CAST(squad.id AS TEXT) = :squadId)
AND (:search IS NULL OR COALESCE(squad.name, '') LIKE '%' || :search || '%') AND (:search IS NULL OR COALESCE(squad.name, '') LIKE '%' || :search || '%')
GROUP BY squad.id GROUP BY squad.id
@@ -2,7 +2,7 @@ SELECT
CAST(profile.id AS TEXT) AS userProfileId, CAST(profile.id AS TEXT) AS userProfileId,
account.id AS steamId, account.id AS steamId,
CAST(prisoner.id AS TEXT) AS gamePlayerId, 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, CAST(member.squad_id AS TEXT) AS squadId,
squad.name AS squadName, squad.name AS squadName,
profile.fame_points AS famePoints, profile.fame_points AS famePoints,
@@ -13,8 +13,8 @@ SELECT
entity.location_z AS z, entity.location_z AS z,
profile.last_login_time AS lastLoginTime, profile.last_login_time AS lastLoginTime,
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS lastSaveTime strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS lastSaveTime
FROM user_profile profile FROM user account
JOIN user account ON account.id = profile.user_id LEFT JOIN user_profile profile ON profile.user_id = account.id
LEFT JOIN prisoner ON prisoner.id = profile.prisoner_id LEFT JOIN prisoner ON prisoner.id = profile.prisoner_id
LEFT JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id LEFT JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
LEFT JOIN entity ON entity.id = prisoner_entity.entity_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 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) WHERE (:userProfileId IS NULL OR CAST(profile.id AS TEXT) = :userProfileId)
AND (:steamId IS NULL OR account.id = :steamId) AND (:steamId IS NULL OR account.id = :steamId)
AND (:search IS NULL OR COALESCE(profile.name, account.name, '') LIKE '%' || :search || '%') AND (:search IS NULL OR COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) LIKE '%' || :search || '%')
GROUP BY profile.id GROUP BY account.id
ORDER BY profile.last_login_time DESC ORDER BY profile.last_login_time DESC
LIMIT COALESCE(:limit, 500) LIMIT COALESCE(:limit, 500)
@@ -259,6 +259,11 @@
"items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" }, "items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" },
"maxItems": 128 "maxItems": 128
}, },
"logProjections": {
"type": "array",
"items": { "$ref": "#/$defs/gameClientBridgeLogProjection" },
"maxItems": 128
},
"dataPacks": { "dataPacks": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/gameClientBridgeDataPack" }, "items": { "$ref": "#/$defs/gameClientBridgeDataPack" },
@@ -356,17 +361,76 @@
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" }, "sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
"rowTarget": { "$ref": "#/$defs/pluginDataRowTarget" }, "rowTarget": { "$ref": "#/$defs/pluginDataRowTarget" },
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 }, "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": { "pluginDataRowTarget": {
"type": "object", "type": "object",
"required": ["collection", "upsertKeys", "columnMappings"], "required": ["collection", "upsertKeys", "columnMappings", "writeMode"],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" }, "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 }, "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": { "gameClientBridgeDataPack": {
+101 -2
View File
@@ -17,6 +17,19 @@ function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined):
return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`); 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 { function unsafeFieldReason(fieldName: string): string | undefined {
const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, ""); const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) { if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) {
@@ -673,9 +686,25 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
parameterSchemaRef?: string; parameterSchemaRef?: string;
resultSchemaRef?: string; resultSchemaRef?: string;
sqlRef?: string; sqlRef?: string;
rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record<string, string> }; rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record<string, string>; writeMode?: string };
maxRows?: number; maxRows?: number;
timeoutSeconds?: number; timeoutSeconds?: number;
pollIntervalSeconds?: number;
};
type BridgeLogProjectionTarget = { collection?: string; upsertKeys?: string[]; captureMappings?: Record<string, string>; fixedValues?: Record<string, string>; 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 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 }; 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[] }; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
pages?: PluginPage[]; pages?: PluginPage[];
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] }; 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; const bridge = declaration.gameClientBridge;
if (!bridge) { if (!bridge) {
@@ -728,6 +757,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
const commands = new Set<string>(); const commands = new Set<string>();
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? "")); const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
const queryTemplates = new Map<string, BridgeQueryTemplate>(); const queryTemplates = new Map<string, BridgeQueryTemplate>();
const logProjections = new Set<string>();
const operationTemplates = new Map<string, BridgeOperationTemplate>(); const operationTemplates = new Map<string, BridgeOperationTemplate>();
const declaredPermissions = new Set(declaration.permissions ?? []); const declaredPermissions = new Set(declaration.permissions ?? []);
const declaredCapabilities = new Set(declaration.capabilities ?? []); const declaredCapabilities = new Set(declaration.capabilities ?? []);
@@ -851,6 +881,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
const mappings = target?.columnMappings; 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(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 (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) { 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`); 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) { 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`); 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); const transportProfile = transportProfiles.find((profile) => profile.key === queryTemplate.transportKey);
if (!transportProfile) { if (!transportProfile) {
errors.push(`${location}.transportKey: undeclared transport profile ${queryTemplate.transportKey ?? ""}`); 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`); 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>): 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<string>();
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()) { for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
const location = `manifest.gameClientBridge.operationTemplates[${index}]`; const location = `manifest.gameClientBridge.operationTemplates[${index}]`;
const key = operationTemplate.key ?? ""; const key = operationTemplate.key ?? "";
+40
View File
@@ -265,6 +265,7 @@ export interface GameClientBridgeQueryTemplateDeclaration {
sqlRef?: string; sqlRef?: string;
maxRows: number; maxRows: number;
timeoutSeconds: number; timeoutSeconds: number;
pollIntervalSeconds?: number;
rowTarget?: PluginDataRowTargetDeclaration; rowTarget?: PluginDataRowTargetDeclaration;
} }
@@ -272,6 +273,44 @@ export interface PluginDataRowTargetDeclaration {
collection: string; collection: string;
upsertKeys: string[]; upsertKeys: string[];
columnMappings: Record<string, string>; columnMappings: Record<string, string>;
writeMode: "merge" | "replace";
}
export interface GameClientBridgeLogProjectionStepDeclaration {
pattern: string;
}
export interface GameClientBridgeLogProjectionTargetDeclaration {
collection: string;
upsertKeys: string[];
captureMappings: Record<string, string>;
fixedValues?: Record<string, string>;
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 { export interface GameClientBridgeDataPackDeclaration {
@@ -352,6 +391,7 @@ export interface GameClientBridgeManifest {
commands: GameClientBridgeCommandDeclaration[]; commands: GameClientBridgeCommandDeclaration[];
snapshots: GameClientBridgeSnapshotDeclaration[]; snapshots: GameClientBridgeSnapshotDeclaration[];
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[]; queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
logProjections?: GameClientBridgeLogProjectionDeclaration[];
dataPacks?: GameClientBridgeDataPackDeclaration[]; dataPacks?: GameClientBridgeDataPackDeclaration[];
operationTemplates?: GameClientBridgeOperationTemplateDeclaration[]; operationTemplates?: GameClientBridgeOperationTemplateDeclaration[];
commandRetentionSeconds: number; commandRetentionSeconds: number;
+90 -9
View File
@@ -26,6 +26,7 @@ import {
parseBridgeExecutionResponse, parseBridgeExecutionResponse,
parseAIInvocationResponse, parseAIInvocationResponse,
type GameClientBridgeQueryTemplateDeclaration, type GameClientBridgeQueryTemplateDeclaration,
type GameClientBridgeLogProjectionDeclaration,
type GameClientBridgeOperationTemplateDeclaration, type GameClientBridgeOperationTemplateDeclaration,
type GameClientBridgeProtectedRequestDeclaration, type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration, type GameClientBridgeCompanionDeclaration,
@@ -35,7 +36,7 @@ import {
type PluginLifecycleActionDeclaration, type PluginLifecycleActionDeclaration,
type PluginBridgeContext type PluginBridgeContext
} from "../sdk/index.js"; } 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)); 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([]); 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 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); 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.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.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"])); 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); 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<displayName>[^\"]+)" reported as player (?P<slot>\\d+)',
"Player (?P<slot>\\d+) SteamID \\(assumed\\): (?P<steamId>\\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", () => { it("declares SCUM install/update and start lifecycle through plugin assets", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any; const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any;
@@ -525,7 +545,7 @@ describe("plugin manifest validation", () => {
}; };
}; };
const expected = { 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" }, "companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" },
"player.lookup": { 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" }, "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))); const schemaRefs = manifest.gameClientBridge.commands.flatMap((command) => [command.payloadSchemaRef, command.resultSchemaRef].filter((ref): ref is string => Boolean(ref)));
for (const schemaRef of schemaRefs) { for (const schemaRef of schemaRefs) {
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record<string, unknown>; const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record<string, unknown>;
const hasPluginInventedCountLimitsRemoved = ["schemas/bridge/reward-deliver.payload.schema.json", "schemas/bridge/event-start.payload.schema.json"].includes(schemaRef);
const visit = (value: unknown): void => { const visit = (value: unknown): void => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach(visit); value.forEach(visit);
@@ -565,13 +586,15 @@ describe("plugin manifest validation", () => {
expect(record.additionalProperties).toBe(false); expect(record.additionalProperties).toBe(false);
} }
if (record.type === "array") { if (record.type === "array") {
expect(record.maxItems).toBeGreaterThan(0); expect(record.items).toBeDefined();
if (!hasPluginInventedCountLimitsRemoved) expect(record.maxItems).toBeGreaterThan(0);
} }
if (record.type === "string") { if (record.type === "string") {
expect(record.maxLength).toBeGreaterThan(0); expect(record.maxLength).toBeGreaterThan(0);
} }
if (record.type === "integer" || record.type === "number") { 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); Object.values(record).forEach(visit);
}; };
@@ -634,7 +657,8 @@ describe("plugin manifest validation", () => {
parameterSchemaRef: string; parameterSchemaRef: string;
resultSchemaRef: string; resultSchemaRef: string;
sqlRef: string; sqlRef: string;
rowTarget: { collection: string; upsertKeys: string[]; columnMappings: Record<string, string> }; pollIntervalSeconds: number;
rowTarget: { collection: string; upsertKeys: string[]; writeMode: "merge" | "replace"; columnMappings: Record<string, string> };
maxRows: number; maxRows: number;
timeoutSeconds: 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.events": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"],
"scum.native-timed-gifts": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"] "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])); const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query"); 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.targetKey).toBe("scum-database");
expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/); expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/);
expect(template.rowTarget.collection).toMatch(/^scum_/); 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.length).toBeGreaterThan(0);
expect(template.rowTarget.upsertKeys.every((upsertKey) => upsertKey in template.rowTarget.columnMappings)).toBe(true); expect(template.rowTarget.upsertKeys.every((upsertKey) => upsertKey in template.rowTarget.columnMappings)).toBe(true);
expect(fs.existsSync(path.join(pluginDir, template.sqlRef))).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(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 playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads"); const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map"); const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
@@ -884,6 +917,7 @@ describe("plugin manifest validation", () => {
manifest.gameClientBridge = { 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 }], 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 }], 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 \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" }, { pattern: "Player (?<slot>\\\\d+) SteamID: (?<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, commandRetentionSeconds: 86400,
maxCommands: 1000, maxCommands: 1000,
pages: [] pages: []
@@ -894,6 +928,40 @@ describe("plugin manifest validation", () => {
expect(validate(manifest)).toBe(false); 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 \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" },
{ pattern: "Player (?<slot>\\\\d+) SteamID: (?<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", () => { it("loads and validates every schema referenced by a safe game-client bridge manifest", () => {
expect(validateTemporaryBridgeManifest()).toEqual([]); expect(validateTemporaryBridgeManifest()).toEqual([]);
}); });
@@ -1140,12 +1208,25 @@ describe("plugin SDK", () => {
parameterSchemaRef: "schemas/bridge/queries/player-by-id.parameters.schema.json", parameterSchemaRef: "schemas/bridge/queries/player-by-id.parameters.schema.json",
resultSchemaRef: "schemas/bridge/queries/player-by-id.result.schema.json", resultSchemaRef: "schemas/bridge/queries/player-by-id.result.schema.json",
maxRows: 1, maxRows: 1,
timeoutSeconds: 10 timeoutSeconds: 10,
pollIntervalSeconds: 0
}; };
expect(declaration).toMatchObject({ engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", maxRows: 1 }); 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/); 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 (?<slot>\\d+) SteamID: (?<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", () => { it("types controlled operation template declarations", () => {
const declaration: GameClientBridgeOperationTemplateDeclaration = { const declaration: GameClientBridgeOperationTemplateDeclaration = {
key: "player.attribute.855.set", key: "player.attribute.855.set",
+30 -31
View File
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; 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 { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.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"; 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` }); 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 pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } });
const gameClient = gameClientActions(); 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"); const data = await loadSCUMSurface({ pluginData, gameClient }, "players");
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "online.sessions"]); 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:01:00Z" }); expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:00:00Z" });
expect(mergePlayerSnapshots([{ gamePlayerId: "steam-2", displayName: "Noah" }], { items: [] }, { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { sessions: [] } }] })[0]).toMatchObject({ online: false }); 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 () => { 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 })); const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows"); 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]); expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
const dispatch = dispatchAction(); await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "activity");
await requestSCUMPageQueries({ dispatch }, "workflows"); expect(list.mock.calls.slice(-6).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
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" } });
}); });
it("uses transaction, put, and delete for plugin-owned gift data", async () => { 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(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(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("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 saveGiftDefinition(actions, { code: "starter", name: "Starter", items: [] });
await createGiftDelivery(actions, { id: "delivery-1", giftCode: "starter", playerId: "steam-1" }); await createGiftDelivery(actions, { id: "delivery-1", giftCode: "starter", playerId: "steam-1" });
await deleteGiftDefinition(actions, "starter"); 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", () => { it("renders searchable user management from real collection values", () => {
const view = renderAndCollect(); const view = renderAndCollect();
expect(view.nodes).toContain("section:用户管理"); expect(view.nodes).toContain("section:用户管理");
expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步"); expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步");
expect(view.texts).toContain("通用数据/机器动作可用"); 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.inputs.map((input) => input.label)).toContain("搜索用户");
expect(view.texts).toContain("Mira"); expect(view.texts).toContain("Mira");
expect(view.texts.join("\n")).toContain("Steam 76561198000000001"); 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", () => { it("renders activity definitions, status filters, runs, and records", () => {
const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" }); const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" });
expect(view.inputs.map((input) => input.label)).toContain("活动状态"); 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("Friday Range");
expect(view.texts).toContain("running"); expect(view.texts).toContain("running");
expect(view.texts).toContain("最近活动记录"); 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 () => { 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); expect(collectMapPoints(duplicateData)).toHaveLength(264);
const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 }); const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 });
expect(bounds).toEqual({ worldMinX: -100000, worldMinY: 100000, worldMaxX: 300000, worldMaxY: 300000 }); 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}`; const source = `${pageSource}\n${dataClientSource}`;
for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "SELECT ", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden); 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("pluginData");
expect(source).toContain("remote.access.request"); expect(source).not.toContain("remote.access.request");
expect(source).toContain("input.templateKey"); 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<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ requestId: envelope.requestId, action: envelope.action, status: "queued" }));
}
function gameClientActions() { function gameClientActions() {
const queue = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["queue"]>(async () => ({ id: "command-1", state: "pending" })); const queue = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["queue"]>(async () => ({ id: "command-1", state: "pending" }));
const get = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["get"]>(async () => ({ id: "command-1", state: "pending" })); const get = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["get"]>(async () => ({ id: "command-1", state: "pending" }));
@@ -310,7 +309,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
return [value, () => undefined]; return [value, () => undefined];
} }
}; };
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: dispatchAction() }; const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions() };
renderPluginPage(react, { renderPluginPage(react, {
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] }, context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },