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,199 @@
|
||||
package dnfbridge
|
||||
|
||||
// gm_channel_reload.go 提供"频道热重载":不重启进程,重新读取 channel_info.etc
|
||||
// 并把新的频道目录 + 客户端频道脚本换进内存,同时把游戏端口监听对账到新表。
|
||||
//
|
||||
// 为什么需要这个能力
|
||||
//
|
||||
// 频道目录(channelcatalog.Catalog)与下发给客户端的频道表(channelScript)原本
|
||||
// 只在 Service.Start 里构建一次并缓存(loadChannelAssets),运行期没有任何 setter。
|
||||
// 于是运维侧改完 channel_info.etc 必须重启 DNF90Server 才生效——重启会踢掉全部在线玩家,
|
||||
// 而"关掉几个没人用的频道"这种小操作不值得整服重启。
|
||||
//
|
||||
// 热重载做的事(顺序有意为之)
|
||||
//
|
||||
// 1. 重新读文件、重建 catalog 与 script;任一步失败都直接返回,内存里的旧表**原样保留**
|
||||
// (宁可维持现状,也不能让一次坏改动把服务端搞成半死状态)。
|
||||
// 2. 先校验新表仍然含有默认城镇频道(登录选频道的宿主),缺了直接拒绝。
|
||||
// 引导频道由 Build90CNOnlineScript 自己强制,缺了在步骤 1 就会失败。
|
||||
// 3. 先换内存里的目录与脚本,**再**对账监听。这个顺序保证:即使某个游戏端口因为被占用
|
||||
// 而开不出来,新连进来的玩家看到的也已经是一致的频道表(不会出现"列表里有、端口没开完"
|
||||
// 之外的更糟情况——反过来先对账监听就会出现一段"端口开着但表还是旧的"窗口)。
|
||||
// 4. 对账游戏端口监听:新启用的频道开监听,被关闭的频道关监听。
|
||||
// 关监听**不会**断开已建立的连接(net.Listener.Close 只影响 Accept),
|
||||
// 所以频道里已经站着的玩家不会被踢;他们离开后该频道就进不去了。
|
||||
//
|
||||
// 生效范围(运维侧 UI 必须如实说明,不能让人以为"全服立刻刷新")
|
||||
//
|
||||
// - 新连入的登录/频道会话:立刻拿到新频道表(脚本与目录都是现读现发)。
|
||||
// - 已在线、停在选频道界面的玩家:客户端拿到目录后会本地缓存,需要重开频道列表
|
||||
// 或重新登录才会刷新。服务端不主动推未请求的目录包——那会打乱客户端自身的
|
||||
// 目录状态机,代价远大于收益。
|
||||
// - 已被关闭频道里的玩家:不受影响,直到他们自己离开该频道。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
)
|
||||
|
||||
// GMChannelReloadResult 热重载结果(供 GM 工具/运维工具展示)。
|
||||
type GMChannelReloadResult struct {
|
||||
Path string `json:"path"`
|
||||
TotalChannels int `json:"total_channels"`
|
||||
AddedChannels []int `json:"added_channels"`
|
||||
RemovedChannels []int `json:"removed_channels"`
|
||||
GamePortsOpened []int `json:"game_ports_opened"`
|
||||
GamePortsClosed []int `json:"game_ports_closed"`
|
||||
OnlineCharacters int `json:"online_characters"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
ReloadedAt time.Time `json:"reloaded_at"`
|
||||
}
|
||||
|
||||
// GMReloadChannels 重新读取 channel_info.etc 并热替换频道目录与客户端频道脚本。
|
||||
//
|
||||
// 与其它 GM 写操作一致:参数非法/环境不满足返回 ErrGMRequestInvalid 包装的错误,
|
||||
// 调用方(GM 工具接口)据此回 400;失败时内存与监听都保持重载前的状态。
|
||||
func (s *Service) GMReloadChannels(ctx context.Context) (GMChannelReloadResult, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if s == nil {
|
||||
return GMChannelReloadResult{}, ErrGMUnavailable
|
||||
}
|
||||
|
||||
// 1) 重新构建(失败即回退,不动运行态)。
|
||||
catalog, script, err := s.loadChannelAssets()
|
||||
if err != nil {
|
||||
return GMChannelReloadResult{}, fmt.Errorf("%w: 重新读取频道表失败,已保留原表: %v", ErrGMRequestInvalid, err)
|
||||
}
|
||||
if catalog == nil {
|
||||
return GMChannelReloadResult{}, fmt.Errorf("%w: 频道表解析结果为空,已保留原表", ErrGMRequestInvalid)
|
||||
}
|
||||
// 2) 表能跑但玩家侧会受影响的状态:缺默认城镇频道 11(客户端那条"按记住的频道号找候选"
|
||||
// 的循环一个都命中不了,玩家停在选频道界面)、以及没有任何特殊区域频道(type 22)。
|
||||
// 默认只告警不拒绝——运维会主动只留少数频道甚至只留特殊区域频道;
|
||||
// DNFBRIDGE_CHANNEL_TABLE_STRICT=1 恢复"缺 11 就拒绝热重载"的旧行为。
|
||||
if _, ok := catalog.Channel(dnfenum.DefaultGameChannelID); !ok && s.channelTableStrict() {
|
||||
return GMChannelReloadResult{}, fmt.Errorf(
|
||||
"%w: 新频道表缺少默认城镇频道 %d,拒绝热重载(已保留原表)",
|
||||
ErrGMRequestInvalid, dnfenum.DefaultGameChannelID)
|
||||
}
|
||||
preWarnings := currentChannelTableWarnings(catalog, !s.channelTableStrict())
|
||||
newPorts := catalog.GamePorts()
|
||||
if len(newPorts) == 0 {
|
||||
return GMChannelReloadResult{}, fmt.Errorf("%w: 新频道表没有任何游戏端口,拒绝热重载(已保留原表)", ErrGMRequestInvalid)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
oldCatalog := s.catalog
|
||||
runCtx := s.runCtx
|
||||
if s.cancel == nil || runCtx == nil {
|
||||
s.mu.Unlock()
|
||||
return GMChannelReloadResult{}, fmt.Errorf("%w: 服务未启动,无法热重载频道", ErrGMUnavailable)
|
||||
}
|
||||
// 3) 先换内存里的目录与脚本。
|
||||
s.catalog = catalog
|
||||
s.channelScript = append([]byte(nil), script...)
|
||||
s.mu.Unlock()
|
||||
|
||||
oldPorts := []int(nil)
|
||||
if oldCatalog != nil {
|
||||
oldPorts = oldCatalog.GamePorts()
|
||||
}
|
||||
added, removed := diffChannelIDs(oldCatalog, catalog)
|
||||
|
||||
// 4) 对账游戏端口监听。
|
||||
result := GMChannelReloadResult{
|
||||
Path: s.options.channelInfoFile,
|
||||
TotalChannels: len(catalog.Channels()),
|
||||
AddedChannels: added,
|
||||
RemovedChannels: removed,
|
||||
ReloadedAt: time.Now().UTC(),
|
||||
Warnings: preWarnings,
|
||||
}
|
||||
for _, port := range oldPorts {
|
||||
if containsIntValue(newPorts, port) {
|
||||
continue
|
||||
}
|
||||
if err := s.closeGamePort(port); err != nil {
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("关闭已停用频道的游戏端口 %d 失败: %v", port, err))
|
||||
continue
|
||||
}
|
||||
result.GamePortsClosed = append(result.GamePortsClosed, port)
|
||||
}
|
||||
for _, port := range newPorts {
|
||||
if containsIntValue(oldPorts, port) {
|
||||
continue
|
||||
}
|
||||
if err := s.listenGamePort(runCtx, port); err != nil {
|
||||
// 端口开不出来不影响频道表已经生效:新玩家在选频道界面依然看得到这个频道,
|
||||
// 只是连进来会失败。把原因回报给运维侧,而不是让整次热重载失败。
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("启用频道的游戏端口 %d 监听失败(频道表已生效,但该频道连不进去): %v", port, err))
|
||||
continue
|
||||
}
|
||||
result.GamePortsOpened = append(result.GamePortsOpened, port)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
result.OnlineCharacters = len(s.gameSessions)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.logInfo("dnfbridge channels hot-reloaded",
|
||||
"path", result.Path,
|
||||
"total_channels", result.TotalChannels,
|
||||
"added_channels", result.AddedChannels,
|
||||
"removed_channels", result.RemovedChannels,
|
||||
"game_ports_opened", result.GamePortsOpened,
|
||||
"game_ports_closed", result.GamePortsClosed,
|
||||
"online_characters", result.OnlineCharacters,
|
||||
"warnings", len(result.Warnings))
|
||||
for _, warning := range result.Warnings {
|
||||
s.logWarn("dnfbridge channel hot-reload warning", "warning", warning)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// diffChannelIDs 比较新旧目录的频道号集合,返回新增与被移除的频道号(升序)。
|
||||
func diffChannelIDs(oldCatalog, newCatalog *channelcatalog.Catalog) (added, removed []int) {
|
||||
oldIDs := make(map[int]struct{})
|
||||
if oldCatalog != nil {
|
||||
for _, channel := range oldCatalog.Channels() {
|
||||
oldIDs[channel.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
newIDs := make(map[int]struct{})
|
||||
if newCatalog != nil {
|
||||
for _, channel := range newCatalog.Channels() {
|
||||
newIDs[channel.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for id := range newIDs {
|
||||
if _, existed := oldIDs[id]; !existed {
|
||||
added = append(added, id)
|
||||
}
|
||||
}
|
||||
for id := range oldIDs {
|
||||
if _, still := newIDs[id]; !still {
|
||||
removed = append(removed, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(added)
|
||||
sort.Ints(removed)
|
||||
return added, removed
|
||||
}
|
||||
|
||||
func containsIntValue(values []int, target int) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user