Files
90CNServer/server/internal/services/dnfbridge/game_account_single_session_test.go
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

264 lines
9.7 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 (
"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")
}
}