Files
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

302 lines
13 KiB
Go

package inventory
import (
"context"
"encoding/binary"
"strconv"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func handleEnchantRequest(t *testing.T, repos dnfrepo.Group, resolver alignedcmd.EnchantBeadResolver, body []byte) alignedcmd.Result {
t.Helper()
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketEnchantByBead),
Body: body,
AccountID: "acc",
SelectedCharacterID: 77,
Repositories: repos,
EnchantBeadResolver: resolver,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
return got
}
func enchantRequestBody(beadList byte, beadSlot int16, targetList byte, targetSlot int16) []byte {
return []byte{
beadList,
byte(beadSlot), byte(beadSlot >> 8),
targetList,
byte(targetSlot), byte(targetSlot >> 8),
}
}
func TestHandlerEnchantByBeadSendsRefreshThenAck(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveEnchantFixture(t, ctx, repos, 2, 0, 0)
got := handleEnchantRequest(t, repos, coatEnchantResolver(9001, nil), enchantRequestBody(listTypeMain, 12, listTypeMain, 30))
if !got.Handled || !got.ResponseAllowed {
t.Fatalf("result = %+v", got)
}
if len(got.UpperResponses) != 2 {
t.Fatalf("responses = %d, want refresh then ack", len(got.UpperResponses))
}
refresh := got.UpperResponses[0]
if refresh.MsgID != msgItemListRefresh || refresh.Classification != 0 {
t.Fatalf("first response = msg 0x%04X class %d, want class0 0x000D refresh", refresh.MsgID, refresh.Classification)
}
ack := got.UpperResponses[1]
if ack.MsgID != uint16(dnfenum.CmdPacketEnchantByBead) {
t.Fatalf("ack msg = 0x%04X, want 0x0110", ack.MsgID)
}
wantAck := []byte{1, listTypeMain, 30, 0}
if string(ack.Body) != string(wantAck) {
t.Fatalf("ack body = %x, want %x", ack.Body, wantAck)
}
if len(got.PostActions) != 0 {
t.Fatalf("post actions = %v, want no duplicate refresh after ACK", got.PostActions)
}
}
func TestHandlerEnchantPetCreatureSendsTargetAndBeadUpdatesBeforeAck(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
savePetEnchantFixture(t, ctx, repos, 1)
got := handleEnchantRequest(t, repos, creatureEnchantResolver(10008663, 400990168), enchantRequestBody(listTypeMain, 12, listTypePet, 24))
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 3 {
t.Fatalf("result = %+v", got)
}
petUpdate := got.UpperResponses[0]
if petUpdate.Classification != 0 || petUpdate.MsgID != msgItemListUpdate || len(petUpdate.Body) != 3+currentItemListEntrySize || petUpdate.Body[0] != listTypePet {
t.Fatalf("pet update = class%d msg0x%04X body=%x", petUpdate.Classification, petUpdate.MsgID, petUpdate.Body)
}
petRow := petUpdate.Body[3:]
if gotCard := uint32(petRow[0x0E]) | uint32(petRow[0x0F])<<8 | uint32(petRow[0x10])<<16 | uint32(petRow[0x11])<<24; gotCard != 10008663 {
t.Fatalf("pet update card=%d want=10008663", gotCard)
}
beadUpdate := got.UpperResponses[1]
if beadUpdate.Classification != 0 || beadUpdate.MsgID != msgItemListUpdate || len(beadUpdate.Body) != 3+currentItemListEntrySize || beadUpdate.Body[0] != listTypeMain {
t.Fatalf("bead update = class%d msg0x%04X body=%x", beadUpdate.Classification, beadUpdate.MsgID, beadUpdate.Body)
}
beadRow := beadUpdate.Body[3:]
if beadRow[0x02] != 0xFF || beadRow[0x03] != 0xFF || beadRow[0x04] != 0xFF || beadRow[0x05] != 0xFF {
t.Fatalf("consumed bead row did not carry empty item marker: %x", beadRow[:10])
}
ack := got.UpperResponses[2]
if ack.MsgID != uint16(dnfenum.CmdPacketEnchantByBead) || string(ack.Body) != string([]byte{1, listTypePet, 24, 0}) {
t.Fatalf("ack = msg0x%04X body=%x", ack.MsgID, ack.Body)
}
}
func TestHandlerEnchantEquippedPetCreatureSendsBeadUpdateAckAndEquipmentRefresh(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
const petItemID int64 = 400990167
const petSerial uint32 = 41
beadRaw := make([]byte, currentItemListEntrySize)
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 12): {ItemID: 490007163, Count: 1, RawEntry: beadRaw, Extra: map[string]string{"item_kind": "stackable"}},
}}); err != nil {
t.Fatal(err)
}
raw := make([]byte, 43)
raw[0] = 26
binary.LittleEndian.PutUint32(raw[1:5], uint32(petItemID))
binary.LittleEndian.PutUint32(raw[24:28], petSerial)
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{"26": {SlotIndex: 26, ItemID: petItemID, RawEntry: raw, Extra: map[string]string{"item_kind": "equipment", "equipment_type": "creature"}}}}); err != nil {
t.Fatal(err)
}
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{CharacterID: "77", EquippedKey: "41", Entries: map[string]dnfrepo.PetEntry{"41": {PetKey: "41", CreatureKey: petSerial, ItemID: petItemID, SourceListType: listTypeEquipment, SourceSlotIndex: 26}}}); err != nil {
t.Fatal(err)
}
got := handleEnchantRequest(t, repos, creatureEnchantResolver(10008713, petItemID), enchantRequestBody(listTypeMain, 12, listTypeEquipment, 26))
if len(got.UpperResponses) != 2 || len(got.PostActions) != 1 || got.PostActions[0] != alignedcmd.PostActionRefreshSelectedEquippedCreatureItem {
t.Fatalf("result = %+v", got)
}
if got.UpperResponses[0].MsgID != msgItemListUpdate || got.UpperResponses[0].Body[0] != listTypeMain {
t.Fatalf("bead update = %+v", got.UpperResponses[0])
}
if got.UpperResponses[1].MsgID != uint16(dnfenum.CmdPacketEnchantByBead) || string(got.UpperResponses[1].Body) != string([]byte{1, listTypeEquipment, 26, 0}) {
t.Fatalf("ack = %+v", got.UpperResponses[1])
}
}
func TestHandlerEnchantByBeadErrorAckOnly(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveEnchantFixture(t, ctx, repos, 2, 0, 0)
got := handleEnchantRequest(t, repos, coatEnchantResolver(0, nil), enchantRequestBody(listTypeMain, 12, listTypeMain, 30))
if !got.Handled || !got.ResponseAllowed {
t.Fatalf("result = %+v", got)
}
if len(got.UpperResponses) != 1 {
t.Fatalf("responses = %d, want single error ack", len(got.UpperResponses))
}
ack := got.UpperResponses[0]
if ack.MsgID != uint16(dnfenum.CmdPacketEnchantByBead) {
t.Fatalf("ack msg = 0x%04X, want 0x0110", ack.MsgID)
}
if string(ack.Body) != string([]byte{0, enchantErrorInvalidBead}) {
t.Fatalf("ack body = %x, want {0,0x11}", ack.Body)
}
}
func TestHandlerEnchantByBeadParseFailureAcksInvalidBead(t *testing.T) {
repos := dnfrepomemory.NewMemoryGroup()
got := handleEnchantRequest(t, repos, coatEnchantResolver(9001, nil), []byte{1, 2})
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
t.Fatalf("result = %+v", got)
}
if string(got.UpperResponses[0].Body) != string([]byte{0, enchantErrorInvalidBead}) {
t.Fatalf("ack body = %x, want {0,0x11}", got.UpperResponses[0].Body)
}
}
func TestHandlerEnchantByBeadNilResolverFailsClosed(t *testing.T) {
repos := dnfrepomemory.NewMemoryGroup()
got := handleEnchantRequest(t, repos, nil, enchantRequestBody(listTypeMain, 12, listTypeMain, 30))
if !got.Handled {
t.Fatalf("result = %+v", got)
}
if got.ResponseAllowed || len(got.UpperResponses) != 0 {
t.Fatalf("nil resolver must fail closed without any response: %+v", got)
}
}
// currentEquipmentQualityRow builds one complete current 0x77 equipment row:
// the PVF item id at +2 and the durable quality seed at +6, which is the word
// the client reads the displayed grade (鍝佺骇) from.
func currentEquipmentQualityRow(slot int16, itemID int64, seed uint32) []byte {
raw := make([]byte, currentItemListEntrySize)
binary.LittleEndian.PutUint16(raw[0:2], uint16(slot))
binary.LittleEndian.PutUint32(raw[2:6], uint32(itemID))
binary.LittleEndian.PutUint32(raw[6:10], seed)
return raw
}
// currentEquipmentQualityStack models a durable equipment row. carryExtraSeed
// selects the two shapes that exist in the database: a row created by the drop
// and reward paths (quality_seed mirrored into Extra) and an imported row whose
// grade lives only in its stored 0x77 row.
func currentEquipmentQualityStack(slot int16, itemID int64, seed uint32, carryExtraSeed bool) dnfrepo.ItemStack {
extra := map[string]string{
"item_kind": "equipment",
"pvf_path": "equipment/character/common/coat/test_coat.equ",
"equipment_type": "[coat]",
}
if carryExtraSeed {
extra["quality_seed"] = strconv.FormatUint(uint64(seed), 10)
}
return dnfrepo.ItemStack{
ItemID: itemID,
Count: 1,
RawEntry: currentEquipmentQualityRow(slot, itemID, seed),
Extra: extra,
}
}
// mainListRefreshRowForSlot returns the single 0x77 row a class0 0x000D refresh
// body carries for one slot.
func mainListRefreshRowForSlot(t *testing.T, body []byte, slot int16) []byte {
t.Helper()
if len(body) < 5 || body[0] != listTypeMain {
t.Fatalf("refresh body = % X", body)
}
count := int(binary.LittleEndian.Uint16(body[3:5]))
if len(body) != 5+count*currentItemListEntrySize {
t.Fatalf("refresh body len=%d rows=%d", len(body), count)
}
for index := 0; index < count; index++ {
row := body[5+index*currentItemListEntrySize : 5+(index+1)*currentItemListEntrySize]
if int16(binary.LittleEndian.Uint16(row[0:2])) == slot {
return row
}
}
t.Fatalf("slot %d missing from a %d-row refresh", slot, count)
return nil
}
// TestHandlerEnchantMainRefreshKeepsEquipmentQualitySeed is the regression guard
// for the live report "enchanting changes the grade of the equipment in the
// bag, and only re-selecting the character restores it".
//
// A bag enchant answers with a full 0x000D main-list refresh, which re-encodes
// every row from the durable inventory. raw+0x06 of an equipment row is its
// quality seed, so encoding the stack count there (equipment always stores
// Count 1) repainted the whole bag as the lowest grade until the client
// re-read the list on the next select-character. The bead row must still carry
// its remaining stack count, which is what that same word means for stackables.
func TestHandlerEnchantMainRefreshKeepsEquipmentQualitySeed(t *testing.T) {
const (
beadSlot int16 = 12
targetSlot int16 = 30
siblingSlot int16 = 31
targetSeed uint32 = 998506240
siblingSeed uint32 = 345678901
beadCount int64 = 5
)
for _, test := range []struct {
name string
carryExtraSeed bool
}{
{name: "grade mirrored into Extra", carryExtraSeed: true},
{name: "grade only in the stored row", carryExtraSeed: false},
} {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
beadRaw := make([]byte, currentItemListEntrySize)
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, beadSlot): {
ItemID: 8801,
Count: beadCount,
RawEntry: beadRaw,
Extra: map[string]string{
"item_kind": "stackable",
"stackable_type": "[etc]",
"pvf_path": "stackable/bead/fire_bead.stk",
},
},
slotKey(listTypeMain, targetSlot): currentEquipmentQualityStack(targetSlot, 700, targetSeed, test.carryExtraSeed),
slotKey(listTypeMain, siblingSlot): currentEquipmentQualityStack(siblingSlot, 701, siblingSeed, test.carryExtraSeed),
},
})
got := handleEnchantRequest(t, repos, coatEnchantResolver(9001, nil),
enchantRequestBody(listTypeMain, beadSlot, listTypeMain, targetSlot))
if len(got.UpperResponses) != 2 || got.UpperResponses[0].MsgID != msgItemListRefresh {
t.Fatalf("result = %+v", got)
}
body := got.UpperResponses[0].Body
target := mainListRefreshRowForSlot(t, body, targetSlot)
if got := binary.LittleEndian.Uint32(target[6:10]); got != targetSeed {
t.Fatalf("enchanted row grade seed=%d want=%d row=% X", got, targetSeed, target[:0x10])
}
// The enchant rewrites one slot's state; every other equipment row
// in the same refresh must keep the grade the character loaded with.
sibling := mainListRefreshRowForSlot(t, body, siblingSlot)
if got := binary.LittleEndian.Uint32(sibling[6:10]); got != siblingSeed {
t.Fatalf("sibling row grade seed=%d want=%d row=% X", got, siblingSeed, sibling[:0x10])
}
// A stackable row still carries its remaining count in that word.
bead := mainListRefreshRowForSlot(t, body, beadSlot)
if got := binary.LittleEndian.Uint32(bead[6:10]); got != uint32(beadCount-1) {
t.Fatalf("bead row count=%d want=%d row=% X", got, beadCount-1, bead[:0x10])
}
})
}
}