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

1285 lines
57 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"os"
"sort"
"testing"
"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"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestParseCurrentNPCShopRequestsUseExactCurrentEXEBodies(t *testing.T) {
shopID, err := parseCurrentNPCShopPurchaseCountRequest([]byte{0x78, 0x56, 0x34, 0x12})
if err != nil || shopID != 0x12345678 {
t.Fatalf("purchase-count shop=%08x err=%v", shopID, err)
}
if shopID, err = parseCurrentNPCShopPurchaseCountRequest(nil); err != nil || shopID != 0 {
t.Fatalf("initial purchase-count shop=%08x err=%v", shopID, err)
}
if _, err = parseCurrentNPCShopPurchaseCountRequest(make([]byte, 3)); err == nil {
t.Fatal("malformed purchase-count request accepted")
}
buyBody := currentNPCShopBuyTestBody(600, 3, 0x11223344, 0x55667788)
buy, err := parseCurrentNPCShopBuyRequest(buyBody)
if err != nil {
t.Fatal(err)
}
if buy.ItemID != 600 || buy.Count != 3 || buy.ShopContext != 0x11223344 || buy.ActorContext != 0x55667788 {
t.Fatalf("buy request=%+v", buy)
}
equipmentBuy, err := parseCurrentNPCShopBuyRequest(currentNPCShopBuyTestBody(700, 0, 28, 45))
if err != nil || equipmentBuy.ItemID != 700 || equipmentBuy.Count != 0 {
t.Fatalf("equipment buy request=%+v err=%v", equipmentBuy, err)
}
sellBody := currentNPCShopSellTestBody(dnfrepo.MainInventoryListType, 65, 2, 0xAABBCCDD)
sell, err := parseCurrentNPCShopSellRequest(sellBody)
if err != nil {
t.Fatal(err)
}
if sell.ListType != dnfrepo.MainInventoryListType || sell.Slot != 65 || sell.Count != 2 || sell.ActorContext != 0xAABBCCDD {
t.Fatalf("sell request=%+v", sell)
}
for _, body := range [][]byte{buyBody[:15], append(append([]byte(nil), buyBody...), 0), make([]byte, 16)} {
if _, err := parseCurrentNPCShopBuyRequest(body); err == nil {
t.Fatalf("malformed buy accepted: %x", body)
}
}
for _, body := range [][]byte{sellBody[:10], append(append([]byte(nil), sellBody...), 0), currentNPCShopSellTestBody(1, 65, 2, 0), currentNPCShopSellTestBody(0, 0, 2, 0)} {
if _, err := parseCurrentNPCShopSellRequest(body); err == nil {
t.Fatalf("malformed sell accepted: %x", body)
}
}
if normalized := normalizeLegacyGameBody(uint16(dnfenum.CmdPacketBuyItem), append(append([]byte(nil), buyBody...), 1, 2, 3, 4)); !bytes.Equal(normalized, buyBody) {
t.Fatalf("normalized buy=%x want=%x", normalized, buyBody)
}
if normalized := normalizeLegacyGameBody(uint16(dnfenum.CmdPacketSellItem), append(append([]byte(nil), sellBody...), 1, 2, 3, 4)); !bytes.Equal(normalized, sellBody) {
t.Fatalf("normalized sell=%x want=%x", normalized, sellBody)
}
purchaseCountBody := []byte{0x78, 0x56, 0x34, 0x12}
if normalized := normalizeLegacyGameBody(uint16(dnfenum.CmdPacketShopPurchaseCount), append(append([]byte(nil), purchaseCountBody...), 1, 2, 3, 4)); !bytes.Equal(normalized, purchaseCountBody) {
t.Fatalf("normalized purchase-count=%x want=%x", normalized, purchaseCountBody)
}
}
func TestCurrentNPCShopPurchaseCountReturnsFourEmptyCurrentEXELists(t *testing.T) {
connection := &bufferConn{}
service := &Service{}
session := &gameSession{conn: connection, connID: "npc-shop-purchase-count", selectedCharacterID: 19}
request := []byte{0x78, 0x56, 0x34, 0x12}
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketShopPurchaseCount), request); err != nil {
t.Fatal(err)
}
ack, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
wantBody := append([]byte{1}, make([]byte, currentNPCShopPurchaseCountListCount*4)...)
if len(trailing) != 0 || ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketShopPurchaseCount) || !bytes.Equal(ack.Body, wantBody) {
t.Fatalf("purchase-count ACK header=%+v body=%x trailing=%d", ack.Header, ack.Body, len(trailing))
}
}
func TestCurrentNPCShopCatalogAndPricingUsePVF(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
if shop.listedShopCount != 2 || shop.loadedShopCount != 1 || shop.missingShopCount != 1 || shop.firstMissingShopPath != "stale_missing_shop.shp" {
t.Fatalf("shop load evidence=%+v", shop)
}
pricing, err := resolveCurrentNPCShopPricing(shop, items, 600)
if err != nil {
t.Fatal(err)
}
if !pricing.Buyable || pricing.BuyGold != 100 || pricing.SellGold != 10 || pricing.Definition.StackLimit != 999 {
t.Fatalf("stackable pricing=%+v", pricing)
}
equipment, err := resolveCurrentNPCShopPricing(shop, items, 700)
if err != nil {
t.Fatal(err)
}
if !equipment.Buyable || equipment.BuyGold != 1000 || equipment.SellGold != 100 || equipment.Definition.Durability != 20 {
t.Fatalf("equipment pricing=%+v", equipment)
}
unlisted, err := resolveCurrentNPCShopPricing(shop, items, 601)
if err != nil {
t.Fatal(err)
}
if unlisted.Buyable || unlisted.BuyGold != 100 || unlisted.SellGold != 10 {
t.Fatalf("unlisted pricing=%+v", unlisted)
}
material, err := resolveCurrentNPCShopPricing(shop, items, 602)
if err != nil {
t.Fatal(err)
}
if !material.Buyable || !material.MaterialExchange || material.NeedMaterialItem != 900 || material.NeedMaterialCount != 2 {
t.Fatalf("material pricing=%+v", material)
}
}
func TestCurrentNPCTradingSystemPricingIsScopedToMatchingNPC(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
if len(shop.tradingShops) != 1 {
t.Fatalf("trading shops=%d want=1", len(shop.tradingShops))
}
ordinary, err := resolveCurrentNPCShopPricingForContext(shop, items, 603, math.MaxUint32, 0)
if err != nil {
t.Fatal(err)
}
if ordinary.Buyable || ordinary.BuyGold != 0 || ordinary.SellGold != 0 || ordinary.TradingShopID != 0 {
t.Fatalf("ordinary time-stone pricing=%+v", ordinary)
}
timeStone, err := resolveCurrentNPCShopPricingForContext(shop, items, 603, math.MaxUint32, 88)
if err != nil {
t.Fatal(err)
}
if !timeStone.Buyable || timeStone.BuyGold != 125 || timeStone.SellGold != 100 || timeStone.TradingShopID != 88 || timeStone.MaterialExchange {
t.Fatalf("special time-stone pricing=%+v", timeStone)
}
refined, err := resolveCurrentNPCShopPricingForContext(shop, items, 604, math.MaxUint32, 88)
if err != nil {
t.Fatal(err)
}
if !refined.Buyable || !refined.MaterialExchange || refined.NeedMaterialItem != 603 || refined.NeedMaterialCount != 10 || refined.TradingShopID != 88 {
t.Fatalf("refined time-stone pricing=%+v", refined)
}
wrongNPC, err := resolveCurrentNPCShopPricingForContext(shop, items, 604, math.MaxUint32, 89)
if err != nil {
t.Fatal(err)
}
if wrongNPC.Buyable || wrongNPC.TradingShopID != 0 {
t.Fatalf("wrong-NPC refined pricing=%+v", wrongNPC)
}
forgedShopContext, err := resolveCurrentNPCShopPricingForContext(shop, items, 603, 88, 89)
if err != nil {
t.Fatal(err)
}
if forgedShopContext.Buyable || forgedShopContext.TradingShopID != 0 {
t.Fatalf("forged shop-context pricing=%+v", forgedShopContext)
}
}
func TestCurrentNPCTradingSystemBuySellAndExchangeUsePVFAtomically(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
repositories := mustCurrentNPCShopRepositories(t, 1000, map[string]dnfrepo.ItemStack{
"0:121": {ItemID: 603, Count: 20},
})
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{selectedCharacterID: 19}
buy, err := service.commitCurrentNPCShopBuy(context.Background(), session, shop, items, currentNPCShopBuyRequest{
ItemID: 603, Count: 2, ShopContext: math.MaxUint32, ActorContext: 88,
})
if err != nil {
t.Fatal(err)
}
if buy.GoldAfter != 750 || buy.ItemID != 603 || buy.Count != 2 {
t.Fatalf("time-stone buy=%+v", buy)
}
inventory, _, _ := repositories.Inventory.Load(context.Background(), "19")
if stack := inventory.Slots["0:121"]; stack.ItemID != 603 || stack.Count != 22 {
t.Fatalf("time stones after buy=%+v", stack)
}
exchange, err := service.commitCurrentNPCShopBuy(context.Background(), session, shop, items, currentNPCShopBuyRequest{
ItemID: 604, Count: 1, ShopContext: math.MaxUint32, ActorContext: 88,
})
if err != nil {
t.Fatal(err)
}
if exchange.GoldAfter != 750 || exchange.CostItemID != 603 || exchange.CostItemNewCount != 12 || exchange.ItemID != 604 || exchange.Count != 1 {
t.Fatalf("refined exchange=%+v", exchange)
}
inventory, _, _ = repositories.Inventory.Load(context.Background(), "19")
if stack := inventory.Slots["0:121"]; stack.ItemID != 603 || stack.Count != 12 {
t.Fatalf("time stones after exchange=%+v", stack)
}
assertCurrentNPCShopInventoryAmount(t, inventory, 604, 1)
refinedSlot := int16(-1)
for key, stack := range inventory.Slots {
if stack.ItemID == 604 {
if _, scanErr := fmt.Sscanf(key, "0:%d", &refinedSlot); scanErr != nil {
t.Fatal(scanErr)
}
break
}
}
if refinedSlot < 0 {
t.Fatal("refined time-stone slot not found")
}
sell, err := service.commitCurrentNPCShopSell(context.Background(), session, shop, items, currentNPCShopSellRequest{
ListType: 0, Slot: 121, Count: 2, ActorContext: 88,
})
if err != nil {
t.Fatal(err)
}
if sell.GoldAfter != 950 || sell.ItemID != 603 || sell.Applied != 2 {
t.Fatalf("time-stone sell=%+v refined_slot=%d", sell, refinedSlot)
}
inventory, _, _ = repositories.Inventory.Load(context.Background(), "19")
if stack := inventory.Slots["0:121"]; stack.ItemID != 603 || stack.Count != 10 {
t.Fatalf("time stones after sell=%+v", stack)
}
}
// TestCurrentNPCShopSellConsumesAccountSharedCrystalSlot 钉住晶体/灵魂仓库(list 0,
// 354..365)里的道具可以原地卖给 NPC。现场 2026-09-17 16:19:09,玩家把无色小晶块拖到
// 商店"没反应",服务端只留下
//
// game-npc-shop-sell-rejected reason=dnf current NPC shop request is invalid: sell list=0 slot=358 count=10
//
// —— 解析器当时把账号共享槽一律拒掉。这些格子的归属是**账号记录**,所以卖完必须写回
// 账号记录,角色背包一个字节都不能动;卖空那一格时刷新行要是 0xFFFFFFFF 删除行,否则
// 客户端右侧晶体/灵魂面板会继续显示已经卖掉的那一堆。
func TestCurrentNPCShopSellConsumesAccountSharedCrystalSlot(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
ctx := context.Background()
// 现场抓到的那一发请求体就是 list=0 slot=358 count=10:解析器必须放行。
if _, err := parseCurrentNPCShopSellRequest(currentNPCShopSellTestBody(0, dnfrepo.CrystalWarehouseFirstSlot+4, 10, 88)); err != nil {
t.Fatalf("account-shared sell request rejected by the parser: %v", err)
}
repositories := mustCurrentNPCShopRepositories(t, 1000, nil)
sharedKey := dnfrepo.AccountSharedInventorySlotKey(dnfrepo.CrystalWarehouseFirstSlot + 4) // 0:358
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]dnfrepo.ItemStack{sharedKey: {ItemID: 603, Count: 20}},
}); err != nil {
t.Fatal(err)
}
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{selectedCharacterID: 19}
sell, err := service.commitCurrentNPCShopSell(ctx, session, shop, items, currentNPCShopSellRequest{
ListType: 0, Slot: dnfrepo.CrystalWarehouseFirstSlot + 4, Count: 2, ActorContext: 88,
})
if err != nil {
t.Fatalf("account-shared sell error = %v", err)
}
if sell.GoldAfter != 1200 || sell.ItemID != 603 || sell.Applied != 2 {
t.Fatalf("account-shared sell = %+v", sell)
}
account, found, err := repositories.AccountInventory.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account inventory found=%t err=%v", found, err)
}
if stack := account.Slots[sharedKey]; stack.ItemID != 603 || stack.Count != 18 {
t.Fatalf("account crystal slot after sell = %+v", stack)
}
inventory, _, err := repositories.Inventory.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
if _, exists := inventory.Slots[sharedKey]; exists {
t.Fatalf("character record gained an account-shared row: %+v", inventory.Slots)
}
emptied, err := service.commitCurrentNPCShopSell(ctx, session, shop, items, currentNPCShopSellRequest{
ListType: 0, Slot: dnfrepo.CrystalWarehouseFirstSlot + 4, Count: 18, ActorContext: 88,
})
if err != nil {
t.Fatalf("emptying account-shared sell error = %v", err)
}
if emptied.GoldAfter != 3000 || emptied.Applied != 18 {
t.Fatalf("emptying account-shared sell = %+v", emptied)
}
foundEmptyRow := false
for _, row := range emptied.Updates {
if binary.LittleEndian.Uint16(row.data[0x00:0x02]) != uint16(dnfrepo.CrystalWarehouseFirstSlot+4) {
continue
}
// 账号共享槽清空写 (itemID=0, count=0):拍卖行上架、摆摊售出都是这一写法,也是现场
// 唯一能让晶体面板把那一格归零的写法(0xFFFFFFFF 是角色背包格的删除行)。
if got := binary.LittleEndian.Uint32(row.data[0x02:0x06]); got != 0 {
t.Fatalf("emptied crystal slot refresh row item=%d want the empty row marker 0", got)
}
foundEmptyRow = true
}
if !foundEmptyRow {
t.Fatalf("emptied crystal slot produced no refresh row: %+v", emptied.Updates)
}
account, _, err = repositories.AccountInventory.Load(ctx, "account-1")
if err != nil {
t.Fatal(err)
}
if _, exists := account.Slots[sharedKey]; exists {
t.Fatalf("emptied account crystal slot still present: %+v", account.Slots)
}
}
func TestCurrentNPCTradingSystemRejectsWrongNPCAndZeroValueSaleWithoutMutation(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
repositories := mustCurrentNPCShopRepositories(t, 1000, map[string]dnfrepo.ItemStack{
"0:121": {ItemID: 603, Count: 2},
})
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{selectedCharacterID: 19}
if _, err := service.commitCurrentNPCShopBuy(context.Background(), session, shop, items, currentNPCShopBuyRequest{
ItemID: 603, Count: 1, ShopContext: 88, ActorContext: 89,
}); err == nil {
t.Fatal("forged shop context authorized time-stone purchase")
}
if _, err := service.commitCurrentNPCShopSell(context.Background(), session, shop, items, currentNPCShopSellRequest{
ListType: 0, Slot: 121, Count: 1, ActorContext: 89,
}); err == nil {
t.Fatal("wrong NPC accepted zero-value time-stone sale")
}
inventory, _, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil {
t.Fatal(err)
}
if stack := inventory.Slots["0:121"]; stack.ItemID != 603 || stack.Count != 2 {
t.Fatalf("rejected sale changed source=%+v", stack)
}
character, _, err := repositories.Character.Load(context.Background(), "19")
if err != nil {
t.Fatal(err)
}
if character.Stats["gold"] != 1000 {
t.Fatalf("rejected sale changed gold=%d", character.Stats["gold"])
}
}
// TestCurrentNPCShopSellAllowsZeroValueConsumableWithoutGoldPayout pins the
// policy the live report asked for: no item dictionary carries a "cannot sell"
// field, so a consumable without [price]/[value] is sold for 0 gold and simply
// leaves the bag instead of being refused.
func TestCurrentNPCShopSellAllowsZeroValueConsumableWithoutGoldPayout(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
pricing, err := resolveCurrentNPCShopPricing(shop, items, 607)
if err != nil {
t.Fatal(err)
}
if pricing.SellGold != 0 || pricing.ZeroGoldDisposal || pricing.Definition.Kind != dungeonDropItemStackable {
t.Fatalf("zero-value potion pricing=%+v", pricing)
}
repositories := mustCurrentNPCShopRepositories(t, 1000, map[string]dnfrepo.ItemStack{
"0:93": {ItemID: 607, Count: 4},
})
service := currentNPCShopTestService(repositories, shop, items)
result, err := service.commitCurrentNPCShopSell(context.Background(), &gameSession{selectedCharacterID: 19}, shop, items, currentNPCShopSellRequest{
ListType: 0, Slot: 93, Count: 4, ActorContext: 146,
})
if err != nil {
t.Fatalf("zero-value consumable sale was refused: %v", err)
}
if result.ItemID != 607 || result.Applied != 4 || result.GoldAfter != 1000 {
t.Fatalf("zero-value sale=%+v", result)
}
inventory, _, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil {
t.Fatal(err)
}
if stack, found := inventory.Slots["0:93"]; found {
t.Fatalf("sold cell still holds %+v", stack)
}
character, _, err := repositories.Character.Load(context.Background(), "19")
if err != nil {
t.Fatal(err)
}
if character.Stats["gold"] != 1000 {
t.Fatalf("zero-value sale paid gold=%d", character.Stats["gold"])
}
}
func TestCurrentNPCShopAllowsPVFUpgradeTicketZeroGoldDisposal(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
pricing, err := resolveCurrentNPCShopPricing(shop, items, 605)
if err != nil {
t.Fatal(err)
}
if pricing.SellGold != 0 || !pricing.ZeroGoldDisposal {
t.Fatalf("package ticket pricing=%+v", pricing)
}
repositories := mustCurrentNPCShopRepositories(t, 1000, map[string]dnfrepo.ItemStack{
"0:177": {ItemID: 605, Count: 2},
})
service := currentNPCShopTestService(repositories, shop, items)
result, err := service.commitCurrentNPCShopSell(context.Background(), &gameSession{selectedCharacterID: 19}, shop, items, currentNPCShopSellRequest{
ListType: 0, Slot: 177, Count: 2,
})
if err != nil {
t.Fatal(err)
}
if result.ItemID != 605 || result.Applied != 2 || result.GoldAfter != 1000 {
t.Fatalf("ticket disposal=%+v", result)
}
inventory, _, _ := repositories.Inventory.Load(context.Background(), "19")
if _, found := inventory.Slots["0:177"]; found {
t.Fatalf("disposed ticket remains: %+v", inventory.Slots)
}
character, _, _ := repositories.Character.Load(context.Background(), "19")
if character.Stats["gold"] != 1000 {
t.Fatalf("zero-value disposal changed gold=%d", character.Stats["gold"])
}
}
func TestCurrentNPCTradingSystemAggregatesSplitMaterialStacks(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
repositories := mustCurrentNPCShopRepositories(t, 1000, map[string]dnfrepo.ItemStack{
"0:121": {ItemID: 603, Count: 6},
"0:122": {ItemID: 603, Count: 4},
})
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{selectedCharacterID: 19}
result, err := service.commitCurrentNPCShopBuy(context.Background(), session, shop, items, currentNPCShopBuyRequest{
ItemID: 604, Count: 1, ShopContext: math.MaxUint32, ActorContext: 88,
})
if err != nil {
t.Fatal(err)
}
if result.CostItemID != 603 || result.CostItemNewCount != 0 || result.ItemID != 604 {
t.Fatalf("split-stack exchange=%+v", result)
}
inventory, _, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil {
t.Fatal(err)
}
assertCurrentNPCShopInventoryAmount(t, inventory, 603, 0)
assertCurrentNPCShopInventoryAmount(t, inventory, 604, 1)
}
func TestCurrentNPCTradingSystemMaterialPurchaseUsesMaterialPageAndFailsAtomicallyWhenFull(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
session := &gameSession{selectedCharacterID: 19}
emptyRepositories := mustCurrentNPCShopRepositories(t, 1000, nil)
emptyService := currentNPCShopTestService(emptyRepositories, shop, items)
if _, err := emptyService.commitCurrentNPCShopBuy(context.Background(), session, shop, items, currentNPCShopBuyRequest{
ItemID: 603, Count: 1, ShopContext: math.MaxUint32, ActorContext: 88,
}); err != nil {
t.Fatal(err)
}
emptyInventory, _, _ := emptyRepositories.Inventory.Load(context.Background(), "19")
if stack := emptyInventory.Slots["0:121"]; stack.ItemID != 603 || stack.Count != 1 {
t.Fatalf("first material purchase slot=%+v want material slot 121", stack)
}
fullSlots := make(map[string]dnfrepo.ItemStack)
for slot := int16(121); slot <= 176; slot++ {
fullSlots[currentCeraShopInventorySlotKey(0, slot)] = dnfrepo.ItemStack{ItemID: 999, Count: 1}
}
fullRepositories := mustCurrentNPCShopRepositories(t, 1000, fullSlots)
fullService := currentNPCShopTestService(fullRepositories, shop, items)
if _, err := fullService.commitCurrentNPCShopBuy(context.Background(), session, shop, items, currentNPCShopBuyRequest{
ItemID: 603, Count: 1, ShopContext: math.MaxUint32, ActorContext: 88,
}); err == nil {
t.Fatal("full material page accepted time-stone purchase")
}
fullInventory, _, _ := fullRepositories.Inventory.Load(context.Background(), "19")
if !equalCurrentNPCShopSlots(fullInventory.Slots, fullSlots) {
t.Fatalf("failed material purchase changed inventory=%+v", fullInventory.Slots)
}
fullCharacter, _, _ := fullRepositories.Character.Load(context.Background(), "19")
if fullCharacter.Stats["gold"] != 1000 {
t.Fatalf("failed material purchase changed gold=%d", fullCharacter.Stats["gold"])
}
}
func TestCurrentNPCShopCatalogFallsBackToRevisedShopFile(t *testing.T) {
source := bridgePVFSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "600 `shop_potion.stk`\n",
"equipment/equipment.lst": "",
"itemshop/itemshop.lst": "15 `EquipmentShop7.shp`\n",
"itemshop/(r)equipmentshop7.shp": "[sell info]\n[tab]\n`其他`\n[item list]\n600\n[/item list]\n[/tab]\n[/sell info]\n",
"equipment/pricetable.tbl": "[rate]\n200 150 30\n",
"stackable/shop_potion.stk": "[stackable type]\n`[waste]`\n[stack limit]\n999\n[price]\n100\n[value]\n50\n",
}
shop, err := newCurrentNPCShopCatalog(source)
if err != nil {
t.Fatal(err)
}
if shop.missingShopCount != 0 || shop.loadedShopCount != 1 || !currentNPCShopContainsItem(shop, 600) {
t.Fatalf("revised shop fallback load=%+v contains600=%t", shop, currentNPCShopContainsItem(shop, 600))
}
}
func TestCurrentNPCShopBuyCommitsGoldAndItemThenSendsIncrementalUpdate(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
repositories := mustCurrentNPCShopRepositories(t, 1000, nil)
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{conn: connection, connID: "npc-shop-buy", selectedCharacterID: 19}
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketBuyItem), currentNPCShopBuyTestBody(600, 3, 44, 88)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyItem) {
t.Fatalf("buy ACK header=%+v", ack.Header)
}
if len(ack.Body) != 1+4*4+currentItemListEntryWireSize+1 || ack.Body[0] != 1 ||
binary.LittleEndian.Uint32(ack.Body[1:5]) != 700 || binary.LittleEndian.Uint32(ack.Body[5:9]) != 11 ||
binary.LittleEndian.Uint32(ack.Body[9:13]) != 0 || binary.LittleEndian.Uint32(ack.Body[13:17]) != 22 ||
binary.LittleEndian.Uint32(ack.Body[19:23]) != 600 || binary.LittleEndian.Uint32(ack.Body[23:27]) != 3 ||
ack.Body[len(ack.Body)-1] != 0 {
t.Fatalf("buy ACK body=%x", ack.Body)
}
itemSlot := int16(binary.LittleEndian.Uint16(ack.Body[17:19]))
update, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || update.Header.Classification != 0 || update.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("buy update header=%+v trailing=%d", update.Header, len(trailing))
}
assertCurrentNPCShopIncrementalRows(t, update.Body, 700, itemSlot, 600, 3)
character, found, err := repositories.Character.Load(context.Background(), "19")
if err != nil || !found || character.Stats["gold"] != 700 {
t.Fatalf("character=%+v found=%t err=%v", character, found, err)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
stack, found := inventory.Slots[currentCeraShopInventorySlotKey(0, itemSlot)]
if !found || stack.ItemID != 600 || stack.Count != 3 || len(stack.RawEntry) != currentItemListEntryWireSize ||
stack.Extra["last_grant_source"] != "npc_shop" || stack.Extra["last_npc_shop_context"] != "44" || stack.Extra["last_npc_shop_actor_context"] != "88" {
t.Fatalf("bought stack=%+v found=%t", stack, found)
}
}
func TestCurrentNPCShopBuyNormalizesCurrentEXEEquipmentZeroCountToOne(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
repositories := mustCurrentNPCShopRepositories(t, 2000, nil)
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{conn: connection, connID: "npc-shop-equipment-zero-count", selectedCharacterID: 19}
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketBuyItem), currentNPCShopBuyTestBody(700, 0, 28, 45)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyItem) || ack.Body[0] != 1 {
t.Fatalf("equipment buy ACK header=%+v body=%x", ack.Header, ack.Body)
}
_, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 {
t.Fatalf("equipment buy trailing=%d", len(trailing))
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
var bought dnfrepo.ItemStack
for _, stack := range inventory.Slots {
if stack.ItemID == 700 {
bought = stack
break
}
}
if bought.ItemID != 700 || bought.Count != 1 || bought.Extra["last_npc_shop_context"] != "28" || bought.Extra["last_npc_shop_actor_context"] != "45" {
t.Fatalf("bought equipment=%+v", bought)
}
character, found, err := repositories.Character.Load(context.Background(), "19")
if err != nil || !found || character.Stats["gold"] != 1000 {
t.Fatalf("character=%+v found=%t err=%v", character, found, err)
}
}
func TestCurrentNPCShopSellUsesPVFPriceNotForgedMetadataAndSendsIncrementalUpdate(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
stack := dnfrepo.ItemStack{ItemID: 600, Count: 5, Extra: map[string]string{"sell_gold": "999999999"}}
entry := currentItemListEntryFromStack(0, 65, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
repositories := mustCurrentNPCShopRepositories(t, 100, map[string]dnfrepo.ItemStack{"0:65": stack})
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
if err := service.handleCurrentNPCShopSell(&gameSession{conn: connection, selectedCharacterID: 19}, currentNPCShopSellTestBody(0, 65, 2, 88)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != uint16(dnfenum.CmdPacketSellItem) ||
len(ack.Body) != 12 || ack.Body[0] != 1 || binary.LittleEndian.Uint32(ack.Body[1:5]) != 120 ||
ack.Body[5] != 0 || binary.LittleEndian.Uint16(ack.Body[6:8]) != 65 || binary.LittleEndian.Uint32(ack.Body[8:12]) != 2 {
t.Fatalf("sell ACK header=%+v body=%x", ack.Header, ack.Body)
}
update, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || update.Header.Classification != 0 || update.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("sell update header=%+v trailing=%d", update.Header, len(trailing))
}
assertCurrentNPCShopIncrementalRows(t, update.Body, 120, 65, 600, 3)
character, found, err := repositories.Character.Load(context.Background(), "19")
if err != nil || !found || character.Stats["gold"] != 120 {
t.Fatalf("character=%+v found=%t err=%v", character, found, err)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
remaining := inventory.Slots["0:65"]
if err != nil || !found || remaining.Count != 3 || binary.LittleEndian.Uint32(remaining.RawEntry[6:10]) != 3 {
t.Fatalf("inventory=%+v remaining=%+v found=%t err=%v", inventory, remaining, found, err)
}
}
func TestCurrentNPCShopSellAllUsesCurrentEXEMinusOneDeletionRow(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
stack := dnfrepo.ItemStack{ItemID: 600, Count: 2}
repositories := mustCurrentNPCShopRepositories(t, 100, map[string]dnfrepo.ItemStack{"0:65": stack})
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
if err := service.handleCurrentNPCShopSell(&gameSession{conn: connection, selectedCharacterID: 19}, currentNPCShopSellTestBody(0, 65, 2, 0)); err != nil {
t.Fatal(err)
}
_, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
update, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || len(update.Body) != 3+2*currentItemListEntryWireSize {
t.Fatalf("deletion update len=%d trailing=%d", len(update.Body), len(trailing))
}
second := update.Body[3+currentItemListEntryWireSize:]
if binary.LittleEndian.Uint16(second[0:2]) != 65 || binary.LittleEndian.Uint32(second[2:6]) != math.MaxUint32 || binary.LittleEndian.Uint32(second[6:10]) != 0 {
t.Fatalf("deletion row=%x", second)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
if _, exists := inventory.Slots["0:65"]; exists {
t.Fatalf("sold row remains: %+v", inventory.Slots["0:65"])
}
}
func TestCurrentNPCShopBuyFailuresDoNotMutateGoldOrInventory(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
for _, test := range []struct {
name string
gold int64
itemID uint32
count uint32
slots map[string]dnfrepo.ItemStack
}{
{name: "insufficient gold", gold: 200, itemID: 600, count: 3},
{name: "zero stackable count", gold: 1000, itemID: 600, count: 0},
{name: "forged unlisted item", gold: 1000, itemID: 601, count: 1},
{name: "material exchange unsupported", gold: 1000, itemID: 602, count: 1},
{name: "inventory full", gold: 1000, itemID: 600, count: 1, slots: fullCurrentNPCShopWasteInventory()},
} {
t.Run(test.name, func(t *testing.T) {
repositories := mustCurrentNPCShopRepositories(t, test.gold, test.slots)
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
if err := service.handleCurrentNPCShopBuy(&gameSession{conn: connection, selectedCharacterID: 19}, currentNPCShopBuyTestBody(test.itemID, test.count, 0, 0)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || failure.Header.MsgID != uint16(dnfenum.CmdPacketBuyItem) ||
failure.Header.Classification != dnfproto.DefaultChannelClassification || !bytes.Equal(failure.Body, []byte{0, currentNPCShopBuyErrorCode}) {
t.Fatalf("failure header=%+v body=%x trailing=%d", failure.Header, failure.Body, len(trailing))
}
character, found, err := repositories.Character.Load(context.Background(), "19")
if err != nil || !found || character.Stats["gold"] != test.gold {
t.Fatalf("character mutated=%+v found=%t err=%v", character, found, err)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found || !equalCurrentNPCShopSlots(inventory.Slots, test.slots) {
t.Fatalf("inventory mutated=%+v found=%t err=%v", inventory.Slots, found, err)
}
})
}
}
func TestCurrentNPCShopSellLockedOrOverflowDoesNotMutate(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
for _, test := range []struct {
name string
gold int64
stack dnfrepo.ItemStack
wantError byte
}{
// A locked item answers its own code: the client renders 214 as dstr 42232
// 「在物品已锁定状态下, 您无法进行当前操作。」 instead of staying silent.
{name: "locked", gold: 100, stack: dnfrepo.ItemStack{ItemID: 600, Count: 2, Extra: map[string]string{"equipment_lock_state": "locked"}}, wantError: currentNPCShopSellErrorLocked},
{name: "gold overflow", gold: math.MaxInt32, stack: dnfrepo.ItemStack{ItemID: 600, Count: 2}, wantError: currentNPCShopSellErrorCode},
} {
t.Run(test.name, func(t *testing.T) {
repositories := mustCurrentNPCShopRepositories(t, test.gold, map[string]dnfrepo.ItemStack{"0:65": test.stack})
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
if err := service.handleCurrentNPCShopSell(&gameSession{conn: connection, selectedCharacterID: 19}, currentNPCShopSellTestBody(0, 65, 1, 0)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || failure.Header.MsgID != uint16(dnfenum.CmdPacketSellItem) || !bytes.Equal(failure.Body, []byte{0, test.wantError}) {
t.Fatalf("failure header=%+v body=%x want=%x trailing=%d", failure.Header, failure.Body, []byte{0, test.wantError}, len(trailing))
}
character, _, _ := repositories.Character.Load(context.Background(), "19")
inventory, _, _ := repositories.Inventory.Load(context.Background(), "19")
if character.Stats["gold"] != test.gold || inventory.Slots["0:65"].Count != test.stack.Count {
t.Fatalf("mutation character=%+v inventory=%+v", character, inventory)
}
})
}
}
func TestCurrentNPCShopBuyCommitFailureRollsBackWalletAndInventory(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
repositories := mustCurrentNPCShopRepositories(t, 1000, nil)
repositories.AccountAssets = rejectingCurrentNPCShopCommit{
inner: repositories.AccountAssets,
shop: currentShopPurchaseUnitOfWork(repositories.AccountAssets),
err: errors.New("injected NPC shop commit failure"),
}
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
if err := service.handleCurrentNPCShopBuy(&gameSession{conn: connection, selectedCharacterID: 19}, currentNPCShopBuyTestBody(600, 2, 0, 0)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || !bytes.Equal(failure.Body, []byte{0, currentNPCShopBuyErrorCode}) {
t.Fatalf("failure body=%x trailing=%d", failure.Body, len(trailing))
}
character, found, err := repositories.Character.Load(context.Background(), "19")
if err != nil || !found || character.Stats["gold"] != 1000 {
t.Fatalf("character=%+v found=%t err=%v", character, found, err)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("inventory=%+v found=%t err=%v", inventory, found, err)
}
}
func TestRealPVFCurrentNPCShopCatalogAndPrice(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to the runtime Script.pvf")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
shop, err := newCurrentNPCShopCatalog(archive)
if err != nil {
t.Fatal(err)
}
t.Logf("real NPC shop catalog: listed=%d loaded=%d missing=%d first_missing=%q buyable_items=%d", shop.listedShopCount, shop.loadedShopCount, shop.missingShopCount, shop.firstMissingShopPath, len(shop.buyableItems))
items, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
ids := make([]uint32, 0, len(shop.buyableItems))
for itemID := range shop.buyableItems {
ids = append(ids, itemID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, itemID := range ids {
pricing, priceErr := resolveCurrentNPCShopPricing(shop, items, itemID)
if priceErr == nil && pricing.Buyable && pricing.BuyGold > 0 && pricing.SellGold >= 0 {
return
}
}
t.Fatalf("no positive-price real NPC shop item resolved from %d listed items", len(ids))
}
func TestRealPVFPackageUpgradeTicketAllowsZeroGoldNPCDisposal(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to the runtime Script.pvf")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
shop, err := newCurrentNPCShopCatalog(archive)
if err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
const packageReinforcementTicket = uint32(490701660)
pricing, err := resolveCurrentNPCShopPricing(shop, items, packageReinforcementTicket)
if err != nil {
t.Fatal(err)
}
if pricing.SellGold != 0 || !pricing.ZeroGoldDisposal || pricing.Definition.Kind != dungeonDropItemStackable {
t.Fatalf("real package ticket pricing=%+v", pricing)
}
ordinaryZeroValue, err := resolveCurrentNPCShopPricing(shop, items, 10158964)
if err != nil {
t.Fatal(err)
}
if ordinaryZeroValue.ZeroGoldDisposal {
t.Fatalf("ordinary zero-value material was made disposable: %+v", ordinaryZeroValue)
}
}
func TestRealPVFCurrentNPCTimeStoneTradingSystem(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to the runtime Script.pvf")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
shop, err := newCurrentNPCShopCatalog(archive)
if err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
const (
timeStone = uint32(10158700)
refinedTimeStone = uint32(10158964)
jonathanShop = uint32(476)
)
stone, err := resolveCurrentNPCShopPricingForContext(shop, items, timeStone, math.MaxUint32, jonathanShop)
if err != nil {
t.Fatal(err)
}
if !stone.Buyable || stone.BuyGold != 8332 || stone.SellGold != 6666 || stone.TradingShopID != jonathanShop {
t.Fatalf("real time-stone pricing=%+v", stone)
}
refined, err := resolveCurrentNPCShopPricingForContext(shop, items, refinedTimeStone, math.MaxUint32, jonathanShop)
if err != nil {
t.Fatal(err)
}
if !refined.Buyable || !refined.MaterialExchange || refined.NeedMaterialItem != timeStone || refined.NeedMaterialCount != 10 || refined.TradingShopID != jonathanShop {
t.Fatalf("real refined time-stone pricing=%+v", refined)
}
wrongNPC, err := resolveCurrentNPCShopPricingForContext(shop, items, timeStone, math.MaxUint32, 1)
if err != nil {
t.Fatal(err)
}
if wrongNPC.Buyable {
t.Fatalf("real time stone escaped NPC scope: %+v", wrongNPC)
}
}
func TestRealPVFCurrentNPCShopObservedEquipmentMaterialExchange(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to the runtime Script.pvf")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
shop, err := newCurrentNPCShopCatalog(archive)
if err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
const itemID = uint32(116010117)
pricing, err := resolveCurrentNPCShopPricing(shop, items, itemID)
if err != nil {
t.Fatal(err)
}
if !pricing.Buyable || !pricing.MaterialExchange || pricing.Definition.Kind != dungeonDropItemEquipment || pricing.NeedMaterialItem == 0 || pricing.NeedMaterialCount <= 0 {
t.Fatalf("observed shop equipment pricing=%+v", pricing)
}
t.Logf("item=%d path=%s material=%d x%d", itemID, pricing.Definition.PVFPath, pricing.NeedMaterialItem, pricing.NeedMaterialCount)
}
func TestCurrentNPCShopBuyMaterialExchangeConsumesCharacterStack(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
repositories := mustCurrentNPCShopRepositories(t, 1000, map[string]dnfrepo.ItemStack{"0:121": {ItemID: 900, Count: 10}})
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{conn: connection, connID: "npc-shop-exchange", selectedCharacterID: 19}
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketBuyItem), currentNPCShopBuyTestBody(602, 3, 15, 2)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyItem) || ack.Body[0] != 1 {
t.Fatalf("exchange ACK header=%+v body=%x", ack.Header, ack.Body)
}
// 602 的 PVF 同时有 [price] 100 与 [need material] 900 2:金币与材料都要扣
// (2026-10-08 修:以前只扣材料,连买 7 次金币一分不动)。
if len(ack.Body) != 1+4*4+currentItemListEntryWireSize+1+8 ||
binary.LittleEndian.Uint32(ack.Body[1:5]) != 700 || // 1000 - 100*3
ack.Body[len(ack.Body)-9] != 1 || binary.LittleEndian.Uint32(ack.Body[len(ack.Body)-8:]) != 900 || binary.LittleEndian.Uint32(ack.Body[len(ack.Body)-4:]) != 4 {
t.Fatalf("exchange ACK cost rows=%x", ack.Body)
}
_, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 {
t.Fatalf("exchange trailing packets=%d", len(trailing))
}
character, _, _ := repositories.Character.Load(context.Background(), "19")
if character.Stats["gold"] != 700 {
t.Fatalf("gold after exchange=%d want 700 (1000 - 100x3)", character.Stats["gold"])
}
inventory, _, _ := repositories.Inventory.Load(context.Background(), "19")
if stack := inventory.Slots["0:121"]; stack.ItemID != 900 || stack.Count != 4 {
t.Fatalf("material stack=%+v want 900x4", stack)
}
assertCurrentNPCShopInventoryAmount(t, inventory, 602, 3)
}
func TestCurrentNPCShopBuyEquipmentMaterialExchangeConsumesMaterialAndGrantsOne(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
repositories := mustCurrentNPCShopRepositories(t, 1000, map[string]dnfrepo.ItemStack{"0:121": {ItemID: 900, Count: 10}})
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{conn: connection, connID: "npc-shop-equipment-exchange", selectedCharacterID: 19}
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketBuyItem), currentNPCShopBuyTestBody(701, 0, 28, 45)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyItem) || ack.Body[0] != 1 ||
ack.Body[len(ack.Body)-9] != 1 || binary.LittleEndian.Uint32(ack.Body[len(ack.Body)-8:]) != 900 || binary.LittleEndian.Uint32(ack.Body[len(ack.Body)-4:]) != 8 {
t.Fatalf("equipment exchange ACK=%x", ack.Body)
}
_, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 {
t.Fatalf("equipment exchange trailing=%d", len(trailing))
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
if stack := inventory.Slots["0:121"]; stack.ItemID != 900 || stack.Count != 8 {
t.Fatalf("material stack=%+v want 900x8", stack)
}
assertCurrentNPCShopInventoryAmount(t, inventory, 701, 1)
for _, stack := range inventory.Slots {
if stack.ItemID == 701 && (stack.Extra["last_npc_shop_context"] != "28" || stack.Extra["last_npc_shop_actor_context"] != "45") {
t.Fatalf("equipment exchange metadata=%+v", stack.Extra)
}
}
// 701 同样同时标价与收材料:1000 金币必须一起扣掉。
character, _, _ := repositories.Character.Load(context.Background(), "19")
if character.Stats["gold"] != 0 {
t.Fatalf("gold after equipment exchange=%d want 0 (1000 - 1000x1)", character.Stats["gold"])
}
}
func TestCurrentNPCShopBuyEquipmentMaterialExchangeInsufficientMaterialDoesNotMutate(t *testing.T) {
shop, items := mustCurrentNPCShopTestCatalogs(t)
wantSlots := map[string]dnfrepo.ItemStack{"0:121": {ItemID: 900, Count: 1}}
repositories := mustCurrentNPCShopRepositories(t, 1000, wantSlots)
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{conn: connection, connID: "npc-shop-equipment-exchange-insufficient", selectedCharacterID: 19}
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketBuyItem), currentNPCShopBuyTestBody(701, 0, 28, 45)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || !bytes.Equal(failure.Body, []byte{0, currentNPCShopBuyErrorCode}) {
t.Fatalf("failure body=%x trailing=%d", failure.Body, len(trailing))
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found || !equalCurrentNPCShopSlots(inventory.Slots, wantSlots) {
t.Fatalf("inventory=%+v found=%t err=%v", inventory, found, err)
}
assertCurrentNPCShopInventoryAmount(t, inventory, 701, 0)
}
func TestCurrentNPCShopBuyCubeFragmentExchangeUsesAccountWarehouse(t *testing.T) {
source := bridgePVFSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "3033 `cube_black.stk`\n3037 `cube_clear.stk`\n",
"equipment/equipment.lst": "",
"itemshop/itemshop.lst": "15 `EquipmentShop7.shp`\n",
"itemshop/(r)equipmentshop7.shp": "[sell info]\n[tab]\n`其他`\n[item list]\n3033\n[/item list]\n[/tab]\n[/sell info]\n",
"equipment/pricetable.tbl": "[rate]\n200 150 30\n",
"stackable/cube_black.stk": "[stackable type]\n`[material]`\n[stack limit]\n999\n[value]\n40\n[need material]\n3037 5\n",
"stackable/cube_clear.stk": "[stackable type]\n`[material]`\n[stack limit]\n999\n[value]\n40\n",
}
shop, err := newCurrentNPCShopCatalog(source)
if err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
repositories := mustCurrentNPCShopRepositories(t, 1000, nil)
if err := repositories.AccountInventory.Save(context.Background(), dnfrepo.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]dnfrepo.ItemStack{"0:358": {ItemID: 3037, Count: 10}},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{conn: connection, connID: "npc-shop-cube-exchange", selectedCharacterID: 19}
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketBuyItem), currentNPCShopBuyTestBody(3033, 2, 15, 2)); err != nil {
t.Fatal(err)
}
ack, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyItem) || ack.Body[0] != 1 ||
ack.Body[len(ack.Body)-9] != 1 || binary.LittleEndian.Uint32(ack.Body[len(ack.Body)-8:]) != 3037 || binary.LittleEndian.Uint32(ack.Body[len(ack.Body)-4:]) != 0 {
t.Fatalf("cube exchange ACK=%x", ack.Body)
}
account, found, err := repositories.AccountInventory.Load(context.Background(), "account-1")
if err != nil || !found {
t.Fatalf("account inventory found=%t err=%v", found, err)
}
if stack := account.Slots["0:358"]; stack.ItemID != 3037 || stack.Count != 0 {
t.Fatalf("colorless cube cell=%+v want 3037x0", stack)
}
if stack := account.Slots["0:354"]; stack.ItemID != 3033 || stack.Count != 2 {
t.Fatalf("black cube cell=%+v want 3033x2", stack)
}
character, _, _ := repositories.Character.Load(context.Background(), "19")
if character.Stats["gold"] != 1000 {
t.Fatalf("gold after cube exchange=%d want unchanged 1000", character.Stats["gold"])
}
}
func assertCurrentNPCShopInventoryAmount(t *testing.T, inventory dnfrepo.InventoryRecord, itemID int64, want int64) {
t.Helper()
var got int64
for _, stack := range inventory.Slots {
if stack.ItemID == itemID {
got += stack.Count
}
}
if got != want {
t.Fatalf("item=%d count=%d want=%d inventory=%+v", itemID, got, want, inventory.Slots)
}
}
func mustCurrentNPCShopTestCatalogs(t *testing.T) (*currentNPCShopCatalog, *pvfDungeonDropCatalog) {
t.Helper()
source := bridgePVFSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "600 `shop_potion.stk`\n601 `unlisted_potion.stk`\n602 `material_exchange.stk`\n603 `time_stone.stk`\n604 `refined_time_stone.stk`\n605 `package_upgrade_ticket.stk`\n606 `flaggem/guardian_gem.stk`\n607 `zero_value_potion.stk`\n",
"equipment/equipment.lst": "700 `shop_sword.equ`\n701 `material_sword.equ`\n702 `flag/guild_medal.equ`\n",
"itemshop/itemshop.lst": "1 `test_shop.shp`\n2 `stale_missing_shop.shp`\n",
"itemshop/test_shop.shp": "[item list]\n600 700 701 602 606 702\n[/item list]\n",
"equipment/pricetable.tbl": "[rate]\n200 150 30\n[repair cost]\n0.08\n",
"etc/tradingsystem.etc": "[trade item list]\n88\n[item list]\n603 100 1.25 1\n[/item list]\n[shop item]\n604\n[/shop item]\n[/trade item list]\n",
"stackable/shop_potion.stk": "[stackable type]\n`[waste]`\n[stack limit]\n999\n[price]\n100\n[value]\n50\n",
"stackable/unlisted_potion.stk": "[stackable type]\n`[waste]`\n[stack limit]\n999\n[price]\n100\n[value]\n50\n",
"stackable/material_exchange.stk": "[stackable type]\n`[material]`\n[stack limit]\n999\n[price]\n100\n[value]\n50\n[need material]\n900 2\n",
"stackable/time_stone.stk": "[stackable type]\n`[material]`\n[stack limit]\n999\n",
"stackable/refined_time_stone.stk": "[stackable type]\n`[material]`\n[stack limit]\n999\n[need material]\n603 10\n",
"stackable/package_upgrade_ticket.stk": "[stackable type]\n`[legacy]`\n[stack limit]\n100\n[equipment reinforcement ticket]\n10 100 -1\n",
"stackable/zero_value_potion.stk": "[stackable type]\n`[waste]`\n[stack limit]\n999\n",
"equipment/shop_sword.equ": "[price]\n1000\n[value]\n500\n[durability]\n20\n",
"equipment/material_sword.equ": "[price]\n1000\n[value]\n500\n[durability]\n20\n[need material]\n900 2\n",
"stackable/flaggem/guardian_gem.stk": "[stackable type]\n`[flag gem]`\n[stack limit]\n999\n[price]\n100\n[value]\n50\n[grade]\n2\n",
"equipment/flag/guild_medal.equ": "[equipment type]\n`[flag]`\n[price]\n1000\n[value]\n500\n[need material]\n900 2\n",
}
shop, err := newCurrentNPCShopCatalog(source)
if err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
return shop, items
}
func mustCurrentNPCShopRepositories(t *testing.T, gold int64, slots map[string]dnfrepo.ItemStack) dnfrepo.Group {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
if slots == nil {
slots = map[string]dnfrepo.ItemStack{}
}
ctx := context.Background()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_cera": "22"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Stats: map[string]int64{"gold": gold, "sp": 11, "cera": 22},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: dnfrepo.CloneInventory(dnfrepo.InventoryRecord{Slots: slots}).Slots}); err != nil {
t.Fatal(err)
}
return repositories
}
func currentNPCShopTestService(repositories dnfrepo.Group, shop *currentNPCShopCatalog, items *pvfDungeonDropCatalog) *Service {
return &Service{
options: options{
accountID: "account-1",
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
npcShopCatalog: shop,
pvfItemCatalog: items,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
}
func currentNPCShopBuyTestBody(itemID uint32, count uint32, shopContext uint32, actorContext uint32) []byte {
body := make([]byte, currentNPCShopBuyRequestWireSize)
binary.LittleEndian.PutUint32(body[0:4], itemID)
binary.LittleEndian.PutUint32(body[4:8], count)
binary.LittleEndian.PutUint32(body[8:12], shopContext)
binary.LittleEndian.PutUint32(body[12:16], actorContext)
return body
}
func currentNPCShopSellTestBody(listType byte, slot int16, count uint32, actorContext uint32) []byte {
body := make([]byte, currentNPCShopSellRequestWireSize)
body[0] = listType
binary.LittleEndian.PutUint16(body[1:3], uint16(slot))
binary.LittleEndian.PutUint32(body[3:7], count)
binary.LittleEndian.PutUint32(body[7:11], actorContext)
return body
}
func assertCurrentNPCShopIncrementalRows(t *testing.T, body []byte, gold uint32, itemSlot int16, itemID uint32, count uint32) {
t.Helper()
if len(body) != 3+2*currentItemListEntryWireSize || body[0] != 0 || binary.LittleEndian.Uint16(body[1:3]) != 2 {
t.Fatalf("incremental body header=%x len=%d", body, len(body))
}
wallet := body[3 : 3+currentItemListEntryWireSize]
item := body[3+currentItemListEntryWireSize:]
if binary.LittleEndian.Uint16(wallet[0:2]) != 0 || binary.LittleEndian.Uint32(wallet[2:6]) != 0 || binary.LittleEndian.Uint32(wallet[6:10]) != gold {
t.Fatalf("wallet row=%x", wallet)
}
if int16(binary.LittleEndian.Uint16(item[0:2])) != itemSlot || binary.LittleEndian.Uint32(item[2:6]) != itemID || binary.LittleEndian.Uint32(item[6:10]) != count {
t.Fatalf("item row=%x", item)
}
}
func fullCurrentNPCShopWasteInventory() map[string]dnfrepo.ItemStack {
slots := make(map[string]dnfrepo.ItemStack)
for slot := int16(currentDungeonPickupQuickSlotStart); slot <= currentDungeonPickupQuickSlotEnd; slot++ {
slots[currentCeraShopInventorySlotKey(0, slot)] = dnfrepo.ItemStack{ItemID: 999, Count: 1}
}
for slot := int16(65); slot <= 120; slot++ {
slots[currentCeraShopInventorySlotKey(0, slot)] = dnfrepo.ItemStack{ItemID: 999, Count: 1}
}
return slots
}
func equalCurrentNPCShopSlots(left map[string]dnfrepo.ItemStack, right map[string]dnfrepo.ItemStack) bool {
if len(left) != len(right) {
return false
}
for key, want := range right {
got, found := left[key]
if !found || got.ItemID != want.ItemID || got.Count != want.Count || got.Bind != want.Bind {
return false
}
}
return true
}
type rejectingCurrentNPCShopCommit struct {
inner dnfrepo.AccountCharacterAssetUnitOfWork
shop dnfrepo.ShopPurchaseAssetUnitOfWork
err error
}
// currentShopPurchaseUnitOfWork 把 AccountAssets 的具体实现取成限购边界
// (内存与 MySQL 实现都是同一个类型,两个接口都满足)。
func currentShopPurchaseUnitOfWork(assets dnfrepo.AccountCharacterAssetUnitOfWork) dnfrepo.ShopPurchaseAssetUnitOfWork {
shop, _ := assets.(dnfrepo.ShopPurchaseAssetUnitOfWork)
return shop
}
func (u rejectingCurrentNPCShopCommit) WithinAccountCharacterAssets(
ctx context.Context,
accountID string,
characterID string,
apply func(dnfrepo.AccountInventoryRepository, dnfrepo.CharacterRepository, dnfrepo.InventoryRepository, dnfrepo.EquipmentRepository) error,
) error {
if u.inner == nil {
return dnfrepo.ErrAccountCharacterAssetTransactionUnavailable
}
return u.inner.WithinAccountCharacterAssets(ctx, accountID, characterID, func(accounts dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository) error {
if err := apply(accounts, characters, inventory, equipment); err != nil {
return err
}
return u.err
})
}
func (u rejectingCurrentNPCShopCommit) WithinShopPurchaseAssets(
ctx context.Context,
accountID string,
characterID string,
apply func(dnfrepo.AccountRepository, dnfrepo.AccountInventoryRepository, dnfrepo.CharacterRepository, dnfrepo.InventoryRepository) error,
) error {
if u.shop == nil {
return dnfrepo.ErrShopPurchaseAssetTransactionUnavailable
}
return u.shop.WithinShopPurchaseAssets(ctx, accountID, characterID, func(
account dnfrepo.AccountRepository,
accounts dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventory dnfrepo.InventoryRepository,
) error {
if err := apply(account, accounts, characters, inventory); err != nil {
return err
}
return u.err
})
}
// TestCurrentNPCShopBuyMaterialOnlyItemLeavesGoldUntouched 钉住另一半语义:
// 只写 [need material]、**没有** [price] 的商品是纯材料兑换,[value] 只是卖出价基准,
// 绝不能据此收金币(cube_black.stk 就是这一类:3037 x5,无 [price])。
func TestCurrentNPCShopBuyMaterialOnlyItemLeavesGoldUntouched(t *testing.T) {
source := bridgePVFSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "3033 `cube_black.stk`\n3037 `cube_clear.stk`\n",
"equipment/equipment.lst": "",
"itemshop/itemshop.lst": "15 `EquipmentShop7.shp`\n",
"itemshop/(r)equipmentshop7.shp": "[sell info]\n[tab]\n`other`\n[item list]\n3033\n[/item list]\n[/tab]\n[/sell info]\n",
"equipment/pricetable.tbl": "[rate]\n200 150 30\n",
"stackable/cube_black.stk": "[stackable type]\n`[material]`\n[stack limit]\n999\n[value]\n40\n[need material]\n3037 5\n",
"stackable/cube_clear.stk": "[stackable type]\n`[material]`\n[stack limit]\n999\n[value]\n40\n",
}
shop, err := newCurrentNPCShopCatalog(source)
if err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
pricing, err := resolveCurrentNPCShopPricing(shop, items, 3033)
if err != nil {
t.Fatal(err)
}
if !pricing.MaterialExchange || pricing.RequiresGold {
t.Fatalf("material-only pricing=%+v want MaterialExchange=true RequiresGold=false", pricing)
}
repositories := mustCurrentNPCShopRepositories(t, 1000, nil)
if err := repositories.AccountInventory.Save(context.Background(), dnfrepo.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]dnfrepo.ItemStack{"0:358": {ItemID: 3037, Count: 10}},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := currentNPCShopTestService(repositories, shop, items)
session := &gameSession{conn: connection, connID: "npc-shop-material-only", selectedCharacterID: 19}
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketBuyItem), currentNPCShopBuyTestBody(3033, 2, 15, 2)); err != nil {
t.Fatal(err)
}
character, _, _ := repositories.Character.Load(context.Background(), "19")
if character.Stats["gold"] != 1000 {
t.Fatalf("material-only purchase charged gold: %d want 1000", character.Stats["gold"])
}
account, found, err := repositories.AccountInventory.Load(context.Background(), "account-1")
if err != nil || !found {
t.Fatalf("account inventory found=%t err=%v", found, err)
}
if stack := account.Slots["0:358"]; stack.ItemID != 3037 || stack.Count != 0 {
t.Fatalf("material cell=%+v want 3037x0", stack)
}
}