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) } }