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