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