Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

213 lines
8.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}
}