Align SCUM operations with reference tools

This commit is contained in:
npc0-hue
2026-08-13 18:21:08 +08:00
parent 8b236d7c15
commit da01d39866
24 changed files with 375 additions and 105 deletions
+1 -1
View File
@@ -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) {
+31 -22
View File
@@ -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
}
+10 -8
View File
@@ -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 {
+1 -2
View File
@@ -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}
}
+3 -3
View File
@@ -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" }
+3 -3
View File
@@ -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:
+42 -10
View File
@@ -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 {
+1
View File
@@ -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)
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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