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,390 @@
package dnfbridge
// gm_channel_reload_test.go 覆盖频道热重载:目录/脚本的热替换、保护性拒绝、
// 以及游戏端口监听的按频道开关对账。
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
appkit "longheng.io/server/internal/platform/app"
)
// writeChannelInfoFixture 写一份最小可用的 channel_info.etc。
// rows 是 `[server]` 段的原始行文本。
func writeChannelInfoFixture(t *testing.T, rows []string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "channel_info.etc")
body := "[dungeon]\r\n`[town]`\r\n<4::dname_1>\r\n1\r\n[/dungeon]\r\n" +
"[server] 1\r\n" + strings.Join(rows, "\r\n") + "\r\n[/server]\r\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("写频道档 fixture: %v", err)
}
return path
}
// channelRow 生成一条 `[server]` 段频道行(字段顺序与真实文件一致)。
func channelRow(id int, typeID int, group string) string {
return fmt.Sprintf(" %d `<chn_channel_info_%03d>` %d `[%s]` 0 0 ``", id, id, typeID, group)
}
// newChannelReloadTestService 造一个只加载了频道资源、没有真正开监听的服务实例。
func newChannelReloadTestService(t *testing.T, path string) *Service {
t.Helper()
service := NewWithOptions(&appkit.Env{}, ServiceOptions{})
service.options.channelInfoFile = path
catalog, script, err := service.loadChannelAssets()
if err != nil {
t.Fatalf("loadChannelAssets: %v", err)
}
runCtx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
service.mu.Lock()
service.catalog = catalog
service.channelScript = append([]byte(nil), script...)
service.runCtx = runCtx
service.cancel = cancel
service.mu.Unlock()
return service
}
// TestGMReloadChannelsSwapsCatalogAndScript 频道号不变、只改频道类型时,
// 热重载必须换掉内存里的目录与客户端脚本,且不触碰任何监听。
func TestGMReloadChannelsSwapsCatalogAndScript(t *testing.T) {
path := writeChannelInfoFixture(t, []string{
channelRow(11, 11, "granfloris"),
channelRow(19, 40, "crack"),
channelRow(50, 11, "granfloris"),
})
service := newChannelReloadTestService(t, path)
before, ok := service.currentCatalog().Channel(50)
if !ok || before.Type != 11 {
t.Fatalf("fixture 频道 50 = %+v, want type 11", before)
}
beforeScript := string(service.currentChannelScript())
// 只改 50 的类型:频道号集合完全不变 → 不应该有任何端口开/关。
if err := os.WriteFile(path, []byte("[server] 1\r\n"+
channelRow(11, 11, "granfloris")+"\r\n"+
channelRow(19, 40, "crack")+"\r\n"+
channelRow(50, 22, "ancient")+"\r\n"+
"[/server]\r\n"), 0o644); err != nil {
t.Fatalf("改写频道档: %v", err)
}
result, err := service.GMReloadChannels(context.Background())
if err != nil {
t.Fatalf("GMReloadChannels: %v", err)
}
if len(result.AddedChannels) != 0 || len(result.RemovedChannels) != 0 {
t.Fatalf("频道号集合没变却报告增删: +%v -%v", result.AddedChannels, result.RemovedChannels)
}
if len(result.GamePortsOpened) != 0 || len(result.GamePortsClosed) != 0 {
t.Fatalf("频道号集合没变却动了监听: +%v -%v", result.GamePortsOpened, result.GamePortsClosed)
}
after, ok := service.currentCatalog().Channel(50)
if !ok || after.Type != 22 || after.Group != "ancient" {
t.Fatalf("热重载后频道 50 = %+v, want type 22 group ancient", after)
}
// 客户端下载的那份频道表也要跟着变(脚本里带类型),否则玩家看到的还是旧类型。
if afterScript := string(service.currentChannelScript()); afterScript == beforeScript {
t.Fatal("客户端频道脚本没有跟着热重载更新")
}
if !strings.Contains(string(service.currentChannelScript()), "`[ancient]`") {
t.Fatalf("客户端频道脚本里没有新分组: %s", service.currentChannelScript())
}
}
// TestGMReloadChannelsAllowsCatalogWithoutDefaultTownChannel 缺默认城镇频道 11 的表
// 默认只告警、照常重载:运维会主动只留特殊区域频道(2026-09-30 需求)。
// 告警仍然要出现在结果里,否则运维不知道有多少玩家会停在选频道界面。
func TestGMReloadChannelsAllowsCatalogWithoutDefaultTownChannel(t *testing.T) {
path := writeChannelInfoFixture(t, []string{
channelRow(11, 11, "granfloris"),
channelRow(19, 40, "crack"),
channelRow(50, 11, "granfloris"),
})
service := newChannelReloadTestService(t, path)
// 去掉 11,保留引导频道 19 与另一个频道。
if err := os.WriteFile(path, []byte("[server] 1\r\n"+
channelRow(19, 40, "crack")+"\r\n"+
channelRow(50, 11, "granfloris")+"\r\n"+
"[/server]\r\n"), 0o644); err != nil {
t.Fatalf("改写频道档: %v", err)
}
result, err := service.GMReloadChannels(context.Background())
if err != nil {
t.Fatalf("缺少默认城镇频道的表默认应被放行(只告警),却被拒绝: %v", err)
}
if _, ok := service.currentCatalog().Channel(11); ok {
t.Fatal("频道 11 应从新表里消失")
}
if _, ok := service.currentCatalog().Channel(50); !ok {
t.Fatal("新表里的 50 必须生效")
}
warned := false
for _, warning := range result.Warnings {
if strings.Contains(warning, "默认城镇频道 11") {
warned = true
}
}
if !warned {
t.Fatalf("缺少默认城镇频道时必须留下告警,实际 warnings=%v", result.Warnings)
}
}
// TestGMReloadChannelsRefusesCatalogWithoutDefaultTownChannelInStrictMode
// DNFBRIDGE_CHANNEL_TABLE_STRICT=1 时恢复旧行为:拒绝且**保留旧表**。
func TestGMReloadChannelsRefusesCatalogWithoutDefaultTownChannelInStrictMode(t *testing.T) {
path := writeChannelInfoFixture(t, []string{
channelRow(11, 11, "granfloris"),
channelRow(19, 40, "crack"),
channelRow(50, 11, "granfloris"),
})
service := newChannelReloadTestService(t, path)
service.options.channelTableStrict = true
// 去掉 11,保留引导频道 19(引导频道在严格模式下由 Build90CNOnlineScript 强制,
// 先去掉它会在解析阶段就失败,测不到"缺默认城镇频道"这条分支)。
if err := os.WriteFile(path, []byte("[server] 1\r\n"+
channelRow(19, 40, "crack")+"\r\n"+
channelRow(50, 11, "granfloris")+"\r\n"+
"[/server]\r\n"), 0o644); err != nil {
t.Fatalf("改写频道档: %v", err)
}
if _, err := service.GMReloadChannels(context.Background()); err == nil {
t.Fatal("严格模式下缺少默认城镇频道的表应被拒绝热重载")
}
if _, ok := service.currentCatalog().Channel(11); !ok {
t.Fatal("拒绝热重载后旧表必须原样保留,但频道 11 已经不见了")
}
}
// TestGMReloadChannelsKeepsOldTableWhenFileIsBroken 文件坏掉时不能把服务端搞成半死状态。
func TestGMReloadChannelsKeepsOldTableWhenFileIsBroken(t *testing.T) {
path := writeChannelInfoFixture(t, []string{
channelRow(11, 11, "granfloris"),
channelRow(19, 40, "crack"),
})
service := newChannelReloadTestService(t, path)
beforeScript := string(service.currentChannelScript())
if err := os.WriteFile(path, []byte("[server] 1\r\n这行根本不是频道记录\r\n[/server]\r\n"), 0o644); err != nil {
t.Fatalf("改写频道档: %v", err)
}
if _, err := service.GMReloadChannels(context.Background()); err == nil {
t.Fatal("坏掉的频道档应被拒绝")
}
if _, ok := service.currentCatalog().Channel(11); !ok {
t.Fatal("拒绝后旧目录必须保留")
}
if string(service.currentChannelScript()) != beforeScript {
t.Fatal("拒绝后旧客户端脚本必须保留")
}
}
// TestGMReloadChannelsRequiresRunningService 服务没起来时不能假装重载成功。
func TestGMReloadChannelsRequiresRunningService(t *testing.T) {
path := writeChannelInfoFixture(t, []string{
channelRow(11, 11, "granfloris"),
channelRow(19, 40, "crack"),
})
service := NewWithOptions(&appkit.Env{}, ServiceOptions{})
service.options.channelInfoFile = path
if _, err := service.GMReloadChannels(context.Background()); err == nil {
t.Fatal("未启动的服务应拒绝热重载")
}
}
// TestGMReloadChannelsReconcilesGamePorts 端到端验证端口对账:
// 关掉的频道必须停掉监听,新启用的频道必须开监听。
//
// 游戏端口 = GamePortBase + 频道号(dnfenum.GamePortBase = 10000),
// 所以这里先借 OS 分配一个空闲端口,再反推一个映射到它的频道号,
// 避免撞上机器上真实占用的端口。
func TestGMReloadChannelsReconcilesGamePorts(t *testing.T) {
channelID := reserveFreeChannelID(t)
// 初始状态:11/19/新频道 都在表里,并且新频道的端口已经被本服务监听。
path := writeChannelInfoFixture(t, []string{
channelRow(11, 11, "granfloris"),
channelRow(19, 40, "crack"),
channelRow(channelID, 11, "granfloris"),
})
service := newChannelReloadTestService(t, path)
runCtx := service.runCtxForTest()
if err := service.listenGamePort(runCtx, 10000+channelID); err != nil {
t.Skipf("测试环境无法监听端口 %d,跳过端到端端口对账: %v", 10000+channelID, err)
}
port := 10000 + channelID
if _, ok := service.gameListeners[port]; !ok {
t.Fatalf("listenGamePort 之后端口 %d 未登记", port)
}
// 关掉那个频道 → 端口监听必须被关掉。
if err := os.WriteFile(path, []byte("[server] 1\r\n"+
channelRow(11, 11, "granfloris")+"\r\n"+
channelRow(19, 40, "crack")+"\r\n"+
"[/server]\r\n"), 0o644); err != nil {
t.Fatalf("改写频道档: %v", err)
}
result, err := service.GMReloadChannels(context.Background())
if err != nil {
t.Fatalf("GMReloadChannels(close): %v", err)
}
if len(result.RemovedChannels) != 1 || result.RemovedChannels[0] != channelID {
t.Fatalf("RemovedChannels = %v, want [%d]", result.RemovedChannels, channelID)
}
if len(result.GamePortsClosed) != 1 || result.GamePortsClosed[0] != port {
t.Fatalf("GamePortsClosed = %v, want [%d]", result.GamePortsClosed, port)
}
if _, still := service.gameListeners[port]; still {
t.Fatalf("端口 %d 的监听未解除登记", port)
}
// 端口真的被释放了:现在能重新绑上。
probe, probeErr := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if probeErr != nil {
t.Fatalf("关闭频道后端口 %d 仍被占用,监听没真正关掉: %v", port, probeErr)
}
_ = probe.Close()
// 再把频道加回来 → 端口监听必须重新开出来。
if err := os.WriteFile(path, []byte("[server] 1\r\n"+
channelRow(11, 11, "granfloris")+"\r\n"+
channelRow(19, 40, "crack")+"\r\n"+
channelRow(channelID, 11, "granfloris")+"\r\n"+
"[/server]\r\n"), 0o644); err != nil {
t.Fatalf("改写频道档: %v", err)
}
result, err = service.GMReloadChannels(context.Background())
if err != nil {
t.Fatalf("GMReloadChannels(open): %v", err)
}
if len(result.AddedChannels) != 1 || result.AddedChannels[0] != channelID {
t.Fatalf("AddedChannels = %v, want [%d]", result.AddedChannels, channelID)
}
if len(result.GamePortsOpened) != 1 || result.GamePortsOpened[0] != port {
t.Fatalf("GamePortsOpened = %v(warnings=%v), want [%d]", result.GamePortsOpened, result.Warnings, port)
}
if _, ok := service.gameListeners[port]; !ok {
t.Fatalf("重新启用后端口 %d 未登记", port)
}
}
// TestCloseGamePortRemovesOnlyTargetListener 关闭单个端口不能误伤别的监听。
func TestCloseGamePortRemovesOnlyTargetListener(t *testing.T) {
first, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
second, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
_ = first.Close()
t.Fatalf("listen: %v", err)
}
defer second.Close()
service := &Service{
listeners: []net.Listener{first, second},
gameListeners: map[int]net.Listener{10050: first, 10051: second},
}
if err := service.closeGamePort(10050); err != nil {
t.Fatalf("closeGamePort: %v", err)
}
if _, still := service.gameListeners[10050]; still {
t.Fatal("被关闭的端口仍留在 gameListeners 里")
}
if len(service.listeners) != 1 || service.listeners[0] != second {
t.Fatalf("listeners = %v,期望只剩第二个", service.listeners)
}
if _, err := first.Accept(); err == nil {
t.Fatal("被关闭的监听仍在 Accept")
}
// 未知端口是幂等空操作,不能报错。
if err := service.closeGamePort(19999); err != nil {
t.Fatalf("关闭未知端口应是无操作: %v", err)
}
}
// TestDiffChannelIDs 比较函数本身(升序、去重语义)。
func TestDiffChannelIDs(t *testing.T) {
oldCatalog := channelCatalogForTest(t, []int{11, 19, 50, 51})
newCatalog := channelCatalogForTest(t, []int{11, 19, 51, 52})
added, removed := diffChannelIDs(oldCatalog, newCatalog)
if fmt.Sprint(added) != "[52]" || fmt.Sprint(removed) != "[50]" {
t.Fatalf("added=%v removed=%v, want [52] / [50]", added, removed)
}
}
func channelCatalogForTest(t *testing.T, ids []int) *channelcatalog.Catalog {
t.Helper()
rows := make([]string, 0, len(ids)+1)
hasBootstrap := false
for _, id := range ids {
rows = append(rows, channelRow(id, 11, "granfloris"))
if id == 19 {
hasBootstrap = true
}
}
// 引导频道 19 是频道脚本构建的硬要求(Build90CNOnlineScript 会在缺失时直接失败),
// 每次都补一条——但显式列了 19 时不能再补,重复行会让解析器报 "duplicate server channel"。
if !hasBootstrap {
rows = append(rows, channelRow(19, 40, "crack"))
}
path := writeChannelInfoFixture(t, rows)
service := NewWithOptions(&appkit.Env{}, ServiceOptions{})
service.options.channelInfoFile = path
catalog, _, err := service.loadChannelAssets()
if err != nil {
t.Fatalf("loadChannelAssets: %v", err)
}
return catalog
}
// reserveFreeChannelID 借 OS 分配一个空闲端口,反推出映射到它的频道号并立即释放端口。
// 若拿到的端口不落在游戏端口基址之上(理论上不会),就反复重试。
func reserveFreeChannelID(t *testing.T) int {
t.Helper()
for attempt := 0; attempt < 32; attempt++ {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("探测空闲端口失败: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
_ = listener.Close()
id := port - 10000
// 避开保护频道与过大的号(频道号要能放进 int16 槽位语义之外也无妨,这里只要求 > 2)。
if id <= 2 || id == 11 || id == 19 {
continue
}
// 确认现在真的空闲。
probe, probeErr := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if probeErr != nil {
continue
}
_ = probe.Close()
time.Sleep(0)
return id
}
t.Skip("32 次都没能拿到可用的空闲端口,跳过")
return 0
}
// runCtxForTest 读出租约上下文(仅供同包用例使用)。
func (s *Service) runCtxForTest() context.Context {
s.mu.Lock()
defer s.mu.Unlock()
return s.runCtx
}