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

302 lines
14 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"testing"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
const currentSocialTestFriendName = "friend5"
func TestDecodeCurrentPeerChatRequestMatchesLiveMenuShape(t *testing.T) {
var writer packetWriter
writer.writeByte(0x24)
writer.writeUint16(5)
writer.writeUint32(0)
writer.writeRawDstr([]byte("hello "))
writer.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
request, err := decodeCurrentPeerChatRequest(writer.bytes())
if err != nil {
t.Fatal(err)
}
if request.MessageType != 0x24 || request.TargetID != 5 || request.Unknown != 0 ||
!bytes.Equal(request.Message, []byte("hello ")) ||
!bytes.Equal(request.TargetName, rosterNameBytes(currentSocialTestFriendName)) {
t.Fatalf("decoded request = %+v", request)
}
}
func TestHandleCurrentFriendAddAcknowledgesAndProjectsPersistentFriendList(t *testing.T) {
service, source, _, sourceConn, _ := newCurrentSocialPeerTest(t)
var request packetWriter
request.writeUint16(5)
request.writeByte(0)
request.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
handled, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), currentFriendAddOpcode, request.bytes())
if err != nil {
t.Fatal(err)
}
if !handled {
t.Fatal("friend add was not handled")
}
ack, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != currentFriendAddOpcode ||
!bytes.Equal(ack.Body, append([]byte{1}, buildCurrentFriendSuccessBody()...)) {
t.Fatalf("friend ack = class %d msg %d body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
first, rest := splitGameServerUpperPacket(t, rest)
second, rest := splitGameServerUpperPacket(t, rest)
if first.Header.Classification != 0 || first.Header.MsgID != currentFriendListOpcode ||
second.Header.Classification != 0 || second.Header.MsgID != currentFriendListOpcode ||
!bytes.Equal(first.Body, second.Body) || len(rest) != 0 {
t.Fatalf("friend list first=%+v second=%+v trailing=%d", first.Header, second.Header, len(rest))
}
if got := binary.LittleEndian.Uint32(first.Body[4:8]); got != 1 {
t.Fatalf("friend list count=%d body=%x", got, first.Body)
}
assertCurrentUnitedFriendSingleRowBody(t, first.Body, defaultCurrentRegion, 42, false)
}
func TestHandleCurrentFriendAddMalformedRequestReleasesClientPendingGate(t *testing.T) {
service, source, _, sourceConn, _ := newCurrentSocialPeerTest(t)
handled, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), currentFriendAddOpcode, []byte{1, 0})
if err != nil {
t.Fatal(err)
}
if !handled {
t.Fatal("malformed friend add was not handled")
}
ack, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != currentFriendAddOpcode ||
!bytes.Equal(ack.Body, []byte{0, currentFriendFailureMissing}) || len(rest) != 0 {
t.Fatalf("malformed friend add ack = class %d msg %d body=% X trailing=%d", ack.Header.Classification, ack.Header.MsgID, ack.Body, len(rest))
}
}
func TestHandleCurrentFriendDeleteAcknowledgesDeletesVisibleRowAndPersists(t *testing.T) {
service, source, _, sourceConn, _ := newCurrentSocialPeerTest(t)
var add packetWriter
add.writeUint16(5)
add.writeByte(0)
add.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
if err := service.handleCurrentFriendAdd(source, currentFriendAddOpcode, add.bytes()); err != nil {
t.Fatal(err)
}
sourceConn.write.Reset()
var remove packetWriter
remove.writeByte(0)
remove.writeUint32(5)
remove.writeBytes(rosterNameBytes(currentSocialTestFriendName))
handled, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), currentFriendDeleteOpcode, remove.bytes())
if err != nil {
t.Fatal(err)
}
if !handled {
t.Fatal("friend delete was not handled")
}
ack, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if ack.Header.MsgID != currentFriendDeleteOpcode ||
!bytes.Equal(ack.Body, append([]byte{1}, buildCurrentFriendSuccessBody()...)) {
t.Fatalf("delete ack = msg %d body=% X", ack.Header.MsgID, ack.Body)
}
deleted, rest := splitGameServerUpperPacket(t, rest)
if deleted.Header.MsgID != currentFriendListOpcode ||
!bytes.Equal(deleted.Body, buildCurrentUnitedFriendDeleteBody(defaultCurrentRegion, currentSocialTestFriendName)) {
t.Fatalf("delete row = msg %d body=% X", deleted.Header.MsgID, deleted.Body)
}
first, rest := splitGameServerUpperPacket(t, rest)
second, rest := splitGameServerUpperPacket(t, rest)
if first.Header.MsgID != currentFriendListOpcode || second.Header.MsgID != currentFriendListOpcode ||
!bytes.Equal(first.Body, second.Body) || len(first.Body) != 8 ||
binary.LittleEndian.Uint32(first.Body[4:8]) != 0 || len(rest) != 0 {
t.Fatalf("absolute empty refresh first=% X second=% X trailing=%d", first.Body, second.Body, len(rest))
}
}
func assertCurrentUnitedFriendSingleRowBody(t *testing.T, body []byte, wantRegion byte, wantChannel uint16, wantBusy bool) {
t.Helper()
if len(body) < 20 || binary.LittleEndian.Uint32(body[:4]) != 0 || binary.LittleEndian.Uint32(body[4:8]) != 1 {
t.Fatalf("friend list header/body=% X", body)
}
if body[8] != wantRegion || binary.LittleEndian.Uint16(body[9:11]) != wantChannel {
t.Fatalf("friend row region/channel body=% X", body)
}
name, next, ok := readCurrentRawDstr(body, 11)
if !ok || !bytes.Equal(name, rosterNameBytes(currentSocialTestFriendName)) {
t.Fatalf("friend row name=% X ok=%t body=% X", name, ok, body)
}
// Current NoPack sub_26E8680 consumes level,u32 job,grow,Y,Z and one
// final boolean. Pinning the exact tail prevents a multi-row shift back to
// the older 86JP/A21 layout that lacks this client's last byte.
if len(body)-next != 12 || body[next] != 90 || binary.LittleEndian.Uint32(body[next+1:next+5]) != 1 ||
body[next+5] != 0 || body[next+6] != boolByte(wantBusy) ||
binary.LittleEndian.Uint32(body[next+7:next+11]) != 0 || body[next+11] != 0 {
t.Fatalf("current friend row tail=% X", body[next:])
}
}
func TestCurrentUnitedFriendListUsesOfflineOnlineBusyStates(t *testing.T) {
service, source, target, sourceConn, _ := newCurrentSocialPeerTest(t)
var add packetWriter
add.writeUint16(5)
add.writeByte(0)
add.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
if err := service.handleCurrentFriendAdd(source, currentFriendAddOpcode, add.bytes()); err != nil {
t.Fatal(err)
}
assertState := func(wantChannel uint16, wantBusy bool) {
t.Helper()
sourceConn.write.Reset()
if err := service.sendCurrentUnitedFriendList(source, "source1", "friend_presence_state_test"); err != nil {
t.Fatal(err)
}
first, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
_, rest = splitGameServerUpperPacket(t, rest)
if len(rest) != 0 {
t.Fatalf("friend state refresh trailing=%d", len(rest))
}
assertCurrentUnitedFriendSingleRowBody(t, first.Body, defaultCurrentRegion, wantChannel, wantBusy)
}
assertState(42, false)
service.onlinePlayers.LeaveAreaSession(5, target)
assertState(42, true)
service.mu.Lock()
delete(service.gameSessions, 5)
service.mu.Unlock()
assertState(0, false)
}
func TestCurrentUnitedFriendListUsesConfiguredRegionID(t *testing.T) {
service, source, _, sourceConn, _ := newCurrentSocialPeerTest(t)
service.options.regionID = 77
var add packetWriter
add.writeUint16(5)
add.writeByte(0)
add.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
if err := service.handleCurrentFriendAdd(source, currentFriendAddOpcode, add.bytes()); err != nil {
t.Fatal(err)
}
_, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
packet, _ := splitGameServerUpperPacket(t, rest)
assertCurrentUnitedFriendSingleRowBody(t, packet.Body, 77, 42, false)
if body := buildCurrentUnitedFriendDeleteBody(service.currentRegionID(), currentSocialTestFriendName); len(body) < 9 || body[8] != 77 {
t.Fatalf("configured region delete body=% X", body)
}
}
func TestHandleCurrentPeerChatAcknowledgesAndForwardsCurrentNotifications(t *testing.T) {
service, source, _, sourceConn, targetConn := newCurrentSocialPeerTest(t)
var request packetWriter
request.writeByte(1)
request.writeUint16(5)
request.writeUint32(0)
request.writeRawDstr([]byte("hello"))
request.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
request.writeByte(0)
handled, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), currentPeerChatStateOpcode, request.bytes())
if err != nil {
t.Fatal(err)
}
if !handled {
t.Fatal("peer chat was not handled")
}
ack, sourceRest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != currentPeerChatStateOpcode ||
!bytes.Equal(ack.Body, []byte{1}) {
t.Fatalf("chat ack = class %d msg %d body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
sourceState, sourceRest := splitGameServerUpperPacket(t, sourceRest)
if sourceState.Header.Classification != 0 || sourceState.Header.MsgID != currentPeerChatStateOpcode ||
!bytes.Equal(sourceState.Body, buildCurrentPeerChatStateBody(5, 1)) {
t.Fatalf("source chat state = class %d msg %d body=% X trailing=%d", sourceState.Header.Classification, sourceState.Header.MsgID, sourceState.Body, len(sourceRest))
}
sourceNotice, sourceRest := splitGameServerUpperPacket(t, sourceRest)
// The sender is named its peer and told the outgoing twin of the mode.
wantSourceNotice := buildCurrentGuildChatNoticeBody(currentTownChatWhisperSentMode, currentChatServerGroup, byte(source.channel.ID), 0, rosterNameBytes(currentSocialTestFriendName), []byte("hello"))
if sourceNotice.Header.Classification != 0 || sourceNotice.Header.MsgID != currentPeerChatNotifyOpcode ||
!bytes.Equal(sourceNotice.Body, wantSourceNotice) || len(sourceRest) != 0 {
t.Fatalf("source chat notice = class %d msg %d body=% X trailing=%d", sourceNotice.Header.Classification, sourceNotice.Header.MsgID, sourceNotice.Body, len(sourceRest))
}
targetState, targetRest := splitGameServerUpperPacket(t, targetConn.write.Bytes())
if targetState.Header.Classification != 0 || targetState.Header.MsgID != currentPeerChatStateOpcode ||
!bytes.Equal(targetState.Body, buildCurrentPeerChatStateBody(1, 1)) {
t.Fatalf("target chat state = class %d msg %d body=% X", targetState.Header.Classification, targetState.Header.MsgID, targetState.Body)
}
targetNotice, targetRest := splitGameServerUpperPacket(t, targetRest)
wantNotice := buildCurrentGuildChatNoticeBody(1, currentChatServerGroup, 42, 0, rosterNameBytes("source1"), []byte("hello"))
if targetNotice.Header.Classification != 0 || targetNotice.Header.MsgID != currentPeerChatNotifyOpcode ||
!bytes.Equal(targetNotice.Body, wantNotice) || len(targetRest) != 0 {
t.Fatalf("target chat notice = class %d msg %d body=% X trailing=%d", targetNotice.Header.Classification, targetNotice.Header.MsgID, targetNotice.Body, len(targetRest))
}
}
func TestHandleCurrentBlacklistMutationEchoesNameDstrInSuccess(t *testing.T) {
conn := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: conn, selectedCharacterID: 1}
var request packetWriter
request.writeRawDstr(rosterNameBytes(currentSocialTestFriendName))
handled, err := service.handleAlignedGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRegisiterToBlacklist), request.bytes())
if err != nil {
t.Fatal(err)
}
if !handled {
t.Fatal("blacklist mutation was not handled")
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
want := append([]byte{1}, request.bytes()...)
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketRegisiterToBlacklist) ||
!bytes.Equal(packet.Body, want) || len(rest) != 0 {
t.Fatalf("blacklist ack = class %d msg %d body=% X trailing=%d", packet.Header.Classification, packet.Header.MsgID, packet.Body, len(rest))
}
}
func newCurrentSocialPeerTest(t *testing.T) (*Service, *gameSession, *gameSession, *bufferConn, *bufferConn) {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
for _, character := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Name: "source1", Job: "0", Level: 90},
{CharacterID: "5", AccountID: "dnf:2", Name: currentSocialTestFriendName, Job: "1", Level: 90},
} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
sourceConn := &bufferConn{}
targetConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 42, Name: "ch.42"}
source := &gameSession{conn: sourceConn, channel: channel, accountID: "dnf:1", selectedCharacterID: 1}
target := &gameSession{conn: targetConn, channel: channel, accountID: "dnf:2", selectedCharacterID: 5}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
service.bindGameSessionCharacter(source, 1)
service.bindGameSessionCharacter(target, 5)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 1, Name: "source1", TownID: 1, AreaID: 1, Session: source})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 5, Name: currentSocialTestFriendName, TownID: 1, AreaID: 1, Session: target})
return service, source, target, sourceConn, targetConn
}