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)
|
||||
Reference in New Issue
Block a user