服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
302 lines
13 KiB
Go
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])
|
|
}
|
|
})
|
|
}
|
|
}
|