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