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