Rebuild SCUM plugin-owned data flow
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -416,7 +417,7 @@ func rewardGrant(payload map[string]any) (RewardGrant, error) {
|
||||
playerID, playerOK := payload["playerId"].(string)
|
||||
rawItems, itemsOK := payload["items"].([]any)
|
||||
rawOperations, operationsOK := payload["operations"].([]any)
|
||||
if !grantOK || !playerOK || !itemsOK || !operationsOK || (len(rawItems) == 0 && len(rawOperations) == 0) || len(rawItems) > 8 {
|
||||
if !grantOK || !playerOK || !itemsOK || !operationsOK || (len(rawItems) == 0 && len(rawOperations) == 0) {
|
||||
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
|
||||
}
|
||||
items := make([]RewardItem, 0, len(rawItems))
|
||||
@@ -427,7 +428,7 @@ func rewardGrant(payload map[string]any) (RewardGrant, error) {
|
||||
}
|
||||
code, codeOK := item["catalogCode"].(string)
|
||||
quantity, quantityOK := integerPayloadValue(item["quantity"])
|
||||
if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 || quantity > 100 {
|
||||
if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 {
|
||||
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
|
||||
}
|
||||
items = append(items, RewardItem{CatalogCode: code, Quantity: quantity})
|
||||
@@ -456,7 +457,7 @@ func eventStartRequest(payload map[string]any) (EventStartRequest, error) {
|
||||
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 {
|
||||
if !ok || count < 0 {
|
||||
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
|
||||
}
|
||||
counts[index] = count
|
||||
@@ -466,7 +467,7 @@ func eventStartRequest(payload map[string]any) (EventStartRequest, error) {
|
||||
if value, exists := payload["maxParticipants"]; exists {
|
||||
var ok bool
|
||||
participants, ok = integerPayloadValue(value)
|
||||
if !ok || participants < 1 || participants > 1000 {
|
||||
if !ok || participants < 1 {
|
||||
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
|
||||
}
|
||||
}
|
||||
@@ -478,7 +479,7 @@ func eventStartRequest(payload map[string]any) (EventStartRequest, error) {
|
||||
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 {
|
||||
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 < 1 {
|
||||
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
|
||||
@@ -486,7 +487,7 @@ func eventStartRequest(payload map[string]any) (EventStartRequest, error) {
|
||||
|
||||
func eventProduceRequests(value any) ([]EventProduceRequest, bool) {
|
||||
raw, ok := value.([]any)
|
||||
if !ok || len(raw) > 100 {
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
result := make([]EventProduceRequest, 0, len(raw))
|
||||
@@ -502,7 +503,7 @@ func eventProduceRequests(value any) ([]EventProduceRequest, bool) {
|
||||
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 {
|
||||
if !idOK || strings.TrimSpace(tradeGoodsID) == "" || len(tradeGoodsID) > 128 || !percentOK || percent < 0 || percent > 100 || !quantityOK || quantity < 1 || !radiusOK || radius < 0 || !xOK || !yOK || !zOK {
|
||||
return nil, false
|
||||
}
|
||||
result = append(result, EventProduceRequest{TradeGoodsID: tradeGoodsID, Percent: percent, Value: quantity, Radius: radius, X: x, Y: y, Z: z})
|
||||
@@ -529,20 +530,22 @@ func integerPayloadValue(value any) (int, bool) {
|
||||
}
|
||||
|
||||
func numberPayloadValue(value any) (float64, bool) {
|
||||
var result float64
|
||||
switch number := value.(type) {
|
||||
case float64:
|
||||
return number, true
|
||||
result = number
|
||||
case float32:
|
||||
return float64(number), true
|
||||
result = float64(number)
|
||||
case int:
|
||||
return float64(number), true
|
||||
result = float64(number)
|
||||
case int32:
|
||||
return float64(number), true
|
||||
result = float64(number)
|
||||
case int64:
|
||||
return float64(number), true
|
||||
result = float64(number)
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
return result, !math.IsNaN(result) && !math.IsInf(result, 0)
|
||||
}
|
||||
|
||||
func supportedCatalogCode(value string) bool {
|
||||
|
||||
@@ -104,6 +104,13 @@ func TestRewardDeliveryAcceptsRealSCUMCatalogCodesAndReturnsDeclaredResult(t *te
|
||||
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)
|
||||
}
|
||||
manyItems := make([]any, 9)
|
||||
for index := range manyItems {
|
||||
manyItems[index] = map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(101 + index)}
|
||||
}
|
||||
if _, err := adapter.DeliverReward(context.Background(), map[string]any{"grantId": "grant-many", "playerId": "76561198000000001", "items": manyItems, "operations": []any{}}); err != nil || len(port.grants) != 2 || len(port.grants[1].Items) != 9 || port.grants[1].Items[0].Quantity != 101 {
|
||||
t.Fatalf("valid reward count or quantity was rejected: grants=%+v err=%v", port.grants, err)
|
||||
}
|
||||
before := len(port.grants)
|
||||
if _, err := adapter.DeliverReward(context.Background(), map[string]any{
|
||||
"grantId": "grant-2", "playerId": "76561198000000001",
|
||||
@@ -114,6 +121,17 @@ func TestRewardDeliveryAcceptsRealSCUMCatalogCodesAndReturnsDeclaredResult(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStartAcceptsPositiveCountsAndDurationWithoutInventedUpperLimits(t *testing.T) {
|
||||
produces := make([]any, 101)
|
||||
for index := range produces {
|
||||
produces[index] = map[string]any{"tradeGoodsId": "cargo-drop", "percent": float64(80), "value": float64(10001 + index), "r": float64(2000001 + index), "x": float64(3000000 + index), "y": float64(-3000000 - index), "z": float64(index)}
|
||||
}
|
||||
request, err := eventStartRequest(map[string]any{"eventId": "event-large", "eventType": "range", "class": float64(1), "title": "Large Event", "placard": "", "percent": float64(100), "npc": float64(10001), "item": float64(10002), "zombie": float64(10003), "animal": float64(10004), "produces": produces, "durationSeconds": float64(86401), "maxParticipants": float64(1001)})
|
||||
if err != nil || request.DurationSeconds != 86401 || request.MaxParticipants != 1001 || request.NPC != 10001 || len(request.Produces) != 101 || request.Produces[0].Value != 10001 || request.Produces[0].Radius != 2000001 {
|
||||
t.Fatalf("valid event values above old limits were rejected: request=%+v err=%v", request, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewardDeliverySupportsOperationsWithoutItemsAndRejectsEmptyGrant(t *testing.T) {
|
||||
port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered"}}
|
||||
adapter := RuntimeAdapter{BoundServerID: "server-1", Rewards: port}
|
||||
|
||||
@@ -17,9 +17,6 @@ export type PluginGameClientQueueRequest = {
|
||||
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?: {
|
||||
@@ -28,7 +25,6 @@ export type SCUMWorkspaceActions = {
|
||||
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 = {
|
||||
@@ -91,14 +87,6 @@ const pageCollections: Record<PageKey, SurfaceKey[]> = {
|
||||
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 };
|
||||
@@ -106,52 +94,32 @@ export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: st
|
||||
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);
|
||||
const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined);
|
||||
data.players = mergePlayerSnapshots(data.players, playersSnapshot);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown, sessionsResponse: unknown): RecordMap[] {
|
||||
export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] {
|
||||
const playerSnapshot = latestSnapshotPayload(playersResponse);
|
||||
const sessionSnapshot = latestSnapshotPayload(sessionsResponse);
|
||||
let merged = players.map((player) => ({ ...player }));
|
||||
const 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) };
|
||||
const match = findPlayer(byIdentity, snapshotPlayer);
|
||||
const value = { ...(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") });
|
||||
else {
|
||||
const created = { ...value, gamePlayerId: firstText(snapshotPlayer, "gamePlayerId", "playerId", "steamId", "id") };
|
||||
merged.push(created);
|
||||
addPlayerToIndex(byIdentity, created, merged.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 }]);
|
||||
@@ -234,9 +202,9 @@ export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap
|
||||
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),
|
||||
npc: minimumInteger(event.npc, 0, 0), item: minimumInteger(event.item, 0, 0), zombie: minimumInteger(event.zombie, 0, 0), animal: minimumInteger(event.animal, 0, 0),
|
||||
produces: queuedProduces,
|
||||
durationSeconds: boundedInteger(event.durationSeconds, 30, 86400, 1800), announce: event.announce !== false
|
||||
durationSeconds: minimumInteger(event.durationSeconds, 1, 1800), announce: event.announce !== false
|
||||
},
|
||||
idempotencyKey: runId,
|
||||
expiresAt: new Date(now + 5 * 60_000).toISOString()
|
||||
@@ -250,10 +218,9 @@ export function parseGiftItems(input: string): Array<{ catalogCode: string; quan
|
||||
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。");
|
||||
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(rawKey) || !rawQuantity || extra.length || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品格式无效,请使用 SCUM 目录代码:正整数数量。");
|
||||
return { catalogCode: rawKey, quantity };
|
||||
});
|
||||
if (items.length > 8) throw new Error("单个礼包最多包含 8 项物品。");
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -308,9 +275,15 @@ function latestSnapshotPayload(response: unknown): RecordMap | 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 playerIndex(players: RecordMap[]): Map<string, number> { const result = new Map<string, number>(); players.forEach((player, index) => addPlayerToIndex(result, player, index)); return result; }
|
||||
function addPlayerToIndex(index: Map<string, number>, player: RecordMap, playerIndex: number): void { playerIdentities(player).forEach((identity) => index.set(identity, playerIndex)); }
|
||||
function findPlayer(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 }; } return undefined; }
|
||||
function playerIdentities(player: RecordMap): string[] {
|
||||
const identities = new Set<string>();
|
||||
for (const key of ["gamePlayerId", "playerId", "steamId", "id"]) { const value = textValue(player[key]); if (value) identities.add(`player:${value}`); }
|
||||
for (const key of ["userProfileId", "profileId"]) { const value = textValue(player[key]); if (value) identities.add(`profile:${value}`); }
|
||||
return [...identities];
|
||||
}
|
||||
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"; }
|
||||
|
||||
@@ -329,17 +302,19 @@ function requiredRecordKey(value: RecordMap, label: string): string { const 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;
|
||||
return Number.isSafeInteger(number) && number >= min && number <= max ? number : fallback;
|
||||
}
|
||||
|
||||
function minimumInteger(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isSafeInteger(number) && number >= min ? 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 项。");
|
||||
if (!Array.isArray(value)) throw new Error("礼包物品格式无效。");
|
||||
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 目录代码或数量约束。");
|
||||
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(catalogCode) || !Number.isSafeInteger(quantity) || quantity < 1) throw new Error("礼包物品不符合 SCUM 目录代码或正整数数量约束。");
|
||||
return { catalogCode, quantity };
|
||||
});
|
||||
}
|
||||
@@ -350,15 +325,16 @@ 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)
|
||||
value: minimumInteger(produce.value, 1, 1),
|
||||
r: minimumNumber(produce.r, 0, 0),
|
||||
x: finiteNumber(produce.x, 0),
|
||||
y: finiteNumber(produce.y, 0),
|
||||
z: finiteNumber(produce.z, 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 minimumNumber(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min ? number : fallback; }
|
||||
function finiteNumber(value: unknown, fallback: number): number { const number = Number(value); return Number.isFinite(number) ? 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 ""; }
|
||||
|
||||
@@ -3,12 +3,10 @@ import {
|
||||
deleteEventDefinition,
|
||||
deleteEventProduce,
|
||||
emptySCUMSurfaceData,
|
||||
hasSCUMPageQueries,
|
||||
loadSCUMSurface,
|
||||
parseGiftItems,
|
||||
parseGiftCommands,
|
||||
queueGiftDelivery,
|
||||
requestSCUMPageQueries,
|
||||
resetGiftClaim,
|
||||
resetPendingGift,
|
||||
resolveMapBounds,
|
||||
@@ -63,6 +61,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
const [eventClass, setEventClass] = usePluginState(react, "1");
|
||||
const [eventPlacard, setEventPlacard] = usePluginState(react, "");
|
||||
const [eventPercent, setEventPercent] = usePluginState(react, "100");
|
||||
const [eventDuration, setEventDuration] = usePluginState(react, "1800");
|
||||
const [eventNpc, setEventNpc] = usePluginState(react, "0");
|
||||
const [eventItem, setEventItem] = usePluginState(react, "0");
|
||||
const [eventZombie, setEventZombie] = usePluginState(react, "0");
|
||||
@@ -103,32 +102,23 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" });
|
||||
return;
|
||||
}
|
||||
setState({ status: "loading" });
|
||||
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]);
|
||||
if (react.useEffect) react.useEffect(() => {
|
||||
refresh();
|
||||
const interval = setInterval(refresh, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [input.serverInstanceId, pageKey, input.workspaceActions]);
|
||||
|
||||
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 ? "通用数据/机器动作可用" : 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
|
||||
e("span", { className: "page-status" }, input.availability.available ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion")
|
||||
)
|
||||
),
|
||||
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
|
||||
@@ -137,7 +127,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
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,
|
||||
eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, 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,
|
||||
@@ -154,6 +144,7 @@ type ViewState = {
|
||||
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>;
|
||||
eventDuration: string; setEventDuration: 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>;
|
||||
@@ -238,8 +229,8 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
|
||||
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()
|
||||
npc: minimumIntegerInput(view.eventNpc, 0, "NPC 数量"), item: minimumIntegerInput(view.eventItem, 0, "物品数量"), zombie: minimumIntegerInput(view.eventZombie, 0, "僵尸数量"), animal: minimumIntegerInput(view.eventAnimal, 0, "动物数量"),
|
||||
status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: minimumIntegerInput(view.eventDuration, 1, "活动持续秒数"), updatedAt: new Date().toISOString()
|
||||
});
|
||||
view.refresh();
|
||||
return `活动 ${name} 已保存。`;
|
||||
@@ -266,6 +257,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
|
||||
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("input", { value: view.eventDuration, "aria-label": "活动持续秒数", type: "number", min: 1, placeholder: "持续秒数", onChange: (event: InputEvent) => view.setEventDuration(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)) }),
|
||||
@@ -307,7 +299,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
|
||||
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", 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.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); 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 "活动定义已删除。"; }) }, "删除")
|
||||
)
|
||||
);
|
||||
@@ -436,14 +428,15 @@ function resettableGiftPanel(e: ReactLike["createElement"], title: string, rows:
|
||||
|
||||
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 players = data.players.flatMap((player) => withPosition(player, "players", textField(player, "displayName"), textField(player, "steamId", "gamePlayerId", "id")));
|
||||
const vehicles = data.vehicles.flatMap((vehicle) => withPosition(vehicle, "vehicles", textField(vehicle, "label", "name"), textField(vehicle, "vehicleId", "id")));
|
||||
const 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);
|
||||
const current = uniquePoints.get(key);
|
||||
uniquePoints.set(key, current ? { ...point, ...current, name: textField(point, "name") || textField(current, "name") } : point);
|
||||
}
|
||||
return [...uniquePoints.values()];
|
||||
}
|
||||
@@ -485,6 +478,7 @@ function giftItemsInput(gift: RecordMap): string { const items = field(gift, "it
|
||||
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 minimumIntegerInput(value: string, minimum: number, label: string): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error(`${label}必须是不小于 ${minimum} 的整数。`); return parsed; }
|
||||
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; }
|
||||
|
||||
@@ -127,11 +127,18 @@
|
||||
"type": "announcement.send",
|
||||
"title": "Send SCUM announcement",
|
||||
"permission": "server.game-client.command",
|
||||
"approvalLevel": "none",
|
||||
"approvalLevel": "operator",
|
||||
"payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/announcement.result.schema.json",
|
||||
"timeoutSeconds": 60,
|
||||
"maxPayloadBytes": 4096
|
||||
"maxPayloadBytes": 4096,
|
||||
"protectedRequest": {
|
||||
"kind": "rcon",
|
||||
"transportKey": "scum-management",
|
||||
"targetKey": "scum-management",
|
||||
"textField": "requestText",
|
||||
"maxTextBytes": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "companion.diagnostics",
|
||||
@@ -293,9 +300,11 @@
|
||||
"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",
|
||||
"pollIntervalSeconds": 3,
|
||||
"rowTarget": {
|
||||
"collection": "scum_users",
|
||||
"upsertKeys": ["userProfileId"],
|
||||
"upsertKeys": ["steamId"],
|
||||
"writeMode": "merge",
|
||||
"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,
|
||||
@@ -311,9 +320,11 @@
|
||||
"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",
|
||||
"pollIntervalSeconds": 1800,
|
||||
"rowTarget": {
|
||||
"collection": "scum_squads",
|
||||
"upsertKeys": ["squadId"],
|
||||
"writeMode": "replace",
|
||||
"columnMappings": { "squadId": "squadId", "name": "name", "leaderProfileId": "leaderProfileId", "leaderPlayerId": "leaderPlayerId", "memberCount": "memberCount", "score": "score", "memberLimit": "memberLimit", "message": "message", "info": "info", "lastMemberLoginTime": "lastMemberLoginTime" }
|
||||
},
|
||||
"maxRows": 500,
|
||||
@@ -329,9 +340,11 @@
|
||||
"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",
|
||||
"pollIntervalSeconds": 1800,
|
||||
"rowTarget": {
|
||||
"collection": "scum_squad_members",
|
||||
"upsertKeys": ["squadId", "userProfileId"],
|
||||
"upsertKeys": ["squadId", "steamId"],
|
||||
"writeMode": "replace",
|
||||
"columnMappings": { "squadId": "squadId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "steamId": "steamId", "displayName": "displayName", "rank": "rank", "isLeader": "isLeader" }
|
||||
},
|
||||
"maxRows": 500,
|
||||
@@ -347,9 +360,11 @@
|
||||
"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",
|
||||
"pollIntervalSeconds": 3,
|
||||
"rowTarget": {
|
||||
"collection": "scum_vehicles",
|
||||
"upsertKeys": ["vehicleId"],
|
||||
"writeMode": "replace",
|
||||
"columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" }
|
||||
},
|
||||
"maxRows": 500,
|
||||
@@ -365,9 +380,11 @@
|
||||
"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",
|
||||
"pollIntervalSeconds": 1800,
|
||||
"rowTarget": {
|
||||
"collection": "scum_flags",
|
||||
"upsertKeys": ["flagId"],
|
||||
"writeMode": "replace",
|
||||
"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,
|
||||
@@ -383,9 +400,11 @@
|
||||
"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",
|
||||
"pollIntervalSeconds": 3,
|
||||
"rowTarget": {
|
||||
"collection": "scum_map_points",
|
||||
"upsertKeys": ["subjectType", "subjectId"],
|
||||
"writeMode": "replace",
|
||||
"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,
|
||||
@@ -401,9 +420,11 @@
|
||||
"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",
|
||||
"pollIntervalSeconds": 1800,
|
||||
"rowTarget": {
|
||||
"collection": "scum_tasks",
|
||||
"upsertKeys": ["taskRecordId"],
|
||||
"writeMode": "replace",
|
||||
"columnMappings": { "taskRecordId": "taskRecordId", "taskKind": "taskKind", "userProfileId": "userProfileId", "mapId": "mapId", "trackingDataSetId": "trackingDataSetId", "dataAssetPath": "dataAssetPath", "sequenceIndex": "sequenceIndex", "isTracked": "isTracked", "state": "state", "completionDeadline": "completionDeadline" }
|
||||
},
|
||||
"maxRows": 500,
|
||||
@@ -419,9 +440,11 @@
|
||||
"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",
|
||||
"pollIntervalSeconds": 1800,
|
||||
"rowTarget": {
|
||||
"collection": "scum_native_event_rounds",
|
||||
"upsertKeys": ["eventRecordId"],
|
||||
"writeMode": "replace",
|
||||
"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,
|
||||
@@ -437,15 +460,54 @@
|
||||
"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",
|
||||
"pollIntervalSeconds": 1800,
|
||||
"rowTarget": {
|
||||
"collection": "scum_timed_gift_events",
|
||||
"upsertKeys": ["timedGiftId"],
|
||||
"writeMode": "replace",
|
||||
"columnMappings": { "timedGiftId": "timedGiftId", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt" }
|
||||
},
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
}
|
||||
],
|
||||
"logProjections": [
|
||||
{
|
||||
"key": "scum.battleye.login",
|
||||
"streamKeys": ["scum.console.stdout"],
|
||||
"steps": [
|
||||
{ "pattern": "Player \"(?P<displayName>[^\"]+)\" reported as player (?P<slot>\\d+)" },
|
||||
{ "pattern": "Player (?P<slot>\\d+) SteamID \\(assumed\\): (?P<steamId>\\d+)" }
|
||||
],
|
||||
"correlationFields": ["slot"],
|
||||
"maxInterveningLines": 8,
|
||||
"target": {
|
||||
"collection": "scum_users",
|
||||
"upsertKeys": ["steamId"],
|
||||
"captureMappings": { "steamId": "steamId", "displayName": "displayName", "slot": "slot" },
|
||||
"fixedValues": { "online": "true", "source": "process.stdout" },
|
||||
"observedAtField": "lastLoginObservedAt"
|
||||
},
|
||||
"presence": {
|
||||
"timestampField": "lastLoginObservedAt",
|
||||
"activeWindowSeconds": 600,
|
||||
"activityTarget": {
|
||||
"collection": "scum_activity_events",
|
||||
"upsertKeys": ["steamId", "observedAt"],
|
||||
"captureMappings": { "steamId": "steamId", "displayName": "displayName" },
|
||||
"fixedValues": { "eventType": "login", "source": "process.stdout" },
|
||||
"observedAtField": "observedAt"
|
||||
},
|
||||
"announcement": {
|
||||
"profileKey": "scum-client-manager",
|
||||
"commandType": "announcement.send",
|
||||
"textField": "requestText",
|
||||
"newTextTemplate": "#announce 欢迎新玩家 {{displayName}} 加入服务器!",
|
||||
"returningTextTemplate": "#announce 欢迎 {{displayName}} 继续游戏!"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"dataPacks": [
|
||||
{
|
||||
"key": "scum-db-v57",
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"title": "SCUMAnnouncementPayload",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["message"],
|
||||
"required": ["requestText"],
|
||||
"properties": {
|
||||
"message": {
|
||||
"requestText": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
"maxLength": 2048
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-14
@@ -23,13 +23,11 @@
|
||||
},
|
||||
"durationSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 30,
|
||||
"maximum": 86400
|
||||
"minimum": 1
|
||||
},
|
||||
"maxParticipants": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000
|
||||
"minimum": 1
|
||||
},
|
||||
"announce": {
|
||||
"type": "boolean"
|
||||
@@ -43,13 +41,12 @@
|
||||
"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 },
|
||||
"npc": { "type": "integer", "minimum": 0 },
|
||||
"item": { "type": "integer", "minimum": 0 },
|
||||
"zombie": { "type": "integer", "minimum": 0 },
|
||||
"animal": { "type": "integer", "minimum": 0 },
|
||||
"produces": {
|
||||
"type": "array",
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -57,11 +54,11 @@
|
||||
"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 }
|
||||
"value": { "type": "integer", "minimum": 1 },
|
||||
"r": { "type": "number", "minimum": 0 },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"z": { "type": "number" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"required": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"],
|
||||
"properties": {
|
||||
"gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"userProfileId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
|
||||
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"squadId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"gamePlayerId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
|
||||
"steamId": { "type": ["string", "null"], "minLength": 1, "maxLength": 96 },
|
||||
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"rank": { "type": ["string", "null"], "minLength": 1, "maxLength": 32 },
|
||||
"isLeader": { "type": "integer", "minimum": 0, "maximum": 1 }
|
||||
|
||||
+1
-3
@@ -17,20 +17,18 @@
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["catalogCode", "quantity"],
|
||||
"properties": {
|
||||
"catalogCode": { "type": "string", "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]{1,128}$" },
|
||||
"quantity": { "type": "integer", "minimum": 1, "maximum": 100 }
|
||||
"quantity": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
SELECT
|
||||
'player' AS subjectType,
|
||||
CAST(profile.id AS TEXT) AS subjectId,
|
||||
account.id AS subjectId,
|
||||
CAST(profile.id AS TEXT) AS userProfileId,
|
||||
CAST(prisoner.id AS TEXT) AS gamePlayerId,
|
||||
NULL AS vehicleId,
|
||||
@@ -11,11 +11,12 @@ SELECT
|
||||
entity.location_z AS z,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt
|
||||
FROM user_profile profile
|
||||
JOIN user account ON account.id = profile.user_id
|
||||
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)
|
||||
AND (:subjectId IS NULL OR account.id = :subjectId)
|
||||
UNION ALL
|
||||
SELECT
|
||||
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL,
|
||||
|
||||
@@ -8,7 +8,7 @@ SELECT
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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,
|
||||
MAX(CASE WHEN member.rank = 4 THEN CAST(member.user_profile_id AS TEXT) END) AS leaderProfileId,
|
||||
MAX(CASE WHEN member.rank = 4 THEN CAST(member_profile.prisoner_id AS TEXT) END) AS leaderPlayerId,
|
||||
COUNT(member.id) AS memberCount,
|
||||
squad.score AS score,
|
||||
squad.member_limit AS memberLimit,
|
||||
@@ -11,8 +11,7 @@ SELECT
|
||||
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
|
||||
LEFT JOIN user_profile member_profile ON member_profile.id = member.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
|
||||
|
||||
@@ -2,7 +2,7 @@ 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,
|
||||
COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) AS displayName,
|
||||
CAST(member.squad_id AS TEXT) AS squadId,
|
||||
squad.name AS squadName,
|
||||
profile.fame_points AS famePoints,
|
||||
@@ -13,8 +13,8 @@ SELECT
|
||||
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
|
||||
FROM user account
|
||||
LEFT JOIN user_profile profile ON profile.user_id = account.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
|
||||
@@ -24,7 +24,7 @@ LEFT JOIN bank_account_registry bank ON bank.account_owner_user_profile_id = pro
|
||||
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
|
||||
AND (:search IS NULL OR COALESCE(NULLIF(profile.name, ''), NULLIF(account.name, ''), account.id) LIKE '%' || :search || '%')
|
||||
GROUP BY account.id
|
||||
ORDER BY profile.last_login_time DESC
|
||||
LIMIT COALESCE(:limit, 500)
|
||||
|
||||
@@ -259,6 +259,11 @@
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" },
|
||||
"maxItems": 128
|
||||
},
|
||||
"logProjections": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeLogProjection" },
|
||||
"maxItems": 128
|
||||
},
|
||||
"dataPacks": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeDataPack" },
|
||||
@@ -356,17 +361,76 @@
|
||||
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
|
||||
"rowTarget": { "$ref": "#/$defs/pluginDataRowTarget" },
|
||||
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
|
||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
|
||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 },
|
||||
"pollIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 }
|
||||
}
|
||||
},
|
||||
"pluginDataRowTarget": {
|
||||
"type": "object",
|
||||
"required": ["collection", "upsertKeys", "columnMappings"],
|
||||
"required": ["collection", "upsertKeys", "columnMappings", "writeMode"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" },
|
||||
"upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 },
|
||||
"columnMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } }
|
||||
"columnMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } },
|
||||
"writeMode": { "enum": ["merge", "replace"] }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeLogProjection": {
|
||||
"type": "object",
|
||||
"required": ["key", "streamKeys", "steps", "correlationFields", "maxInterveningLines", "target"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
|
||||
"streamKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 },
|
||||
"steps": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeLogProjectionStep" }, "minItems": 1, "maxItems": 64 },
|
||||
"correlationFields": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 64 },
|
||||
"maxInterveningLines": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
||||
"target": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" },
|
||||
"presence": { "$ref": "#/$defs/gameClientBridgeLogProjectionPresence" }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeLogProjectionStep": {
|
||||
"type": "object",
|
||||
"required": ["pattern"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pattern": { "type": "string", "minLength": 1, "maxLength": 16384 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeLogProjectionTarget": {
|
||||
"type": "object",
|
||||
"required": ["collection", "upsertKeys", "captureMappings"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" },
|
||||
"upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 },
|
||||
"captureMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,79}$" } },
|
||||
"fixedValues": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } },
|
||||
"observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeLogProjectionPresence": {
|
||||
"type": "object",
|
||||
"required": ["timestampField", "activeWindowSeconds", "announcement"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"timestampField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
|
||||
"activeWindowSeconds": { "type": "integer", "minimum": 1, "maximum": 31536000 },
|
||||
"activityTarget": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" },
|
||||
"announcement": { "$ref": "#/$defs/gameClientBridgeLogProjectionAnnouncement" }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeLogProjectionAnnouncement": {
|
||||
"type": "object",
|
||||
"required": ["profileKey", "commandType", "textField", "newTextTemplate", "returningTextTemplate"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"profileKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"commandType": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
|
||||
"textField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
|
||||
"newTextTemplate": { "type": "string", "minLength": 1, "maxLength": 4096 },
|
||||
"returningTextTemplate": { "type": "string", "minLength": 1, "maxLength": 4096 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeDataPack": {
|
||||
|
||||
@@ -17,6 +17,19 @@ function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined):
|
||||
return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
function extractNamedCaptureNames(pattern: string): string[] {
|
||||
const captures: string[] = [];
|
||||
const capturePattern = /\(\?(?:P)?<([A-Za-z][A-Za-z0-9_]*)>/g;
|
||||
for (const match of pattern.matchAll(capturePattern)) {
|
||||
captures.push(match[1]);
|
||||
}
|
||||
return captures;
|
||||
}
|
||||
|
||||
function normalizeNamedCaptureSyntax(pattern: string): string {
|
||||
return pattern.replace(/\(\?P<([A-Za-z][A-Za-z0-9_]*)>/g, "(?<$1>");
|
||||
}
|
||||
|
||||
function unsafeFieldReason(fieldName: string): string | undefined {
|
||||
const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) {
|
||||
@@ -673,9 +686,25 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
parameterSchemaRef?: string;
|
||||
resultSchemaRef?: string;
|
||||
sqlRef?: string;
|
||||
rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record<string, string> };
|
||||
rowTarget?: { collection?: string; upsertKeys?: string[]; columnMappings?: Record<string, string>; writeMode?: string };
|
||||
maxRows?: number;
|
||||
timeoutSeconds?: number;
|
||||
pollIntervalSeconds?: number;
|
||||
};
|
||||
type BridgeLogProjectionTarget = { collection?: string; upsertKeys?: string[]; captureMappings?: Record<string, string>; fixedValues?: Record<string, string>; observedAtField?: string };
|
||||
type BridgeLogProjection = {
|
||||
key?: string;
|
||||
streamKeys?: string[];
|
||||
steps?: Array<{ pattern?: string }>;
|
||||
correlationFields?: string[];
|
||||
maxInterveningLines?: number;
|
||||
target?: BridgeLogProjectionTarget;
|
||||
presence?: {
|
||||
timestampField?: string;
|
||||
activeWindowSeconds?: number;
|
||||
activityTarget?: BridgeLogProjectionTarget;
|
||||
announcement?: { profileKey?: string; commandType?: string; textField?: string; newTextTemplate?: string; returningTextTemplate?: string };
|
||||
};
|
||||
};
|
||||
type BridgeOperationSafety = { requiresApproval?: boolean; requiresOfflinePlayer?: boolean; requiresMaintenanceWindow?: boolean; requiresBeforeValue?: boolean; requiresConfirmation?: boolean; backupRequired?: boolean };
|
||||
type BridgeOperationMutation = { fieldKey?: string; tableKey?: string; identityKey?: string; valueKey?: string; confirmationQueryKey?: string; allowedValueType?: string; minValue?: number; maxValue?: number };
|
||||
@@ -718,7 +747,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
pages?: PluginPage[];
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; logProjections?: BridgeLogProjection[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
@@ -728,6 +757,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
const commands = new Set<string>();
|
||||
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
|
||||
const queryTemplates = new Map<string, BridgeQueryTemplate>();
|
||||
const logProjections = new Set<string>();
|
||||
const operationTemplates = new Map<string, BridgeOperationTemplate>();
|
||||
const declaredPermissions = new Set(declaration.permissions ?? []);
|
||||
const declaredCapabilities = new Set(declaration.capabilities ?? []);
|
||||
@@ -851,6 +881,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
const mappings = target?.columnMappings;
|
||||
if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([destination, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(destination) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) errors.push(`${location}.rowTarget.columnMappings: projected queries require safe field mappings`);
|
||||
if (mappings && Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => key in mappings)) errors.push(`${location}.rowTarget.upsertKeys: every upsert key must be declared in columnMappings`);
|
||||
if (!new Set(["merge", "replace"]).has(target?.writeMode ?? "")) errors.push(`${location}.rowTarget.writeMode: projected queries require merge or replace`);
|
||||
}
|
||||
if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) {
|
||||
errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
|
||||
@@ -858,6 +889,9 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
if (!Number.isInteger(queryTemplate.timeoutSeconds) || (queryTemplate.timeoutSeconds ?? 0) < 1 || (queryTemplate.timeoutSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.timeoutSeconds: must be an integer between 1 and 60`);
|
||||
}
|
||||
if (!Number.isInteger(queryTemplate.pollIntervalSeconds ?? 0) || (queryTemplate.pollIntervalSeconds ?? 0) < 0 || (queryTemplate.pollIntervalSeconds ?? 0) > 86400) {
|
||||
errors.push(`${location}.pollIntervalSeconds: must be 0 or an integer between 1 and 86400`);
|
||||
}
|
||||
const transportProfile = transportProfiles.find((profile) => profile.key === queryTemplate.transportKey);
|
||||
if (!transportProfile) {
|
||||
errors.push(`${location}.transportKey: undeclared transport profile ${queryTemplate.transportKey ?? ""}`);
|
||||
@@ -876,6 +910,71 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
|
||||
}
|
||||
}
|
||||
const captureNamePattern = /^[A-Za-z][A-Za-z0-9_]{0,79}$/;
|
||||
const fieldNamePattern = /^[A-Za-z][A-Za-z0-9._-]{0,79}$/;
|
||||
const collectionPattern = /^[A-Za-z][A-Za-z0-9._-]{0,119}$/;
|
||||
const validateProjectionTarget = (location: string, target: BridgeLogProjectionTarget | undefined, captures: Set<string>): string[] => {
|
||||
const targetErrors: string[] = [];
|
||||
if (!target || !collectionPattern.test(target.collection ?? "")) targetErrors.push(`${location}.collection: must be a safe collection`);
|
||||
if (!Array.isArray(target?.upsertKeys) || target.upsertKeys.length < 1 || target.upsertKeys.length > 8 || !target.upsertKeys.every((key) => fieldNamePattern.test(key))) targetErrors.push(`${location}.upsertKeys: must contain 1 to 8 safe fields`);
|
||||
const mappings = target?.captureMappings;
|
||||
if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length < 1 || Object.keys(mappings).length > 64) {
|
||||
targetErrors.push(`${location}.captureMappings: must contain 1 to 64 mappings`);
|
||||
} else {
|
||||
for (const [destination, capture] of Object.entries(mappings)) {
|
||||
if (!fieldNamePattern.test(destination) || typeof capture !== "string" || !captureNamePattern.test(capture)) targetErrors.push(`${location}.captureMappings: contains an invalid field or capture`);
|
||||
if (!captures.has(capture)) targetErrors.push(`${location}.captureMappings: references undeclared capture ${capture}`);
|
||||
}
|
||||
}
|
||||
const fixedValues = target?.fixedValues ?? {};
|
||||
if (Array.isArray(fixedValues) || Object.keys(fixedValues).length > 64 || !Object.entries(fixedValues).every(([destination, value]) => fieldNamePattern.test(destination) && typeof value === "string" && value.length <= 4096)) targetErrors.push(`${location}.fixedValues: contains an invalid field or value`);
|
||||
const declaredFields = new Set([...Object.keys(mappings ?? {}), ...Object.keys(fixedValues)]);
|
||||
if (Object.keys(mappings ?? {}).some((field) => Object.prototype.hasOwnProperty.call(fixedValues, field))) targetErrors.push(`${location}: a field cannot be declared by both captureMappings and fixedValues`);
|
||||
if (target?.observedAtField && (!fieldNamePattern.test(target.observedAtField) || declaredFields.has(target.observedAtField))) targetErrors.push(`${location}.observedAtField: must be a safe unique field`);
|
||||
if (target?.observedAtField) declaredFields.add(target.observedAtField);
|
||||
if (Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => declaredFields.has(key))) targetErrors.push(`${location}.upsertKeys: every key must be projected`);
|
||||
return targetErrors;
|
||||
};
|
||||
for (const [index, projection] of (bridge.logProjections ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.logProjections[${index}]`;
|
||||
const key = projection.key ?? "";
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key)) errors.push(`${location}.key: log projection key is unsafe`);
|
||||
if (logProjections.has(key)) errors.push(`${location}.key: duplicate log projection ${key}`);
|
||||
logProjections.add(key);
|
||||
if (!Array.isArray(projection.streamKeys) || projection.streamKeys.length < 1 || projection.streamKeys.length > 64 || new Set(projection.streamKeys).size !== projection.streamKeys.length || !projection.streamKeys.every((streamKey) => /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(streamKey))) errors.push(`${location}.streamKeys: must contain 1 to 64 unique safe streams`);
|
||||
const captures = new Set<string>();
|
||||
if (!Array.isArray(projection.steps) || projection.steps.length < 1 || projection.steps.length > 64) {
|
||||
errors.push(`${location}.steps: must contain 1 to 64 regular expressions`);
|
||||
} else {
|
||||
for (const [stepIndex, step] of projection.steps.entries()) {
|
||||
try {
|
||||
if (!step.pattern || step.pattern.length > 16384) throw new Error("invalid");
|
||||
new RegExp(normalizeNamedCaptureSyntax(step.pattern));
|
||||
for (const capture of extractNamedCaptureNames(step.pattern)) captures.add(capture);
|
||||
} catch {
|
||||
errors.push(`${location}.steps[${stepIndex}].pattern: must be a valid bounded regular expression`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(projection.correlationFields) || projection.correlationFields.length < 1 || projection.correlationFields.length > 64 || new Set(projection.correlationFields).size !== projection.correlationFields.length || !projection.correlationFields.every((field) => captureNamePattern.test(field) && captures.has(field))) errors.push(`${location}.correlationFields: must reference unique named captures`);
|
||||
if (!Number.isInteger(projection.maxInterveningLines) || (projection.maxInterveningLines ?? -1) < 0 || (projection.maxInterveningLines ?? 0) > 100000) errors.push(`${location}.maxInterveningLines: must be between 0 and 100000`);
|
||||
errors.push(...validateProjectionTarget(`${location}.target`, projection.target, captures));
|
||||
const presence = projection.presence;
|
||||
if (!presence) continue;
|
||||
const target = projection.target;
|
||||
const targetFields = new Set([...Object.keys(target?.captureMappings ?? {}), ...Object.keys(target?.fixedValues ?? {}), ...(target?.observedAtField ? [target.observedAtField] : [])]);
|
||||
if (!fieldNamePattern.test(presence.timestampField ?? "") || !targetFields.has(presence.timestampField ?? "")) errors.push(`${location}.presence.timestampField: must reference a projected target field`);
|
||||
if (!Number.isInteger(presence.activeWindowSeconds) || (presence.activeWindowSeconds ?? 0) < 1 || (presence.activeWindowSeconds ?? 0) > 31536000) errors.push(`${location}.presence.activeWindowSeconds: must be between 1 and 31536000`);
|
||||
if (presence.activityTarget) errors.push(...validateProjectionTarget(`${location}.presence.activityTarget`, presence.activityTarget, captures));
|
||||
const announcement = presence.announcement;
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === announcement?.profileKey && candidate.health?.requiredCapabilities?.includes("game-client.bridge"));
|
||||
if (!manager) errors.push(`${location}.presence.announcement.profileKey: must reference a declared game-client bridge profile`);
|
||||
const command = (bridge.commands ?? []).find((candidate) => candidate.type === announcement?.commandType);
|
||||
if (!command) errors.push(`${location}.presence.announcement.commandType: must reference a declared command`);
|
||||
if (!fieldNamePattern.test(announcement?.textField ?? "") || (command?.protectedRequest && command.protectedRequest.textField !== announcement?.textField)) errors.push(`${location}.presence.announcement.textField: must be safe and match the command protected request`);
|
||||
if (!announcement?.newTextTemplate || announcement.newTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.newTextTemplate: must be a non-empty bounded template`);
|
||||
if (!announcement?.returningTextTemplate || announcement.returningTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.returningTextTemplate: must be a non-empty bounded template`);
|
||||
}
|
||||
for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.operationTemplates[${index}]`;
|
||||
const key = operationTemplate.key ?? "";
|
||||
|
||||
@@ -265,6 +265,7 @@ export interface GameClientBridgeQueryTemplateDeclaration {
|
||||
sqlRef?: string;
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
pollIntervalSeconds?: number;
|
||||
rowTarget?: PluginDataRowTargetDeclaration;
|
||||
}
|
||||
|
||||
@@ -272,6 +273,44 @@ export interface PluginDataRowTargetDeclaration {
|
||||
collection: string;
|
||||
upsertKeys: string[];
|
||||
columnMappings: Record<string, string>;
|
||||
writeMode: "merge" | "replace";
|
||||
}
|
||||
|
||||
export interface GameClientBridgeLogProjectionStepDeclaration {
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeLogProjectionTargetDeclaration {
|
||||
collection: string;
|
||||
upsertKeys: string[];
|
||||
captureMappings: Record<string, string>;
|
||||
fixedValues?: Record<string, string>;
|
||||
observedAtField?: string;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeLogProjectionAnnouncementDeclaration {
|
||||
profileKey: string;
|
||||
commandType: string;
|
||||
textField: string;
|
||||
newTextTemplate: string;
|
||||
returningTextTemplate: string;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeLogProjectionPresenceDeclaration {
|
||||
timestampField: string;
|
||||
activeWindowSeconds: number;
|
||||
activityTarget?: GameClientBridgeLogProjectionTargetDeclaration;
|
||||
announcement: GameClientBridgeLogProjectionAnnouncementDeclaration;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeLogProjectionDeclaration {
|
||||
key: string;
|
||||
streamKeys: string[];
|
||||
steps: GameClientBridgeLogProjectionStepDeclaration[];
|
||||
correlationFields: string[];
|
||||
maxInterveningLines: number;
|
||||
target: GameClientBridgeLogProjectionTargetDeclaration;
|
||||
presence?: GameClientBridgeLogProjectionPresenceDeclaration;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeDataPackDeclaration {
|
||||
@@ -352,6 +391,7 @@ export interface GameClientBridgeManifest {
|
||||
commands: GameClientBridgeCommandDeclaration[];
|
||||
snapshots: GameClientBridgeSnapshotDeclaration[];
|
||||
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
|
||||
logProjections?: GameClientBridgeLogProjectionDeclaration[];
|
||||
dataPacks?: GameClientBridgeDataPackDeclaration[];
|
||||
operationTemplates?: GameClientBridgeOperationTemplateDeclaration[];
|
||||
commandRetentionSeconds: number;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
type GameClientBridgeLogProjectionDeclaration,
|
||||
type GameClientBridgeOperationTemplateDeclaration,
|
||||
type GameClientBridgeProtectedRequestDeclaration,
|
||||
type GameClientBridgeCompanionDeclaration,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
type PluginLifecycleActionDeclaration,
|
||||
type PluginBridgeContext
|
||||
} from "../sdk/index.js";
|
||||
import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
|
||||
import { validateGameClientBridgeCatalog, validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
|
||||
|
||||
const pluginsRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
@@ -190,11 +191,13 @@ describe("plugin manifest validation", () => {
|
||||
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes raw protected SQL and management request command surfaces", () => {
|
||||
it("removes raw SQL command surfaces and keeps announcements as a typed protected RCON request", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; protectedRequest?: { kind: string } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; protectedRequest?: { kind: string; transportKey: string; targetKey: string; textField: string; maxTextBytes: number } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
|
||||
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
|
||||
expect(commands).toEqual([]);
|
||||
expect(commands).toEqual([expect.objectContaining({ type: "announcement.send", protectedRequest: { kind: "rcon", transportKey: "scum-management", targetKey: "scum-management", textField: "requestText", maxTextBytes: 2048 } })]);
|
||||
const announcementPayload = JSON.parse(fs.readFileSync(path.join(pluginDir, commands[0].payloadSchemaRef), "utf8"));
|
||||
expect(announcementPayload).toMatchObject({ required: ["requestText"], properties: { requestText: { type: "string", minLength: 1, maxLength: 2048 } } });
|
||||
expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"]));
|
||||
expect(manifest.gameClientBridge.queryTemplates.map((query) => query.key)).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
expect(manifest.gameClientBridge.operationTemplates.map((operation) => operation.key)).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver", "player.attribute.855.set"]));
|
||||
@@ -202,6 +205,23 @@ describe("plugin manifest validation", () => {
|
||||
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("declares BattlEye login projection, presence deduplication, and plugin-owned welcome messages", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any;
|
||||
const projection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.battleye.login");
|
||||
expect(projection).toMatchObject({
|
||||
streamKeys: ["scum.console.stdout"], correlationFields: ["slot"], maxInterveningLines: 8,
|
||||
target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", displayName: "displayName", slot: "slot" }, fixedValues: { online: "true", source: "process.stdout" }, observedAtField: "lastLoginObservedAt" },
|
||||
presence: { timestampField: "lastLoginObservedAt", activeWindowSeconds: 600, activityTarget: { collection: "scum_activity_events", upsertKeys: ["steamId", "observedAt"], captureMappings: { steamId: "steamId", displayName: "displayName" }, fixedValues: { eventType: "login", source: "process.stdout" }, observedAtField: "observedAt" }, announcement: { profileKey: "scum-client-manager", commandType: "announcement.send", textField: "requestText", newTextTemplate: "#announce 欢迎新玩家 {{displayName}} 加入服务器!", returningTextTemplate: "#announce 欢迎 {{displayName}} 继续游戏!" } }
|
||||
});
|
||||
expect(projection.steps.map((step: { pattern: string }) => step.pattern)).toEqual([
|
||||
'Player "(?P<displayName>[^\"]+)" reported as player (?P<slot>\\d+)',
|
||||
"Player (?P<slot>\\d+) SteamID \\(assumed\\): (?P<steamId>\\d+)"
|
||||
]);
|
||||
const compile = (pattern: string) => new RegExp(pattern.replaceAll("(?P<", "(?<"));
|
||||
expect(compile(projection.steps[0].pattern).exec('LogBattlEye: Display: Player "love_fitting" reported as player 0')?.groups).toMatchObject({ displayName: "love_fitting", slot: "0" });
|
||||
expect(compile(projection.steps[1].pattern).exec("LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111")?.groups).toMatchObject({ slot: "0", steamId: "76561199510658111" });
|
||||
});
|
||||
|
||||
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any;
|
||||
@@ -525,7 +545,7 @@ describe("plugin manifest validation", () => {
|
||||
};
|
||||
};
|
||||
const expected = {
|
||||
"announcement.send": { permission: "server.game-client.command", approvalLevel: "none" },
|
||||
"announcement.send": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" },
|
||||
"player.lookup": { permission: "server.game-client.read", approvalLevel: "none" },
|
||||
"reward.deliver": { permission: "server.game-client.command", approvalLevel: "none" },
|
||||
@@ -552,6 +572,7 @@ describe("plugin manifest validation", () => {
|
||||
const schemaRefs = manifest.gameClientBridge.commands.flatMap((command) => [command.payloadSchemaRef, command.resultSchemaRef].filter((ref): ref is string => Boolean(ref)));
|
||||
for (const schemaRef of schemaRefs) {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record<string, unknown>;
|
||||
const hasPluginInventedCountLimitsRemoved = ["schemas/bridge/reward-deliver.payload.schema.json", "schemas/bridge/event-start.payload.schema.json"].includes(schemaRef);
|
||||
const visit = (value: unknown): void => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
@@ -565,13 +586,15 @@ describe("plugin manifest validation", () => {
|
||||
expect(record.additionalProperties).toBe(false);
|
||||
}
|
||||
if (record.type === "array") {
|
||||
expect(record.maxItems).toBeGreaterThan(0);
|
||||
expect(record.items).toBeDefined();
|
||||
if (!hasPluginInventedCountLimitsRemoved) expect(record.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "string") {
|
||||
expect(record.maxLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
expect(record.maximum).toBeDefined();
|
||||
if (!hasPluginInventedCountLimitsRemoved) expect(record.maximum).toBeDefined();
|
||||
if (typeof record.minimum === "number" && typeof record.maximum === "number") expect(record.minimum).toBeLessThanOrEqual(record.maximum);
|
||||
}
|
||||
Object.values(record).forEach(visit);
|
||||
};
|
||||
@@ -634,7 +657,8 @@ describe("plugin manifest validation", () => {
|
||||
parameterSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
sqlRef: string;
|
||||
rowTarget: { collection: string; upsertKeys: string[]; columnMappings: Record<string, string> };
|
||||
pollIntervalSeconds: number;
|
||||
rowTarget: { collection: string; upsertKeys: string[]; writeMode: "merge" | "replace"; columnMappings: Record<string, string> };
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
}>;
|
||||
@@ -655,6 +679,7 @@ describe("plugin manifest validation", () => {
|
||||
"scum.events": ["eventRecordId", "eventId", "roundId", "userProfileId", "startTime", "endTime", "state", "score", "enemyKills", "teamKills", "deaths", "assists", "headshots"],
|
||||
"scum.native-timed-gifts": ["timedGiftId", "userProfileId", "mapId", "spawnTime", "spawnAt"]
|
||||
};
|
||||
const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "scum.positions"]);
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
@@ -670,6 +695,8 @@ describe("plugin manifest validation", () => {
|
||||
expect(template.targetKey).toBe("scum-database");
|
||||
expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/.+\.sql$/);
|
||||
expect(template.rowTarget.collection).toMatch(/^scum_/);
|
||||
expect(template.pollIntervalSeconds).toBe(fastTemplates.has(key) ? 3 : 1800);
|
||||
expect(template.rowTarget.writeMode).toBe(key === "scum.player.profile" ? "merge" : "replace");
|
||||
expect(template.rowTarget.upsertKeys.length).toBeGreaterThan(0);
|
||||
expect(template.rowTarget.upsertKeys.every((upsertKey) => upsertKey in template.rowTarget.columnMappings)).toBe(true);
|
||||
expect(fs.existsSync(path.join(pluginDir, template.sqlRef))).toBe(true);
|
||||
@@ -688,6 +715,12 @@ describe("plugin manifest validation", () => {
|
||||
expect(sql).toMatch(new RegExp(`\\bAS\\s+${column}\\b`, "i"));
|
||||
}
|
||||
}
|
||||
expect(templatesByKey.get("scum.player.profile")?.rowTarget.upsertKeys).toEqual(["steamId"]);
|
||||
expect(templatesByKey.get("scum.squad-members")?.rowTarget.upsertKeys).toEqual(["squadId", "steamId"]);
|
||||
const userSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.player.profile")!.sqlRef), "utf8");
|
||||
const positionSQL = fs.readFileSync(path.join(pluginDir, templatesByKey.get("scum.positions")!.sqlRef), "utf8");
|
||||
expect(userSQL).toMatch(/FROM user account\s+LEFT JOIN user_profile profile/i);
|
||||
expect(positionSQL).toMatch(/account\.id AS subjectId/i);
|
||||
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
||||
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
|
||||
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
|
||||
@@ -884,6 +917,7 @@ describe("plugin manifest validation", () => {
|
||||
manifest.gameClientBridge = {
|
||||
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
|
||||
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
|
||||
logProjections: [{ key: "player.login", streamKeys: ["process.stdout"], steps: [{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" }, { pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }], correlationFields: ["slot"], maxInterveningLines: 16, target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" }, presence: { timestampField: "lastLoginAt", activeWindowSeconds: 600, announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" } } }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: []
|
||||
@@ -894,6 +928,40 @@ describe("plugin manifest validation", () => {
|
||||
expect(validate(manifest)).toBe(false);
|
||||
});
|
||||
|
||||
it("validates ordered log projections and repeated correlation captures", () => {
|
||||
const projection = {
|
||||
key: "player.login",
|
||||
streamKeys: ["process.stdout"],
|
||||
steps: [
|
||||
{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" },
|
||||
{ pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }
|
||||
],
|
||||
correlationFields: ["slot"],
|
||||
maxInterveningLines: 16,
|
||||
target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" },
|
||||
presence: {
|
||||
timestampField: "lastLoginAt",
|
||||
activeWindowSeconds: 600,
|
||||
activityTarget: { collection: "activity", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "observedAt" },
|
||||
announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" }
|
||||
}
|
||||
};
|
||||
const manifest = {
|
||||
permissions: ["server.game-client.command"],
|
||||
runtimeProfiles: { clientManagers: [{ key: "scum-client", health: { requiredCapabilities: ["game-client.bridge"] } }] },
|
||||
gameClientBridge: {
|
||||
commands: [{ type: "announcement.send", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/announcement.schema.json" }],
|
||||
snapshots: [],
|
||||
logProjections: [projection]
|
||||
}
|
||||
};
|
||||
expect(validateGameClientBridgeCatalog(manifest)).toEqual([]);
|
||||
|
||||
projection.target.captureMappings.steamId = "missing";
|
||||
const errors = validateGameClientBridgeCatalog(manifest);
|
||||
expect(errors.some((error) => error.includes("references undeclared capture missing"))).toBe(true);
|
||||
});
|
||||
|
||||
it("loads and validates every schema referenced by a safe game-client bridge manifest", () => {
|
||||
expect(validateTemporaryBridgeManifest()).toEqual([]);
|
||||
});
|
||||
@@ -1140,12 +1208,25 @@ describe("plugin SDK", () => {
|
||||
parameterSchemaRef: "schemas/bridge/queries/player-by-id.parameters.schema.json",
|
||||
resultSchemaRef: "schemas/bridge/queries/player-by-id.result.schema.json",
|
||||
maxRows: 1,
|
||||
timeoutSeconds: 10
|
||||
timeoutSeconds: 10,
|
||||
pollIntervalSeconds: 0
|
||||
};
|
||||
expect(declaration).toMatchObject({ engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", maxRows: 1 });
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
|
||||
});
|
||||
|
||||
it("types plugin-declared ordered log projections", () => {
|
||||
const declaration: GameClientBridgeLogProjectionDeclaration = {
|
||||
key: "scum.player.login",
|
||||
streamKeys: ["process.stdout"],
|
||||
steps: [{ pattern: "Player (?<slot>\\d+) SteamID: (?<steamId>\\d+)" }],
|
||||
correlationFields: ["slot"],
|
||||
maxInterveningLines: 16,
|
||||
target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "lastLoginAt" }
|
||||
};
|
||||
expect(declaration).toMatchObject({ key: "scum.player.login", correlationFields: ["slot"] });
|
||||
});
|
||||
|
||||
it("types controlled operation template declarations", () => {
|
||||
const declaration: GameClientBridgeOperationTemplateDeclaration = {
|
||||
key: "player.attribute.855.set",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
|
||||
import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, requestSCUMPageQueries, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js";
|
||||
import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js";
|
||||
import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js";
|
||||
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
|
||||
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
|
||||
@@ -84,34 +84,25 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` });
|
||||
});
|
||||
|
||||
it("merges the latest typed player and online-session snapshots into database users", async () => {
|
||||
it("merges player snapshots only by stable identifiers and ignores name-only online sessions", async () => {
|
||||
const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } });
|
||||
const gameClient = gameClientActions();
|
||||
gameClient.snapshots.mockImplementation(async (query) => query?.type === "players" ? { items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] } : { items: [{ sequence: 3, observedAt: "2026-08-10T00:01:00Z", payload: { sessions: [{ sessionId: "session-1", playerName: "Mira" }] } }] });
|
||||
gameClient.snapshots.mockResolvedValue({ items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] });
|
||||
const data = await loadSCUMSurface({ pluginData, gameClient }, "players");
|
||||
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "online.sessions"]);
|
||||
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:01:00Z" });
|
||||
expect(mergePlayerSnapshots([{ gamePlayerId: "steam-2", displayName: "Noah" }], { items: [] }, { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { sessions: [] } }] })[0]).toMatchObject({ online: false });
|
||||
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players"]);
|
||||
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:00:00Z" });
|
||||
const sameName = mergePlayerSnapshots([{ steamId: "steam-2", displayName: "Noah", online: false }], { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { players: [{ playerId: "steam-3", playerName: "Noah", status: "online" }] } }] });
|
||||
expect(sameName).toHaveLength(2);
|
||||
expect(sameName.find((player) => player.steamId === "steam-2")).toMatchObject({ online: false });
|
||||
expect(dataClientSource).not.toContain('type: "online.sessions"');
|
||||
});
|
||||
|
||||
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
|
||||
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
|
||||
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows");
|
||||
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
|
||||
const dispatch = dispatchAction();
|
||||
await requestSCUMPageQueries({ dispatch }, "workflows");
|
||||
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]);
|
||||
dispatch.mockClear();
|
||||
await requestSCUMPageQueries({ dispatch }, "activity");
|
||||
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]);
|
||||
});
|
||||
|
||||
it("dispatches only declared SQLite query envelopes for machine refresh", async () => {
|
||||
const dispatch = dispatchAction();
|
||||
await requestSCUMPageQueries({ dispatch }, "squads");
|
||||
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.squads", "scum.squad-members", "scum.flags"]);
|
||||
for (const [envelope] of dispatch.mock.calls) expect(envelope).toMatchObject({ action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database" } });
|
||||
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "activity");
|
||||
expect(list.mock.calls.slice(-6).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
|
||||
});
|
||||
|
||||
it("uses transaction, put, and delete for plugin-owned gift data", async () => {
|
||||
@@ -120,7 +111,7 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(parseGiftItems("BP_Cash_01:2, Water-Bottle.01:1")).toEqual([{ catalogCode: "BP_Cash_01", quantity: 2 }, { catalogCode: "Water-Bottle.01", quantity: 1 }]);
|
||||
expect(parseGiftCommands("#announce Hello\n#spawnitem BP_Cash_01 2")).toEqual([{ command: "#announce Hello" }, { command: "#spawnitem BP_Cash_01 2" }]);
|
||||
expect(() => parseGiftItems("cash:0")).toThrow("格式无效");
|
||||
expect(() => parseGiftItems("a:1,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toThrow("最多包含 8 项");
|
||||
expect(parseGiftItems("a:101,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toHaveLength(9);
|
||||
await saveGiftDefinition(actions, { code: "starter", name: "Starter", items: [] });
|
||||
await createGiftDelivery(actions, { id: "delivery-1", giftCode: "starter", playerId: "steam-1" });
|
||||
await deleteGiftDefinition(actions, "starter");
|
||||
@@ -172,12 +163,21 @@ describe("SCUM plugin feature module", () => {
|
||||
}) }));
|
||||
});
|
||||
|
||||
it("keeps positive event duration and counts above the removed arbitrary limits", async () => {
|
||||
const gameClient = gameClientActions();
|
||||
await startEvent({ pluginData: pluginDataActions(), gameClient }, { id: "event-large", name: "Large Event", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]);
|
||||
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({
|
||||
durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004,
|
||||
produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]
|
||||
}) }));
|
||||
});
|
||||
|
||||
it("renders searchable user management from real collection values", () => {
|
||||
const view = renderAndCollect();
|
||||
expect(view.nodes).toContain("section:用户管理");
|
||||
expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步");
|
||||
expect(view.texts).toContain("通用数据/机器动作可用");
|
||||
expect(view.buttons.find((button) => button.label === "同步 SCUM.db")?.disabled).toBe(false);
|
||||
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"]));
|
||||
expect(view.inputs.map((input) => input.label)).toContain("搜索用户");
|
||||
expect(view.texts).toContain("Mira");
|
||||
expect(view.texts.join("\n")).toContain("Steam 76561198000000001");
|
||||
@@ -204,7 +204,7 @@ describe("SCUM plugin feature module", () => {
|
||||
it("renders activity definitions, status filters, runs, and records", () => {
|
||||
const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" });
|
||||
expect(view.inputs.map((input) => input.label)).toContain("活动状态");
|
||||
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
|
||||
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
|
||||
expect(view.texts).toContain("Friday Range");
|
||||
expect(view.texts).toContain("running");
|
||||
expect(view.texts).toContain("最近活动记录");
|
||||
@@ -241,7 +241,7 @@ describe("SCUM plugin feature module", () => {
|
||||
});
|
||||
|
||||
it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => {
|
||||
const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "steam-1", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] };
|
||||
const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "76561198000000001", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] };
|
||||
expect(collectMapPoints(duplicateData)).toHaveLength(264);
|
||||
const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 });
|
||||
expect(bounds).toEqual({ worldMinX: -100000, worldMinY: 100000, worldMaxX: 300000, worldMaxY: 300000 });
|
||||
@@ -256,8 +256,11 @@ describe("SCUM plugin feature module", () => {
|
||||
const source = `${pageSource}\n${dataClientSource}`;
|
||||
for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "SELECT ", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden);
|
||||
expect(source).toContain("pluginData");
|
||||
expect(source).toContain("remote.access.request");
|
||||
expect(source).toContain("input.templateKey");
|
||||
expect(source).not.toContain("remote.access.request");
|
||||
expect(source).not.toContain("input.templateKey");
|
||||
expect(source).not.toContain("requestSCUMPageQueries");
|
||||
expect(pageSource).toContain("setInterval(refresh, 3000)");
|
||||
expect(pageSource).toContain("clearInterval(interval)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,10 +273,6 @@ function pluginDataActions(overrides: Partial<{ list: (collection: string, key?:
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchAction() {
|
||||
return vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ requestId: envelope.requestId, action: envelope.action, status: "queued" }));
|
||||
}
|
||||
|
||||
function gameClientActions() {
|
||||
const queue = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["queue"]>(async () => ({ id: "command-1", state: "pending" }));
|
||||
const get = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["get"]>(async () => ({ id: "command-1", state: "pending" }));
|
||||
@@ -310,7 +309,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
|
||||
return [value, () => undefined];
|
||||
}
|
||||
};
|
||||
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: dispatchAction() };
|
||||
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions() };
|
||||
renderPluginPage(react, {
|
||||
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },
|
||||
|
||||
Reference in New Issue
Block a user