diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index f9421e4..225a0a3 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -50,17 +50,7 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai } job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp) if !ok { - if err := svc.scheduleDuePluginQueryProjectionJobs(claim, stamp); err != nil { - return domain.RunJobClaimResult{}, err - } - jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID}) - if err != nil { - return domain.RunJobClaimResult{}, err - } - job, ok = firstEligibleSupportedJob(jobs, claim.Capabilities, stamp) - if !ok { - return emptyJobClaim(claim.RunEndpointID, stamp), nil - } + return emptyJobClaim(claim.RunEndpointID, stamp), nil } leaseToken, err := randomToken() @@ -353,9 +343,6 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil { return domain.RunJobResultResult{}, err } - if err := svc.projectPluginQueryJobResult(job, stamp); err != nil { - return domain.RunJobResultResult{}, err - } return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil } diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go index e337512..51152b8 100644 --- a/platform/service/plugin_data_test.go +++ b/platform/service/plugin_data_test.go @@ -137,6 +137,7 @@ func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T) func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) { svc, plugin, endpoint, _, _ := createSQLiteQueryBridgeFixture(t) plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3 + plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{Collection: "scum_users", RowPath: "rows", UpsertKeys: []string{"steamId"}}} if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("enable query refresh hint: %v", err) } @@ -157,9 +158,8 @@ func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) { } } -func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) { +func TestDeclaredSQLiteQueryProjectionsAreNotAppliedByPlatform(t *testing.T) { svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t) - plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3 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", @@ -168,39 +168,38 @@ func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) { 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) + t.Fatalf("store legacy query projection declarations: %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) + queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-projection-ignored-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{ + "capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-projection-ignored-1", "input.templateKey": "players.by-id", + }}) + if err != nil || queued.Status != "queued" { + t.Fatalf("queue declared query=%+v err=%v", queued, err) } helloRequest := validRunControlHello() helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) - helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler" + helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-projection-ignored" 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("projection query was not scheduled: %+v err=%v", claim, err) + t.Fatalf("declared query job was not claimable: %+v err=%v", claim, err) } if claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" { - t.Fatalf("scheduled projection query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs) + t.Fatalf("declared query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs) } _, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"kind":"player","steamId":"steam-1","displayName":"Ada"},{"kind":"vehicle","steamId":"vehicle-1","displayName":"Truck"}]}`}}) if err != nil { - t.Fatalf("complete projection query job: %v", err) + t.Fatalf("complete declared query job: %v", err) } items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"}) - 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) + if err != nil || len(items) != 0 { + t.Fatalf("platform applied query projection to 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) + if err != nil || len(goods) != 0 { + t.Fatalf("platform applied vehicle catalog query projection=%+v err=%v", goods, err) } } diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 4299358..4d45317 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -778,7 +778,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))} .console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px} .console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850} -.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);background-size:cover;background-position:center}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-projection-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%);cursor:pointer}.map-projection-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-projection-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-projection-dot.map-layer-flags{background:var(--gold)}.map-projection-dot.map-layer-regions{background:var(--success)}.map-projection-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-trajectory-dot{position:absolute;z-index:2;width:4px;height:4px;border-radius:999px;background:color-mix(in srgb,var(--accent) 82%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 66%,transparent);transform:translate(-50%,-50%);pointer-events:none}.map-trajectory-dot.map-layer-vehicles{width:5px;height:5px;background:color-mix(in srgb,var(--gold) 86%,transparent)} +.map-world-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);background-size:cover;background-position:center}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-world-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%);cursor:pointer}.map-world-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-world-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-world-dot.map-layer-flags{background:var(--gold)}.map-world-dot.map-layer-regions{background:var(--success)}.map-world-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-trajectory-dot{position:absolute;z-index:2;width:4px;height:4px;border-radius:999px;background:color-mix(in srgb,var(--accent) 82%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 66%,transparent);transform:translate(-50%,-50%);pointer-events:none}.map-trajectory-dot.map-layer-vehicles{width:5px;height:5px;background:color-mix(in srgb,var(--gold) 86%,transparent)} .console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px} .console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left} .console-row-button,.operations-job-row{width:100%;cursor:pointer} diff --git a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go index b1c6b28..30baf15 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go @@ -199,7 +199,6 @@ func TestCompanionProductionSourceHasNoForbiddenAdapterPaths(t *testing.T) { t.Fatal("resolve companion source directory") } forbidden := map[string]*regexp.Regexp{ - "raw SQL or direct database access": regexp.MustCompile(`(?im)"(?:database/sql|github\.com/(?:mattn/go-sqlite3|go-sql-driver/mysql)|gorm\.io/gorm)"|\b(?:sql|db|database)\.(?:Open|Exec(?:Context)?|Query(?:Context)?|Prepare(?:Context)?)\s*\(`), "unrestricted RCON or command execution": regexp.MustCompile(`(?i)\b(?:send|execute|run|dispatch)[a-z0-9_]*(?:rcon|rawcommand|command)\s*\(`), "desktop automation or screen capture": regexp.MustCompile(`(?i)\b(?:tesseract|gosseract|screenshot|robotgo|autogui|keybd_event|mouse_event|sendinput)\b`), "direct socket transport": regexp.MustCompile(`\bnet\.(?:Dial|DialTimeout)\s*\(`), diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher.go b/plugins/examples/scum-server-plugin/companion/dispatcher.go index e0f9a8c..ca9a985 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher.go @@ -10,8 +10,9 @@ import ( ) // SafeAdapter is intentionally narrow: it receives typed values only and has -// no direct transport, host-path, credential, or shell access. Protected SQL, -// RCON, and management-program text is forwarded to Run by Platform, not here. +// no direct transport, host-path, credential, or shell access. Game SQLite, +// RCON, and management-program text stay behind declared typed ports; plugin-owned +// durable writes use the dedicated SCUMSQLStore instead of browser page payloads. type SafeAdapter interface { ReadConfiguration(context.Context) (map[string]any, error) PatchConfiguration(context.Context, map[string]any) (map[string]any, error) diff --git a/plugins/examples/scum-server-plugin/companion/go.mod b/plugins/examples/scum-server-plugin/companion/go.mod index 9f76220..8685cd2 100644 --- a/plugins/examples/scum-server-plugin/companion/go.mod +++ b/plugins/examples/scum-server-plugin/companion/go.mod @@ -2,4 +2,8 @@ module browser.local/plugins/scum-server-plugin/companion go 1.25.1 -require gopkg.in/yaml.v3 v3.0.1 +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/go-sql-driver/mysql v1.10.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/plugins/examples/scum-server-plugin/companion/go.sum b/plugins/examples/scum-server-plugin/companion/go.sum index a62c313..c9432a6 100644 --- a/plugins/examples/scum-server-plugin/companion/go.sum +++ b/plugins/examples/scum-server-plugin/companion/go.sum @@ -1,3 +1,7 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/plugins/examples/scum-server-plugin/companion/storage.go b/plugins/examples/scum-server-plugin/companion/storage.go new file mode 100644 index 0000000..28731ae --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/storage.go @@ -0,0 +1,366 @@ +package companion + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "os" + "strconv" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +const PlatformMySQLDSNEnvironment = "PLATFORM_MYSQL_DSN" + +var openSQL = sql.Open + +type SCUMSQLStore struct{ db *sql.DB } + +type TrajectorySample struct { + ServerInstanceID string + SubjectType string + SubjectID string + SteamID string + UserProfileID string + GamePlayerID string + VehicleID string + EntityID string + BaseID string + DisplayName string + Label string + ClassName string + WorldX float64 + WorldY float64 + WorldZ *float64 + ObservedAt time.Time + SampledAt time.Time + Source string +} + +func NewSCUMSQLStore(db *sql.DB) (*SCUMSQLStore, error) { + if db == nil { + return nil, fmt.Errorf("platform SQL handle is required") + } + return &SCUMSQLStore{db: db}, nil +} + +func OpenSCUMSQLStoreFromEnv(envName string) (*SCUMSQLStore, error) { + name := strings.TrimSpace(envName) + if name == "" { + name = PlatformMySQLDSNEnvironment + } + dsn := strings.TrimSpace(os.Getenv(name)) + if dsn == "" { + return nil, fmt.Errorf("%s is required for SCUM plugin SQL storage", name) + } + db, err := openSQL("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("open SCUM plugin SQL storage: %w", err) + } + return NewSCUMSQLStore(db) +} + +func (store *SCUMSQLStore) EnsureSchema(ctx context.Context) error { + if store == nil || store.db == nil { + return fmt.Errorf("SCUM plugin SQL store is not configured") + } + for _, statement := range scumSQLStoreMigrations { + if _, err := store.db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("apply SCUM plugin SQL migration: %w", err) + } + } + return nil +} + +func (store *SCUMSQLStore) StorePositionRows(ctx context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) { + samples, err := TrajectorySamplesFromPositionRows(serverInstanceID, rows, sampledAt) + if err != nil { + return 0, err + } + return store.StoreTrajectorySamples(ctx, samples) +} + +func (store *SCUMSQLStore) StoreVehicleRows(ctx context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) { + samples, err := TrajectorySamplesFromVehicleRows(serverInstanceID, rows, sampledAt) + if err != nil { + return 0, err + } + return store.StoreTrajectorySamples(ctx, samples) +} + +func (store *SCUMSQLStore) StoreTrajectorySamples(ctx context.Context, samples []TrajectorySample) (int, error) { + if store == nil || store.db == nil { + return 0, fmt.Errorf("SCUM plugin SQL store is not configured") + } + normalized := make([]TrajectorySample, 0, len(samples)) + for _, sample := range samples { + value, err := normalizeTrajectorySample(sample) + if err != nil { + return 0, err + } + normalized = append(normalized, value) + } + if len(normalized) == 0 { + return 0, nil + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("begin SCUM trajectory write: %w", err) + } + defer tx.Rollback() + stamp := time.Now().UTC() + for _, sample := range normalized { + args := scumTrajectoryInsertArgs(sample, stamp) + if _, err := tx.ExecContext(ctx, scumTrajectoryInsertSQL, args...); err != nil { + return 0, fmt.Errorf("write SCUM trajectory sample: %w", err) + } + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit SCUM trajectory samples: %w", err) + } + return len(normalized), nil +} + +func TrajectorySamplesFromPositionRows(serverInstanceID string, rows []map[string]any, sampledAt time.Time) ([]TrajectorySample, error) { + result := make([]TrajectorySample, 0, len(rows)) + for _, row := range rows { + subjectType := textFromRow(row["subjectType"]) + if subjectType != "player" && subjectType != "vehicle" { + continue + } + x, xOK := numberFromRow(row["x"]) + y, yOK := numberFromRow(row["y"]) + if !xOK || !yOK { + continue + } + z := optionalNumberFromRow(row["z"]) + subjectID := textFromRow(row["subjectId"]) + sample := TrajectorySample{ + ServerInstanceID: serverInstanceID, + SubjectType: subjectType, + SubjectID: subjectID, + UserProfileID: textFromRow(row["userProfileId"]), + GamePlayerID: textFromRow(row["gamePlayerId"]), + VehicleID: textFromRow(row["vehicleId"]), + EntityID: textFromRow(row["entityId"]), + BaseID: textFromRow(row["baseId"]), + WorldX: x, + WorldY: y, + WorldZ: z, + ObservedAt: timestampFromRow(row["observedAt"], sampledAt), + SampledAt: sampledAt, + Source: "plugin.sql.scum.positions", + } + if sample.SubjectType == "player" { + sample.SteamID = sample.SubjectID + } + result = append(result, sample) + } + return result, nil +} + +func TrajectorySamplesFromVehicleRows(serverInstanceID string, rows []map[string]any, sampledAt time.Time) ([]TrajectorySample, error) { + result := make([]TrajectorySample, 0, len(rows)) + for _, row := range rows { + x, xOK := numberFromRow(row["x"]) + y, yOK := numberFromRow(row["y"]) + if !xOK || !yOK { + continue + } + vehicleID := textFromRow(row["vehicleId"]) + result = append(result, TrajectorySample{ + ServerInstanceID: serverInstanceID, + SubjectType: "vehicle", + SubjectID: vehicleID, + VehicleID: vehicleID, + EntityID: textFromRow(row["entityId"]), + Label: textFromRow(row["label"]), + ClassName: textFromRow(row["className"]), + WorldX: x, + WorldY: y, + WorldZ: optionalNumberFromRow(row["z"]), + ObservedAt: timestampFromRow(row["lastAccessTime"], sampledAt), + SampledAt: sampledAt, + Source: "plugin.sql.scum.vehicles", + }) + } + return result, nil +} + +func normalizeTrajectorySample(sample TrajectorySample) (TrajectorySample, error) { + sample.ServerInstanceID = strings.TrimSpace(sample.ServerInstanceID) + sample.SubjectType = strings.TrimSpace(sample.SubjectType) + sample.SubjectID = strings.TrimSpace(sample.SubjectID) + sample.SteamID = strings.TrimSpace(sample.SteamID) + sample.UserProfileID = strings.TrimSpace(sample.UserProfileID) + sample.GamePlayerID = strings.TrimSpace(sample.GamePlayerID) + sample.VehicleID = strings.TrimSpace(sample.VehicleID) + sample.EntityID = strings.TrimSpace(sample.EntityID) + sample.BaseID = strings.TrimSpace(sample.BaseID) + sample.DisplayName = strings.TrimSpace(sample.DisplayName) + sample.Label = strings.TrimSpace(sample.Label) + sample.ClassName = strings.TrimSpace(sample.ClassName) + sample.Source = strings.TrimSpace(sample.Source) + if sample.ServerInstanceID == "" || sample.SubjectID == "" || sample.SampledAt.IsZero() || !validTrajectorySubjectType(sample.SubjectType) || !finite(sample.WorldX) || !finite(sample.WorldY) || sample.WorldZ != nil && !finite(*sample.WorldZ) { + return TrajectorySample{}, fmt.Errorf("SCUM trajectory sample is invalid") + } + if sample.ObservedAt.IsZero() { + sample.ObservedAt = sample.SampledAt + } + if sample.Source == "" { + sample.Source = "plugin.sql.scum" + } + if !boundedTrajectoryTexts(sample) { + return TrajectorySample{}, fmt.Errorf("SCUM trajectory sample text is too long") + } + sample.ObservedAt = sample.ObservedAt.UTC() + sample.SampledAt = sample.SampledAt.UTC() + return sample, nil +} + +func scumTrajectoryInsertArgs(sample TrajectorySample, stamp time.Time) []any { + return []any{ + scumTrajectoryRecordKey(sample), sample.ServerInstanceID, sample.SubjectType, sample.SubjectID, + nullText(sample.SteamID), nullText(sample.UserProfileID), nullText(sample.GamePlayerID), nullText(sample.VehicleID), nullText(sample.EntityID), nullText(sample.BaseID), + nullText(sample.DisplayName), nullText(sample.Label), nullText(sample.ClassName), sample.WorldX, sample.WorldY, nullFloat(sample.WorldZ), sample.ObservedAt, sample.SampledAt, sample.Source, stamp, stamp, + } +} + +func scumTrajectoryRecordKey(sample TrajectorySample) string { + digest := sha256.Sum256([]byte(strings.Join([]string{sample.ServerInstanceID, sample.SubjectType, sample.SubjectID, sample.SampledAt.UTC().Format(time.RFC3339Nano)}, "\x00"))) + return hex.EncodeToString(digest[:]) +} + +func validTrajectorySubjectType(value string) bool { return value == "player" || value == "vehicle" } +func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } +func nullText(value string) any { + if value == "" { + return nil + } + return value +} +func nullFloat(value *float64) any { + if value == nil { + return nil + } + return *value +} + +func boundedTrajectoryTexts(sample TrajectorySample) bool { + return len(sample.ServerInstanceID) <= 96 && len(sample.SubjectType) <= 16 && len(sample.SubjectID) <= 128 && len(sample.SteamID) <= 32 && len(sample.UserProfileID) <= 96 && len(sample.GamePlayerID) <= 96 && len(sample.VehicleID) <= 96 && len(sample.EntityID) <= 96 && len(sample.BaseID) <= 96 && len(sample.DisplayName) <= 120 && len(sample.Label) <= 120 && len(sample.ClassName) <= 160 && len(sample.Source) <= 80 +} + +func textFromRow(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + default: + return strings.TrimSpace(fmt.Sprint(typed)) + } +} + +func numberFromRow(value any) (float64, bool) { + switch typed := value.(type) { + case nil: + return 0, false + case float64: + return typed, finite(typed) + case float32: + value := float64(typed) + return value, finite(value) + case int: + return float64(typed), true + case int64: + return float64(typed), true + case int32: + return float64(typed), true + case json.Number: + value, err := typed.Float64() + return value, err == nil && finite(value) + case string: + value, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return value, err == nil && finite(value) + default: + return 0, false + } +} + +func optionalNumberFromRow(value any) *float64 { + number, ok := numberFromRow(value) + if !ok { + return nil + } + return &number +} + +func timestampFromRow(value any, fallback time.Time) time.Time { + text := textFromRow(value) + if text == "" { + return fallback + } + parsed, err := time.Parse(time.RFC3339Nano, text) + if err != nil { + return fallback + } + return parsed +} + +var scumSQLStoreMigrations = []string{` +CREATE TABLE IF NOT EXISTS scum_trajectories ( + record_key CHAR(64) PRIMARY KEY, + server_instance_id VARCHAR(96) NOT NULL, + subject_type VARCHAR(16) NOT NULL, + subject_id VARCHAR(128) NOT NULL, + steam_id VARCHAR(32) NULL, + user_profile_id VARCHAR(96) NULL, + game_player_id VARCHAR(96) NULL, + vehicle_id VARCHAR(96) NULL, + entity_id VARCHAR(96) NULL, + base_id VARCHAR(96) NULL, + display_name VARCHAR(120) NULL, + label VARCHAR(120) NULL, + class_name VARCHAR(160) NULL, + world_x DOUBLE NOT NULL, + world_y DOUBLE NOT NULL, + world_z DOUBLE NULL, + observed_at DATETIME(6) NOT NULL, + sampled_at DATETIME(6) NOT NULL, + source VARCHAR(80) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + UNIQUE KEY scum_trajectories_sample_uq (server_instance_id, subject_type, subject_id, sampled_at), + KEY scum_trajectories_subject_idx (server_instance_id, subject_type, subject_id, observed_at), + KEY scum_trajectories_sampled_idx (server_instance_id, sampled_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`} + +const scumTrajectoryInsertSQL = ` +INSERT INTO scum_trajectories ( + record_key, server_instance_id, subject_type, subject_id, steam_id, user_profile_id, game_player_id, vehicle_id, entity_id, base_id, + display_name, label, class_name, world_x, world_y, world_z, observed_at, sampled_at, source, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE + steam_id = VALUES(steam_id), + user_profile_id = VALUES(user_profile_id), + game_player_id = VALUES(game_player_id), + vehicle_id = VALUES(vehicle_id), + entity_id = VALUES(entity_id), + base_id = VALUES(base_id), + display_name = VALUES(display_name), + label = VALUES(label), + class_name = VALUES(class_name), + world_x = VALUES(world_x), + world_y = VALUES(world_y), + world_z = VALUES(world_z), + observed_at = VALUES(observed_at), + source = VALUES(source), + updated_at = VALUES(updated_at)` diff --git a/plugins/examples/scum-server-plugin/companion/storage_test.go b/plugins/examples/scum-server-plugin/companion/storage_test.go new file mode 100644 index 0000000..0b01033 --- /dev/null +++ b/plugins/examples/scum-server-plugin/companion/storage_test.go @@ -0,0 +1,170 @@ +package companion + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "strings" + "sync" + "testing" + "time" +) + +const recordingSQLDriverName = "scum_companion_recording_sql" + +var ( + recordingSQLDriverOnce sync.Once + activeSQLRecorder *sqlRecorder +) + +type sqlRecorder struct { + mu sync.Mutex + dsn string + statements []string + args [][]driver.NamedValue + commits int +} + +func (recorder *sqlRecorder) append(query string, args []driver.NamedValue) { + recorder.mu.Lock() + defer recorder.mu.Unlock() + recorder.statements = append(recorder.statements, query) + recorder.args = append(recorder.args, append([]driver.NamedValue(nil), args...)) +} + +func (recorder *sqlRecorder) committed() { + recorder.mu.Lock() + defer recorder.mu.Unlock() + recorder.commits++ +} + +type recordingSQLDriver struct{} +type recordingSQLConn struct{ recorder *sqlRecorder } +type recordingSQLTx struct{ recorder *sqlRecorder } + +func (recordingSQLDriver) Open(dsn string) (driver.Conn, error) { + if activeSQLRecorder == nil { + return nil, errors.New("recording SQL recorder is not configured") + } + activeSQLRecorder.dsn = dsn + return &recordingSQLConn{recorder: activeSQLRecorder}, nil +} +func (conn *recordingSQLConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("prepare is not supported") +} +func (conn *recordingSQLConn) Close() error { return nil } +func (conn *recordingSQLConn) Begin() (driver.Tx, error) { + return &recordingSQLTx{recorder: conn.recorder}, nil +} +func (conn *recordingSQLConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { + return &recordingSQLTx{recorder: conn.recorder}, nil +} +func (conn *recordingSQLConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + conn.recorder.append(query, args) + return driver.RowsAffected(1), nil +} +func (tx *recordingSQLTx) Commit() error { tx.recorder.committed(); return nil } +func (tx *recordingSQLTx) Rollback() error { return nil } + +func TestSCUMSQLStoreWritesTrajectorySamplesWithoutCoordinateConversion(t *testing.T) { + db, recorder := newRecordingSQLDB(t, "direct-db") + store, err := NewSCUMSQLStore(db) + if err != nil { + t.Fatalf("create SCUM SQL store: %v", err) + } + if err := store.EnsureSchema(context.Background()); err != nil { + t.Fatalf("ensure schema: %v", err) + } + z := 7.75 + sampledAt := time.Date(2026, 8, 28, 7, 30, 0, 123456000, time.UTC) + written, err := store.StoreTrajectorySamples(context.Background(), []TrajectorySample{{ + ServerInstanceID: "server-1", SubjectType: "player", SubjectID: "76561198000000001", SteamID: "76561198000000001", UserProfileID: "profile-1", GamePlayerID: "player-1", + WorldX: 123.5, WorldY: -456.25, WorldZ: &z, ObservedAt: sampledAt.Add(-time.Second), SampledAt: sampledAt, Source: "plugin.sql.scum.positions", + }}) + if err != nil || written != 1 { + t.Fatalf("store trajectory sample: written=%d err=%v", written, err) + } + if recorder.commits != 1 { + t.Fatalf("trajectory write did not commit once: %d", recorder.commits) + } + insertIndex := findStatement(recorder.statements, "INSERT INTO scum_trajectories") + if insertIndex < 0 { + t.Fatalf("missing trajectory insert statement: %v", recorder.statements) + } + insert := recorder.statements[insertIndex] + if !strings.Contains(insert, "world_x") || !strings.Contains(insert, "world_y") || !strings.Contains(insert, "world_z") || strings.Contains(insert, "map_x") || strings.Contains(insert, "pixel") { + t.Fatalf("trajectory SQL does not use raw world coordinate columns: %s", insert) + } + args := recorder.args[insertIndex] + if args[13].Value != 123.5 || args[14].Value != -456.25 || args[15].Value != 7.75 { + t.Fatalf("coordinates were changed before storage: %+v", args[13:16]) + } +} + +func TestTrajectorySamplesFromSCUMRowsKeepWorldCoordinates(t *testing.T) { + sampledAt := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC) + positionSamples, err := TrajectorySamplesFromPositionRows("server-1", []map[string]any{ + {"subjectType": "player", "subjectId": "76561198000000001", "userProfileId": "profile-1", "gamePlayerId": "player-1", "x": 10.25, "y": -20.5, "z": 3.75, "observedAt": "2026-08-28T07:59:00Z"}, + {"subjectType": "base", "subjectId": "base-1", "x": 1, "y": 2, "z": 0}, + }, sampledAt) + if err != nil || len(positionSamples) != 1 { + t.Fatalf("position samples=%+v err=%v", positionSamples, err) + } + if positionSamples[0].WorldX != 10.25 || positionSamples[0].WorldY != -20.5 || positionSamples[0].WorldZ == nil || *positionSamples[0].WorldZ != 3.75 || positionSamples[0].Source != "plugin.sql.scum.positions" { + t.Fatalf("position coordinates were not preserved: %+v", positionSamples[0]) + } + vehicleSamples, err := TrajectorySamplesFromVehicleRows("server-1", []map[string]any{{"vehicleId": "vehicle-1", "entityId": "entity-1", "className": "BPC_Laika_C", "label": "Laika", "x": "400.5", "y": 200, "z": 0, "lastAccessTime": "2026-08-28T07:58:00Z"}}, sampledAt) + if err != nil || len(vehicleSamples) != 1 { + t.Fatalf("vehicle samples=%+v err=%v", vehicleSamples, err) + } + if vehicleSamples[0].SubjectType != "vehicle" || vehicleSamples[0].WorldX != 400.5 || vehicleSamples[0].WorldY != 200 || vehicleSamples[0].ClassName != "BPC_Laika_C" || vehicleSamples[0].Source != "plugin.sql.scum.vehicles" { + t.Fatalf("vehicle coordinates were not preserved: %+v", vehicleSamples[0]) + } +} + +func TestOpenSCUMSQLStoreFromEnvUsesSharedPlatformDSNName(t *testing.T) { + recorder := &sqlRecorder{} + activeSQLRecorder = recorder + previousOpenSQL := openSQL + t.Cleanup(func() { openSQL = previousOpenSQL }) + openSQL = func(driverName, dsn string) (*sql.DB, error) { + if driverName != "mysql" { + t.Fatalf("unexpected SQL driver: %s", driverName) + } + return sql.Open(recordingSQLDriverName, dsn) + } + t.Setenv(PlatformMySQLDSNEnvironment, "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true") + store, err := OpenSCUMSQLStoreFromEnv(PlatformMySQLDSNEnvironment) + if err != nil { + t.Fatalf("open store from shared env: %v", err) + } + if err := store.EnsureSchema(context.Background()); err != nil { + t.Fatalf("ensure schema from shared env: %v", err) + } + store.db.Close() + if recorder.dsn != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" { + t.Fatalf("store did not use the shared platform DSN environment: %q", recorder.dsn) + } +} + +func newRecordingSQLDB(t *testing.T, dsn string) (*sql.DB, *sqlRecorder) { + t.Helper() + recordingSQLDriverOnce.Do(func() { sql.Register(recordingSQLDriverName, recordingSQLDriver{}) }) + recorder := &sqlRecorder{} + activeSQLRecorder = recorder + db, err := sql.Open(recordingSQLDriverName, dsn) + if err != nil { + t.Fatalf("open recording SQL db: %v", err) + } + return db, recorder +} + +func findStatement(statements []string, prefix string) int { + for index, statement := range statements { + if strings.Contains(statement, prefix) { + return index + } + } + return -1 +} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json new file mode 100644 index 0000000..f7d869b --- /dev/null +++ b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "databaseUserVersion": 57, + "owner": "game.scum", + "store": "platform-mysql", + "tables": [ + { + "name": "scum_trajectories", + "writer": "companion.SCUMSQLStore.StoreTrajectorySamples", + "migration": "companion.SCUMSQLStore.EnsureSchema", + "primaryKey": "record_key", + "uniqueSampleKey": ["server_instance_id", "subject_type", "subject_id", "sampled_at"], + "coordinateColumns": ["world_x", "world_y", "world_z"], + "coordinatePolicy": "store-game-world-coordinates-only" + } + ] +} diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts index 4936e73..087b196 100644 --- a/plugins/examples/scum-server-plugin/features/api.ts +++ b/plugins/examples/scum-server-plugin/features/api.ts @@ -19,8 +19,8 @@ export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatu async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode(result.result) : null; }, async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); }, async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); }, - async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "gifts" }); return result.status === "ok" ? decode(result.result) ?? [] : []; }, - async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; } + async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "gifts" }); return result.status === "ok" ? decode(result.result) ?? [] : []; }, + async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "trajectories" }); return result.status === "ok" ? decode(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; } }; } diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 0182faf..8053e03 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -311,12 +311,12 @@ function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) { function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase())); - return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件声明的 SCUM 登录日志投影。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "lastLoginIp", "loginIp", "ipAddress", "ip") || shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件自有 SCUM 登录事件记录。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "lastLoginIp", "loginIp", "ipAddress", "ip") || shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); } function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { const rows = playerRecords(data.trajectories, player).filter((row) => layerOf(row) === "players" && hasCoordinates(positionOf(row))).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 120); - return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自 Run 每 3 秒查询 SCUM.db 的采样投影;乘车状态按同一时刻附近载具保守标识。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = nearbyVehicle(row, data.vehicles); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, ride ? `疑似乘坐 ${pointTitle(ride)}` : textField(row, "source") || "run.sqlite")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。"))); + return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自 SCUM 插件自有轨迹表;保存的是游戏原始 world 坐标,地图像素只在前端显示时计算。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = nearbyVehicle(row, data.vehicles); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, ride ? `疑似乘坐 ${pointTitle(ride)}` : textField(row, "source") || "plugin.sql")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。"))); } function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); } @@ -563,8 +563,8 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在保存地图范围…", async () => { await saveMapSettings(actions ?? {}, { customMapEnabled: customEnabled, centerX: numberInput(centerX, 0), centerY: numberInput(centerY, 0), widthKm: numberInput(widthKm, 15.24), heightKm: numberInput(heightKm, 15.24) }); view.refresh(); return "地图范围已保存。"; }) }, "保存地图范围")) ), 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))), 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" }, "当前图层和筛选条件下没有真实地图点。")) + e("div", { className: "map-world-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-world-dot map-layer-${layerOf(point)}${ride ? " map-world-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))), 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") || "plugin.sql")))) : null) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。")) ) ); } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index ee0b921..e7f8025 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -268,21 +268,7 @@ "sqlRef": "sql/scum-db-v57/users.sql", "pollIntervalSeconds": 3, "maxRows": 500, - "timeoutSeconds": 15, - "projections": [ - { - "collection": "scum_users", - "rowPath": "rows", - "upsertKeys": [ - "steamId" - ], - "fixedValues": { - "source": "run.sqlite.scum.player.profile" - }, - "observedAtField": "profileSampledAt", - "mergeExisting": true - } - ] + "timeoutSeconds": 15 }, { "key": "scum.squads", @@ -324,66 +310,7 @@ "sqlRef": "sql/scum-db-v57/vehicles.sql", "pollIntervalSeconds": 3, "maxRows": 500, - "timeoutSeconds": 15, - "projections": [ - { - "collection": "scum_vehicles", - "rowPath": "rows", - "upsertKeys": [ - "vehicleId" - ], - "fixedValues": { - "source": "run.sqlite.scum.vehicles" - }, - "observedAtField": "sampledAt" - }, - { - "collection": "scum_trajectories", - "rowPath": "rows", - "upsertKeys": [ - "subjectType", - "subjectId", - "sampledAt" - ], - "fieldMappings": { - "subjectId": "vehicleId", - "vehicleId": "vehicleId", - "entityId": "entityId", - "className": "className", - "label": "label", - "x": "x", - "y": "y", - "z": "z", - "lastAccessTime": "lastAccessTime" - }, - "fixedValues": { - "subjectType": "vehicle", - "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 - } - ] + "timeoutSeconds": 15 }, { "key": "scum.flags", @@ -411,69 +338,7 @@ "sqlRef": "sql/scum-db-v57/map-points.sql", "pollIntervalSeconds": 3, "maxRows": 500, - "timeoutSeconds": 15, - "projections": [ - { - "collection": "scum_users", - "rowPath": "rows", - "matchField": "subjectType", - "matchValue": "player", - "upsertKeys": [ - "steamId" - ], - "fieldMappings": { - "steamId": "subjectId", - "userProfileId": "userProfileId", - "gamePlayerId": "gamePlayerId", - "x": "x", - "y": "y", - "z": "z", - "lastPositionObservedAt": "observedAt" - }, - "fixedValues": { - "source": "run.sqlite.scum.positions" - }, - "observedAtField": "positionSampledAt" - }, - { - "collection": "scum_map_points", - "rowPath": "rows", - "upsertKeys": [ - "subjectType", - "subjectId" - ], - "fixedValues": { - "source": "run.sqlite.scum.positions" - }, - "observedAtField": "sampledAt" - }, - { - "collection": "scum_trajectories", - "rowPath": "rows", - "matchField": "subjectType", - "matchValue": "player", - "upsertKeys": [ - "subjectType", - "subjectId", - "sampledAt" - ], - "fieldMappings": { - "subjectType": "subjectType", - "subjectId": "subjectId", - "steamId": "subjectId", - "userProfileId": "userProfileId", - "gamePlayerId": "gamePlayerId", - "x": "x", - "y": "y", - "z": "z", - "observedAt": "observedAt" - }, - "fixedValues": { - "source": "run.sqlite.scum.positions" - }, - "observedAtField": "sampledAt" - } - ] + "timeoutSeconds": 15 }, { "key": "scum.tasks", @@ -888,7 +753,8 @@ ], "dataRefs": [ "data-packs/scum-db-v57/gift-items.json", - "data-packs/scum-db-v57/map-geometry.json" + "data-packs/scum-db-v57/map-geometry.json", + "data-packs/scum-db-v57/storage-model.json" ] } ], @@ -1341,6 +1207,10 @@ { "path": "data-packs/scum-db-v57/map-geometry.json", "mode": 384 + }, + { + "path": "data-packs/scum-db-v57/storage-model.json", + "mode": 384 } ], "productionLifecycle": { diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md b/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md index 6711fc8..1581529 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md @@ -2,7 +2,7 @@ These query template keys are browser-safe declarations. They intentionally do not carry SQL text, host paths, DSNs, sockets, or credentials. The bound run/agent beside the current SCUM service owns the actual SQLite read implementation and must return rows matching the referenced result schemas. -| Template key | SCUM.db source tables | Projection target | +| Template key | SCUM.db source tables | Plugin-owned output | | --- | --- | --- | | `scum.player.profile` | `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, `bank_account_registry_currencies`, optional `squad_member` / `squad` joins | Player identity, economy, squad summary, and current position | | `scum.squads` | `squad`, optional `squad_member`, optional `user_profile` leader joins | Squad records and leader/member counts | diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json index 8cf9a42..c2697d8 100644 --- a/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json @@ -1 +1 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","playerId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}} +{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","playerId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json index 2bbc446..d06dd9c 100644 --- a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json @@ -1 +1 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}} +{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json index 2bbc446..d06dd9c 100644 --- a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json @@ -1 +1 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}} +{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json index 8b95322..fa2dd9b 100644 --- a/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json @@ -1 +1 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","vehicleId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","log-projection"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}} +{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","vehicleId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}} diff --git a/plugins/tests/fixtures/scum-migration-parity.ts b/plugins/tests/fixtures/scum-migration-parity.ts index 8bad2ef..305b2fd 100644 --- a/plugins/tests/fixtures/scum-migration-parity.ts +++ b/plugins/tests/fixtures/scum-migration-parity.ts @@ -16,7 +16,7 @@ export const scumMigrationParityFixtures = { expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } } }, trajectory: { - source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] }, + source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "legacy-log" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] }, expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "trajectory-1", recordedAt: "2026-07-29T00:40:00Z", payload: { subjectId: "player-1", subjectType: "player", provenance: "transitional-read-only", points: [{ occurredAt: "2026-07-29T00:39:00Z", subjectId: "player-1", subjectType: "player", x: 10, y: 20, source: "transitional-read-only" }] } } } } as const; diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 45aebf5..c9d389c 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -501,9 +501,7 @@ 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.queryTemplates.every((template) => !template.projections?.length)).toBe(true); 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"); @@ -595,7 +593,7 @@ describe("plugin manifest validation", () => { } }); - it("declares bounded SCUM snapshot schemas for operations projections", () => { + it("declares bounded SCUM snapshot schemas for operations views", () => { const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { gameClientBridge: { @@ -673,7 +671,7 @@ describe("plugin manifest validation", () => { const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "scum.positions"]); const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template])); expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); - expect(templatesByKey.get("scum.player.profile")?.projections).toEqual([expect.objectContaining({ collection: "scum_users", rowPath: "rows", upsertKeys: ["steamId"], observedAtField: "profileSampledAt", mergeExisting: true })]); + expect([...templatesByKey.values()].every((template) => !template.projections?.length)).toBe(true); expect(manifest.capabilities).toContain("remote.run.db.sqlite.query"); expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute"); expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query"); @@ -741,11 +739,13 @@ describe("plugin manifest validation", () => { const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8")); const giftMetadata = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![0]), "utf8")); const mapGeometry = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![1]), "utf8")); + const storageModel = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![2]), "utf8")); expect(logParsers).toMatchObject({ encoding: "utf-16le", lineEnding: "lf", continuationPolicy: "append-to-previous-timestamped-record", timestampFormat: "yyyy.MM.dd-HH.mm.ss" }); expect(logParsers.parsers.map((parser: { key: string }) => parser.key)).toEqual(expect.arrayContaining(["login", "chat", "admin", "kill", "event-kill", "quests", "vehicle-destruction"])); expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"])); expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } }); expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-overview.jpg", width: 256, height: 256 }, runtimeOverride: { kilometersToWorldUnits: 100000 } }); + expect(storageModel).toMatchObject({ databaseUserVersion: 57, store: "platform-mysql", tables: [expect.objectContaining({ name: "scum_trajectories", writer: "companion.SCUMSQLStore.StoreTrajectorySamples", coordinateColumns: ["world_x", "world_y", "world_z"], coordinatePolicy: "store-game-world-coordinates-only" })] }); }); it("declares typed SCUM semantic log events with bounded schemas", () => { diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index f701c17..e6e6dfc 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -39,8 +39,8 @@ const surfaceData: SCUMSurfaceData = { mapSettings: [], 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" } + { subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "plugin.sql.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: "plugin.sql.scum.vehicles" } ], flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { x: 100, y: 80, z: 0 }, freshness: { status: "fresh" } }] };