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