DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,735 @@
package channelinfo
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"sort"
"strconv"
"strings"
"unicode"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
var (
ErrMissing = errors.New("dnf channel info is missing")
ErrInvalid = errors.New("dnf channel info is invalid")
)
// SourceKind 标记 channel_info.etc 的实际来源,便于启动日志和验收证据区分。
type SourceKind string
const (
SourcePVF SourceKind = "pvf"
SourceFile SourceKind = "file"
)
// Source 记录本次加载命中的来源位置。
type Source struct {
Kind SourceKind
Path string
}
// Reader 是 PVF archive 或其他资源包需要实现的最小文本读取接口;未找到路径时应返回 os.ErrNotExist。
type Reader interface {
ReadText(path string) (string, error)
}
// LoadOptions 配置外部活配置路径;Paths 仅供离线 PVF 导出路径复用。
type LoadOptions struct {
Paths []string
FilePath string
}
// Channel 是单条 DNF 频道到区域的路由配置。
type Channel struct {
ServerID int
ID int
NameKey string
Type int
AreaKey string
RawFields []string
}
// Area 是 DNF 区域到副本 ID 的映射。
type Area struct {
Key string
NameKey string
DungeonIDs []int
}
// Index 是启动时构建好的只读查询索引,查询路径不再重复解析 channel_info.etc。
type Index struct {
channels map[channelKey]Channel
channelByID map[int]channelKey
channelOrder []channelKey
areas map[string]Area
areasByDungeon map[int][]string
channelsByDungeon map[int][]channelKey
channelsByServer map[int][]channelKey
}
type channelKey struct {
serverID int
channelID int
}
// DefaultPaths 返回 PVF 中查找 channel_info.etc 的候选路径。
func DefaultPaths() []string {
return []string{
"channel_info.etc",
"channel_info/channel_info.etc",
"etc/channel_info.etc",
"Etc/channel_info.etc",
"script/channel_info.etc",
"Script/channel_info.etc",
"game/channel_info/channel_info.etc",
}
}
// Load 只从外部 channel_info.etc 加载目录;PVF 文本读取请走 ReadPVFText。
func Load(_ Reader, options LoadOptions) (*Index, Source, error) {
if path := strings.TrimSpace(options.FilePath); path != "" {
index, err := LoadFile(path)
if err == nil {
return index, Source{Kind: SourceFile, Path: path}, nil
}
return nil, Source{}, fmt.Errorf("load external channel info %s: %w", path, err)
}
return nil, Source{}, ErrMissing
}
// ReadPVFText 从 PVF 候选路径中读取原始 channel_info.etc 文本,供加载和导出共用。
func ReadPVFText(reader Reader, paths []string) (string, Source, error) {
if len(paths) == 0 {
paths = DefaultPaths()
}
if reader != nil {
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
text, err := reader.ReadText(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return "", Source{}, fmt.Errorf("read pvf channel info %s: %w", path, err)
}
return text, Source{Kind: SourcePVF, Path: path}, nil
}
}
return "", Source{}, ErrMissing
}
// LoadFile 从外部文件加载 channel_info.etc;生产侧可把它当作可修改的活配置。
func LoadFile(path string) (*Index, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, ErrMissing
}
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自启动配置或测试临时目录,调用方负责限定资源根目录。
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", ErrMissing, path)
}
return nil, err
}
return Parse(data)
}
// Parse 解析 channel_info.etc 文本并一次性构建内存索引。
func Parse(data []byte) (*Index, error) {
parser := newParser()
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 1024), 1024*1024)
for scanner.Scan() {
parser.line++
if err := parser.accept(cleanLine(scanner.Text())); err != nil {
return nil, err
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if err := parser.close(); err != nil {
return nil, err
}
return buildIndex(parser.areas, parser.channels, parser.channelOrder)
}
// Channel 按频道 ID 查询路由,返回值已 clone,可被调用方安全修改。
func (i *Index) Channel(id int) (Channel, bool) {
if i == nil {
return Channel{}, false
}
key, ok := i.channelByID[id]
if !ok {
return Channel{}, false
}
channel := i.channels[key]
return cloneChannel(channel), true
}
// ServerChannel 按服务器组和频道 ID 查询精确路由。
func (i *Index) ServerChannel(serverID, channelID int) (Channel, bool) {
if i == nil {
return Channel{}, false
}
channel, ok := i.channels[channelKey{serverID: serverID, channelID: channelID}]
if !ok {
return Channel{}, false
}
return cloneChannel(channel), true
}
// Channels 返回全部频道路由,顺序与外部 channel_info.etc 保持一致。
func (i *Index) Channels() []Channel {
if i == nil {
return nil
}
out := make([]Channel, 0, len(i.channelOrder))
for _, key := range i.channelOrder {
out = append(out, cloneChannel(i.channels[key]))
}
return out
}
// ChannelsForServer 返回指定服务器组的全部频道,顺序与外部 channel_info.etc 保持一致。
func (i *Index) ChannelsForServer(serverID int) []Channel {
if i == nil {
return nil
}
keys := cloneChannelKeys(i.channelsByServer[serverID])
out := make([]Channel, 0, len(keys))
for _, key := range keys {
if channel, ok := i.channels[key]; ok {
out = append(out, cloneChannel(channel))
}
}
return out
}
// Area 按区域 key 查询副本映射,返回值已 clone。
func (i *Index) Area(key string) (Area, bool) {
if i == nil {
return Area{}, false
}
area, ok := i.areas[areaKey(key)]
if !ok {
return Area{}, false
}
return cloneArea(area), true
}
// Areas 返回全部区域映射,按区域 key 升序排列。
func (i *Index) Areas() []Area {
if i == nil {
return nil
}
out := make([]Area, 0, len(i.areas))
for _, area := range i.areas {
out = append(out, cloneArea(area))
}
sort.Slice(out, func(a, b int) bool { return out[a].Key < out[b].Key })
return out
}
// AreasForDungeon 查询副本 ID 所属区域;同一副本出现在多区域时全部返回。
func (i *Index) AreasForDungeon(id int) []string {
if i == nil {
return nil
}
return cloneStrings(i.areasByDungeon[id])
}
// ChannelsForDungeon 查询副本 ID 可进入的频道列表。
func (i *Index) ChannelsForDungeon(id int) []Channel {
if i == nil {
return nil
}
channelIDs := i.channelsByDungeon[id]
out := make([]Channel, 0, len(channelIDs))
for _, key := range channelIDs {
if channel, ok := i.channels[key]; ok {
out = append(out, cloneChannel(channel))
}
}
return out
}
type parser struct {
areas map[string]Area
channels map[channelKey]Channel
channelOrder []channelKey
section string
area Area
serverID int
stage int
line int
}
func newParser() *parser {
return &parser{
areas: make(map[string]Area),
channels: make(map[channelKey]Channel),
serverID: dnfenum.LoginChannelServerIndex,
}
}
func (p *parser) accept(line string) error {
if line == "" {
return nil
}
if serverID, ok, err := parseServerSectionHeader(line); ok {
if err != nil {
return p.fail(err.Error())
}
p.section = "server"
p.serverID = serverID
return nil
}
switch strings.ToLower(line) {
case "[dungeon]":
p.section = "dungeon"
p.area = Area{}
p.stage = 0
return nil
case "[/dungeon]":
return p.endDungeon()
case "[/server]":
p.section = ""
return nil
}
switch p.section {
case "dungeon":
return p.readDungeon(line)
case "server":
return p.readServer(line)
default:
return nil
}
}
func (p *parser) readDungeon(line string) error {
fields := splitFields(line)
if len(fields) == 0 {
return nil
}
if p.stage == 0 {
key := areaKey(fields[0])
if key == "" {
return p.fail("dungeon area key is required")
}
p.area.Key = key
if len(fields) == 1 {
p.stage = 1
return nil
}
p.area.NameKey = textKey(fields[1])
if err := p.appendDungeonIDs(fields[2:]); err != nil {
return err
}
p.stage = 2
return nil
}
if p.stage == 1 {
if isInt(fields[0]) {
p.stage = 2
return p.appendDungeonIDs(fields)
}
p.area.NameKey = textKey(fields[0])
if err := p.appendDungeonIDs(fields[1:]); err != nil {
return err
}
p.stage = 2
return nil
}
return p.appendDungeonIDs(fields)
}
func (p *parser) appendDungeonIDs(fields []string) error {
for _, field := range fields {
id, err := strconv.Atoi(field)
if err != nil {
return p.fail("dungeon id must be integer")
}
if id < 0 {
return p.fail("dungeon id must not be negative")
}
p.area.DungeonIDs = appendInt(p.area.DungeonIDs, id)
}
return nil
}
func (p *parser) readServer(line string) error {
fields := splitFields(line)
if len(fields) == 1 && isInt(fields[0]) {
serverID, err := strconv.Atoi(fields[0])
if err != nil || serverID < 0 {
return p.fail("server id must be non-negative integer")
}
p.serverID = serverID
return nil
}
if len(fields) >= 5 && isInt(fields[0]) && isChannelStart(fields, 1) {
serverID, err := strconv.Atoi(fields[0])
if err != nil || serverID < 0 {
return p.fail("server id must be non-negative integer")
}
p.serverID = serverID
return p.readPackedServer(fields[1:])
}
return p.readServerRow(p.serverID, fields)
}
func (p *parser) readPackedServer(fields []string) error {
for start := 0; start < len(fields); {
if !isChannelStart(fields, start) {
return p.fail("packed server row has invalid channel header")
}
end := len(fields)
for next := start + 4; next < len(fields); next++ {
if isChannelStart(fields, next) {
end = next
break
}
}
if err := p.readServerRow(p.serverID, fields[start:end]); err != nil {
return err
}
start = end
}
return nil
}
func (p *parser) readServerRow(serverID int, fields []string) error {
if len(fields) < 4 {
return p.fail("server row requires channel id, name, type and area")
}
id, err := strconv.Atoi(fields[0])
if err != nil || id < 0 {
return p.fail("channel id must be non-negative integer")
}
channelType, err := strconv.Atoi(fields[2])
if err != nil {
return p.fail("channel type must be integer")
}
channel := Channel{
ServerID: serverID,
ID: id,
NameKey: textKey(fields[1]),
Type: channelType,
AreaKey: areaKey(fields[3]),
RawFields: cloneStrings(fields[4:]),
}
if channel.NameKey == "" || channel.AreaKey == "" {
return p.fail("channel name and area are required")
}
return p.addChannel(channel)
}
func (p *parser) endDungeon() error {
if p.section != "dungeon" || p.area.Key == "" {
return p.fail("dungeon block is incomplete")
}
p.section = ""
p.stage = 0
return p.addArea(p.area)
}
func (p *parser) close() error {
if p.section != "" {
return p.fail("section is not closed")
}
if len(p.areas) == 0 && len(p.channels) == 0 {
return fmt.Errorf("%w: no dungeon or server block", ErrInvalid)
}
return nil
}
func (p *parser) addArea(area Area) error {
if area.Key == "" {
return p.fail("dungeon area key is required")
}
if existing, ok := p.areas[area.Key]; ok {
if existing.NameKey != "" && area.NameKey != "" && existing.NameKey != area.NameKey {
return p.fail("duplicate dungeon area has different name")
}
if existing.NameKey == "" {
existing.NameKey = area.NameKey
}
for _, id := range area.DungeonIDs {
existing.DungeonIDs = appendInt(existing.DungeonIDs, id)
}
p.areas[area.Key] = existing
return nil
}
area.DungeonIDs = cloneInts(area.DungeonIDs)
p.areas[area.Key] = area
return nil
}
func (p *parser) addChannel(channel Channel) error {
key := channelKey{serverID: channel.ServerID, channelID: channel.ID}
if existing, ok := p.channels[key]; ok {
if sameChannel(existing, channel) {
return nil
}
return p.fail("duplicate server channel has different route")
}
p.channels[key] = cloneChannel(channel)
p.channelOrder = append(p.channelOrder, key)
return nil
}
func (p *parser) fail(message string) error {
return fmt.Errorf("%w: line %d: %s", ErrInvalid, p.line, message)
}
func buildIndex(areas map[string]Area, channels map[channelKey]Channel, order []channelKey) (*Index, error) {
if len(areas) == 0 && len(channels) == 0 {
return nil, ErrMissing
}
order = normalizeChannelOrder(order, channels)
index := &Index{
channels: make(map[channelKey]Channel, len(channels)),
channelByID: make(map[int]channelKey, len(channels)),
channelOrder: cloneChannelKeys(order),
areas: make(map[string]Area, len(areas)),
areasByDungeon: make(map[int][]string),
channelsByDungeon: make(map[int][]channelKey),
channelsByServer: make(map[int][]channelKey),
}
for key, area := range areas {
area = cloneArea(area)
index.areas[key] = area
for _, dungeonID := range area.DungeonIDs {
index.areasByDungeon[dungeonID] = appendString(index.areasByDungeon[dungeonID], key)
}
}
areaChannels := make(map[string][]channelKey)
for _, key := range order {
channel := channels[key]
channel = cloneChannel(channel)
index.channels[key] = channel
if _, ok := index.channelByID[channel.ID]; !ok {
index.channelByID[channel.ID] = key
}
index.channelsByServer[channel.ServerID] = appendChannelKey(index.channelsByServer[channel.ServerID], key)
areaChannels[channel.AreaKey] = appendChannelKey(areaChannels[channel.AreaKey], key)
}
for dungeonID, areaKeys := range index.areasByDungeon {
for _, areaKey := range areaKeys {
for _, key := range areaChannels[areaKey] {
index.channelsByDungeon[dungeonID] = appendChannelKey(index.channelsByDungeon[dungeonID], key)
}
}
}
return index, nil
}
func normalizeChannelOrder(order []channelKey, channels map[channelKey]Channel) []channelKey {
out := make([]channelKey, 0, len(channels))
seen := make(map[channelKey]struct{}, len(channels))
for _, key := range order {
if _, ok := channels[key]; !ok {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, key)
}
if len(out) == len(channels) {
return out
}
for _, key := range sortedChannelKeys(channels) {
if _, ok := seen[key]; ok {
continue
}
out = append(out, key)
}
return out
}
func cleanLine(line string) string {
line = strings.TrimPrefix(line, "\ufeff")
for _, mark := range []string{"//", "#", ";"} {
if index := strings.Index(line, mark); index >= 0 {
line = line[:index]
}
}
return strings.TrimSpace(line)
}
func splitFields(line string) []string {
fields := make([]string, 0, 8)
var builder strings.Builder
inBacktick := false
flush := func() {
if builder.Len() == 0 {
return
}
fields = append(fields, builder.String())
builder.Reset()
}
for _, char := range line {
if char == '`' {
inBacktick = !inBacktick
builder.WriteRune(char)
continue
}
if unicode.IsSpace(char) && !inBacktick {
flush()
continue
}
builder.WriteRune(char)
}
flush()
return fields
}
func parseServerSectionHeader(line string) (int, bool, error) {
fields := splitFields(line)
if len(fields) == 0 || !strings.EqualFold(fields[0], "[server]") {
return 0, false, nil
}
if len(fields) == 1 {
return dnfenum.LoginChannelServerIndex, true, nil
}
if len(fields) != 2 {
return 0, true, errors.New("server section header accepts at most one server id")
}
serverID, err := strconv.Atoi(fields[1])
if err != nil || serverID < 0 {
return 0, true, errors.New("server id must be non-negative integer")
}
return serverID, true, nil
}
func textKey(value string) string {
value = strings.TrimSpace(value)
value = strings.Trim(value, "`")
value = strings.TrimPrefix(value, "<")
value = strings.TrimSuffix(value, ">")
value = strings.TrimPrefix(value, "4::")
return strings.TrimSpace(value)
}
func areaKey(value string) string {
value = textKey(value)
value = strings.TrimPrefix(value, "[")
value = strings.TrimSuffix(value, "]")
return strings.ToLower(strings.TrimSpace(value))
}
func isInt(value string) bool {
_, err := strconv.Atoi(value)
return err == nil
}
func isChannelStart(fields []string, index int) bool {
return index >= 0 && index+3 < len(fields) &&
isInt(fields[index]) && isQuoted(fields[index+1]) &&
isInt(fields[index+2]) && isQuoted(fields[index+3])
}
func isQuoted(value string) bool {
return len(value) >= 2 && value[0] == '`' && value[len(value)-1] == '`'
}
func sameChannel(left, right Channel) bool {
return left.ServerID == right.ServerID &&
left.ID == right.ID &&
left.NameKey == right.NameKey &&
left.Type == right.Type &&
left.AreaKey == right.AreaKey &&
sameStrings(left.RawFields, right.RawFields)
}
func sortedChannelKeys(channels map[channelKey]Channel) []channelKey {
keys := make([]channelKey, 0, len(channels))
for key := range channels {
keys = append(keys, key)
}
sortChannelKeys(keys)
return keys
}
func sortChannelKeys(keys []channelKey) {
sort.Slice(keys, func(left, right int) bool {
if keys[left].serverID != keys[right].serverID {
return keys[left].serverID < keys[right].serverID
}
return keys[left].channelID < keys[right].channelID
})
}
func cloneChannelKeys(keys []channelKey) []channelKey {
return append([]channelKey(nil), keys...)
}
func sameStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func cloneChannel(channel Channel) Channel {
channel.RawFields = cloneStrings(channel.RawFields)
return channel
}
func cloneArea(area Area) Area {
area.DungeonIDs = cloneInts(area.DungeonIDs)
return area
}
func cloneStrings(values []string) []string {
return append([]string(nil), values...)
}
func cloneInts(values []int) []int {
return append([]int(nil), values...)
}
func appendString(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
values = append(values, value)
sort.Strings(values)
return values
}
func appendInt(values []int, value int) []int {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
func appendChannelKey(values []channelKey, value channelKey) []channelKey {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
@@ -0,0 +1,292 @@
package channelinfo
import (
"errors"
"os"
"path/filepath"
"testing"
)
const routeFixture = `
[dungeon]
` + "`[granfloris]`" + `
` + "`<4::channel_info_dname_1>`" + `
100
101
[/dungeon]
[server]
1
10 ` + "`<4::chn_channel_info_003>`" + ` 1 ` + "`[granfloris]`" + ` 0 0
6 ` + "`<4::chn_channel_info_002>`" + ` 3 ` + "`[trade]`" + ` 0
[/server]
`
func TestParseBuildsRouteIndex(t *testing.T) {
index, err := Parse([]byte(routeFixture))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
channel, ok := index.Channel(10)
if !ok {
t.Fatalf("channel 10 should exist")
}
if channel.NameKey != "chn_channel_info_003" || channel.AreaKey != "granfloris" || channel.Type != 1 {
t.Fatalf("channel 10 mismatch: %+v", channel)
}
area, ok := index.Area("`[granfloris]`")
if !ok {
t.Fatalf("area granfloris should exist")
}
if area.NameKey != "channel_info_dname_1" || len(area.DungeonIDs) != 2 {
t.Fatalf("area granfloris mismatch: %+v", area)
}
areas := index.AreasForDungeon(101)
if len(areas) != 1 || areas[0] != "granfloris" {
t.Fatalf("dungeon 101 areas mismatch: %+v", areas)
}
channels := index.ChannelsForDungeon(100)
if len(channels) != 1 || channels[0].ID != 10 {
t.Fatalf("dungeon 100 channels mismatch: %+v", channels)
}
if got := index.ChannelsForDungeon(999); len(got) != 0 {
t.Fatalf("unknown dungeon should return no channel, got %+v", got)
}
serverChannels := index.ChannelsForServer(1)
if len(serverChannels) != 2 || serverChannels[0].ID != 10 || serverChannels[1].ID != 6 {
t.Fatalf("server channel order mismatch: %+v", serverChannels)
}
}
func TestParseProtectsIndexFromCallerMutation(t *testing.T) {
index, err := Parse([]byte(routeFixture))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
channel, ok := index.Channel(10)
if !ok {
t.Fatalf("channel 10 should exist")
}
channel.RawFields[0] = "mutated"
again, _ := index.Channel(10)
if again.RawFields[0] == "mutated" {
t.Fatalf("channel raw fields should be cloned")
}
area, ok := index.Area("granfloris")
if !ok {
t.Fatalf("area granfloris should exist")
}
area.DungeonIDs[0] = 999
againArea, _ := index.Area("granfloris")
if againArea.DungeonIDs[0] == 999 {
t.Fatalf("area dungeon ids should be cloned")
}
}
func TestParseAcceptsDuplicateServerBlock(t *testing.T) {
const text = `
[dungeon]
[skycastle]
<4::channel_info_dname_2>
200 201
[/dungeon]
[server]
1
11 ` + "`<4::channel_info_cname_11>`" + ` 0 ` + "`[skycastle]`" + ` 5
[/server]
[server]
1
11 ` + "`<4::channel_info_cname_11>`" + ` 0 ` + "`[skycastle]`" + ` 5
[/server]
`
index, err := Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
channels := index.ChannelsForDungeon(201)
if len(channels) != 1 || channels[0].ID != 11 {
t.Fatalf("dungeon 201 channels mismatch: %+v", channels)
}
}
func TestParseAcceptsInlineServerID(t *testing.T) {
const text = `
[dungeon]
` + "`[granfloris]`" + `
<4::channel_info_dname_1>
3
5
[/dungeon]
[server] 1
10 ` + "`<chn_channel_info_028>`" + ` 11 ` + "`[granfloris]`" + ` 5 0.0 0.0 ` + "``" + `
19 ` + "`<chn_channel_info_028>`" + ` 11 ` + "`[granfloris]`" + ` 5 0 0 ` + "``" + `
[/server]
`
index, err := Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
channels := index.ChannelsForServer(1)
if len(channels) != 2 || channels[0].ID != 10 || channels[1].ID != 19 {
t.Fatalf("inline server channels mismatch: %+v", channels)
}
if channels[0].NameKey != "chn_channel_info_028" ||
channels[0].AreaKey != "granfloris" ||
channels[0].Type != 11 {
t.Fatalf("inline server channel mismatch: %+v", channels[0])
}
}
func TestParseRejectsInvalidInlineServerID(t *testing.T) {
_, err := Parse([]byte("[server] invalid\n[/server]\n"))
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Parse() error = %v, want ErrInvalid", err)
}
}
func TestParseAcceptsPackedPVFChannelInfo(t *testing.T) {
const text = `
[dungeon]
` + "`[elven_guard]` `艾尔文防线` 1 2" + `
[/dungeon]
[server]
1 1 ` + "`洛兰`" + ` 2 ` + "`[elven_guard]`" + ` 10 0 0 6 ` + "`交易 - 拍卖行`" + ` 3 ` + "`[none]`" + ` 0 0
[/server]
[server]
98 1 ` + "`内部测试`" + ` 2 ` + "`[elven_guard]`" + ` 9 0
[/server]
`
index, err := Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if channels := index.Channels(); len(channels) != 3 {
t.Fatalf("Channels() len = %d, want 3: %+v", len(channels), channels)
}
channel, ok := index.ServerChannel(1, 1)
if !ok {
t.Fatalf("server 1 channel 1 should exist")
}
if channel.NameKey != "洛兰" || channel.AreaKey != "elven_guard" {
t.Fatalf("server channel mismatch: %+v", channel)
}
if channel, ok := index.ServerChannel(98, 1); !ok || channel.NameKey != "内部测试" {
t.Fatalf("server 98 internal channel should be parsed, got %+v ok=%v", channel, ok)
}
if got := index.ChannelsForServer(98); len(got) != 1 || got[0].ServerID != 98 {
t.Fatalf("server 98 channels mismatch: %+v", got)
}
routed := index.ChannelsForDungeon(1)
if len(routed) != 2 {
t.Fatalf("dungeon 1 routed channels len = %d, want 2: %+v", len(routed), routed)
}
if routed[0].ServerID != 1 || routed[0].ID != 1 {
t.Fatalf("dungeon route should keep server 1 first: %+v", routed)
}
}
func TestParseRejectsConflictingDuplicateChannel(t *testing.T) {
const text = `
[server]
1
11 ` + "`<4::channel_info_cname_11>`" + ` 0 ` + "`[skycastle]`" + `
11 ` + "`<4::channel_info_cname_11>`" + ` 0 ` + "`[granfloris]`" + `
[/server]
`
_, err := Parse([]byte(text))
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Parse() error = %v, want ErrInvalid", err)
}
}
func TestLoadUsesExternalFile(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "channel_info.etc")
if err := os.WriteFile(filePath, []byte(routeFixture), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
reader := textReader{
"Etc/channel_info.etc": routeFixture,
}
index, source, err := Load(reader, LoadOptions{
Paths: []string{"missing/channel_info.etc", "Etc/channel_info.etc"},
FilePath: filePath,
})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if source.Kind != SourceFile || source.Path != filePath {
t.Fatalf("source mismatch: %+v", source)
}
if _, ok := index.Channel(10); !ok {
t.Fatalf("channel 10 should exist")
}
}
func TestLoadDoesNotReadPVFWhenExternalFileIsMissing(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "channel_info.etc")
reader := textReader{"Etc/channel_info.etc": routeFixture}
_, _, err := Load(reader, LoadOptions{
Paths: []string{"missing/channel_info.etc", "Etc/channel_info.etc"},
FilePath: filePath,
})
if !errors.Is(err, ErrMissing) {
t.Fatalf("Load() error = %v, want ErrMissing", err)
}
}
func TestLoadRejectsInvalidExternalFileWithoutPVFFallback(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "channel_info.etc")
if err := os.WriteFile(filePath, []byte("[server]\n"), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
reader := textReader{"Etc/channel_info.etc": routeFixture}
_, _, err := Load(reader, LoadOptions{
Paths: []string{"Etc/channel_info.etc"},
FilePath: filePath,
})
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Load() error = %v, want ErrInvalid", err)
}
}
func TestParseRejectsEmptyInput(t *testing.T) {
_, err := Parse([]byte(" \n // empty"))
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Parse() error = %v, want ErrInvalid", err)
}
}
func TestLoadRealChannelInfoFileWhenConfigured(t *testing.T) {
path := os.Getenv("DNF_TEST_CHANNEL_INFO_PATH")
if path == "" {
t.Skip("DNF_TEST_CHANNEL_INFO_PATH not set")
}
index, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile(%q) error = %v", path, err)
}
if len(index.Areas()) == 0 || len(index.Channels()) == 0 {
t.Fatalf("real channel_info.etc parsed empty index")
}
}
type textReader map[string]string
func (r textReader) ReadText(path string) (string, error) {
text, ok := r[path]
if !ok {
return "", os.ErrNotExist
}
return text, nil
}
@@ -0,0 +1,2 @@
// Package channelinfo 解析 DNF channel_info.etc,并生成频道、区域和副本的内存路由索引。
package channelinfo
@@ -0,0 +1,151 @@
package channelinfo
import (
"bufio"
"bytes"
"errors"
"fmt"
"strconv"
"strings"
)
// OneClickProfile describes the deliberately small channel directory shipped
// with the binary-only one-click release. Non-ordinary and non-arena channel
// groups are retained so special and raid entrances remain available.
type OneClickProfile struct {
ServerID int
OrdinaryChannelID int
OrdinaryAreaKey string
ArenaChannelID int
ArenaAreaKey string
}
// BuildOneClickChannelInfo keeps one ordinary channel and one arena channel
// while preserving every special and team channel from the authoritative ETC.
func BuildOneClickChannelInfo(data []byte, profile OneClickProfile) ([]byte, error) {
if profile.ServerID <= 0 || profile.OrdinaryChannelID <= 0 || profile.ArenaChannelID <= 0 {
return nil, errors.New("one-click channel profile IDs must be positive")
}
ordinaryArea := areaKey(profile.OrdinaryAreaKey)
arenaArea := areaKey(profile.ArenaAreaKey)
if ordinaryArea == "" || arenaArea == "" || ordinaryArea == arenaArea {
return nil, errors.New("one-click ordinary and arena area keys must be distinct")
}
index, err := Parse(data)
if err != nil {
return nil, err
}
channels := index.ChannelsForServer(profile.ServerID)
if len(channels) == 0 {
return nil, fmt.Errorf("one-click source server %d has no channels", profile.ServerID)
}
ordinary, ok := index.ServerChannel(profile.ServerID, profile.OrdinaryChannelID)
if !ok || ordinary.Type != 11 || ordinary.AreaKey != ordinaryArea {
return nil, fmt.Errorf(
"one-click ordinary channel %d must be type 11 in [%s]",
profile.OrdinaryChannelID,
ordinaryArea,
)
}
arena, ok := index.ServerChannel(profile.ServerID, profile.ArenaChannelID)
if !ok || arena.AreaKey != arenaArea {
return nil, fmt.Errorf(
"one-click arena channel %d must be in [%s]",
profile.ArenaChannelID,
arenaArea,
)
}
selected := make([]Channel, 0, len(channels))
for _, channel := range channels {
switch {
case channel.Type == 11:
if channel.ID == profile.OrdinaryChannelID && channel.AreaKey == ordinaryArea {
selected = append(selected, channel)
}
case channel.AreaKey == arenaArea:
if channel.ID == profile.ArenaChannelID {
selected = append(selected, channel)
}
default:
selected = append(selected, channel)
}
}
prefix, err := channelInfoPrefix(data)
if err != nil {
return nil, err
}
var output strings.Builder
output.WriteString(prefix)
output.WriteString("[server] ")
output.WriteString(strconv.Itoa(profile.ServerID))
output.WriteByte('\n')
for _, channel := range selected {
fields := []string{
strconv.Itoa(channel.ID),
"`<" + channel.NameKey + ">`",
strconv.Itoa(channel.Type),
"`[" + channel.AreaKey + "]`",
}
fields = append(fields, normalizedRawFields(channel.RawFields)...)
output.WriteString(" ")
output.WriteString(strings.Join(fields, " "))
output.WriteByte('\n')
}
output.WriteString("[/server]\n")
result := []byte(output.String())
compacted, err := Parse(result)
if err != nil {
return nil, fmt.Errorf("validate one-click channel info: %w", err)
}
ordinaryCount := 0
arenaCount := 0
for _, channel := range compacted.ChannelsForServer(profile.ServerID) {
if channel.Type == 11 {
ordinaryCount++
}
if channel.AreaKey == arenaArea {
arenaCount++
}
}
if ordinaryCount != 1 || arenaCount != 1 {
return nil, fmt.Errorf(
"one-click channel validation failed: ordinary=%d arena=%d",
ordinaryCount,
arenaCount,
)
}
return result, nil
}
func channelInfoPrefix(data []byte) (string, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 1024), 1024*1024)
var prefix strings.Builder
for scanner.Scan() {
line := scanner.Text()
if _, ok, err := parseServerSectionHeader(strings.TrimSpace(line)); ok {
if err != nil {
return "", err
}
return prefix.String(), nil
}
prefix.WriteString(line)
prefix.WriteByte('\n')
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", errors.New("one-click channel source has no server section")
}
func normalizedRawFields(fields []string) []string {
out := append([]string(nil), fields...)
for len(out) > 0 && out[len(out)-1] == "``" {
out = out[:len(out)-1]
}
return append(out, "``")
}
@@ -0,0 +1,54 @@
package channelinfo
import "testing"
func TestBuildOneClickChannelInfoKeepsOneOrdinaryAndArenaChannel(t *testing.T) {
data := []byte("[dungeon]\n`[granfloris]` `forest` 1\n[/dungeon]\n" +
"[dungeon]\n`[pvp]` `arena`\n[/dungeon]\n" +
"[server] 1\n" +
"10 `forest` 11 `[granfloris]` 5 0 0 ``\n" +
"19 `forest` 11 `[granfloris]` 5 0 0 ``\n" +
"21 `sky` 11 `[sky_catle]` 5 0 0 ``\n" +
"23 `raid` 23 `[anton_raid]` 5 0 0 ``\n" +
"501 `arena1` 24 `[pvp]` 0 0 0 ``\n" +
"502 `arena2` 24 `[pvp]` 0 0 0 ``\n" +
"[/server]\n")
result, err := BuildOneClickChannelInfo(data, OneClickProfile{
ServerID: 1,
OrdinaryChannelID: 19,
OrdinaryAreaKey: "granfloris",
ArenaChannelID: 501,
ArenaAreaKey: "pvp",
})
if err != nil {
t.Fatal(err)
}
index, err := Parse(result)
if err != nil {
t.Fatal(err)
}
channels := index.ChannelsForServer(1)
if len(channels) != 3 {
t.Fatalf("one-click channels = %+v, want ordinary, raid, and arena", channels)
}
for idx, want := range []int{19, 23, 501} {
if channels[idx].ID != want {
t.Fatalf("one-click channel[%d] = %d, want %d", idx, channels[idx].ID, want)
}
}
}
func TestBuildOneClickChannelInfoRejectsMismatchedRequiredChannel(t *testing.T) {
data := []byte("[server] 1\n19 `forest` 11 `[granfloris]` 0 ``\n501 `arena` 24 `[pvp]` 0 ``\n[/server]\n")
_, err := BuildOneClickChannelInfo(data, OneClickProfile{
ServerID: 1,
OrdinaryChannelID: 10,
OrdinaryAreaKey: "granfloris",
ArenaChannelID: 501,
ArenaAreaKey: "pvp",
})
if err == nil {
t.Fatal("missing required ordinary channel was accepted")
}
}
@@ -0,0 +1,171 @@
package channelinfo
import (
"bufio"
"bytes"
"errors"
"fmt"
"strconv"
"strings"
)
// Build90CNOnlineScript derives the channel table downloaded by the 90CN
// client from the raw PVF channel_info.etc. Source names and groups remain
// authoritative; only the advertised server ID and proved online type
// projection may differ from the raw in-game catalog.
//
// 严格版:源表缺少引导频道时直接失败。只开少数频道(甚至只开特殊区域频道)的运维配置
// 请走 Build90CNOnlineScriptAllowingMissingBootstrap。
func Build90CNOnlineScript(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int) ([]byte, error) {
script, bootstrapFound, err := build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID)
if err != nil {
return nil, err
}
if !bootstrapFound {
return nil, fmt.Errorf(
"channel script source server %d is missing bootstrap channel %d",
sourceServerID,
bootstrapChannelID,
)
}
return script, nil
}
// Build90CNOnlineScriptAllowingMissingBootstrap 是 Build90CNOnlineScript 的宽松版:
// 源频道表里没有引导频道时不再失败,而是返回 bootstrapPresent=false,由调用方决定
// 是告警还是拒绝。
//
// 为什么需要它:引导频道这条硬校验来自官方 PVF 流程——那边的引导频道是 id 1 / type 2,
// 由 OnlineTypeFor90CN 映成线上的 type 11;而线上这份 channel_info.etc 里并没有 id 1,
// 于是 dnfenum.BootstrapChannelID 被定成 19,校验就变成了"表里必须有 19"。
// 实测(2026-09-30,1192 次客户端连接)客户端**从不**连 10019:它从 7001 拿目录后
// 直接从目录里挑端口连。所以这条约束对这条链路是化石约束,运维只开任意一个频道时
// 不该把服务端拦在门外。
func Build90CNOnlineScriptAllowingMissingBootstrap(
data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int,
) ([]byte, bool, error) {
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID)
}
func build90CNOnlineScript(
data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int,
) ([]byte, bool, error) {
if sourceServerID <= 0 || advertiseServerID < 0 || bootstrapChannelID <= 0 {
return nil, false, errors.New("channel script source, advertisement, and bootstrap IDs are invalid")
}
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 1024), 1024*1024)
var prefix strings.Builder
serverSectionSeen := false
inServer := false
activeServer := 1
records := make([][]string, 0, 64)
bootstrapFound := false
for lineNumber := 1; scanner.Scan(); lineNumber++ {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if serverID, ok, err := parseServerSectionHeader(trimmed); ok {
if err != nil {
return nil, false, fmt.Errorf("channel script line %d: %w", lineNumber, err)
}
serverSectionSeen = true
inServer = true
activeServer = serverID
continue
}
switch strings.ToLower(trimmed) {
case "[/server]":
inServer = false
continue
}
if !serverSectionSeen {
prefix.WriteString(line)
prefix.WriteByte('\n')
continue
}
if !inServer {
continue
}
if trimmed == "" {
continue
}
fields := splitFields(trimmed)
if len(fields) == 0 {
continue
}
if len(fields) == 1 {
if id, err := strconv.Atoi(fields[0]); err == nil {
activeServer = id
}
continue
}
start := 0
if len(fields) >= 5 && isInt(fields[0]) && isChannelStart(fields, 1) {
activeServer, _ = strconv.Atoi(fields[0])
start = 1
}
for start < len(fields) {
if !isChannelStart(fields, start) {
return nil, false, fmt.Errorf("channel script line %d: invalid packed channel row", lineNumber)
}
end := len(fields)
for next := start + 4; next < len(fields); next++ {
if isChannelStart(fields, next) {
end = next
break
}
}
if activeServer == sourceServerID {
record := append([]string(nil), fields[start:end]...)
id, _ := strconv.Atoi(record[0])
channelType, _ := strconv.Atoi(record[2])
record[2] = strconv.Itoa(OnlineTypeFor90CN(id, channelType))
if id == bootstrapChannelID {
bootstrapFound = true
}
for len(record) > 0 && record[len(record)-1] == "``" {
record = record[:len(record)-1]
}
record = append(record, "``")
records = append(records, record)
}
start = end
}
}
if err := scanner.Err(); err != nil {
return nil, false, err
}
if len(records) == 0 {
return nil, false, fmt.Errorf("channel script source server %d has no channels", sourceServerID)
}
var output strings.Builder
output.WriteString(prefix.String())
output.WriteString("[server]\n")
output.WriteString(strconv.Itoa(advertiseServerID))
output.WriteByte('\n')
for _, record := range records {
output.WriteString(strings.Join(record, " "))
output.WriteByte('\n')
}
output.WriteString("[/server]\n")
return []byte(output.String()), bootstrapFound, nil
}
// OnlineTypeFor90CN converts a raw PVF channel type into the type stored in
// the downloaded online directory. It must never be used for game CHANNELINFO.
func OnlineTypeFor90CN(channelID, channelType int) int {
if channelType == 0 || channelType == 1 {
return 22
}
if channelID == 1 && channelType == 2 {
return 11
}
return channelType
}
@@ -0,0 +1,213 @@
package channelinfo
import (
"strconv"
"strings"
"testing"
)
func TestBuild90CNOnlineScriptExpandsPackedSourceServer(t *testing.T) {
data := []byte("[dungeon]\n`[sky_catle]` `sky` 11 12\n[/dungeon]\n[server]\n1 1 `tower` 2 `[none]` 0 0 0 6 `trade` 3 `[none]` 0 0 0 10 `forest` 0 `[granfloris]` 5 0 0 19 `crack` 1 `[crack]` 5 0 0 1 0 0 0\n[/server]\n[server]\n2 19 `other` 0 `[sky_catle]` 3 0 0\n[/server]\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
text := string(script)
for _, want := range []string{
"[dungeon]\n`[sky_catle]` `sky` 11 12\n[/dungeon]",
"[server]\n0\n",
"1 `tower` 11 `[none]` 0 0 0 ``\n",
"6 `trade` 3 `[none]` 0 0 0 ``\n",
"10 `forest` 22 `[granfloris]` 5 0 0 ``\n",
"19 `crack` 22 `[crack]` 5 0 0 1 0 0 0 ``\n",
} {
if !strings.Contains(text, want) {
t.Fatalf("online script is missing %q:\n%s", want, text)
}
}
if strings.Contains(text, "2 19 `other`") {
t.Fatalf("online script contains another server:\n%s", text)
}
index, err := Parse(script)
if err != nil {
t.Fatal(err)
}
channels := index.ChannelsForServer(0)
if len(channels) != 4 {
t.Fatalf("online channels = %d, want 4: %+v", len(channels), channels)
}
if channels[0].Type != 11 || channels[1].Type != 3 || channels[2].Type != 22 || channels[3].Type != 22 {
t.Fatalf("online channel types = %d/%d/%d/%d", channels[0].Type, channels[1].Type, channels[2].Type, channels[3].Type)
}
}
func TestBuild90CNOnlineScriptAcceptsInlineServerID(t *testing.T) {
data := []byte("[dungeon]\n`[granfloris]`\n<4::channel_info_dname_1>\n3\n[/dungeon]\n[server] 1\n10 `<chn_channel_info_028>` 11 `[granfloris]` 5 0.0 0.0 ``\n19 `<chn_channel_info_028>` 11 `[granfloris]` 5 0 0 ``\n[/server]\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
index, err := Parse(script)
if err != nil {
t.Fatal(err)
}
channels := index.ChannelsForServer(0)
if len(channels) != 2 || channels[0].ID != 10 || channels[1].ID != 19 {
t.Fatalf("online inline channels mismatch: %+v", channels)
}
if channels[0].NameKey != "chn_channel_info_028" ||
channels[1].NameKey != "chn_channel_info_028" ||
channels[0].AreaKey != "granfloris" ||
channels[1].AreaKey != "granfloris" {
t.Fatalf("online inline source mapping was not preserved: %+v", channels)
}
}
func TestBuild90CNOnlineScriptPreservesSourceMappingForChannel38(t *testing.T) {
data := []byte("[server]\n1\n19 `crack` 1 `[crack]` 5 0 0\n38 `trade` 3 `[trade]` 0 0 0\n[/server]\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
text := string(script)
if !strings.Contains(text, "38 `trade` 3 `[trade]`") {
t.Fatalf("online script did not preserve channel 38 source mapping:\n%s", text)
}
}
func TestBuild90CNOnlineScriptPreservesOriginPvpChannels(t *testing.T) {
data := []byte("[dungeon]\n`[pvp]`\n<4::chn_channel_info_044>\n[/dungeon]\n[server] 1\n" +
"19 `<chn_channel_info_028>` 11 `[granfloris]` 5 0 0 ``\n" +
"501 `<chn_channel_info_039>` 24 `[pvp]` 0 0 0 ``\n" +
"502 `<chn_channel_info_040>` 24 `[pvp]` 0 0 0 ``\n" +
"503 `<chn_channel_info_041>` 24 `[pvp]` 0 0 0 ``\n" +
"504 `<chn_channel_info_042>` 24 `[pvp]` 0 0 0 ``\n" +
"505 `<chn_channel_info_043>` 24 `[pvp]` 0 0 0 ``\n" +
"506 `<chn_channel_info_044>` 24 `[pvp]` 0 0 0 ``\n" +
"507 `<chn_channel_info_045>` 24 `[pvp]` 0 0 0 ``\n" +
"[/server]\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
index, err := Parse(script)
if err != nil {
t.Fatal(err)
}
channels := index.ChannelsForServer(0)
if len(channels) != 8 {
t.Fatalf("online channels = %d, want 8: %+v", len(channels), channels)
}
for i, channel := range channels[1:] {
wantID := 501 + i
wantNameKey := "chn_channel_info_0" + strconv.Itoa(39+i)
if channel.ID != wantID || channel.Type != 24 ||
channel.AreaKey != "pvp" || channel.NameKey != wantNameKey {
t.Fatalf("pvp channel %d mismatch: %+v", wantID, channel)
}
}
}
func TestBuild90CNOnlineScriptNormalizesExistingRecordTerminators(t *testing.T) {
data := []byte("[server]\r\n1\r\n10 `forest` 1 `[granfloris]` 5 0 0 ``\r\n19 `crack` 1 `[crack]` 5 0 0 ``\r\n[/server]\r\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
text := string(script)
if strings.Contains(text, " `` ``\n") {
t.Fatalf("online script contains duplicate record terminators:\n%s", text)
}
if got := strings.Count(text, " ``\n"); got != 2 {
t.Fatalf("online script terminators = %d, want 2:\n%s", got, text)
}
index, err := Parse(script)
if err != nil {
t.Fatal(err)
}
if got := len(index.ChannelsForServer(0)); got != 2 {
t.Fatalf("online channels = %d, want 2", got)
}
}
func TestOnlineTypeFor90CNSeparatesDirectoryTypeFromRawType(t *testing.T) {
tests := []struct {
channelID int
channelType int
want int
}{
{channelID: 10, channelType: 0, want: 22},
{channelID: 31, channelType: 1, want: 22},
{channelID: 1, channelType: 2, want: 11},
{channelID: 6, channelType: 3, want: 3},
{channelID: 51, channelType: 4, want: 4},
}
for _, test := range tests {
if got := OnlineTypeFor90CN(test.channelID, test.channelType); got != test.want {
t.Errorf("OnlineTypeFor90CN(%d, %d) = %d, want %d", test.channelID, test.channelType, got, test.want)
}
}
}
func TestBuild90CNOnlineScriptRequiresBootstrapChannel(t *testing.T) {
_, err := Build90CNOnlineScript([]byte("[server]\n1 10 `forest` 1 `[granfloris]` 0 0\n[/server]\n"), 1, 1, 19)
if err == nil || !strings.Contains(err.Error(), "missing bootstrap channel 19") {
t.Fatalf("error = %v, want missing bootstrap channel", err)
}
}
// TestBuild90CNOnlineScriptAllowingMissingBootstrap 宽松版必须照常产出脚本,
// 并用第二个返回值把"源表没有引导频道"这件事告诉调用方(由它决定告警还是拒绝)。
func TestBuild90CNOnlineScriptAllowingMissingBootstrap(t *testing.T) {
script, bootstrapPresent, err := Build90CNOnlineScriptAllowingMissingBootstrap(
[]byte("[server]\n1 171 `chn_channel_info_014` 22 `[deathtower]` 0 0\n[/server]\n"),
1,
1,
19,
)
if err != nil {
t.Fatalf("宽松模式下不该失败: %v", err)
}
if bootstrapPresent {
t.Fatal("源表里没有 19,bootstrapPresent 必须是 false")
}
online, err := Parse(script)
if err != nil {
t.Fatalf("宽松模式产出的脚本必须仍可解析: %v", err)
}
if _, ok := online.ServerChannel(1, 171); !ok {
t.Fatalf("宽松模式脚本里没有 171:\n%s", script)
}
// 引导频道在表里时必须报告 true,且严格版与宽松版产出一致。
withBootstrap := []byte("[server]\n1 19 `chn_channel_info_028` 11 `[granfloris]` 0 0\n[/server]\n")
relaxedScript, present, err := Build90CNOnlineScriptAllowingMissingBootstrap(withBootstrap, 1, 1, 19)
if err != nil || !present {
t.Fatalf("含引导频道时 present=%v err=%v, want true/nil", present, err)
}
strictScript, err := Build90CNOnlineScript(withBootstrap, 1, 1, 19)
if err != nil {
t.Fatal(err)
}
if string(relaxedScript) != string(strictScript) {
t.Fatalf("宽松版与严格版产出不一致:\n宽松:\n%s\n严格:\n%s", relaxedScript, strictScript)
}
}
func TestBuild90CNOnlineScriptRejectsUnquotedChannelFields(t *testing.T) {
_, err := Build90CNOnlineScript(
[]byte("[server]\n1\n19 crack 1 [crack] 5 0 0\n[/server]\n"),
1,
0,
19,
)
if err == nil || !strings.Contains(err.Error(), "invalid packed channel row") {
t.Fatalf("error = %v, want invalid packed channel row", err)
}
}