DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,301 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user