DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,769 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"os"
"reflect"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnflimitedcube "longheng.io/server/internal/modules/dnf/limitedcube"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestResolveCurrentLimitedCubePolicyUsesPVFConditionsAndWeights(t *testing.T) {
catalog := newLimitedCubeTestCatalog(t)
policy, recognized, err := resolveCurrentLimitedCubePolicy(catalog, 9000)
if err != nil || !recognized {
t.Fatalf("resolve recognized=%t err=%v", recognized, err)
}
if policy.TicketItemID != 9000 ||
!reflect.DeepEqual(policy.Conditions, []dnflimitedcube.Requirement{{ItemID: 9001, Count: 1}, {ItemID: 9002, Count: 1}}) ||
!reflect.DeepEqual(policy.Materials, []dnflimitedcube.Requirement{{ItemID: 3037, Count: 10}}) ||
len(policy.Results) != 2 || policy.Results[0].Weight != 1000 || policy.Results[1].Weight != 1000 {
t.Fatalf("policy = %+v", policy)
}
if first := policy.Results[0].Stack; first.ItemID != 9001 || first.Count != 1 || first.Extra["item_kind"] != "stackable" ||
first.Extra["pvf_path"] != "stackable/bead/power.stk" || first.Extra["stackable_type"] != "[enchant waste]" {
t.Fatalf("first result stack = %+v", first)
}
for _, itemID := range []int64{9003, 3037, 9999} {
if _, recognized, err := resolveCurrentLimitedCubePolicy(catalog, itemID); err != nil || recognized {
t.Fatalf("item %d recognized=%t err=%v, want non-limited cube", itemID, recognized, err)
}
}
}
func TestRealPVFPetBeadChangeTicketsResolveExactPVFPool(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to validate pet-bead change ticket PVF rules")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
for _, ticketItemID := range []int64{490007246, 490701734} {
policy, recognized, err := resolveCurrentLimitedCubePolicy(catalog, ticketItemID)
if err != nil || !recognized {
t.Fatalf("ticket %d recognized=%t err=%v", ticketItemID, recognized, err)
}
if policy.TicketItemID != ticketItemID || len(policy.Conditions) != 91 ||
!reflect.DeepEqual(policy.Materials, []dnflimitedcube.Requirement{{ItemID: 3037, Count: 10}}) || len(policy.Results) != 91 {
t.Fatalf("ticket %d policy shape = %+v", ticketItemID, policy)
}
for offset := int64(0); offset < 91; offset++ {
wantItemID := int64(490007152) + offset
if condition := policy.Conditions[offset]; condition.ItemID != wantItemID || condition.Count != 1 {
t.Fatalf("ticket %d condition %d = %+v, want item=%d count=1", ticketItemID, offset, condition, wantItemID)
}
if result := policy.Results[offset]; result.Stack.ItemID != wantItemID || result.Stack.Count != 1 || result.Weight != 1000 {
t.Fatalf("ticket %d result %d = %+v, want item=%d count=1 weight=1000", ticketItemID, offset, result, wantItemID)
}
}
}
}
func TestRealPVFTitleChangeCouponsResolveEquipmentPool(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to validate the 称号随机变更券 rules")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
// 490702315 / 490702318 / 490702379 are the three 热舞一夏称号随机变更券
// stackables and 490700080 is 劳动节称号随机变更券. All four are
// `[stackable type] [upgrade limit cube]` tickets whose A-condition,
// B-condition and result items are title equipment, not stackables — the
// family the bridge used to refuse with "result must be a positive stackable
// item" (现场 2026-09-27 15:14:56 op338 item=490702318 result=400330118).
for _, test := range []struct {
ticketItemID int64
titleItemIDs []int64
weights []int64
}{
{490702315, []int64{400330118, 400330119, 400330120, 400330121}, []int64{3000, 3000, 3000, 1000}},
{490702318, []int64{400330118, 400330119, 400330120, 400330121}, []int64{3000, 3000, 3000, 1000}},
{490702379, []int64{400330118, 400330119, 400330120, 400330121}, []int64{3000, 3000, 3000, 1000}},
{490700080, []int64{400330076, 400330077, 400330078}, []int64{4750, 4750, 500}},
} {
policy, recognized, err := resolveCurrentLimitedCubePolicy(catalog, test.ticketItemID)
if err != nil || !recognized {
t.Fatalf("ticket %d recognized=%t err=%v", test.ticketItemID, recognized, err)
}
if policy.TicketItemID != test.ticketItemID ||
!reflect.DeepEqual(policy.Materials, []dnflimitedcube.Requirement{{ItemID: 3037, Count: 10}}) ||
len(policy.Conditions) != len(test.titleItemIDs) || len(policy.Results) != len(test.titleItemIDs) {
t.Fatalf("ticket %d policy shape = %+v", test.ticketItemID, policy)
}
for offset, titleItemID := range test.titleItemIDs {
if condition := policy.Conditions[offset]; condition.ItemID != titleItemID || condition.Count != 1 {
t.Fatalf("ticket %d condition %d = %+v, want item=%d count=1", test.ticketItemID, offset, condition, titleItemID)
}
result := policy.Results[offset]
if result.Stack.ItemID != titleItemID || result.Stack.Count != 1 || result.Weight != test.weights[offset] {
t.Fatalf("ticket %d result %d = %+v, want item=%d count=1 weight=%d",
test.ticketItemID, offset, result, titleItemID, test.weights[offset])
}
if result.Stack.Extra["item_kind"] != "equipment" || result.Stack.Extra["equipment_type"] != "[title name]" ||
!validCurrentEquipmentQualitySeed(sceneInventoryExtraUint32(result.Stack.Extra, "quality_seed")) {
t.Fatalf("ticket %d result %d stack = %+v", test.ticketItemID, offset, result.Stack)
}
}
}
}
func TestRealPVFPetBeadChangeTicketConsumesCrystalWarehouseMaterial(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to validate account crystal material consumption")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
service, session, repositories, connection := newPremiumServiceTestRuntime(t)
service.pvfItemCatalog = catalog
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:95": {ItemID: 490007240, Count: 1},
"0:97": {ItemID: 490701734, Count: 1},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(context.Background(), dnfrepo.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]dnfrepo.ItemStack{
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 100},
},
}); err != nil {
t.Fatal(err)
}
request := make([]byte, currentLimitedCubeUseBodySize)
binary.LittleEndian.PutUint16(request[0:2], 95)
binary.LittleEndian.PutUint32(request[2:6], 490007240)
binary.LittleEndian.PutUint16(request[6:8], 97)
if err := service.handleCurrentLimitedCubeUseRequest(session, request); err != nil {
t.Fatal(err)
}
ack, remaining := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketUseLimitCube) || len(ack.Body) != 12 || ack.Body[0] != 1 {
t.Fatalf("op338 acknowledgement=%+v body=%x", ack.Header, ack.Body)
}
cleared, _ := splitGameServerUpperPacket(t, remaining)
if cleared.Header.Classification != 0 || cleared.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(cleared.Body) != 3+currentItemListEntryWireSize || cleared.Body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(cleared.Body[1:3]) != 1 {
t.Fatalf("target replacement clear=%+v body=%x", cleared.Header, cleared.Body)
}
if got := limitedCubeRefreshRows(t, cleared.Body)[95]; got.itemID != ^uint32(0) || got.count != 0 {
t.Fatalf("target replacement clear row=%+v", got)
}
account, found, err := repositories.AccountInventory.Load(context.Background(), "account-1")
if err != nil || !found {
t.Fatalf("load account inventory found=%t err=%v", found, err)
}
if got := account.Slots["0:358"].Count; got != 90 {
t.Fatalf("account clear cube count=%d want=90", got)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
// 490007240 is one of the 91 equally weighted candidates, so the draw may
// hand it back; the invariant is that the ack and the durable slot agree and
// that the bead is still one member of the ticket's own pool.
bead := inventory.Slots["0:95"]
promptItemID := int64(binary.LittleEndian.Uint32(ack.Body[1:5]))
if bead.ItemID != promptItemID || bead.Count != 1 || bead.Extra["source"] != "limited_cube_result" {
t.Fatalf("changed real-PVF bead = %+v, want item=%d count=1", bead, promptItemID)
}
if bead.ItemID < 490007152 || bead.ItemID > 490007152+90 {
t.Fatalf("changed real-PVF bead item=%d is outside the ticket's own pool", bead.ItemID)
}
}
func TestRealPVFTitleChangeCouponOp338TransformsWornBagTitle(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to validate the 热舞一夏称号随机变更券 op338 path")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
service, session, repositories, connection := newPremiumServiceTestRuntime(t)
service.pvfItemCatalog = catalog
// Reproduces the live 2026-09-27 attempt: the 2018热舞一夏自选礼盒 reward
// 400330118 热浪舞者 landed in bag slot 18, the ticket 490702318 sat in slot
// 103 and the player held 无色小晶块 in slot 9.
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:103": {ItemID: 490702318, Count: 1},
"0:18": {
ItemID: 400330118,
Count: 1,
Bind: true,
Extra: map[string]string{
"item_kind": "equipment",
"equipment_type": "[title name]",
"quality_seed": "959062881",
"enchant_card_id": "10008663",
"value_a": "10008663",
"seal_flag": "1",
},
},
"0:9": {ItemID: 3037, Count: 10},
},
}); err != nil {
t.Fatal(err)
}
request := make([]byte, currentLimitedCubeUseBodySize)
binary.LittleEndian.PutUint16(request[0:2], 18)
binary.LittleEndian.PutUint32(request[2:6], 400330118)
binary.LittleEndian.PutUint16(request[6:8], 103)
if err := service.handleCurrentLimitedCubeUseRequest(session, request); err != nil {
t.Fatal(err)
}
ack, remaining := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketUseLimitCube) || len(ack.Body) != 12 || ack.Body[0] != 1 {
t.Fatalf("op338 acknowledgement=%+v body=%x", ack.Header, ack.Body)
}
if _, trailing := splitGameServerUpperPacket(t, remaining); len(trailing) == 0 {
t.Fatalf("op338 committed without the target replacement and refresh packets")
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, found := inventory.Slots["0:103"]; found {
t.Fatalf("ticket remains in inventory: %+v", inventory.Slots)
}
if _, found := inventory.Slots["0:9"]; found {
t.Fatalf("十 无色小晶块 remains in inventory: %+v", inventory.Slots)
}
title := inventory.Slots["0:18"]
// The draw follows the PVF weights, which list 400330118 itself at 3000, so
// any of the four 热舞一夏 titles is a legal outcome — including the one the
// player put in.
switch title.ItemID {
case 400330118, 400330119, 400330120, 400330121:
default:
t.Fatalf("changed title item=%d, want one of the four 热舞一夏 titles; stack=%+v", title.ItemID, title)
}
if title.Count != 1 || !title.Bind || title.RawEntry != nil ||
title.Extra["pvf_path"] == "" || title.Extra["item_kind"] != "equipment" {
t.Fatalf("changed title = %+v", title)
}
if title.Extra["enchant_card_id"] != "10008663" || title.Extra["seal_flag"] != "1" {
t.Fatalf("changed title dropped the 宝珠 enchant / 封装 state: %+v", title.Extra)
}
if !validCurrentEquipmentQualitySeed(sceneInventoryExtraUint32(title.Extra, "quality_seed")) {
t.Fatalf("changed title quality seed = %q", title.Extra["quality_seed"])
}
if promptItemID := int64(binary.LittleEndian.Uint32(ack.Body[1:5])); promptItemID != title.ItemID {
t.Fatalf("op338 prompt item=%d want transformed title=%d", promptItemID, title.ItemID)
}
}
func TestCurrentLimitedCubeOp338ChangesPetBeadAndRefreshesAllChangedSlots(t *testing.T) {
service, session, repositories, connection := newPremiumServiceTestRuntime(t)
service.pvfItemCatalog = newLimitedCubeTestCatalog(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:2": {ItemID: 9000, Count: 1},
"0:7": {ItemID: 9001, Count: 1, Bind: true},
"0:9": {ItemID: 3037, Count: 10},
},
}); err != nil {
t.Fatal(err)
}
request := make([]byte, currentLimitedCubeUseBodySize)
binary.LittleEndian.PutUint16(request[0:2], 7)
binary.LittleEndian.PutUint32(request[2:6], 9001)
binary.LittleEndian.PutUint16(request[6:8], 2)
if err := service.handleCurrentLimitedCubeUseRequest(session, request); err != nil {
t.Fatal(err)
}
ack, remaining := splitGameServerUpperPacket(t, connection.write.Bytes())
promptItemID := binary.LittleEndian.Uint32(ack.Body[1:5])
// The pool is 9001/9002 at equal weight and the PVF weights are drawn as
// published, so both outcomes are legal; what must hold is that the ack, the
// refreshed row and the durable slot all name the same selected template.
if ack.Header.MsgID != uint16(dnfenum.CmdPacketUseLimitCube) ||
len(ack.Body) != 12 || ack.Body[0] != 1 ||
(promptItemID != 9001 && promptItemID != 9002) ||
binary.LittleEndian.Uint16(ack.Body[5:7]) != 1 ||
binary.LittleEndian.Uint32(ack.Body[7:11]) != 0 || ack.Body[11] != 1 {
t.Fatalf("op338 acknowledgement = %+v body=%x", ack.Header, ack.Body)
}
cleared, remaining := splitGameServerUpperPacket(t, remaining)
if cleared.Header.Classification != 0 || cleared.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(cleared.Body) != 3+currentItemListEntryWireSize || cleared.Body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(cleared.Body[1:3]) != 1 {
t.Fatalf("target replacement clear header=%+v body=%x", cleared.Header, cleared.Body)
}
if got := limitedCubeRefreshRows(t, cleared.Body)[7]; got.itemID != ^uint32(0) || got.count != 0 {
t.Fatalf("target replacement clear row=%+v", got)
}
refresh, trailing := splitGameServerUpperPacket(t, remaining)
if len(trailing) != 0 || refresh.Header.Classification != 0 || refresh.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(refresh.Body) != 3+3*currentItemListEntryWireSize || refresh.Body[0] != dnfrepo.MainInventoryListType || binary.LittleEndian.Uint16(refresh.Body[1:3]) != 3 {
t.Fatalf("op14 refresh header=%+v body=%x trailing=%x", refresh.Header, refresh.Body, trailing)
}
updates := limitedCubeRefreshRows(t, refresh.Body)
if got := updates[2]; got.itemID != ^uint32(0) || got.count != 0 {
t.Fatalf("ticket update = %+v", got)
}
if got := updates[7]; got.itemID != promptItemID || got.count != 1 {
t.Fatalf("bead result update = %+v, want item=%d count=1", got, promptItemID)
}
if got := updates[9]; got.itemID != ^uint32(0) || got.count != 0 {
t.Fatalf("material update = %+v", got)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, found := inventory.Slots["0:2"]; found {
t.Fatalf("ticket remains in inventory: %+v", inventory.Slots)
}
bead := inventory.Slots["0:7"]
if bead.ItemID != int64(promptItemID) || bead.Count != 1 || !bead.Bind || bead.Extra["source"] != "limited_cube_result" {
t.Fatalf("changed bead = %+v, want item=%d", bead, promptItemID)
}
if _, found := inventory.Slots["0:9"]; found {
t.Fatalf("material remains in inventory: %+v", inventory.Slots)
}
}
func TestCurrentLimitedCubeOp338ConsumesCrystalWarehouseMaterial(t *testing.T) {
service, session, repositories, connection := newPremiumServiceTestRuntime(t)
service.pvfItemCatalog = newLimitedCubeTestCatalog(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:2": {ItemID: 9000, Count: 1},
"0:7": {ItemID: 9001, Count: 1, Bind: true},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(context.Background(), dnfrepo.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]dnfrepo.ItemStack{
dnfrepo.AccountSharedInventorySlotKey(358): {ItemID: 3037, Count: 999},
},
}); err != nil {
t.Fatal(err)
}
request := make([]byte, currentLimitedCubeUseBodySize)
binary.LittleEndian.PutUint16(request[0:2], 7)
binary.LittleEndian.PutUint32(request[2:6], 9001)
binary.LittleEndian.PutUint16(request[6:8], 2)
if err := service.handleCurrentLimitedCubeUseRequest(session, request); err != nil {
t.Fatal(err)
}
ack, remaining := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketUseLimitCube) || len(ack.Body) != 12 || ack.Body[0] != 1 {
t.Fatalf("op338 acknowledgement=%+v body=%x", ack.Header, ack.Body)
}
cleared, remaining := splitGameServerUpperPacket(t, remaining)
if cleared.Header.Classification != 0 || cleared.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(cleared.Body) != 3+currentItemListEntryWireSize || cleared.Body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(cleared.Body[1:3]) != 1 {
t.Fatalf("target replacement clear header=%+v body=%x", cleared.Header, cleared.Body)
}
if got := limitedCubeRefreshRows(t, cleared.Body)[7]; got.itemID != ^uint32(0) || got.count != 0 {
t.Fatalf("target replacement clear row=%+v", got)
}
refresh, trailing := splitGameServerUpperPacket(t, remaining)
if len(trailing) != 0 || refresh.Header.Classification != 0 || refresh.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(refresh.Body) != 3+3*currentItemListEntryWireSize || refresh.Body[0] != dnfrepo.MainInventoryListType || binary.LittleEndian.Uint16(refresh.Body[1:3]) != 3 {
t.Fatalf("op14 refresh header=%+v body=%x trailing=%x", refresh.Header, refresh.Body, trailing)
}
updates := limitedCubeRefreshRows(t, refresh.Body)
if got := updates[358]; got.itemID != 3037 || got.count != 989 {
t.Fatalf("account crystal update=%+v want item=3037 count=989", got)
}
account, found, err := repositories.AccountInventory.Load(context.Background(), "account-1")
if err != nil || !found {
t.Fatalf("load account inventory found=%t err=%v", found, err)
}
if got := account.Slots["0:358"].Count; got != 989 {
t.Fatalf("persisted account crystal count=%d want=989", got)
}
}
func TestCurrentLimitedCubeDispatcherDoesNotTreatCrystalContractAsOp338(t *testing.T) {
service, session, repositories, connection := newCrystalContractTestRuntime(t, map[string]dnfrepo.ItemStack{
"0:354": {ItemID: 3033, Count: 1},
})
service.pvfItemCatalog = newLimitedCubeTestCatalog(t)
if err := service.handleCurrentCrystalContractUpdate(session, []byte{0, 0}); err != nil {
t.Fatal(err)
}
connection.write.Reset()
session.dungeon.runtime = &runtimeDungeonState{Character: dnfrepo.CharacterRecord{CharacterID: "19"}}
request := make([]byte, currentLimitedCubeUseBodySize)
binary.LittleEndian.PutUint16(request[0:2], 354)
binary.LittleEndian.PutUint32(request[2:6], 3033)
binary.LittleEndian.PutUint16(request[6:8], 1)
if err := service.handleCurrentLimitedCubeUseRequest(session, request); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketUseLimitCube) || !bytes.Equal(packet.Body, []byte{0, 1}) {
t.Fatalf("crystal op338 packet=%+v body=%x trailing=%x", packet.Header, packet.Body, trailing)
}
inventory, found, err := repositories.AccountInventory.Load(context.Background(), "account-1")
if err != nil || !found || inventory.Slots[dnfrepo.AccountSharedInventorySlotKey(354)].Count != 1 {
t.Fatalf("op338 mutated crystal inventory found=%t slots=%+v err=%v", found, inventory.Slots, err)
}
}
func TestCurrentLimitedCubeRejectsDungeonUseWithoutMutation(t *testing.T) {
service, session, repositories, connection := newPremiumServiceTestRuntime(t)
service.pvfItemCatalog = newLimitedCubeTestCatalog(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:2": {ItemID: 9000, Count: 1},
"0:7": {ItemID: 9001, Count: 1},
"0:9": {ItemID: 3037, Count: 10},
},
}); err != nil {
t.Fatal(err)
}
session.dungeon.runtime = &runtimeDungeonState{Character: dnfrepo.CharacterRecord{CharacterID: "19"}}
request := make([]byte, currentLimitedCubeUseBodySize)
binary.LittleEndian.PutUint16(request[0:2], 7)
binary.LittleEndian.PutUint32(request[2:6], 9001)
binary.LittleEndian.PutUint16(request[6:8], 2)
if err := service.handleCurrentLimitedCubeUseRequest(session, request); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketUseLimitCube) || !bytes.Equal(packet.Body, []byte{0, 1}) {
t.Fatalf("dungeon use packet=%+v body=%x trailing=%x", packet.Header, packet.Body, trailing)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if inventory.Slots["0:2"].Count != 1 || inventory.Slots["0:7"].ItemID != 9001 || inventory.Slots["0:9"].Count != 10 {
t.Fatalf("dungeon use mutated inventory = %+v", inventory.Slots)
}
}
func TestResolveCurrentLimitedCubePolicyAcceptsEquipmentResultTemplates(t *testing.T) {
catalog := newLimitedCubeTestCatalog(t)
policy, recognized, err := resolveCurrentLimitedCubePolicy(catalog, 9010)
if err != nil || !recognized {
t.Fatalf("resolve recognized=%t err=%v", recognized, err)
}
if policy.TicketItemID != 9010 ||
!reflect.DeepEqual(policy.Conditions, []dnflimitedcube.Requirement{{ItemID: 9101, Count: 1}, {ItemID: 9102, Count: 1}}) ||
!reflect.DeepEqual(policy.Materials, []dnflimitedcube.Requirement{{ItemID: 3037, Count: 10}}) ||
len(policy.Results) != 2 || policy.Results[0].Weight != 3000 || policy.Results[1].Weight != 1000 {
t.Fatalf("policy = %+v", policy)
}
first := policy.Results[0].Stack
if first.ItemID != 9101 || first.Count != 1 || first.Extra["item_kind"] != "equipment" ||
first.Extra["pvf_path"] != "equipment/character/common/title/title_a.equ" ||
first.Extra["equipment_type"] != "[title name]" ||
first.Extra["source"] != "limited_cube_result" {
t.Fatalf("first equipment result stack = %+v", first)
}
// raw+0x06 is the grade seed the client recomputes 品级 from; a result that
// carried no valid seed would be repainted with the grade of seed 1.
if seed := sceneInventoryExtraUint32(first.Extra, "quality_seed"); !validCurrentEquipmentQualitySeed(seed) {
t.Fatalf("first result quality seed = %d (%q)", seed, first.Extra["quality_seed"])
}
if second := policy.Results[1].Stack; second.Extra["quality_seed"] == first.Extra["quality_seed"] {
t.Fatalf("candidates must own independent seeds, both = %q", second.Extra["quality_seed"])
}
// An equipment template is a single instance; a PVF triple asking for a
// stacked equipment result is a broken document and must stay refused.
badCatalog, err := newPVFDungeonDropCatalog(dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "9101 `character/common/title/title_a.equ`\n",
"stackable/stackable.lst": "9011 `cash/title_change_stack.stk`\n9101 `character/common/title/title_a.equ`\n",
"stackable/cash/title_change_stack.stk": "[stackable type] `[upgrade limit cube]`\n[action type] `[limited cube]`\n[action usable place] `[village]`\n[A condition item]\n9101 1\n[B condition item]\n3037 10\n[result item]\n9101 2 1000\n",
"equipment/character/common/title/title_a.equ": "[name] `Title A`\n[equipment type] `[title name]`\n[stackable type] `[material]`\n",
})
if err != nil {
t.Fatal(err)
}
if _, _, err := resolveCurrentLimitedCubePolicy(badCatalog, 9011); err == nil {
t.Fatal("a stacked equipment result must be refused")
}
}
func TestCurrentLimitedCubeOp338ChangesTitleAndKeepsBeadEnchantAndSeal(t *testing.T) {
service, session, repositories, connection := newPremiumServiceTestRuntime(t)
service.pvfItemCatalog = newLimitedCubeTestCatalog(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:2": {ItemID: 9010, Count: 1},
"0:7": {
ItemID: 9101,
Count: 1,
Bind: true,
Extra: map[string]string{
"item_kind": "equipment",
"equipment_type": "[title name]",
"quality_seed": "12345",
"value_a": "10008663",
"enchant_card_id": "10008663",
"byte_12": "0",
"enchant_upgrade_count": "0",
"seal_flag": "1",
},
},
"0:9": {ItemID: 3037, Count: 10},
},
}); err != nil {
t.Fatal(err)
}
request := make([]byte, currentLimitedCubeUseBodySize)
binary.LittleEndian.PutUint16(request[0:2], 7)
binary.LittleEndian.PutUint32(request[2:6], 9101)
binary.LittleEndian.PutUint16(request[6:8], 2)
if err := service.handleCurrentLimitedCubeUseRequest(session, request); err != nil {
t.Fatal(err)
}
ack, remaining := splitGameServerUpperPacket(t, connection.write.Bytes())
promptItemID := binary.LittleEndian.Uint32(ack.Body[1:5])
// The pool is title_a(3000)/title_b(1000) and the PVF weights are drawn as
// published, so both templates are legal outcomes.
if ack.Header.MsgID != uint16(dnfenum.CmdPacketUseLimitCube) ||
len(ack.Body) != 12 || ack.Body[0] != 1 ||
(promptItemID != 9101 && promptItemID != 9102) ||
binary.LittleEndian.Uint16(ack.Body[5:7]) != 1 ||
binary.LittleEndian.Uint32(ack.Body[7:11]) != 0 || ack.Body[11] != 1 {
t.Fatalf("op338 acknowledgement = %+v body=%x", ack.Header, ack.Body)
}
cleared, remaining := splitGameServerUpperPacket(t, remaining)
if cleared.Header.Classification != 0 || cleared.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(cleared.Body) != 3+currentItemListEntryWireSize || cleared.Body[0] != dnfrepo.MainInventoryListType {
t.Fatalf("target replacement clear header=%+v body=%x", cleared.Header, cleared.Body)
}
if got := limitedCubeRefreshRows(t, cleared.Body)[7]; got.itemID != ^uint32(0) {
t.Fatalf("target replacement clear row=%+v", got)
}
refresh, trailing := splitGameServerUpperPacket(t, remaining)
if len(trailing) != 0 || refresh.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(refresh.Body) != 3+3*currentItemListEntryWireSize || refresh.Body[0] != dnfrepo.MainInventoryListType {
t.Fatalf("op14 refresh header=%+v body=%x trailing=%x", refresh.Header, refresh.Body, trailing)
}
row := limitedCubeRow(t, refresh.Body, 7)
if got := binary.LittleEndian.Uint32(row[2:6]); got != promptItemID {
t.Fatalf("refreshed title row item=%d want %d row=%x", got, promptItemID, row)
}
// list 0 is an equipment container, so raw+0x06 is the grade seed, not a count.
if seed := binary.LittleEndian.Uint32(row[6:10]); !validCurrentEquipmentQualitySeed(seed) {
t.Fatalf("refreshed title row quality seed=%d row=%x", seed, row)
}
if row[0x0D] != 1 {
t.Fatalf("refreshed title row seal flag=%d, want the 封装 mark kept; row=%x", row[0x0D], row)
}
if got := binary.LittleEndian.Uint32(row[0x0E:0x12]); got != 10008663 {
t.Fatalf("refreshed title row enchant card=%d, want the 宝珠 kept; row=%x", got, row)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, found := inventory.Slots["0:2"]; found {
t.Fatalf("ticket remains in inventory: %+v", inventory.Slots)
}
if _, found := inventory.Slots["0:9"]; found {
t.Fatalf("material remains in inventory: %+v", inventory.Slots)
}
title := inventory.Slots["0:7"]
if title.ItemID != int64(promptItemID) || title.Count != 1 || !title.Bind || title.RawEntry != nil {
t.Fatalf("changed title = %+v, want item=%d", title, promptItemID)
}
wantPath := map[int64]string{
9101: "equipment/character/common/title/title_a.equ",
9102: "equipment/character/common/title/title_b.equ",
}[title.ItemID]
for key, want := range map[string]string{
"source": "limited_cube_result",
"item_kind": "equipment",
"pvf_path": wantPath,
"equipment_type": "[title name]",
"enchant_card_id": "10008663",
"value_a": "10008663",
"seal_flag": "1",
"random_title_enchant_config_id": "",
} {
if got := title.Extra[key]; got != want {
t.Fatalf("changed title extra[%q] = %q want %q (extras=%+v)", key, got, want, title.Extra)
}
}
if !validCurrentEquipmentQualitySeed(sceneInventoryExtraUint32(title.Extra, "quality_seed")) {
t.Fatalf("changed title quality seed = %q", title.Extra["quality_seed"])
}
}
// TestCurrentLimitedCubeOp338KeepsRandomTitleEnchantOnTransformedTitle covers
// the other half of 「称号变更后, 附在称号上的宝珠属性仍然有效」: a title carrying
// the random title enchant (etc/randomtitleenchant.etc config/row, raw
// +0x61/+0x63) instead of an ordinary bead. The template swap clears RawEntry,
// so the result row is synthesised from Extra and only the entry-level overlay
// can render the enchant; before that fix the transformed title came back with
// +0x61..+0x65 empty.
func TestCurrentLimitedCubeOp338KeepsRandomTitleEnchantOnTransformedTitle(t *testing.T) {
service, session, repositories, connection := newPremiumServiceTestRuntime(t)
service.pvfItemCatalog = newLimitedCubeTestCatalog(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:2": {ItemID: 9010, Count: 1},
"0:7": {
ItemID: 9101,
Count: 1,
Extra: map[string]string{
"item_kind": "equipment",
"equipment_type": "[title name]",
"quality_seed": "12345",
// setRandomTitleEnchantFields zeroes the ordinary bead half
// of the same block when it writes the config/row pair.
"value_a": "0",
"enchant_card_id": "0",
"byte_12": "0",
"enchant_upgrade_count": "0",
"random_title_enchant_config_id": "10001",
"random_title_enchant_row_id": "72",
},
},
"0:9": {ItemID: 3037, Count: 10},
},
}); err != nil {
t.Fatal(err)
}
request := make([]byte, currentLimitedCubeUseBodySize)
binary.LittleEndian.PutUint16(request[0:2], 7)
binary.LittleEndian.PutUint32(request[2:6], 9101)
binary.LittleEndian.PutUint16(request[6:8], 2)
if err := service.handleCurrentLimitedCubeUseRequest(session, request); err != nil {
t.Fatal(err)
}
ack, remaining := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketUseLimitCube) || len(ack.Body) != 12 || ack.Body[0] != 1 {
t.Fatalf("op338 acknowledgement = %+v body=%x", ack.Header, ack.Body)
}
_, remaining = splitGameServerUpperPacket(t, remaining)
refresh, trailing := splitGameServerUpperPacket(t, remaining)
if len(trailing) != 0 || refresh.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("op14 refresh header=%+v body=%x trailing=%x", refresh.Header, refresh.Body, trailing)
}
row := limitedCubeRow(t, refresh.Body, 7)
if got := binary.LittleEndian.Uint16(row[currentRandomTitleEnchantConfigOffset:currentRandomTitleEnchantRowOffset]); got != 10001 {
t.Fatalf("transformed title row config=%d want 10001; row=%x", got, row)
}
if got := binary.LittleEndian.Uint16(row[currentRandomTitleEnchantRowOffset:currentRandomTitleEnchantFieldEnd]); got != 72 {
t.Fatalf("transformed title row id=%d want 72; row=%x", got, row)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
title := inventory.Slots["0:7"]
for key, want := range map[string]string{
"random_title_enchant_config_id": "10001",
"random_title_enchant_row_id": "72",
"enchant_card_id": "0",
} {
if got := title.Extra[key]; got != want {
t.Fatalf("changed title extra[%q] = %q want %q (extras=%+v)", key, got, want, title.Extra)
}
}
}
func limitedCubeRow(t *testing.T, body []byte, slot uint16) []byte {
t.Helper()
for offset := 3; offset+currentItemListEntryWireSize <= len(body); offset += currentItemListEntryWireSize {
row := body[offset : offset+currentItemListEntryWireSize]
if binary.LittleEndian.Uint16(row[0:2]) == slot {
return row
}
}
t.Fatalf("no refresh row for slot %d in body=%x", slot, body)
return nil
}
type limitedCubeRefreshRow struct {
itemID uint32
count uint32
}
func limitedCubeRefreshRows(t *testing.T, body []byte) map[uint16]limitedCubeRefreshRow {
t.Helper()
rows := make(map[uint16]limitedCubeRefreshRow)
for offset := 3; offset+currentItemListEntryWireSize <= len(body); offset += currentItemListEntryWireSize {
row := body[offset : offset+currentItemListEntryWireSize]
rows[binary.LittleEndian.Uint16(row[0:2])] = limitedCubeRefreshRow{
itemID: binary.LittleEndian.Uint32(row[2:6]),
count: binary.LittleEndian.Uint32(row[6:10]),
}
}
return rows
}
func newLimitedCubeTestCatalog(t *testing.T) *pvfDungeonDropCatalog {
t.Helper()
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "9101 `character/common/title/title_a.equ`\n9102 `character/common/title/title_b.equ`\n",
"stackable/stackable.lst": "9000 `cash/pet_bead_change.stk`\n9001 `bead/power.stk`\n9002 `bead/element.stk`\n9003 `material/plain.stk`\n3037 `material/clear_crystal.stk`\n9010 `cash/title_change.stk`\n",
"stackable/cash/pet_bead_change.stk": "[stackable type] `[upgrade limit cube]`\n[action type] `[limited cube]`\n[action usable place] `[village]`\n[A condition item]\n9001 1\n9002 1\n[B condition item]\n3037 10\n[result item]\n9001 1 1000\n9002 1 1000\n",
"stackable/cash/title_change.stk": "[stackable type] `[upgrade limit cube]`\n[action type] `[limited cube]`\n[action usable place] `[village]`\n[A condition item]\n9101 1\n9102 1\n[B condition item]\n3037 10\n[result item]\n9101 1 3000\n9102 1 1000\n",
"stackable/bead/power.stk": "[stackable type] `[enchant waste]`\n[stack limit] 1\n",
"stackable/bead/element.stk": "[stackable type] `[enchant waste]`\n[stack limit] 1\n",
"stackable/material/plain.stk": "[stackable type] `[material]`\n",
"stackable/material/clear_crystal.stk": "[stackable type] `[material]`\n[stack limit] 99999\n",
"equipment/character/common/title/title_a.equ": "[name] `Title A`\n[equipment type] `[title name]`\n[rarity] 2\n[attach type] `[sealing]`\n",
"equipment/character/common/title/title_b.equ": "[name] `Title B`\n[equipment type] `[title name]`\n[rarity] 2\n[attach type] `[sealing]`\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
return catalog
}