From a0e7ae362b5323c341b4adf502e5fe70a73085be Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Thu, 27 Aug 2026 16:14:59 +0800 Subject: [PATCH] Add SCUM trade catalog projections --- platform/domain/game_client_bridge.go | 1 + platform/domain/game_client_bridge_test.go | 4 +- platform/dto/resources.go | 5 +- platform/dto/resources_test.go | 7 +- platform/service/plugin_data_test.go | 10 +++ .../service/plugin_log_projection_test.go | 53 +++++++++++ platform/service/plugin_query_projection.go | 9 ++ platform_web/api/types.ts | 14 +++ .../scum-server-plugin/features/page-data.ts | 12 ++- .../scum-server-plugin/features/page.ts | 35 ++++++-- .../examples/scum-server-plugin/manifest.json | 90 +++++++++++++++++++ .../log-events/trade.event.schema.json | 5 ++ .../game-plugin.manifest.schema.json | 3 +- plugins/sdk/index.ts | 13 +++ plugins/tests/manifest-validation.test.ts | 10 ++- plugins/tests/scum-feature-module.test.ts | 19 +++- 16 files changed, 267 insertions(+), 23 deletions(-) diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index f698d98..1168536 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -56,6 +56,7 @@ type GameClientBridgeQueryProjectionDeclaration struct { FieldMappings map[string]string FixedValues map[string]string ObservedAtField string + MergeExisting bool } type GameClientBridgeLogProjectionStepDeclaration struct { diff --git a/platform/domain/game_client_bridge_test.go b/platform/domain/game_client_bridge_test.go index 795f94c..67b64dc 100644 --- a/platform/domain/game_client_bridge_test.go +++ b/platform/domain/game_client_bridge_test.go @@ -4,7 +4,7 @@ import "testing" func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { manifest := GameClientBridgeManifest{ - QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql", Projections: []GameClientBridgeQueryProjectionDeclaration{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt"}}}}, + QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql", Projections: []GameClientBridgeQueryProjectionDeclaration{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}}}}, LogProjections: []GameClientBridgeLogProjectionDeclaration{{ Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, HashMappings: map[string]string{"networkCorrelation": "ip"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, @@ -26,7 +26,7 @@ func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated" manifestCopy.DataPacks[0].DataRefs[0] = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" - if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.LifecycleProjections[0].Capabilities[0] != "process.stop" || manifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { + if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || !manifest.QueryTemplates[0].Projections[0].MergeExisting || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.LifecycleProjections[0].Capabilities[0] != "process.stop" || manifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy) } diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 2e2335f..f82d901 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -303,6 +303,7 @@ type GameClientBridgeQueryProjectionDeclarationBody struct { FieldMappings map[string]string `json:"fieldMappings,omitempty"` FixedValues map[string]string `json:"fixedValues,omitempty"` ObservedAtField string `json:"observedAtField,omitempty"` + MergeExisting bool `json:"mergeExisting,omitempty"` } type GameClientBridgeLogProjectionStepDeclarationBody struct { @@ -1339,7 +1340,7 @@ func gameClientBridgeQueryProjectionsToDomain(values []GameClientBridgeQueryProj } out := make([]domain.GameClientBridgeQueryProjectionDeclaration, len(values)) for index, value := range values { - out[index] = domain.GameClientBridgeQueryProjectionDeclaration{Collection: value.Collection, RowPath: value.RowPath, MatchField: value.MatchField, MatchValue: value.MatchValue, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), FieldMappings: domain.CopyStringMap(value.FieldMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} + out[index] = domain.GameClientBridgeQueryProjectionDeclaration{Collection: value.Collection, RowPath: value.RowPath, MatchField: value.MatchField, MatchValue: value.MatchValue, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), FieldMappings: domain.CopyStringMap(value.FieldMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField, MergeExisting: value.MergeExisting} } return out } @@ -1838,7 +1839,7 @@ func gameClientBridgeQueryProjectionsFromDomain(values []domain.GameClientBridge } out := make([]GameClientBridgeQueryProjectionDeclarationBody, len(values)) for index, value := range values { - out[index] = GameClientBridgeQueryProjectionDeclarationBody{Collection: value.Collection, RowPath: value.RowPath, MatchField: value.MatchField, MatchValue: value.MatchValue, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), FieldMappings: domain.CopyStringMap(value.FieldMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField} + out[index] = GameClientBridgeQueryProjectionDeclarationBody{Collection: value.Collection, RowPath: value.RowPath, MatchField: value.MatchField, MatchValue: value.MatchValue, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), FieldMappings: domain.CopyStringMap(value.FieldMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField, MergeExisting: value.MergeExisting} } return out } diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index b893677..f512fdd 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -137,7 +137,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{ Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, PollIntervalSeconds: 3, - Projections: []GameClientBridgeQueryProjectionDeclarationBody{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt"}}, + Projections: []GameClientBridgeQueryProjectionDeclarationBody{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}}, }}, LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{ Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, @@ -152,7 +152,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { } domainManifest := body.ToDomain() - if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.QueryTemplates[0].Projections) != 1 || domainManifest.QueryTemplates[0].Projections[0].MatchValue != "player" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || domainManifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || len(domainManifest.LifecycleProjections) != 1 || domainManifest.LifecycleProjections[0].Target.ActivityTarget.FixedValues["eventType"] != "logout" || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || 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 || len(domainManifest.QueryTemplates[0].Projections) != 1 || domainManifest.QueryTemplates[0].Projections[0].MatchValue != "player" || !domainManifest.QueryTemplates[0].Projections[0].MergeExisting || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || domainManifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || len(domainManifest.LifecycleProjections) != 1 || domainManifest.LifecycleProjections[0].Target.ActivityTarget.FixedValues["eventType"] != "logout" || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest) } domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated" @@ -182,6 +182,9 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { domainManifest.DataPacks[0].DataRefs[0] = "data/items.json" response := gameClientBridgeManifestFromDomain(domainManifest) + if !response.QueryTemplates[0].Projections[0].MergeExisting { + t.Fatal("query projection mergeExisting was not preserved") + } response.QueryTemplates[0].Projections[0].FixedValues["source"] = "mutated" if domainManifest.QueryTemplates[0].Projections[0].FixedValues["source"] != "sqlite" { t.Fatal("query projection target aliases domain data") diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go index c0e9d9b..e337512 100644 --- a/platform/service/plugin_data_test.go +++ b/platform/service/plugin_data_test.go @@ -163,10 +163,16 @@ func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) { plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{ Collection: "scum_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", + }, { + Collection: "scum_trade_goods", RowPath: "rows", MatchField: "kind", MatchValue: "vehicle", UpsertKeys: []string{"code"}, + FieldMappings: map[string]string{"className": "displayName"}, FixedValues: map[string]string{"code": "#spawnvehicle {{displayName}}", "spawnCommand": "#spawnvehicle {{displayName}}", "catalogType": "vehicle", "type": "21", "typeName": "其他载具", "imagePath": "/original/{{displayName}}.webp", "source": "sqlite"}, ObservedAtField: "lastSeenAt", MergeExisting: true, }} if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("enable query projection polling: %v", err) } + if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "#spawnvehicle Truck", Value: map[string]any{"code": "#spawnvehicle Truck", "name": "Named Truck"}}}}); err != nil { + t.Fatalf("seed vehicle catalog: %v", err) + } helloRequest := validRunControlHello() helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler" @@ -189,6 +195,10 @@ func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) { if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" || items[0].Value["source"] != "sqlite" || items[0].Value["sampledAt"] == nil { t.Fatalf("declared projection did not write scoped plugin data=%+v err=%v", items, err) } + goods, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"}) + if err != nil || len(goods) != 1 || goods[0].Key != "#spawnvehicle Truck" || goods[0].Value["name"] != "Named Truck" || goods[0].Value["className"] != "Truck" || goods[0].Value["type"] != "21" || goods[0].Value["lastSeenAt"] == nil { + t.Fatalf("declared vehicle catalog projection did not merge scoped plugin data=%+v err=%v", goods, err) + } second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}}) if err != nil || second.HasJob { t.Fatalf("fresh projection poll should not reschedule immediately: %+v err=%v", second, err) diff --git a/platform/service/plugin_log_projection_test.go b/platform/service/plugin_log_projection_test.go index 918c874..ce23a49 100644 --- a/platform/service/plugin_log_projection_test.go +++ b/platform/service/plugin_log_projection_test.go @@ -71,6 +71,46 @@ func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *test assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 0) } +func TestTradeLogProjectionsCreateCatalogAndTradeEvents(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + pattern := `^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}: \[Trade\] Tradeable \((?P[A-Za-z0-9_.-]{1,128}) \(x(?P\d{1,9})\)\) (?Ppurchased|sold) by .*?\((?P\d{1,50})\) for (?P-?\d{1,12})$` + plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{ + Key: "scum.trade.catalog", StreamKeys: []string{"scum.trade"}, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{{Pattern: pattern}}, CorrelationFields: []string{"itemCode"}, MaxInterveningLines: 0, + Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_trade_goods", UpsertKeys: []string{"code"}, CaptureMappings: map[string]string{"code": "itemCode"}, FixedValues: map[string]string{"catalogType": "item", "source": "scum.trade"}, ObservedAtField: "lastSeenAt"}, + }, { + Key: "scum.trade.events", StreamKeys: []string{"scum.trade"}, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{{Pattern: pattern}}, CorrelationFields: []string{"steamId", "itemCode", "tradeVerb"}, MaxInterveningLines: 0, + Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_trade_events", UpsertKeys: []string{"steamId", "itemCode", "tradeVerb", "quantity", "price", "observedAt"}, CaptureMappings: map[string]string{"steamId": "steamId", "itemCode": "itemCode", "tradeVerb": "tradeVerb", "quantity": "quantity", "price": "price"}, FixedValues: map[string]string{"eventType": "trade", "source": "scum.trade"}, ObservedAtField: "observedAt"}, + }} + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update trade projections: %v", err) + } + instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-trade-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM trade projection", State: domain.ServerInstanceStateRunning}) + if err != nil { + t.Fatalf("create server: %v", err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Fingerprint = "cap-trade-log-projection" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register Run: %v", err) + } + stream, err := svc.CreateLogStream(domain.LogStream{ID: "trade-log-projection", ServerInstanceID: instance.ID, Source: domain.LogStreamSourceFile, StreamKey: "scum.trade", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"}) + if err != nil { + t.Fatalf("create trade stream: %v", err) + } + base := time.Date(2026, 8, 27, 12, 34, 56, 0, time.UTC) + ingestTradeProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 1, base, []string{`2026.08.27-12.34.56: [Trade] Tradeable (BPC_Apple (x2)) purchased by Mira(76561198000000001) for 120`}) + goods, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"}) + if err != nil || len(goods) != 1 || goods[0].Key != "BPC_Apple" || goods[0].Value["catalogType"] != "item" || goods[0].Value["lastSeenAt"] == nil { + t.Fatalf("trade item catalog was not projected: %+v err=%v", goods, err) + } + trades, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_events"}) + if err != nil || len(trades) != 1 || trades[0].Value["itemCode"] != "BPC_Apple" || trades[0].Value["quantity"] != "2" || trades[0].Value["tradeVerb"] != "purchased" || trades[0].Value["steamId"] != "76561198000000001" || trades[0].Value["price"] != "120" { + t.Fatalf("trade event was not projected: %+v err=%v", trades, err) + } +} + func TestLifecycleProjectionMarksOnlineUsersOffline(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) @@ -167,6 +207,19 @@ func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpoin } } +func ingestTradeProjectionLines(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: "info", Line: line} + } + lastSeq := firstSeq + uint64(len(entries)) - 1 + batch := domain.LogBatchIngest{RunEndpointID: endpointID, SessionToken: sessionToken, LogStreamID: streamID, ServerInstanceID: serverID, StreamKey: "scum.trade", Source: domain.LogStreamSourceFile, 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 trade 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"}) diff --git a/platform/service/plugin_query_projection.go b/platform/service/plugin_query_projection.go index 4f3a26c..1c2d09c 100644 --- a/platform/service/plugin_query_projection.go +++ b/platform/service/plugin_query_projection.go @@ -3,11 +3,13 @@ package service import ( "bytes" "encoding/json" + "errors" "fmt" "strings" "time" "browser.local/platform/domain" + "browser.local/platform/repo" ) func (svc *CoreService) scheduleDuePluginQueryProjectionJobs(claim domain.RunJobClaim, stamp time.Time) error { @@ -147,6 +149,13 @@ func (svc *CoreService) projectPluginQueryJobResult(job domain.Job, stamp time.T if keyErr != nil { return keyErr } + if projection.MergeExisting { + if existing, existingErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, projection.Collection, key)); existingErr == nil { + value = mergePluginDataValues(existing.Value, value) + } else if !errors.Is(existingErr, repo.ErrNotFound) { + return existingErr + } + } collectionMutations[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value} } } diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 24818a7..2b6c661 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -51,6 +51,20 @@ export interface GameClientBridgeQueryTemplateDeclarationResponse { sqlRef?: string; maxRows: number; timeoutSeconds: number; + pollIntervalSeconds?: number; + projections?: GameClientBridgeQueryProjectionDeclarationResponse[]; +} + +export interface GameClientBridgeQueryProjectionDeclarationResponse { + collection: string; + rowPath: "rows"; + matchField?: string; + matchValue?: string; + upsertKeys: string[]; + fieldMappings?: Record; + fixedValues?: Record; + observedAtField?: string; + mergeExisting?: boolean; } export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; } diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index e30d88c..bd20929 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -101,6 +101,8 @@ export type SCUMSurfaceData = { nativeEventRounds: RecordMap[]; tasks: RecordMap[]; activityEvents: RecordMap[]; + tradeGoods: RecordMap[]; + tradeEvents: RecordMap[]; gifts: RecordMap[]; giftClaims: RecordMap[]; pendingGifts: RecordMap[]; @@ -116,7 +118,7 @@ export type SCUMSurfaceData = { export const emptySCUMSurfaceData: SCUMSurfaceData = { players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [], - gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: [], trajectories: [] + tradeGoods: [], tradeEvents: [], gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: [], trajectories: [] }; export const scumCollections = { @@ -129,6 +131,8 @@ export const scumCollections = { nativeEventRounds: "scum_native_event_rounds", tasks: "scum_tasks", activityEvents: "scum_activity_events", + tradeGoods: "scum_trade_goods", + tradeEvents: "scum_trade_events", gifts: "scum_gifts", giftClaims: "scum_gift_claims", pendingGifts: "scum_pending_gifts", @@ -148,9 +152,9 @@ type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; const pageCollections: Record = { players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries", "trajectories", "vehicles"], squads: ["squads", "members", "flags"], - "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags", "trajectories"], - gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"], - workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"] + "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags", "trajectories", "tradeGoods"], + gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players", "tradeGoods"], + workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"] }; export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise { diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 5786870..06f41be 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -354,6 +354,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, return view.activityStatus === "all" || status === view.activityStatus; }); const statuses = unique(data.events.map((event) => textField(runsByEvent.get(textField(event, "id", "eventId")), "status", "state") || textField(event, "status", "state")).filter(Boolean)); + const tradeEvents = recentRows(data.tradeEvents, "observedAt", "occurredAt", "createdAt"); const saveEvent = () => runAction(view.setAction, "正在保存活动定义…", async () => { const id = view.eventId.trim(); const name = view.eventName.trim(); @@ -380,7 +381,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, }); const activityHistory = data.activityEvents.filter((event) => Boolean(textField(event, "occurredAt", "createdAt")) && textField(event, "taskKind").toLowerCase() !== "active-task"); return e("div", { className: "console-record-list" }, - statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["原生赛事轮次", data.nativeEventRounds.length], ["任务", data.tasks.length]]), + statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["物品列表", data.tradeGoods.length], ["交易记录", data.tradeEvents.length]]), e("div", { className: "overview-two-col" }, e("details", { className: "console-module scum-editor", open: view.eventEditorOpen, onToggle: (event: InputEvent) => view.setEventEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新活动"), e("span", { className: "page-status" }, view.eventId || view.eventName ? "编辑中" : "点击展开")), labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", onChange: (event: InputEvent) => view.setEventId(inputValue(event)) })), @@ -416,7 +417,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, labeledField(e, "Z", e("input", { value: view.produceZ, "aria-label": "生成 Z", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceZ(inputValue(event)) })), e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveProduce }, "保存生成项")), e("div", { className: "console-row-list" }, data.eventProduces.length ? data.eventProduces.map((produce, index) => e("div", { key: idOf(produce, `produce-${index}`), className: "console-row" }, - e("span", null, `${textField(produce, "eventId", "event")} / ${textField(produce, "tradeGoodsId", "trade_goods_id")}`), + e("span", null, `${textField(produce, "eventId", "event")} / ${tradeGoodsLabel(data.tradeGoods, textField(produce, "tradeGoodsId", "trade_goods_id"))}`), e("strong", null, `${numField(produce, "percent")}% × ${numField(produce, "value")}`), e("strong", null, `R ${numField(produce, "r")} · ${coords(produce)}`), e("button", { type: "button", className: "icon-command", onClick: () => { view.setProduceEditorOpen(true); view.setProduceEventId(textField(produce, "eventId", "event")); view.setProduceId(textField(produce, "id", "produceId")); view.setProduceTradeGoodsId(textField(produce, "tradeGoodsId", "trade_goods_id")); view.setProducePercent(numField(produce, "percent")); view.setProduceValue(numField(produce, "value")); view.setProduceRadius(numField(produce, "r")); view.setProduceX(numField(produce, "x")); view.setProduceY(numField(produce, "y")); view.setProduceZ(numField(produce, "z")); } }, "编辑"), @@ -440,6 +441,10 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, tablePanel(e, "原生赛事轮次", data.nativeEventRounds, (event) => [textField(event, "eventId") || "event", textField(event, "state") || "unknown", `Kills ${numField(event, "enemyKills")}`, dateField(event, "startTime")]), tablePanel(e, "Quest / Task", data.tasks, (task) => [textField(task, "taskKind") || "task", textField(task, "dataAssetPath") || "unknown", textField(task, "state") || "unknown", textField(task, "userProfileId") || "unknown"]) ), + e("div", { className: "overview-two-col" }, + itemCatalogPanel(e, data.tradeGoods), + tablePanel(e, "最近商人交易", tradeEvents, (event) => [tradeGoodsLabel(data.tradeGoods, textField(event, "itemCode", "code", "tradeGoodsId")), `${tradeActionLabel(event)} × ${numField(event, "quantity", "itemCount")}`, `玩家 ${shortHash(textField(event, "steamId", "playerId"))}`, `价格 ${numField(event, "price", "currencyDelta")}`, dateField(event, "observedAt", "occurredAt", "createdAt")]) + ), tablePanel(e, "最近活动记录", [...data.eventRuns, ...activityHistory], (event) => [textField(event, "eventName", "type", "kind", "activityType") || "event", textField(event, "subjectName", "eventId", "subjectId", "subject") || "unknown", textField(event, "status", "result", "state") || "unknown", dateField(event, "startedAt", "occurredAt", "createdAt")]) ); } @@ -472,7 +477,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu return "礼包发放命令已进入执行队列。"; }); return e("div", { className: "console-record-list" }, - statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["原生定时记录", data.timedGiftEvents.length]]), + statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["物品列表", data.tradeGoods.length]]), e("div", { className: "console-row-actions", role: "tablist", "aria-label": "礼包视图" }, giftTabButton(e, view, "definitions", "礼包定义"), giftTabButton(e, view, "claims", "领取/待领"), giftTabButton(e, view, "deliveries", "发放记录"), giftTabButton(e, view, "timed", "游戏定时记录") ), @@ -499,6 +504,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu ); }) : e("p", { className: "page-status" }, "暂无礼包定义。")) ) : null, + view.giftTab === "definitions" ? itemCatalogPanel(e, data.tradeGoods) : null, view.giftTab === "claims" ? e("div", { className: "overview-two-col" }, resettableGiftPanel(e, "领取记录", data.giftClaims, "重置领取", (claim) => runAction(view.setAction, "正在重置领取记录…", async () => { await resetGiftClaim(actions ?? {}, claim); view.refresh(); return "领取记录已重置。"; }), actions), resettableGiftPanel(e, "待领礼包", data.pendingGifts, "重置待领", (pending) => runAction(view.setAction, "正在重置待领记录…", async () => { await resetPendingGift(actions ?? {}, pending); view.refresh(); return "待领状态已重置。"; }), actions) @@ -521,6 +527,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { const actions = input.workspaceActions; const points = collectMapPoints(data); + const vehicleCatalog = data.tradeGoods.filter((item) => isVehicleCatalogItem(item)); const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0]; const bounds = resolveMapBounds(settings); const customEnabled = view.mapCustomEnabled ?? Boolean(settings && boolField(settings, "customMapEnabled")); @@ -534,7 +541,7 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: const trails = visibleTrajectoryPoints(data.trajectories, view.mapLayers, search).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 180).reverse(); const selectedTrails = selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : []; return e("div", { className: "console-record-list" }, - statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["轨迹采样", data.trajectories.length]]), + statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["载具目录", vehicleCatalog.length]]), e("div", { className: "resource-filter-bar scum-filter-bar" }, labeledField(e, "筛选地图点", e("input", { value: view.mapSearch, "aria-label": "筛选地图点", placeholder: "名称 / 类型 / ID", onChange: (event: InputEvent) => view.setMapSearch(inputValue(event)) })), (["players", "vehicles", "flags", "regions", "other"] as MapLayer[]).map((layer) => e("label", { key: layer, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer))) @@ -550,7 +557,7 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: ), e("div", { className: "overview-two-col" }, e("div", { className: "map-projection-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? nearbyVehicle(point, data.vehicles) : undefined; return e("button", { key: idOf(point, `point-${index}`), type: "button", className: `map-projection-dot map-layer-${layerOf(point)}${ride ? " map-projection-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 疑似乘坐 ${pointTitle(ride)}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })), - e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected))), selectedTrails.length ? e("div", { className: "console-row-list" }, selectedTrails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "run.sqlite")))) : null) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。")) + e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected))), layerOf(selected) === "vehicles" ? e("div", { className: "console-record-meta" }, e("span", null, `类型 ${textField(selected, "className", "vehicleClass", "vehicleType") || "unknown"}`), e("span", null, `状态 ${textField(selected, "status", "state", "isFunctional") || "unknown"}`), e("span", null, `访问 ${dateField(selected, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`)) : null, selectedTrails.length ? e("div", { className: "console-row-list" }, selectedTrails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "run.sqlite")))) : null) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。")) ) ); } @@ -561,11 +568,24 @@ function giftTabButton(e: ReactLike["createElement"], view: ViewState, tab: Gift function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); } function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, render(row).map((part, partIndex) => partIndex === 0 ? e("span", { key: partIndex }, String(part ?? "unknown")) : e("strong", { key: partIndex }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实记录。"))); } function resettableGiftPanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], actionLabel: string, onReset: (row: RecordMap) => void, actions: SCUMWorkspaceActions | undefined) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, e("span", null, textField(row, "playerName", "playerId", "displayName", "userProfileId") || "unknown"), e("strong", null, textField(row, "giftName", "giftCode", "giftType") || "unknown"), e("strong", null, textField(row, "status") || "unknown"), e("strong", null, dateField(row, "claimedAt", "receivedAt", "createdAt")), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => onReset(row) }, actionLabel))) : e("p", { className: "page-status" }, "暂无真实记录。"))); } +function itemCatalogPanel(e: ReactLike["createElement"], rows: RecordMap[]) { return tablePanel(e, "物品列表", recentRows(rows, "lastSeenAt", "updatedAt", "createdAt"), (item) => [tradeGoodsName(item), tradeGoodsTypeLabel(item), textField(item, "code", "itemCode", "className") || "unknown", dateField(item, "lastSeenAt", "updatedAt", "createdAt")]); } +function tradeGoodsLabel(rows: RecordMap[], code: string): string { const item = tradeGoodsIndex(rows).get(code) ?? tradeGoodsIndex(rows).get(code.replace(/^#spawnvehicle\s+/i, "")); return item ? `${tradeGoodsName(item)} (${code})` : code || "unknown"; } +function tradeGoodsName(item: RecordMap): string { return textField(item, "nameCn", "name_cn", "name", "className") || textField(item, "code", "itemCode") || "未命名物品"; } +function tradeGoodsTypeLabel(item: RecordMap): string { return textField(item, "typeName", "type_name") || (textField(item, "type") === "21" || isVehicleCatalogItem(item) ? "其他载具" : "未知类型"); } +function tradeActionLabel(event: RecordMap): string { const value = textField(event, "tradeVerb", "tradeKind", "action").toLowerCase(); return value === "purchased" || value === "purchase" ? "买入" : value === "sold" || value === "sale" ? "卖出" : value || "交易"; } +function tradeGoodsIndex(rows: RecordMap[]): Map { const result = new Map(); for (const row of rows) for (const key of [textField(row, "code"), textField(row, "itemCode"), textField(row, "className")].filter(Boolean)) result.set(key, row); return result; } +function recentRows(rows: RecordMap[], ...keys: string[]): RecordMap[] { return [...rows].sort((left, right) => rowTime(right, keys) - rowTime(left, keys)).slice(0, 24); } +function rowTime(row: RecordMap, keys: string[]): number { for (const key of keys) { const stamp = Date.parse(textField(row, key)); if (!Number.isNaN(stamp)) return stamp; } return 0; } +function isVehicleCatalogItem(item: RecordMap): boolean { const type = textField(item, "catalogType", "kind", "typeName", "type_name", "type").toLowerCase(); const code = textField(item, "code", "spawnCommand").toLowerCase(); return type.includes("vehicle") || type.includes("载具") || textField(item, "type") === "21" || code.startsWith("#spawnvehicle"); } +function vehicleCatalogIndex(rows: RecordMap[]): Map { const result = new Map(); for (const row of rows) if (isVehicleCatalogItem(row)) for (const key of vehicleCatalogKeys(row)) result.set(key, row); return result; } +function vehicleCatalogKeys(row: RecordMap): string[] { return ["className", "vehicleClass", "entityClass", "vehicleType", "name", "code", "spawnCommand"].map((key) => vehicleClassKey(textField(row, key))).filter(Boolean); } +function enrichVehicleFromCatalog(vehicle: RecordMap, catalog: Map): RecordMap { const found = vehicleCatalogKeys(vehicle).map((key) => catalog.get(key)).find(Boolean); return found ? { ...found, ...vehicle, imagePath: textField(vehicle, "imagePath", "image_path") || textField(found, "imagePath", "image_path"), catalogCode: textField(found, "code"), spawnCommand: textField(found, "spawnCommand", "code") } : vehicle; } export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] { const direct = data.mapPoints.map((point) => ({ ...point, layer: textField(point, "layer", "subjectType", "type") || "other" })); const players = data.players.flatMap((player) => withPosition(player, "players", textField(player, "displayName"), textField(player, "steamId", "gamePlayerId", "id"))); - const vehicles = data.vehicles.flatMap((vehicle) => withPosition(vehicle, "vehicles", textField(vehicle, "label", "name"), textField(vehicle, "vehicleId", "id"))); + const catalog = vehicleCatalogIndex(data.tradeGoods); + const vehicles = data.vehicles.flatMap((vehicle) => { const enriched = enrichVehicleFromCatalog(vehicle, catalog); return withPosition(enriched, "vehicles", textField(enriched, "label", "name", "className"), textField(enriched, "vehicleId", "id")); }); const flags = data.flags.flatMap((flag) => withPosition(flag, "flags", textField(flag, "name"), textField(flag, "flagId", "id"))); const regions = data.mapRegions.flatMap((region) => withPosition(region, "regions", textField(region, "name"), textField(region, "id", "regionId"))); const uniquePoints = new Map(); @@ -605,7 +625,8 @@ function trajectoryIdentity(row: RecordMap): string[] { return [textField(row, " function trajectoryOrder(row: RecordMap): number { const stamp = Date.parse(textField(row, "sampledAt", "observedAt", "createdAt")); return Number.isNaN(stamp) ? 0 : stamp; } function nearbyVehicle(point: RecordMap, vehicles: RecordMap[]): RecordMap | undefined { if (!hasCoordinates(positionOf(point))) return undefined; let best: { vehicle: RecordMap; distance: number } | undefined; for (const vehicle of vehicles) { if (!hasCoordinates(positionOf(vehicle))) continue; const distance = distance2D(positionOf(point)!, positionOf(vehicle)!); if (distance <= rideDistanceThreshold && (!best || distance < best.distance)) best = { vehicle, distance }; } return best?.vehicle; } function distance2D(left: RecordMap, right: RecordMap): number { const dx = Number(field(left, "x", "locationX")) - Number(field(right, "x", "locationX")); const dy = Number(field(left, "y", "locationY")) - Number(field(right, "y", "locationY")); return Math.sqrt(dx * dx + dy * dy); } -function vehicleIconFor(point: RecordMap): string { return vehicleIconByClass[normalizedVehicleClass(textField(point, "className", "vehicleClass", "entityClass"))] ?? ""; } +function vehicleIconFor(point: RecordMap): string { const explicit = textField(point, "imagePath", "image_path"); if (explicit) return explicit.startsWith("/") ? explicit : `/${explicit}`; return vehicleIconByClass[normalizedVehicleClass(textField(point, "className", "vehicleClass", "entityClass", "vehicleType"))] ?? ""; } +function vehicleClassKey(value: string): string { return normalizedVehicleClass(value.replace(/^#spawnvehicle\s+/i, "")); } function normalizedVehicleClass(value: string): string { return value.replace(/_C$/i, "").split(".").pop()?.trim() ?? value.trim(); } function shortHash(value: string): string { return value ? `${value.slice(0, 10)}…${value.slice(-6)}` : ""; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 6bed6ed..a602e64 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -347,6 +347,27 @@ "source": "run.sqlite.scum.vehicles" }, "observedAtField": "sampledAt" + }, + { + "collection": "scum_trade_goods", + "rowPath": "rows", + "upsertKeys": [ + "code" + ], + "fieldMappings": { + "className": "className" + }, + "fixedValues": { + "code": "#spawnvehicle {{className}}", + "spawnCommand": "#spawnvehicle {{className}}", + "catalogType": "vehicle", + "type": "21", + "typeName": "其他载具", + "imagePath": "/original/{{className}}.webp", + "source": "run.sqlite.scum.vehicles" + }, + "observedAtField": "lastSeenAt", + "mergeExisting": true } ] }, @@ -484,6 +505,75 @@ } ], "logProjections": [ + { + "key": "scum.trade.catalog", + "streamKeys": [ + "scum.trade" + ], + "steps": [ + { + "pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: \\[Trade\\] Tradeable \\((?P[A-Za-z0-9_.-]{1,128}) \\(x(?P\\d{1,9})\\)\\) (?Ppurchased|sold) by .*?\\((?P\\d{1,50})\\) for (?P-?\\d{1,12})$" + } + ], + "correlationFields": [ + "itemCode" + ], + "maxInterveningLines": 0, + "target": { + "collection": "scum_trade_goods", + "upsertKeys": [ + "code" + ], + "captureMappings": { + "code": "itemCode" + }, + "fixedValues": { + "catalogType": "item", + "source": "scum.trade" + }, + "observedAtField": "lastSeenAt" + } + }, + { + "key": "scum.trade.events", + "streamKeys": [ + "scum.trade" + ], + "steps": [ + { + "pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: \\[Trade\\] Tradeable \\((?P[A-Za-z0-9_.-]{1,128}) \\(x(?P\\d{1,9})\\)\\) (?Ppurchased|sold) by .*?\\((?P\\d{1,50})\\) for (?P-?\\d{1,12})$" + } + ], + "correlationFields": [ + "steamId", + "itemCode", + "tradeVerb" + ], + "maxInterveningLines": 0, + "target": { + "collection": "scum_trade_events", + "upsertKeys": [ + "steamId", + "itemCode", + "tradeVerb", + "quantity", + "price", + "observedAt" + ], + "captureMappings": { + "steamId": "steamId", + "itemCode": "itemCode", + "tradeVerb": "tradeVerb", + "quantity": "quantity", + "price": "price" + }, + "fixedValues": { + "eventType": "trade", + "source": "scum.trade" + }, + "observedAtField": "observedAt" + } + }, { "key": "scum.battleye.login", "streamKeys": [ diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/trade.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/trade.event.schema.json index 541237f..1674a36 100644 --- a/plugins/examples/scum-server-plugin/schemas/log-events/trade.event.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/log-events/trade.event.schema.json @@ -5,10 +5,15 @@ "required": ["occurredAt", "playerId", "tradeKind", "itemCount", "currencyDelta", "suspicious"], "properties": { "occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 }, + "source": { "type": "string", "enum": ["companion", "log-projection", "scum.trade"] }, "playerId": { "type": "string", "minLength": 1, "maxLength": 96 }, "counterpartyPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 }, + "itemCode": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,128}$", "minLength": 1, "maxLength": 128 }, + "tradeVerb": { "type": "string", "enum": ["purchased", "sold"] }, "tradeKind": { "type": "string", "enum": ["purchase", "sale", "transfer", "unknown"] }, + "quantity": { "type": "integer", "minimum": 0, "maximum": 1000000000 }, "itemCount": { "type": "integer", "minimum": 0, "maximum": 1000 }, + "price": { "type": "integer", "minimum": -1000000000, "maximum": 1000000000 }, "currencyDelta": { "type": "integer", "minimum": -1000000000, "maximum": 1000000000 }, "suspicious": { "type": "boolean" } } diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 447e97d..f509885 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -358,7 +358,8 @@ "upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 }, "fieldMappings": { "type": "object", "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}$" } + "observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, + "mergeExisting": { "type": "boolean" } } }, "gameClientBridgeLogProjection": { diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 5374e21..163c39e 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -251,6 +251,19 @@ export interface GameClientBridgeQueryTemplateDeclaration { maxRows: number; timeoutSeconds: number; pollIntervalSeconds?: number; + projections?: GameClientBridgeQueryProjectionDeclaration[]; +} + +export interface GameClientBridgeQueryProjectionDeclaration { + collection: string; + rowPath: "rows"; + matchField?: string; + matchValue?: string; + upsertKeys: string[]; + fieldMappings?: Record; + fixedValues?: Record; + observedAtField?: string; + mergeExisting?: boolean; } export interface GameClientBridgeLogProjectionStepDeclaration { diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 74c6e54..3375c21 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -456,6 +456,8 @@ describe("plugin manifest validation", () => { maxPayloadBytes: number; }>; snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>; + queryTemplates: Array<{ key: string; projections?: Array<{ collection?: string; fixedValues?: Record; mergeExisting?: boolean }> }>; + logProjections?: Array<{ key: string; streamKeys?: string[]; target?: { collection?: string; upsertKeys?: string[]; captureMappings?: Record } }>; pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }>; }; pages: Array<{ key: string; permissions?: string[] }>; @@ -496,6 +498,12 @@ describe("plugin manifest validation", () => { "maintenance.prepare" ])); expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"])); + expect(manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.vehicles")?.projections).toEqual(expect.arrayContaining([ + expect.objectContaining({ collection: "scum_trade_goods", mergeExisting: true, fixedValues: expect.objectContaining({ catalogType: "vehicle", type: "21", typeName: "其他载具" }) }) + ])); + expect(manifest.gameClientBridge.logProjections?.map((projection) => projection.key)).toEqual(expect.arrayContaining(["scum.trade.catalog", "scum.trade.events"])); + expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.catalog")).toMatchObject({ streamKeys: ["scum.trade"], target: { collection: "scum_trade_goods", upsertKeys: ["code"], captureMappings: { code: "itemCode" } } }); + expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.events")?.target?.collection).toBe("scum_trade_events"); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"])); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config"); expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"])); @@ -507,7 +515,7 @@ describe("plugin manifest validation", () => { expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"])); expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "game-port", "query-port", "max-players", "welcome-message"])); expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command"); - expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-client-events"])); + expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events", "scum-client-events"])); }); it("declares bounded and permissioned SCUM bridge commands", () => { diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 7b4a55d..a45745d 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -24,6 +24,11 @@ const surfaceData: SCUMSurfaceData = { nativeEventRounds: [{ eventRecordId: "native-1", eventId: "native-event", state: "active", startTime: "2026-08-10T00:00:00Z", enemyKills: 2 }], tasks: [{ taskRecordId: "task-1", taskKind: "active-task", state: "active", userProfileId: "profile-1" }], activityEvents: [{ id: "activity-1", type: "reward", subjectName: "Mira", status: "delivered", occurredAt: "2026-08-10T00:02:00Z" }], + tradeGoods: [ + { code: "goods-1", name: "Cargo Drop", catalogType: "item", lastSeenAt: "2026-08-10T00:01:00Z" }, + { code: "#spawnvehicle BPC_Laika_C", className: "BPC_Laika_C", catalogType: "vehicle", type: "21", typeName: "其他载具", imagePath: "/original/BPC_Laika_C.webp", lastSeenAt: "2026-08-10T00:00:03Z" } + ], + tradeEvents: [{ steamId: "76561198000000001", itemCode: "goods-1", tradeVerb: "purchased", quantity: "2", price: "120", observedAt: "2026-08-10T00:01:00Z" }], gifts: [{ code: "starter-pack", name: "Starter Pack", class: 5, audience: "all", number: 1, achievement: 2, achievementNumber: 10, status: "active", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], commands: [{ command: "#announce Starter pack" }] }], giftClaims: [{ id: "claim-1", playerId: "steam-1", giftCode: "starter-pack", status: "claimed", claimedAt: "2026-08-10T00:03:00Z" }], pendingGifts: [{ id: "pending-1", playerId: "steam-1", giftCode: "starter-pack", status: "pending", createdAt: "2026-08-10T00:03:30Z" }], @@ -32,7 +37,7 @@ const surfaceData: SCUMSurfaceData = { mapPoints: [{ id: "poi-1", name: "Airfield", layer: "other", x: 800, y: 900, z: 10, source: "plugin-map" }], mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }], mapSettings: [], - vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }], + vehicles: [{ vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }], trajectories: [ { subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.positions" }, { subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.vehicles" } @@ -84,7 +89,7 @@ describe("SCUM plugin feature module", () => { it("loads page data only through scoped plugin collections", async () => { const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); const data = await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "gifts"); - expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.players]); + expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.players, scumCollections.tradeGoods]); expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` }); }); @@ -104,9 +109,9 @@ describe("SCUM plugin feature module", () => { it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => { const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows"); - expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]); + expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]); await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "activity"); - expect(list.mock.calls.slice(-6).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]); + expect(list.mock.calls.slice(-8).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]); }); it("uses transaction, put, and delete for plugin-owned gift data", async () => { @@ -239,6 +244,7 @@ describe("SCUM plugin feature module", () => { expect(view.texts).toContain("最近活动记录"); expect(view.texts).toContain("Mira"); expect(view.texts).toContain("活动生成项"); + expect(view.texts).toEqual(expect.arrayContaining(["物品列表", "最近商人交易", "Cargo Drop", "买入 × 2"])); expect(pageSource).toContain("setEventEditorOpen(detailOpen(event))"); expect(pageSource).not.toContain("open: Boolean(view.eventId || view.eventName)"); }); @@ -246,7 +252,9 @@ describe("SCUM plugin feature module", () => { it("renders gift definitions, claims, and delivery records", () => { const definitions = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" }); expect(definitions.texts).toContain("礼包定义"); + expect(definitions.texts).toContain("物品列表"); expect(definitions.texts).toContain("Starter Pack"); + expect(definitions.texts).toContain("Cargo Drop"); expect(definitions.buttons.find((button) => button.label === "保存礼包")?.disabled).toBe(false); expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["礼包周期", "适用玩家", "发放次数", "成就类型", "成就值", "礼包物品", "礼包命令"])); const claims = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "claims" }); @@ -274,12 +282,15 @@ describe("SCUM plugin feature module", () => { it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => { const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "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(surfaceData).find((point) => point.vehicleId === "veh-1")).toMatchObject({ imagePath: "/original/BPC_Laika_C.webp", spawnCommand: "#spawnvehicle BPC_Laika_C" }); 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(mapPointStyle({ x: -100000, y: 100000 }, bounds)).toEqual({ left: "99%", top: "99%" }); const pluginData = pluginDataActions(); await saveMapSettings({ pluginData }, { customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 }); expect(pluginData.put).toHaveBeenCalledWith(scumCollections.mapSettings, "current", expect.objectContaining(bounds)); + expect(pageSource).toContain('textField(point, "imagePath", "image_path")'); + expect(pageSource).toContain('"className", "vehicleClass", "entityClass", "vehicleType"'); expect(pageSource).not.toContain("visible.slice(0, 240)"); });