服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
132 lines
5.1 KiB
Go
132 lines
5.1 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"testing"
|
|
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
)
|
|
|
|
// The raid leader's own raid-channel speech travels as the announcement mode (56),
|
|
// and as exactly ONE packet: mode 56 already prints the line and the overhead bubble
|
|
// as well as the banner, so sending the ordinary raid line (55) alongside it made
|
|
// every message appear twice in the client's chat window.
|
|
//
|
|
// Live evidence for the doubling: 3 messages produced 6 identical chat lines, and the
|
|
// server logged the mode-55 and the mode-56 broadcast on the same microsecond with
|
|
// one recipient each. Live evidence for the surface: the banner drew "通知 | 横幅测试"
|
|
// from the raw GBK bytes.
|
|
func TestRaidLeaderSpeechIsSentOnceOnTheAnnouncementMode(t *testing.T) {
|
|
fixture := newRuntimeRaidDisconnectFixture(t, 1001, 1002)
|
|
leader := fixture.sessions[1001]
|
|
member := fixture.sessions[1002]
|
|
leader.conn.(*bufferConn).write.Reset()
|
|
member.conn.(*bufferConn).write.Reset()
|
|
|
|
if err := fixture.service.handleCurrentScopedTownChat(leader, currentTownChatRequest{
|
|
Mode: currentTownChatRaidMode,
|
|
Message: []byte("集合"),
|
|
}); err != nil {
|
|
t.Fatalf("leader raid chat: %v", err)
|
|
}
|
|
|
|
// One packet per recipient, not two.
|
|
leaderPackets := runtimeRaidPeerTake(t, leader)
|
|
if len(leaderPackets) != 1 {
|
|
t.Fatalf("leader received %d packets, want exactly 1: %+v", len(leaderPackets), leaderPackets)
|
|
}
|
|
memberPackets := runtimeRaidPeerTake(t, member)
|
|
if len(memberPackets) != 1 {
|
|
t.Fatalf("member received %d packets, want exactly 1: %+v", len(memberPackets), memberPackets)
|
|
}
|
|
|
|
// The banner mode is what selects the surface; the actor key is what lets the
|
|
// receiver resolve the speaker, and the text stays the client's own GBK bytes.
|
|
want := buildCurrentTownChatNotification(currentRaidAnnouncementMode, 1001, []byte("集合"))
|
|
for name, packet := range map[string]dnfproto.ChannelPacket{
|
|
"leader": leaderPackets[0],
|
|
"member": memberPackets[0],
|
|
} {
|
|
if packet.Header.MsgID != currentTownChatNotificationMsgID {
|
|
t.Fatalf("%s msg=%#04x want %#04x", name, packet.Header.MsgID, currentTownChatNotificationMsgID)
|
|
}
|
|
if packet.Body[0] != currentRaidAnnouncementMode {
|
|
t.Fatalf("%s mode=%d want %d", name, packet.Body[0], currentRaidAnnouncementMode)
|
|
}
|
|
if !bytes.Equal(packet.Body, want) {
|
|
t.Fatalf("%s body=% X want % X", name, packet.Body, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A squad leader or an ordinary member keeps the plain raid line: same single packet,
|
|
// mode 55, no banner.
|
|
func TestRaidMemberSpeechKeepsThePlainRaidLine(t *testing.T) {
|
|
fixture := newRuntimeRaidDisconnectFixture(t, 1001, 1002)
|
|
leader := fixture.sessions[1001]
|
|
member := fixture.sessions[1002]
|
|
leader.conn.(*bufferConn).write.Reset()
|
|
member.conn.(*bufferConn).write.Reset()
|
|
|
|
if err := fixture.service.handleCurrentScopedTownChat(member, currentTownChatRequest{
|
|
Mode: currentTownChatRaidMode,
|
|
Message: []byte("集合"),
|
|
}); err != nil {
|
|
t.Fatalf("member raid chat: %v", err)
|
|
}
|
|
|
|
leaderPackets := runtimeRaidPeerTake(t, leader)
|
|
if len(leaderPackets) != 1 {
|
|
t.Fatalf("leader received %d packets, want exactly 1", len(leaderPackets))
|
|
}
|
|
want := buildCurrentTownChatNotification(currentTownChatRaidMode, 1002, []byte("集合"))
|
|
if !bytes.Equal(leaderPackets[0].Body, want) {
|
|
t.Fatalf("member line body=% X want % X", leaderPackets[0].Body, want)
|
|
}
|
|
if leaderPackets[0].Body[0] == currentRaidAnnouncementMode {
|
|
t.Fatal("a non-leader member raised the announcement mode")
|
|
}
|
|
runtimeRaidPeerTake(t, member)
|
|
}
|
|
|
|
// A non-raid chat mode never reaches the banner path, even for the raid leader.
|
|
func TestRaidAnnouncementIgnoresOtherChatModes(t *testing.T) {
|
|
fixture := newRuntimeRaidDisconnectFixture(t, 1001, 1002)
|
|
leader := fixture.sessions[1001]
|
|
leader.conn.(*bufferConn).write.Reset()
|
|
|
|
mode, raidKey := fixture.service.currentRaidAnnouncementModeFor(leader, currentTownChatAreaMode)
|
|
if raidKey != 0 || mode != currentTownChatAreaMode {
|
|
t.Fatalf("area chat promoted: mode=%d raid_key=%d", mode, raidKey)
|
|
}
|
|
|
|
if err := fixture.service.handleCurrentScopedTownChat(leader, currentTownChatRequest{
|
|
Mode: currentTownChatAreaMode,
|
|
Message: []byte("区域喊话"),
|
|
}); err != nil {
|
|
t.Fatalf("area chat: %v", err)
|
|
}
|
|
packets := runtimeRaidPeerTake(t, leader)
|
|
if len(packets) != 1 {
|
|
t.Fatalf("area chat wrote %d packets, want 1", len(packets))
|
|
}
|
|
if packets[0].Body[0] != currentTownChatAreaMode {
|
|
t.Fatalf("area chat mode=%d want %d", packets[0].Body[0], currentTownChatAreaMode)
|
|
}
|
|
}
|
|
|
|
// The banner body carries the client's own bytes untouched: no GB18030 -> UTF-8 or
|
|
// UTF-16LE re-encoding. The temporary "B "/"C " probe prefixes are gone with it, so a
|
|
// leader whose message happens to start with that text keeps it verbatim.
|
|
func TestRaidAnnouncementPassesTheMessageBytesThrough(t *testing.T) {
|
|
message := []byte("B \xd6\xd0\xce\xc4")
|
|
body := buildCurrentRaidAnnouncementNotification(1001, message)
|
|
want := buildCurrentTownChatNotification(currentRaidAnnouncementMode, 1001, message)
|
|
if !bytes.Equal(body, want) {
|
|
t.Fatalf("banner body=% X want % X", body, want)
|
|
}
|
|
if !bytes.HasSuffix(body, message) {
|
|
t.Fatalf("banner body=% X lost the raw tail % X", body, message)
|
|
}
|
|
}
|