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,263 @@
package dnfbridge
import (
"encoding/binary"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
// TestCurrentDuplicateLoginWouldDisplaceBoundaries 钉住"另一个客户端"判定的每个边界。
// 放过是安全的错,误踢会把玩家自己换频道踢下线,所以边界都必须明确。
func TestCurrentDuplicateLoginWouldDisplaceBoundaries(t *testing.T) {
service := &Service{}
cases := []struct {
name string
previous *gameSession
session *gameSession
want bool
}{
{
name: "本地 同账号 两个进程 = 第二个客户端",
previous: &gameSession{accountID: "dnf:1", clientPID: 100},
session: &gameSession{accountID: "dnf:1", clientPID: 200},
want: true,
},
{
name: "本地 同账号 同一个进程 = 换频道/返回选角",
previous: &gameSession{accountID: "dnf:1", clientPID: 100},
session: &gameSession{accountID: "dnf:1", clientPID: 100},
want: false,
},
{
name: "远程 同账号 令牌摘要不同 = 另一台机器",
previous: &gameSession{accountID: "dnf:1", loginIdentity: "aaaa"},
session: &gameSession{accountID: "dnf:1", loginIdentity: "bbbb"},
want: true,
},
{
name: "远程 同账号 同一个令牌摘要 = 同一次启动的多条连接",
previous: &gameSession{accountID: "dnf:1", loginIdentity: "aaaa"},
session: &gameSession{accountID: "dnf:1", loginIdentity: "aaaa"},
want: false,
},
{
name: "远程 同账号 但身份未知 = 放过(宁可漏踢)",
previous: &gameSession{accountID: "dnf:1"},
session: &gameSession{accountID: "dnf:1"},
want: false,
},
{
name: "不同账号 永不踢",
previous: &gameSession{accountID: "dnf:1", clientPID: 100},
session: &gameSession{accountID: "dnf:2", clientPID: 200},
want: false,
},
{
name: "账号未知 放过",
previous: &gameSession{clientPID: 100},
session: &gameSession{clientPID: 200},
want: false,
},
{
name: "只有一侧有 PID = 放过",
previous: &gameSession{accountID: "dnf:1", clientPID: 100},
session: &gameSession{accountID: "dnf:1"},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := service.currentDuplicateLoginWouldDisplace(tc.previous, tc.session); got != tc.want {
t.Fatalf("wouldDisplace = %v, want %v", got, tc.want)
}
})
}
// 同一条会话永远不踢自己。
same := &gameSession{accountID: "dnf:1", clientPID: 100}
if service.currentDuplicateLoginWouldDisplace(same, same) {
t.Fatal("a session must never displace itself")
}
}
// TestCurrentDuplicateLoginNoticeIsClass1Opcode1Reason18 钉住线上字节。
//
// 客户端只认 class=1 / opcode=1 / body = 00 12:a2=0 走通知分支,a3=0x12 是唯一能到
// reason 7 的取值,而 reason 7 正是 GetString(157)「此ID已在游戏中」。
// 包体必须是明文 —— 客户端只对 class==0 && opcode==1 解码,套上 codec 这个包就废了。
func TestCurrentDuplicateLoginNoticeIsClass1Opcode1Reason18(t *testing.T) {
service := &Service{}
conn := &bufferConn{}
session := &gameSession{conn: conn, connID: "game-000001", accountID: "dnf:1"}
if err := service.sendCurrentDuplicateLoginNotice(session); err != nil {
t.Fatalf("send notice: %v", err)
}
packet := conn.write.Bytes()
if len(packet) < 3 {
t.Fatalf("packet too short: %d bytes", len(packet))
}
if got, want := packet[0], byte(dnfproto.DefaultChannelClassification); got != want {
t.Fatalf("classification = %d, want %d (class-1)", got, want)
}
if got, want := binary.LittleEndian.Uint16(packet[1:3]), uint16(dnfenum.CmdPacketLogin); got != want {
t.Fatalf("opcode = %d, want %d (ENUM_CMDPACKET_LOGIN)", got, want)
}
total := int(binary.LittleEndian.Uint32(packet[3:7]))
if total != len(packet) {
t.Fatalf("declared length %d != actual %d", total, len(packet))
}
headerSize := len(packet) - 2
if headerSize != dnfproto.ChannelHeaderSize && headerSize != dnfproto.GameServerUpperHeaderSize16 {
t.Fatalf("unexpected header size %d", headerSize)
}
body := packet[headerSize:]
if body[0] != 0x00 {
t.Fatalf("a2 = 0x%02X, want 0x00 (notice branch)", body[0])
}
if body[1] != 0x12 {
t.Fatalf("a3 = 0x%02X, want 0x12 (reason 7 -> text 157)", body[1])
}
}
// TestEnforceCurrentAccountSingleSessionCountsSelectCharacterAsOnline 钉住"进了频道就算在线"。
//
// 停在角色选择界面的会话没有绑定任何角色(selectedCharacterID==0),但它已经进了频道、
// 已经占着这个账号。第二个客户端进频道时必须能把它当成目标顶掉,而不是等双方都进城镇。
func TestEnforceCurrentAccountSingleSessionCountsSelectCharacterAsOnline(t *testing.T) {
atSelectScreen := &gameSession{
conn: &bufferConn{}, connID: "game-000001", accountID: "dnf:1",
selectedCharacterID: 0, clientPID: 100, // 停在选角,没选角色
}
entering := &gameSession{
conn: &bufferConn{}, connID: "game-000002", accountID: "dnf:1",
selectedCharacterID: 0, clientPID: 200,
}
service := &Service{onlineSeats: map[*gameSession]struct{}{atSelectScreen: {}}}
kicked := service.enforceCurrentAccountSingleSession(entering, "dnf:1")
if !atSelectScreen.duplicateLoginKicked {
t.Fatal("a session sitting at character select must count as online")
}
if !kicked {
t.Fatal("the entering session must be told to stop before it enters the channel")
}
}
// TestEnforceCurrentAccountSingleSessionKicksBothSides 钉住"两边都断"。
// 官方老服务端对旧会话与新会话发的是同一个 0x12,并把新登录中止。
func TestEnforceCurrentAccountSingleSessionKicksBothSides(t *testing.T) {
old := &gameSession{
conn: &bufferConn{}, connID: "game-000001", accountID: "dnf:1",
selectedCharacterID: 1, clientPID: 100,
}
cur := &gameSession{
conn: &bufferConn{}, connID: "game-000002", accountID: "dnf:1",
selectedCharacterID: 2, clientPID: 200,
}
service := &Service{onlineSeats: map[*gameSession]struct{}{old: {}}}
service.enforceCurrentAccountSingleSession(cur, "dnf:1")
if !old.duplicateLoginKicked {
t.Fatal("the older client was not kicked")
}
if !cur.duplicateLoginKicked {
t.Fatal("the new session must also be rejected, like the official server")
}
}
// TestEnforceCurrentAccountSingleSessionIgnoresSameProcess 是防回归的核心:
// 换频道/返回选角/启动时那上百条连接都是同一个进程,绝不能被判成第二个客户端。
func TestEnforceCurrentAccountSingleSessionIgnoresSameProcess(t *testing.T) {
old := &gameSession{
conn: &bufferConn{}, connID: "game-000001", accountID: "dnf:1",
selectedCharacterID: 1, clientPID: 100,
}
cur := &gameSession{
conn: &bufferConn{}, connID: "game-000002", accountID: "dnf:1",
selectedCharacterID: 2, clientPID: 100,
}
service := &Service{onlineSeats: map[*gameSession]struct{}{old: {}}}
service.enforceCurrentAccountSingleSession(cur, "dnf:1")
if old.duplicateLoginKicked || cur.duplicateLoginKicked {
t.Fatal("a channel switch must never be treated as a duplicate login")
}
}
// TestEnforceCurrentAccountSingleSessionUndecidableDoesNothing 钉住"判不出来就放过":
// 身份未知时宁可漏踢,日志里留一行 undecidable 便于现场定位。
func TestEnforceCurrentAccountSingleSessionUndecidableDoesNothing(t *testing.T) {
old := &gameSession{
conn: &bufferConn{}, connID: "game-000001", accountID: "dnf:1",
selectedCharacterID: 1,
}
cur := &gameSession{
conn: &bufferConn{}, connID: "game-000002", accountID: "dnf:1",
selectedCharacterID: 2,
}
service := &Service{onlineSeats: map[*gameSession]struct{}{old: {}}}
service.enforceCurrentAccountSingleSession(cur, "dnf:1")
if old.duplicateLoginKicked || cur.duplicateLoginKicked {
t.Fatal("an unknown identity must never be kicked")
}
}
// TestEnforceCurrentAccountSingleSessionRemoteIdentity 钉住远程判据:
// 两侧 clientPID 都是 0(公网拿不到对端 PID),靠每次登录唯一的身份串区分。
func TestEnforceCurrentAccountSingleSessionRemoteIdentity(t *testing.T) {
old := &gameSession{
conn: &bufferConn{}, connID: "game-000001", accountID: "dnf:1",
selectedCharacterID: 1, loginIdentity: "token-a",
}
cur := &gameSession{
conn: &bufferConn{}, connID: "game-000002", accountID: "dnf:1",
selectedCharacterID: 2, loginIdentity: "token-b",
}
service := &Service{onlineSeats: map[*gameSession]struct{}{old: {}}}
service.enforceCurrentAccountSingleSession(cur, "dnf:1")
if !old.duplicateLoginKicked || !cur.duplicateLoginKicked {
t.Fatal("a different per-login identity on the same account must be kicked")
}
}
// TestDisplaceReplacedAccountSessionHandlesSameCharacter 钉住"两边选同一个角色"。
// 这时旧会话已不再是任何角色的持有者,只有这条接管路径能看到它。
func TestDisplaceReplacedAccountSessionHandlesSameCharacter(t *testing.T) {
previous := &gameSession{
conn: &bufferConn{}, connID: "game-000001", accountID: "dnf:1",
selectedCharacterID: 1, clientPID: 100,
}
session := &gameSession{
conn: &bufferConn{}, connID: "game-000002", accountID: "dnf:1",
selectedCharacterID: 1, clientPID: 200,
}
service := &Service{}
service.displaceReplacedAccountSession(previous, session)
if !previous.duplicateLoginKicked {
t.Fatal("the displaced client was not kicked")
}
// 同一角色被本客户端换连接接管时(PID 相同)不能踢。
previous.duplicateLoginKicked = false
sameProcess := &gameSession{
conn: &bufferConn{}, connID: "game-000003", accountID: "dnf:1",
selectedCharacterID: 1, clientPID: 100,
}
service.displaceReplacedAccountSession(previous, sameProcess)
if previous.duplicateLoginKicked {
t.Fatal("the same process re-taking its own character must not be kicked")
}
}