package dnfbridge // gm_activity_switches.go 提供「活动开关」的运行时配置:服主在启动器的 // 「游戏设置 → 活动开关」页里切换某个活动是否下发,这里负责持久化 + 缓存, // 并让活动目录下发(buildCurrentOnlineEventInfoBodyAt)与各活动投影读取它。 // // 为什么单独一份 scope 而不是塞进 gm-gameplay: // // gameplay 那一份 scope 管的是"数值旋钮"(掉落率、倍率、疲劳、燃烧), // 活动开关管的是"这个活动要不要在客户端显示/可参与",语义与读写频率都不同。 // 分开后,启动器页面可以各自独立刷新,也不用担心一份 scope 里键越堆越多。 // // 持久化与 gameplay 设置同模式:repositories.Settings + scope + mutex 缓存。 // 缓存未就绪时首次读取落一次仓储,之后走内存;写入即时更新缓存并落盘。 // // 开关的默认值(defaults)来自当前部署的真实状态,不是"全开": // // spend_time 累计在线得好礼(2347) 默认关:本部署走 PC 房泡点(228)分支 // level_aiming 等级预约(222) 默认关:按运营要求已关闭 // joust 骑士马战(2365) 默认关:活动已取消 // monthly 月度签到(2370) 默认开 // weekly 周周签到(2208) 默认开 // pcroom PC 房时间点(228) 默认开:当前生效的泡点活动 import ( "context" "fmt" "strconv" "strings" "time" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) const gmActivitySwitchesScope = "global:dnf90:gm-activity-switches" // 活动开关键名。值与 true/false 对应("1"/"true" 为开)。 const ( gmActivitySpendTimeKey = "spend_time" gmActivityLevelAimingKey = "level_aiming" gmActivityJoustKey = "joust" gmActivityMonthlyKey = "monthly_attendance" gmActivityWeeklyKey = "weekly_attendance" gmActivityPCRoomKey = "pcroom_timepoint" ) // gmActivitySwitchDefinition 描述一个可切换的活动。 type gmActivitySwitchDefinition struct { Key string // 持久化键名 Title string // 中文名 Description string // 中文说明 Default bool // 默认开关状态 } // gmActivitySwitchDefinitions 全部可切换活动(顺序即启动器展示顺序)。 var gmActivitySwitchDefinitions = []gmActivitySwitchDefinition{ { Key: gmActivityPCRoomKey, Title: "PC房时间点奖励", Description: "泡点/挂机得奖励(事件 228/561)。当前部署默认开启。", Default: true, }, { Key: gmActivitySpendTimeKey, Title: "累计在线得好礼", Description: "按累计在线时长领取奖励(事件 2347)。与 PC 房泡点互斥,默认关闭。", Default: false, }, { Key: gmActivityMonthlyKey, Title: "月度签到", Description: "每月推荐副本通关计数领奖(事件 2370)。默认开启。", Default: true, }, { Key: gmActivityWeeklyKey, Title: "周周签到", Description: "每周签到领奖(事件 2208)。默认开启。", Default: true, }, { Key: gmActivityLevelAimingKey, Title: "等级预约(命运/决策)", Description: "全职LV预约得好礼(事件 222)。按运营要求默认关闭。", Default: false, }, { Key: gmActivityJoustKey, Title: "骑士马战", Description: "骑士马战竞猜活动(事件 2365)。已取消,默认关闭。", Default: false, }, } // GMActivitySwitch 单个活动的当前开关状态。 type GMActivitySwitch struct { Key string `json:"key"` Title string `json:"title"` Description string `json:"description"` Enabled bool `json:"enabled"` } // GMActivitySwitches 活动开关总览(供 GM 接口 / 启动器展示)。 type GMActivitySwitches struct { Switches []GMActivitySwitch `json:"switches"` UpdatedAt time.Time `json:"updated_at,omitempty"` } // gmActivitySwitchesFromRecord 从设置记录解析全部活动开关。 // 缺失的键回落对应默认值;非法值回落默认值(不做硬失败,保持可恢复)。 func gmActivitySwitchesFromRecord(record dnfrepo.SettingsRecord, found bool) map[string]bool { out := make(map[string]bool, len(gmActivitySwitchDefinitions)) for _, def := range gmActivitySwitchDefinitions { out[def.Key] = def.Default } if !found { return out } for _, def := range gmActivitySwitchDefinitions { raw := strings.TrimSpace(record.Values[def.Key]) if raw == "" { continue } out[def.Key] = raw == "1" || strings.EqualFold(raw, "true") } return out } // GMActivitySwitches 返回当前活动开关总览(带缓存)。 func (s *Service) GMActivitySwitches(ctx context.Context) (GMActivitySwitches, error) { if s == nil { return GMActivitySwitches{}, ErrGMUnavailable } s.gmActivitySwitchesMu.Lock() defer s.gmActivitySwitchesMu.Unlock() if s.gmActivitySwitchesReady { return s.gmActivitySwitchesSnapshotLocked(), nil } repositories, ok := s.repositoryGroup() if !ok || repositories.Settings == nil { return GMActivitySwitches{}, ErrGMUnavailable } record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope) if err != nil { return GMActivitySwitches{}, err } s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found) s.gmActivitySwitchesAt = record.UpdatedAt s.gmActivitySwitchesReady = true return s.gmActivitySwitchesSnapshotLocked(), nil } // GMSetActivitySwitch 切换某个活动的开关状态,返回更新后的总览。 func (s *Service) GMSetActivitySwitch(ctx context.Context, key string, enabled bool) (GMActivitySwitches, error) { if s == nil { return GMActivitySwitches{}, ErrGMUnavailable } key = strings.TrimSpace(strings.ToLower(key)) if !validGMActivitySwitchKey(key) { return GMActivitySwitches{}, fmt.Errorf("%w: 未知的活动开关 %q", ErrGMInvalidRequest, key) } s.gmActivitySwitchesMu.Lock() defer s.gmActivitySwitchesMu.Unlock() repositories, ok := s.repositoryGroup() if !ok || repositories.Settings == nil { return GMActivitySwitches{}, ErrGMUnavailable } // 先确保缓存已就绪(读当前值),再改。 if !s.gmActivitySwitchesReady { record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope) if err != nil { return GMActivitySwitches{}, err } s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found) s.gmActivitySwitchesAt = record.UpdatedAt s.gmActivitySwitchesReady = true } // 读最新记录用于落盘(避免覆盖其它并发写入)。 record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope) if err != nil { return GMActivitySwitches{}, err } if !found { record = dnfrepo.SettingsRecord{Scope: gmActivitySwitchesScope} } else { record = dnfrepo.CloneSettings(record) } if record.Values == nil { record.Values = make(map[string]string) } record.Values[key] = strconv.FormatBool(enabled) record.UpdatedAt = time.Now().UTC() if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil { return GMActivitySwitches{}, err } s.gmActivitySwitches[key] = enabled s.gmActivitySwitchesAt = record.UpdatedAt return s.gmActivitySwitchesSnapshotLocked(), nil } // activitySwitchEnabled 读取某个活动的当前开关(内部无锁读取,调用方自行加锁)。 func (s *Service) activitySwitchEnabledLocked(key string) bool { if s == nil || s.gmActivitySwitches == nil { return false } enabled, ok := s.gmActivitySwitches[key] return ok && enabled } // ActivitySwitchEnabled 读取某个活动的当前开关(公开、带锁)。 // // 语义:开关「开启」意味着该活动照常下发/可参与;「关闭」意味着不下发、 // 客户端不显示该活动。未持久化过时回落定义里的默认值。 // // 注意默认值的含义:本方法在缓存未就绪时会尽力加载一次(懒加载), // 加载失败或仓储不可用时,回落"默认值"——即该活动在定义里的 Default。 // 这样即使 GM 接口从未被调用,玩法侧也能按部署默认(而非全关)工作。 func (s *Service) ActivitySwitchEnabled(ctx context.Context, key string) bool { if s == nil { return false } key = strings.TrimSpace(strings.ToLower(key)) s.gmActivitySwitchesMu.Lock() defer s.gmActivitySwitchesMu.Unlock() if !s.gmActivitySwitchesReady { if repositories, ok := s.repositoryGroup(); ok && repositories.Settings != nil { if record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope); err == nil { s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found) s.gmActivitySwitchesAt = record.UpdatedAt s.gmActivitySwitchesReady = true } } } if s.gmActivitySwitches == nil { for _, def := range gmActivitySwitchDefinitions { if def.Key == key { return def.Default } } return false } if enabled, ok := s.gmActivitySwitches[key]; ok { return enabled } for _, def := range gmActivitySwitchDefinitions { if def.Key == key { return def.Default } } return false } // gmActivitySwitchesSnapshotLocked 组装总览(调用方须持 gmActivitySwitchesMu)。 func (s *Service) gmActivitySwitchesSnapshotLocked() GMActivitySwitches { switches := make([]GMActivitySwitch, 0, len(gmActivitySwitchDefinitions)) for _, def := range gmActivitySwitchDefinitions { enabled := def.Default if s.gmActivitySwitches != nil { if value, ok := s.gmActivitySwitches[def.Key]; ok { enabled = value } } switches = append(switches, GMActivitySwitch{ Key: def.Key, Title: def.Title, Description: def.Description, Enabled: enabled, }) } return GMActivitySwitches{ Switches: switches, UpdatedAt: s.gmActivitySwitchesAt, } } func validGMActivitySwitchKey(key string) bool { for _, def := range gmActivitySwitchDefinitions { if def.Key == key { return true } } return false }