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

261 lines
12 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.
package dnfbridge
import (
"crypto/sha256"
"encoding/hex"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
// currentLoginIdentityDigest 把身份串(登录票据 / 机器码)压成可安全落日志的摘要。
//
// **绝不记录原文** —— 票据是凭证,机器码会被封禁流程当判据。这里只保留 16 字节,
// 足够现场区分"两条连接是不是同一个客户端/同一台机器",又不可逆推原文。
//
// 之所以需要它:外网部署上 clientPID 恒为 0、IP 可能相同、机器码也可能相同,
// 唯一可能按"客户端实例"区分的就是每次启动的那张登录票据。先用摘要确认它是不是
// 一次启动一张(该客户端的上百条连接共用),再决定能不能拿来当顶号判据。
func currentLoginIdentityDigest(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:16])
}
// currentDuplicateLoginNoticeReason 是 class-1 / opcode 1 上的挤下线通知码。
//
// 它是客户端 `CNGameSocket::ShowDisconnectPopup` 的 socket 关闭原因选择子:
//
// class-1/op1 body = 00 12
// → CNGameCore::NetworkProc 0x2261E30 预读 a2=0、a3=0x12
// → handler 0x01CFF660 → a3 分派表 → 0x01D0050A `push 7`
// → set_CloseReason(7) [socket+0x2BD064] = 7
// → reason 7 → GetString(157) = 「此ID已在游戏中」 → 0x2FDD1A0(0x298, 文案, 2) 弹窗
//
// a3=18 (0x12) 是唯一能到 reason 7 的取值(0xEA 个 a3 全量枚举确认)。
//
// 这个数字不是我们发明的:官方老服务端 `df_game_r` 的
// `Inter_LoginInfo::dispatch_sig`(0x084C5BAA)在顶号时对**新旧两侧**都调用
// `SendCmdErrorPacket(ENUM_CMDPACKET_LOGIN, 0x12)`,再把旧会话 `DisConnSig` 掉、
// 把新登录中止。十多年前的 0x12 与今天的 a3=18 是同一个数。
const currentDuplicateLoginNoticeReason = 0x12
// currentDuplicateLoginCloseGrace 是"发完通知再关套接字"之间的等待。
//
// 关得太早,那 2 个字节还没冲出去,客户端只会看到一条普通的「网络连接中断」(150),
// 而不是 157;关得太晚,被顶的玩家还在操作。1 秒对局域网与公网都够。
const currentDuplicateLoginCloseGrace = time.Second
// enforceCurrentAccountSingleSession 让一个账号同时只有一个客户端在玩。
//
// **挂点是角色绑定之后,不是连接建立/登录鉴权之后。** 后者看着更早更合适,实际不能用:
//
// - 客户端一启动就同时连上**所有**频道(实测一个客户端 250ms 内连了 6/12/13/14/15/
// 19/26/27/43/45/55/56/58/59/60/96/98/99/112/113/114/116/141/154/182… 上百条),
// 每条连接都会绑定同一个账号,按账号踢会让客户端自己踢自己;
// - 同一条连接还会反复认证(实测单条最多 19 次),切频道和回角色选择都会再认一次。
//
// 挂在绑定之后就没有这些问题:s.gameSessions 是**按角色**索引的,只装"已经选好角色、
// 正在玩"的那一条会话。换频道时新会话接管的是同一个角色,bindGameSessionCharacter
// 已经把注册持有者换成了新会话,旧会话不再是任何角色的持有者,查不到也就不会被误踢。
// 能被查到的只剩"另一个客户端正拿着这个账号的另一个角色在玩",那才是要踢的。
//
// **只对账号确实已知的会话生效。** accountID 为空表示这条连接既没通过带内鉴权、也没有
// PID 注册,服务端只能靠 instance.json 的实例账号兜底 —— 而那个兜底值本身就是一个真实
// 账号,拿它当判据会让一个未鉴权的客户端把真正的持有者踢下线。账号不确定时什么都不做。
// 返回值表示**本会话**是否已被判定为顶号并被安排下线:调用方据此提前结束本次处理,
// 不要再把进城数据发给一个马上就要收 157 的会话。
func (s *Service) enforceCurrentAccountSingleSession(session *gameSession, accountID string) bool {
accountID = strings.TrimSpace(accountID)
if s == nil || session == nil || accountID == "" {
return false
}
kicked := false
for _, old := range s.currentAccountInGameSessions(accountID, session) {
if !s.currentDuplicateLoginWouldDisplace(old, session) {
continue
}
kicked = true
s.displaceCurrentDuplicateLogin(old, session, accountID, "another_client_plays_the_same_account")
}
if !kicked {
return false
}
// 官方对两侧发的是同一个包:新会话同样收到 0x12,于是它的登录也到此为止。
s.closeCurrentDuplicateLoginSession(session, accountID, "new_session_rejected_by_duplicate_login")
s.mu.Lock()
thisSessionKicked := session.duplicateLoginKicked
s.mu.Unlock()
return thisSessionKicked
}
// displaceReplacedAccountSession 处理"新会话接管了旧会话正拿着的那个角色"。
//
// 这一步是必须的:同账号的两个客户端看到的是同一批角色,玩家在两边选同一个角色时,
// bindGameSessionCharacter 会把注册持有者换成新会话,旧会话于是不再是任何角色的持有
// 者,enforceCurrentAccountSingleSession 查不到它,就这样漏掉。
func (s *Service) displaceReplacedAccountSession(previous, session *gameSession) {
if !s.currentDuplicateLoginWouldDisplace(previous, session) {
return
}
accountID := strings.TrimSpace(previous.accountID)
s.displaceCurrentDuplicateLogin(previous, session, accountID, "another_client_took_over_the_same_character")
s.closeCurrentDuplicateLoginSession(session, accountID, "new_session_replaced_the_same_character")
}
// displaceCurrentDuplicateLogin 是两条路径共用的动作:记录 + 把旧会话请出去。
func (s *Service) displaceCurrentDuplicateLogin(
old, session *gameSession,
accountID, reason string,
) {
s.logPacketEvent("game-account-duplicate-login-displaced",
"conn_id", old.connID,
"account_id", accountID,
"displaced_char_id", old.selectedCharacterID,
"displaced_client_pid", old.clientPID,
"displaced_login_identity", currentDuplicateLoginIdentity(old),
"new_conn_id", session.connID,
"new_char_id", session.selectedCharacterID,
"new_client_pid", session.clientPID,
"new_login_identity", currentDuplicateLoginIdentity(session),
"reason", reason)
s.closeCurrentDuplicateLoginSession(old, accountID, reason)
}
// currentAccountInGameSessions 返回该账号下**已经占着这个账号**的会话,按需排除一个。
//
// 遍历的是 onlineSeats —— 它装的是"已绑账号、连接还活着"的会话,连接一关就释放。
// 刻意**不**用 s.gameSessions(按角色索引、只装已选好角色正在玩的会话):那样停在
// 角色选择界面的同账号客户端不算占用者,第二个客户端进频道时找不到目标,要等它自己
// 也进到城镇才被处理。而"能进角色选择界面"本身就说明它已经进了频道、已经占着这个账号。
//
// 同一客户端的上百条连接会同时落在这里,但判据是身份(currentDuplicateLoginWouldDisplace),
// 同身份互相放过,所以只有"另一个客户端"会被处理。
func (s *Service) currentAccountInGameSessions(accountID string, exclude *gameSession) []*gameSession {
accountID = strings.TrimSpace(accountID)
if s == nil || accountID == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
result := make([]*gameSession, 0, 1)
for candidate := range s.onlineSeats {
if candidate == nil || candidate == exclude || candidate.duplicateLoginKicked {
continue
}
if !strings.EqualFold(strings.TrimSpace(candidate.accountID), accountID) {
continue
}
result = append(result, candidate)
}
return result
}
// currentDuplicateLoginWouldDisplace 是"这是不是另一个客户端"的判定。
//
// 判据按部署取,两种都拿不到就**放过**——宁可漏踢,也不能把玩家自己换频道踢下线:
//
// - 本地一键端:clientPID 是套接字属主进程(tcp_owner_windows.go 反查 TCP 表拿到对端
// 行的 PID)。两边都已知且不同 = 第二个客户端;相同 = 同一个客户端换了条连接
// (换频道、返回选角、攻坚探测),放过。
//
// - 远程联网:服务端拿不到对端 Windows PID,两侧 clientPID 恒为 0。此时用 loginIdentity
// ——每次登录唯一的身份串(带内 class-1/op1 凭证的令牌摘要)。同一次启动的所有连接
// 带的是同一个身份,相同则放过;另一台机器是另一次登录、身份不同即要踢。
//
// 刻意**不用** remoteClientPID:它来自按源 IP 索引的注册表,同一个 IP 后面的两个客户端
// 会读到同一条记录(这正是串号的成因),拿它比对既漏踢又可能把同一客户端的两条连接判成
// 两个客户端而误踢。也刻意**不用** accountSourceIP:实测同一客户端可能多源 IP。
func (s *Service) currentDuplicateLoginWouldDisplace(previous, session *gameSession) bool {
if s == nil || previous == nil || session == nil || previous == session {
return false
}
accountID := strings.TrimSpace(previous.accountID)
if accountID == "" || !strings.EqualFold(accountID, strings.TrimSpace(session.accountID)) {
return false
}
if previous.clientPID != 0 && session.clientPID != 0 {
return previous.clientPID != session.clientPID
}
before := currentDuplicateLoginIdentity(previous)
after := currentDuplicateLoginIdentity(session)
if before != "" && after != "" {
return !strings.EqualFold(before, after)
}
// 两种判据都拿不到。放过,但留一行日志 —— 否则表现就是"功能没做成"却查不出原因。
s.logPacketEvent("game-account-duplicate-login-undecidable",
"conn_id", previous.connID,
"account_id", accountID,
"other_conn_id", session.connID,
"previous_client_pid", previous.clientPID,
"session_client_pid", session.clientPID,
"reason", "no_process_and_no_login_identity")
return false
}
func currentDuplicateLoginIdentity(session *gameSession) string {
if session == nil {
return ""
}
return strings.TrimSpace(session.loginIdentity)
}
// sendCurrentDuplicateLoginNotice 把挤下线通知发给一条会话。
//
// 用 sendGameUpperRawClassNoCodec 而不是 sendGameUpperRawClass:客户端只对
// `class==0 && opcode==1` 的包体做解码(CNGameCore::NetworkProc 0x02261FC0/0x02261FCC),
// class-1 的包体是原样使用的。带 codec 的版本在 gameUpperBodyCodec 不是 plaintext 时会对
// msgID==1 套 CAST5,把这个 2 字节包静默加密成客户端读不懂的东西 —— 表现就只剩掉线,
// 没有 157 弹窗。
func (s *Service) sendCurrentDuplicateLoginNotice(session *gameSession) error {
body := []byte{0x00, currentDuplicateLoginNoticeReason}
return s.sendGameUpperRawClassNoCodec(
session,
uint16(dnfenum.CmdPacketLogin),
body,
dnfproto.DefaultChannelClassification,
)
}
// closeCurrentDuplicateLoginSession 先把"请下线"发出去,再关掉这条连接。
//
// 延迟是必须的:立刻关会让那 2 个字节随 TCP 缓冲一起丢掉,客户端只会走普通的
// "网络连接中断",看不到 157。延迟期间连接仍然可用,不影响任何已完成的接管。
// 关闭走的是既有的连接关闭路径(读循环报错 → releaseOnlineSeat → unbindGameSession),
// 所以城镇同屏、队伍、私人商店、攻坚队断线保位都按正常掉线处理,不需要另写一套释放顺序。
func (s *Service) closeCurrentDuplicateLoginSession(session *gameSession, accountID, reason string) {
if s == nil || session == nil || session.conn == nil {
return
}
s.mu.Lock()
if session.duplicateLoginKicked {
s.mu.Unlock()
return
}
session.duplicateLoginKicked = true
s.mu.Unlock()
if err := s.sendCurrentDuplicateLoginNotice(session); err != nil {
s.logPacketEvent("game-account-duplicate-login-notice-failed",
"conn_id", session.connID,
"account_id", accountID,
"reason", reason,
"error", err)
}
conn := session.conn
connID := session.connID
time.AfterFunc(currentDuplicateLoginCloseGrace, func() {
s.logPacketEvent("game-account-duplicate-login-socket-closed",
"conn_id", connID,
"account_id", accountID,
"grace", currentDuplicateLoginCloseGrace.String(),
"reason", reason)
_ = conn.Close()
})
}