服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
239 lines
8.6 KiB
Go
239 lines
8.6 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
|
|
repo "longheng.io/server/internal/modules/dnf/repository"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func totalTestPackets(t *testing.T, p *gameSession) []dnfproto.ChannelPacket {
|
|
t.Helper()
|
|
conn := p.conn.(*bufferConn)
|
|
wire := append([]byte(nil), conn.write.Bytes()...)
|
|
conn.write.Reset()
|
|
var packets []dnfproto.ChannelPacket
|
|
for len(wire) > 0 {
|
|
p, rest := splitCurrentGameServerUpperPacketAuto(t, wire)
|
|
packets = append(packets, p)
|
|
wire = rest
|
|
}
|
|
return packets
|
|
}
|
|
|
|
func TestPVPTotalSelectionLoadingAndThreeLives(t *testing.T) {
|
|
s := pvpRoomTestService(t)
|
|
s.gameplayTimers = newFakeCurrentDungeonDeathTimerQueue()
|
|
a := &gameSession{conn: &bufferConn{}, accountID: "pvp-29", selectedCharacterID: 29, channel: s.catalog.Channels()[1]}
|
|
b := &gameSession{conn: &bufferConn{}, accountID: "pvp-30", selectedCharacterID: 30, channel: a.channel}
|
|
viewer := &gameSession{conn: &bufferConn{}, selectedCharacterID: 31, channel: a.channel}
|
|
seedPVPRoomTestSession(s, a)
|
|
room := a.pvpRoom
|
|
b.pvpRoom = room
|
|
viewer.pvpRoom = room
|
|
room.battleMode, room.started = 8, true
|
|
room.match = ¤tPVPMatch{winner: 255, total: newCurrentPVPTotalMatch(), startedAt: s.gameplayNow()}
|
|
room.members[1] = ¤tPVPMember{session: b, characterID: 30}
|
|
room.members[2] = ¤tPVPMember{session: viewer, characterID: 31}
|
|
room.seatState[1], room.seatState[2] = 2, 3
|
|
s.pvpTotalCatalog = pvpDailyTestCatalog()
|
|
repos, _ := s.repositoryGroup()
|
|
for seat, p := range []*gameSession{a, b} {
|
|
if err := repos.Account.Save(context.Background(), repo.AccountRecord{AccountID: p.accountID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
team := ¤tPVPTotalTeam{}
|
|
for i, id := range []int{int(p.selectedCharacterID), 40 + seat*3, 41 + seat*3} {
|
|
c := repo.CharacterRecord{CharacterID: strconv.Itoa(id), AccountID: p.accountID, Name: strconv.Itoa(id), Slot: i, Level: 90, Job: strconv.Itoa(i), Stats: map[string]int64{}}
|
|
if err := repos.Character.Save(context.Background(), c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
team.snapshot.Team.Characters[i], team.snapshot.Characters[i] = c.CharacterID, c
|
|
}
|
|
room.members[seat].total = team
|
|
}
|
|
for _, p := range []*gameSession{a, b, viewer} {
|
|
if err := s.handleCurrentPVPTotalOrder(p, []byte{0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if room.match.total.selected[0] != 255 {
|
|
t.Fatal("accepted selection before loading")
|
|
}
|
|
if err := s.handleCurrentPVPMatchSignal(a, 298, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(totalTestPackets(t, a)) != 0 {
|
|
t.Fatal("opened selection before peer loaded")
|
|
}
|
|
if err := s.handleCurrentPVPMatchSignal(b, 298, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []*gameSession{a, b, viewer} {
|
|
packets := totalTestPackets(t, p)
|
|
if len(packets) != 1 || packets[0].Header.MsgID != 804 || len(packets[0].Body) != 0 {
|
|
t.Fatal("invalid native selection", packets)
|
|
}
|
|
}
|
|
if err := s.handleCurrentPVPMatchSignal(b, 298, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(totalTestPackets(t, b)) != 0 {
|
|
t.Fatal("duplicate load reopened selection")
|
|
}
|
|
if err := s.handleCurrentPVPTotalOrder(a, []byte{2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.handleCurrentPVPTotalOrder(a, []byte{1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.handleCurrentPVPTotalOrder(viewer, []byte{0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if room.match.total.selected[0] != 2 || room.match.total.selected[2] != 255 {
|
|
t.Fatal("duplicate/observer changed selection")
|
|
}
|
|
if err := s.handleCurrentPVPTotalOrder(b, []byte{0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []*gameSession{a, b, viewer} {
|
|
packets := totalTestPackets(t, p)
|
|
if len(packets) != 1 || packets[0].Header.MsgID != 803 || !bytes.Equal(packets[0].Body, []byte{29, 0, 0, 0, 2, 30, 0, 0, 0, 0}) {
|
|
t.Fatal("invalid fixed two-owner selection", packets)
|
|
}
|
|
}
|
|
death := []byte{30, 0, 29, 0, 0, 80, 0}
|
|
for round := 0; round < 3; round++ {
|
|
if err := s.handleCurrentPVPDeath(b, death); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if round < 2 {
|
|
if room.match.resultSent || room.match.dead[1] || !room.match.total.defeated[1][round] || !room.match.total.choosing {
|
|
t.Fatal("team ended on early character death")
|
|
}
|
|
kills := room.match.kills[0]
|
|
if err := s.handleCurrentPVPDeath(b, death); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if room.match.kills[0] != kills {
|
|
t.Fatal("repeated death counted twice")
|
|
}
|
|
if err := s.handleCurrentPVPTotalOrder(b, []byte{byte(round)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if room.match.total.selected[1] != 255 {
|
|
t.Fatal("defeated character reused")
|
|
}
|
|
if err := s.handleCurrentPVPTotalOrder(b, []byte{byte(round + 1)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if room.match.total.choosing || room.match.total.selected[0] != 2 {
|
|
t.Fatal("survivor lost its actor")
|
|
}
|
|
} else if !room.match.resultSent || room.match.winner != 0 {
|
|
t.Fatal("three exhausted characters did not end match")
|
|
}
|
|
}
|
|
if room.match.total.kills[0] != [3]uint32{0, 0, 3} {
|
|
t.Fatal("per-character all-kill lost")
|
|
}
|
|
for _, p := range []*gameSession{a, b} {
|
|
for _, packet := range totalTestPackets(t, p) {
|
|
if packet.Header.MsgID == 48 && len(packet.Body) != 129 {
|
|
t.Fatal("total certification truncated", len(packet.Body))
|
|
}
|
|
}
|
|
}
|
|
for _, id := range room.members[0].total.snapshot.Team.Characters {
|
|
c, _, _ := repos.Character.Load(context.Background(), id)
|
|
if c.Stats[dnfpvp.ExperienceKey] != 450 || c.Stats[dnfpvp.WinsKey] != 0 {
|
|
t.Fatal("manual total settlement polluted ranked counters", c.Stats)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPVPTotalRequestNameAndNativeSkillSlots(t *testing.T) {
|
|
var w packetWriter
|
|
w.writeRawDstr(rosterNameBytes("王者队伍"))
|
|
w.writeBytes([]byte{1, 4, 9})
|
|
name, slots, err := parsePVPTotalTeamName(w.bytes(), 3)
|
|
if err != nil || name != "王者队伍" || !bytes.Equal(slots, []byte{1, 4, 9}) {
|
|
t.Fatal(name, slots, err)
|
|
}
|
|
for _, b := range [][]byte{nil, w.bytes()[:5], append(w.bytes(), 0), {255, 255, 255, 255}} {
|
|
if _, _, err := parsePVPTotalTeamName(b, 3); err == nil {
|
|
t.Fatal("bad string accepted", b)
|
|
}
|
|
}
|
|
r := repo.SkillRecord{Skills: map[int64]repo.SkillState{20: {Level: 3}, 46: {Level: 7}}, Layouts: map[int]repo.SkillLayout{0: {198: 46}}}
|
|
b, err := buildCurrentPVPTotalSkills(r, 0)
|
|
if err != nil || !bytes.Equal(b, []byte{2, 0, 198, 46, 0, 7, 255, 20, 0, 3}) {
|
|
t.Fatal("actual shortcuts/learned levels lost", b, err)
|
|
}
|
|
request := []byte{1, 7, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0}
|
|
parsed, err := parseCurrentPVPMatchingRequest(request)
|
|
if err != nil || parsed.gameType != 7 {
|
|
t.Fatal(parsed, err)
|
|
}
|
|
binary.LittleEndian.PutUint16(request[1:], 8)
|
|
if _, err := parseCurrentPVPMatchingRequest(request); err == nil {
|
|
t.Fatal("confused scene8 with gameType7")
|
|
}
|
|
}
|
|
|
|
func TestPVPTotalTimeoutExhaustsOnlyCurrentActorAndWatchdog(t *testing.T) {
|
|
s := pvpRoomTestService(t)
|
|
q := newFakeCurrentDungeonDeathTimerQueue()
|
|
s.gameplayTimers = q
|
|
a := &gameSession{conn: &bufferConn{}, selectedCharacterID: 29, channel: s.catalog.Channels()[1]}
|
|
b := &gameSession{conn: &bufferConn{}, selectedCharacterID: 30, channel: a.channel}
|
|
seedPVPRoomTestSession(s, a)
|
|
room := a.pvpRoom
|
|
room.battleMode, room.started = 8, true
|
|
room.match = ¤tPVPMatch{winner: 255, startedAt: q.now, total: newCurrentPVPTotalMatch()}
|
|
room.members[1] = ¤tPVPMember{session: b, characterID: 30}
|
|
room.seatState[1] = 2
|
|
b.pvpRoom = room
|
|
m := room.match.total
|
|
m.selected[0], m.selected[1] = 2, 0
|
|
m.loaded[0], m.loaded[1] = true, true
|
|
if err := s.finishCurrentPVPTotalTimeoutLocked(room, a, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m.defeated[1] != [3]bool{true, false, false} || !m.choosing || room.match.settlementPending || m.selected[0] != 2 {
|
|
t.Fatal("timeout ended whole team")
|
|
}
|
|
task := q.task(t, 0)
|
|
q.now = q.now.Add(45 * time.Second)
|
|
q.fire(task, false)
|
|
if m.choosing || m.selected[1] != 1 || m.selected[0] != 2 {
|
|
t.Fatal("watchdog selected exhausted actor or replaced survivor")
|
|
}
|
|
q.fire(task, true)
|
|
if m.selected[1] != 1 {
|
|
t.Fatal("stale selection timer changed round")
|
|
}
|
|
if err := s.finishCurrentPVPTotalTimeoutLocked(room, a, 255); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !m.defeated[0][2] || !m.defeated[1][1] || room.match.settlementPending {
|
|
t.Fatal("draw did not exhaust both current actors")
|
|
}
|
|
q.fire(task, true)
|
|
if !m.choosing || m.selected[0] != 255 || m.selected[1] != 255 {
|
|
t.Fatal("previous phase watchdog selected actors in new phase")
|
|
}
|
|
if err := s.scheduleCurrentPVPTotalPhaseLocked(room, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.removeCurrentPVPMemberLocked(b, false)
|
|
if _, _, active := q.counts(); active != 0 {
|
|
t.Fatal("disconnect left total timer", active)
|
|
}
|
|
}
|