服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
170 lines
6.2 KiB
Go
170 lines
6.2 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
"longheng.io/server/internal/modules/dnf/raid"
|
|
)
|
|
|
|
// C2S 666 is answered with the client's own refusal surface: body {u8 result,
|
|
// u8 message code}, where a zero result makes sub_1D04620 look the code up in its
|
|
// table and open the announcement window (owner 0x298) with that string. Codes
|
|
// 0x11 and 0xD9 are followed by a u32 count and count u32 member ids.
|
|
func TestRaidStartFailureUsesTheClientsOwnMessageCodes(t *testing.T) {
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 23, Type: 23}}
|
|
service := &Service{}
|
|
|
|
if err := service.sendRaidStartFailure(session, 666, runtimeRaidStartFailureMinimumRoster, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 || !bytes.Equal(packet.Body, []byte{0, 0x16}) {
|
|
t.Fatalf("minimum-roster refusal body=% X rest=%d", packet.Body, len(rest))
|
|
}
|
|
|
|
// The entry-cost message names the members the client's string is about.
|
|
conn.write.Reset()
|
|
if err := service.sendRaidStartFailure(session, 666, runtimeRaidStartFailureEntryCost, []uint16{2, 7}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest = splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
want := []byte{0, 0x11, 2, 0, 0, 0, 2, 0, 0, 0, 7, 0, 0, 0}
|
|
if len(rest) != 0 || !bytes.Equal(packet.Body, want) {
|
|
t.Fatalf("entry-cost refusal body=% X want=% X rest=%d", packet.Body, want, len(rest))
|
|
}
|
|
|
|
// A code the client does not read a list for must not carry one.
|
|
conn.write.Reset()
|
|
if err := service.sendRaidStartFailure(session, 666, runtimeRaidStartFailureNotLeader, []uint16{2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, _ = splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if !bytes.Equal(packet.Body, []byte{0, 0x08}) {
|
|
t.Fatalf("not-leader refusal body=% X", packet.Body)
|
|
}
|
|
}
|
|
|
|
// The clear announcement is the client's own 0x0272 line: u32 mode, dstr raid
|
|
// name, dstr leader name, u8 solo. The client formats string 60454 with the two
|
|
// arguments and draws it in orange.
|
|
func TestRaidClearAnnouncementBodyLayout(t *testing.T) {
|
|
raidName := []byte("我的攻坚队")
|
|
leaderName := []byte("团长")
|
|
body, err := raid.BuildRaidClearAnnouncementBody(raid.RaidClearAnnouncement{
|
|
Kind: raid.RaidClearKindAnton,
|
|
RaidName: raidName,
|
|
LeaderName: leaderName,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []byte{0, 0, 0, 0}
|
|
want = binary.LittleEndian.AppendUint32(want, uint32(len(raidName)))
|
|
want = append(want, raidName...)
|
|
want = binary.LittleEndian.AppendUint32(want, uint32(len(leaderName)))
|
|
want = append(want, leaderName...)
|
|
want = append(want, 0)
|
|
if !bytes.Equal(body, want) {
|
|
t.Fatalf("team announcement body=% X want=% X", body, want)
|
|
}
|
|
|
|
solo, err := raid.BuildRaidClearAnnouncementBody(raid.RaidClearAnnouncement{
|
|
Kind: raid.RaidClearKindAnton,
|
|
RaidName: nil,
|
|
LeaderName: []byte("独行"),
|
|
Solo: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if solo[len(solo)-1] != 1 {
|
|
t.Fatalf("solo announcement body=% X", solo)
|
|
}
|
|
|
|
// The reader falls back to an empty string past 0x100 bytes instead of
|
|
// truncating, so an oversized argument has to be refused here.
|
|
long := bytes.Repeat([]byte("a"), 0x101)
|
|
if _, err := raid.BuildRaidClearAnnouncementBody(raid.RaidClearAnnouncement{LeaderName: long}); err == nil {
|
|
t.Fatal("an over-long leader name was accepted")
|
|
}
|
|
}
|
|
|
|
// The announcement goes to every session on the raid's channel, and only for the
|
|
// phase that actually ends the raid.
|
|
func TestRuntimeRaidClearAnnouncementReachesTheChannelOnTheFinalPhase(t *testing.T) {
|
|
channel := channelcatalog.Channel{ID: 23, Type: 23}
|
|
newSession := func(character uint16) (*gameSession, *bufferConn) {
|
|
conn := &bufferConn{}
|
|
return &gameSession{conn: conn, channel: channel, selectedCharacterID: character}, conn
|
|
}
|
|
leader, leaderConn := newSession(1001)
|
|
other, otherConn := newSession(1002)
|
|
outsider, outsiderConn := newSession(1003)
|
|
outsider.channel = channelcatalog.Channel{ID: 24, Type: 23}
|
|
|
|
service := &Service{
|
|
gameSessions: map[uint16]*gameSession{1001: leader, 1002: other, 1003: outsider},
|
|
onlinePlayers: newOnlinePlayerManager(),
|
|
}
|
|
state := runtimeRaidState{
|
|
RaidKey: uint32(23)<<16 | 1001,
|
|
LeaderID: 1001,
|
|
NameBytes: []byte("我的攻坚队"),
|
|
Members: []runtimeRaidMemberState{
|
|
{CharID: 1001, Name: "团长", GroupIndex: 1},
|
|
{CharID: 1002, Name: "队员", GroupIndex: 1},
|
|
},
|
|
Lifecycle: runtimeRaidLifecycleState{Status: runtimeRaidLifecyclePhaseActive, Phase: runtimeRaidDungeonPhaseTwo},
|
|
}
|
|
|
|
if !runtimeRaidClearedByPhase(state, runtimeRaidDungeonPhaseTwo) {
|
|
t.Fatal("phase two of a team raid is not treated as the clear")
|
|
}
|
|
if runtimeRaidClearedByPhase(state, runtimeRaidDungeonPhaseOne) {
|
|
t.Fatal("phase one of a team raid is treated as the clear")
|
|
}
|
|
|
|
service.announceRuntimeRaidClearIfNeeded(state, runtimeRaidDungeonPhaseTwo)
|
|
packet, rest := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
|
|
if packet.Header.MsgID != raid.RaidClearAnnouncementMsgID || len(rest) != 0 {
|
|
t.Fatalf("announcement packet=%+v rest=%d", packet.Header, len(rest))
|
|
}
|
|
want, err := raid.BuildRaidClearAnnouncementBody(raid.RaidClearAnnouncement{
|
|
Kind: raid.RaidClearKindAnton,
|
|
RaidName: []byte("我的攻坚队"),
|
|
LeaderName: rosterNameBytes("团长"),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(packet.Body, want) {
|
|
t.Fatalf("announcement body=% X want=% X", packet.Body, want)
|
|
}
|
|
if otherConn.write.Len() == 0 {
|
|
t.Fatal("a member on the same channel received no announcement")
|
|
}
|
|
if outsiderConn.write.Len() != 0 {
|
|
t.Fatal("the announcement crossed into another channel")
|
|
}
|
|
|
|
// The settlement moves the phase off PhaseActive, so it can never fire twice.
|
|
leaderConn.write.Reset()
|
|
settled := state
|
|
settled.Lifecycle.Status = runtimeRaidLifecycleAwaitingMovie
|
|
service.announceRuntimeRaidClearIfNeeded(settled, runtimeRaidDungeonPhaseTwo)
|
|
if leaderConn.write.Len() != 0 {
|
|
t.Fatal("a settled raid announced its clear a second time")
|
|
}
|
|
|
|
// Phase one of a team raid is a phase break, not a clear.
|
|
leaderConn.write.Reset()
|
|
service.announceRuntimeRaidClearIfNeeded(state, runtimeRaidDungeonPhaseOne)
|
|
if leaderConn.write.Len() != 0 {
|
|
t.Fatal("the phase-one break announced a raid clear")
|
|
}
|
|
}
|