Files
90CNServer/server/internal/services/dnfbridge/attendance_marble_test.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

371 lines
14 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/onlineevent"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestParseAttendanceMarbleCatalogRequiresRealBoardShape(t *testing.T) {
documentText := "[reward postal day]\n15\n[/reward postal day]\n[reward clear]\n4 2 8 2 12 2\n[/reward clear]\n[accumulate race info]\n4 490008906 1 1591 1593 8 490008907 1 1591 1594 12 490008908 1 1591 1595\n[/accumulate race info]\n"
for index := 0; index < attendanceMarbleBoardSize; index++ {
documentText += fmt.Sprintf("[slot]\n[index]\n%d\n[type]\n1\n[item]\n490008905 1\n[/slot]\n", index)
}
document, err := parsePVFForTest(documentText)
if err != nil {
t.Fatal(err)
}
catalog, err := parseAttendanceMarbleCatalog(document)
if err != nil {
t.Fatal(err)
}
if len(catalog.StageRewards) != 3 || catalog.Slots[27].ItemID != 490008905 {
t.Fatalf("catalog=%+v", catalog)
}
}
func TestAttendanceMarbleProtocolBodiesHaveVerifiedWidths(t *testing.T) {
state := attendanceMarbleState{Position: 27, Round: 12, Turns: 8, DiceUsed: 2, Double: 1, CalendarDay: 20260809}
info := buildAttendanceMarbleInfoBody(state)
if len(info) != 16 || binary.LittleEndian.Uint16(info[:2]) != 0 || binary.LittleEndian.Uint16(info[2:]) != 8 || binary.LittleEndian.Uint16(info[4:]) != 6 || binary.LittleEndian.Uint16(info[6:]) != 27 || binary.LittleEndian.Uint32(info[8:]) != 0 || binary.LittleEndian.Uint32(info[12:]) != 12 {
t.Fatalf("info body=%x", info)
}
dice := buildAttendanceMarbleDiceBody([2]byte{6, 3}, 1, 12)
if len(dice) != 8 || dice[0] != 6 || dice[1] != 3 || binary.LittleEndian.Uint16(dice[2:]) != 1 || binary.LittleEndian.Uint32(dice[4:]) != 12 {
t.Fatalf("dice body=%x", dice)
}
if err := parseAttendanceMarbleRequestInfo(nil); err != nil {
t.Fatal(err)
}
if _, err := parseAttendanceMarbleRequestDice([]byte{1}); err != nil {
t.Fatal(err)
}
if _, err := parseAttendanceMarbleRequestDice(nil); err == nil {
t.Fatal("empty dice request accepted")
}
}
func TestAttendanceMarbleFreshLoginDoesNotMarkAllLapsCompleted(t *testing.T) {
body := buildAttendanceMarbleInfoBody(attendanceMarbleState{CalendarDay: 20260912, Turns: 2})
if binary.LittleEndian.Uint16(body[2:]) != 2 || binary.LittleEndian.Uint16(body[4:]) != 2 || binary.LittleEndian.Uint32(body[12:]) != 0 {
t.Fatalf("fresh login needs two earned/remaining turns and zero laps: %x", body)
}
}
func TestAttendanceMarbleNumericBuffEffects(t *testing.T) {
mask := byte(2)
if got := currentAttendanceMarbleMonsterExperienceBonus(mask, 200); got != 200 {
t.Fatalf("monster buff 30 bonus=%d want=200", got)
}
mask |= 8
if got := currentAttendanceMarbleMonsterExperienceBonus(mask, 200); got != 300 {
t.Fatalf("monster buffs 30+32 bonus=%d want=300", got)
}
if got := currentAttendanceMarbleClearExperienceBonus(mask, 200); got != 200 {
t.Fatalf("clear buff 32 bonus=%d want=200", got)
}
mask |= 4
if got := currentAttendanceMarbleClearExperienceBonus(mask, 200); got != 400 {
t.Fatalf("clear buffs 31+32 bonus=%d want=400", got)
}
if got := currentAttendanceMarbleMonsterExperienceBonus(0, 200); got != 0 {
t.Fatalf("inactive monster bonus=%d want=0", got)
}
}
func TestParseAttendanceMarbleCatalogAgainstRuntimePVF(t *testing.T) {
path, err := filepath.Abs(filepath.Join("..", "..", "..", "..", "runtime", "data", "dnf", "Script.pvf"))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Skipf("runtime PVF unavailable: %v", err)
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: path})
if err != nil {
t.Fatal(err)
}
text, err := archive.ReadText(attendanceMarblePVFPath)
if err != nil {
t.Fatal(err)
}
document, err := dnfpvf.Parse(attendanceMarblePVFPath, text)
if err != nil {
t.Fatal(err)
}
catalog, err := parseAttendanceMarbleCatalog(document)
if err != nil {
t.Fatal(err)
}
if len(catalog.StageRewards) != 3 || catalog.Slots[7].Type != 3 || catalog.Slots[14].Type != 5 {
t.Fatalf("runtime attendance catalog shape=%+v", catalog)
}
}
func parsePVFForTest(text string) (*dnfpvf.Document, error) {
return dnfpvf.Parse("event/attendancemarble.evt", text)
}
func TestAttendanceMarbleNativeHandlersAndMail(t *testing.T) {
for _, legacy := range []bool{false, true} {
t.Run(fmt.Sprint("legacy=", legacy), func(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "7", AccountID: "a"}); err != nil {
t.Fatal(err)
}
queue := newFakeCurrentDungeonDeathTimerQueue()
queue.now = time.Date(2026, 9, 12, 12, 0, 0, 0, onlineevent.ChinaCalendar)
c := &attendanceMarbleCatalog{ClearRewards: [3][2]int64{{4, 2}, {8, 2}, {12, 2}}, PostalDays: 15}
for i := range c.Slots {
c.Slots[i] = onlineevent.MarbleSlot{Index: i, Type: 1, ItemID: 100, ItemCount: 1}
}
service := &Service{gameplayTimers: queue, attendanceMarbleCatalog: c, repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
pvfItemCatalog: &pvfDungeonDropCatalog{source: bridgePVFSource{}, itemCache: map[uint32]dungeonDropItemDefinition{100: {ItemID: 100, Kind: dungeonDropItemStackable}}}}
conn := &bufferConn{}
session := &gameSession{conn: conn, accountID: "a", selectedCharacterID: 7}
module := attendanceMarbleGameplayModule()
handlers := module.UpperHandlers
if legacy {
handlers = module.LegacyHandlers
}
invoke := func(op uint16, body []byte) {
t.Helper()
conn.write.Reset()
if err := handlers[op](service, session, gameplayRequest{Opcode: op, Classification: 1, Body: body}); err != nil {
t.Fatal(err)
}
}
invoke(629, nil)
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 || packet.Header.MsgID != 629 || packet.Header.Classification != 1 || len(packet.Body) != 17 || packet.Body[0] != 1 || binary.LittleEndian.Uint16(packet.Body[3:]) != 2 || binary.LittleEndian.Uint16(packet.Body[5:]) != 2 || binary.LittleEndian.Uint32(packet.Body[13:]) != 0 {
t.Fatalf("fresh native body=%x", packet.Body)
}
invoke(630, []byte{0})
packet, rest = splitGameServerUpperPacket(t, conn.write.Bytes())
if packet.Header.MsgID != 630 || len(packet.Body) != 9 || packet.Body[0] != 1 || packet.Body[1] < 1 || packet.Body[1] > 6 || packet.Body[2] < 1 || packet.Body[2] > 6 || binary.LittleEndian.Uint16(packet.Body[3:]) != 1 || binary.LittleEndian.Uint32(packet.Body[5:]) != 0 {
t.Fatalf("dice native body=%x", packet.Body)
}
for len(rest) > 0 {
p, r := splitGameServerUpperPacket(t, rest)
rest = r
if p.Header.MsgID == 629 {
t.Fatal("info interrupted native dice animation")
}
}
ch, _, _ := repos.Character.Load(ctx, "7")
if ch.Stats["attendance_marble_position"] != int64(packet.Body[1]+packet.Body[2]) {
t.Fatal("server and client board position differ")
}
mailbox, _, _ := repos.Mailbox.Load(ctx, "7")
if len(mailbox.Mails) != 1 {
t.Fatalf("mailbox=%+v", mailbox)
}
for _, m := range mailbox.Mails {
if len(m.Attachments) != 1 || m.Attachments[0].ItemID != 100 || m.Metadata["unlimited"] == "true" || !m.ExpireAt.Equal(queue.now.Add(15*24*time.Hour)) {
t.Fatalf("reward mail=%+v", m)
}
}
invoke(641, nil)
if conn.write.Len() != 0 {
t.Fatal("unrecognized same-op 641 response")
}
ch, _, _ = repos.Character.Load(ctx, "7")
if ch.Stats["attendance_marble_double"] != 0 {
t.Fatal("double exit not saved")
}
ch.Stats["attendance_marble_dice_used"] = 2
if err := repos.Character.Save(ctx, ch); err != nil {
t.Fatal(err)
}
invoke(630, []byte{0})
packet, rest = splitGameServerUpperPacket(t, conn.write.Bytes())
if packet.Header.MsgID != 630 || len(packet.Body) != 2 || packet.Body[0] != 0 {
t.Fatalf("spent ack=%x", packet.Body)
}
packet, rest = splitGameServerUpperPacket(t, rest)
if packet.Header.MsgID != 629 || packet.Body[0] != 1 || binary.LittleEndian.Uint16(packet.Body[5:]) != 0 || len(rest) != 0 {
t.Fatal("failed roll did not restore board state")
}
mailbox, _, _ = repos.Mailbox.Load(ctx, "7")
if len(mailbox.Mails) != 1 {
t.Fatal("rejected roll delivered reward")
}
})
}
}
func TestAttendanceMarbleAllRuntimeRewardsDeliver(t *testing.T) {
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if path == "" {
t.Skip("real PVF not requested")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
repos := memory.NewMemoryGroup()
queue := newFakeCurrentDungeonDeathTimerQueue()
queue.now = time.Date(2026, 9, 12, 12, 0, 0, 0, onlineevent.ChinaCalendar)
service := &Service{initialEquipmentArchive: archive, gameplayTimers: queue, repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true }}
c, err := service.currentAttendanceMarbleCatalog()
if err != nil {
t.Fatal(err)
}
buffText, err := archive.ReadText("etc/variousbufflist.etc")
if err != nil {
t.Fatal(err)
}
buffDoc, err := dnfpvf.Parse("etc/variousbufflist.etc", buffText)
if err != nil {
t.Fatal(err)
}
index := int64(0)
name := ""
effect := false
seen := map[int64]int{}
for _, section := range buffDoc.Sections {
tokens := buffDoc.Tokens[section.Start:section.End]
if section.Name == "index" && len(tokens) == 1 {
index = tokens[0].Int
}
if index < 29 || index > 32 {
continue
}
switch section.Name {
case "effect":
effect = true
case "/effect":
effect = false
case "name", "type":
if len(tokens) == 1 {
name = tokens[0].Value
}
case "param":
want := []int64{1, 0}
if effect {
switch name {
case "unique_rarity_item_drop_rate":
want = []int64{3, onlineevent.MarbleUniqueBonusPercent}
case "hunter_exp_rate":
want = []int64{100}
if index == 32 {
want[0] = 50
}
case "dungeon_clear_exp_rate":
want = []int64{100}
default:
t.Fatalf("unexpected marble effect %s", name)
}
} else if name != "dungeon_one_match" {
t.Fatalf("buff %d lifecycle %s", index, name)
}
if len(tokens) != len(want) {
t.Fatal("buff parameter count differs")
}
for i, v := range want {
if tokens[i].Int != v {
t.Fatalf("buff %d %s parameter %d=%d want %d", index, name, i, tokens[i].Int, v)
}
}
seen[index]++
}
}
if len(seen) != 4 || seen[29] != 2 || seen[30] != 2 || seen[31] != 2 || seen[32] != 3 {
t.Fatalf("missing buff definitions: %v", seen)
}
session := &gameSession{accountID: "a", selectedCharacterID: 7}
for i, slot := range c.Slots {
ch := dnfrepo.CharacterRecord{CharacterID: "7", AccountID: "a", Stats: map[string]int64{
"gold": 10000, onlineevent.MarbleDayKey: onlineevent.MarbleDay(queue.now), "attendance_marble_position": int64((i + 25) % 28), "attendance_marble_serial": int64(i),
}}
if err := repos.Character.Save(ctx, ch); err != nil {
t.Fatal(err)
}
s, err := service.settleAttendanceMarble(session, &[2]byte{1, 2}, false)
if err != nil {
t.Fatalf("real slot %d: %v", i, err)
}
if slot.Type == 3 {
slot = c.Slots[slot.Goto]
}
if s.Position != int64(slot.Index) {
t.Fatalf("slot %d position=%d", i, s.Position)
}
if slot.Type == 1 || slot.Type == 5 {
mailbox, _, _ := repos.Mailbox.Load(ctx, "7")
m := mailbox.Mails[s.MailIDs[0]]
if m.Attachments[0].ItemID != slot.ItemID || m.Attachments[0].Count != slot.ItemCount {
t.Fatalf("slot %d reward=%+v", i, m)
}
}
}
ch, _, _ := repos.Character.Load(ctx, "7")
ch.Stats["attendance_marble_round"] = 12
ch.Stats["attendance_marble_dice_used"] = 0
if err := repos.Character.Save(ctx, ch); err != nil {
t.Fatal(err)
}
s, err := service.settleAttendanceMarble(session, &[2]byte{1, 2}, false)
if err != nil || len(s.MailIDs) != 4 || s.Reward4 != 1 || s.Reward8 != 1 || s.Reward12 != 1 {
t.Fatalf("real cumulative rewards: %+v %v", s, err)
}
}
func TestAttendanceMarbleBuffConsumedOnceForWholeDungeon(t *testing.T) {
s, runtime, repos := prepareCurrentDungeonMonsterExperienceTest(t)
ctx := context.Background()
runtime.Character.Stats[attendanceMarbleBuffKey(30)] = 1
if err := repos.Character.Save(ctx, runtime.Character); err != nil {
t.Fatal(err)
}
session := &gameSession{conn: &bufferConn{}, accountID: runtime.Character.AccountID, selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: runtime}}
if err := s.sendAttendanceMarbleBuff(session, 30, 2); err != nil {
t.Fatal(err)
}
remove, rest := splitGameServerUpperPacket(t, session.conn.(*bufferConn).write.Bytes())
add, rest := splitGameServerUpperPacket(t, rest)
if remove.Header.MsgID != 488 || add.Header.MsgID != 487 || len(add.Body) != 17 || binary.LittleEndian.Uint32(add.Body[1:]) != 30 || binary.LittleEndian.Uint32(add.Body[5:]) != 2 || len(rest) != 0 {
t.Fatal("native pending-dungeon count differs")
}
if err := s.activateAttendanceMarbleDungeonBuffs(session, runtime); err != nil {
t.Fatal(err)
}
if runtime.attendanceMarbleBuffs != 2 || !runtime.attendanceMarbleBuffsLoaded {
t.Fatal("entry did not activate pending buff")
}
ch, _, _ := repos.Character.Load(ctx, "99")
if ch.Stats[attendanceMarbleBuffKey(30)] != 0 {
t.Fatal("pending buff not consumed")
}
ch.Stats[attendanceMarbleBuffKey(32)] = 1
if err := repos.Character.Save(ctx, ch); err != nil {
t.Fatal(err)
}
if err := s.activateAttendanceMarbleDungeonBuffs(session, runtime); err != nil {
t.Fatal(err)
}
if runtime.attendanceMarbleBuffs != 2 {
t.Fatal("room revisit replaced active buffs")
}
next := &runtimeDungeonState{Character: ch}
if err := s.activateAttendanceMarbleDungeonBuffs(session, next); err != nil {
t.Fatal(err)
}
if next.attendanceMarbleBuffs != 8 {
t.Fatalf("next run buff %d", next.attendanceMarbleBuffs)
}
}