Align SCUM operations with reference tools
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-13
|
||||
@@ -0,0 +1,28 @@
|
||||
## Context
|
||||
|
||||
`scum_new_web` and `scum_robot` establish the useful operator shape: players can be searched and narrowed by squad, squads expose their rosters, maps use layers and marker inspection, and gifts distinguish definitions from a player's receipt history. The existing browser repository already has typed SCUM observations and gift workflows, so the design extends those contracts instead of importing the old direct-access path.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Projection views compose existing facts
|
||||
|
||||
The frontend reads users, squads, squad members, vehicles, flags, map points, observed timed-gift events, and platform gift APIs. It joins those responses only for display. A user action creates existing typed operations or gift grants; it never mutates a projection in the browser.
|
||||
|
||||
### Plugin data packs own version-sensitive game data
|
||||
|
||||
The SCUM manifest names a v57 gift-item catalog and map geometry data asset. The platform resolves a requested gift item through the installed server plugin and game version. Updating SCUM changes the plugin package, not platform code.
|
||||
|
||||
### Observed events have a distinct target table
|
||||
|
||||
`finished_timed_gift_spawner` represents a completed in-game timed-gift event. It is stored as `scum_gift_events`; `game_gift_catalogs`, revisions, and grants remain platform-owned operational data. This removes the ambiguous old `scum_gift_catalogs` observation table.
|
||||
|
||||
### Map is geometric, not a copied game asset
|
||||
|
||||
The plugin supplies world bounds and axis direction. The web client projects points into that normalized frame and exposes player, vehicle, base, and flag layers. No copyrighted tile bundle is copied. Trajectory playback remains dependent on independently collected track points.
|
||||
|
||||
## Verification
|
||||
|
||||
- Go tests covering observed-gift target and plugin-resolved gift items.
|
||||
- Plugin typecheck, tests, and manifest validation.
|
||||
- Frontend typecheck, tests, and build.
|
||||
- `scripts/check-structure.sh` and `openspec validate align-scum-operations-with-reference-tools --strict`.
|
||||
@@ -0,0 +1,23 @@
|
||||
## Why
|
||||
|
||||
The SCUM projection pipeline now reads real v57 facts, but the five SCUM pages still present those facts as disconnected tables. The established `scum_new_web` and `scum_robot` tools have useful operator workflows for player search, squad rosters, map overlays, and gifts. Their direct database, FTP, and SQL-transport design does not fit this platform.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Turn SCUM users into a searchable, squad-filterable projection view with player details and typed-operation entry points.
|
||||
- Show each squad with its linked roster and flags rather than a disconnected aggregate table.
|
||||
- Render current player, vehicle, base, and flag points in a versioned plugin-declared coordinate frame with independent layers and a selected-marker inspector.
|
||||
- Separate game-observed completed timed-gift events from platform-owned gift definitions, revisions, and grants.
|
||||
- Move SCUM gift item catalogs and map geometry out of platform code into versioned SCUM plugin data packs.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Platform remains the durable owner of projections, gift definitions, revisions, and grants; the plugin owns version-specific SQL, item keys, and map geometry; Run only executes declared work.
|
||||
- Do not reintroduce FTP, direct platform/browser access to `SCUM.db`, arbitrary SQL, raw RCON, host paths, or a `run/` source tree.
|
||||
- Do not copy third-party map tiles into this repository. The map is an operator coordinate board until a plugin-declared asset reference is available.
|
||||
|
||||
## Impact
|
||||
|
||||
- `platform/`: separates observed timed-gift events from platform gift catalog data and resolves gift item definitions from the registered plugin.
|
||||
- `platform_web/`: adds projection-oriented user, squad, map, and gift views within the existing themed console.
|
||||
- `plugins/examples/scum-server-plugin/`: declares SCUM v57 gift items and map geometry as versioned data-pack assets.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Reference-aligned SCUM projection workspaces
|
||||
The system SHALL present SCUM users, squads, map points, and gifts as connected operator workspaces backed by platform projections and typed workflows.
|
||||
|
||||
#### Scenario: Operator filters users by squad
|
||||
- **WHEN** an operator searches or selects a squad in the SCUM users workspace
|
||||
- **THEN** the page filters real local player projections by the selected name, Steam identifier, or squad without querying `SCUM.db` from the browser
|
||||
|
||||
#### Scenario: Operator inspects a squad
|
||||
- **WHEN** an operator selects a squad
|
||||
- **THEN** the page shows its observed members, leader markers, and related flag observations without fabricating missing values
|
||||
|
||||
### Requirement: Plugin-declared SCUM map geometry
|
||||
The system SHALL use SCUM plugin package geometry to project current map-point overlays.
|
||||
|
||||
#### Scenario: Operator changes map layers
|
||||
- **WHEN** an operator enables or disables players, vehicles, bases, or flags
|
||||
- **THEN** the map renders only the selected real projection classes using the installed plugin's declared coordinate frame
|
||||
|
||||
### Requirement: Distinct observed and operational gifts
|
||||
The system SHALL keep observed SCUM timed-gift completion events separate from platform-owned gift definitions and grant state.
|
||||
|
||||
#### Scenario: Operator opens gifts
|
||||
- **WHEN** an operator opens SCUM gift management
|
||||
- **THEN** the workspace distinguishes platform gift definitions and grants from completed in-game timed-gift observations
|
||||
|
||||
### Requirement: Versioned plugin game data
|
||||
The system SHALL resolve SCUM gift item definitions from versioned plugin package data.
|
||||
|
||||
#### Scenario: SCUM version changes
|
||||
- **WHEN** the server uses a plugin package with a different declared SCUM item catalog
|
||||
- **THEN** gift validation uses that plugin package data without changing platform source code
|
||||
@@ -0,0 +1,12 @@
|
||||
## 1. Reference-Aligned SCUM Operations
|
||||
|
||||
- [x] 1.1 Positive prompt: Deliver practical SCUM user, squad, map, and gift operations shaped by the mature reference tools while retaining the current plugin/platform/Run ownership model.
|
||||
- [x] 1.2 Directional prompt: Update `platform/`, `platform_web/`, and the SCUM plugin package using existing projection, gift, manifest, and themed-console patterns; verify backend, frontend, plugin, structure, and OpenSpec checks.
|
||||
- [x] 1.3 Boundary prompt: Do not reintroduce direct SQLite/FTP/browser SQL access, raw RCON, host paths, copied map tiles, unrelated product areas, or a `run/` tree.
|
||||
- [x] 1.4 Separate observed timed-gift completion events from platform gift catalog/revision/grant records.
|
||||
- [x] 1.5 Resolve SCUM gift item definitions and map geometry from the versioned installed plugin package.
|
||||
- [x] 1.6 Implement searchable player and squad-filtered user projections plus roster/flag-aware squad detail.
|
||||
- [x] 1.7 Implement layered projection map with plugin-declared coordinate conversion and marker inspection.
|
||||
- [x] 1.8 Implement a gift workspace that clearly separates definitions/grants from observed completed timed-gift events.
|
||||
- [x] 1.9 Add focused tests and run the required validation suite.
|
||||
- [x] 1.10 Stage only task files, commit on `main`, and push the configured remote after verification succeeds.
|
||||
@@ -34,7 +34,7 @@ func (h *coreHandlers) serverSCUMActivity(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMGifts(w http.ResponseWriter, r *http.Request) {
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetGifts)
|
||||
h.serverSCUMDataSet(w, r, domain.SCUMDataSetGiftEvents)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMMapPoints(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SCUMRewardDeliverCommandType = "reward.deliver"
|
||||
const SCUMGiftNotificationCommandType = "player.notify"
|
||||
@@ -15,7 +19,32 @@ type SCUMGiftItemCatalog struct {
|
||||
Items []SCUMGiftItemDefinition
|
||||
}
|
||||
|
||||
var SCUMGiftItemCatalogs = []SCUMGiftItemCatalog{{GameVersion: "0.9.700.90357", Items: []SCUMGiftItemDefinition{{Key: "bandage", Label: "绷带", MaximumQuantity: 20}, {Key: "water-bottle", Label: "饮用水", MaximumQuantity: 10}, {Key: "improvised-spear", Label: "简易长矛", MaximumQuantity: 2}}}}
|
||||
func ParseSCUMGiftItemCatalog(content string) (SCUMGiftItemCatalog, bool) {
|
||||
var catalog struct {
|
||||
GameVersion string `json:"gameVersion"`
|
||||
Items []struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
MaximumQuantity int `json:"maximumQuantity"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if json.Unmarshal([]byte(content), &catalog) != nil || strings.TrimSpace(catalog.GameVersion) == "" || len(catalog.Items) == 0 {
|
||||
return SCUMGiftItemCatalog{}, false
|
||||
}
|
||||
result := SCUMGiftItemCatalog{GameVersion: catalog.GameVersion, Items: make([]SCUMGiftItemDefinition, 0, len(catalog.Items))}
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range catalog.Items {
|
||||
if strings.TrimSpace(item.Key) == "" || strings.TrimSpace(item.Label) == "" || item.MaximumQuantity < 1 {
|
||||
return SCUMGiftItemCatalog{}, false
|
||||
}
|
||||
if _, ok := seen[item.Key]; ok {
|
||||
return SCUMGiftItemCatalog{}, false
|
||||
}
|
||||
seen[item.Key] = struct{}{}
|
||||
result.Items = append(result.Items, SCUMGiftItemDefinition{Key: item.Key, Label: item.Label, MaximumQuantity: item.MaximumQuantity})
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
type GameGiftItem struct {
|
||||
CatalogItemKey string
|
||||
@@ -122,23 +151,3 @@ func CopyGameGiftGrant(value GameGiftGrant) GameGiftGrant {
|
||||
value.Items = CopyGameGiftItems(value.Items)
|
||||
return value
|
||||
}
|
||||
func SCUMGiftCatalogForVersion(version string) (SCUMGiftItemCatalog, bool) {
|
||||
for _, catalog := range SCUMGiftItemCatalogs {
|
||||
if catalog.GameVersion == version {
|
||||
return catalog, true
|
||||
}
|
||||
}
|
||||
return SCUMGiftItemCatalog{}, false
|
||||
}
|
||||
func SCUMGiftItemForVersion(version, key string) (SCUMGiftItemDefinition, bool) {
|
||||
catalog, ok := SCUMGiftCatalogForVersion(version)
|
||||
if !ok {
|
||||
return SCUMGiftItemDefinition{}, false
|
||||
}
|
||||
for _, item := range catalog.Items {
|
||||
if item.Key == key {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return SCUMGiftItemDefinition{}, false
|
||||
}
|
||||
|
||||
@@ -88,14 +88,16 @@ type SCUMObservationResult struct {
|
||||
type SCUMDataSet string
|
||||
|
||||
const (
|
||||
SCUMDataSetUsers SCUMDataSet = "scum_users"
|
||||
SCUMDataSetSquads SCUMDataSet = "scum_squads"
|
||||
SCUMDataSetMembers SCUMDataSet = "scum_squad_members"
|
||||
SCUMDataSetVehicles SCUMDataSet = "scum_vehicles"
|
||||
SCUMDataSetFlags SCUMDataSet = "scum_flags"
|
||||
SCUMDataSetActivity SCUMDataSet = "scum_activity_events"
|
||||
SCUMDataSetGifts SCUMDataSet = "scum_gift_catalogs"
|
||||
SCUMDataSetMapPoints SCUMDataSet = "scum_map_points"
|
||||
SCUMDataSetUsers SCUMDataSet = "scum_users"
|
||||
SCUMDataSetSquads SCUMDataSet = "scum_squads"
|
||||
SCUMDataSetMembers SCUMDataSet = "scum_squad_members"
|
||||
SCUMDataSetVehicles SCUMDataSet = "scum_vehicles"
|
||||
SCUMDataSetFlags SCUMDataSet = "scum_flags"
|
||||
SCUMDataSetActivity SCUMDataSet = "scum_activity_events"
|
||||
// Gift events are facts observed in SCUM's finished_timed_gift_spawner table.
|
||||
// Platform-owned gift catalogs, revisions, and grants use their own repositories.
|
||||
SCUMDataSetGiftEvents SCUMDataSet = "scum_gift_events"
|
||||
SCUMDataSetMapPoints SCUMDataSet = "scum_map_points"
|
||||
)
|
||||
|
||||
type SCUMDataRow struct {
|
||||
|
||||
@@ -74,8 +74,7 @@ type GameGiftGrantListResponse struct {
|
||||
func (r GameGiftCatalogRequest) ToDomain() domain.GameGiftCatalogRequest {
|
||||
items := make([]domain.GameGiftItem, len(r.Items))
|
||||
for i, item := range r.Items {
|
||||
def, _ := domain.SCUMGiftItemForVersion(r.GameVersion, item.CatalogItemKey)
|
||||
items[i] = domain.GameGiftItem{CatalogItemKey: item.CatalogItemKey, Label: def.Label, Quantity: item.Quantity}
|
||||
items[i] = domain.GameGiftItem{CatalogItemKey: item.CatalogItemKey, Quantity: item.Quantity}
|
||||
}
|
||||
return domain.GameGiftCatalogRequest{ID: r.ID, Name: r.Name, GameVersion: r.GameVersion, Items: items}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type GameGiftCatalog struct {
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (GameGiftCatalog) TableName() string { return "game_gift_catalogs" }
|
||||
func (GameGiftCatalog) TableName() string { return "scum_gift_catalogs" }
|
||||
|
||||
// GameGiftRevision is an immutable gift item snapshot.
|
||||
type GameGiftRevision struct {
|
||||
@@ -27,7 +27,7 @@ type GameGiftRevision struct {
|
||||
PublishedAt time.Time `json:"publishedAt" db:"published_at"`
|
||||
}
|
||||
|
||||
func (GameGiftRevision) TableName() string { return "game_gift_revisions" }
|
||||
func (GameGiftRevision) TableName() string { return "scum_gift_revisions" }
|
||||
|
||||
// GameGiftGrant records a directed frozen gift lifecycle without raw game commands.
|
||||
type GameGiftGrant struct {
|
||||
@@ -42,4 +42,4 @@ type GameGiftGrant struct {
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (GameGiftGrant) TableName() string { return "game_gift_grants" }
|
||||
func (GameGiftGrant) TableName() string { return "scum_gift_grants" }
|
||||
|
||||
@@ -253,7 +253,7 @@ CREATE TABLE IF NOT EXISTS platform_metadata_snapshots (
|
||||
if err != nil {
|
||||
return fmt.Errorf("create mysql metadata snapshot table: %w", err)
|
||||
}
|
||||
for _, table := range []string{"scum_sync_runs", "scum_users", "scum_squads", "scum_squad_members", "scum_vehicles", "scum_flags", "scum_activity_events", "scum_gift_catalogs", "scum_map_points"} {
|
||||
for _, table := range []string{"scum_sync_runs", "scum_users", "scum_squads", "scum_squad_members", "scum_vehicles", "scum_flags", "scum_activity_events", "scum_gift_events", "scum_map_points"} {
|
||||
statement := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
id VARCHAR(191) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(191) NOT NULL,
|
||||
@@ -396,8 +396,8 @@ func mysqlSCUMTable(target domain.SCUMDataSet) (string, bool) {
|
||||
return "scum_flags", true
|
||||
case domain.SCUMDataSetActivity:
|
||||
return "scum_activity_events", true
|
||||
case domain.SCUMDataSetGifts:
|
||||
return "scum_gift_catalogs", true
|
||||
case domain.SCUMDataSetGiftEvents:
|
||||
return "scum_gift_events", true
|
||||
case domain.SCUMDataSetMapPoints:
|
||||
return "scum_map_points", true
|
||||
default:
|
||||
|
||||
@@ -23,7 +23,7 @@ func (svc *CoreService) SaveGameGiftCatalogForSession(sessionID, serverID string
|
||||
if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
|
||||
return domain.GameGiftCatalog{}, err
|
||||
}
|
||||
if err = validateGiftItems(request.GameVersion, request.Items); err != nil {
|
||||
if err = svc.validateGiftItems(serverID, request.GameVersion, request.Items); err != nil {
|
||||
return domain.GameGiftCatalog{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
@@ -60,7 +60,7 @@ func (svc *CoreService) PublishGameGiftCatalogForSession(sessionID, catalogID st
|
||||
if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil {
|
||||
return domain.GameGiftRevision{}, err
|
||||
}
|
||||
if err = validateGiftItems(catalog.GameVersion, catalog.DraftItems); err != nil {
|
||||
if err = svc.validateGiftItems(catalog.ServerInstanceID, catalog.GameVersion, catalog.DraftItems); err != nil {
|
||||
return domain.GameGiftRevision{}, err
|
||||
}
|
||||
revisions, err := svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalog.ID})
|
||||
@@ -111,7 +111,7 @@ func (svc *CoreService) RequestGameGiftGrantForSession(sessionID, serverID strin
|
||||
if err != nil || revision.ServerInstanceID != serverID {
|
||||
return domain.GameGiftGrant{}, repo.ErrNotFound
|
||||
}
|
||||
if err = validateGiftItems(revision.GameVersion, revision.Items); err != nil {
|
||||
if err = svc.validateGiftItems(serverID, revision.GameVersion, revision.Items); err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
player, err := svc.store.GamePlayers().Get(request.GamePlayerRecordID)
|
||||
@@ -148,7 +148,7 @@ func (svc *CoreService) ApproveGameGiftGrantForSession(sessionID, grantID string
|
||||
if grant.Status != domain.GameGiftGrantPendingApproval {
|
||||
return domain.GameGiftGrant{}, validationError("gift grant is not awaiting approval")
|
||||
}
|
||||
if err = validateGiftItems(grant.GameVersion, grant.Items); err != nil {
|
||||
if err = svc.validateGiftItems(grant.ServerInstanceID, grant.GameVersion, grant.Items); err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: grant.GamePlayerRecordID, OpenOnly: true})
|
||||
@@ -198,23 +198,55 @@ func (svc *CoreService) ListGameGiftGrantsForSession(sessionID, serverID string)
|
||||
sort.Slice(grants, func(i, j int) bool { return grants[i].CreatedAt.After(grants[j].CreatedAt) })
|
||||
return grants, nil
|
||||
}
|
||||
func validateGiftItems(version string, items []domain.GameGiftItem) error {
|
||||
if _, ok := domain.SCUMGiftCatalogForVersion(version); !ok {
|
||||
return validationError("SCUM game version has no verified gift item catalog")
|
||||
func (svc *CoreService) validateGiftItems(serverID, version string, items []domain.GameGiftItem) error {
|
||||
instance, err := svc.store.ServerInstances().Get(serverID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
catalog, ok := scumGiftCatalogFromPlugin(plugin, version)
|
||||
if !ok {
|
||||
return validationError("installed SCUM plugin has no verified gift item catalog for this game version")
|
||||
}
|
||||
if len(items) == 0 || len(items) > 8 {
|
||||
return validationError("gift requires 1 to 8 catalog items")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range items {
|
||||
def, ok := domain.SCUMGiftItemForVersion(version, item.CatalogItemKey)
|
||||
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity || item.Label != def.Label {
|
||||
for index, item := range items {
|
||||
def, ok := scumGiftItem(catalog, item.CatalogItemKey)
|
||||
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity {
|
||||
return validationError("gift item is not valid for this SCUM version")
|
||||
}
|
||||
items[index].Label = def.Label
|
||||
seen[item.CatalogItemKey] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scumGiftCatalogFromPlugin(plugin domain.GamePlugin, version string) (domain.SCUMGiftItemCatalog, bool) {
|
||||
for _, asset := range plugin.LifecycleAssets {
|
||||
if !strings.HasPrefix(asset.Path, "data-packs/") || !strings.HasSuffix(asset.Path, "/gift-items.json") || strings.TrimSpace(asset.Content) == "" {
|
||||
continue
|
||||
}
|
||||
catalog, ok := domain.ParseSCUMGiftItemCatalog(asset.Content)
|
||||
if ok && catalog.GameVersion == version {
|
||||
return catalog, true
|
||||
}
|
||||
}
|
||||
return domain.SCUMGiftItemCatalog{}, false
|
||||
}
|
||||
|
||||
func scumGiftItem(catalog domain.SCUMGiftItemCatalog, key string) (domain.SCUMGiftItemDefinition, bool) {
|
||||
for _, item := range catalog.Items {
|
||||
if item.Key == key {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return domain.SCUMGiftItemDefinition{}, false
|
||||
}
|
||||
func giftDeliveryPayload(grant domain.GameGiftGrant) map[string]any {
|
||||
items := make([]any, len(grant.Items))
|
||||
for i, item := range grant.Items {
|
||||
|
||||
@@ -100,6 +100,7 @@ func gameGiftFixture(t *testing.T, online bool) (*CoreService, string, domain.Ga
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
plugin, _ := svc.store.GamePlugins().Get("game.scum")
|
||||
plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{Type: domain.SCUMRewardDeliverCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 4096}, {Type: domain.SCUMGiftNotificationCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 60, MaxPayloadBytes: 2048}}
|
||||
plugin.LifecycleAssets = append(plugin.LifecycleAssets, domain.PluginAssetFile{Path: "data-packs/scum-db-v57/gift-items.json", Content: `{"gameVersion":"0.9.700.90357","items":[{"key":"bandage","label":"绷带","maximumQuantity":20},{"key":"water-bottle","label":"饮用水","maximumQuantity":10},{"key":"improvised-spear","label":"简易长矛","maximumQuantity":2}]}`})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ func (svc *CoreService) upsertSCUMDataRow(target domain.SCUMRowTargetDeclaration
|
||||
|
||||
func validSCUMDataSet(value domain.SCUMDataSet) bool {
|
||||
switch value {
|
||||
case domain.SCUMDataSetUsers, domain.SCUMDataSetSquads, domain.SCUMDataSetMembers, domain.SCUMDataSetVehicles, domain.SCUMDataSetFlags, domain.SCUMDataSetActivity, domain.SCUMDataSetGifts, domain.SCUMDataSetMapPoints:
|
||||
case domain.SCUMDataSetUsers, domain.SCUMDataSetSquads, domain.SCUMDataSetMembers, domain.SCUMDataSetVehicles, domain.SCUMDataSetFlags, domain.SCUMDataSetActivity, domain.SCUMDataSetGiftEvents, domain.SCUMDataSetMapPoints:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -782,7 +782,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
|
||||
func validSCUMTargetTable(value string) bool {
|
||||
switch value {
|
||||
case "scum_users", "scum_squads", "scum_activity_events", "scum_gift_catalogs", "scum_map_points":
|
||||
case "scum_users", "scum_squads", "scum_activity_events", "scum_gift_events", "scum_map_points":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -47,6 +47,14 @@ import type {
|
||||
GameClientBridgeSnapshotListResponse,
|
||||
GameClientBridgeSnapshotQuery,
|
||||
GameClientBridgeStatusResponse,
|
||||
GameGiftCatalogListResponse,
|
||||
GameGiftCatalogRequest,
|
||||
GameGiftCatalogResponse,
|
||||
GameGiftGrantListResponse,
|
||||
GameGiftGrantRequest,
|
||||
GameGiftGrantResponse,
|
||||
GameGiftRevisionListResponse,
|
||||
GameGiftRevisionResponse,
|
||||
GamePluginListResponse,
|
||||
HealthResponse,
|
||||
JobCreateRequest,
|
||||
@@ -628,6 +636,34 @@ export class PlatformApiClient {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`);
|
||||
}
|
||||
|
||||
async listGameGiftCatalogs(serverInstanceId: string): Promise<GameGiftCatalogListResponse> {
|
||||
return this.request<GameGiftCatalogListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`);
|
||||
}
|
||||
|
||||
async saveGameGiftCatalog(serverInstanceId: string, request: GameGiftCatalogRequest): Promise<GameGiftCatalogResponse> {
|
||||
return this.request<GameGiftCatalogResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async publishGameGiftCatalog(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionResponse> {
|
||||
return this.request<GameGiftRevisionResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/publish`, { method: "POST", body: {} });
|
||||
}
|
||||
|
||||
async listGameGiftRevisions(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionListResponse> {
|
||||
return this.request<GameGiftRevisionListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/revisions`);
|
||||
}
|
||||
|
||||
async listGameGiftGrants(serverInstanceId: string): Promise<GameGiftGrantListResponse> {
|
||||
return this.request<GameGiftGrantListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`);
|
||||
}
|
||||
|
||||
async requestGameGiftGrant(serverInstanceId: string, request: GameGiftGrantRequest): Promise<GameGiftGrantResponse> {
|
||||
return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async approveGameGiftGrant(serverInstanceId: string, grantId: string): Promise<GameGiftGrantResponse> {
|
||||
return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants/${encodeURIComponent(grantId)}/approve`, { method: "POST", body: {} });
|
||||
}
|
||||
|
||||
async listSCUMOperations(serverInstanceId: string): Promise<SCUMOperationListResponse> {
|
||||
return this.request<SCUMOperationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`);
|
||||
}
|
||||
|
||||
@@ -388,9 +388,12 @@ export interface GamePluginResponse {
|
||||
validationViolations?: string[];
|
||||
runtimeProfiles?: GamePluginRuntimeProfilesResponse;
|
||||
gameClientBridge?: GameClientBridgeManifestResponse;
|
||||
mapTrajectories?: GameMapGeometryResponse;
|
||||
status: GamePluginStatus;
|
||||
}
|
||||
|
||||
export interface GameMapGeometryResponse { mapId: string; mapVersion: string; worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number; imageWidth: number; imageHeight: number; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number; }
|
||||
|
||||
export type PluginCreateFieldType = "text" | "number" | "boolean" | "select" | "port";
|
||||
|
||||
export interface PluginCreateFieldResponse {
|
||||
@@ -1386,6 +1389,15 @@ export type SCUMSquadsListResponse = SCUMListResponse<SCUMSquadRecord>;
|
||||
export type SCUMActivityListResponse = SCUMListResponse<SCUMActivityEventRecord>;
|
||||
export type SCUMGiftsListResponse = SCUMListResponse<SCUMGiftRecord>;
|
||||
export type SCUMMapPointsListResponse = SCUMListResponse<SCUMMapPointRecord>;
|
||||
export interface GameGiftItemResponse { catalogItemKey: string; label: string; quantity: number; }
|
||||
export interface GameGiftCatalogResponse { id: string; name: string; gameVersion: string; draftItems: GameGiftItemResponse[]; latestRevisionId?: string; updatedAt: string; }
|
||||
export interface GameGiftCatalogListResponse { items: GameGiftCatalogResponse[]; }
|
||||
export interface GameGiftRevisionResponse { id: string; catalogId: string; revision: number; gameVersion: string; items: GameGiftItemResponse[]; publishedBy: string; publishedAt: string; }
|
||||
export interface GameGiftRevisionListResponse { items: GameGiftRevisionResponse[]; }
|
||||
export interface GameGiftGrantResponse { id: string; revisionId: string; revisionNumber: number; gameVersion: string; items: GameGiftItemResponse[]; gamePlayerRecordId: string; playerDisplayName: string; notice: string; requesterId: string; approverId?: string; status: string; deliverySummary?: string; notificationSummary?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
|
||||
export interface GameGiftGrantListResponse { items: GameGiftGrantResponse[]; }
|
||||
export interface GameGiftCatalogRequest { id?: string; name: string; gameVersion: string; items: Array<{ catalogItemKey: string; quantity: number }>; }
|
||||
export interface GameGiftGrantRequest { revisionId: string; gamePlayerRecordId: string; notice: string; idempotencyKey: string; }
|
||||
export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; }
|
||||
export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; }
|
||||
export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface PluginPageWorkspaceActions {
|
||||
listSCUMActivity?: () => Promise<unknown>;
|
||||
listSCUMGifts?: () => Promise<unknown>;
|
||||
listSCUMMapPoints?: () => Promise<unknown>;
|
||||
getSCUMMapGeometry?: () => unknown;
|
||||
listSCUMSquadMembers?: () => Promise<unknown>;
|
||||
listSCUMVehicles?: () => Promise<unknown>;
|
||||
listSCUMFlags?: () => Promise<unknown>;
|
||||
@@ -15,4 +16,10 @@ export interface PluginPageWorkspaceActions {
|
||||
listSCUMWorkflows?: () => Promise<unknown>;
|
||||
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
|
||||
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
|
||||
listGameGiftCatalogs?: () => Promise<unknown>;
|
||||
saveGameGiftCatalog?: (request: unknown) => Promise<unknown>;
|
||||
publishGameGiftCatalog?: (catalogId: string) => Promise<unknown>;
|
||||
listGameGiftGrants?: () => Promise<unknown>;
|
||||
requestGameGiftGrant?: (request: unknown) => Promise<unknown>;
|
||||
approveGameGiftGrant?: (grantId: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -85,10 +85,10 @@ describe("PluginPageHostPage", () => {
|
||||
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
|
||||
});
|
||||
|
||||
it("renders the persisted SCUM dataset surface without a browser-side bundle dependency", () => {
|
||||
it("loads the declared SCUM bundle without a browser-side game branch", () => {
|
||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
|
||||
expect(html).toContain("未绑定服务器");
|
||||
expect(html).toContain("正在读取用户同步数据");
|
||||
expect(html).toContain("正在校验并加载插件页面 bundle");
|
||||
});
|
||||
|
||||
it("remains a manifest-driven host without SCUM component imports or game branches", () => {
|
||||
@@ -104,7 +104,7 @@ describe("PluginPageHostPage", () => {
|
||||
expect(hostSource).toContain("listSCUMActivity: () => platformApiClient.listSCUMActivity(serverId)");
|
||||
expect(hostSource).toContain("listSCUMGifts: () => platformApiClient.listSCUMGifts(serverId)");
|
||||
expect(hostSource).toContain("listSCUMMapPoints: () => platformApiClient.listSCUMMapPoints(serverId)");
|
||||
expect(hostSource).toContain("SCUMPersistedDataView");
|
||||
expect(hostSource).not.toContain("SCUMPersistedDataView");
|
||||
expect(hostSource).toContain("createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never)");
|
||||
expect(hostSource).toContain("createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never)");
|
||||
expect(hostSource).toContain("listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)");
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType }
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { PageFrame } from "../components/PageFrame";
|
||||
import { SCUMPersistedDataView, scumDatasetForPluginRoute } from "../components/SCUMPersistedDataView";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
||||
@@ -76,6 +75,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
listSCUMActivity: () => platformApiClient.listSCUMActivity(serverId),
|
||||
listSCUMGifts: () => platformApiClient.listSCUMGifts(serverId),
|
||||
listSCUMMapPoints: () => platformApiClient.listSCUMMapPoints(serverId),
|
||||
getSCUMMapGeometry: () => readyPluginRef.current?.mapTrajectories,
|
||||
listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId),
|
||||
listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId),
|
||||
listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId),
|
||||
@@ -85,7 +85,13 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
approveSCUMOperation: (operationId) => platformApiClient.approveSCUMOperation(serverId, operationId),
|
||||
listSCUMWorkflows: () => platformApiClient.listSCUMWorkflows(serverId),
|
||||
createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never),
|
||||
listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)
|
||||
listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId),
|
||||
listGameGiftCatalogs: () => platformApiClient.listGameGiftCatalogs(serverId),
|
||||
saveGameGiftCatalog: (request) => platformApiClient.saveGameGiftCatalog(serverId, request as never),
|
||||
publishGameGiftCatalog: (catalogId) => platformApiClient.publishGameGiftCatalog(serverId, catalogId),
|
||||
listGameGiftGrants: () => platformApiClient.listGameGiftGrants(serverId),
|
||||
requestGameGiftGrant: (request) => platformApiClient.requestGameGiftGrant(serverId, request as never),
|
||||
approveGameGiftGrant: (grantId) => platformApiClient.approveGameGiftGrant(serverId, grantId)
|
||||
};
|
||||
}, [pluginId, serverId]);
|
||||
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
|
||||
@@ -117,14 +123,12 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
if (!hostContext) {
|
||||
return <ErrorState title="插件页面不可用" reason="插件页面上下文初始化失败。" />;
|
||||
}
|
||||
const scumDataset = state.plugin.serverType === "scum" ? scumDatasetForPluginRoute(routeKey) : null;
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
{scumDataset && <SCUMPersistedDataView serverInstanceId={serverId} dataset={scumDataset} />}
|
||||
{!scumDataset && bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!scumDataset && !bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
|
||||
{!scumDataset && bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -152,10 +156,9 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
|
||||
<div className="action-list" aria-label="plugin page declarations"><span><strong>Bundle</strong> {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}</span><span><strong>完整性</strong> {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}</span><span><strong>Companion</strong> {availability.available ? "可用" : "不可用"}</span></div>
|
||||
</section>
|
||||
{scumDataset && <SCUMPersistedDataView serverInstanceId={serverId} dataset={scumDataset} />}
|
||||
{!scumDataset && bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!scumDataset && !bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
||||
{!scumDataset && bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"gameVersion": "0.9.700.90357",
|
||||
"items": [
|
||||
{ "key": "bandage", "label": "绷带", "maximumQuantity": 20 },
|
||||
{ "key": "water-bottle", "label": "饮用水", "maximumQuantity": 10 },
|
||||
{ "key": "improvised-spear", "label": "简易长矛", "maximumQuantity": 2 }
|
||||
]
|
||||
}
|
||||
@@ -17,30 +17,46 @@ export type SCUMPageContext = {
|
||||
};
|
||||
|
||||
type SCUMWorkspaceActions = {
|
||||
listSCUMPlayers?: () => Promise<unknown>;
|
||||
listSCUMUsers?: () => Promise<unknown>;
|
||||
listSCUMPlayers?: () => Promise<unknown>;
|
||||
listSCUMSquads?: () => Promise<unknown>;
|
||||
listSCUMSquadMembers?: () => Promise<unknown>;
|
||||
listSCUMVehicles?: () => Promise<unknown>;
|
||||
listSCUMFlags?: () => Promise<unknown>;
|
||||
listSCUMPositions?: () => Promise<unknown>;
|
||||
listSCUMPositions?: () => Promise<unknown>;
|
||||
listSCUMMapPoints?: () => Promise<unknown>;
|
||||
listSCUMGifts?: () => Promise<unknown>;
|
||||
getSCUMMapGeometry?: () => unknown;
|
||||
listSCUMOperations?: () => Promise<unknown>;
|
||||
createSCUMOperation?: (request: unknown) => Promise<unknown>;
|
||||
listSCUMWorkflows?: () => Promise<unknown>;
|
||||
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
|
||||
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
|
||||
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
|
||||
listGameGiftCatalogs?: () => Promise<unknown>;
|
||||
saveGameGiftCatalog?: (request: unknown) => Promise<unknown>;
|
||||
publishGameGiftCatalog?: (catalogId: string) => Promise<unknown>;
|
||||
listGameGiftGrants?: () => Promise<unknown>;
|
||||
requestGameGiftGrant?: (request: unknown) => Promise<unknown>;
|
||||
approveGameGiftGrant?: (grantId: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type RecordMap = Record<string, unknown>;
|
||||
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
|
||||
type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string };
|
||||
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[] };
|
||||
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; mapPoints: RecordMap[]; giftEvents: RecordMap[]; catalogs: RecordMap[]; grants: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[]; mapGeometry?: RecordMap };
|
||||
|
||||
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], operations: [], workflows: [], steps: [] };
|
||||
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], mapPoints: [], giftEvents: [], catalogs: [], grants: [], operations: [], workflows: [], steps: [] };
|
||||
|
||||
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
|
||||
const e = react.createElement;
|
||||
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
|
||||
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
|
||||
const [playerQuery, setPlayerQuery] = usePluginState(react, "");
|
||||
const [squadFilter, setSquadFilter] = usePluginState(react, "");
|
||||
const [selectedSquad, setSelectedSquad] = usePluginState(react, "");
|
||||
const [layers, setLayers] = usePluginState<Record<string, boolean>>(react, { player: true, vehicle: true, base: true, flag: true });
|
||||
const [selectedMarker, setSelectedMarker] = usePluginState<RecordMap | undefined>(react, undefined);
|
||||
const [giftTab, setGiftTab] = usePluginState(react, "catalogs");
|
||||
const pageKey = input.pageKey ?? "players";
|
||||
|
||||
const refresh = () => {
|
||||
@@ -51,9 +67,9 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
}
|
||||
setState({ status: "loading" });
|
||||
void Promise.all([
|
||||
safeList(actions.listSCUMPlayers), safeList(actions.listSCUMSquads), safeList(actions.listSCUMSquadMembers), safeList(actions.listSCUMVehicles),
|
||||
safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions), safeList(actions.listSCUMOperations), safeList(actions.listSCUMWorkflows), safeList(actions.listSCUMWorkflowSteps)
|
||||
]).then(([players, squads, members, vehicles, flags, positions, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, operations, workflows, steps } }))
|
||||
safeList(actions.listSCUMPlayers), safeList(actions.listSCUMSquads), safeList(actions.listSCUMSquadMembers), safeList(actions.listSCUMVehicles), safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions),
|
||||
safeList(actions.listSCUMMapPoints), safeList(actions.listSCUMGifts), safeList(actions.listGameGiftCatalogs), safeList(actions.listGameGiftGrants), safeList(actions.listSCUMOperations), safeList(actions.listSCUMWorkflows), safeList(actions.listSCUMWorkflowSteps)
|
||||
]).then(([players, squads, members, vehicles, flags, positions, mapPoints, giftEvents, catalogs, grants, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, mapPoints, giftEvents, catalogs, grants, operations, workflows, steps, mapGeometry: asRecord(actions.getSCUMMapGeometry?.()) } }))
|
||||
.catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 投影读取失败。" }));
|
||||
};
|
||||
|
||||
@@ -72,25 +88,30 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
|
||||
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影…") : null,
|
||||
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
|
||||
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh) : null
|
||||
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh, { playerQuery, setPlayerQuery, squadFilter, setSquadFilter, selectedSquad, setSelectedSquad, layers, setLayers, selectedMarker, setSelectedMarker, giftTab, setGiftTab }) : null
|
||||
);
|
||||
}
|
||||
|
||||
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
|
||||
type SurfaceControls = { playerQuery: string; setPlayerQuery: StateSetter<string>; squadFilter: string; setSquadFilter: StateSetter<string>; selectedSquad: string; setSelectedSquad: StateSetter<string>; layers: Record<string, boolean>; setLayers: StateSetter<Record<string, boolean>>; selectedMarker?: RecordMap; setSelectedMarker: StateSetter<RecordMap | undefined>; giftTab: string; setGiftTab: StateSetter<string> };
|
||||
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, controls: SurfaceControls) {
|
||||
switch (pageKey) {
|
||||
case "players": return playersSurface(e, data, input, setAction, refresh);
|
||||
case "squads": return squadsSurface(e, data);
|
||||
case "live-map": return mapSurface(e, data);
|
||||
case "gifts": return giftsSurface(e, data, input, setAction, refresh);
|
||||
case "players": return playersSurface(e, data, input, setAction, refresh, controls);
|
||||
case "squads": return squadsSurface(e, data, controls);
|
||||
case "live-map": return mapSurface(e, data, controls);
|
||||
case "gifts": return giftsSurface(e, data, input, setAction, refresh, controls);
|
||||
case "workflows": return workflowsSurface(e, data);
|
||||
default: return playersSurface(e, data, input, setAction, refresh);
|
||||
default: return playersSurface(e, data, input, setAction, refresh, controls);
|
||||
}
|
||||
}
|
||||
|
||||
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
|
||||
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, controls: SurfaceControls) {
|
||||
const squads = uniqueStrings(data.players.map((player) => textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId")));
|
||||
const query = controls.playerQuery.trim().toLowerCase();
|
||||
const players = data.players.filter((player) => (!controls.squadFilter || controls.squadFilter === (textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId"))) && (!query || ["DisplayName", "displayName", "SteamID", "steamId", "SquadName", "squadName"].some((key) => textField(player, key).toLowerCase().includes(query))));
|
||||
return e("div", { className: "console-record-list" },
|
||||
statsStrip(e, [["玩家投影", data.players.length], ["在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标", data.positions.length], ["待审操作", data.operations.filter((op) => field(op, "Status", "status") === "waiting").length]]),
|
||||
data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
|
||||
e("div", { className: "server-toolbar" }, e("input", { type: "search", value: controls.playerQuery, placeholder: "搜索昵称、Steam 或队伍", onChange: (event: { target: { value: string } }) => controls.setPlayerQuery(event.target.value) }), e("select", { value: controls.squadFilter, onChange: (event: { target: { value: string } }) => controls.setSquadFilter(event.target.value) }, e("option", { value: "" }, "全部队伍"), squads.map((squad) => e("option", { key: squad, value: squad }, squad)))),
|
||||
players.length ? players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
|
||||
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")),
|
||||
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, freshness(player))),
|
||||
e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`),
|
||||
@@ -99,37 +120,57 @@ function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, in
|
||||
operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000),
|
||||
operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true)
|
||||
)
|
||||
)) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
|
||||
)) : e("p", { className: "page-status" }, data.players.length ? "没有符合筛选条件的真实玩家投影。" : "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
|
||||
);
|
||||
}
|
||||
|
||||
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
||||
return e("div", { className: "overview-two-col" },
|
||||
tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, freshness(squad)]),
|
||||
tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", freshness(item)])
|
||||
);
|
||||
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, controls: SurfaceControls) {
|
||||
const selected = controls.selectedSquad || textField(data.squads[0], "SquadID", "squadId");
|
||||
const roster = data.members.filter((member) => textField(member, "SquadID", "squadId") === selected);
|
||||
const flags = data.flags.filter((flag) => textField(flag, "OwnerSquadID", "ownerSquadId") === selected);
|
||||
const squadRows = e("div", { className: "console-row-list" }, data.squads.map((squad) => {
|
||||
const id = textField(squad, "SquadID", "squadId");
|
||||
return e("button", { type: "button", key: idOf(squad), className: `console-row ${selected === id ? "console-row-selected" : ""}`, onClick: () => controls.setSelectedSquad(id) }, e("span", null, textField(squad, "Name", "name") || id), e("strong", null, `成员 ${numField(squad, "MemberCount", "memberCount")}`), e("strong", null, `分数 ${numField(squad, "Score", "score")}`), e("strong", null, freshness(squad)));
|
||||
}));
|
||||
const rosterRows = roster.length ? e("div", { className: "console-row-list" }, roster.map((member) => e("div", { key: idOf(member), className: "console-row" }, e("span", null, textField(member, "DisplayName", "displayName") || "未知成员"), e("strong", null, textField(member, "IsLeader", "isLeader") === "true" ? "队长" : `军衔 ${textField(member, "Rank", "rank") || "--"}`), e("strong", null, `Steam ${textField(member, "SteamID", "steamId") || "--"}`), e("strong", null, freshness(member))))) : e("p", { className: "page-status" }, "此队伍暂无成员投影。");
|
||||
const flagRows = e("div", { className: "console-row-list" }, flags.map((flag) => e("div", { key: idOf(flag), className: "console-row" }, e("span", null, `旗帜 ${textField(flag, "FlagID", "flagId")}`), e("strong", null, textField(flag, "OwnershipConfidence", "ownershipConfidence") || "verified"), e("strong", null, coords(field(flag, "Position", "position") as RecordMap)), e("strong", null, freshness(flag)))));
|
||||
return e("div", { className: "overview-two-col" }, e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "队伍"), e("span", { className: "page-status" }, `${data.squads.length} 条`)), squadRows), e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "成员 / 旗帜"), e("span", { className: "page-status" }, selected || "未选择队伍")), rosterRows, flagRows));
|
||||
}
|
||||
|
||||
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
||||
const overlays = [...data.positions, ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(Boolean), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(Boolean)];
|
||||
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, controls: SurfaceControls) {
|
||||
const overlays = mapOverlays(data);
|
||||
const visible = overlays.filter((point) => controls.layers[textField(point, "SubjectType", "subjectType")] !== false);
|
||||
return e("div", { className: "console-record-list" },
|
||||
statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]),
|
||||
e("div", { className: "map-projection-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-projection-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))),
|
||||
tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
|
||||
statsStrip(e, [["玩家", overlays.filter((point) => textField(point, "SubjectType", "subjectType") === "player").length], ["载具", overlays.filter((point) => textField(point, "SubjectType", "subjectType") === "vehicle").length], ["旗帜", overlays.filter((point) => textField(point, "SubjectType", "subjectType") === "flag").length], ["坐标点", visible.length]]),
|
||||
e("div", { className: "map-layer-controls" }, ["player", "vehicle", "base", "flag"].map((kind) => e("label", { key: kind }, e("input", { type: "checkbox", checked: controls.layers[kind] !== false, onChange: () => controls.setLayers((previous) => ({ ...previous, [kind]: previous[kind] === false })) }), mapKindLabel(kind)))),
|
||||
e("div", { className: "map-projection-board" }, visible.slice(0, 500).map((point, index) => e("button", { type: "button", key: `${idOf(point)}:${index}`, className: `map-projection-dot map-projection-dot-${textField(point, "SubjectType", "subjectType") || "unknown"}`, title: `${mapKindLabel(textField(point, "SubjectType", "subjectType"))} ${coords(point)}`, style: dotStyle(point, data.mapGeometry), onClick: () => controls.setSelectedMarker(point) }, ""))),
|
||||
controls.selectedMarker ? e("article", { className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, mapKindLabel(textField(controls.selectedMarker, "SubjectType", "subjectType"))), e("span", { className: "status-pill status-active" }, freshness(controls.selectedMarker))), e("div", { className: "console-record-meta" }, e("span", null, `对象 ${textField(controls.selectedMarker, "SubjectID", "subjectId") || "--"}`), e("span", null, coords(controls.selectedMarker)), e("span", null, `观察 ${dateField(controls.selectedMarker, "ObservedAt", "observedAt")}`))) : null,
|
||||
tablePanel(e, "地图覆盖物", visible, (point) => [mapKindLabel(textField(point, "SubjectType", "subjectType")), textField(point, "Label", "label") || textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
|
||||
);
|
||||
}
|
||||
|
||||
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
|
||||
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, controls: SurfaceControls) {
|
||||
const tab = controls.giftTab;
|
||||
return e("div", { className: "console-record-list" },
|
||||
statsStrip(e, [["可选玩家", data.players.length], ["发放操作", data.operations.filter((op) => textField(op, "TemplateKey", "templateKey") === "reward.deliver").length], ["未知态", data.operations.filter((op) => field(op, "Status", "status") === "unknown").length]]),
|
||||
e("p", { className: "page-status" }, "礼包只创建 typed delivery workflow;确认结果未知时不会重复发放。"),
|
||||
data.players.slice(0, 40).map((player) => e("article", { key: idOf(player), className: "console-record" },
|
||||
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || idOf(player)), e("span", { className: "status-pill status-disabled" }, freshness(player))),
|
||||
e("div", { className: "console-row-actions" }, operationButton(e, input, setAction, refresh, player, "reward.deliver", "rewardKey", "创建礼包发放", "starter-pack"), operationButton(e, input, setAction, refresh, player, "player.notify", "message", "发送通知", "你的礼包正在审核发放。"))
|
||||
))
|
||||
statsStrip(e, [["礼包定义", (data.catalogs ?? []).length], ["发放单", (data.grants ?? []).length], ["定时礼包完成", (data.giftEvents ?? []).length], ["可选玩家", data.players.length]]),
|
||||
e("p", { className: "page-status" }, "礼包只走 typed delivery workflow;结果未知时不会重复发放。"),
|
||||
e("div", { className: "section-tabs" }, [["catalogs", "礼包定义"], ["grants", "发放记录"], ["events", "定时礼包完成"]].map(([key, label]) => e("button", { type: "button", key, className: `section-tab ${tab === key ? "section-tab-active" : ""}`, onClick: () => controls.setGiftTab(key) }, label))),
|
||||
tab === "catalogs" ? giftCatalogSurface(e, data, input, setAction, refresh) : null,
|
||||
tab === "grants" ? giftGrantSurface(e, data, input, setAction, refresh) : null,
|
||||
tab === "events" ? tablePanel(e, "游戏内已完成定时礼包", data.giftEvents ?? [], (event) => [textField(event, "displayName", "DisplayName") || "未知玩家", textField(event, "giftType", "GiftType") || "finished-timed-gift", dateField(event, "spawnAt", "SpawnAt"), textField(event, "mapId", "MapID") || "--"]) : null
|
||||
);
|
||||
}
|
||||
|
||||
function giftCatalogSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
|
||||
const catalogs = data.catalogs ?? [];
|
||||
return e("div", { className: "console-row-list" }, catalogs.length ? catalogs.map((catalog) => e("article", { key: idOf(catalog), className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, textField(catalog, "Name", "name")), e("span", { className: "status-pill status-active" }, textField(catalog, "GameVersion", "gameVersion"))), e("span", { className: "provider-id" }, giftItemsText(field(catalog, "DraftItems", "draftItems") as RecordMap[])), e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => publishCatalog(input, setAction, refresh, idOf(catalog)) }, "发布版本")))) : e("p", { className: "page-status" }, "暂无平台运营礼包定义。"));
|
||||
}
|
||||
|
||||
function giftGrantSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
|
||||
const grants = data.grants ?? [];
|
||||
return e("div", { className: "console-row-list" }, grants.length ? grants.map((grant) => e("article", { key: idOf(grant), className: "console-record" }, e("div", { className: "console-record-head" }, e("strong", null, textField(grant, "PlayerDisplayName", "playerDisplayName") || "未知玩家"), e("span", { className: "status-pill status-disabled" }, textField(grant, "Status", "status"))), e("span", { className: "provider-id" }, giftItemsText(field(grant, "Items", "items") as RecordMap[])), textField(grant, "Status", "status") === "pending-approval" ? e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => approveGrant(input, setAction, refresh, idOf(grant)) }, "批准发放")) : null)) : e("p", { className: "page-status" }, "暂无礼包发放记录。"));
|
||||
}
|
||||
|
||||
function workflowsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
|
||||
return e("div", { className: "console-record-list" },
|
||||
data.workflows.length ? data.workflows.map((wf) => e("article", { key: idOf(wf), className: "console-record" },
|
||||
@@ -184,7 +225,25 @@ function workflowLabel(templateKey: string): string { return templateKey.include
|
||||
|
||||
function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); }
|
||||
function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实投影数据。"))); }
|
||||
function dotStyle(point: RecordMap): Record<string, string> { const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; }
|
||||
function mapOverlays(data: SCUMSurfaceData): RecordMap[] {
|
||||
const mapPoints = (data.mapPoints ?? data.positions ?? []).map((point) => ({ ...(field(point, "Fields", "fields") as RecordMap), ...point, SubjectType: textField(field(point, "Fields", "fields") as RecordMap, "subjectType", "SubjectType") || textField(point, "SubjectType", "subjectType") || "player" }));
|
||||
if (mapPoints.length) return mapPoints;
|
||||
const vehicles = data.vehicles.map((vehicle) => ({ ...(field(vehicle, "Position", "position") as RecordMap), SubjectType: "vehicle", Label: textField(vehicle, "Label", "label") || textField(vehicle, "ClassName", "className"), VehicleID: textField(vehicle, "VehicleID", "vehicleId") })).filter((point) => Boolean(point));
|
||||
const flags = data.flags.map((flag) => ({ ...(field(flag, "Position", "position") as RecordMap), SubjectType: "flag", Label: textField(flag, "OwnerSquadName", "ownerSquadName"), FlagID: textField(flag, "FlagID", "flagId") })).filter((point) => Boolean(point));
|
||||
return [...mapPoints, ...vehicles, ...flags];
|
||||
}
|
||||
function dotStyle(point: RecordMap, geometry?: RecordMap): Record<string, string> {
|
||||
const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0);
|
||||
const minX = Number(field(geometry, "WorldMinX", "worldMinX")); const maxX = Number(field(geometry, "WorldMaxX", "worldMaxX")); const minY = Number(field(geometry, "WorldMinY", "worldMinY")); const maxY = Number(field(geometry, "WorldMaxY", "worldMaxY"));
|
||||
if ([minX, maxX, minY, maxY].every(Number.isFinite) && maxX > minX && maxY > minY) return { left: `${Math.max(1, Math.min(99, (x - minX) / (maxX - minX) * 100))}%`, top: `${Math.max(1, Math.min(99, 100 - (y - minY) / (maxY - minY) * 100))}%` };
|
||||
return { left: "50%", top: "50%" };
|
||||
}
|
||||
function mapKindLabel(kind: string): string { return kind === "player" ? "玩家" : kind === "vehicle" ? "载具" : kind === "base" ? "基地" : kind === "flag" ? "旗帜" : "实体"; }
|
||||
function uniqueStrings(values: string[]): string[] { return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh-CN")); }
|
||||
function asRecord(value: unknown): RecordMap | undefined { return value && typeof value === "object" ? value as RecordMap : undefined; }
|
||||
function giftItemsText(items: RecordMap[] | undefined): string { return Array.isArray(items) && items.length ? items.map((item) => `${textField(item, "Label", "label", "CatalogItemKey", "catalogItemKey")} x${textField(item, "Quantity", "quantity")}`).join(" · ") : "未声明物品"; }
|
||||
function publishCatalog(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, catalogId: string) { setAction({ status: "pending", message: "正在发布礼包版本…" }); void input.workspaceActions?.publishGameGiftCatalog?.(catalogId).then(() => { setAction({ status: "ok", message: "礼包版本已发布。" }); refresh(); }).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "礼包发布失败。" })); }
|
||||
function approveGrant(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, grantId: string) { setAction({ status: "pending", message: "正在批准礼包发放…" }); void input.workspaceActions?.approveGameGiftGrant?.(grantId).then(() => { setAction({ status: "ok", message: "礼包已进入发放队列。" }); refresh(); }).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "礼包批准失败。" })); }
|
||||
function safeList(fn?: () => Promise<unknown>): Promise<RecordMap[]> { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); }
|
||||
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
|
||||
function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; }
|
||||
|
||||
@@ -405,7 +405,7 @@
|
||||
"parameterSchemaRef": "schemas/bridge/queries/scum-gifts.parameters.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/queries/scum-gifts.result.schema.json",
|
||||
"sqlRef": "sql/scum-db-v57/gifts.sql",
|
||||
"targetTable": "scum_gift_catalogs",
|
||||
"targetTable": "scum_gift_events",
|
||||
"upsertKeys": ["giftId"],
|
||||
"columnMappings": { "giftId": "giftId", "giftType": "giftType", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt", "displayName": "displayName" },
|
||||
"maxRows": 500,
|
||||
@@ -417,7 +417,7 @@
|
||||
"key": "scum-db-v57",
|
||||
"databaseUserVersion": 57,
|
||||
"logParserRefs": ["data-packs/scum-db-v57/log-parsers.json"],
|
||||
"configMapRefs": ["data-packs/scum-db-v57/config-maps.json"]
|
||||
"configMapRefs": ["data-packs/scum-db-v57/config-maps.json", "data-packs/scum-db-v57/gift-items.json"]
|
||||
}
|
||||
],
|
||||
"operationTemplates": [
|
||||
@@ -802,6 +802,10 @@
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/config-maps.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/gift-items.json",
|
||||
"mode": 384
|
||||
}
|
||||
],
|
||||
"productionLifecycle": {
|
||||
|
||||
@@ -112,8 +112,8 @@ describe("SCUM plugin feature module", () => {
|
||||
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
|
||||
expect(gifts.nodes).toContain("section:礼包管理");
|
||||
expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
|
||||
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
|
||||
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
|
||||
expect(gifts.texts).toContain("礼包定义");
|
||||
expect(gifts.texts).toContain("定时礼包完成");
|
||||
|
||||
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
|
||||
expect(workflows.nodes).toContain("section:Workflow 状态");
|
||||
|
||||
Reference in New Issue
Block a user