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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user