DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,309 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 _, 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...)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// catalog_test.go 验证 DNF channel_info.etc 频道目录和首包频道名派生规则。
|
||||
package channelcatalog
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/channelinfo"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
)
|
||||
|
||||
const latestChannelInfoFixture = `
|
||||
[dungeon]
|
||||
` + "`[elven_guard]` `艾尔文防线` 1 2" + `
|
||||
[/dungeon]
|
||||
[dungeon]
|
||||
` + "`[raid]` `团队频道` 200 201" + `
|
||||
[/dungeon]
|
||||
[server]
|
||||
1 1 ` + "`洛兰`" + ` 2 ` + "`[elven_guard]`" + ` 10 0 0 6 ` + "`交易 - 拍卖行`" + ` 3 ` + "`[none]`" + ` 0 0 200 ` + "`团队频道`" + ` 23 ` + "`[raid]`" + ` 0 0 201 ` + "`隐藏团队频道`" + ` 32 ` + "`[raid]`" + ` 0 0
|
||||
[/server]
|
||||
[server]
|
||||
98 1 ` + "`内部测试`" + ` 2 ` + "`[elven_guard]`" + ` 9 0
|
||||
[/server]
|
||||
`
|
||||
|
||||
func TestNewBuildsGameChannelCatalog(t *testing.T) {
|
||||
index := parseFixture(t)
|
||||
catalog, err := New(index, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
channels := catalog.Channels()
|
||||
if len(channels) != 4 {
|
||||
t.Fatalf("channels len = %d, want 4: %+v", len(channels), channels)
|
||||
}
|
||||
first := channels[0]
|
||||
if first.ID != 1 || first.Type != 2 || first.Group != "elven_guard" || first.Name != dnfenum.ChannelNamePrefix+"1" || first.Port != dnfenum.GamePortBase+1 || first.MaxUsers != dnfenum.DefaultChannelMaxUsers {
|
||||
t.Fatalf("first channel mismatch: %+v", first)
|
||||
}
|
||||
if !reflect.DeepEqual(first.AreaDungeonIDs, []int{1, 2}) {
|
||||
t.Fatalf("first channel area dungeon ids = %+v, want [1 2]", first.AreaDungeonIDs)
|
||||
}
|
||||
channels[0].AreaDungeonIDs[0] = 999
|
||||
again, ok := catalog.Channel(1)
|
||||
if !ok || !reflect.DeepEqual(again.AreaDungeonIDs, []int{1, 2}) {
|
||||
t.Fatalf("channel area dungeon ids should be cloned, got %+v ok=%v", again.AreaDungeonIDs, ok)
|
||||
}
|
||||
if _, ok := catalog.Channel(98); ok {
|
||||
t.Fatalf("server 98 channel should not enter catalog")
|
||||
}
|
||||
if got := catalog.GamePorts(); !reflect.DeepEqual(got, []int{dnfenum.GamePortBase + 1, dnfenum.GamePortBase + 6, dnfenum.GamePortBase + 200, dnfenum.GamePortBase + 201}) {
|
||||
t.Fatalf("GamePorts() = %+v", got)
|
||||
}
|
||||
channel, ok := catalog.ForPort(dnfenum.GamePortBase + 6)
|
||||
if !ok || channel.ID != 6 || channel.Type != 3 {
|
||||
t.Fatalf("ForPort(10006) = %+v, %v", channel, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKeepsNoticeNameOnID(t *testing.T) {
|
||||
const text = "\n[dungeon]\n`[crack]` `crack` 1\n[/dungeon]\n[server]\n1 19 `<4::chn_channel_info_021>` 1 `[crack]` 0 0 6 `<4::chn_channel_info_002>` 3 `[crack]` 0 0\n[/server]\n"
|
||||
index, err := channelinfo.Parse([]byte(text))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
catalog, err := New(index, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
channel19, ok := catalog.Channel(19)
|
||||
if !ok {
|
||||
t.Fatalf("channel 19 missing")
|
||||
}
|
||||
if channel19.Name != dnfenum.ChannelNamePrefix+"19" || channel19.NoticeName != dnfenum.ChannelNamePrefix+"19" || channel19.Port != dnfenum.GamePortBase+19 {
|
||||
t.Fatalf("channel 19 mismatch: %+v", channel19)
|
||||
}
|
||||
channel6, ok := catalog.Channel(6)
|
||||
if !ok {
|
||||
t.Fatalf("channel 6 missing")
|
||||
}
|
||||
if channel6.Name != dnfenum.ChannelNamePrefix+"6" || channel6.NoticeName != dnfenum.ChannelNamePrefix+"6" || channel6.Port != dnfenum.GamePortBase+6 {
|
||||
t.Fatalf("channel 6 mismatch: %+v", channel6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResidentForKeepsNormalChannelAndReplacesCrackBootstrap(t *testing.T) {
|
||||
const text = `
|
||||
[dungeon]
|
||||
` + "`[crack]` `crack`" + `
|
||||
[/dungeon]
|
||||
[dungeon]
|
||||
` + "`[granfloris]` `granfloris` 1 2" + `
|
||||
[/dungeon]
|
||||
[server]
|
||||
1 19 ` + "`crack`" + ` 1 ` + "`[crack]`" + ` 0 0 10 ` + "`granfloris`" + ` 1 ` + "`[granfloris]`" + ` 0 0 11 ` + "`sky`" + ` 1 ` + "`[granfloris]`" + ` 0 0
|
||||
[/server]
|
||||
`
|
||||
index, err := channelinfo.Parse([]byte(text))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
catalog, err := New(index, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
crack, _ := catalog.Channel(19)
|
||||
resident, ok := catalog.ResidentFor(crack)
|
||||
if !ok || resident.ID != 10 || resident.Group != "granfloris" {
|
||||
t.Fatalf("resident for crack = %+v, %v", resident, ok)
|
||||
}
|
||||
normal, _ := catalog.Channel(11)
|
||||
resident, ok = catalog.ResidentFor(normal)
|
||||
if !ok || resident.ID != 11 {
|
||||
t.Fatalf("resident for normal channel = %+v, %v", resident, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterForRequest(t *testing.T) {
|
||||
catalog, err := New(parseFixture(t), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
ordinary := catalog.FilterForRequest("cain")
|
||||
if len(ordinary) != 4 || ordinary[0].ID != 1 || ordinary[1].ID != 6 || ordinary[2].ID != 200 || ordinary[3].ID != 201 {
|
||||
t.Fatalf("cain channels mismatch: %+v", ordinary)
|
||||
}
|
||||
raid := catalog.FilterForRequest("raid")
|
||||
if len(raid) != 2 || raid[0].ID != 200 || raid[1].ID != 201 {
|
||||
t.Fatalf("raid channels mismatch: %+v", raid)
|
||||
}
|
||||
elven := catalog.FilterForRequest("`[elven_guard]`")
|
||||
if len(elven) != 1 || elven[0].ID != 1 {
|
||||
t.Fatalf("elven channels mismatch: %+v", elven)
|
||||
}
|
||||
unknown := catalog.FilterForRequest("unknown")
|
||||
if len(unknown) != 2 || unknown[0].ID != 1 || unknown[1].ID != 6 {
|
||||
t.Fatalf("unknown group fallback mismatch: %+v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterForBootstrapOmitsNativeEntrancesOnly(t *testing.T) {
|
||||
const text = `
|
||||
[dungeon]
|
||||
` + "`[deathtower]` `tower` 1" + `
|
||||
[/dungeon]
|
||||
[server]
|
||||
1 1 ` + "`tower`" + ` 2 ` + "`[deathtower]`" + ` 0 0 6 ` + "`trade`" + ` 3 ` + "`[trade]`" + ` 0 0 19 ` + "`crack`" + ` 1 ` + "`[crack]`" + ` 0 0 31 ` + "`town`" + ` 1 ` + "`[granfloris]`" + ` 0 0
|
||||
[/server]
|
||||
`
|
||||
index, err := channelinfo.Parse([]byte(text))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
catalog, err := New(index, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
bootstrap := catalog.FilterForBootstrap(dnfenum.GroupCain)
|
||||
if len(bootstrap) != 2 || bootstrap[0].ID != 19 || bootstrap[1].ID != 31 {
|
||||
t.Fatalf("bootstrap channels = %+v, want 19/31", bootstrap)
|
||||
}
|
||||
full := catalog.FilterForRequest(dnfenum.GroupCain)
|
||||
if len(full) != 4 {
|
||||
t.Fatalf("full refresh channels = %d, want 4: %+v", len(full), full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterUsesSourceTypeInsteadOfLegacyRaidIDs(t *testing.T) {
|
||||
const text = `
|
||||
[dungeon]
|
||||
` + "`[metro]` `metro` 1" + `
|
||||
[/dungeon]
|
||||
[dungeon]
|
||||
` + "`[luke_raid]` `luke` 2" + `
|
||||
[/dungeon]
|
||||
[server] 1
|
||||
201 ` + "`<chn_channel_info_024>`" + ` 11 ` + "`[metro]`" + ` 0 0
|
||||
241 ` + "`<chn_channel_info_025>`" + ` 32 ` + "`[luke_raid]`" + ` 0 0
|
||||
[/server]
|
||||
`
|
||||
index, err := channelinfo.Parse([]byte(text))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
catalog, err := New(index, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
ordinary := catalog.FilterForRequest(dnfenum.GroupCain)
|
||||
if len(ordinary) != 2 || ordinary[0].ID != 201 || ordinary[1].ID != 241 {
|
||||
t.Fatalf("cain channels = %+v, want source type-11 and type-32 channels", ordinary)
|
||||
}
|
||||
raid := catalog.FilterForRequest(dnfenum.GroupRaid)
|
||||
if len(raid) != 1 || raid[0].ID != 241 {
|
||||
t.Fatalf("raid channels = %+v, want source type-32 channel 241", raid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFiendWarChannelIsRaidAndNeverLeaksIntoOrdinaryLists covers the Fiend War
|
||||
// owner. The ETC writes its group as `[fiendwar]`, not `raid`, so before the
|
||||
// type-42 test existed isRaid() was false for it and the channel was treated as
|
||||
// an ordinary town in both directions: it was left out of the team-channel list
|
||||
// AND it was eligible for the `!isRaid` fallback that serves a client whose
|
||||
// requested group matched nothing.
|
||||
func TestFiendWarChannelIsRaidAndNeverLeaksIntoOrdinaryLists(t *testing.T) {
|
||||
const text = `
|
||||
[dungeon]
|
||||
` + "`[anton_raid]` `anton` 1" + `
|
||||
[/dungeon]
|
||||
[dungeon]
|
||||
` + "`[fiendwar]` `fiend` 2" + `
|
||||
[/dungeon]
|
||||
[server] 1
|
||||
23 ` + "`<chn_channel_info_016>`" + ` 23 ` + "`[anton_raid]`" + ` 0 0
|
||||
184 ` + "`<chn_channel_info_048>`" + ` 42 ` + "`[fiendwar]`" + ` 0 0
|
||||
[/server]
|
||||
`
|
||||
index, err := channelinfo.Parse([]byte(text))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
catalog, err := New(index, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
raid := catalog.FilterForRequest(dnfenum.GroupRaid)
|
||||
if len(raid) != 2 || raid[0].ID != 23 || raid[1].ID != 184 {
|
||||
t.Fatalf("raid channels = %+v, want the type-23 and type-42 channels", raid)
|
||||
}
|
||||
// The unmatched-group fallback is the direction that regressed silently: it
|
||||
// is what a client receives when its requested group matched nothing, so a
|
||||
// type-42 channel left out of isRaid() would be offered as an ordinary town.
|
||||
unknown := catalog.FilterForRequest("unknown")
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("unknown-group fallback leaked raid channels: %+v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPreservesChannelInfoOrder(t *testing.T) {
|
||||
const text = `
|
||||
[dungeon]
|
||||
` + "`[trade]` `交易频道` 1" + `
|
||||
[/dungeon]
|
||||
[server]
|
||||
1 1 ` + "`洛兰`" + ` 2 ` + "`[trade]`" + ` 0 0 38 ` + "`自动频道`" + ` 3 ` + "`[trade]`" + ` 0 0 10 ` + "`洛兰深处`" + ` 1 ` + "`[trade]`" + ` 0 0
|
||||
[/server]
|
||||
`
|
||||
index, err := channelinfo.Parse([]byte(text))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
catalog, err := New(index, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
channels := catalog.FilterForRequest("cain")
|
||||
if len(channels) != 3 || channels[0].ID != 1 || channels[1].ID != 38 || channels[2].ID != 10 {
|
||||
t.Fatalf("channel order mismatch: %+v", channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCanSelectConfiguredServer(t *testing.T) {
|
||||
catalog, err := New(parseFixture(t), Options{ServerID: 98})
|
||||
if err != nil {
|
||||
t.Fatalf("New(server 98) error = %v", err)
|
||||
}
|
||||
channels := catalog.Channels()
|
||||
if len(channels) != 1 || channels[0].ServerID != 98 || channels[0].ID != 1 {
|
||||
t.Fatalf("server 98 channels mismatch: %+v", channels)
|
||||
}
|
||||
if got := catalog.GamePorts(); !reflect.DeepEqual(got, []int{dnfenum.GamePortBase + 1}) {
|
||||
t.Fatalf("server 98 ports = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsEmptyIndex(t *testing.T) {
|
||||
_, err := New(nil, Options{})
|
||||
if !errors.Is(err, ErrEmpty) {
|
||||
t.Fatalf("New(nil) error = %v, want ErrEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFixture(t *testing.T) *channelinfo.Index {
|
||||
t.Helper()
|
||||
index, err := channelinfo.Parse([]byte(latestChannelInfoFixture))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
return index
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package channelcatalog 把 DNF channel_info.etc 索引转换成游戏频道目录。
|
||||
package channelcatalog
|
||||
Reference in new issue
Block a user