feat(scum): rebuild plugin-owned management data
This commit is contained in:
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 := ¬ificationPortFixture{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 查询与日志同步,不显示样例数据。"; }
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+11
@@ -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 }
|
||||
}
|
||||
}
|
||||
+34
@@ -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
-8
@@ -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"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+10
@@ -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 }
|
||||
}
|
||||
}
|
||||
+26
@@ -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" }
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -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" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -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
-9
@@ -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" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+5
-6
@@ -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 }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+8
-4
@@ -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 }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+10
@@ -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 }
|
||||
}
|
||||
}
|
||||
+31
@@ -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" }
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -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" }
|
||||
|
||||
+12
-4
@@ -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)
|
||||
@@ -50,7 +50,6 @@
|
||||
"gameClientBridge": {
|
||||
"$ref": "#/$defs/gameClientBridgeManifest"
|
||||
},
|
||||
"mapTrajectories": { "$ref": "#/$defs/mapTrajectoryDeclaration" },
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/runCapability" },
|
||||
@@ -214,17 +213,6 @@
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"mapTrajectoryDeclaration": {
|
||||
"type": "object",
|
||||
"required": ["mapId", "mapVersion", "worldMinX", "worldMinY", "worldMaxX", "worldMaxY", "imageWidth", "imageHeight", "precision", "sampleDistance", "sampleIntervalSeconds", "retentionSeconds"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mapId": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$" }, "mapVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" },
|
||||
"worldMinX": { "type": "number" }, "worldMinY": { "type": "number" }, "worldMaxX": { "type": "number" }, "worldMaxY": { "type": "number" },
|
||||
"imageWidth": { "type": "number", "exclusiveMinimum": 0 }, "imageHeight": { "type": "number", "exclusiveMinimum": 0 }, "precision": { "type": "number", "exclusiveMinimum": 0 }, "sampleDistance": { "type": "number", "minimum": 0 },
|
||||
"sampleIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 }, "retentionSeconds": { "type": "integer", "minimum": 1, "maximum": 2678400 }
|
||||
}
|
||||
},
|
||||
"pluginLogicalDirectory": { "type": "object", "required": ["key", "label", "scope"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 60 }, "scope": { "enum": ["config", "logs"] } } },
|
||||
"pluginLogicalFile": { "type": "object", "required": ["key", "directoryKey", "label", "kind"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "directoryKey": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["config", "log"] }, "streamKey": { "$ref": "#/$defs/logicalKey" }, "editable": { "type": "boolean" } } },
|
||||
"pluginConfigField": { "type": "object", "required": ["key", "fileKey", "configKey", "label", "description", "control", "restartImpact"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "fileKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$", "maxLength": 120 }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "minLength": 1, "maxLength": 240 }, "control": { "enum": ["text", "number", "boolean", "port"] }, "minimum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "maximum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "defaultValue": { "type": "string", "maxLength": 120 }, "restartImpact": { "enum": ["none", "restart-required"] } } },
|
||||
@@ -271,6 +259,11 @@
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" },
|
||||
"maxItems": 128
|
||||
},
|
||||
"dataPacks": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeDataPack" },
|
||||
"maxItems": 64
|
||||
},
|
||||
"operationTemplates": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeOperationTemplate" },
|
||||
@@ -360,10 +353,34 @@
|
||||
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
|
||||
"rowTarget": { "$ref": "#/$defs/pluginDataRowTarget" },
|
||||
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
|
||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
|
||||
}
|
||||
},
|
||||
"pluginDataRowTarget": {
|
||||
"type": "object",
|
||||
"required": ["collection", "upsertKeys", "columnMappings"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" },
|
||||
"upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 },
|
||||
"columnMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeDataPack": {
|
||||
"type": "object",
|
||||
"required": ["key", "databaseUserVersion", "logParserRefs", "configMapRefs"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"databaseUserVersion": { "type": "integer", "minimum": 1 },
|
||||
"logParserRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1 },
|
||||
"configMapRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true, "minItems": 1 },
|
||||
"dataRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeOperationSafety": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -440,6 +457,10 @@
|
||||
"type": "string",
|
||||
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.json$"
|
||||
},
|
||||
"relativeSqlRef": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.sql$"
|
||||
},
|
||||
"runCapability": {
|
||||
"enum": [
|
||||
"process.install",
|
||||
|
||||
@@ -134,6 +134,10 @@ function isSafeRelativeJsonRef(value: string): boolean {
|
||||
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
|
||||
}
|
||||
|
||||
function isSafeRelativeSqlRef(value: string): boolean {
|
||||
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.sql$/i.test(value);
|
||||
}
|
||||
|
||||
function isSafeRelativePathRef(value: string): boolean {
|
||||
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+$/.test(value);
|
||||
}
|
||||
@@ -406,10 +410,6 @@ function validateManifestAssetFiles(manifest: unknown, manifestDir: string): { e
|
||||
errors.push(`${location}.path: asset file must be a regular file under 64KiB`);
|
||||
continue;
|
||||
}
|
||||
const body = fs.readFileSync(target);
|
||||
if (body.includes(0)) {
|
||||
errors.push(`${location}.path: asset file contains NUL bytes`);
|
||||
}
|
||||
}
|
||||
return { errors, declared };
|
||||
}
|
||||
@@ -672,6 +672,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
targetKey?: string;
|
||||
parameterSchemaRef?: string;
|
||||
resultSchemaRef?: string;
|
||||
sqlRef?: string;
|
||||
rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record<string, string> };
|
||||
maxRows?: number;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
@@ -840,6 +842,16 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
}
|
||||
const projectsRows = queryTemplate.sqlRef !== undefined || queryTemplate.rowTarget !== undefined;
|
||||
if (projectsRows) {
|
||||
if (!queryTemplate.sqlRef || !isSafeRelativeSqlRef(queryTemplate.sqlRef)) errors.push(`${location}.sqlRef: projected queries require a package-relative SQL asset`);
|
||||
const target = queryTemplate.rowTarget;
|
||||
if (!target || !/^[A-Za-z][A-Za-z0-9._-]{0,119}$/.test(target.collection ?? "")) errors.push(`${location}.rowTarget.collection: projected queries require a safe collection`);
|
||||
if (!Array.isArray(target?.upsertKeys) || target.upsertKeys.length === 0 || !target.upsertKeys.every((key) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(key))) errors.push(`${location}.rowTarget.upsertKeys: projected queries require safe upsert keys`);
|
||||
const mappings = target?.columnMappings;
|
||||
if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([destination, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(destination) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) errors.push(`${location}.rowTarget.columnMappings: projected queries require safe field mappings`);
|
||||
if (mappings && Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => key in mappings)) errors.push(`${location}.rowTarget.upsertKeys: every upsert key must be declared in columnMappings`);
|
||||
}
|
||||
if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) {
|
||||
errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
|
||||
}
|
||||
@@ -990,6 +1002,56 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) return [];
|
||||
const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; logParserRefs?: string[]; configMapRefs?: string[]; dataRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? [];
|
||||
const errors: string[] = [];
|
||||
const keys = new Set<string>();
|
||||
for (const [index, dataPack] of dataPacks.entries()) {
|
||||
const location = `manifest.gameClientBridge.dataPacks[${index}]`;
|
||||
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(dataPack.key ?? "") || keys.has(dataPack.key ?? "")) errors.push(`${location}.key: must be a unique data-pack key`);
|
||||
keys.add(dataPack.key ?? "");
|
||||
if (!Number.isInteger(dataPack.databaseUserVersion) || (dataPack.databaseUserVersion ?? 0) < 1) errors.push(`${location}.databaseUserVersion: must be a positive SQLite user_version`);
|
||||
for (const field of ["logParserRefs", "configMapRefs", "dataRefs"] as const) {
|
||||
const refs = dataPack[field] ?? [];
|
||||
if (field !== "dataRefs" && refs.length === 0) errors.push(`${location}.${field}: must declare at least one package asset`);
|
||||
for (const ref of refs) {
|
||||
if (!isSafeRelativeJsonRef(ref)) {
|
||||
errors.push(`${location}.${field}: must use package-relative JSON assets`);
|
||||
continue;
|
||||
}
|
||||
if (!declaredAssets.has(ref)) errors.push(`${location}.${field}: ${ref} must be declared in manifest.assetFiles`);
|
||||
const target = path.resolve(manifestDir, ref);
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) errors.push(`${location}.${field}: missing package asset ${ref}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) return [];
|
||||
const templates = (manifest as { gameClientBridge?: { queryTemplates?: Array<{ sqlRef?: string }> } }).gameClientBridge?.queryTemplates ?? [];
|
||||
const errors: string[] = [];
|
||||
for (const [index, template] of templates.entries()) {
|
||||
if (!template.sqlRef) continue;
|
||||
const location = `manifest.gameClientBridge.queryTemplates[${index}].sqlRef`;
|
||||
if (!isSafeRelativeSqlRef(template.sqlRef)) {
|
||||
errors.push(`${location}: must be a package-relative .sql asset`);
|
||||
continue;
|
||||
}
|
||||
if (!declaredAssets.has(template.sqlRef)) errors.push(`${location}: ${template.sqlRef} must be declared in manifest.assetFiles`);
|
||||
const assetPath = path.resolve(manifestDir, template.sqlRef);
|
||||
if (!fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) {
|
||||
errors.push(`${location}: missing SQL asset ${template.sqlRef}`);
|
||||
continue;
|
||||
}
|
||||
const body = fs.readFileSync(assetPath, "utf8").trim();
|
||||
if (!/^select\b/i.test(body) || /;\s*\S/.test(body) || /\b(?:insert|update|delete|drop|alter|create|attach|pragma)\b/i.test(body)) errors.push(`${location}: SQL assets must contain one read-only SELECT statement`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
@@ -1378,6 +1440,8 @@ export function validateManifestFile(manifestPath: string): string[] {
|
||||
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
|
||||
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
|
||||
errors.push(...assetValidation.errors);
|
||||
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
|
||||
errors.push(...validateGameClientBridgeDataPacks(manifest, manifestDir, assetValidation.declared));
|
||||
|
||||
for (const declaration of referencedLifecycleActions(manifest)) {
|
||||
if (!isSafeRelativeJsonRef(declaration.ref)) {
|
||||
|
||||
@@ -262,8 +262,23 @@ export interface GameClientBridgeQueryTemplateDeclaration {
|
||||
targetKey: string;
|
||||
parameterSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
sqlRef?: string;
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
rowTarget?: PluginDataRowTargetDeclaration;
|
||||
}
|
||||
|
||||
export interface PluginDataRowTargetDeclaration {
|
||||
collection: string;
|
||||
upsertKeys: string[];
|
||||
columnMappings: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeDataPackDeclaration {
|
||||
key: string;
|
||||
databaseUserVersion: number;
|
||||
logParserRefs: string[];
|
||||
configMapRefs: string[];
|
||||
}
|
||||
|
||||
export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation";
|
||||
@@ -337,6 +352,7 @@ export interface GameClientBridgeManifest {
|
||||
commands: GameClientBridgeCommandDeclaration[];
|
||||
snapshots: GameClientBridgeSnapshotDeclaration[];
|
||||
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
|
||||
dataPacks?: GameClientBridgeDataPackDeclaration[];
|
||||
operationTemplates?: GameClientBridgeOperationTemplateDeclaration[];
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
|
||||
@@ -211,7 +211,7 @@ describe("plugin manifest validation", () => {
|
||||
const installScript = fs.readFileSync(path.join(pluginDir, installAction.executableKey), "utf8");
|
||||
const startScript = fs.readFileSync(path.join(pluginDir, startAction.executableKey), "utf8");
|
||||
expect(manifest.runtimeProfiles.serverDeployments).toBeUndefined();
|
||||
expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd"]));
|
||||
expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd", "assets/map/scum-map-overview.jpg"]));
|
||||
expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 +quit" } });
|
||||
expect(installAction.timeoutMs).toBe(7200000);
|
||||
expect(startAction).toMatchObject({ executableKey: "bin/scum-start.cmd", environment: { SERVER_LOG_FLAG: "-log" } });
|
||||
@@ -478,7 +478,7 @@ describe("plugin manifest validation", () => {
|
||||
const serialized = JSON.stringify(manifest).toLowerCase();
|
||||
|
||||
expect(serialized).not.toContain("local-proof");
|
||||
expect(manifest.version).toBe("0.1.6");
|
||||
expect(manifest.version).toBe("0.1.7");
|
||||
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
|
||||
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
|
||||
@@ -525,13 +525,13 @@ describe("plugin manifest validation", () => {
|
||||
};
|
||||
};
|
||||
const expected = {
|
||||
"announcement.send": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"announcement.send": { permission: "server.game-client.command", approvalLevel: "none" },
|
||||
"companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" },
|
||||
"player.lookup": { permission: "server.game-client.read", approvalLevel: "none" },
|
||||
"reward.deliver": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"event.start": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"restart.prepare": { permission: "server.game-client.maintenance", approvalLevel: "operator" },
|
||||
"maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "platform-admin" }
|
||||
"reward.deliver": { permission: "server.game-client.command", approvalLevel: "none" },
|
||||
"event.start": { permission: "server.game-client.command", approvalLevel: "none" },
|
||||
"restart.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" },
|
||||
"maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" }
|
||||
} as const;
|
||||
|
||||
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected)));
|
||||
@@ -633,15 +633,28 @@ describe("plugin manifest validation", () => {
|
||||
targetKey: string;
|
||||
parameterSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
sqlRef: string;
|
||||
rowTarget: { collection: string; upsertKeys: string[]; columnMappings: Record<string, string> };
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
}>;
|
||||
pages: Array<{ pageKey: string; queryTemplateKeys?: string[] }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
||||
};
|
||||
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"];
|
||||
const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"];
|
||||
const expectedColumnsByKey: Record<string, string[]> = {
|
||||
"scum.player.profile": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"],
|
||||
"scum.squads": ["squadId", "name", "leaderProfileId", "leaderPlayerId", "memberCount", "score", "memberLimit", "message", "info", "lastMemberLoginTime"],
|
||||
"scum.squad-members": ["squadId", "userProfileId", "gamePlayerId", "steamId", "displayName", "rank", "isLeader"],
|
||||
"scum.vehicles": ["vehicleId", "entityId", "className", "label", "x", "y", "z", "lastAccessTime", "isFunctional"],
|
||||
"scum.flags": ["flagId", "entityId", "baseId", "ownerProfileId", "ownerPlayerId", "ownerSquadId", "ownerSquadName", "overtakerProfileId", "overtakeEndTime", "ownershipConfidence", "x", "y", "z"],
|
||||
"scum.positions": ["subjectType", "subjectId", "userProfileId", "gamePlayerId", "vehicleId", "entityId", "baseId", "x", "y", "z", "observedAt"],
|
||||
"scum.tasks": ["taskRecordId", "taskKind", "userProfileId", "mapId", "trackingDataSetId", "dataAssetPath", "sequenceIndex", "isTracked", "state", "completionDeadline"],
|
||||
"scum.events": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"],
|
||||
"scum.native-timed-gifts": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"]
|
||||
};
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
@@ -655,24 +668,45 @@ describe("plugin manifest validation", () => {
|
||||
expect(template.engine).toBe("sqlite");
|
||||
expect(template.transportKey).toBe("scum-database");
|
||||
expect(template.targetKey).toBe("scum-database");
|
||||
expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/);
|
||||
expect(template.rowTarget.collection).toMatch(/^scum_/);
|
||||
expect(template.rowTarget.upsertKeys.length).toBeGreaterThan(0);
|
||||
expect(template.rowTarget.upsertKeys.every((upsertKey) => upsertKey in template.rowTarget.columnMappings)).toBe(true);
|
||||
expect(fs.existsSync(path.join(pluginDir, template.sqlRef))).toBe(true);
|
||||
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/);
|
||||
const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8"));
|
||||
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8"));
|
||||
const sql = fs.readFileSync(path.join(pluginDir, template.sqlRef), "utf8");
|
||||
const expectedColumns = expectedColumnsByKey[key];
|
||||
expect(parameters).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] });
|
||||
expect(result.properties.rows.maxItems).toBeLessThanOrEqual(template.maxRows);
|
||||
expect(template.rowTarget.columnMappings).toEqual(Object.fromEntries(expectedColumns.map((column) => [column, column])));
|
||||
expect(Object.keys(result.properties.rows.items.properties).sort()).toEqual([...expectedColumns].sort());
|
||||
expect([...result.properties.rows.items.required].sort()).toEqual([...expectedColumns].sort());
|
||||
for (const column of expectedColumns) {
|
||||
expect(sql).toMatch(new RegExp(`\\bAS\\s+${column}\\b`, "i"));
|
||||
}
|
||||
}
|
||||
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
||||
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
|
||||
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
|
||||
const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts");
|
||||
const workflowsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows");
|
||||
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"]));
|
||||
expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"]));
|
||||
expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
for (const pageKey of ["players", "squads", "live-map"]) {
|
||||
expect(giftsPage?.commandTypes).toEqual(["reward.deliver"]);
|
||||
expect(workflowsPage?.commandTypes).toEqual(["event.start"]);
|
||||
for (const pageKey of ["players", "squads", "live-map", "gifts", "workflows"]) {
|
||||
const pluginPage = manifest.pages.find((page) => page.key === pageKey);
|
||||
expect(pluginPage?.permissions).toContain("server.game-client.read");
|
||||
expect(pluginPage?.permissions).toContain("server.remote.access");
|
||||
expect(pluginPage?.bridgeActions).toContain("remote.access.request");
|
||||
}
|
||||
for (const pageKey of ["gifts", "workflows"]) {
|
||||
expect(manifest.pages.find((page) => page.key === pageKey)?.permissions).toContain("server.game-client.command");
|
||||
}
|
||||
});
|
||||
|
||||
it("declares typed SCUM RCON operations without arbitrary command inputs", () => {
|
||||
@@ -709,6 +743,24 @@ describe("plugin manifest validation", () => {
|
||||
expect(manifest.pages.find((page) => page.key === "gifts")?.permissions).toContain("server.game-client.command");
|
||||
});
|
||||
|
||||
it("packages SCUM v57 config, UTF-16LE logs, and gift metadata inside the plugin", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; dataRefs?: string[] }> };
|
||||
};
|
||||
const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-db-v57");
|
||||
expect(pack).toMatchObject({ databaseUserVersion: 57 });
|
||||
const logParsers = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.logParserRefs[0]), "utf8"));
|
||||
const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8"));
|
||||
const giftMetadata = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![0]), "utf8"));
|
||||
const mapGeometry = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![1]), "utf8"));
|
||||
expect(logParsers).toMatchObject({ encoding: "utf-16le", lineEnding: "lf", continuationPolicy: "append-to-previous-timestamped-record", timestampFormat: "yyyy.MM.dd-HH.mm.ss" });
|
||||
expect(logParsers.parsers.map((parser: { key: string }) => parser.key)).toEqual(expect.arrayContaining(["login", "chat", "admin", "kill", "event-kill", "quests", "vehicle-destruction"]));
|
||||
expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"]));
|
||||
expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } });
|
||||
expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-overview.jpg", width: 256, height: 256 }, runtimeOverride: { kilometersToWorldUnits: 100000 } });
|
||||
});
|
||||
|
||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
|
||||
import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, requestSCUMPageQueries, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js";
|
||||
import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js";
|
||||
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
|
||||
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
|
||||
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
|
||||
|
||||
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
|
||||
const projectionData = {
|
||||
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3, hasCoordinates: true }, freshness: { status: "fresh" }, unknownFields: { "855": 100 } }],
|
||||
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 3, leaderProfileId: "profile-1", freshness: { status: "fresh" } }],
|
||||
members: [{ gamePlayerId: "steam-1", displayName: "Mira", squadId: "squad-1", rank: "Leader", freshness: { status: "fresh" } }],
|
||||
vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { subjectType: "vehicle", subjectId: "veh-1", x: 400, y: 200, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
|
||||
flags: [{ flagId: "flag-1", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { subjectType: "flag", subjectId: "flag-1", x: 100, y: 80, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
|
||||
positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }],
|
||||
operations: [{ id: "op-1", templateKey: "player.fame.set", status: "waiting", safeSummary: { message: "awaiting approval" } }],
|
||||
workflows: [{ id: "wf-1", templateKey: "scum.world-refresh", status: "queued", currentStepKey: "read-positions", createdAt: "2026-08-10T00:00:00Z", safeSummary: { message: "world refresh queued" } }],
|
||||
steps: [{ stepKey: "read-positions", status: "queued", capability: "remote.run.db.sqlite.query", safeSummary: { message: "queued safely" } }]
|
||||
const featureRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features");
|
||||
const pageSource = readFileSync(resolve(featureRoot, "page.ts"), "utf8");
|
||||
const dataClientSource = readFileSync(resolve(featureRoot, "page-data.ts"), "utf8");
|
||||
|
||||
const surfaceData: SCUMSurfaceData = {
|
||||
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }],
|
||||
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }],
|
||||
members: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", displayName: "Mira", squadId: "squad-1", rank: "Leader", score: 42, lastLoginAt: "2026-08-10T00:00:00Z", freshness: { status: "fresh" } }],
|
||||
events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }],
|
||||
eventProduces: [{ _recordKey: "event-1:produce-1", id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }],
|
||||
eventRuns: [{ id: "run-1", eventId: "event-1", status: "running", startedAt: "2026-08-10T00:00:00Z", summary: "Round 1" }],
|
||||
nativeEventRounds: [{ eventRecordId: "native-1", eventId: "native-event", state: "active", startTime: "2026-08-10T00:00:00Z", enemyKills: 2 }],
|
||||
tasks: [{ taskRecordId: "task-1", taskKind: "active-task", state: "active", userProfileId: "profile-1" }],
|
||||
activityEvents: [{ id: "activity-1", type: "reward", subjectName: "Mira", status: "delivered", occurredAt: "2026-08-10T00:02:00Z" }],
|
||||
gifts: [{ code: "starter-pack", name: "Starter Pack", class: 5, audience: "all", number: 1, achievement: 2, achievementNumber: 10, status: "active", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], commands: [{ command: "#announce Starter pack" }] }],
|
||||
giftClaims: [{ id: "claim-1", playerId: "steam-1", giftCode: "starter-pack", status: "claimed", claimedAt: "2026-08-10T00:03:00Z" }],
|
||||
pendingGifts: [{ id: "pending-1", playerId: "steam-1", giftCode: "starter-pack", status: "pending", createdAt: "2026-08-10T00:03:30Z" }],
|
||||
giftDeliveries: [{ id: "delivery-1", playerId: "steam-1", giftCode: "starter-pack", status: "delivered", deliveredAt: "2026-08-10T00:04:00Z" }],
|
||||
timedGiftEvents: [{ timedGiftId: "timed-1", userProfileId: "profile-1", mapId: "map-1", spawnTime: 1, spawnAt: "2026-08-10T00:05:00Z" }],
|
||||
mapPoints: [{ id: "poi-1", name: "Airfield", layer: "other", x: 800, y: 900, z: 10, source: "plugin-map" }],
|
||||
mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }],
|
||||
mapSettings: [],
|
||||
vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
|
||||
flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { x: 100, y: 80, z: 0 }, freshness: { status: "fresh" } }]
|
||||
};
|
||||
|
||||
describe("SCUM plugin feature module", () => {
|
||||
@@ -62,114 +77,245 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
|
||||
});
|
||||
|
||||
it("renders projection-backed user management without raw file/config panels", () => {
|
||||
it("loads page data only through scoped plugin collections", async () => {
|
||||
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
|
||||
const data = await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "gifts");
|
||||
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.players]);
|
||||
expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` });
|
||||
});
|
||||
|
||||
it("merges the latest typed player and online-session snapshots into database users", async () => {
|
||||
const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } });
|
||||
const gameClient = gameClientActions();
|
||||
gameClient.snapshots.mockImplementation(async (query) => query?.type === "players" ? { items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] } : { items: [{ sequence: 3, observedAt: "2026-08-10T00:01:00Z", payload: { sessions: [{ sessionId: "session-1", playerName: "Mira" }] } }] });
|
||||
const data = await loadSCUMSurface({ pluginData, gameClient }, "players");
|
||||
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "online.sessions"]);
|
||||
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:01:00Z" });
|
||||
expect(mergePlayerSnapshots([{ gamePlayerId: "steam-2", displayName: "Noah" }], { items: [] }, { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { sessions: [] } }] })[0]).toMatchObject({ online: false });
|
||||
});
|
||||
|
||||
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
|
||||
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
|
||||
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows");
|
||||
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
|
||||
const dispatch = dispatchAction();
|
||||
await requestSCUMPageQueries({ dispatch }, "workflows");
|
||||
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]);
|
||||
dispatch.mockClear();
|
||||
await requestSCUMPageQueries({ dispatch }, "activity");
|
||||
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]);
|
||||
});
|
||||
|
||||
it("dispatches only declared SQLite query envelopes for machine refresh", async () => {
|
||||
const dispatch = dispatchAction();
|
||||
await requestSCUMPageQueries({ dispatch }, "squads");
|
||||
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.squads", "scum.squad-members", "scum.flags"]);
|
||||
for (const [envelope] of dispatch.mock.calls) expect(envelope).toMatchObject({ action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database" } });
|
||||
});
|
||||
|
||||
it("uses transaction, put, and delete for plugin-owned gift data", async () => {
|
||||
const pluginData = pluginDataActions();
|
||||
const actions = { pluginData };
|
||||
expect(parseGiftItems("BP_Cash_01:2, Water-Bottle.01:1")).toEqual([{ catalogCode: "BP_Cash_01", quantity: 2 }, { catalogCode: "Water-Bottle.01", quantity: 1 }]);
|
||||
expect(parseGiftCommands("#announce Hello\n#spawnitem BP_Cash_01 2")).toEqual([{ command: "#announce Hello" }, { command: "#spawnitem BP_Cash_01 2" }]);
|
||||
expect(() => parseGiftItems("cash:0")).toThrow("格式无效");
|
||||
expect(() => parseGiftItems("a:1,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toThrow("最多包含 8 项");
|
||||
await saveGiftDefinition(actions, { code: "starter", name: "Starter", items: [] });
|
||||
await createGiftDelivery(actions, { id: "delivery-1", giftCode: "starter", playerId: "steam-1" });
|
||||
await deleteGiftDefinition(actions, "starter");
|
||||
expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.gifts, [{ operation: "put", key: "starter", value: { code: "starter", name: "Starter", items: [] } }]);
|
||||
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, "delivery-1", expect.objectContaining({ giftCode: "starter", playerId: "steam-1" }));
|
||||
expect(pluginData.delete).toHaveBeenCalledWith(scumCollections.gifts, "starter");
|
||||
});
|
||||
|
||||
it("persists event produces, event runs, and gift resets in plugin-owned collections", async () => {
|
||||
const pluginData = pluginDataActions();
|
||||
const gameClient = gameClientActions();
|
||||
const actions: SCUMWorkspaceActions = { pluginData, gameClient };
|
||||
await saveEventProduce(actions, { id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 });
|
||||
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventProduces, "event-1:produce-1", expect.objectContaining({ eventId: "event-1", tradeGoodsId: "goods-1" }));
|
||||
await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces);
|
||||
expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({
|
||||
eventType: "range", class: 1, placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2,
|
||||
produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }]
|
||||
}) }));
|
||||
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventRuns, expect.any(String), expect.objectContaining({ eventId: "event-1", status: "queued", produces: surfaceData.eventProduces }));
|
||||
await resetGiftClaim(actions, { _recordKey: "claim-1" });
|
||||
await resetPendingGift(actions, { _recordKey: "pending-1", status: "received", receivedAt: "now" });
|
||||
expect(pluginData.delete).toHaveBeenCalledWith(scumCollections.giftClaims, "claim-1");
|
||||
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.pendingGifts, "pending-1", expect.objectContaining({ status: "pending", receivedAt: null }));
|
||||
});
|
||||
|
||||
it("queues gift and event commands through the host-compatible generic gameClient bridge", async () => {
|
||||
const pluginData = pluginDataActions();
|
||||
const gameClient = gameClientActions();
|
||||
const actions: SCUMWorkspaceActions = { pluginData, gameClient };
|
||||
await queueGiftDelivery(actions, { ...surfaceData.gifts[0], operations: ["#announce Starter pack", "#SetFamePoints 250"] }, surfaceData.players[0]);
|
||||
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "reward.deliver", payload: expect.objectContaining({ playerId: "steam-1", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], operations: ["#announce Starter pack", "#SetFamePoints 250"] }) }));
|
||||
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, expect.any(String), expect.objectContaining({ giftCode: "starter-pack", playerId: "steam-1", status: "queued" }));
|
||||
await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces);
|
||||
expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "event.start", payload: expect.objectContaining({ eventId: "event-1", eventType: "range", class: 1, placard: "Event starting", percent: 75, produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }] }) }));
|
||||
expect(Object.keys(gameClient).sort()).toEqual(["get", "list", "queue", "snapshots"]);
|
||||
});
|
||||
|
||||
it("defaults activity class to range and strips collection metadata from queued produces", async () => {
|
||||
const pluginData = pluginDataActions();
|
||||
const gameClient = gameClientActions();
|
||||
await startEvent({ pluginData, gameClient }, { id: "event-default", name: "Default Event" }, [{
|
||||
_recordKey: "event-default:produce-1", id: "produce-1", eventId: "event-default", updatedAt: "2026-08-10T00:00:00Z",
|
||||
tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300
|
||||
}]);
|
||||
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({
|
||||
eventType: "range", class: 1, npc: 0, item: 0, zombie: 0, animal: 0,
|
||||
produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300 }]
|
||||
}) }));
|
||||
});
|
||||
|
||||
it("renders searchable user management from real collection values", () => {
|
||||
const view = renderAndCollect();
|
||||
expect(view.nodes).toContain("section:用户管理");
|
||||
expect(view.texts.join("\n")).toContain("登录日志和 SCUM.db typed observations");
|
||||
expect(view.texts).toContain("投影/Companion 可用");
|
||||
expect(view.texts).toContain("刷新投影");
|
||||
expect(view.texts).toContain("刷新真实数据");
|
||||
expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步");
|
||||
expect(view.texts).toContain("通用数据/机器动作可用");
|
||||
expect(view.buttons.find((button) => button.label === "同步 SCUM.db")?.disabled).toBe(false);
|
||||
expect(view.inputs.map((input) => input.label)).toContain("搜索用户");
|
||||
expect(view.texts).toContain("Mira");
|
||||
expect(view.texts.join("\n")).toContain("Steam 76561198000000001");
|
||||
expect(view.texts.join("\n")).toContain("Profile profile-1");
|
||||
expect(view.texts.join("\n")).toContain("Fame 42");
|
||||
expect(view.buttons.find((button) => button.label === "Fame +100")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "现金 +1000")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "855 审批")?.disabled).toBe(false);
|
||||
for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText);
|
||||
});
|
||||
|
||||
it("does not invent fake players when projections are empty", () => {
|
||||
const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } });
|
||||
expect(view.texts.join("\n")).toContain("暂无玩家投影");
|
||||
expect(view.texts.join("\n")).toContain("不会显示假玩家");
|
||||
it("does not invent users when the collection is empty", () => {
|
||||
const view = renderAndCollect({ data: { ...surfaceData, players: [] } });
|
||||
expect(view.texts.join("\n")).toContain("没有符合筛选条件的真实用户记录");
|
||||
expect(view.texts).not.toContain("Mira");
|
||||
expect(pageSource).not.toContain("fallbackFiles");
|
||||
expect(pageSource).not.toContain("samplePlayers");
|
||||
expect(pageSource).not.toContain("fallbackFiles");
|
||||
});
|
||||
|
||||
it("renders squad and flag governance from projections", () => {
|
||||
it("renders squad filtering, roster, and flag details", () => {
|
||||
const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" });
|
||||
expect(view.nodes).toContain("section:队伍管理");
|
||||
expect(view.texts).toContain("队伍");
|
||||
expect(view.texts).toContain("成员 / 旗帜");
|
||||
expect(view.inputs.map((input) => input.label)).toContain("搜索队伍");
|
||||
expect(view.texts).toContain("Wolves");
|
||||
expect(view.texts.join("\n")).toContain("成员 3");
|
||||
expect(view.texts).toContain("队伍成员");
|
||||
expect(view.texts).toContain("Mira");
|
||||
expect(view.texts.join("\n")).toContain("verified");
|
||||
});
|
||||
|
||||
it("renders realtime map overlays without sample coordinates", () => {
|
||||
it("renders activity definitions, status filters, runs, and records", () => {
|
||||
const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" });
|
||||
expect(view.inputs.map((input) => input.label)).toContain("活动状态");
|
||||
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
|
||||
expect(view.texts).toContain("Friday Range");
|
||||
expect(view.texts).toContain("running");
|
||||
expect(view.texts).toContain("最近活动记录");
|
||||
expect(view.texts).toContain("Mira");
|
||||
expect(view.texts).toContain("活动生成项");
|
||||
});
|
||||
|
||||
it("renders gift definitions, claims, and delivery records", () => {
|
||||
const definitions = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
|
||||
expect(definitions.texts).toContain("礼包定义");
|
||||
expect(definitions.texts).toContain("Starter Pack");
|
||||
expect(definitions.buttons.find((button) => button.label === "保存礼包")?.disabled).toBe(false);
|
||||
expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["礼包周期", "适用玩家", "发放次数", "成就类型", "成就值", "礼包物品", "礼包命令"]));
|
||||
const claims = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "claims" });
|
||||
expect(claims.texts).toContain("领取记录");
|
||||
expect(claims.texts).toContain("claimed");
|
||||
const deliveries = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "deliveries" });
|
||||
expect(deliveries.texts).toContain("发放记录");
|
||||
expect(deliveries.texts).toContain("delivered");
|
||||
expect(deliveries.buttons.find((button) => button.label === "立即发放")?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("renders map layers, filter controls, points, and selected-point details", () => {
|
||||
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
|
||||
expect(view.nodes).toContain("section:实时地图");
|
||||
expect(view.texts).toContain("地图覆盖物");
|
||||
expect(view.texts.join("\n")).toContain("坐标点");
|
||||
expect(view.texts.join("\n")).toContain("X 10 / Y 20 / Z 3");
|
||||
expect(pageSource).toContain("map-projection-board");
|
||||
expect(pageSource).not.toContain("sampleCoordinates");
|
||||
expect(view.nodes).toContain("div:SCUM 地图图层");
|
||||
expect(view.inputs.map((input) => input.label)).toContain("筛选地图点");
|
||||
for (const layer of ["用户", "载具", "旗帜", "区域", "其他"]) expect(view.texts).toContain(layer);
|
||||
expect(view.texts).toContain("地图点详情");
|
||||
expect(view.texts).toContain("Airfield");
|
||||
expect(view.texts.join("\n")).toContain("X 800 / Y 900 / Z 10");
|
||||
expect(pageSource).toContain('new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href');
|
||||
expect(view.elements.find((element) => element.label === "SCUM 地图图层")?.style?.backgroundImage).toContain("scum-map-overview.jpg");
|
||||
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["启用自定义地图", "地图中心 X", "地图中心 Y", "地图宽度公里", "地图高度公里"]));
|
||||
});
|
||||
|
||||
it("renders gift and workflow typed status surfaces", () => {
|
||||
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
|
||||
expect(gifts.nodes).toContain("section:礼包管理");
|
||||
expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
|
||||
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
|
||||
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
|
||||
|
||||
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
|
||||
expect(workflows.nodes).toContain("section:Workflow 状态");
|
||||
expect(workflows.texts.join("\n")).toContain("scum.world-refresh");
|
||||
expect(workflows.texts.join("\n")).toContain("read-positions");
|
||||
it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => {
|
||||
const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "steam-1", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] };
|
||||
expect(collectMapPoints(duplicateData)).toHaveLength(264);
|
||||
const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 });
|
||||
expect(bounds).toEqual({ worldMinX: -100000, worldMinY: 100000, worldMaxX: 300000, worldMaxY: 300000 });
|
||||
expect(mapPointStyle({ x: -100000, y: 100000 }, bounds)).toEqual({ left: "99%", top: "99%" });
|
||||
const pluginData = pluginDataActions();
|
||||
await saveMapSettings({ pluginData }, { customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 });
|
||||
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.mapSettings, "current", expect.objectContaining(bounds));
|
||||
expect(pageSource).not.toContain("visible.slice(0, 240)");
|
||||
});
|
||||
|
||||
it("loads plugin-owned projections through generic platform collections instead of file snapshots", () => {
|
||||
expect(pageSource).toContain("pluginData");
|
||||
expect(pageSource).toContain('"scum_users"');
|
||||
expect(pageSource).toContain('"scum_squads"');
|
||||
expect(pageSource).toContain('"scum_map_points"');
|
||||
expect(pageSource).not.toContain("getFileSnapshot");
|
||||
expect(pageSource).not.toContain("requestFile");
|
||||
expect(pageSource).not.toContain("writeFile");
|
||||
expect(pageSource).not.toContain("setInterval");
|
||||
it("contains no specialized host callbacks, raw SQL, machine paths, or fake-data branches", () => {
|
||||
const source = `${pageSource}\n${dataClientSource}`;
|
||||
for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "SELECT ", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden);
|
||||
expect(source).toContain("pluginData");
|
||||
expect(source).toContain("remote.access.request");
|
||||
expect(source).toContain("input.templateKey");
|
||||
});
|
||||
});
|
||||
|
||||
function renderAndCollect(options: { data?: typeof projectionData; permissions?: string[]; pageKey?: string; pageTitle?: string } = {}) {
|
||||
function pluginDataActions(overrides: Partial<{ list: (collection: string, key?: string) => Promise<unknown> }> = {}) {
|
||||
return {
|
||||
list: vi.fn(overrides.list ?? (async () => ({ items: [], count: 0 }))),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => undefined),
|
||||
transact: vi.fn(async () => ({ items: [], count: 0 }))
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchAction() {
|
||||
return vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ requestId: envelope.requestId, action: envelope.action, status: "queued" }));
|
||||
}
|
||||
|
||||
function gameClientActions() {
|
||||
const queue = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["queue"]>(async () => ({ id: "command-1", state: "pending" }));
|
||||
const get = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["get"]>(async () => ({ id: "command-1", state: "pending" }));
|
||||
const list = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["list"]>(async () => ({ items: [], count: 0 }));
|
||||
const snapshots = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["snapshots"]>(async () => ({ items: [], count: 0 }));
|
||||
return {
|
||||
queue, get, list, snapshots
|
||||
};
|
||||
}
|
||||
|
||||
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed" } = {}) {
|
||||
const nodes: string[] = [];
|
||||
const texts: string[] = [];
|
||||
const buttons: Array<{ label: string; disabled: boolean }> = [];
|
||||
const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = [];
|
||||
const inputs: Array<{ label: string; value: unknown; onChange?: (event: unknown) => void }> = [];
|
||||
const elements: Array<{ label: string; style?: Record<string, unknown> }> = [];
|
||||
const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); };
|
||||
let stateCall = 0;
|
||||
const react = {
|
||||
createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => {
|
||||
if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`);
|
||||
if (typeof type === "string") elements.push({ label: String(props?.["aria-label"] ?? ""), style: props?.style as Record<string, unknown> | undefined });
|
||||
children.forEach(collectText);
|
||||
if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled) });
|
||||
if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled), onClick: props?.onClick as (() => void) | undefined });
|
||||
if (type === "input" || type === "select" || type === "textarea") inputs.push({ label: String(props?.["aria-label"] ?? ""), value: props?.value ?? props?.checked, onChange: props?.onChange as ((event: unknown) => void) | undefined });
|
||||
return { type, props, children };
|
||||
},
|
||||
useEffect: () => undefined,
|
||||
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => {
|
||||
stateCall += 1;
|
||||
if (stateCall === 1) return [{ status: "ready", data: options.data ?? projectionData } as T, () => undefined];
|
||||
return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined];
|
||||
if (stateCall === 1) return [{ status: "ready", data: options.data ?? surfaceData } as T, () => undefined];
|
||||
const value = typeof initial === "function" ? (initial as () => T)() : initial;
|
||||
if (options.giftTab && value === "definitions") return [options.giftTab as T, () => undefined];
|
||||
return [value, () => undefined];
|
||||
}
|
||||
};
|
||||
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: dispatchAction() };
|
||||
renderPluginPage(react, {
|
||||
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] },
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },
|
||||
availability: { available: true, features: [{ key: "player.intelligence", available: true }] },
|
||||
workspaceActions: {
|
||||
listSCUMPlayers: async () => ({ items: projectionData.players, count: projectionData.players.length }),
|
||||
listSCUMSquads: async () => ({ items: projectionData.squads, count: projectionData.squads.length }),
|
||||
listSCUMSquadMembers: async () => ({ items: projectionData.members, count: projectionData.members.length }),
|
||||
listSCUMVehicles: async () => ({ items: projectionData.vehicles, count: projectionData.vehicles.length }),
|
||||
listSCUMFlags: async () => ({ items: projectionData.flags, count: projectionData.flags.length }),
|
||||
listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length }),
|
||||
listSCUMOperations: async () => ({ items: projectionData.operations, count: projectionData.operations.length }),
|
||||
listSCUMWorkflows: async () => ({ items: projectionData.workflows, count: projectionData.workflows.length }),
|
||||
listSCUMWorkflowSteps: async () => ({ items: projectionData.steps, count: projectionData.steps.length }),
|
||||
createSCUMOperation: async () => ({ id: "op-new", status: "waiting" }),
|
||||
createSCUMWorkflow: async () => ({ id: "wf-new", status: "queued" })
|
||||
}
|
||||
workspaceActions: actions
|
||||
});
|
||||
return { nodes, texts, buttons };
|
||||
return { nodes, texts, buttons, inputs, elements, actions };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user