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,212 @@
// channel_follow.go 实现「频道跟随」(class-1 opcode 804 = REQUEST_USER_CHANNEL)。
//
// 语义与格式全部来自客户端反汇编 + 活进程验证, 证据见
// WorkBuddy/2026-09-10-15-53-33/channel_follow_re_notes.md:
//
// 上行 legacy cmd=1 type=804, body = u8 region + dstr 目标名
// 下行必须是 class-1 同号 804(不是 class-0 的同号包, 那是另一个功能),
// body = u8 mode + u8 flag + dstr 目标名 + u8 serverId + u8 channelId
//
// 客户端侧链路(真机验证: 目标在 171 频道时跟随成功):
//
// 1. class-1 处理器 sub_3250DC0: mode != 0 时读包体, 经构造函数 0x324F920 落到
// 「跟随状态对象」([[0x51B2764]+0x60]):
// +0x54 = flag, +0x58 = 名字, +0x78 = serverId, +0x7C = channelId。
// 2. 换频道时客户端先用自己的频道表筛大区(0x1898AC0 比对 [频道对象+4]),
// 再按频道号查目标(0x324E3F0 比对 [频道对象+0x70])。
// 3. 目标不在线时回状态包(mode == 0 + u8 state), 客户端把 100 映射成
// 「缺少目标角色。」(字符串 0x8226)。
package dnfbridge
import (
"context"
"strings"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
const (
// currentChannelFollowDataMode 是响应首字节(客户端分发给处理器的 a2 参数)。
// 非 0 才会走「读包体填跟随状态」的分支。
currentChannelFollowDataMode = 1
// currentChannelFollowFlag 是响应第 2 字节(客户端落到 +0x54)。
// 换频道链路不消费它, 作为占位/结果位固定为 1。
currentChannelFollowFlag = 1
// currentChannelFollowStateMode 是「只设置跟随状态」的分支(客户端 a2 == 0)。
currentChannelFollowStateMode = 0
// currentChannelFollowMissingTargetState 是目标不可用时的跟随状态。
// 客户端状态门把 100 映射成本地化串 0x8226「缺少目标角色。」。
currentChannelFollowMissingTargetState = 100
)
// currentChannelFollowRequest 是客户端「频道跟随」上行包。
type currentChannelFollowRequest struct {
region byte
name string
}
// decodeCurrentChannelFollowRequest 解析 u8 region + dstr 目标名。
func decodeCurrentChannelFollowRequest(body []byte) (currentChannelFollowRequest, bool) {
if len(body) < 1 {
return currentChannelFollowRequest{}, false
}
raw, _, ok := readCurrentRawDstr(body, 1)
if !ok {
return currentChannelFollowRequest{}, false
}
name, err := decodeCharacterName(raw)
if err != nil || strings.TrimSpace(name) == "" {
return currentChannelFollowRequest{}, false
}
return currentChannelFollowRequest{region: body[0], name: name}, true
}
// handleCurrentUserChannelRequest 是 legacy/handleGameCommand 的 804 分支。
func (s *Service) handleCurrentUserChannelRequest(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketRequestUserChannel)
request, ok := decodeCurrentChannelFollowRequest(body)
if !ok {
s.logGameEvent(session, "game-channel-follow-request-rejected",
"char_id", session.selectedCharacterID,
"body_len", len(body),
"reason", "current_client_u8_region_plus_dstr_name_shape_required")
return nil
}
target, found := s.currentOnlinePlayerByName(request.name)
if !found {
// 目标不在线/查不到: 只回状态包, 客户端自己弹「缺少目标角色。」。
s.logPacketEvent("game-channel-follow-target-missing",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source_char_id", session.selectedCharacterID,
"target_name", request.name,
"request_region", request.region,
"state", currentChannelFollowMissingTargetState,
"msg_id", opcode,
"classification", dnfproto.DefaultChannelClassification)
return s.sendGameUpperRawClass(session, opcode,
buildCurrentChannelFollowStateBody(currentChannelFollowMissingTargetState),
dnfproto.DefaultChannelClassification)
}
serverID := s.currentChannelFollowServerID(target)
s.logPacketEvent("game-channel-follow-response-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source_char_id", session.selectedCharacterID,
"target_char_id", target.CharacterID,
"target_name", target.Name,
"target_channel_id", target.ChannelID,
"target_town_id", target.TownID,
"request_region", request.region,
"response_flag", currentChannelFollowFlag,
"response_server_id", serverID,
"response_channel", byte(target.ChannelID),
"response_mode", currentChannelFollowDataMode,
"msg_id", opcode,
"classification", dnfproto.DefaultChannelClassification)
return s.sendGameUpperRawClass(session, opcode,
buildCurrentChannelFollowDataBody(currentChannelFollowDataMode,
currentChannelFollowFlag, target.Name, serverID, byte(target.ChannelID)),
dnfproto.DefaultChannelClassification)
}
// currentChannelFollowServerID 返回目标频道所属的大区(服务器)ID。
//
// 取自 channel_info.etc —— 客户端频道表里每个频道对象的大区字段就是它;
// 不能用请求里那个 region 字节代替, 两者在本服并不等价。
func (s *Service) currentChannelFollowServerID(target onlinePlayerInfo) byte {
// 必须走带锁的 currentCatalog():频道热重载会在 s.mu 下整体替换 s.catalog,
// 直接读字段属于数据竞争(旧代码只在启动时写一次,所以一直没暴露)。
if s != nil && target.ChannelID != 0 {
if catalog := s.currentCatalog(); catalog != nil {
if channel, ok := catalog.Channel(int(target.ChannelID)); ok && channel.ServerID > 0 {
return byte(channel.ServerID)
}
}
}
if s != nil {
return byte(s.channelServerID())
}
return 1
}
// currentOnlinePlayerByName 按角色名查在线玩家。
//
// 先用在线玩家表(覆盖城镇/场景内已登记的玩家), 命中即可拿到频道号;
// 未命中再退回账号仓储: 角色名 -> 角色 ID -> 在线会话 -> 会话频道。
// 仓储回退会为每个在线会话查一次库, 因此放在第二步。
func (s *Service) currentOnlinePlayerByName(name string) (onlinePlayerInfo, bool) {
if s == nil || name == "" {
return onlinePlayerInfo{}, false
}
sessions := s.currentOnlineChatSessions()
if s.onlinePlayers != nil {
for _, candidate := range sessions {
if candidate == nil || candidate.selectedCharacterID == 0 {
continue
}
info, ok := s.onlinePlayers.PlayerForCharacter(candidate.selectedCharacterID)
if ok && info.Name == name {
return info, true
}
}
}
for _, candidate := range sessions {
if candidate == nil || candidate.selectedCharacterID == 0 {
continue
}
sessionName, ok := s.currentSessionCharacterName(candidate)
if !ok || sessionName != name {
continue
}
return onlinePlayerInfo{
CharacterID: candidate.selectedCharacterID,
AccountID: candidate.accountID,
Name: sessionName,
ChannelID: uint16(candidate.channel.ID),
}, true
}
return onlinePlayerInfo{}, false
}
// currentSessionCharacterName 用账号仓储把在线会话解析成角色名。
func (s *Service) currentSessionCharacterName(session *gameSession) (string, bool) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return "", false
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
_, name, _, found := s.selectedCharacterForEnter(ctx, session)
if !found || name == "" {
return "", false
}
return name, true
}
// buildCurrentChannelFollowDataBody 构造 class-1 op804 的「数据」响应体。
//
// 字段顺序由客户端处理器 sub_3250DC0 的读取顺序 + 构造函数 0x324F920 的落点决定,
// 真机验证(目标在 171 频道时跟随成功):
//
// u8 mode = a2, 非 0 才会走读包体填状态对象的分支
// u8 flag = 落在 +0x54, 换频道链路不消费
// dstr name = 目标角色名
// u8 server = 落在 +0x78, 用于筛客户端频道表(比对频道对象的大区字段)
// u8 channel = 落在 +0x7C, 用于在筛出的频道里查目标频道(比对频道号字段)
func buildCurrentChannelFollowDataBody(mode, flag byte, name string, serverID, channel byte) []byte {
var writer packetWriter
writer.writeByte(mode)
writer.writeByte(flag)
writeClientNameDstr(&writer, name)
writer.writeByte(serverID)
writer.writeByte(channel)
return writer.bytes()
}
// buildCurrentChannelFollowStateBody 构造「只设置跟随状态」的响应体(mode == 0)。
func buildCurrentChannelFollowStateBody(state byte) []byte {
return []byte{currentChannelFollowStateMode, state}
}