启动器新增游戏设置页(频道开关+活动开关),窗口标题改名

- 服务端新增活动开关运行时配置与 GM 接口(GET/POST /dnf/gm/activity-switches),
  持久化到 Settings,接入月签到/周签到/PC房↔累计在线的实时下发
- 服务端新增频道开关 GM 接口(channel-switches),目录生成时过滤禁用频道
- channelcatalog/channelinfo 支持按禁用频道 ID 过滤目录与客户端脚本
- 启动器新增「游戏设置」页面:频道开关 Tab + 活动开关 Tab
- native.py/server.py 新增对应转发 API
- 窗口标题/页面 title/侧边栏「90轮回」统一改为「DNF90 灵狐小竹整合单机版」
This commit is contained in:
hanfuit committed 2026-11-04 20:08:50 +08:00
1 parent 977a4a1196
commit 0ced464168
15 files changed
+1022 -14

No files matched your search

@@ -373,7 +373,7 @@ func (s *Service) buildScriptBody() ([]byte, error) {
if err != nil {
return nil, err
}
script, err = s.buildChannelScript(raw)
script, err = s.buildChannelScriptFiltered(raw, s.loadDisabledChannelIDs())
if err != nil {
return nil, fmt.Errorf("build 90CN online channel script: %w", err)
}
@@ -0,0 +1,283 @@
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
}
@@ -0,0 +1,303 @@
package dnfbridge
// gm_channel_switches.go 提供「频道开关」的运行时配置:服主在启动器的
// 「游戏设置 → 频道开关」页里关掉某个频道,这里持久化禁用频道集合,
// 并在频道目录(channelcatalog.Catalog)与客户端频道脚本(channelScript)
// 构建时过滤掉被禁用的频道,随后对账游戏端口监听(关掉的频道不再开端口)。
//
// 与 gm_channel_reload.go 的热重载区别:
//
// 热重载是"重读 channel_info.etc 这个文件",适合运维在文件里改完后一键生效;
// 频道开关是"运行时按频道 ID 记录禁用状态",不要求改文件,开关即改内存目录 +
// 落盘,重启后依然生效(从 Settings 读回)。
//
// 持久化与 activity/gameplay 设置同模式:repositories.Settings + scope + mutex 缓存。
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/channelinfo"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const gmChannelSwitchesScope = "global:dnf90:gm-channel-switches"
// gmChannelDisabledKeyPrefix 是持久化里"禁用频道"键的前缀,键形如 "disabled:<id>"。
const gmChannelDisabledKeyPrefix = "disabled:"
// GMChannelSwitch 单个频道的开关状态。
type GMChannelSwitch struct {
ID int `json:"id"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Type uint8 `json:"type"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
}
// GMChannelSwitches 频道开关总览(供 GM 接口 / 启动器展示)。
type GMChannelSwitches struct {
TotalChannels int `json:"total_channels"`
EnabledChannels int `json:"enabled_channels"`
DisabledChannels int `json:"disabled_channels"`
Channels []GMChannelSwitch `json:"channels"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// gmDisabledChannelIDsFromRecord 从设置记录解析禁用频道 ID 集合。
func gmDisabledChannelIDsFromRecord(record dnfrepo.SettingsRecord, found bool) map[int]bool {
out := make(map[int]bool)
if !found {
return out
}
for key, value := range record.Values {
if !strings.HasPrefix(key, gmChannelDisabledKeyPrefix) {
continue
}
if strings.TrimSpace(value) != "1" && !strings.EqualFold(strings.TrimSpace(value), "true") {
continue
}
idText := strings.TrimPrefix(key, gmChannelDisabledKeyPrefix)
id, err := strconv.Atoi(idText)
if err != nil || id <= 0 {
continue
}
out[id] = true
}
return out
}
// gmDisabledChannelIDsLocked 返回当前禁用的频道 ID 集合(调用方须持 gmChannelSwitchesMu)。
func (s *Service) gmDisabledChannelIDsLocked() map[int]bool {
if s == nil || s.gmChannelSwitches == nil {
return nil
}
return s.gmChannelSwitches
}
// GMChannelSwitches 返回当前频道开关总览(带缓存)。
func (s *Service) GMChannelSwitches(ctx context.Context) (GMChannelSwitches, error) {
if s == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
s.gmChannelSwitchesMu.Lock()
defer s.gmChannelSwitchesMu.Unlock()
if s.gmChannelSwitchesReady {
return s.gmChannelSwitchesSnapshotLocked(), nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
if err != nil {
return GMChannelSwitches{}, err
}
s.gmChannelSwitches = gmDisabledChannelIDsFromRecord(record, found)
s.gmChannelSwitchesAt = record.UpdatedAt
s.gmChannelSwitchesReady = true
return s.gmChannelSwitchesSnapshotLocked(), nil
}
// GMSetChannelSwitch 切换某频道的启用状态,返回更新后的总览。
//
// 生效机制:更新持久化的禁用集合 → 重建频道目录与脚本(应用禁用过滤)→
// 对账游戏端口监听。与 GMReloadChannels 一样,失败时保留原运行态。
func (s *Service) GMSetChannelSwitch(ctx context.Context, channelID int, enabled bool) (GMChannelSwitches, error) {
if s == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
if channelID <= 0 {
return GMChannelSwitches{}, fmt.Errorf("%w: channel id must be positive", ErrGMInvalidRequest)
}
s.gmChannelSwitchesMu.Lock()
defer s.gmChannelSwitchesMu.Unlock()
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
if !s.gmChannelSwitchesReady {
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
if err != nil {
return GMChannelSwitches{}, err
}
s.gmChannelSwitches = gmDisabledChannelIDsFromRecord(record, found)
s.gmChannelSwitchesAt = record.UpdatedAt
s.gmChannelSwitchesReady = true
}
// 确认该频道确实存在于当前目录(不能凭空开关一个不存在的频道)。
active := s.currentCatalog()
if active == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
if _, exists := active.Channel(channelID); !exists {
return GMChannelSwitches{}, fmt.Errorf("%w: channel %d not found", ErrGMInvalidRequest, channelID)
}
// 读最新记录落盘。
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
if err != nil {
return GMChannelSwitches{}, err
}
if !found {
record = dnfrepo.SettingsRecord{Scope: gmChannelSwitchesScope}
} else {
record = dnfrepo.CloneSettings(record)
}
if record.Values == nil {
record.Values = make(map[string]string)
}
key := gmChannelDisabledKeyPrefix + strconv.Itoa(channelID)
if enabled {
delete(record.Values, key)
} else {
record.Values[key] = "1"
}
record.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
return GMChannelSwitches{}, err
}
// 更新内存缓存。
newDisabled := gmDisabledChannelIDsFromRecord(record, true)
s.gmChannelSwitches = newDisabled
s.gmChannelSwitchesAt = record.UpdatedAt
// 应用过滤:重建目录与脚本并热替换(复用热重载的对账逻辑,但源是内存禁用集合)。
if err := s.applyChannelSwitchesLocked(newDisabled); err != nil {
// 目录应用失败不覆盖已落盘的开关,下次读回仍会尝试应用。
return GMChannelSwitches{}, fmt.Errorf("%w: 应用频道开关失败: %v", ErrGMUnavailable, err)
}
return s.gmChannelSwitchesSnapshotLocked(), nil
}
// applyChannelSwitchesLocked 用禁用集合重建频道目录与脚本并热替换(调用方须持 gmChannelSwitchesMu)。
// 与 GMReloadChannels 同源,但过滤来源是内存禁用集合而非 etc 文件。
func (s *Service) applyChannelSwitchesLocked(disabled map[int]bool) error {
raw, err := osReadFile(s.options.channelInfoFile)
if err != nil {
return fmt.Errorf("load dnf channel info: %w", err)
}
index, err := channelinfo.Parse(raw)
if err != nil {
return fmt.Errorf("parse dnf channel info: %w", err)
}
catalog, err := channelcatalog.New(index, channelcatalog.Options{
ServerID: s.channelServerID(),
DisabledIDs: disabled,
})
if err != nil {
return err
}
script, err := s.buildChannelScriptFiltered(raw, disabled)
if err != nil {
return fmt.Errorf("build 90CN online channel script: %w", err)
}
s.mu.Lock()
oldCatalog := s.catalog
runCtx := s.runCtx
if s.cancel == nil || runCtx == nil {
s.mu.Unlock()
return fmt.Errorf("服务未启动,无法应用频道开关")
}
s.catalog = catalog
s.channelScript = append([]byte(nil), script...)
s.mu.Unlock()
// 对账游戏端口监听(与 GMReloadChannels 同逻辑)。
oldPorts := []int(nil)
if oldCatalog != nil {
oldPorts = oldCatalog.GamePorts()
}
newPorts := catalog.GamePorts()
for _, port := range oldPorts {
if containsIntValue(newPorts, port) {
continue
}
_ = s.closeGamePort(port)
}
for _, port := range newPorts {
if containsIntValue(oldPorts, port) {
continue
}
if err := s.listenGamePort(runCtx, port); err != nil {
s.logWarn("dnfbridge channel switch open port failed", "port", port, "error", err)
}
}
s.logInfo("dnfbridge channel switches applied",
"disabled", len(disabled),
"total_channels", len(catalog.Channels()))
return nil
}
// buildChannelScriptFiltered 用禁用集合生成客户端频道脚本。
func (s *Service) buildChannelScriptFiltered(raw []byte, disabled map[int]bool) ([]byte, error) {
if s.channelTableStrict() {
return channelinfo.Build90CNOnlineScriptFiltered(
raw,
s.channelServerID(),
s.channelAdvertiseID(),
dnfenum.BootstrapChannelID,
disabled,
)
}
script, bootstrapPresent, err := channelinfo.Build90CNOnlineScriptAllowingMissingBootstrapFiltered(
raw,
s.channelServerID(),
s.channelAdvertiseID(),
dnfenum.BootstrapChannelID,
disabled,
)
if err != nil {
return nil, err
}
if !bootstrapPresent {
s.logWarn("dnfbridge channel table has no bootstrap channel; applying switch anyway",
"bootstrap_channel", dnfenum.BootstrapChannelID)
}
return script, nil
}
// gmChannelSwitchesSnapshotLocked 组装总览(调用方须持 gmChannelSwitchesMu)。
func (s *Service) gmChannelSwitchesSnapshotLocked() GMChannelSwitches {
active := s.currentCatalog()
disabled := s.gmDisabledChannelIDsLocked()
if active == nil {
return GMChannelSwitches{
Channels: []GMChannelSwitch{},
UpdatedAt: s.gmChannelSwitchesAt,
}
}
channels := active.Channels()
out := make([]GMChannelSwitch, 0, len(channels))
enabledCount := 0
for _, channel := range channels {
isDisabled := disabled != nil && disabled[channel.ID]
enabled := !isDisabled
if enabled {
enabledCount++
}
out = append(out, GMChannelSwitch{
ID: channel.ID,
Name: channel.Name,
Group: channel.Group,
Type: channel.Type,
Port: channel.Port,
Enabled: enabled,
})
}
return GMChannelSwitches{
TotalChannels: len(channels),
EnabledChannels: enabledCount,
DisabledChannels: len(channels) - enabledCount,
Channels: out,
UpdatedAt: s.gmChannelSwitchesAt,
}
}
@@ -59,6 +59,10 @@ func (s *Service) sendCurrentMonthlyAttendance(session *gameSession, claim bool)
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
// 活动开关:月度签到被关闭时不下发(客户端活动窗口不显示该活动)。
if !s.ActivitySwitchEnabled(context.Background(), gmActivityMonthlyKey) {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
defer cancel()
catalog, err := s.currentMonthlyCatalog(ctx)
@@ -488,12 +488,15 @@ func (s *Service) sendCurrentSpendTimeProtocolState(
return false, nil
}
var eventInfoTransport []byte
// 活动开关:pcroom_timepoint 开关决定走「PC 房时间点(228)」还是「累计在线得好礼(2347)」。
// 开关关闭时强制走 2347 分支(即使 etc/pcroomtimepoint.etc 存在)。
usePCRoom := catalog.nativePCRoom != nil && s.ActivitySwitchEnabled(context.Background(), gmActivityPCRoomKey)
if includeFirstProcessDescriptor {
eventInfoBody, err := buildCurrentOnlineEventInfoBodyAt(
catalog.rewardItemIDs,
catalog.sumRewardItemIDs,
catalog.totalStageSeconds,
catalog.nativePCRoom != nil,
usePCRoom,
catalog.laborDay, s.gameplayNow(),
)
if err != nil {
@@ -526,7 +529,7 @@ func (s *Service) sendCurrentSpendTimeProtocolState(
}
// Install the directory before the selected online owner's state. The
// original four-plus-four panel is 228/561; 2347/1206 is a different HUD.
if catalog.nativePCRoom != nil {
if usePCRoom {
if err := s.sendCurrentPCRoomTimePoint(session, catalog, 0, -1); err != nil {
return descriptorSent, err
}
+35 -2
View File
@@ -284,6 +284,16 @@ type Service struct {
gmFatigueMode string
gmBurningEnabled bool
gmGameplaySettingsAt time.Time
// gmActivitySwitches 是活动开关的运行时缓存(见 gm_activity_switches.go)。
gmActivitySwitchesMu sync.Mutex
gmActivitySwitchesReady bool
gmActivitySwitches map[string]bool
gmActivitySwitchesAt time.Time
// gmChannelSwitches 是频道开关(禁用频道 ID 集合)的运行时缓存(见 gm_channel_switches.go)。
gmChannelSwitchesMu sync.Mutex
gmChannelSwitchesReady bool
gmChannelSwitches map[int]bool
gmChannelSwitchesAt time.Time
automaticRobotAppearanceMu sync.Mutex
automaticRobotAvatarConfig *currentAvatarFittingRoomConfig
automaticRobotAvatarLoadErr error
@@ -1015,17 +1025,40 @@ func (s *Service) loadChannelAssets() (*channelcatalog.Catalog, []byte, error) {
if err != nil {
return nil, nil, fmt.Errorf("parse dnf channel info: %w", err)
}
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: s.channelServerID()})
disabled := s.loadDisabledChannelIDs()
catalog, err := channelcatalog.New(index, channelcatalog.Options{
ServerID: s.channelServerID(),
DisabledIDs: disabled,
})
if err != nil {
return nil, nil, err
}
script, err := s.buildChannelScript(raw)
script, err := s.buildChannelScriptFiltered(raw, disabled)
if err != nil {
return nil, nil, fmt.Errorf("build 90CN online channel script: %w", err)
}
return catalog, script, nil
}
// loadDisabledChannelIDs 读取已持久化的禁用频道集合。启动时 Settings 未就绪或读失败
// 时返回 nil(视为无禁用频道),不阻塞服务启动。
func (s *Service) loadDisabledChannelIDs() map[int]bool {
if s == nil {
return nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
if err != nil || !found {
return nil
}
return gmDisabledChannelIDsFromRecord(record, true)
}
func (s *Service) buildChannelScript(raw []byte) ([]byte, error) {
if s.channelTableStrict() {
return channelinfo.Build90CNOnlineScript(
@@ -68,6 +68,10 @@ func (s *Service) sendCurrentWeeklyAttendance(session *gameSession, claim, bonus
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
// 活动开关:周周签到被关闭时不下发(客户端活动窗口不显示该活动)。
if !s.ActivitySwitchEnabled(context.Background(), gmActivityWeeklyKey) {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
defer cancel()
c, err := s.currentWeeklyCatalog(ctx)