服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
217 lines
7.9 KiB
Go
217 lines
7.9 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
// TestChatRecordTextDecodesGBK 锁住聊天正文的解码:客户端发的是 GBK 字节,
|
|
// 记录里必须是能看懂的中文,而不是一串问号或乱码。
|
|
func TestChatRecordTextDecodesGBK(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw []byte
|
|
want string
|
|
wantRaw bool
|
|
}{
|
|
{name: "ascii", raw: []byte("hello world"), want: "hello world"},
|
|
{name: "empty", raw: nil, want: ""},
|
|
// “你好” 的 GBK 编码是 C4 E3 BA C3。
|
|
{name: "gbk", raw: []byte{0xC4, 0xE3, 0xBA, 0xC3}, want: "你好"},
|
|
}
|
|
for _, testCase := range cases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
if got := chatRecordText(testCase.raw); got != testCase.want {
|
|
t.Fatalf("chatRecordText(%v) = %q, want %q", testCase.raw, got, testCase.want)
|
|
}
|
|
})
|
|
}
|
|
// 一段解不出合法文本的字节不能被丢掉:原样保留(转成字符串)比丢信息好。
|
|
junk := []byte{0x81, 0x40, 0xFF}
|
|
if got := chatRecordText(junk); got == "" {
|
|
t.Fatalf("undecodable message was dropped: %v", junk)
|
|
}
|
|
}
|
|
|
|
func TestCurrentTownChatModeName(t *testing.T) {
|
|
cases := map[byte]string{
|
|
currentTownChatWhisperMode: "私聊",
|
|
currentTownChatOneToOneMode: "1:1会话",
|
|
currentTownChatMegaphoneMode: "服务器喇叭",
|
|
currentTownChatChannelMegaphoneMode: "频道喇叭",
|
|
currentTownChatAreaMode: "区域",
|
|
currentTownChatPartyMode: "队伍",
|
|
currentTownChatGuildMode: "公会",
|
|
currentTownChatGuildLegacyMode: "公会",
|
|
currentTownChatRaidMode: "攻坚队",
|
|
}
|
|
for mode, want := range cases {
|
|
if got := currentTownChatModeName(mode); got != want {
|
|
t.Fatalf("mode %d name = %q, want %q", mode, got, want)
|
|
}
|
|
}
|
|
if got := currentTownChatModeName(0xEE); !strings.Contains(got, "未知") {
|
|
t.Fatalf("unknown mode name = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestRecordChatFields 覆盖一条对话记录的字段形状:查记录的人要能直接回答
|
|
// "谁、在哪个频道、用什么方式、说了什么、发给了多少人"。
|
|
func TestRecordChatFields(t *testing.T) {
|
|
recorder, _ := newTestRecorder(t)
|
|
service := &Service{}
|
|
service.records.Store(recorder)
|
|
session := &gameSession{accountID: "dnf:77", channel: channelcatalog.Channel{ID: 508}}
|
|
session.selectedCharacterID = 21
|
|
|
|
service.recordChat(session, currentTownChatAreaMode, currentTownChatModeName(currentTownChatAreaMode),
|
|
[]byte{0xC4, 0xE3, 0xBA, 0xC3}, 3, 0, "ok", "", map[string]any{"scope": "visible_town_scene"})
|
|
service.recordChat(session, currentTownChatMegaphoneMode, currentTownChatModeName(currentTownChatMegaphoneMode),
|
|
[]byte("spam"), 0, 0, "rejected", "item 36 PVF identity mismatch", map[string]any{"material_item_id": 36})
|
|
|
|
records, _, err := recorder.queryRecords(recordQuery{Kind: recordKindChat})
|
|
if err != nil {
|
|
t.Fatalf("query: %v", err)
|
|
}
|
|
if len(records) != 2 {
|
|
t.Fatalf("record count = %d, want 2", len(records))
|
|
}
|
|
// 最新在前:第二条是被拒绝的那条喇叭。
|
|
rejected := records[0]
|
|
if rejected["result"] != "rejected" || rejected["mode_name"] != "服务器喇叭" {
|
|
t.Fatalf("rejected record = %+v", rejected)
|
|
}
|
|
if rejected["reason"] != "item 36 PVF identity mismatch" {
|
|
t.Fatalf("rejection reason missing: %+v", rejected)
|
|
}
|
|
if rejected["material_item_id"] != float64(36) {
|
|
t.Fatalf("material item missing: %+v", rejected)
|
|
}
|
|
if rejected["message_hex"] != "7370616d" {
|
|
t.Fatalf("rejected message hex = %v, want 7370616d", rejected["message_hex"])
|
|
}
|
|
delivered := records[1]
|
|
if delivered["message"] != "你好" || delivered["message_len"] != float64(2) || delivered["message_bytes"] != float64(4) {
|
|
t.Fatalf("delivered record = %+v", delivered)
|
|
}
|
|
if delivered["recipients"] != float64(3) || delivered["channel_id"] != float64(508) {
|
|
t.Fatalf("audience fields = %+v", delivered)
|
|
}
|
|
if delivered["character_id"] != "21" || delivered["account_id"] != "dnf:77" {
|
|
t.Fatalf("identity fields = %+v", delivered)
|
|
}
|
|
if delivered["scope"] != "visible_town_scene" {
|
|
t.Fatalf("scope field = %+v", delivered)
|
|
}
|
|
}
|
|
|
|
// TestRecordDungeonFields 覆盖副本记录的进入/离开字段,并确认离开记录会带上
|
|
// 生命周期结束原因与"是否通关"——排障时这两项是第一个要看的。
|
|
func TestRecordDungeonFields(t *testing.T) {
|
|
recorder, _ := newTestRecorder(t)
|
|
service := &Service{}
|
|
service.records.Store(recorder)
|
|
|
|
runtime := &runtimeDungeonState{
|
|
Dungeon: worldmap.Dungeon{
|
|
ID: 11006,
|
|
Path: "dungeon/tutorial.stk",
|
|
Metadata: worldmap.DungeonMetadata{Name: "洛兰的陷落"},
|
|
},
|
|
MazeIndex: 2,
|
|
startedAt: time.Now().Add(-90 * time.Second),
|
|
lifecycleToken: 777,
|
|
clearKillNormal: 12,
|
|
clearKillBoss: 1,
|
|
}
|
|
runtime.Request.DungeonID = 11006
|
|
runtime.Request.Difficulty = 3
|
|
session := &gameSession{accountID: "dnf:9"}
|
|
session.selectedCharacterID = 33
|
|
|
|
service.recordDungeonEnter(session, runtime)
|
|
runtime.ordinaryFinalRoomClearAccepted = true
|
|
runtime.settlementEntrySent = true
|
|
service.recordDungeonExit(session, runtime, "completed_town_return")
|
|
|
|
records, _, err := recorder.queryRecords(recordQuery{Kind: recordKindDungeon})
|
|
if err != nil {
|
|
t.Fatalf("query: %v", err)
|
|
}
|
|
if len(records) != 2 {
|
|
t.Fatalf("record count = %d, want 2", len(records))
|
|
}
|
|
exit := records[0]
|
|
if exit["event"] != "exit" || exit["event_name"] != "离开副本" {
|
|
t.Fatalf("exit record = %+v", exit)
|
|
}
|
|
if exit["exit_reason"] != "completed_town_return" || exit["cleared"] != true {
|
|
t.Fatalf("exit reason/cleared = %+v", exit)
|
|
}
|
|
if exit["dungeon_id"] != float64(11006) || exit["dungeon_name"] != "洛兰的陷落" {
|
|
t.Fatalf("dungeon identity = %+v", exit)
|
|
}
|
|
if exit["difficulty"] != float64(3) || exit["maze_index"] != float64(2) {
|
|
t.Fatalf("dungeon selection = %+v", exit)
|
|
}
|
|
if exit["killed_normal"] != float64(12) || exit["killed_boss"] != float64(1) {
|
|
t.Fatalf("kill tally = %+v", exit)
|
|
}
|
|
if elapsed, ok := exit["elapsed_seconds"].(float64); !ok || elapsed < 89 {
|
|
t.Fatalf("elapsed_seconds = %v", exit["elapsed_seconds"])
|
|
}
|
|
enter := records[1]
|
|
if enter["event"] != "enter" || enter["event_name"] != "进入副本" {
|
|
t.Fatalf("enter record = %+v", enter)
|
|
}
|
|
if enter["lifecycle_token"] != float64(777) || enter["character_id"] != "33" {
|
|
t.Fatalf("enter identity = %+v", enter)
|
|
}
|
|
// 进入记录不该带"离开"专有字段,否则同一个字段在两类记录里含义不同。
|
|
if _, present := enter["exit_reason"]; present {
|
|
t.Fatalf("enter record carries exit fields: %+v", enter)
|
|
}
|
|
}
|
|
|
|
// TestRecordCeraSpendFields 覆盖点券消费记录的公共字段(商城与金库升级两条路
|
|
// 都经过 recordCeraSpend)。
|
|
func TestRecordCeraSpendFields(t *testing.T) {
|
|
recorder, _ := newTestRecorder(t)
|
|
service := &Service{}
|
|
service.records.Store(recorder)
|
|
session := &gameSession{accountID: "dnf:5"}
|
|
session.selectedCharacterID = 88
|
|
|
|
service.recordAlignedCeraSpend(session, alignedcmd.Result{
|
|
Operation: "upgrade_account_cargo",
|
|
CeraSpent: 2000,
|
|
CeraAfter: 1000,
|
|
CeraSpentNote: "金库升级(账号金库升到 16 阶段)",
|
|
})
|
|
// 没有消费的命令不得留下记录。
|
|
service.recordAlignedCeraSpend(session, alignedcmd.Result{Operation: "deposit_money", CeraAfter: 3000})
|
|
|
|
records, _, err := recorder.queryRecords(recordQuery{Kind: recordKindCera})
|
|
if err != nil {
|
|
t.Fatalf("query: %v", err)
|
|
}
|
|
if len(records) != 1 {
|
|
t.Fatalf("record count = %d, want 1", len(records))
|
|
}
|
|
record := records[0]
|
|
if record["source"] != "aligned_command" || record["operation"] != "upgrade_account_cargo" {
|
|
t.Fatalf("record = %+v", record)
|
|
}
|
|
if record["cera_spent"] != float64(2000) || record["cera_before"] != float64(3000) || record["cera_after"] != float64(1000) {
|
|
t.Fatalf("cera fields = %+v", record)
|
|
}
|
|
if record["character_id"] != "88" || record["account_id"] != "dnf:5" {
|
|
t.Fatalf("identity fields = %+v", record)
|
|
}
|
|
}
|