feat(scum): rebuild plugin-owned management data

This commit is contained in:
npc0-hue
2026-08-15 12:43:09 +08:00
parent 92b1159cc8
commit 65353cf269
113 changed files with 3116 additions and 8058 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -47,21 +47,59 @@ type StateFieldPatch struct {
After float64
}
// AuthorizedRewardPort accepts only a frozen grant and typed items. It cannot
// receive SQL, a raw database row, a shell command, an RCON command, or secrets.
// AuthorizedRewardPort accepts a frozen grant with typed items and operations.
type AuthorizedRewardPort interface {
DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error)
}
type RewardGrant struct {
GrantID string
PlayerID string
Items []RewardItem
GrantID string
PlayerID string
Items []RewardItem
Operations []string
}
type RewardItem struct {
CatalogCode string
Quantity int
}
type DeliveryReceipt struct{ Outcome string }
type DeliveryReceipt struct {
Outcome string
DeliveryID string
}
type AuthorizedEventPort interface {
StartEvent(context.Context, EventStartRequest) (EventStartReceipt, error)
}
type EventStartRequest struct {
EventID string
EventType string
Class int
Title string
Placard string
Percent int
NPC int
Item int
Zombie int
Animal int
Produces []EventProduceRequest
DurationSeconds int
MaxParticipants int
Announce bool
}
type EventProduceRequest struct {
TradeGoodsID string
Percent int
Value int
Radius float64
X float64
Y float64
Z float64
}
type EventStartReceipt struct {
Accepted bool
Status string
EventID string
Message string
}
const fixedNotificationType = 4
@@ -102,6 +140,7 @@ type RuntimeAdapter struct {
Config AuthorizedConfigPort
GameData AuthorizedGameDataPort
Rewards AuthorizedRewardPort
Events AuthorizedEventPort
Notification UE4SSNotificationPort
VehicleSpawn UE4SSVehicleSpawnPort
DiagnosticsState map[string]string
@@ -191,12 +230,45 @@ func (adapter RuntimeAdapter) DeliverReward(ctx context.Context, payload map[str
}
receipt, err := adapter.Rewards.DeliverReward(ctx, grant)
if err != nil || receipt.Outcome == "unknown" {
return map[string]any{"outcome": "unknown"}, nil
return map[string]any{"accepted": false, "status": "rejected", "message": "reward delivery result is unknown"}, nil
}
if receipt.Outcome != "delivered" {
return map[string]any{"outcome": "failed"}, nil
return map[string]any{"accepted": false, "status": "rejected"}, nil
}
return map[string]any{"outcome": "delivered"}, nil
result := map[string]any{"accepted": true, "status": "delivered"}
if receipt.DeliveryID != "" {
result["deliveryId"] = receipt.DeliveryID
}
return result, nil
}
func (adapter RuntimeAdapter) StartEvent(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.Events == nil {
return nil, errAdapterUnsupported
}
request, err := eventStartRequest(payload)
if err != nil {
return nil, err
}
receipt, err := adapter.Events.StartEvent(ctx, request)
if err != nil {
return nil, err
}
status := receipt.Status
if status == "" {
status = "rejected"
}
if status != "started" && status != "queued" && status != "rejected" {
return nil, fmt.Errorf("event start receipt is invalid")
}
eventID := receipt.EventID
if eventID == "" {
eventID = request.EventID
}
result := map[string]any{"accepted": receipt.Accepted, "status": status, "eventId": eventID}
if receipt.Message != "" {
result["message"] = receipt.Message
}
return result, nil
}
func (adapter RuntimeAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.BoundServerID == "" || adapter.Notification == nil {
@@ -342,22 +414,149 @@ func stateApplied(values map[string]float64, fields []StateFieldPatch) bool {
func rewardGrant(payload map[string]any) (RewardGrant, error) {
grantID, grantOK := payload["grantId"].(string)
playerID, playerOK := payload["playerId"].(string)
raw, itemsOK := payload["items"].([]any)
if !grantOK || !playerOK || !itemsOK || len(raw) == 0 || len(raw) > 8 {
rawItems, itemsOK := payload["items"].([]any)
rawOperations, operationsOK := payload["operations"].([]any)
if !grantOK || !playerOK || !itemsOK || !operationsOK || (len(rawItems) == 0 && len(rawOperations) == 0) || len(rawItems) > 8 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items := make([]RewardItem, 0, len(raw))
for _, value := range raw {
items := make([]RewardItem, 0, len(rawItems))
for _, value := range rawItems {
item, ok := value.(map[string]any)
if !ok {
if !ok || len(item) != 2 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
code, codeOK := item["catalogCode"].(string)
quantity, quantityOK := item["quantity"].(float64)
if !codeOK || !quantityOK || quantity < 1 || quantity > 99 {
quantity, quantityOK := integerPayloadValue(item["quantity"])
if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 || quantity > 100 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items = append(items, RewardItem{CatalogCode: code, Quantity: int(quantity)})
items = append(items, RewardItem{CatalogCode: code, Quantity: quantity})
}
return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items}, nil
operations := make([]string, 0, len(rawOperations))
for _, value := range rawOperations {
operation, ok := value.(string)
if !ok || operation == "" || !utf8.ValidString(operation) {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
operations = append(operations, operation)
}
return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items, Operations: operations}, nil
}
func eventStartRequest(payload map[string]any) (EventStartRequest, error) {
eventID, eventIDOK := payload["eventId"].(string)
eventType, eventTypeOK := payload["eventType"].(string)
eventClass, classOK := integerPayloadValue(payload["class"])
title, titleOK := payload["title"].(string)
duration, durationOK := integerPayloadValue(payload["durationSeconds"])
percent, percentOK := integerPayloadValue(payload["percent"])
placard, placardOK := payload["placard"].(string)
produces, producesOK := eventProduceRequests(payload["produces"])
counts := make([]int, 4)
for index, key := range []string{"npc", "item", "zombie", "animal"} {
if value, exists := payload[key]; exists {
count, ok := integerPayloadValue(value)
if !ok || count < 0 || count > 10000 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
counts[index] = count
}
}
participants := 0
if value, exists := payload["maxParticipants"]; exists {
var ok bool
participants, ok = integerPayloadValue(value)
if !ok || participants < 1 || participants > 1000 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
}
announce := false
if value, exists := payload["announce"]; exists {
var ok bool
announce, ok = value.(bool)
if !ok {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
}
if !eventIDOK || !eventTypeOK || !classOK || !titleOK || !durationOK || !percentOK || !placardOK || !producesOK || !supportedEventType(eventType) || eventClass < 1 || eventClass > 2 || (eventClass == 1) != (eventType == "range") || strings.TrimSpace(eventID) == "" || strings.TrimSpace(title) == "" || len(placard) > 500 || percent < 0 || percent > 100 || duration < 30 || duration > 86400 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
return EventStartRequest{EventID: eventID, EventType: eventType, Class: eventClass, Title: title, Placard: placard, Percent: percent, NPC: counts[0], Item: counts[1], Zombie: counts[2], Animal: counts[3], Produces: produces, DurationSeconds: duration, MaxParticipants: participants, Announce: announce}, nil
}
func eventProduceRequests(value any) ([]EventProduceRequest, bool) {
raw, ok := value.([]any)
if !ok || len(raw) > 100 {
return nil, false
}
result := make([]EventProduceRequest, 0, len(raw))
for _, candidate := range raw {
produce, ok := candidate.(map[string]any)
if !ok || len(produce) != 7 {
return nil, false
}
tradeGoodsID, idOK := produce["tradeGoodsId"].(string)
percent, percentOK := integerPayloadValue(produce["percent"])
quantity, quantityOK := integerPayloadValue(produce["value"])
radius, radiusOK := numberPayloadValue(produce["r"])
x, xOK := numberPayloadValue(produce["x"])
y, yOK := numberPayloadValue(produce["y"])
z, zOK := numberPayloadValue(produce["z"])
if !idOK || strings.TrimSpace(tradeGoodsID) == "" || len(tradeGoodsID) > 128 || !percentOK || percent < 0 || percent > 100 || !quantityOK || quantity < 1 || quantity > 10000 || !radiusOK || radius < 0 || radius > 2000000 || !xOK || !yOK || !zOK || x < -2000000 || x > 2000000 || y < -2000000 || y > 2000000 || z < -2000000 || z > 2000000 {
return nil, false
}
result = append(result, EventProduceRequest{TradeGoodsID: tradeGoodsID, Percent: percent, Value: quantity, Radius: radius, X: x, Y: y, Z: z})
}
return result, true
}
func integerPayloadValue(value any) (int, bool) {
switch number := value.(type) {
case float64:
if number != float64(int(number)) {
return 0, false
}
return int(number), true
case int:
return number, true
case int32:
return int(number), true
case int64:
return int(number), true
default:
return 0, false
}
}
func numberPayloadValue(value any) (float64, bool) {
switch number := value.(type) {
case float64:
return number, true
case float32:
return float64(number), true
case int:
return float64(number), true
case int32:
return float64(number), true
case int64:
return float64(number), true
default:
return 0, false
}
}
func supportedCatalogCode(value string) bool {
if value == "" || len(value) > 128 {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '_' && character != '-' && character != '.' {
return false
}
}
return true
}
func supportedEventType(value string) bool {
return value == "range" || value == "fixed"
}
@@ -82,7 +82,7 @@ func e2eClaim(id, commandType string, payload map[string]any, stamp time.Time) C
}
func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
stamp := time.Now().UTC()
port := &isolatedAdapterPort{
configFields: map[string]string{"ServerName": "Moonlight", "Password": "never-return", "hostPath": "C:/private/server.ini"},
notifyAccept: true,
@@ -130,7 +130,7 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
}
func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
stamp := time.Now().UTC()
for name, testCase := range map[string]struct {
availability HandlerAvailability
adapter RuntimeAdapter
@@ -156,7 +156,7 @@ func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T
}
func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
stamp := time.Now().UTC()
port := &isolatedAdapterPort{patchErr: errors.New("private port failed"), notifyErr: errors.New("private notification failed")}
adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port}
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{
@@ -25,6 +25,28 @@ type notificationPortFixture struct {
accepted bool
}
type rewardPortFixture struct {
grants []RewardGrant
receipt DeliveryReceipt
err error
}
func (fixture *rewardPortFixture) DeliverReward(_ context.Context, grant RewardGrant) (DeliveryReceipt, error) {
fixture.grants = append(fixture.grants, grant)
return fixture.receipt, fixture.err
}
type eventPortFixture struct {
requests []EventStartRequest
receipt EventStartReceipt
err error
}
func (fixture *eventPortFixture) StartEvent(_ context.Context, request EventStartRequest) (EventStartReceipt, error) {
fixture.requests = append(fixture.requests, request)
return fixture.receipt, fixture.err
}
// nonProductionVehicleSpawnPortFixture is an isolated test double. It has no
// network, socket, credential, or raw-command entry point; it can observe only
// the Companion's private typed request and return a bounded receipt.
@@ -68,6 +90,87 @@ func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testi
}
}
func TestRewardDeliveryAcceptsRealSCUMCatalogCodesAndReturnsDeclaredResult(t *testing.T) {
port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered", DeliveryID: "delivery-1"}}
adapter := RuntimeAdapter{Rewards: port}
result, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-1", "playerId": "76561198000000001",
"items": []any{map[string]any{"catalogCode": "BPC_Improvised_Backpack.01", "quantity": float64(2)}},
"operations": []any{"#SpawnItem BPC_Improvised_Backpack.01 2"},
})
if err != nil || result["accepted"] != true || result["status"] != "delivered" || result["deliveryId"] != "delivery-1" {
t.Fatalf("reward result did not match the bridge schema: result=%+v err=%v", result, err)
}
if len(port.grants) != 1 || port.grants[0].Items[0] != (RewardItem{CatalogCode: "BPC_Improvised_Backpack.01", Quantity: 2}) || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SpawnItem BPC_Improvised_Backpack.01 2" {
t.Fatalf("reward items or operations did not reach the typed reward port: %+v", port.grants)
}
before := len(port.grants)
if _, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-2", "playerId": "76561198000000001",
"items": []any{map[string]any{"catalogCode": "#SpawnItem BPC_Bad", "quantity": float64(1)}},
"operations": []any{},
}); err == nil || len(port.grants) != before {
t.Fatal("invalid catalog text reached the typed reward port")
}
}
func TestRewardDeliverySupportsOperationsWithoutItemsAndRejectsEmptyGrant(t *testing.T) {
port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered"}}
adapter := RuntimeAdapter{BoundServerID: "server-1", Rewards: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"reward.deliver": true}}, adapter)
stamp := time.Now().UTC()
result, err := registry.Execute(context.Background(), ClaimedCommand{
ID: "reward-operations", ProfileKey: ProfileKey, CommandType: "reward.deliver", FencingToken: 1,
LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute),
Payload: map[string]any{"grantId": "grant-operations", "playerId": "76561198000000001", "items": []any{}, "operations": []any{"#SetFamePoints 250"}},
})
if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || len(port.grants) != 1 || len(port.grants[0].Items) != 0 || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SetFamePoints 250" {
t.Fatalf("operation-only reward was not preserved: result=%+v grants=%+v err=%v", result, port.grants, err)
}
if _, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{},
}); err == nil || len(port.grants) != 1 {
t.Fatalf("empty reward reached the typed reward port: grants=%+v err=%v", port.grants, err)
}
}
func TestEventStartRequiresMatchingClassAndType(t *testing.T) {
payload := map[string]any{"eventId": "event-fixed", "eventType": "fixed", "class": float64(2), "title": "Fixed Event", "placard": "Hold the point", "percent": float64(100), "produces": []any{}, "durationSeconds": float64(600)}
request, err := eventStartRequest(payload)
if err != nil || request.EventType != "fixed" || request.Class != 2 {
t.Fatalf("fixed class event was not accepted: request=%+v err=%v", request, err)
}
payload["class"] = float64(1)
if _, err := eventStartRequest(payload); err == nil {
t.Fatal("divergent event class and type was accepted")
}
}
func TestEventStartHandlerInvokesTypedPortAndReturnsCachedCommandResult(t *testing.T) {
stamp := time.Now().UTC()
port := &eventPortFixture{receipt: EventStartReceipt{Accepted: true, Status: "started", EventID: "event-1", Message: "range event started"}}
adapter := RuntimeAdapter{BoundServerID: "server-1", Events: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"event.start": true}}, adapter)
command := ClaimedCommand{
ID: "event-command-1", ProfileKey: ProfileKey, CommandType: "event.start", FencingToken: 1,
LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute),
Payload: map[string]any{"eventId": "event-1", "eventType": "range", "class": float64(1), "title": "Friday Range", "placard": "Event starting", "percent": float64(75), "npc": float64(2), "item": float64(3), "zombie": float64(4), "animal": float64(1), "produces": []any{map[string]any{"tradeGoodsId": "cargo-drop", "percent": float64(80), "value": float64(2), "r": float64(500), "x": float64(1000), "y": float64(2000), "z": float64(300)}}, "durationSeconds": float64(1800), "maxParticipants": float64(40), "announce": true},
}
for range 2 {
result, err := registry.Execute(context.Background(), command)
if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || result.Payload["status"] != "started" || result.Payload["eventId"] != "event-1" {
t.Fatalf("event.start did not return its executable result: result=%+v err=%v", result, err)
}
}
if len(port.requests) != 1 {
t.Fatalf("event.start handler did not execute exactly once: %+v", port.requests)
}
request := port.requests[0]
if request.EventID != "event-1" || request.EventType != "range" || request.Class != 1 || request.Title != "Friday Range" || request.Placard != "Event starting" || request.Percent != 75 || request.NPC != 2 || request.Item != 3 || request.Zombie != 4 || request.Animal != 1 || len(request.Produces) != 1 || request.Produces[0].TradeGoodsID != "cargo-drop" || request.DurationSeconds != 1800 || request.MaxParticipants != 40 || !request.Announce {
t.Fatalf("event.start payload did not reach the typed event port: %+v", request)
}
}
func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) {
port := &notificationPortFixture{accepted: true}
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
@@ -18,6 +18,7 @@ type SafeAdapter interface {
Diagnostics(context.Context) (map[string]any, error)
PatchGameState(context.Context, map[string]any) (map[string]any, error)
DeliverReward(context.Context, map[string]any) (map[string]any, error)
StartEvent(context.Context, map[string]any) (map[string]any, error)
NotifyPlayer(context.Context, map[string]any) (map[string]any, error)
SpawnVehicle(context.Context, map[string]any) (map[string]any, error)
}
@@ -62,6 +63,9 @@ func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) *
registry.handlers["reward.deliver"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.DeliverReward(ctx, payload)
}
registry.handlers["event.start"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.StartEvent(ctx, payload)
}
registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.NotifyPlayer(ctx, payload)
}
@@ -183,19 +187,23 @@ func validateCommandPayload(commandType string, payload map[string]any) error {
}
return nil
case "reward.deliver":
if err := require("grantId", "playerId", "items"); err != nil {
if err := require("grantId", "playerId", "items", "operations"); err != nil {
return err
}
if err := noUnknown("grantId", "playerId", "items"); err != nil {
if err := noUnknown("grantId", "playerId", "items", "operations"); err != nil {
return err
}
_, grantOK := payload["grantId"].(string)
_, playerOK := payload["playerId"].(string)
items, itemsOK := payload["items"].([]any)
if !grantOK || !playerOK || !itemsOK || len(items) == 0 || len(items) > 8 {
return fmt.Errorf("reward payload is invalid")
_, err := rewardGrant(payload)
return err
case "event.start":
if err := require("eventId", "eventType", "class", "title", "placard", "percent", "produces", "durationSeconds"); err != nil {
return err
}
return nil
if err := noUnknown("eventId", "eventType", "class", "title", "placard", "percent", "npc", "item", "zombie", "animal", "produces", "durationSeconds", "maxParticipants", "announce"); err != nil {
return err
}
_, err := eventStartRequest(payload)
return err
case "player.notify":
if err := require("playerId", "message"); err != nil {
return err
@@ -42,6 +42,9 @@ func (*adapterFixture) PatchGameState(context.Context, map[string]any) (map[stri
func (*adapterFixture) DeliverReward(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func (*adapterFixture) StartEvent(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func (*adapterFixture) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
@@ -92,3 +95,21 @@ func TestRegistryRejectsUndeclaredAndMalformedPayloads(t *testing.T) {
}
}
}
func TestRewardPayloadValidationRequiresItemsOrOperations(t *testing.T) {
validBase := map[string]any{"grantId": "grant-1", "playerId": "76561198000000001"}
for name, payload := range map[string]map[string]any{
"items": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{}},
"operations": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{}, "operations": []any{"#SetFamePoints 250"}},
"both": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{"#SetFamePoints 250"}},
} {
t.Run(name, func(t *testing.T) {
if err := validateCommandPayload("reward.deliver", payload); err != nil {
t.Fatalf("valid reward payload was rejected: %v", err)
}
})
}
if err := validateCommandPayload("reward.deliver", map[string]any{"grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{}}); err == nil {
t.Fatal("reward payload with no items or operations was accepted")
}
}
@@ -0,0 +1,29 @@
{
"version": 2,
"baseDirectoryKey": "config/windows-server",
"maps": [
{
"key": "server-settings",
"format": "ini",
"encoding": "utf-8",
"fileName": "ServerSettings.ini",
"sections": {
"General": ["scum.ServerName", "scum.ServerDescription", "scum.MaxPlayers", "scum.WelcomeMessage", "scum.MessageOfTheDay", "scum.AllowEvents", "scum.DisableTimedGifts"],
"World": ["scum.CustomMapEnabled", "scum.CustomMapCenterXCoordinate", "scum.CustomMapCenterYCoordinate", "scum.CustomMapWidth", "scum.CustomMapHeight"],
"Respawn": ["scum.AllowSectorRespawn", "scum.AllowShelterRespawn", "scum.AllowSquadmateRespawn", "scum.RandomRespawnPrice", "scum.SquadRespawnPrice"],
"Vehicles": ["scum.MaximumTimeOfVehicleInactivity", "scum.LogVehicleDestroyed"],
"Damage": ["scum.HumanToHumanDamageMultiplier", "scum.ZombieDamageMultiplier", "scum.ItemDecayDamageMultiplier"],
"Features": ["scum.FlagOvertakeDuration", "scum.AllowMultipleFlagsPerPlayer", "scum.RaidProtectionType", "scum.QuestsEnabled", "scum.EnableNewPlayerProtection"]
}
},
{ "key": "economy-override", "format": "json", "encoding": "utf-8", "fileName": "EconomyOverride.json", "rootPath": "economy-override", "fields": ["traders", "economy-reset-time-hours", "tradeable-rotation-enabled", "traders-unlimited-funds", "traders-unlimited-stock"] },
{ "key": "raid-times", "format": "json", "encoding": "utf-8", "fileName": "RaidTimes.json", "rootPath": "raiding-times", "fields": ["day", "time", "start-announcement-time", "end-announcement-time"] },
{ "key": "notifications", "format": "json", "encoding": "utf-8", "fileName": "Notifications.json", "rootPath": "Notifications", "fields": [] },
{ "key": "admin-users", "format": "line-list", "encoding": "utf-8", "fileName": "AdminUsers.ini" },
{ "key": "server-settings-admin-users", "format": "line-list", "encoding": "utf-8", "fileName": "ServerSettingsAdminUsers.ini" },
{ "key": "banned-users", "format": "line-list", "encoding": "utf-8", "fileName": "BannedUsers.ini" },
{ "key": "whitelisted-users", "format": "line-list", "encoding": "utf-8", "fileName": "WhitelistedUsers.ini" },
{ "key": "exclusive-users", "format": "line-list", "encoding": "utf-8", "fileName": "ExclusiveUsers.ini" },
{ "key": "silenced-users", "format": "line-list", "encoding": "utf-8", "fileName": "SilencedUsers.ini" }
]
}
@@ -0,0 +1,26 @@
{
"version": 2,
"databaseUserVersion": 57,
"catalogSource": {
"configMapKey": "economy-override",
"rootPath": "economy-override.traders",
"itemCodeField": "tradeable-code"
},
"giftClasses": [
{ "value": 1, "key": "daily" },
{ "value": 2, "key": "weekly" },
{ "value": 3, "key": "monthly" },
{ "value": 4, "key": "yearly" },
{ "value": 5, "key": "one-time" },
{ "value": 6, "key": "daily-five" }
],
"recipientStatuses": [
{ "value": 0, "key": "all" },
{ "value": 1, "key": "pve" },
{ "value": 2, "key": "pvp" }
],
"deliveryCommands": [
{ "kind": "item", "template": "#SpawnItem {itemCode} {quantity}" },
{ "kind": "vehicle", "template": "#SpawnVehicle {vehicleCode}" }
]
}
@@ -0,0 +1,30 @@
{
"version": 2,
"encoding": "utf-16le",
"lineEnding": "lf",
"continuationPolicy": "append-to-previous-timestamped-record",
"timestampFormat": "yyyy.MM.dd-HH.mm.ss",
"defaultPattern": "^([0-9]{4}\\.[0-9]{2}\\.[0-9]{2}-[0-9]{2}\\.[0-9]{2}\\.[0-9]{2}):?\\s*(.*)$",
"defaultFields": ["occurredAt", "payload"],
"parsers": [
{ "key": "login", "filePattern": "^login_[0-9]{14}\\.log$", "eventType": "scum.login" },
{ "key": "chat", "filePattern": "^chat_[0-9]{14}\\.log$", "eventType": "scum.chat" },
{ "key": "admin", "filePattern": "^admin_[0-9]{14}\\.log$", "eventType": "scum.admin" },
{ "key": "kill", "filePattern": "^kill_[0-9]{14}\\.log$", "eventType": "scum.kill" },
{ "key": "event-kill", "filePattern": "^event_kill_[0-9]{14}\\.log$", "eventType": "scum.event.kill" },
{ "key": "quests", "filePattern": "^quests_[0-9]{14}\\.log$", "eventType": "scum.quest" },
{ "key": "famepoints", "filePattern": "^famepoints_[0-9]{14}\\.log$", "eventType": "scum.famepoints" },
{ "key": "economy", "filePattern": "^economy_[0-9]{14}\\.log$", "eventType": "scum.economy" },
{ "key": "gameplay", "filePattern": "^gameplay_[0-9]{14}\\.log$", "eventType": "scum.gameplay" },
{ "key": "vehicle-destruction", "filePattern": "^vehicle_destruction_[0-9]{14}\\.log$", "eventType": "scum.vehicle.destruction" },
{ "key": "raid-protection", "filePattern": "^raid_protection_[0-9]{14}\\.log$", "eventType": "scum.raid.protection" },
{ "key": "base-building-destruction", "filePattern": "^base_building_destruction_[0-9]{14}\\.log$", "eventType": "scum.base.destruction" },
{ "key": "chest-ownership", "filePattern": "^chest_ownership_[0-9]{14}\\.log$", "eventType": "scum.chest.ownership" },
{ "key": "loot", "filePattern": "^loot_[0-9]{14}\\.log$", "eventType": "scum.loot" },
{ "key": "violations", "filePattern": "^violations_[0-9]{14}\\.log$", "eventType": "scum.violation" },
{ "key": "sentry", "filePattern": "^sentry_[0-9]{14}\\.log$", "eventType": "scum.sentry" },
{ "key": "server-notifications", "filePattern": "^server_notifications_[0-9]{14}\\.log$", "eventType": "scum.server.notification" },
{ "key": "armor-absorption", "filePattern": "^armor_absorption_[0-9]{14}\\.log$", "eventType": "scum.armor.absorption" },
{ "key": "network-objects", "filePattern": "^network_objects_[0-9]{14}\\.log$", "eventType": "scum.network.object" }
]
}
@@ -0,0 +1,27 @@
{
"version": 1,
"mapId": "scum-island",
"databaseUserVersion": 57,
"units": "unreal-centimeters",
"image": {
"path": "assets/map/scum-map-overview.jpg",
"width": 256,
"height": 256
},
"defaultBounds": {
"worldMinX": -905000,
"worldMinY": -905000,
"worldMaxX": 619000,
"worldMaxY": 619000
},
"runtimeOverride": {
"configMapKey": "server-settings",
"section": "World",
"enabledField": "scum.CustomMapEnabled",
"centerXField": "scum.CustomMapCenterXCoordinate",
"centerYField": "scum.CustomMapCenterYCoordinate",
"widthField": "scum.CustomMapWidth",
"heightField": "scum.CustomMapHeight",
"kilometersToWorldUnits": 100000
}
}
@@ -0,0 +1,367 @@
export type RecordMap = Record<string, unknown>;
export type PluginDataMutation = { operation: "put" | "delete"; key: string; value?: RecordMap };
export type PluginDataActions = {
list: (collection: string, key?: string) => Promise<unknown>;
put: (collection: string, key: string, value: RecordMap) => Promise<unknown>;
delete: (collection: string, key: string) => Promise<void>;
transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>;
};
export type PluginGameClientQueueRequest = {
profileKey: string;
commandType: string;
payload: RecordMap;
idempotencyKey: string;
priority?: number;
expiresAt: string;
};
export type PluginDispatchEnvelope = { requestId: string; action: "remote.access.request"; payload: Record<string, string> };
export type PluginDispatchResult = { requestId: string; action: "remote.access.request"; status: string; result?: Record<string, string>; error?: { code: string; message: string; details?: string[] } };
export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions;
gameClient?: {
queue: (request: PluginGameClientQueueRequest) => Promise<unknown>;
get: (commandId: string) => Promise<unknown>;
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>;
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
};
dispatch?: (envelope: PluginDispatchEnvelope, signal?: AbortSignal) => Promise<PluginDispatchResult>;
};
export type SCUMSurfaceData = {
players: RecordMap[];
squads: RecordMap[];
members: RecordMap[];
events: RecordMap[];
eventProduces: RecordMap[];
eventRuns: RecordMap[];
nativeEventRounds: RecordMap[];
tasks: RecordMap[];
activityEvents: RecordMap[];
gifts: RecordMap[];
giftClaims: RecordMap[];
pendingGifts: RecordMap[];
giftDeliveries: RecordMap[];
timedGiftEvents: RecordMap[];
mapPoints: RecordMap[];
mapRegions: RecordMap[];
mapSettings: RecordMap[];
vehicles: RecordMap[];
flags: RecordMap[];
};
export const emptySCUMSurfaceData: SCUMSurfaceData = {
players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [],
gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: []
};
export const scumCollections = {
players: "scum_users",
squads: "scum_squads",
members: "scum_squad_members",
events: "scum_activity_definitions",
eventProduces: "scum_event_produces",
eventRuns: "scum_event_runs",
nativeEventRounds: "scum_native_event_rounds",
tasks: "scum_tasks",
activityEvents: "scum_activity_events",
gifts: "scum_gifts",
giftClaims: "scum_gift_claims",
pendingGifts: "scum_pending_gifts",
giftDeliveries: "scum_gift_deliveries",
timedGiftEvents: "scum_timed_gift_events",
mapPoints: "scum_map_points",
mapRegions: "scum_map_regions",
mapSettings: "scum_map_settings",
vehicles: "scum_vehicles",
flags: "scum_flags"
} as const;
type SurfaceKey = keyof SCUMSurfaceData;
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
const pageCollections: Record<PageKey, SurfaceKey[]> = {
players: ["players", "members"],
squads: ["squads", "members", "flags"],
"live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"],
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"],
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"]
};
const pageQueries: Record<PageKey, string[]> = {
players: ["scum.player.profile", "scum.positions"],
squads: ["scum.squads", "scum.squad-members", "scum.flags"],
"live-map": ["scum.player.profile", "scum.vehicles", "scum.flags", "scum.positions"],
gifts: ["scum.native-timed-gifts"],
workflows: ["scum.tasks", "scum.events"]
};
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
const keys = pageCollections[canonicalPageKey(pageKey)];
const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const));
for (const [key, response] of records) data[key] = collectionRecords(response);
if (keys.includes("players") && actions.gameClient) {
const [playersSnapshot, sessionsSnapshot] = await Promise.all([
actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined),
actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "online.sessions", streamKey: "current", limit: 1 }).catch(() => undefined)
]);
data.players = mergePlayerSnapshots(data.players, playersSnapshot, sessionsSnapshot);
}
return data;
}
export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown, sessionsResponse: unknown): RecordMap[] {
const playerSnapshot = latestSnapshotPayload(playersResponse);
const sessionSnapshot = latestSnapshotPayload(sessionsResponse);
let merged = players.map((player) => ({ ...player }));
const snapshotPlayers = Array.isArray(playerSnapshot?.players) ? playerSnapshot.players.filter(isRecord) : [];
if (snapshotPlayers.length) {
const byIdentity = playerIndex(merged);
for (const snapshotPlayer of snapshotPlayers) {
const match = findPlayer(merged, byIdentity, snapshotPlayer);
const value = { ...snapshotPlayer, ...(match ? merged[match.index] : {}), ...snapshotPlayer, online: onlineValue(snapshotPlayer), onlineObservedAt: textValue(playerSnapshot?.observedAt) };
if (match) merged[match.index] = value;
else merged.push({ ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") });
}
}
const sessions = Array.isArray(sessionSnapshot?.sessions) ? sessionSnapshot.sessions.filter(isRecord) : [];
if (sessionSnapshot && Array.isArray(sessionSnapshot.sessions)) {
const onlineNames = new Set(sessions.map((session) => firstText(session, "playerName", "displayName", "name").toLowerCase()).filter(Boolean));
merged = merged.map((player) => {
const name = firstText(player, "displayName", "playerName", "name").toLowerCase();
const session = sessions.find((candidate) => firstText(candidate, "playerName", "displayName", "name").toLowerCase() === name);
return { ...player, online: Boolean(name && onlineNames.has(name)), ...(session ? { onlineSession: session } : {}), onlineObservedAt: textValue(sessionSnapshot.observedAt) || textValue(player.onlineObservedAt) };
});
}
return merged;
}
export function hasSCUMPageQueries(pageKey: string): boolean { return pageQueries[canonicalPageKey(pageKey)].length > 0; }
export async function requestSCUMPageQueries(actions: SCUMWorkspaceActions, pageKey: string): Promise<PluginDispatchResult[]> {
if (!actions.dispatch) throw new Error("通用机器动作 dispatch 能力不可用。");
return Promise.all(pageQueries[canonicalPageKey(pageKey)].map((queryKey) => actions.dispatch!({
requestId: requestKey("scum-query", queryKey),
action: "remote.access.request",
payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database", "input.templateKey": queryKey }
})));
}
export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise<unknown> {
const key = requiredKey(gift, "code", "礼包编号");
return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]);
}
export async function deleteGiftDefinition(actions: SCUMWorkspaceActions, key: string): Promise<void> { return requirePluginData(actions).delete(scumCollections.gifts, key); }
export async function resetGiftClaim(actions: SCUMWorkspaceActions, claim: RecordMap): Promise<void> {
const key = firstText(claim, "_recordKey", "id", "claimId");
if (!key) throw new Error("领取记录编号不能为空。");
return requirePluginData(actions).delete(scumCollections.giftClaims, key);
}
export async function resetPendingGift(actions: SCUMWorkspaceActions, pending: RecordMap): Promise<unknown> {
const key = firstText(pending, "_recordKey", "id", "pendingId");
if (!key) throw new Error("待领记录编号不能为空。");
return requirePluginData(actions).put(scumCollections.pendingGifts, key, { ...pending, status: "pending", receivedAt: null, receiveTime: null, resetAt: new Date().toISOString() });
}
export async function createGiftDelivery(actions: SCUMWorkspaceActions, delivery: RecordMap): Promise<unknown> {
const key = requiredKey(delivery, "id", "发放记录编号");
return requirePluginData(actions).put(scumCollections.giftDeliveries, key, delivery);
}
export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: RecordMap, player: RecordMap): Promise<unknown> {
if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。");
const giftCode = requiredKey(gift, "code", "礼包编号");
const playerId = firstText(player, "gamePlayerId", "playerId", "steamId", "id");
if (!playerId) throw new Error("用户编号不能为空。");
const items = normalizeGiftItems(gift.items);
const operations = [...new Set([...normalizeGiftOperations(gift.commands), ...normalizeGiftOperations(gift.operations)])];
if (!items.length && !operations.length) throw new Error("礼包必须包含物品或命令。");
const now = Date.now();
const grantId = safeCommandId(`gift:${giftCode}:${playerId}:${now}`);
const command = await actions.gameClient.queue({
profileKey: "scum-client-manager",
commandType: "reward.deliver",
payload: { grantId, playerId, items, operations },
idempotencyKey: grantId,
expiresAt: new Date(now + 5 * 60_000).toISOString()
});
const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", createdAt: new Date(now).toISOString() };
await createGiftDelivery(actions, record);
return command;
}
export async function saveEventDefinition(actions: SCUMWorkspaceActions, event: RecordMap): Promise<unknown> {
const key = requiredKey(event, "id", "活动编号");
return requirePluginData(actions).put(scumCollections.events, key, event);
}
export async function deleteEventDefinition(actions: SCUMWorkspaceActions, key: string, produces: RecordMap[] = []): Promise<void> {
const pluginData = requirePluginData(actions);
await Promise.all(produces.filter((produce) => firstText(produce, "eventId", "event") === key).map((produce) => pluginData.delete(scumCollections.eventProduces, requiredRecordKey(produce, "生成项"))));
await pluginData.delete(scumCollections.events, key);
}
export async function saveEventProduce(actions: SCUMWorkspaceActions, produce: RecordMap): Promise<unknown> {
const eventId = firstText(produce, "eventId", "event");
if (!eventId) throw new Error("活动编号不能为空。");
const produceId = firstText(produce, "id", "produceId") || requestKey("produce", eventId);
return requirePluginData(actions).put(scumCollections.eventProduces, `${eventId}:${produceId}`, { ...produce, id: produceId, eventId });
}
export async function deleteEventProduce(actions: SCUMWorkspaceActions, produce: RecordMap): Promise<void> {
return requirePluginData(actions).delete(scumCollections.eventProduces, requiredRecordKey(produce, "生成项"));
}
export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap, produces: RecordMap[] = []): Promise<unknown> {
if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。");
const eventId = requiredKey(event, "id", "活动编号");
const eventClass = Number(event.class) === 2 || firstText(event, "eventType") === "fixed" ? 2 : 1;
const eventType = eventClass === 2 ? "fixed" : "range";
const queuedProduces = normalizeEventProduces(produces);
const now = Date.now();
const runId = safeCommandId(`event:${eventId}:${now}`);
const command = await actions.gameClient.queue({
profileKey: "scum-client-manager",
commandType: "event.start",
payload: {
eventId, eventType, class: eventClass, title: textValue(event.name) || eventId,
placard: firstText(event, "placard", "announcement"), percent: boundedInteger(event.percent ?? event.probability, 0, 100, 100),
npc: boundedInteger(event.npc, 0, 10000, 0), item: boundedInteger(event.item, 0, 10000, 0), zombie: boundedInteger(event.zombie, 0, 10000, 0), animal: boundedInteger(event.animal, 0, 10000, 0),
produces: queuedProduces,
durationSeconds: boundedInteger(event.durationSeconds, 30, 86400, 1800), announce: event.announce !== false
},
idempotencyKey: runId,
expiresAt: new Date(now + 5 * 60_000).toISOString()
});
await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", definition: event, produces, startedAt: new Date(now).toISOString() });
return command;
}
export function parseGiftItems(input: string): Array<{ catalogCode: string; quantity: number }> {
if (!input.trim()) return [];
const items = input.split(",").map((part) => {
const [rawKey, rawQuantity, ...extra] = part.split(":").map((value) => value.trim());
const quantity = Number(rawQuantity);
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(rawKey) || !rawQuantity || extra.length || !Number.isInteger(quantity) || quantity < 1 || quantity > 100) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:数量,数量范围 1-100。");
return { catalogCode: rawKey, quantity };
});
if (items.length > 8) throw new Error("单个礼包最多包含 8 项物品。");
return items;
}
export function parseGiftCommands(input: string): Array<{ command: string }> {
return input.split(/\r?\n/).map((command) => command.trim()).filter(Boolean).map((command) => ({ command }));
}
export type SCUMMapBounds = { worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number };
export function resolveMapBounds(settings?: RecordMap): SCUMMapBounds {
const fallback = { worldMinX: -905000, worldMinY: -905000, worldMaxX: 619000, worldMaxY: 619000 };
if (!settings) return fallback;
if (Object.prototype.hasOwnProperty.call(settings, "customMapEnabled") && !booleanValue(settings.customMapEnabled)) return fallback;
const explicit = [settings.worldMinX, settings.worldMinY, settings.worldMaxX, settings.worldMaxY].map(Number);
if (explicit.every(Number.isFinite) && explicit[2] > explicit[0] && explicit[3] > explicit[1]) return { worldMinX: explicit[0], worldMinY: explicit[1], worldMaxX: explicit[2], worldMaxY: explicit[3] };
if (!booleanValue(settings.customMapEnabled)) return fallback;
const centerX = Number(settings.centerX ?? settings.mapX);
const centerY = Number(settings.centerY ?? settings.mapY);
const widthKm = Number(settings.widthKm ?? settings.mapWidth);
const heightKm = Number(settings.heightKm ?? settings.mapHeight);
if (![centerX, centerY, widthKm, heightKm].every(Number.isFinite) || widthKm <= 0 || heightKm <= 0) return fallback;
const halfWidth = widthKm * 100000 / 2;
const halfHeight = heightKm * 100000 / 2;
return { worldMinX: centerX - halfWidth, worldMinY: centerY - halfHeight, worldMaxX: centerX + halfWidth, worldMaxY: centerY + halfHeight };
}
export async function saveMapSettings(actions: SCUMWorkspaceActions, settings: RecordMap): Promise<unknown> {
const value = { ...settings, ...resolveMapBounds(settings), updatedAt: new Date().toISOString() };
return requirePluginData(actions).put(scumCollections.mapSettings, "current", value);
}
function canonicalPageKey(pageKey: string): PageKey {
if (pageKey === "activity") return "workflows";
return pageKey === "squads" || pageKey === "live-map" || pageKey === "gifts" || pageKey === "workflows" ? pageKey : "players";
}
function collectionRecords(response: unknown): RecordMap[] {
if (!isRecord(response) || !Array.isArray(response.items)) return [];
return response.items.flatMap((item) => {
if (!isRecord(item)) return [];
if (isRecord(item.value)) return [{ ...item.value, _recordKey: textValue(item.key) }];
return [item];
});
}
function latestSnapshotPayload(response: unknown): RecordMap | undefined {
if (!isRecord(response) || !Array.isArray(response.items)) return undefined;
const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left));
const latest = snapshots[0];
if (!latest) return undefined;
return isRecord(latest.payload) ? { ...latest.payload, observedAt: textValue(latest.observedAt) || textValue(latest.payload.observedAt) } : undefined;
}
function snapshotOrder(snapshot: RecordMap): number { const observed = Date.parse(textValue(snapshot.observedAt)); return Number.isNaN(observed) ? Number(snapshot.sequence) || 0 : observed; }
function playerIndex(players: RecordMap[]): Map<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => playerIdentities(player).forEach((identity) => result.set(identity, index))); return result; }
function findPlayer(players: RecordMap[], index: Map<string, number>, player: RecordMap): { index: number } | undefined { for (const identity of playerIdentities(player)) { const found = index.get(identity); if (found !== undefined) return { index: found }; } const name = firstText(player, "displayName", "playerName", "name").toLowerCase(); const found = players.findIndex((candidate) => firstText(candidate, "displayName", "playerName", "name").toLowerCase() === name); return found >= 0 && name ? { index: found } : undefined; }
function playerIdentities(player: RecordMap): string[] { return ["gamePlayerId", "playerId", "steamId", "userProfileId", "profileId", "id"].map((key) => textValue(player[key])).filter(Boolean); }
function onlineValue(player: RecordMap): boolean { const status = firstText(player, "status", "state").toLowerCase(); return booleanValue(player.online) || ["online", "active", "connected"].includes(status); }
function booleanValue(value: unknown): boolean { return value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true"; }
function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions {
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
return actions.pluginData;
}
function requiredKey(value: RecordMap, key: string, label: string): string {
const result = textValue(value[key]);
if (!result) throw new Error(`${label}不能为空。`);
return result;
}
function requiredRecordKey(value: RecordMap, label: string): string { const key = firstText(value, "_recordKey", "id", "produceId"); if (!key) throw new Error(`${label}编号不能为空。`); return key.includes(":") ? key : `${firstText(value, "eventId", "event")}:${key}`; }
function boundedInteger(value: unknown, min: number, max: number, fallback: number): number {
const number = Number(value);
return Number.isInteger(number) && number >= min && number <= max ? number : fallback;
}
function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> {
if (value === undefined || value === null) return [];
if (!Array.isArray(value) || value.length > 8) throw new Error("礼包物品最多包含 8 项。");
return value.map((item) => {
if (!isRecord(item)) throw new Error("礼包物品格式无效。");
const catalogCode = firstText(item, "catalogCode", "key");
const quantity = Number(item.quantity);
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isInteger(quantity) || quantity < 1 || quantity > 100) throw new Error("礼包物品不符合 SCUM 目录代码或数量约束。");
return { catalogCode, quantity };
});
}
function normalizeGiftOperations(value: unknown): string[] { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error("礼包命令格式无效。"); return value.map((item) => { const command = isRecord(item) ? firstText(item, "command", "value") : textValue(item); if (!command.trim()) throw new Error("礼包命令不能为空。"); return command.trim(); }); }
function normalizeEventProduces(produces: RecordMap[]): RecordMap[] {
return produces.map((produce) => ({
tradeGoodsId: firstText(produce, "tradeGoodsId"),
percent: boundedInteger(produce.percent, 0, 100, 100),
value: boundedInteger(produce.value, 1, 10000, 1),
r: boundedNumber(produce.r, 0, 2000000, 0),
x: boundedNumber(produce.x, -2000000, 2000000, 0),
y: boundedNumber(produce.y, -2000000, 2000000, 0),
z: boundedNumber(produce.z, -2000000, 2000000, 0)
}));
}
function boundedNumber(value: unknown, min: number, max: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min && number <= max ? number : fallback; }
function safeCommandId(value: string): string { return value.replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 96); }
function firstText(value: RecordMap, ...keys: string[]): string { for (const key of keys) { const result = textValue(value[key]); if (result) return result; } return ""; }
function requestKey(prefix: string, key: string): string { return `${prefix}:${key}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`; }
function textValue(value: unknown): string { return value === undefined || value === null ? "" : String(value); }
function isRecord(value: unknown): value is RecordMap { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
@@ -1,4 +1,32 @@
import {
deleteGiftDefinition,
deleteEventDefinition,
deleteEventProduce,
emptySCUMSurfaceData,
hasSCUMPageQueries,
loadSCUMSurface,
parseGiftItems,
parseGiftCommands,
queueGiftDelivery,
requestSCUMPageQueries,
resetGiftClaim,
resetPendingGift,
resolveMapBounds,
saveEventDefinition,
saveEventProduce,
saveGiftDefinition,
saveMapSettings,
startEvent,
type RecordMap,
type SCUMSurfaceData,
type SCUMWorkspaceActions
} from "./page-data.js";
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
type InputEvent = { target?: { value?: string; checked?: boolean } };
type GiftTab = "definitions" | "claims" | "deliveries" | "timed";
type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other";
const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href;
export type ReactLike = {
createElement: (...args: any[]) => any;
@@ -16,177 +44,449 @@ export type SCUMPageContext = {
workspaceActions?: SCUMWorkspaceActions;
};
type SCUMWorkspaceActions = {
pluginData?: { list: (collection: string, key?: string) => Promise<unknown>; put: (collection: string, key: string, value: RecordMap) => Promise<unknown> };
createSCUMOperation?: (request: unknown) => Promise<unknown>;
listSCUMWorkflows?: () => Promise<unknown>;
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
};
type RecordMap = Record<string, unknown>;
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string };
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[] };
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], operations: [], workflows: [], steps: [] };
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement;
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const [playerSearch, setPlayerSearch] = usePluginState(react, "");
const [playerStatus, setPlayerStatus] = usePluginState(react, "all");
const [squadSearch, setSquadSearch] = usePluginState(react, "");
const [selectedSquadId, setSelectedSquadId] = usePluginState(react, "");
const [activityStatus, setActivityStatus] = usePluginState(react, "all");
const [eventId, setEventId] = usePluginState(react, "");
const [eventName, setEventName] = usePluginState(react, "");
const [eventType, setEventType] = usePluginState(react, "range");
const [eventSchedule, setEventSchedule] = usePluginState(react, "");
const [eventClass, setEventClass] = usePluginState(react, "1");
const [eventPlacard, setEventPlacard] = usePluginState(react, "");
const [eventPercent, setEventPercent] = usePluginState(react, "100");
const [eventNpc, setEventNpc] = usePluginState(react, "0");
const [eventItem, setEventItem] = usePluginState(react, "0");
const [eventZombie, setEventZombie] = usePluginState(react, "0");
const [eventAnimal, setEventAnimal] = usePluginState(react, "0");
const [produceEventId, setProduceEventId] = usePluginState(react, "");
const [produceId, setProduceId] = usePluginState(react, "");
const [produceTradeGoodsId, setProduceTradeGoodsId] = usePluginState(react, "");
const [producePercent, setProducePercent] = usePluginState(react, "100");
const [produceValue, setProduceValue] = usePluginState(react, "1");
const [produceRadius, setProduceRadius] = usePluginState(react, "0");
const [produceX, setProduceX] = usePluginState(react, "0");
const [produceY, setProduceY] = usePluginState(react, "0");
const [produceZ, setProduceZ] = usePluginState(react, "0");
const [giftTab, setGiftTab] = usePluginState<GiftTab>(react, "definitions");
const [giftCode, setGiftCode] = usePluginState(react, "");
const [giftName, setGiftName] = usePluginState(react, "");
const [giftItems, setGiftItems] = usePluginState(react, "");
const [giftCommands, setGiftCommands] = usePluginState(react, "");
const [giftClass, setGiftClass] = usePluginState(react, "5");
const [giftAudience, setGiftAudience] = usePluginState(react, "all");
const [giftNumber, setGiftNumber] = usePluginState(react, "1");
const [giftAchievement, setGiftAchievement] = usePluginState(react, "0");
const [giftAchievementNumber, setGiftAchievementNumber] = usePluginState(react, "0");
const [deliveryGift, setDeliveryGift] = usePluginState(react, "");
const [deliveryPlayer, setDeliveryPlayer] = usePluginState(react, "");
const [mapSearch, setMapSearch] = usePluginState(react, "");
const [mapLayers, setMapLayers] = usePluginState<Record<MapLayer, boolean>>(react, { players: true, vehicles: true, flags: true, regions: true, other: true });
const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, "");
const [mapCustomEnabled, setMapCustomEnabled] = usePluginState<boolean | undefined>(react, undefined);
const [mapCenterX, setMapCenterX] = usePluginState(react, "");
const [mapCenterY, setMapCenterY] = usePluginState(react, "");
const [mapWidthKm, setMapWidthKm] = usePluginState(react, "");
const [mapHeightKm, setMapHeightKm] = usePluginState(react, "");
const pageKey = input.pageKey ?? "players";
const refresh = () => {
const actions = input.workspaceActions;
if (!input.serverInstanceId || !actions) {
setState({ status: "error", reason: "插件页面没有绑定服务器,无法读取 SCUM 投影。" });
if (!input.serverInstanceId || !input.workspaceActions?.pluginData) {
setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" });
return;
}
setState({ status: "loading" });
void Promise.all([
pluginCollection(actions, "scum_users"), pluginCollection(actions, "scum_squads"), pluginCollection(actions, "scum_squad_members"), pluginCollection(actions, "scum_vehicles"),
pluginCollection(actions, "scum_flags"), pluginCollection(actions, "scum_map_points"), pluginCollection(actions, "scum_operations"), pluginCollection(actions, "scum_workflows"), pluginCollection(actions, "scum_workflow_steps")
]).then(([players, squads, members, vehicles, flags, positions, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, operations, workflows, steps } }))
.catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 投影读取失败。" }));
void loadSCUMSurface(input.workspaceActions, pageKey)
.then((data) => setState({ status: "ready", data }))
.catch((error) => setState({ status: "error", reason: errorMessage(error, "SCUM 插件数据读取失败。") }));
};
const syncMachine = () => {
if (!input.workspaceActions) return;
runAction(setAction, "正在提交声明式 SQLite 查询…", async () => {
const results = await requestSCUMPageQueries(input.workspaceActions!, pageKey);
const failed = results.find((result) => !["ok", "queued"].includes(result.status));
if (failed) throw new Error(failed.error?.message || `机器查询状态:${failed.status}`);
return `已提交 ${results.length} 个声明式查询;结果写入集合后可重新读取。`;
});
};
if (react.useEffect) react.useEffect(() => { refresh(); return undefined; }, [input.serverInstanceId, pageKey, input.workspaceActions]);
const data = state.status === "ready" ? state.data : emptyData;
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
e("div", { className: "panel-header" },
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))),
e("div", { className: "console-row-actions" },
e("span", { className: "page-status" }, input.availability.available ? "投影/Companion 可用" : input.availability.reason ?? "等待 Run/Companion"),
e("button", { type: "button", className: "icon-command", onClick: refresh }, "刷新投影"),
workflowButton(e, input, setAction, refresh, pageWorkflow(pageKey))
e("span", { className: "page-status" }, input.availability.available ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion"),
e("button", { type: "button", className: "icon-command", onClick: refresh }, "重新读取"),
hasSCUMPageQueries(pageKey) ? e("button", { type: "button", className: "primary-command", disabled: !input.workspaceActions?.dispatch, onClick: syncMachine }, "同步 SCUM.db") : null
)
),
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影…") : null,
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取插件自有 SCUM 集合…") : null,
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh) : null
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, {
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule,
eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal,
produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ,
giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber,
deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapSearch, setMapSearch, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint,
mapCustomEnabled, setMapCustomEnabled, mapCenterX, setMapCenterX, mapCenterY, setMapCenterY, mapWidthKm, setMapWidthKm, mapHeightKm, setMapHeightKm,
setAction, refresh
}) : null
);
}
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
type ViewState = {
playerSearch: string; setPlayerSearch: StateSetter<string>; playerStatus: string; setPlayerStatus: StateSetter<string>;
squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>;
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>;
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
eventType: string; setEventType: StateSetter<string>; eventSchedule: string; setEventSchedule: StateSetter<string>;
eventClass: string; setEventClass: StateSetter<string>; eventPlacard: string; setEventPlacard: StateSetter<string>; eventPercent: string; setEventPercent: StateSetter<string>;
eventNpc: string; setEventNpc: StateSetter<string>; eventItem: string; setEventItem: StateSetter<string>; eventZombie: string; setEventZombie: StateSetter<string>; eventAnimal: string; setEventAnimal: StateSetter<string>;
produceEventId: string; setProduceEventId: StateSetter<string>; produceId: string; setProduceId: StateSetter<string>; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter<string>;
producePercent: string; setProducePercent: StateSetter<string>; produceValue: string; setProduceValue: StateSetter<string>; produceRadius: string; setProduceRadius: StateSetter<string>; produceX: string; setProduceX: StateSetter<string>; produceY: string; setProduceY: StateSetter<string>; produceZ: string; setProduceZ: StateSetter<string>;
giftCode: string; setGiftCode: StateSetter<string>; giftName: string; setGiftName: StateSetter<string>; giftItems: string; setGiftItems: StateSetter<string>; giftCommands: string; setGiftCommands: StateSetter<string>;
giftClass: string; setGiftClass: StateSetter<string>; giftAudience: string; setGiftAudience: StateSetter<string>; giftNumber: string; setGiftNumber: StateSetter<string>; giftAchievement: string; setGiftAchievement: StateSetter<string>; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter<string>;
deliveryGift: string; setDeliveryGift: StateSetter<string>; deliveryPlayer: string; setDeliveryPlayer: StateSetter<string>;
mapSearch: string; setMapSearch: StateSetter<string>; mapLayers: Record<MapLayer, boolean>; setMapLayers: StateSetter<Record<MapLayer, boolean>>;
selectedMapPoint: string; setSelectedMapPoint: StateSetter<string>; setAction: StateSetter<ActionState>; refresh: () => void;
mapCustomEnabled: boolean | undefined; setMapCustomEnabled: StateSetter<boolean | undefined>; mapCenterX: string; setMapCenterX: StateSetter<string>; mapCenterY: string; setMapCenterY: StateSetter<string>; mapWidthKm: string; setMapWidthKm: StateSetter<string>; mapHeightKm: string; setMapHeightKm: StateSetter<string>;
};
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
switch (pageKey) {
case "players": return playersSurface(e, data, input, setAction, refresh);
case "squads": return squadsSurface(e, data);
case "live-map": return mapSurface(e, data);
case "gifts": return giftsSurface(e, data, input, setAction, refresh);
case "workflows": return workflowsSurface(e, data);
default: return playersSurface(e, data, input, setAction, refresh);
case "players": return playersSurface(e, data, view);
case "squads": return squadsSurface(e, data, view);
case "live-map": return mapSurface(e, data, input, view);
case "gifts": return giftsSurface(e, data, input, view);
case "workflows":
case "activity": return activitiesSurface(e, data, input, view);
default: return playersSurface(e, data, view);
}
}
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) {
const search = view.playerSearch.trim().toLowerCase();
const players = data.players.filter((player) => matchesText(player, search, "displayName", "playerName", "gamePlayerId", "playerId", "steamId", "squadName") && (view.playerStatus === "all" || (view.playerStatus === "online") === playerOnline(player)));
return e("div", { className: "console-record-list" },
statsStrip(e, [["玩家投影", data.players.length], ["在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标", data.positions.length], ["待审操作", data.operations.filter((op) => field(op, "Status", "status") === "waiting").length]]),
data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")),
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, freshness(player))),
e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`),
e("div", { className: "console-row-actions" },
operationButton(e, input, setAction, refresh, player, "player.fame.set", "fame", "Fame +100", 100),
operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000),
operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true)
)
)) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
statsStrip(e, [["用户", data.players.length], ["在线", data.players.filter(playerOnline).length], ["筛选结果", players.length], ["队伍成员", data.members.length]]),
e("div", { className: "console-row-actions" },
e("input", { value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) }),
e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知"))
),
players.length ? players.slice(0, 120).map((player, index) => e("article", { key: idOf(player, `player-${index}`), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户"), e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知")),
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "steamId", "providerId") || "unknown"}`), e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "squadName", "squadId") || "未加入"}`), e("span", null, freshness(player))),
e("span", { className: "provider-id" }, `Fame ${numField(player, "famePoints")} · Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")} · ${coords(positionOf(player))}`)
)) : e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。")
);
}
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) {
const search = view.squadSearch.trim().toLowerCase();
const squads = data.squads.filter((squad) => matchesText(squad, search, "name", "squadId", "leaderProfileId"));
const activeId = view.selectedSquadId || textField(squads[0], "squadId", "id");
const roster = data.members.filter((member) => textField(member, "squadId") === activeId);
return e("div", { className: "overview-two-col" },
tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, freshness(squad)]),
tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", freshness(item)])
e("article", { className: "console-module" },
e("div", { className: "panel-header" }, e("h2", null, "队伍"), e("span", { className: "page-status" }, `${squads.length}`)),
e("input", { value: view.squadSearch, "aria-label": "搜索队伍", placeholder: "队名 / 队长 / 队伍 ID", onChange: (event: InputEvent) => view.setSquadSearch(inputValue(event)) }),
e("div", { className: "console-row-list" }, squads.length ? squads.map((squad, index) => {
const squadId = textField(squad, "squadId", "id");
const memberCount = data.members.filter((member) => textField(member, "squadId") === squadId).length;
return e("button", { key: idOf(squad, `squad-${index}`), type: "button", className: "console-row", onClick: () => view.setSelectedSquadId(squadId) },
e("span", null, textField(squad, "name") || squadId || "未命名队伍"),
e("strong", null, `成员 ${memberCount || numField(squad, "memberCount")} / ${numField(squad, "memberLimit", "member_limit")}`),
e("strong", null, `队长 ${textField(squad, "leaderName", "leaderProfileId") || "unknown"}`),
e("strong", null, `分数 ${numField(squad, "score")}`),
e("strong", null, textField(squad, "message", "info") || "无队伍公告"));
}) : e("p", { className: "page-status" }, "没有符合筛选条件的真实队伍记录。"))
),
e("div", { className: "console-record-list" },
tablePanel(e, "队伍成员", roster, (member) => [textField(member, "displayName", "gamePlayerId") || "unknown", textField(member, "rank") || "member", `Score ${numField(member, "score")}`, dateField(member, "lastLoginAt", "lastMemberLogin", "lastSeenAt")]),
tablePanel(e, "领地旗帜", data.flags.filter((flag) => !activeId || textField(flag, "ownerSquadId", "squadId") === activeId), (flag) => [textField(flag, "name", "flagId") || "flag", textField(flag, "ownershipConfidence") || "unknown", coords(positionOf(flag)), freshness(flag)])
)
);
}
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
const overlays = [...data.positions, ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(Boolean), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(Boolean)];
function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const actions = input.workspaceActions;
const runsByEvent = new Map<string, RecordMap>();
for (const run of data.eventRuns) runsByEvent.set(textField(run, "eventId", "activityId"), run);
const events = data.events.filter((event) => {
const run = runsByEvent.get(textField(event, "id", "eventId"));
const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown";
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 saveEvent = () => runAction(view.setAction, "正在保存活动定义…", async () => {
const id = view.eventId.trim();
const name = view.eventName.trim();
if (!id || !name) throw new Error("活动编号和名称不能为空。");
await saveEventDefinition(actions ?? {}, {
id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(),
placard: view.eventPlacard.trim(), announcement: view.eventPlacard.trim(), percent: integerInput(view.eventPercent, 100), probability: integerInput(view.eventPercent, 100),
npc: integerInput(view.eventNpc, 0), item: integerInput(view.eventItem, 0), zombie: integerInput(view.eventZombie, 0), animal: integerInput(view.eventAnimal, 0),
status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: 1800, updatedAt: new Date().toISOString()
});
view.refresh();
return `活动 ${name} 已保存。`;
});
const saveProduce = () => runAction(view.setAction, "正在保存活动生成项…", async () => {
const eventId = view.produceEventId.trim() || view.eventId.trim() || textField(data.events[0], "id", "eventId");
const tradeGoodsId = view.produceTradeGoodsId.trim();
if (!eventId || !tradeGoodsId) throw new Error("活动编号和物品编号不能为空。");
await saveEventProduce(actions ?? {}, {
id: view.produceId.trim(), eventId, tradeGoodsId, percent: integerInput(view.producePercent, 100), value: integerInput(view.produceValue, 1),
r: numberInput(view.produceRadius, 0), x: numberInput(view.produceX, 0), y: numberInput(view.produceY, 0), z: numberInput(view.produceZ, 0), updatedAt: new Date().toISOString()
});
view.refresh();
return "活动生成项已保存。";
});
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.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]),
e("div", { className: "map-projection-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-projection-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))),
tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["原生赛事轮次", data.nativeEventRounds.length], ["任务", data.tasks.length]]),
e("div", { className: "overview-two-col" },
e("article", { className: "console-module" }, e("h2", null, "新建或更新活动"),
e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "活动编号", onChange: (event: InputEvent) => view.setEventId(inputValue(event)) }),
e("input", { value: view.eventName, "aria-label": "活动名称", placeholder: "活动名称", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) }),
e("select", { value: view.eventClass, "aria-label": "生成类型", onChange: (event: InputEvent) => view.setEventClass(inputValue(event)) }, e("option", { value: "1" }, "范围生成"), e("option", { value: "2" }, "固定坐标生成")),
e("input", { value: view.eventSchedule, "aria-label": "活动计划", placeholder: "Cron", onChange: (event: InputEvent) => view.setEventSchedule(inputValue(event)) }),
e("input", { value: view.eventPlacard, "aria-label": "活动公告", placeholder: "活动开始公告", onChange: (event: InputEvent) => view.setEventPlacard(inputValue(event)) }),
e("input", { value: view.eventPercent, "aria-label": "活动概率", type: "number", placeholder: "触发概率 %", onChange: (event: InputEvent) => view.setEventPercent(inputValue(event)) }),
e("div", { className: "console-row-actions" },
e("input", { value: view.eventNpc, "aria-label": "NPC 数量", type: "number", placeholder: "NPC", onChange: (event: InputEvent) => view.setEventNpc(inputValue(event)) }),
e("input", { value: view.eventItem, "aria-label": "物品数量", type: "number", placeholder: "物品", onChange: (event: InputEvent) => view.setEventItem(inputValue(event)) }),
e("input", { value: view.eventZombie, "aria-label": "僵尸数量", type: "number", placeholder: "僵尸", onChange: (event: InputEvent) => view.setEventZombie(inputValue(event)) }),
e("input", { value: view.eventAnimal, "aria-label": "动物数量", type: "number", placeholder: "动物", onChange: (event: InputEvent) => view.setEventAnimal(inputValue(event)) })),
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveEvent }, "保存活动")
),
e("article", { className: "console-module" }, e("h2", null, "状态筛选"),
e("select", { value: view.activityStatus, "aria-label": "活动状态", onChange: (event: InputEvent) => view.setActivityStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), statuses.map((status) => e("option", { key: status, value: status }, status))),
e("p", { className: "page-status" }, "活动定义属于插件;SCUM 原生 event_round 和 quest/task 只作为运行事实展示。")
)
),
e("article", { className: "console-module" }, e("h2", null, "活动生成项"),
e("div", { className: "console-row-actions" },
e("input", { value: view.produceEventId, "aria-label": "生成项活动编号", placeholder: "活动编号", onChange: (event: InputEvent) => view.setProduceEventId(inputValue(event)) }),
e("input", { value: view.produceId, "aria-label": "生成项编号", placeholder: "生成项编号(更新时填写)", onChange: (event: InputEvent) => view.setProduceId(inputValue(event)) }),
e("input", { value: view.produceTradeGoodsId, "aria-label": "生成物品编号", placeholder: "物品 / TradeGoods ID", onChange: (event: InputEvent) => view.setProduceTradeGoodsId(inputValue(event)) }),
e("input", { value: view.producePercent, "aria-label": "生成概率", type: "number", placeholder: "概率 %", onChange: (event: InputEvent) => view.setProducePercent(inputValue(event)) }),
e("input", { value: view.produceValue, "aria-label": "生成数量", type: "number", placeholder: "数量", onChange: (event: InputEvent) => view.setProduceValue(inputValue(event)) })),
e("div", { className: "console-row-actions" },
e("input", { value: view.produceRadius, "aria-label": "生成半径", type: "number", placeholder: "半径", onChange: (event: InputEvent) => view.setProduceRadius(inputValue(event)) }),
e("input", { value: view.produceX, "aria-label": "生成 X", type: "number", placeholder: "X", onChange: (event: InputEvent) => view.setProduceX(inputValue(event)) }),
e("input", { value: view.produceY, "aria-label": "生成 Y", type: "number", placeholder: "Y", onChange: (event: InputEvent) => view.setProduceY(inputValue(event)) }),
e("input", { value: view.produceZ, "aria-label": "生成 Z", type: "number", placeholder: "Z", 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("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.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")); } }, "编辑"),
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除生成项…", async () => { await deleteEventProduce(actions ?? {}, produce); view.refresh(); return "生成项已删除。"; }) }, "删除"))) : e("p", { className: "page-status" }, "暂无活动生成项。"))
),
events.length ? events.map((event, index) => {
const eventId = textField(event, "id", "eventId");
const run = runsByEvent.get(eventId);
const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown";
return e("article", { key: idOf(event, `event-${index}`), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(event, "name", "title") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status)),
e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")),
e("div", { className: "console-row-actions" },
e("button", { type: "button", className: "primary-command", disabled: !actions?.gameClient || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"),
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除")
)
);
}) : e("p", { className: "page-status" }, "没有符合状态筛选的真实活动。"),
e("div", { className: "overview-two-col" },
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"])
),
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")])
);
}
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const actions = input.workspaceActions;
const saveGift = () => runAction(view.setAction, "正在保存礼包定义…", async () => {
const code = view.giftCode.trim();
const name = view.giftName.trim();
if (!code || !name) throw new Error("礼包编号和名称不能为空。");
const items = parseGiftItems(view.giftItems);
const commands = parseGiftCommands(view.giftCommands);
if (!items.length && !commands.length) throw new Error("礼包至少需要一项物品或命令。");
await saveGiftDefinition(actions ?? {}, {
code, name, class: integerInput(view.giftClass, 5), audience: view.giftAudience, number: integerInput(view.giftNumber, 1),
achievement: integerInput(view.giftAchievement, 0), achievementNumber: integerInput(view.giftAchievementNumber, 0), items, commands,
status: "active", updatedAt: new Date().toISOString()
});
view.refresh();
return `礼包 ${name} 已保存。`;
});
const queueDelivery = () => runAction(view.setAction, "正在创建发放记录…", async () => {
const giftCode = view.deliveryGift || textField(data.gifts[0], "code", "id");
const playerId = view.deliveryPlayer || textField(data.players[0], "gamePlayerId", "steamId", "id");
const gift = data.gifts.find((item) => textField(item, "code", "id") === giftCode);
const player = data.players.find((item) => textField(item, "gamePlayerId", "steamId", "id") === playerId);
if (!gift || !player) throw new Error("请选择礼包和用户。");
await queueGiftDelivery(actions ?? {}, gift, player);
view.refresh();
return "礼包发放命令已进入执行队列。";
});
return e("div", { className: "console-record-list" },
statsStrip(e, [["可选玩家", data.players.length], ["发放操作", data.operations.filter((op) => textField(op, "TemplateKey", "templateKey") === "reward.deliver").length], ["未知态", data.operations.filter((op) => field(op, "Status", "status") === "unknown").length]]),
e("p", { className: "page-status" }, "礼包只创建 typed delivery workflow;确认结果未知时不会重复发放。"),
data.players.slice(0, 40).map((player) => e("article", { key: idOf(player), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || idOf(player)), e("span", { className: "status-pill status-disabled" }, freshness(player))),
e("div", { className: "console-row-actions" }, operationButton(e, input, setAction, refresh, player, "reward.deliver", "rewardKey", "创建礼包发放", "starter-pack"), operationButton(e, input, setAction, refresh, player, "player.notify", "message", "发送通知", "你的礼包正在审核发放。"))
))
statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["原生定时记录", data.timedGiftEvents.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", "游戏定时记录")
),
view.giftTab === "definitions" ? e("div", { className: "overview-two-col" },
e("article", { className: "console-module" }, e("h2", null, "新建或更新礼包"),
e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "礼包编号", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) }),
e("input", { value: view.giftName, "aria-label": "礼包名称", placeholder: "礼包名称", onChange: (event: InputEvent) => view.setGiftName(inputValue(event)) }),
e("select", { value: view.giftClass, "aria-label": "礼包周期", onChange: (event: InputEvent) => view.setGiftClass(inputValue(event)) }, [["1", "每日"], ["2", "每周"], ["3", "每月"], ["4", "每年"], ["5", "一次"], ["6", "每日五次"]].map(([value, label]) => e("option", { key: value, value }, label))),
e("select", { value: view.giftAudience, "aria-label": "适用玩家", onChange: (event: InputEvent) => view.setGiftAudience(inputValue(event)) }, e("option", { value: "all" }, "全部玩家"), e("option", { value: "pve" }, "PVE 玩家"), e("option", { value: "pvp" }, "PVP 玩家")),
e("input", { value: view.giftNumber, "aria-label": "发放次数", type: "number", placeholder: "发放次数", onChange: (event: InputEvent) => view.setGiftNumber(inputValue(event)) }),
e("div", { className: "console-row-actions" }, e("input", { value: view.giftAchievement, "aria-label": "成就类型", type: "number", placeholder: "成就类型", onChange: (event: InputEvent) => view.setGiftAchievement(inputValue(event)) }), e("input", { value: view.giftAchievementNumber, "aria-label": "成就值", type: "number", placeholder: "成就值", onChange: (event: InputEvent) => view.setGiftAchievementNumber(inputValue(event)) })),
e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "SCUM目录代码:数量, SCUM目录代码:数量", onChange: (event: InputEvent) => view.setGiftItems(inputValue(event)) }),
e("textarea", { value: view.giftCommands, "aria-label": "礼包命令", placeholder: "每行一条命令", onChange: (event: InputEvent) => view.setGiftCommands(inputValue(event)) }),
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包")
),
e("div", { className: "console-record-list" }, data.gifts.length ? data.gifts.map((gift, index) => {
const key = textField(gift, "_recordKey", "code", "id");
return e("article", { key: idOf(gift, `gift-${index}`), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(gift, "name") || key), e("span", { className: "status-pill status-active" }, textField(gift, "status") || "active")),
e("div", { className: "console-record-meta" }, e("span", null, `周期 ${giftClassLabel(numField(gift, "class"))}`), e("span", null, `适用 ${textField(gift, "audience") || "all"}`), e("span", null, `次数 ${numField(gift, "number")}`), e("span", null, `成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)),
e("span", { className: "provider-id" }, giftItemsSummary(gift)),
e("span", { className: "provider-id" }, giftCommandsSummary(gift)),
e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => view.setDeliveryGift(key) }, "选择发放"), e("button", { type: "button", className: "icon-command", onClick: () => { view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除"))
);
}) : e("p", { className: "page-status" }, "暂无礼包定义。"))
) : 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)
) : null,
view.giftTab === "deliveries" ? e("div", { className: "console-record-list" },
e("article", { className: "console-module" }, e("h2", null, "创建发放记录"),
e("select", { value: view.deliveryGift, "aria-label": "选择礼包", onChange: (event: InputEvent) => view.setDeliveryGift(inputValue(event)) }, e("option", { value: "" }, "选择礼包"), data.gifts.map((gift, index) => { const key = textField(gift, "code", "id"); return e("option", { key: idOf(gift, `gift-option-${index}`), value: key }, textField(gift, "name") || key); })),
e("select", { value: view.deliveryPlayer, "aria-label": "选择用户", onChange: (event: InputEvent) => view.setDeliveryPlayer(inputValue(event)) }, e("option", { value: "" }, "选择用户"), data.players.map((player, index) => { const key = textField(player, "gamePlayerId", "steamId", "id"); return e("option", { key: idOf(player, `player-option-${index}`), value: key }, textField(player, "displayName") || key); })),
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData || !actions?.gameClient, onClick: queueDelivery }, "立即发放")
),
tablePanel(e, "发放记录", data.giftDeliveries, (delivery) => [textField(delivery, "playerName", "playerId") || "unknown", textField(delivery, "giftName", "giftCode") || "unknown", textField(delivery, "status") || "unknown", dateField(delivery, "deliveredAt", "createdAt")])
) : null,
view.giftTab === "timed" ? e("div", { className: "console-record-list" },
e("p", { className: "page-status" }, "这里是 SCUM 原生 finished_timed_gift_spawner 完成记录,不是插件运营礼包定义。"),
tablePanel(e, "游戏原生定时礼包", data.timedGiftEvents, (event) => [textField(event, "userProfileId") || "unknown", textField(event, "mapId") || "unknown", textField(event, "spawnTime") || "unknown", dateField(event, "spawnAt")])
) : null
);
}
function workflowsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const actions = input.workspaceActions;
const points = collectMapPoints(data);
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"));
const centerX = view.mapCenterX || textField(settings, "centerX", "mapX") || String((bounds.worldMinX + bounds.worldMaxX) / 2);
const centerY = view.mapCenterY || textField(settings, "centerY", "mapY") || String((bounds.worldMinY + bounds.worldMaxY) / 2);
const widthKm = view.mapWidthKm || textField(settings, "widthKm", "mapWidth") || String((bounds.worldMaxX - bounds.worldMinX) / 100000);
const heightKm = view.mapHeightKm || textField(settings, "heightKm", "mapHeight") || String((bounds.worldMaxY - bounds.worldMinY) / 100000);
const search = view.mapSearch.trim().toLowerCase();
const visible = points.filter((point) => view.mapLayers[layerOf(point)] && matchesText(point, search, "name", "label", "subjectId", "subjectType", "layer"));
const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0];
return e("div", { className: "console-record-list" },
data.workflows.length ? data.workflows.map((wf) => e("article", { key: idOf(wf), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(wf, "TemplateKey", "templateKey") || idOf(wf)), e("span", { className: "status-pill status-active" }, textField(wf, "Status", "status") || "queued")),
e("div", { className: "console-record-meta" }, e("span", null, `当前步骤 ${textField(wf, "CurrentStepKey", "currentStepKey") || "等待调度"}`), e("span", null, `创建 ${dateField(wf, "CreatedAt", "createdAt")}`)),
e("span", { className: "provider-id" }, summaryText(wf))
)) : e("p", { className: "page-status" }, "暂无 workflow。可以从各页面发起 refresh/audit/correction/gift workflow。"),
tablePanel(e, "步骤", data.steps, (step) => [textField(step, "StepKey", "stepKey"), textField(step, "Status", "status"), textField(step, "Capability", "capability") || textField(step, "QueryTemplateKey", "queryTemplateKey") || textField(step, "OperationKey", "operationKey"), summaryText(step)])
statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["旗帜/区域", data.flags.length + data.mapRegions.length]]),
e("div", { className: "console-row-actions" },
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 }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer)))
),
e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图范围"), e("span", { className: "page-status" }, customEnabled ? "自定义范围" : "SCUM 默认范围")),
e("div", { className: "console-row-actions" },
e("label", null, e("input", { type: "checkbox", checked: customEnabled, "aria-label": "启用自定义地图", onChange: (event: InputEvent) => view.setMapCustomEnabled(Boolean(event.target?.checked)) }), "启用自定义地图"),
e("input", { value: centerX, "aria-label": "地图中心 X", type: "number", onChange: (event: InputEvent) => view.setMapCenterX(inputValue(event)) }),
e("input", { value: centerY, "aria-label": "地图中心 Y", type: "number", onChange: (event: InputEvent) => view.setMapCenterY(inputValue(event)) }),
e("input", { value: widthKm, "aria-label": "地图宽度公里", type: "number", onChange: (event: InputEvent) => view.setMapWidthKm(inputValue(event)) }),
e("input", { value: heightKm, "aria-label": "地图高度公里", type: "number", onChange: (event: InputEvent) => view.setMapHeightKm(inputValue(event)) }),
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})` } }, visible.map((point, index) => e("button", { key: idOf(point, `point-${index}`), type: "button", className: "map-projection-dot", title: `${pointTitle(point)} ${coords(point)}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, ""))),
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)))) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。"))
)
);
}
function workflowButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, templateKey: string) {
if (!templateKey) return null;
return e("button", { type: "button", className: "primary-command", disabled: !input.workspaceActions?.createSCUMWorkflow, onClick: () => createWorkflow(input, setAction, refresh, templateKey) }, workflowLabel(templateKey));
}
function operationButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, label: string, value: unknown, guarded = false) {
return e("button", { type: "button", className: "icon-command", disabled: !input.workspaceActions?.createSCUMOperation, onClick: () => createOperation(input, setAction, refresh, player, templateKey, valueKey, value, guarded) }, label);
}
function createWorkflow(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, templateKey: string) {
setAction({ status: "pending", message: `正在创建 ${templateKey} workflow…` });
void input.workspaceActions?.createSCUMWorkflow?.({ templateKey, idempotencyKey: `plugin:${templateKey}:${input.serverInstanceId}:${Date.now()}` }).then((result) => {
setAction({ status: "ok", message: `Workflow 已创建:${textField(result as RecordMap, "id") || templateKey}` }); refresh();
}).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "Workflow 创建失败。" }));
}
function createOperation(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, value: unknown, guarded: boolean) {
const playerId = textField(player, "GamePlayerID", "gamePlayerId") || textField(player, "SteamID", "steamId");
const before = guarded ? field(field(player, "UnknownFields", "unknownFields") as RecordMap | undefined, "855") ?? 0 : undefined;
const payload: RecordMap = guarded ? { fieldKey: "855", before, after: value, safetyWindow: `plugin-maintenance-${Date.now()}`, backupRef: `backup-required:${Date.now()}` } : { [valueKey]: value };
setAction({ status: "pending", message: `正在创建 ${templateKey} typed operation…` });
void input.workspaceActions?.createSCUMOperation?.({ templateKey, playerId, payload, reason: "SCUM plugin projection surface request", idempotencyKey: `plugin:${templateKey}:${playerId}:${Date.now()}` }).then((result) => {
setAction({ status: "ok", message: `操作已进入审批/确认队列:${textField(result as RecordMap, "id") || templateKey}` }); refresh();
}).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "操作创建失败。" }));
}
function pageWorkflow(pageKey: string): string {
switch (pageKey) {
case "players": return "scum.player-refresh";
case "squads": return "scum.territory-audit";
case "live-map": return "scum.world-refresh";
case "gifts": return "scum.gift-delivery";
case "workflows": return "scum.product-cleanup";
default: return "scum.bootstrap-real-data";
}
}
function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍/旗帜管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : pageKey === "workflows" ? "Workflow 状态" : "用户管理"; }
function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "玩家、载具、旗帜坐标来自平台本地投影;缺失时显示 stale/unknown。" : pageKey === "gifts" ? "礼包发放、通知和确认都通过 typed workflow,不直接改投影。" : pageKey === "squads" ? "队伍、成员、旗帜所有权来自 SCUM.db typed observations。" : "玩家列表由登录日志和 SCUM.db typed observations 创建,不显示样例数据。"; }
function workflowLabel(templateKey: string): string { return templateKey.includes("audit") ? "发起审计" : templateKey.includes("gift") ? "创建发放 workflow" : templateKey.includes("world") ? "刷新世界投影" : templateKey.includes("cleanup") ? "清理旧入口" : "刷新真实数据"; }
function giftTabButton(e: ReactLike["createElement"], view: ViewState, tab: GiftTab, label: string) { return e("button", { type: "button", role: "tab", "aria-selected": view.giftTab === tab, className: view.giftTab === tab ? "primary-command" : "icon-command", onClick: () => view.setGiftTab(tab) }, label); }
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.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实投影数据。"))); }
function dotStyle(point: RecordMap): Record<string, string> { const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; }
function pluginCollection(actions: SCUMWorkspaceActions, collection: string): Promise<RecordMap[]> { return actions.pluginData?.list(collection).then((value) => Array.isArray((value as RecordMap)?.items) ? ((value as { items: Array<{ value: RecordMap }> }).items.map((item) => item.value)) : []) ?? Promise.resolve([]); }
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" }, "暂无真实记录。"))); }
export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] {
const direct = data.mapPoints.map((point) => ({ ...point, layer: textField(point, "layer", "subjectType", "type") || "other" }));
const players = data.players.flatMap((player) => withPosition(player, "players", textField(player, "displayName"), textField(player, "gamePlayerId", "steamId", "id")));
const vehicles = data.vehicles.flatMap((vehicle) => withPosition(vehicle, "vehicles", textField(vehicle, "label", "name"), textField(vehicle, "vehicleId", "id")));
const flags = data.flags.flatMap((flag) => withPosition(flag, "flags", textField(flag, "name"), textField(flag, "flagId", "id")));
const regions = data.mapRegions.flatMap((region) => withPosition(region, "regions", textField(region, "name"), textField(region, "id", "regionId")));
const uniquePoints = new Map<string, RecordMap>();
for (const point of [...direct, ...players, ...vehicles, ...flags, ...regions].filter(hasCoordinates)) {
const key = mapPointIdentity(point);
if (!uniquePoints.has(key)) uniquePoints.set(key, point);
}
return [...uniquePoints.values()];
}
function withPosition(row: RecordMap, layer: MapLayer, name: string, subjectId: string): RecordMap[] { const position = positionOf(row); return hasCoordinates(position) ? [{ ...position, layer, name, subjectId, _recordKey: `${layer}:${subjectId}`, source: textField(row, "source") }] : []; }
function positionOf(row: RecordMap | undefined): RecordMap | undefined { const nested = field(row, "position", "location"); return isRecord(nested) ? nested : row; }
function hasCoordinates(row: RecordMap | undefined): row is RecordMap { return Boolean(row) && Number.isFinite(Number(field(row, "x", "locationX"))) && Number.isFinite(Number(field(row, "y", "locationY"))); }
function layerOf(point: RecordMap): MapLayer { const value = textField(point, "layer", "subjectType", "type").toLowerCase(); if (value.includes("player") || value.includes("user")) return "players"; if (value.includes("vehicle")) return "vehicles"; if (value.includes("flag")) return "flags"; if (value.includes("region") || value.includes("zone") || value === "base") return "regions"; return "other"; }
function layerLabel(layer: MapLayer): string { return layer === "players" ? "用户" : layer === "vehicles" ? "载具" : layer === "flags" ? "旗帜" : layer === "regions" ? "区域" : "其他"; }
function pointTitle(point: RecordMap): string { return textField(point, "name", "label", "subjectName") || textField(point, "subjectType", "type") || textField(point, "subjectId", "id") || "地图点"; }
function mapPointIdentity(point: RecordMap): string { const subject = textField(point, "subjectId", "gamePlayerId", "vehicleId", "flagId", "regionId"); if (subject) return `${layerOf(point)}:${subject}`; const record = textField(point, "id", "_recordKey"); if (record) return `${layerOf(point)}:${record}`; return `${layerOf(point)}:${pointTitle(point).toLowerCase()}:${numField(point, "x", "locationX")}:${numField(point, "y", "locationY")}`; }
export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record<string, string> {
const x = Number(field(point, "x", "locationX") ?? 0); const y = Number(field(point, "y", "locationY") ?? 0);
const minX = Number(field(bounds, "worldMinX")); const minY = Number(field(bounds, "worldMinY")); const maxX = Number(field(bounds, "worldMaxX")); const maxY = Number(field(bounds, "worldMaxY"));
const left = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? 100 - (x - minX) / (maxX - minX) * 100 : 50;
const top = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? 100 - (y - minY) / (maxY - minY) * 100 : 50;
return { left: `${Math.max(1, Math.min(99, left))}%`, top: `${Math.max(1, Math.min(99, top))}%` };
}
function runAction(setAction: StateSetter<ActionState>, pending: string, task: () => Promise<string>) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); }
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; }
function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(value); }
function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === "true"; }
function inputValue(event: InputEvent): string { return event.target?.value ?? ""; }
function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) { if (row[key] !== undefined) return row[key]; const normalized = normalizeKey(key); const found = Object.keys(row).find((candidate) => normalizeKey(candidate) === normalized); if (found && row[found] !== undefined) return row[found]; } return undefined; }
function normalizeKey(value: string): string { return value.replace(/[_-]/g, "").toLowerCase(); }
function textField(row: RecordMap | undefined, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null ? "" : String(value); }
function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === 1 || value === "1" || value === "true"; }
function playerOnline(player: RecordMap): boolean { const status = textField(player, "status").toLowerCase(); return boolField(player, "online") || ["online", "active", "connected"].includes(status); }
function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); }
function idOf(row: RecordMap): string { return textField(row, "ID", "id", "GamePlayerID", "gamePlayerId", "SquadID", "squadId", "VehicleID", "vehicleId", "FlagID", "flagId", "StepKey", "stepKey") || Math.random().toString(36).slice(2); }
function freshness(row: RecordMap): string { const fresh = field(row, "Freshness", "freshness") as RecordMap | undefined; return textField(fresh, "Status", "status") || "unknown"; }
function coords(row?: RecordMap): string { if (!row) return "坐标 unknown"; const ok = field(row, "HasCoordinates", "hasCoordinates"); return ok === false ? "坐标 unknown" : `X ${numField(row, "X", "x")} / Y ${numField(row, "Y", "y")} / Z ${numField(row, "Z", "z")}`; }
function summaryText(row: RecordMap): string { const summary = field(row, "SafeSummary", "safeSummary") as RecordMap | undefined; return textField(summary, "Message", "message") || textField(row, "BlockerReason", "blockerReason") || "safe summary pending"; }
function dateField(row: RecordMap, ...keys: string[]): string { const value = textField(row, ...keys); return value ? new Date(value).toLocaleString() : "unknown"; }
function idOf(row: RecordMap | undefined, fallback: string): string { return textField(row, "_recordKey", "id", "gamePlayerId", "squadId", "vehicleId", "flagId", "eventId", "code") || fallback; }
function freshness(row: RecordMap): string { const fresh = field(row, "freshness"); return isRecord(fresh) ? textField(fresh, "status") || "unknown" : textField(row, "freshnessStatus", "updatedAt") || "unknown"; }
function coords(row?: RecordMap): string { if (!row || !hasCoordinates(row)) return "坐标 unknown"; return `X ${numField(row, "x", "locationX")} / Y ${numField(row, "y", "locationY")} / Z ${numField(row, "z", "locationZ")}`; }
function dateField(row: RecordMap | undefined, ...keys: string[]): string { const value = textField(row, ...keys); if (!value) return "unknown"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }
function matchesText(row: RecordMap, search: string, ...keys: string[]): boolean { return !search || keys.some((key) => textField(row, key).toLowerCase().includes(search)); }
function unique(values: string[]): string[] { return [...new Set(values)]; }
function activeStatus(status: string): boolean { return ["active", "running", "scheduled", "enabled", "queued"].includes(status.toLowerCase()); }
function giftItemsSummary(gift: RecordMap): string { const items = field(gift, "items"); if (!Array.isArray(items)) return "物品清单未记录"; return items.map((item) => isRecord(item) ? `${textField(item, "label", "catalogCode", "catalogItemKey", "className", "key") || "item"} × ${numField(item, "quantity")}` : String(item)).join(" · "); }
function giftCommandsSummary(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) && commands.length ? `${commands.length} 条命令` : "无命令"; }
function giftItemsInput(gift: RecordMap): string { const items = field(gift, "items"); return Array.isArray(items) ? items.map((item) => isRecord(item) ? `${textField(item, "catalogCode", "catalogItemKey", "key")}:${numField(item, "quantity")}` : "").filter(Boolean).join(", ") : ""; }
function giftCommandsInput(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) ? commands.map((item) => isRecord(item) ? textField(item, "command", "value") : String(item)).filter(Boolean).join("\n") : ""; }
function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record<string, string>)[value] ?? value; }
function integerInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : fallback; }
function numberInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
function isRecord(value: unknown): value is RecordMap { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
function errorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; }
function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : pageKey === "workflows" || pageKey === "activity" ? "活动管理" : "用户管理"; }
function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "地图点、用户、载具、旗帜与区域由 SCUM 插件集合提供。" : pageKey === "gifts" ? "礼包定义、领取和发放记录由 SCUM 插件自有集合管理。" : pageKey === "squads" ? "队伍与成员关系由插件集合管理,可按队伍查看 roster。" : pageKey === "workflows" || pageKey === "activity" ? "活动定义、运行状态和事件记录由插件集合管理。" : "用户列表来自插件声明的 SCUM.db 查询与日志同步,不显示样例数据。"; }
+189 -47
View File
@@ -3,7 +3,7 @@
"id": "game.scum",
"name": "SCUM Server",
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and companion bridge support.",
"version": "0.1.6",
"version": "0.1.7",
"kind": "game-plugin",
"tags": [
"scum",
@@ -127,7 +127,7 @@
"type": "announcement.send",
"title": "Send SCUM announcement",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json",
"resultSchemaRef": "schemas/bridge/announcement.result.schema.json",
"timeoutSeconds": 60,
@@ -157,7 +157,7 @@
"type": "reward.deliver",
"title": "Deliver SCUM reward",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json",
"resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json",
"timeoutSeconds": 60,
@@ -167,7 +167,7 @@
"type": "player.notify",
"title": "Notify SCUM player about approved gift",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-notify.result.schema.json",
"timeoutSeconds": 60,
@@ -177,7 +177,7 @@
"type": "vehicle.spawn",
"title": "Spawn catalogued SCUM vehicle",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/vehicle-spawn.payload.schema.json",
"resultSchemaRef": "schemas/bridge/vehicle-spawn.result.schema.json",
"timeoutSeconds": 60,
@@ -187,7 +187,7 @@
"type": "event.start",
"title": "Start SCUM event",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/event-start.payload.schema.json",
"resultSchemaRef": "schemas/bridge/event-start.result.schema.json",
"timeoutSeconds": 60,
@@ -197,7 +197,7 @@
"type": "restart.prepare",
"title": "Prepare SCUM restart",
"permission": "server.game-client.maintenance",
"approvalLevel": "operator",
"approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/restart-prepare.payload.schema.json",
"resultSchemaRef": "schemas/bridge/restart-prepare.result.schema.json",
"timeoutSeconds": 120,
@@ -207,7 +207,7 @@
"type": "maintenance.prepare",
"title": "Prepare SCUM maintenance",
"permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin",
"approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/maintenance-prepare.payload.schema.json",
"resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json",
"timeoutSeconds": 120,
@@ -217,7 +217,7 @@
"type": "game-state.patch",
"title": "Patch SCUM player state",
"permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin",
"approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/game-state-patch.payload.schema.json",
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
"timeoutSeconds": 120,
@@ -292,6 +292,12 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-player-profile.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-player-profile.result.schema.json",
"sqlRef": "sql/scum-db-v57/users.sql",
"rowTarget": {
"collection": "scum_users",
"upsertKeys": ["userProfileId"],
"columnMappings": { "userProfileId": "userProfileId", "steamId": "steamId", "gamePlayerId": "gamePlayerId", "displayName": "displayName", "squadId": "squadId", "squadName": "squadName", "famePoints": "famePoints", "normalBalance": "normalBalance", "goldBalance": "goldBalance", "x": "x", "y": "y", "z": "z", "lastLoginTime": "lastLoginTime", "lastSaveTime": "lastSaveTime" }
},
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -304,6 +310,12 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json",
"sqlRef": "sql/scum-db-v57/squads.sql",
"rowTarget": {
"collection": "scum_squads",
"upsertKeys": ["squadId"],
"columnMappings": { "squadId": "squadId", "name": "name", "leaderProfileId": "leaderProfileId", "leaderPlayerId": "leaderPlayerId", "memberCount": "memberCount", "score": "score", "memberLimit": "memberLimit", "message": "message", "info": "info", "lastMemberLoginTime": "lastMemberLoginTime" }
},
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -316,6 +328,12 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json",
"sqlRef": "sql/scum-db-v57/squad-members.sql",
"rowTarget": {
"collection": "scum_squad_members",
"upsertKeys": ["squadId", "userProfileId"],
"columnMappings": { "squadId": "squadId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "steamId": "steamId", "displayName": "displayName", "rank": "rank", "isLeader": "isLeader" }
},
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -328,6 +346,12 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json",
"sqlRef": "sql/scum-db-v57/vehicles.sql",
"rowTarget": {
"collection": "scum_vehicles",
"upsertKeys": ["vehicleId"],
"columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" }
},
"maxRows": 500,
"timeoutSeconds": 15
},
@@ -340,20 +364,95 @@
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json",
"sqlRef": "sql/scum-db-v57/flags.sql",
"rowTarget": {
"collection": "scum_flags",
"upsertKeys": ["flagId"],
"columnMappings": { "flagId": "flagId", "entityId": "entityId", "baseId": "baseId", "ownerProfileId": "ownerProfileId", "ownerPlayerId": "ownerPlayerId", "ownerSquadId": "ownerSquadId", "ownerSquadName": "ownerSquadName", "overtakerProfileId": "overtakerProfileId", "overtakeEndTime": "overtakeEndTime", "ownershipConfidence": "ownershipConfidence", "x": "x", "y": "y", "z": "z" }
},
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.positions",
"title": "Read SCUM current player, vehicle, and flag coordinates",
"title": "Read SCUM player, vehicle, base, and flag coordinates",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json",
"sqlRef": "sql/scum-db-v57/map-points.sql",
"rowTarget": {
"collection": "scum_map_points",
"upsertKeys": ["subjectType", "subjectId"],
"columnMappings": { "subjectType": "subjectType", "subjectId": "subjectId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "vehicleId": "vehicleId", "entityId": "entityId", "baseId": "baseId", "x": "x", "y": "y", "z": "z", "observedAt": "observedAt" }
},
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.tasks",
"title": "Read SCUM v57 quest and task records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-tasks.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-tasks.result.schema.json",
"sqlRef": "sql/scum-db-v57/tasks.sql",
"rowTarget": {
"collection": "scum_tasks",
"upsertKeys": ["taskRecordId"],
"columnMappings": { "taskRecordId": "taskRecordId", "taskKind": "taskKind", "userProfileId": "userProfileId", "mapId": "mapId", "trackingDataSetId": "trackingDataSetId", "dataAssetPath": "dataAssetPath", "sequenceIndex": "sequenceIndex", "isTracked": "isTracked", "state": "state", "completionDeadline": "completionDeadline" }
},
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.events",
"title": "Read SCUM v57 native event rounds and statistics",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-events.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-events.result.schema.json",
"sqlRef": "sql/scum-db-v57/events.sql",
"rowTarget": {
"collection": "scum_native_event_rounds",
"upsertKeys": ["eventRecordId"],
"columnMappings": { "eventRecordId": "eventRecordId", "eventId": "eventId", "roundId": "roundId", "userProfileId": "userProfileId", "startTime": "startTime", "endTime": "endTime", "state": "state", "score": "score", "enemyKills": "enemyKills", "teamKills": "teamKills", "deaths": "deaths", "assists": "assists", "headshots": "headshots" }
},
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.native-timed-gifts",
"title": "Read SCUM v57 native timed gift completion records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-native-timed-gifts.result.schema.json",
"sqlRef": "sql/scum-db-v57/native-timed-gifts.sql",
"rowTarget": {
"collection": "scum_timed_gift_events",
"upsertKeys": ["timedGiftId"],
"columnMappings": { "timedGiftId": "timedGiftId", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt" }
},
"maxRows": 500,
"timeoutSeconds": 15
}
],
"dataPacks": [
{
"key": "scum-db-v57",
"databaseUserVersion": 57,
"logParserRefs": ["data-packs/scum-db-v57/log-parsers.json"],
"configMapRefs": ["data-packs/scum-db-v57/config-maps.json"],
"dataRefs": ["data-packs/scum-db-v57/gift-items.json", "data-packs/scum-db-v57/map-geometry.json"]
}
],
"operationTemplates": [
@@ -589,6 +688,12 @@
"snapshotTypes": [
"players"
],
"queryTemplateKeys": [
"scum.native-timed-gifts"
],
"commandTypes": [
"reward.deliver"
],
"operationKeys": [
"reward.deliver",
"player.notify"
@@ -605,22 +710,16 @@
"scum.squad-members",
"scum.vehicles",
"scum.flags",
"scum.positions"
"scum.positions",
"scum.tasks",
"scum.events",
"scum.native-timed-gifts"
],
"operationKeys": [
"player.fame.set",
"player.currency.normal.set",
"player.currency.gold.set",
"player.notify",
"reward.deliver",
"player.attribute.855.set"
"commandTypes": [
"event.start"
],
"featureKeys": [
"player.intelligence",
"reward.delivery",
"state.patch",
"vehicle.spawn",
"trajectory.collect"
"player.intelligence"
]
}
],
@@ -693,6 +792,62 @@
{
"path": "bin/scum-start.cmd",
"mode": 448
},
{
"path": "assets/map/scum-map-overview.jpg",
"mode": 384
},
{
"path": "sql/scum-db-v57/users.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/squads.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/squad-members.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/vehicles.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/flags.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/map-points.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/tasks.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/events.sql",
"mode": 384
},
{
"path": "sql/scum-db-v57/native-timed-gifts.sql",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/config-maps.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/log-parsers.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/gift-items.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/map-geometry.json",
"mode": 384
}
],
"productionLifecycle": {
@@ -722,6 +877,7 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.remote.access",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance"
@@ -744,6 +900,7 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.remote.access",
"server.game-client.read"
],
"bridgeActions": [
@@ -763,6 +920,7 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.remote.access",
"server.game-client.read"
],
"bridgeActions": [
@@ -782,11 +940,13 @@
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.remote.access",
"server.game-client.read",
"server.game-client.command"
],
"bridgeActions": [
"server.instances.read"
"server.instances.read",
"remote.access.request"
],
"featureKeys": [
"reward.delivery"
@@ -794,27 +954,23 @@
},
{
"key": "workflows",
"title": "Workflow 状态",
"path": "/workflows",
"title": "活动管理",
"path": "/activity",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.remote.access",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance"
"server.game-client.command"
],
"bridgeActions": [
"server.instances.read",
"remote.access.request"
],
"featureKeys": [
"player.intelligence",
"reward.delivery",
"state.patch",
"vehicle.spawn",
"trajectory.collect"
"player.intelligence"
]
}
],
@@ -825,20 +981,6 @@
"mediation": "platform",
"configWritePolicy": "review-required"
},
"mapTrajectories": {
"mapId": "scum-island",
"mapVersion": "0.9",
"worldMinX": -500000,
"worldMinY": -500000,
"worldMaxX": 500000,
"worldMaxY": 500000,
"imageWidth": 2048,
"imageHeight": 2048,
"precision": 1,
"sampleDistance": 4,
"sampleIntervalSeconds": 20,
"retentionSeconds": 604800
},
"runtimeProfiles": {
"discovery": [
{
@@ -3,12 +3,23 @@
"title": "SCUMEventStartPayload",
"type": "object",
"additionalProperties": false,
"required": ["eventType"],
"required": ["eventId", "eventType", "class", "title", "placard", "percent", "produces", "durationSeconds"],
"properties": {
"eventId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"eventType": {
"type": "string",
"maxLength": 24,
"enum": ["airdrop", "convoy", "horde", "zombie-surge"]
"enum": ["range", "fixed"]
},
"class": {
"type": "integer",
"minimum": 1,
"maximum": 2,
"enum": [1, 2]
},
"durationSeconds": {
"type": "integer",
@@ -23,6 +34,37 @@
"announce": {
"type": "boolean"
},
"placard": {
"type": "string",
"maxLength": 500
},
"percent": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"npc": { "type": "integer", "minimum": 0, "maximum": 10000 },
"item": { "type": "integer", "minimum": 0, "maximum": 10000 },
"zombie": { "type": "integer", "minimum": 0, "maximum": 10000 },
"animal": { "type": "integer", "minimum": 0, "maximum": 10000 },
"produces": {
"type": "array",
"maxItems": 100,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["tradeGoodsId", "percent", "value", "r", "x", "y", "z"],
"properties": {
"tradeGoodsId": { "type": "string", "minLength": 1, "maxLength": 128 },
"percent": { "type": "integer", "minimum": 0, "maximum": 100 },
"value": { "type": "integer", "minimum": 1, "maximum": 10000 },
"r": { "type": "number", "minimum": 0, "maximum": 2000000 },
"x": { "type": "number", "minimum": -2000000, "maximum": 2000000 },
"y": { "type": "number", "minimum": -2000000, "maximum": 2000000 },
"z": { "type": "number", "minimum": -2000000, "maximum": 2000000 }
}
}
},
"title": {
"type": "string",
"minLength": 1,
@@ -50,9 +50,9 @@
"additionalProperties": false,
"required": ["x", "y", "z"],
"properties": {
"x": { "type": "number", "minimum": -100000, "maximum": 100000 },
"y": { "type": "number", "minimum": -100000, "maximum": 100000 },
"z": { "type": "number", "minimum": -100000, "maximum": 100000 }
"x": { "type": "number", "minimum": -2000000, "maximum": 2000000 },
"y": { "type": "number", "minimum": -2000000, "maximum": 2000000 },
"z": { "type": "number", "minimum": -2000000, "maximum": 2000000 }
}
},
"tags": {
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMEventsParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"eventId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMEventsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"],
"properties": {
"eventRecordId": { "type": "string", "minLength": 1, "maxLength": 192 },
"eventId": { "type": "string", "minLength": 1, "maxLength": 96 },
"roundId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"startTime": { "type": ["string", "null"], "maxLength": 120 },
"endTime": { "type": ["string", "null"], "maxLength": 120 },
"state": { "enum": ["active", "finished"] },
"score": { "type": ["number", "null"] },
"enemyKills": { "type": ["integer", "null"] },
"teamKills": { "type": ["integer", "null"] },
"deaths": { "type": ["integer", "null"] },
"assists": { "type": ["integer", "null"] },
"headshots": { "type": ["integer", "null"] }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -11,18 +11,21 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["flagId"],
"required": ["flagId", "entityId", "baseId", "ownerProfileId", "ownerPlayerId", "ownerSquadId", "ownerSquadName", "overtakerProfileId", "overtakeEndTime", "ownershipConfidence", "x", "y", "z"],
"properties": {
"flagId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerSquadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerSquadName": { "type": "string", "minLength": 1, "maxLength": 80 },
"baseId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"ownerSquadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"ownerSquadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"overtakerProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"overtakeEndTime": { "type": ["string", "null"], "format": "date-time" },
"ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" }
"x": { "type": ["number", "null"] },
"y": { "type": ["number", "null"] },
"z": { "type": ["number", "null"] }
}
}
},
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMNativeTimedGiftsParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMNativeTimedGiftsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"],
"properties": {
"timedGiftId": { "type": "string", "minLength": 1, "maxLength": 192 },
"userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"mapId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"spawnTime": { "type": ["integer", "null"] },
"spawnAt": { "type": ["string", "null"], "format": "date-time" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -11,21 +11,22 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["userProfileId"],
"required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"],
"properties": {
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadName": { "type": "string", "minLength": 1, "maxLength": 80 },
"famePoints": { "type": "number" },
"normalBalance": { "type": "number" },
"goldBalance": { "type": "number" },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastSaveTime": { "type": "string", "format": "date-time" }
"squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"squadName": { "type": ["string", "null"], "minLength": 1, "maxLength": 80 },
"famePoints": { "type": ["number", "null"] },
"normalBalance": { "type": ["number", "null"] },
"goldBalance": { "type": ["number", "null"] },
"x": { "type": ["number", "null"] },
"y": { "type": ["number", "null"] },
"z": { "type": ["number", "null"] },
"lastLoginTime": { "type": ["string", "null"], "maxLength": 120 },
"lastSaveTime": { "type": ["string", "null"], "format": "date-time" }
}
}
},
@@ -4,7 +4,7 @@
"type": "object",
"additionalProperties": false,
"properties": {
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
@@ -11,17 +11,19 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["subjectType", "subjectId", "x", "y"],
"required": ["subjectType", "subjectId", "userProfileId", "gamePlayerId", "vehicleId", "entityId", "baseId", "x", "y", "z", "observedAt"],
"properties": {
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectType": { "enum": ["player", "vehicle", "base", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastSaveTime": { "type": "string", "format": "date-time" }
"userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"vehicleId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"entityId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"baseId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"x": { "type": ["number", "null"] },
"y": { "type": ["number", "null"] },
"z": { "type": ["number", "null"] },
"observedAt": { "type": ["string", "null"], "format": "date-time" }
}
}
},
@@ -11,16 +11,15 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId", "userProfileId"],
"required": ["squadId", "userProfileId", "gamePlayerId", "steamId", "displayName", "rank", "isLeader"],
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"steamId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"rank": { "type": "string", "minLength": 1, "maxLength": 32 },
"isLeader": { "type": "boolean" },
"joinedAt": { "type": "string", "format": "date-time" }
"rank": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 },
"isLeader": { "type": "integer", "minimum": 0, "maximum": 1 }
}
}
},
@@ -11,14 +11,18 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId"],
"required": ["squadId", "name", "leaderProfileId", "leaderPlayerId", "memberCount", "score", "memberLimit", "message", "info", "lastMemberLoginTime"],
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"leaderProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"leaderPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"leaderProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"leaderPlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"score": { "type": "number" }
"score": { "type": ["number", "null"] },
"memberLimit": { "type": ["integer", "null"], "minimum": 0 },
"message": { "type": ["string", "null"], "maxLength": 4096 },
"info": { "type": ["string", "null"], "maxLength": 4096 },
"lastMemberLoginTime": { "type": ["string", "null"], "maxLength": 120 }
}
}
},
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMTasksParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMTasksResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["taskRecordId", "taskKind", "userProfileId", "mapId", "trackingDataSetId", "dataAssetPath", "sequenceIndex", "isTracked", "state", "completionDeadline"],
"properties": {
"taskRecordId": { "type": "string", "minLength": 1, "maxLength": 160 },
"taskKind": { "enum": ["active-quest", "active-task", "available-task"] },
"userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"mapId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"trackingDataSetId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
"dataAssetPath": { "type": "string", "minLength": 1, "maxLength": 512 },
"sequenceIndex": { "type": ["integer", "null"] },
"isTracked": { "type": "integer", "minimum": 0, "maximum": 1 },
"state": { "enum": ["active", "available", "completed-before"] },
"completionDeadline": { "type": ["number", "null"] }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -11,15 +11,14 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["vehicleId"],
"required": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional"],
"properties": {
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"className": { "type": "string", "minLength": 1, "maxLength": 120 },
"label": { "type": "string", "minLength": 1, "maxLength": 120 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"label": { "type": "string", "maxLength": 120 },
"lastAccessTime": { "type": ["string", "null"], "format": "date-time" },
"isFunctional": { "type": "integer", "minimum": 0, "maximum": 1 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" }
@@ -3,7 +3,7 @@
"title": "SCUMRewardDeliverPayload",
"type": "object",
"additionalProperties": false,
"required": ["grantId", "playerId", "items"],
"required": ["grantId", "playerId", "items", "operations"],
"properties": {
"playerId": {
"type": "string",
@@ -17,17 +17,25 @@
},
"items": {
"type": "array",
"minItems": 1,
"maxItems": 8,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["catalogItemKey", "quantity"],
"required": ["catalogCode", "quantity"],
"properties": {
"catalogItemKey": { "type": "string", "maxLength": 64, "pattern": "^[a-z0-9-]{1,64}$" },
"catalogCode": { "type": "string", "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]{1,128}$" },
"quantity": { "type": "integer", "minimum": 1, "maximum": 100 }
}
}
},
"operations": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 4096
}
}
}
}
@@ -0,0 +1,20 @@
SELECT
CAST(round.event_id AS TEXT) || ':' || CAST(round.id AS TEXT) || ':' || COALESCE(CAST(stats.user_profile_id AS TEXT), 'summary') AS eventRecordId,
CAST(round.event_id AS TEXT) AS eventId,
CAST(round.id AS TEXT) AS roundId,
CAST(stats.user_profile_id AS TEXT) AS userProfileId,
round.start_time AS startTime,
round.end_time AS endTime,
CASE WHEN round.end_time IS NULL OR round.end_time = '' THEN 'active' ELSE 'finished' END AS state,
stats.score AS score,
stats.enemy_kills AS enemyKills,
stats.team_kills AS teamKills,
stats.deaths AS deaths,
stats.assists AS assists,
stats.headshots AS headshots
FROM event_round round
LEFT JOIN event_round_stats stats ON stats.round_id = round.id
WHERE (:eventId IS NULL OR CAST(round.event_id AS TEXT) = :eventId)
AND (:userProfileId IS NULL OR CAST(stats.user_profile_id AS TEXT) = :userProfileId)
ORDER BY round.id DESC, stats.score DESC
LIMIT COALESCE(:limit, 500)
@@ -0,0 +1,23 @@
SELECT
CAST(flag.element_id AS TEXT) AS flagId,
CAST(flag.element_id AS TEXT) AS entityId,
CAST(element.base_id AS TEXT) AS baseId,
CAST(element.owner_profile_id AS TEXT) AS ownerProfileId,
CAST(owner.prisoner_id AS TEXT) AS ownerPlayerId,
CAST(owner_member.squad_id AS TEXT) AS ownerSquadId,
owner_squad.name AS ownerSquadName,
CAST(flag.overtaker_user_profile_id AS TEXT) AS overtakerProfileId,
strftime('%Y-%m-%dT%H:%M:%SZ', flag.overtake_end_time, 'unixepoch') AS overtakeEndTime,
CASE WHEN element.owner_profile_id IS NULL THEN 'unknown' ELSE 'direct' END AS ownershipConfidence,
element.location_x AS x,
element.location_y AS y,
element.location_z AS z
FROM base_element_flag flag
JOIN base_element element ON element.element_id = flag.element_id
LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id
LEFT JOIN squad_member owner_member ON owner_member.user_profile_id = element.owner_profile_id
LEFT JOIN squad owner_squad ON owner_squad.id = owner_member.squad_id
WHERE (:flagId IS NULL OR CAST(flag.element_id AS TEXT) = :flagId)
AND (:ownerProfileId IS NULL OR CAST(element.owner_profile_id AS TEXT) = :ownerProfileId)
ORDER BY flag.element_id
LIMIT COALESCE(:limit, 500)
@@ -0,0 +1,47 @@
SELECT
'player' AS subjectType,
CAST(profile.id AS TEXT) AS subjectId,
CAST(profile.id AS TEXT) AS userProfileId,
CAST(prisoner.id AS TEXT) AS gamePlayerId,
NULL AS vehicleId,
CAST(entity.id AS TEXT) AS entityId,
NULL AS baseId,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt
FROM user_profile profile
JOIN prisoner ON prisoner.id = profile.prisoner_id
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
JOIN entity ON entity.id = prisoner_entity.entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'player')
AND (:subjectId IS NULL OR CAST(profile.id AS TEXT) = :subjectId)
UNION ALL
SELECT
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL,
CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL,
entity.location_x, entity.location_y, entity.location_z,
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch')
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId)
UNION ALL
SELECT
'base', CAST(base.id AS TEXT), CAST(base.owner_user_profile_id AS TEXT), NULL,
NULL, NULL, CAST(base.id AS TEXT), base.location_x, base.location_y, 0, NULL
FROM base
WHERE (:subjectType IS NULL OR :subjectType = 'base')
AND (:subjectId IS NULL OR CAST(base.id AS TEXT) = :subjectId)
UNION ALL
SELECT
'flag', CAST(flag.element_id AS TEXT), CAST(element.owner_profile_id AS TEXT), CAST(owner.prisoner_id AS TEXT),
NULL, CAST(flag.element_id AS TEXT), CAST(element.base_id AS TEXT),
element.location_x, element.location_y, element.location_z,
strftime('%Y-%m-%dT%H:%M:%SZ', flag.overtake_end_time, 'unixepoch')
FROM base_element_flag flag
JOIN base_element element ON element.element_id = flag.element_id
LEFT JOIN user_profile owner ON owner.id = element.owner_profile_id
WHERE (:subjectType IS NULL OR :subjectType = 'flag')
AND (:subjectId IS NULL OR CAST(flag.element_id AS TEXT) = :subjectId)
LIMIT COALESCE(:limit, 500)
@@ -0,0 +1,10 @@
SELECT
'native:' || CAST(gift.rowid AS TEXT) || ':' || COALESCE(CAST(gift.user_profile_id AS TEXT), 'unknown') || ':' || COALESCE(CAST(gift.map_id AS TEXT), 'unknown') || ':' || COALESCE(CAST(gift.spawn_time AS TEXT), 'unknown') AS timedGiftId,
CAST(gift.user_profile_id AS TEXT) AS userProfileId,
CAST(gift.map_id AS TEXT) AS mapId,
gift.spawn_time AS spawnTime,
strftime('%Y-%m-%dT%H:%M:%SZ', gift.spawn_time, 'unixepoch') AS spawnAt
FROM finished_timed_gift_spawner gift
WHERE (:userProfileId IS NULL OR CAST(gift.user_profile_id AS TEXT) = :userProfileId)
ORDER BY gift.spawn_time DESC
LIMIT COALESCE(:limit, 500)
@@ -0,0 +1,15 @@
SELECT
CAST(member.squad_id AS TEXT) AS squadId,
CAST(member.user_profile_id AS TEXT) AS userProfileId,
CAST(profile.prisoner_id AS TEXT) AS gamePlayerId,
account.id AS steamId,
COALESCE(profile.name, account.name, '') AS displayName,
CAST(member.rank AS TEXT) AS rank,
CASE WHEN member.rank = 4 THEN 1 ELSE 0 END AS isLeader
FROM squad_member member
JOIN user_profile profile ON profile.id = member.user_profile_id
LEFT JOIN user account ON account.id = profile.user_id
WHERE (:squadId IS NULL OR CAST(member.squad_id AS TEXT) = :squadId)
AND (:userProfileId IS NULL OR CAST(member.user_profile_id AS TEXT) = :userProfileId)
ORDER BY member.squad_id, member.rank DESC, profile.name
LIMIT COALESCE(:limit, 500)
@@ -0,0 +1,20 @@
SELECT
CAST(squad.id AS TEXT) AS squadId,
COALESCE(squad.name, '') AS name,
CAST(leader.user_profile_id AS TEXT) AS leaderProfileId,
CAST(leader_profile.prisoner_id AS TEXT) AS leaderPlayerId,
COUNT(member.id) AS memberCount,
squad.score AS score,
squad.member_limit AS memberLimit,
squad.message AS message,
squad.information AS info,
squad.last_member_login_time AS lastMemberLoginTime
FROM squad
LEFT JOIN squad_member member ON member.squad_id = squad.id
LEFT JOIN squad_member leader ON leader.squad_id = squad.id AND leader.rank = 4
LEFT JOIN user_profile leader_profile ON leader_profile.id = leader.user_profile_id
WHERE (:squadId IS NULL OR CAST(squad.id AS TEXT) = :squadId)
AND (:search IS NULL OR COALESCE(squad.name, '') LIKE '%' || :search || '%')
GROUP BY squad.id
ORDER BY squad.score DESC, squad.id
LIMIT COALESCE(:limit, 500)
@@ -0,0 +1,32 @@
SELECT
'active-quest:' || CAST(quest.id AS TEXT) AS taskRecordId,
'active-quest' AS taskKind,
CAST(quest.user_profile_id AS TEXT) AS userProfileId,
CAST(quest.map_id AS TEXT) AS mapId,
CAST(quest.id AS TEXT) AS trackingDataSetId,
quest.quest_data_asset_path AS dataAssetPath,
tracking.sequence_index AS sequenceIndex,
CASE WHEN EXISTS (SELECT 1 FROM tracked_quest tracked WHERE tracked.quest_id = quest.id) THEN 1 ELSE 0 END AS isTracked,
'active' AS state,
quest.completion_deadline AS completionDeadline
FROM active_quest quest
JOIN tracking_data_set tracking ON tracking.id = quest.id
WHERE (:userProfileId IS NULL OR CAST(quest.user_profile_id AS TEXT) = :userProfileId)
UNION ALL
SELECT
'active-task:' || CAST(task.id AS TEXT), 'active-task', CAST(task.user_profile_id AS TEXT), CAST(task.map_id AS TEXT),
CAST(task.id AS TEXT), available.task_data_asset_path, tracking.sequence_index,
CASE WHEN EXISTS (SELECT 1 FROM tracked_quest tracked WHERE tracked.quest_id = task.id) THEN 1 ELSE 0 END,
'active', NULL
FROM active_task task
JOIN tracking_data_set tracking ON tracking.id = task.id
JOIN available_task available ON available.id = task.available_task_id
WHERE (:userProfileId IS NULL OR CAST(task.user_profile_id AS TEXT) = :userProfileId)
UNION ALL
SELECT
'available-task:' || CAST(available.id AS TEXT), 'available-task', CAST(available.user_profile_id AS TEXT), CAST(available.map_id AS TEXT),
NULL, available.task_data_asset_path, NULL, 0,
CASE WHEN available.was_ever_completed = 1 THEN 'completed-before' ELSE 'available' END, NULL
FROM available_task available
WHERE (:userProfileId IS NULL OR CAST(available.user_profile_id AS TEXT) = :userProfileId)
LIMIT COALESCE(:limit, 500)
@@ -0,0 +1,30 @@
SELECT
CAST(profile.id AS TEXT) AS userProfileId,
account.id AS steamId,
CAST(prisoner.id AS TEXT) AS gamePlayerId,
COALESCE(profile.name, account.name, '') AS displayName,
CAST(member.squad_id AS TEXT) AS squadId,
squad.name AS squadName,
profile.fame_points AS famePoints,
MAX(CASE WHEN currency.currency_type = 1 THEN currency.account_balance END) AS normalBalance,
MAX(CASE WHEN currency.currency_type = 2 THEN currency.account_balance END) AS goldBalance,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
profile.last_login_time AS lastLoginTime,
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS lastSaveTime
FROM user_profile profile
JOIN user account ON account.id = profile.user_id
LEFT JOIN prisoner ON prisoner.id = profile.prisoner_id
LEFT JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
LEFT JOIN entity ON entity.id = prisoner_entity.entity_id
LEFT JOIN squad_member member ON member.user_profile_id = profile.id
LEFT JOIN squad ON squad.id = member.squad_id
LEFT JOIN bank_account_registry bank ON bank.account_owner_user_profile_id = profile.id
LEFT JOIN bank_account_registry_currencies currency ON currency.bank_account_id = bank.id
WHERE (:userProfileId IS NULL OR CAST(profile.id AS TEXT) = :userProfileId)
AND (:steamId IS NULL OR account.id = :steamId)
AND (:search IS NULL OR COALESCE(profile.name, account.name, '') LIKE '%' || :search || '%')
GROUP BY profile.id
ORDER BY profile.last_login_time DESC
LIMIT COALESCE(:limit, 500)
@@ -0,0 +1,16 @@
SELECT
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
entity.class AS className,
spawner.vehicle_alias AS label,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
spawner.is_vehicle_functional AS isFunctional
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
ORDER BY spawner.vehicle_last_access_time DESC
LIMIT COALESCE(:limit, 500)