服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
368 lines
15 KiB
Go
368 lines
15 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"os"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
func assaultTestPeers(t *testing.T) (*Service, [2]*gameSession, *fakeCurrentDungeonDeathTimerQueue) {
|
|
t.Helper()
|
|
s := pvpRoomTestService(t)
|
|
s.onlinePlayers = newOnlinePlayerManager()
|
|
queue := newFakeCurrentDungeonDeathTimerQueue()
|
|
s.gameplayTimers = queue
|
|
var peers [2]*gameSession
|
|
for i := range peers {
|
|
id := uint16(29 + i)
|
|
peer := &gameSession{conn: &bufferConn{}, selectedCharacterID: id, accountID: "pvp-" + strconv.Itoa(int(id)),
|
|
channel: s.catalog.Channels()[0], sceneBootstrapTailSent: true,
|
|
townPositionSnapshot: currentTownPositionSnapshot{CharacterID: id, TownID: 10, AreaID: 0,
|
|
PositionX: 474, PositionY: 234, MovementCode: 5, PositionValid: true},
|
|
partyPeer: currentPartyPeerEndpointRegistration{InnerIPv4: [4]byte{127, 0, 0, 1},
|
|
OuterIPv4: [4]byte{127, 0, 0, 1}, Port: 12000 + id, MTU: 512}}
|
|
s.bindGameSessionCharacter(peer, id)
|
|
s.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: id, TownID: 10, AreaID: 0, Session: peer})
|
|
peers[i] = peer
|
|
}
|
|
return s, peers, queue
|
|
}
|
|
|
|
func assaultTestDispatch(t *testing.T, s *Service, peer *gameSession, route string, opcode uint16, body []byte) {
|
|
t.Helper()
|
|
var err error
|
|
if route == "legacy" {
|
|
err = s.handleGameCommand(peer, 1, opcode, body)
|
|
} else {
|
|
wire, buildErr := dnfproto.BuildChannelPacket(opcode, body, 0, 1)
|
|
if buildErr != nil {
|
|
t.Fatal(buildErr)
|
|
}
|
|
err = s.handleGameUpper(peer, wire)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func assaultTestTake(t *testing.T, peer *gameSession) []dnfproto.ChannelPacket {
|
|
t.Helper()
|
|
conn := peer.conn.(*bufferConn)
|
|
stream := append([]byte(nil), conn.write.Bytes()...)
|
|
conn.write.Reset()
|
|
var packets []dnfproto.ChannelPacket
|
|
for len(stream) > 0 {
|
|
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
|
|
packets = append(packets, packet)
|
|
stream = rest
|
|
}
|
|
return packets
|
|
}
|
|
|
|
func TestAssaultNativeInviteAndDecline(t *testing.T) {
|
|
for _, route := range []string{"legacy", "upper"} {
|
|
for _, mode := range []byte{4, 13} {
|
|
s, peers, _ := assaultTestPeers(t)
|
|
a, b := peers[0], peers[1]
|
|
assaultTestDispatch(t, s, a, route, 10, []byte{30, 0, mode, 0, 0, 0, 0, 0, 0, 0, 0})
|
|
invite, wait := assaultTestTake(t, b), assaultTestTake(t, a)
|
|
if len(invite) != 1 || invite[0].Header.Classification != 0 || invite[0].Header.MsgID != 443 || !bytes.Equal(invite[0].Body, []byte{29, 0, mode}) {
|
|
t.Fatalf("%s mode=%d invitation=%+v", route, mode, invite)
|
|
}
|
|
if len(wait) != 2 || wait[0].Header.MsgID != 10 || wait[1].Header.MsgID != 444 || !bytes.Equal(wait[1].Body, []byte{30, 0, mode}) {
|
|
t.Fatalf("requester wait=%+v", wait)
|
|
}
|
|
// Invalid values must not consume the real pending request.
|
|
assaultTestDispatch(t, s, b, route, 11, []byte{29, 0, mode, 2, 0, 0, 0})
|
|
if len(assaultTestTake(t, b)) != 0 || len(assaultTestTake(t, a)) != 0 {
|
|
t.Fatal("invalid answer emitted a reply")
|
|
}
|
|
assaultTestDispatch(t, s, b, route, 11, []byte{29, 0, mode, 0, 0, 0, 0})
|
|
ack, decline := assaultTestTake(t, b), assaultTestTake(t, a)
|
|
if len(ack) != 1 || !bytes.Equal(ack[0].Body, []byte{1, 29, 0, mode}) ||
|
|
len(decline) != 1 || !bytes.Equal(decline[0].Body, []byte{0, 0xeb, mode}) {
|
|
t.Fatalf("decline ack=%+v requester=%+v", ack, decline)
|
|
}
|
|
assaultTestDispatch(t, s, b, route, 11, []byte{29, 0, mode, 1, 0, 0, 0})
|
|
if len(assaultTestTake(t, b)) != 0 || a.assault != nil || b.assault != nil || a.pvpRoom != nil || hasMultiMemberRuntimeParty(a) {
|
|
t.Fatal("consumed street invite created another activity")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAssaultLoadBarrierDeathAndCleanup(t *testing.T) {
|
|
for _, route := range []string{"legacy", "upper"} {
|
|
for _, mode := range []byte{4, 13} {
|
|
s, peers, queue := assaultTestPeers(t)
|
|
match := ¤tAssault{mode: mode, duration: 180 * time.Second}
|
|
for i, peer := range peers {
|
|
match.members[i] = ¤tPVPMember{session: peer, characterID: peer.selectedCharacterID, name: "fighter" + strconv.Itoa(i), position: peer.townPositionSnapshot, stamina: byte(90 + i)}
|
|
peer.assault = match
|
|
}
|
|
wantMove := []byte{29, 0, 0, 0, 10, 0, mode, 218, 1, 234, 0, 2, 0, 1, 29, 0, 1, 2, 30, 0, 0}
|
|
if body := buildCurrentAssaultMoveBody(match); !bytes.Equal(body, wantMove) {
|
|
t.Fatalf("native op128 layout=%x", body)
|
|
}
|
|
if err := s.scheduleCurrentAssaultCloseLocked(match, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loadingTimer := queue.task(t, 0)
|
|
// Captured native op146 at 2026-09-06 03:21:47.614. This used
|
|
// to close both sessions immediately because 1 was read as failure.
|
|
connected := []byte{8, 0, 1, 1, 1, 2, 1, 3, 1, 4, 1, 5, 1, 6, 1, 7, 1}
|
|
assaultTestDispatch(t, s, peers[0], route, 146, connected)
|
|
if match.connected[0] {
|
|
t.Fatal("accepted P2P report before loading")
|
|
}
|
|
for i, peer := range peers {
|
|
assaultTestDispatch(t, s, peer, route, 145, nil)
|
|
for _, recipient := range peers {
|
|
packets := assaultTestTake(t, recipient)
|
|
if len(packets) != 1 || packets[0].Header.MsgID != 129 || binary.LittleEndian.Uint16(packets[0].Body) != peer.selectedCharacterID {
|
|
t.Fatalf("loaded actor notice=%+v", packets)
|
|
}
|
|
}
|
|
assaultTestDispatch(t, s, peer, route, 146, connected)
|
|
if i == 0 && match.started {
|
|
t.Fatal("battle started before both clients were ready")
|
|
}
|
|
}
|
|
for _, peer := range peers {
|
|
packets := assaultTestTake(t, peer)
|
|
if !match.started || len(packets) != 1 || packets[0].Header.MsgID != 130 || len(packets[0].Body) != 0 {
|
|
t.Fatalf("native start=%+v", packets)
|
|
}
|
|
}
|
|
queue.fire(loadingTimer, true)
|
|
if match.closed {
|
|
t.Fatal("stale load timeout closed a started match")
|
|
}
|
|
death := make([]byte, 17)
|
|
death[0] = 1 // a victim cannot name its own seat as killer
|
|
assaultTestDispatch(t, s, peers[1], route, 147, death)
|
|
if match.ended {
|
|
t.Fatal("accepted invalid death report")
|
|
}
|
|
// B's native death report captured at 2026-09-06 03:34:13.422.
|
|
death = []byte{0, 0x16, 0, 0, 0, 0xc2, 0x7b, 9, 0, 0xa2, 9, 5, 1, 0x86, 0x0a, 0xfc, 0}
|
|
assaultTestDispatch(t, s, peers[1], route, 147, death)
|
|
for _, peer := range peers {
|
|
packets := assaultTestTake(t, peer)
|
|
wantEnd := []byte{0}
|
|
if mode == 13 {
|
|
wantEnd = []byte{1, 0, 0, 0}
|
|
}
|
|
if !match.ended || len(packets) != 2 || packets[0].Header.MsgID != 132 || !bytes.Equal(packets[0].Body, wantEnd) || packets[1].Header.MsgID != 12 {
|
|
t.Fatalf("native death/result=%+v", packets)
|
|
}
|
|
notice := packets[1].Body
|
|
// 参考实现按模式给不同标题:mode 13 是公会擂台赛,其余是街头争霸。
|
|
wantTitle := "街头争霸"
|
|
if mode == 13 {
|
|
wantTitle = "公会擂台赛"
|
|
}
|
|
wantMessage := wantTitle + "结束:fighter0 战胜了 fighter1。"
|
|
if len(notice) < 8 || !bytes.Equal(notice[:4], []byte{0, 29, 0, 0}) || int(binary.LittleEndian.Uint32(notice[4:8])) != len(notice)-8 {
|
|
t.Fatalf("missing native system victory notice: %x", notice)
|
|
}
|
|
message, err := simplifiedchinese.GB18030.NewDecoder().String(string(notice[8:]))
|
|
if err != nil || message != wantMessage {
|
|
t.Fatalf("victory notice=%q err=%v", message, err)
|
|
}
|
|
}
|
|
assaultTestDispatch(t, s, peers[1], route, 147, death)
|
|
if len(assaultTestTake(t, peers[0])) != 0 {
|
|
t.Fatal("duplicate death emitted another result")
|
|
}
|
|
returnTimer := queue.task(t, 2)
|
|
wantDelay := 3 * time.Second
|
|
if mode == 13 {
|
|
wantDelay = 11 * time.Second
|
|
}
|
|
if returnTimer.delay != wantDelay {
|
|
t.Fatalf("return delay=%v want %v", returnTimer.delay, wantDelay)
|
|
}
|
|
queue.fire(returnTimer, false)
|
|
for i, peer := range peers {
|
|
packets := assaultTestTake(t, peer)
|
|
closeBody := []byte{byte(90 + i)}
|
|
if peer.assault != match || !match.closed || len(packets) != 5 || packets[0].Header.MsgID != 133 || binary.LittleEndian.Uint16(packets[0].Body) != peer.selectedCharacterID || packets[1].Header.MsgID != 137 || !bytes.Equal(packets[1].Body, closeBody) || packets[3].Header.MsgID != 24 {
|
|
t.Fatalf("native street close/town restore=%+v", packets)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAssaultP2PFailureAndMalformedReports(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
body []byte
|
|
wantClose bool
|
|
wantReady bool
|
|
}{
|
|
{"failed_peer", []byte{8, 0, 1, 1, 0, 2, 1, 3, 1, 4, 1, 5, 1, 6, 1, 7, 1}, true, false},
|
|
{"unused_seats", []byte{8, 0, 1, 1, 1, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 7, 0}, false, true},
|
|
{"duplicate_seat", []byte{8, 0, 1, 1, 0, 2, 1, 3, 1, 4, 1, 5, 1, 6, 1, 6, 1}, false, false},
|
|
{"invalid_status", []byte{8, 0, 1, 1, 0, 2, 1, 3, 1, 4, 1, 5, 1, 6, 1, 7, 2}, false, false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
s, peers, _ := assaultTestPeers(t)
|
|
match := ¤tAssault{mode: 4, loaded: [2]bool{true, true}}
|
|
for i, peer := range peers {
|
|
match.members[i] = ¤tPVPMember{session: peer, characterID: peer.selectedCharacterID, position: peer.townPositionSnapshot, stamina: 100}
|
|
peer.assault = match
|
|
}
|
|
assaultTestDispatch(t, s, peers[0], "legacy", 146, test.body)
|
|
if match.closed != test.wantClose || match.connected[0] != test.wantReady || match.started {
|
|
t.Fatalf("closed=%v connected=%v started=%v", match.closed, match.connected, match.started)
|
|
}
|
|
for _, peer := range peers {
|
|
packets := assaultTestTake(t, peer)
|
|
if test.wantClose {
|
|
if peer.assault != match || len(packets) != 5 || packets[0].Header.MsgID != 133 || packets[1].Header.MsgID != 137 || !bytes.Equal(packets[1].Body, []byte{100}) {
|
|
t.Fatalf("failed P2P did not restore town: %+v", packets)
|
|
}
|
|
} else if len(packets) != 0 || peer.assault != match {
|
|
t.Fatalf("report unexpectedly changed the street scene: %+v", packets)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRealPVFAssaultAccept(t *testing.T) {
|
|
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
|
if path == "" {
|
|
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to exercise persisted actor loading and native street entry")
|
|
}
|
|
archive, err := platformpvf.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, route := range []string{"legacy", "upper"} {
|
|
s, peers, _ := assaultTestPeers(t)
|
|
s.initialEquipmentArchive = archive
|
|
assaultTestDispatch(t, s, peers[0], route, 10, []byte{30, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0})
|
|
assaultTestTake(t, peers[0])
|
|
assaultTestTake(t, peers[1])
|
|
assaultTestDispatch(t, s, peers[1], route, 11, []byte{29, 0, 4, 1, 0, 0, 0})
|
|
for i, peer := range peers {
|
|
packets := assaultTestTake(t, peer)
|
|
want := []uint16{2, 2, 2, 11, 128}
|
|
if i == 1 {
|
|
want = append([]uint16{11}, want...)
|
|
}
|
|
if peer.assault == nil || hasMultiMemberRuntimeParty(peer) || peer.pvpRoom != nil || len(packets) != len(want) {
|
|
t.Fatalf("%s accept did not enter street fight: packets=%+v", route, packets)
|
|
}
|
|
for j, opcode := range want {
|
|
if packets[j].Header.MsgID != opcode {
|
|
t.Fatalf("street entry[%d]=%d want %d", j, packets[j].Header.MsgID, opcode)
|
|
}
|
|
}
|
|
}
|
|
if peers[0].assault != peers[1].assault || peers[0].assault.duration <= 0 {
|
|
t.Fatal("street fight did not share a PVF-backed match")
|
|
}
|
|
s.closeCurrentPVPSession(peers[0])
|
|
packets := assaultTestTake(t, peers[1])
|
|
if peers[0].assault != nil || peers[1].assault == nil || !peers[1].assault.closed || len(packets) != 5 || packets[0].Header.MsgID != 133 || packets[1].Header.MsgID != 137 {
|
|
t.Fatalf("disconnect left other character in street fight: %+v", packets)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAssaultTownPeersAfterLoadComplete(t *testing.T) {
|
|
for _, route := range []string{"legacy", "upper"} {
|
|
t.Run(route, func(t *testing.T) {
|
|
s, peers, _ := assaultTestPeers(t)
|
|
s.options.gameUpperHeader = gameUpperHeaderServer16
|
|
if path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE"); path != "" {
|
|
archive, err := platformpvf.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.initialEquipmentArchive = archive
|
|
}
|
|
match := ¤tAssault{mode: 4}
|
|
for i, peer := range peers {
|
|
peer.townActorOwnerChannel = byte(21 + i)
|
|
match.members[i] = ¤tPVPMember{session: peer, characterID: peer.selectedCharacterID, position: peer.townPositionSnapshot, stamina: 100}
|
|
peer.assault = match
|
|
}
|
|
for _, peer := range peers {
|
|
assaultTestDispatch(t, s, peer, route, 244, nil)
|
|
if len(assaultTestTake(t, peer)) != 0 || peer.assault != match {
|
|
t.Fatal("early town ACK changed an active match")
|
|
}
|
|
}
|
|
s.closeCurrentAssaultLocked(match, nil)
|
|
for _, peer := range peers {
|
|
packets := assaultTestTake(t, peer)
|
|
if len(packets) != 5 || packets[3].Header.MsgID != 24 {
|
|
t.Fatalf("town return sent actors before load completed: %+v", packets)
|
|
}
|
|
}
|
|
for i, peer := range peers {
|
|
assaultTestDispatch(t, s, peer, route, 244, []byte{1})
|
|
if len(assaultTestTake(t, peer)) != 0 || peer.assault != match {
|
|
t.Fatal("malformed town ACK consumed pending return")
|
|
}
|
|
assaultTestDispatch(t, s, peer, route, 244, nil)
|
|
actor, _ := s.onlinePlayers.PlayerForCharacter(peers[1-i].selectedCharacterID)
|
|
conn := peer.conn.(*bufferConn)
|
|
assertTownRemoteActorSequenceWithMode1(t, conn.write.Bytes(), peer, actor, true)
|
|
conn.write.Reset()
|
|
if peer.assault != nil || len(assaultTestTake(t, peers[1-i])) != 0 {
|
|
t.Fatal("town ACK did not finish only its receiving session")
|
|
}
|
|
assaultTestDispatch(t, s, peer, route, 244, nil)
|
|
if len(assaultTestTake(t, peer)) != 0 {
|
|
t.Fatal("duplicate town ACK recreated actors")
|
|
}
|
|
}
|
|
if !s.currentAssaultPeersAvailable(peers[0], peers[1]) {
|
|
t.Fatal("completed return blocked the next street challenge")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAssaultPendingReturnClearedOnSessionRetirement(t *testing.T) {
|
|
for _, retire := range []string{"disconnect", "character_change"} {
|
|
t.Run(retire, func(t *testing.T) {
|
|
s, peers, _ := assaultTestPeers(t)
|
|
match := ¤tAssault{mode: 4}
|
|
for i, peer := range peers {
|
|
peer.assault = match
|
|
match.members[i] = ¤tPVPMember{session: peer, characterID: peer.selectedCharacterID, position: peer.townPositionSnapshot}
|
|
}
|
|
s.closeCurrentAssaultLocked(match, nil)
|
|
for _, peer := range peers {
|
|
assaultTestTake(t, peer)
|
|
}
|
|
if retire == "disconnect" {
|
|
s.closeCurrentPVPSession(peers[0])
|
|
} else {
|
|
s.bindGameSessionCharacter(peers[0], 31)
|
|
}
|
|
if peers[0].assault != nil || peers[1].assault != match {
|
|
t.Fatal("session retirement left pending state or canceled the peer's return")
|
|
}
|
|
assaultTestDispatch(t, s, peers[0], "legacy", 244, nil)
|
|
if len(assaultTestTake(t, peers[0])) != 0 {
|
|
t.Fatal("stale town ACK recreated the previous character's scene")
|
|
}
|
|
})
|
|
}
|
|
}
|