Files
90CNServer/server/internal/modules/dnf/channelcatalog/catalog.go
T
hanfuit 0ced464168 启动器新增游戏设置页(频道开关+活动开关),窗口标题改名
- 服务端新增活动开关运行时配置与 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 灵狐小竹整合单机版」
2026-11-04 20:08:50 +08:00

316 lines
9.6 KiB
Go

// catalog.go 负责把 DNF channel_info.etc 转成频道广告和 game 监听共用的只读目录。
package channelcatalog
import (
"errors"
"sort"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/channelinfo"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
var ErrEmpty = errors.New("dnf channel catalog is empty")
// Channel 是协议广告和游戏端口监听共用的频道视图。
type Channel struct {
ServerID int
ID int
Type uint8
Group string
AreaDungeonIDs []int
Name string
// NoticeName 只用于 game class0/op1 首包;必须和 channel_info.etc 的频道 ID 对齐。
NoticeName string
MaxUsers int
CurrentUsers int
Port int
}
// Options 控制频道目录生成规则;零值使用 DNF 默认端口和人数。
type Options struct {
ServerID int
GamePortBase int
MaxUsers int
CurrentUsers int
// DisabledIDs 是运行时被关闭的频道 ID 集合(来自启动器「频道开关」)。
// 这些频道不进入目录,也不会开游戏端口监听。
DisabledIDs map[int]bool
}
// Catalog 是从 channel_info.etc 派生的只读频道目录。
type Catalog struct {
channels []Channel
byID map[int]Channel
byPort map[int]Channel
}
// New 从解析好的 channel_info.etc 索引构建游戏频道目录。
func New(index *channelinfo.Index, options Options) (*Catalog, error) {
options = normalizeOptions(options)
source := index.ChannelsForServer(options.ServerID)
if len(source) == 0 {
return nil, ErrEmpty
}
channels := make([]Channel, 0, len(source))
seen := make(map[int]struct{}, len(source))
for _, item := range source {
if item.ID <= 0 {
continue
}
if options.DisabledIDs != nil && options.DisabledIDs[item.ID] {
continue
}
if _, ok := seen[item.ID]; ok {
continue
}
seen[item.ID] = struct{}{}
area, _ := index.Area(item.AreaKey)
channels = append(channels, Channel{
ServerID: item.ServerID,
ID: item.ID,
Type: clampByte(item.Type),
Group: normalizeGroup(item.AreaKey),
AreaDungeonIDs: cloneInts(area.DungeonIDs),
Name: dnfenum.ChannelNamePrefix + strconv.Itoa(item.ID),
NoticeName: channelNoticeName(item),
MaxUsers: options.MaxUsers,
CurrentUsers: options.CurrentUsers,
Port: options.GamePortBase + item.ID,
})
}
if len(channels) == 0 {
return nil, ErrEmpty
}
catalog := &Catalog{
channels: cloneChannels(channels),
byID: make(map[int]Channel, len(channels)),
byPort: make(map[int]Channel, len(channels)),
}
for _, channel := range channels {
catalog.byID[channel.ID] = channel
catalog.byPort[channel.Port] = channel
}
return catalog, nil
}
// Channels 返回全部可广告频道,顺序与外部 channel_info.etc 保持一致。
func (c *Catalog) Channels() []Channel {
if c == nil {
return nil
}
return cloneChannels(c.channels)
}
// FilterForRequest 按客户端请求的频道组过滤广告列表。
func (c *Catalog) FilterForRequest(group string) []Channel {
if c == nil {
return nil
}
normalized := normalizeGroup(group)
if normalized == "" || normalized == dnfenum.GroupCain {
// The current client builds each channel-category control from this
// directory. Keep every authoritative ETC record here, including type 32,
// so the team-channel control has a non-empty source during bootstrap.
return cloneChannels(c.channels)
}
if normalized == dnfenum.GroupRaid || normalized == dnfenum.GroupAttackRaid {
return filterChannels(c.channels, isRaid)
}
matched := filterChannels(c.channels, func(channel Channel) bool {
return normalizeGroup(channel.Group) == normalized
})
if len(matched) > 0 {
return matched
}
return filterChannels(c.channels, func(channel Channel) bool {
return !isRaid(channel)
})
}
// FilterForBootstrap returns the directory used immediately after the client
// downloads channel_info.etc. Native tower and trade entrances cannot become
// the resident town channel during that bootstrap and are left for a later
// full directory refresh.
func (c *Catalog) FilterForBootstrap(group string) []Channel {
return filterChannels(c.FilterForRequest(group), func(channel Channel) bool {
return !isBootstrapEntrance(channel)
})
}
// Channel 按频道 ID 查询目录项。
func (c *Catalog) Channel(id int) (Channel, bool) {
if c == nil {
return Channel{}, false
}
channel, ok := c.byID[id]
return cloneChannel(channel), ok
}
// ForPort 按本地游戏端口反查频道。
func (c *Catalog) ForPort(port int) (Channel, bool) {
if c == nil {
return Channel{}, false
}
channel, ok := c.byPort[port]
return cloneChannel(channel), ok
}
// ResidentFor returns the ordinary town channel that the game client should
// commit after using a special bootstrap channel such as [crack]. The result
// is derived from channel_info.etc order; no channel id is fabricated here.
func (c *Catalog) ResidentFor(connected Channel) (Channel, bool) {
if c == nil {
return Channel{}, false
}
if candidate, ok := c.byID[connected.ID]; ok &&
candidate.ServerID == connected.ServerID && isResidentChannel(candidate) {
return cloneChannel(candidate), true
}
for _, channel := range c.channels {
if isResidentChannel(channel) {
return cloneChannel(channel), true
}
}
return Channel{}, false
}
// GamePorts 返回需要监听的游戏端口,按端口升序排列。
func (c *Catalog) GamePorts() []int {
if c == nil {
return nil
}
ports := make([]int, 0, len(c.byPort))
for port := range c.byPort {
ports = append(ports, port)
}
sort.Ints(ports)
return ports
}
func normalizeOptions(options Options) Options {
if options.ServerID <= 0 {
options.ServerID = dnfenum.LoginChannelServerIndex
}
if options.GamePortBase <= 0 {
options.GamePortBase = dnfenum.GamePortBase
}
if options.MaxUsers <= 0 {
options.MaxUsers = dnfenum.DefaultChannelMaxUsers
}
if options.CurrentUsers < 0 {
options.CurrentUsers = dnfenum.DefaultChannelCurrentUsers
}
return options
}
func filterChannels(channels []Channel, accept func(Channel) bool) []Channel {
out := make([]Channel, 0, len(channels))
for _, channel := range channels {
if accept(channel) {
out = append(out, cloneChannel(channel))
}
}
return out
}
// isRaid reports whether a channel belongs to a team-raid owner. The three
// raid owners are discriminated by type, not by group: the ETC writes
// `[anton_raid]` / `[luke_raid]` / `[fiendwar]`, so the group test alone would
// only ever match a channel literally grouped "raid".
func isRaid(channel Channel) bool {
return channel.Type == dnfenum.RaidChannelType ||
channel.Type == dnfenum.HiddenRaidType ||
channel.Type == dnfenum.FiendWarRaidChannelType ||
normalizeGroup(channel.Group) == dnfenum.GroupRaid
}
func isBootstrapEntrance(channel Channel) bool {
return channel.Type == 2 || channel.Type == 3
}
func isResidentChannel(channel Channel) bool {
group := normalizeGroup(channel.Group)
if group == "" || group == "none" || group == dnfenum.GroupCrack ||
group == dnfenum.GroupDeathTower || group == dnfenum.GroupTrade ||
group == dnfenum.GroupRaid || group == dnfenum.GroupAttackRaid {
return false
}
// Every raid owner is excluded here, Fiend War included: a resident channel
// is one the client may commit as an ordinary town, and town 98 has no
// ordinary-channel ownership any more than town 19 or town 32 does.
return channel.Type != dnfenum.DeathTowerChannelType &&
channel.Type != dnfenum.TradeChannelType &&
channel.Type != dnfenum.RaidChannelType &&
channel.Type != dnfenum.HiddenRaidType &&
channel.Type != dnfenum.FiendWarRaidChannelType
}
// SettableGuildChannel reports whether channel may be stored as a guild's
// 推荐频道, which is what game class1/op830 does.
//
// Only the channel group is consulted, deliberately. ResidentFor cannot be used
// for this: its type test reads type 11 as a death-tower channel, but type 11 is
// this catalog's ordinary town type (19/granfloris, 181/noblsky, 182/noblsky,
// ...), so ResidentFor resolved every town channel to the catalog's first
// resident channel and the guild handler answered op830 with error 19 for every
// ordinary channel -- live: the master of guild 1 standing in channel 181 was
// told 只有公会长才能设置.
func (c *Catalog) SettableGuildChannel(channel Channel) bool {
if c == nil {
return false
}
candidate, ok := c.byID[channel.ID]
if !ok || candidate.ServerID != channel.ServerID {
return false
}
group := normalizeGroup(candidate.Group)
if group == "" || group == "none" || group == dnfenum.GroupCrack ||
group == dnfenum.GroupDeathTower || group == dnfenum.GroupTrade ||
group == dnfenum.GroupRaid || group == dnfenum.GroupAttackRaid {
return false
}
return true
}
func normalizeGroup(value string) string {
value = strings.TrimSpace(value)
value = strings.Trim(value, "`")
value = strings.TrimPrefix(value, "[")
value = strings.TrimSuffix(value, "]")
return strings.ToLower(strings.TrimSpace(value))
}
func clampByte(value int) uint8 {
if value < 0 {
return 0
}
if value > 255 {
return 255
}
return uint8(value)
}
func channelNoticeName(item channelinfo.Channel) string {
// NameKey 是显示资源名,不能拿后缀拼出 etc 里不存在的频道。
return dnfenum.ChannelNamePrefix + strconv.Itoa(item.ID)
}
func cloneChannels(channels []Channel) []Channel {
out := make([]Channel, len(channels))
for idx, channel := range channels {
out[idx] = cloneChannel(channel)
}
return out
}
func cloneChannel(channel Channel) Channel {
channel.AreaDungeonIDs = cloneInts(channel.AreaDungeonIDs)
return channel
}
func cloneInts(values []int) []int {
return append([]int(nil), values...)
}