服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
250 lines
8.3 KiB
Go
250 lines
8.3 KiB
Go
package pet
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
)
|
|
|
|
func TestDecodeHatchCreatureRequestExactCurrentBody(t *testing.T) {
|
|
got, err := DecodeHatchCreatureRequest([]byte{listTypePet, 0x05, 0x00})
|
|
if err != nil {
|
|
t.Fatalf("DecodeHatchCreatureRequest: %v", err)
|
|
}
|
|
if got.ListType != listTypePet || got.SlotIndex != 5 {
|
|
t.Fatalf("got = %+v", got)
|
|
}
|
|
for _, body := range [][]byte{
|
|
{listTypePet, 5},
|
|
{listTypePet, 5, 0, 1},
|
|
{0, 5, 0},
|
|
{listTypePet, 140, 0},
|
|
} {
|
|
if _, err := DecodeHatchCreatureRequest(body); err == nil {
|
|
t.Fatalf("body % X unexpectedly accepted", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildCreatureListBodyUsesTypedFieldsNotRawReplay(t *testing.T) {
|
|
entry := dnfrepo.PetEntry{
|
|
PetKey: "123",
|
|
CreatureKey: 123,
|
|
ItemID: 63000,
|
|
NameRaw: []byte("abc"),
|
|
Satiety: 100,
|
|
Level: 3,
|
|
Exp: 12,
|
|
TailFlag: 1,
|
|
RawEntry: []byte{0xDE, 0xAD, 0xBE, 0xEF},
|
|
}
|
|
body, err := buildCreatureListBody([]dnfrepo.PetEntry{entry})
|
|
if err != nil {
|
|
t.Fatalf("buildCreatureListBody: %v", err)
|
|
}
|
|
want := []byte{
|
|
1,
|
|
123, 0, 0, 0,
|
|
100,
|
|
0,
|
|
12, 0, 0, 0,
|
|
3,
|
|
3, 0, 0, 0, 'a', 'b', 'c',
|
|
1,
|
|
}
|
|
if string(body) != string(want) {
|
|
t.Fatalf("body = % X\nwant = % X", body, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildCreatureListBodyModeOneAndValidation(t *testing.T) {
|
|
body, err := buildCreatureListBody([]dnfrepo.PetEntry{{
|
|
PetKey: "9",
|
|
CreatureKey: 9,
|
|
ItemID: 63000,
|
|
Satiety: 7,
|
|
ModeFlag: 1,
|
|
Mode1Field0A: 2,
|
|
Mode1Field0B: 3,
|
|
Level: 4,
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("build mode1: %v", err)
|
|
}
|
|
if len(body) != 19 || body[6] != 1 || body[11] != 2 || body[12] != 3 || body[13] != 4 {
|
|
t.Fatalf("mode1 body = % X", body)
|
|
}
|
|
invalid := []dnfrepo.PetEntry{{PetKey: "not-numeric", ItemID: 1, Satiety: 1, Level: 1}}
|
|
if _, err := buildCreatureListBody(invalid); err == nil {
|
|
t.Fatal("invalid key unexpectedly encoded")
|
|
}
|
|
}
|
|
|
|
func TestBuildCreatureListBodyEnforcesCurrentReaderExclusiveNameLimit(t *testing.T) {
|
|
accepted := dnfrepo.PetEntry{
|
|
PetKey: "9",
|
|
CreatureKey: 9,
|
|
ItemID: 63000,
|
|
NameRaw: []byte(strings.Repeat("a", 29)),
|
|
Level: 1,
|
|
}
|
|
if _, err := buildCreatureListBody([]dnfrepo.PetEntry{accepted}); err != nil {
|
|
t.Fatalf("29-byte name rejected: %v", err)
|
|
}
|
|
accepted.NameRaw = []byte(strings.Repeat("b", 30))
|
|
if _, err := buildCreatureListBody([]dnfrepo.PetEntry{accepted}); !errors.Is(err, errCreatureNameTooLong) {
|
|
t.Fatalf("30-byte name error = %v, want %v", err, errCreatureNameTooLong)
|
|
}
|
|
}
|
|
|
|
func TestPetHatchResponsesUseCurrentItemRowAndTypedCreatureList(t *testing.T) {
|
|
responses := petHatchResponses(uint16(dnfenum.CmdPacketHatchCreature), HatchResult{
|
|
CharacterID: "61",
|
|
PetKey: "123",
|
|
ItemID: 63000,
|
|
Changed: true,
|
|
PetInventory: map[string]dnfrepo.ItemStack{
|
|
"7:5": {ItemID: 63000, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "123"}},
|
|
},
|
|
EntryCount: 1,
|
|
Entries: []dnfrepo.PetEntry{{PetKey: "123", CreatureKey: 123, ItemID: 63000, Satiety: 100, Level: 1}},
|
|
})
|
|
if len(responses) != 3 {
|
|
t.Fatalf("response count = %d", len(responses))
|
|
}
|
|
if ack := responses[0]; ack.MsgID != uint16(dnfenum.CmdPacketHatchCreature) || ack.Classification != dnfproto.DefaultChannelClassification || string(ack.Body) != string([]byte{1}) {
|
|
t.Fatalf("ack = %+v", ack)
|
|
}
|
|
refresh := responses[1]
|
|
if refresh.MsgID != 0x000D || refresh.Classification != 0 || len(refresh.Body) != 3+0x77 || refresh.Body[0] != listTypePet || binary.LittleEndian.Uint16(refresh.Body[1:3]) != 1 {
|
|
t.Fatalf("refresh header/body = %+v % X", refresh, refresh.Body)
|
|
}
|
|
row := refresh.Body[3:]
|
|
if binary.LittleEndian.Uint16(row[0:2]) != 5 || binary.LittleEndian.Uint32(row[2:6]) != 63000 || binary.LittleEndian.Uint32(row[6:10]) != 123 {
|
|
t.Fatalf("refresh row = % X", row)
|
|
}
|
|
if list := responses[2]; list.MsgID != 0x0069 || list.Classification != 0 || len(list.Body) != 17 || list.Body[0] != 1 || binary.LittleEndian.Uint32(list.Body[1:5]) != 123 {
|
|
t.Fatalf("creature list = %+v body=% X", list, list.Body)
|
|
}
|
|
}
|
|
|
|
func TestHandlerBlocksOldOp173C2SHatchRoute(t *testing.T) {
|
|
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketHatchCreatureEgg),
|
|
Body: []byte{listTypePet, 5, 0},
|
|
SelectedCharacterID: 61,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
if !got.Handled || got.ResponseAllowed || !strings.Contains(got.Reason, "op173") {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerHatchUsesInjectedRuntimePVFResolver(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "61",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"7:5": {ItemID: 63006, Count: 1},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "61", Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
|
|
t.Fatalf("save equipment: %v", err)
|
|
}
|
|
|
|
resolverCalls := 0
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketHatchCreature),
|
|
Body: []byte{listTypePet, 5, 0},
|
|
SelectedCharacterID: 61,
|
|
Repositories: repos,
|
|
PetHatchResolver: func(itemID int64) (alignedcmd.PetHatchResolution, error) {
|
|
resolverCalls++
|
|
if itemID != 63006 {
|
|
t.Fatalf("resolver item = %d", itemID)
|
|
}
|
|
return alignedcmd.PetHatchResolution{EggItemID: 63006, HatchedItemID: 63000, EggPVFPath: "creature/egg.equ", HatchedPVFPath: "creature/pet.equ", MinimumLevel: 1}, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
if resolverCalls != 1 || !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 3 {
|
|
t.Fatalf("resolver_calls=%d result=%+v", resolverCalls, got)
|
|
}
|
|
inventory, found, err := repos.Inventory.Load(ctx, "61")
|
|
if err != nil || !found {
|
|
t.Fatalf("load inventory found=%t err=%v", found, err)
|
|
}
|
|
if stack := inventory.Slots["7:5"]; stack.ItemID != 63000 || stack.Count != 1 {
|
|
t.Fatalf("hatched stack = %+v", stack)
|
|
}
|
|
petRecord, found, err := repos.Pet.Load(ctx, "61")
|
|
if err != nil || !found || len(petRecord.Entries) != 1 {
|
|
t.Fatalf("load pet found=%t entries=%d err=%v", found, len(petRecord.Entries), err)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRequestHatchedCreatureReturnsTypedList(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
|
CharacterID: "66",
|
|
Entries: map[string]dnfrepo.PetEntry{
|
|
"123": {PetKey: "123", CreatureKey: 123, ItemID: 63000, Satiety: 100, Level: 2, Exp: 7, NameRaw: []byte("pet")},
|
|
},
|
|
EquippedKey: "123",
|
|
TownDisplay: true,
|
|
}); err != nil {
|
|
t.Fatalf("save pet: %v", err)
|
|
}
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketRequestHatchedCreature),
|
|
SelectedCharacterID: 66,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRequestHatchedCreatureReturnsEmptyTypedList(t *testing.T) {
|
|
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketRequestHatchedCreature),
|
|
SelectedCharacterID: 66,
|
|
Repositories: dnfrepomemory.NewMemoryGroup(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
|
|
t.Fatalf("result = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestHatchCommandContainsNoClientItemCandidate(t *testing.T) {
|
|
cmd := NewHatchCommand(alignedcmd.Request{AccountID: " acc ", SelectedCharacterID: 70}, "hatch_creature", HatchCreatureRequest{ListType: listTypePet, SlotIndex: 3})
|
|
if cmd.AccountID != "acc" || cmd.SelectedCharacterID != 70 || cmd.ListType != listTypePet || cmd.SlotIndex != 3 {
|
|
t.Fatalf("cmd = %+v", cmd)
|
|
}
|
|
if strings.Contains(cmd.String(), "candidate") || !strings.Contains(cmd.String(), "runtime PVF") {
|
|
t.Fatalf("command summary = %s", cmd.String())
|
|
}
|
|
}
|