服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
467 lines
17 KiB
Go
467 lines
17 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"strings"
|
||
|
||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||
)
|
||
|
||
const (
|
||
currentNPCShopItemListPath = "itemshop/itemshop.lst"
|
||
currentNPCShopPriceTablePath = "equipment/pricetable.tbl"
|
||
currentNPCTradingSystemPath = "etc/tradingsystem.etc"
|
||
)
|
||
|
||
var (
|
||
errCurrentNPCShopCatalogUnavailable = errors.New("dnf current NPC shop PVF catalog is unavailable")
|
||
errCurrentNPCShopProductUnavailable = errors.New("dnf current NPC shop product is unavailable")
|
||
)
|
||
|
||
type currentNPCShopCatalog struct {
|
||
source dnfpvf.Source
|
||
buyableItems map[uint32]struct{}
|
||
// shopItems 保留每个商店 [item list] 的原始顺序(槽位下标 = 序号),供 op715
|
||
// 的限购计数表按槽位索引下发。
|
||
shopItems map[uint32][]uint32
|
||
// shopPaths 是 itemshop.lst 里 shop id → .shp 文件名的映射,只用于诊断输出。
|
||
shopPaths map[uint32]string
|
||
tradingShops map[uint32]currentNPCTradingShop
|
||
equipmentSellRatePermille int64
|
||
listedShopCount int
|
||
loadedShopCount int
|
||
missingShopCount int
|
||
firstMissingShopPath string
|
||
}
|
||
|
||
// currentNPCTradingShop is the PVF-owned special buy/sell/exchange table used
|
||
// by the time-stone NPCs. It is deliberately kept separate from itemshop.lst:
|
||
// an item listed here is legal only while the matching NPC/shop context is
|
||
// present on the current EXE request.
|
||
type currentNPCTradingShop struct {
|
||
TradeItems map[uint32]currentNPCTradingItem
|
||
ShopItems map[uint32]struct{}
|
||
}
|
||
|
||
type currentNPCTradingItem struct {
|
||
BuyGold int64
|
||
SellGold int64
|
||
Buyable bool
|
||
}
|
||
|
||
type currentNPCShopPricing struct {
|
||
Definition dungeonDropItemDefinition
|
||
BuyGold int64
|
||
SellGold int64
|
||
// ZeroGoldDisposal is set only for active-PVF reinforcement/amplification
|
||
// tickets and portable devices. Many package-issued variants deliberately
|
||
// omit both [price] and [value]; the NPC may remove those items for zero
|
||
// gold, while unrelated zero-value goods remain fail-closed.
|
||
ZeroGoldDisposal bool
|
||
Buyable bool
|
||
MaterialExchange bool
|
||
// RequiresGold 表示这一行 PVF **显式**标了 [price] > 0,也就是"金币"这一半是真的。
|
||
// 只写 [need material]、不写 [price] 的商品才是纯材料兑换 —— 它们的 [value] 只是
|
||
// 卖出价基准(同时被当成买入价的兜底),不能据此认定要收金币。
|
||
// 2026-10-08 现场:3042([price] 2000 + [need material] 3037 x100)被当成纯材料兑换,
|
||
// 连买 7 次金币一分没扣(日志 gold_after 恒定 49355535)。
|
||
RequiresGold bool
|
||
NeedMaterialItem uint32
|
||
NeedMaterialCount int64
|
||
TradingShopID uint32
|
||
}
|
||
|
||
func newCurrentNPCShopCatalog(source dnfpvf.Source) (*currentNPCShopCatalog, error) {
|
||
if source == nil {
|
||
return nil, errCurrentNPCShopCatalogUnavailable
|
||
}
|
||
shopPaths, _, err := loadDungeonDropList(source, currentNPCShopItemListPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %v", errCurrentNPCShopCatalogUnavailable, err)
|
||
}
|
||
buyable := make(map[uint32]struct{})
|
||
var shopItems map[uint32][]uint32
|
||
shopPathByID := make(map[uint32]string, len(shopPaths))
|
||
for shopID, listedPath := range shopPaths {
|
||
shopPathByID[uint32(shopID)] = listedPath
|
||
}
|
||
loadedShopCount := 0
|
||
missingShopCount := 0
|
||
firstMissingShopPath := ""
|
||
for shopID, listedPath := range shopPaths {
|
||
documentPath, text, readErr := readDungeonDropText(source, "itemshop", listedPath)
|
||
if readErr != nil {
|
||
if errors.Is(readErr, platformpvf.ErrFileNotFound) || errors.Is(readErr, dnfpvf.ErrDocNotFound) {
|
||
missingShopCount++
|
||
if firstMissingShopPath == "" {
|
||
firstMissingShopPath = listedPath
|
||
}
|
||
continue
|
||
}
|
||
return nil, fmt.Errorf("%w: shop=%d path=%s: %v", errCurrentNPCShopCatalogUnavailable, shopID, listedPath, readErr)
|
||
}
|
||
document, parseErr := dnfpvf.Parse(documentPath, text)
|
||
if parseErr != nil {
|
||
return nil, fmt.Errorf("%w: shop=%d path=%s: %v", errCurrentNPCShopCatalogUnavailable, shopID, documentPath, parseErr)
|
||
}
|
||
loadedShopCount++
|
||
// 每个商店的 [item list] 顺序要保留:客户端 op715 的限购计数表可能按
|
||
// “商品在列表里的槽位下标”而不是道具 id 索引(见 npc_shop_purchase_limit_ack.go)。
|
||
shopItemOrder := make([]uint32, 0, 32)
|
||
for _, section := range document.Sections {
|
||
if strings.ToLower(strings.TrimSpace(section.Name)) != "item list" || section.Start < 0 || section.End > len(document.Tokens) || section.Start > section.End {
|
||
continue
|
||
}
|
||
for _, token := range document.Tokens[section.Start:section.End] {
|
||
if token.Kind == dnfpvf.TokenInt && token.Int > 0 && token.Int <= math.MaxUint32 {
|
||
buyable[uint32(token.Int)] = struct{}{}
|
||
shopItemOrder = append(shopItemOrder, uint32(token.Int))
|
||
}
|
||
}
|
||
}
|
||
if len(shopItemOrder) > 0 {
|
||
if shopItems == nil {
|
||
shopItems = make(map[uint32][]uint32, len(shopPaths))
|
||
}
|
||
shopItems[uint32(shopID)] = shopItemOrder
|
||
}
|
||
}
|
||
if len(buyable) == 0 {
|
||
return nil, fmt.Errorf("%w: no [item list] entries", errCurrentNPCShopCatalogUnavailable)
|
||
}
|
||
tradingShops, err := loadCurrentNPCTradingShops(source)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %s: %v", errCurrentNPCShopCatalogUnavailable, currentNPCTradingSystemPath, err)
|
||
}
|
||
priceTable, err := parseDungeonCardPVFDocument(source, currentNPCShopPriceTablePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %s: %v", errCurrentNPCShopCatalogUnavailable, currentNPCShopPriceTablePath, err)
|
||
}
|
||
rates := priceTable.Ints("rate")
|
||
if len(rates) < 1 || rates[0] <= 0 || rates[0] > math.MaxInt32 {
|
||
return nil, fmt.Errorf("%w: %s [rate] is invalid", errCurrentNPCShopCatalogUnavailable, currentNPCShopPriceTablePath)
|
||
}
|
||
return ¤tNPCShopCatalog{
|
||
source: source,
|
||
buyableItems: buyable,
|
||
shopItems: shopItems,
|
||
shopPaths: shopPathByID,
|
||
tradingShops: tradingShops,
|
||
equipmentSellRatePermille: rates[0],
|
||
listedShopCount: len(shopPaths),
|
||
loadedShopCount: loadedShopCount,
|
||
missingShopCount: missingShopCount,
|
||
firstMissingShopPath: firstMissingShopPath,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) currentNPCShopCatalog() (*currentNPCShopCatalog, error) {
|
||
if s == nil {
|
||
return nil, errCurrentNPCShopCatalogUnavailable
|
||
}
|
||
s.npcShopCatalogMu.Lock()
|
||
defer s.npcShopCatalogMu.Unlock()
|
||
if s.npcShopCatalog != nil || s.npcShopCatalogLoadErr != nil {
|
||
return s.npcShopCatalog, s.npcShopCatalogLoadErr
|
||
}
|
||
s.initialEquipmentMu.Lock()
|
||
archive, err := s.initialEquipmentArchiveLocked()
|
||
s.initialEquipmentMu.Unlock()
|
||
if err == nil {
|
||
s.npcShopCatalog, err = newCurrentNPCShopCatalog(archive)
|
||
}
|
||
s.npcShopCatalogLoadErr = err
|
||
if err == nil && s.npcShopCatalog != nil {
|
||
s.logInfo("dnf NPC shop PVF catalog loaded",
|
||
"listed_shops", s.npcShopCatalog.listedShopCount,
|
||
"loaded_shops", s.npcShopCatalog.loadedShopCount,
|
||
"missing_shops", s.npcShopCatalog.missingShopCount,
|
||
"buyable_items", len(s.npcShopCatalog.buyableItems),
|
||
"trading_shops", len(s.npcShopCatalog.tradingShops))
|
||
if s.npcShopCatalog.missingShopCount > 0 {
|
||
s.logWarn("dnf NPC shop PVF list contains missing shop files",
|
||
"missing_shops", s.npcShopCatalog.missingShopCount,
|
||
"first_missing_shop", s.npcShopCatalog.firstMissingShopPath)
|
||
}
|
||
}
|
||
return s.npcShopCatalog, s.npcShopCatalogLoadErr
|
||
}
|
||
|
||
func loadCurrentNPCTradingShops(source dnfpvf.Source) (map[uint32]currentNPCTradingShop, error) {
|
||
shops := make(map[uint32]currentNPCTradingShop)
|
||
if source == nil {
|
||
return shops, errCurrentNPCShopCatalogUnavailable
|
||
}
|
||
text, err := source.ReadText(currentNPCTradingSystemPath)
|
||
if err != nil {
|
||
if errors.Is(err, platformpvf.ErrFileNotFound) || errors.Is(err, dnfpvf.ErrDocNotFound) {
|
||
return shops, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
document, err := dnfpvf.Parse(currentNPCTradingSystemPath, text)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var currentShopID uint32
|
||
for _, section := range document.Sections {
|
||
if section.Start < 0 || section.End > len(document.Tokens) || section.Start > section.End {
|
||
return nil, fmt.Errorf("invalid section range %q", section.Name)
|
||
}
|
||
name := strings.ToLower(strings.TrimSpace(section.Name))
|
||
tokens := document.Tokens[section.Start:section.End]
|
||
switch name {
|
||
case "trade item list":
|
||
currentShopID = 0
|
||
for _, token := range tokens {
|
||
if token.Kind != dnfpvf.TokenInt || token.Int <= 0 || token.Int > math.MaxUint32 {
|
||
continue
|
||
}
|
||
currentShopID = uint32(token.Int)
|
||
break
|
||
}
|
||
if currentShopID == 0 {
|
||
return nil, errors.New("[trade item list] has no positive shop id")
|
||
}
|
||
shop := shops[currentShopID]
|
||
if shop.TradeItems == nil {
|
||
shop.TradeItems = make(map[uint32]currentNPCTradingItem)
|
||
}
|
||
if shop.ShopItems == nil {
|
||
shop.ShopItems = make(map[uint32]struct{})
|
||
}
|
||
shops[currentShopID] = shop
|
||
case "/trade item list":
|
||
currentShopID = 0
|
||
case "item list":
|
||
if currentShopID == 0 {
|
||
continue
|
||
}
|
||
rows, parseErr := parseCurrentNPCTradingItemRows(tokens)
|
||
if parseErr != nil {
|
||
return nil, fmt.Errorf("shop=%d [item list]: %w", currentShopID, parseErr)
|
||
}
|
||
shop := shops[currentShopID]
|
||
for itemID, row := range rows {
|
||
shop.TradeItems[itemID] = row
|
||
}
|
||
shops[currentShopID] = shop
|
||
case "shop item":
|
||
if currentShopID == 0 {
|
||
continue
|
||
}
|
||
shop := shops[currentShopID]
|
||
for _, token := range tokens {
|
||
if token.Kind == dnfpvf.TokenInt && token.Int > 0 && token.Int <= math.MaxUint32 {
|
||
shop.ShopItems[uint32(token.Int)] = struct{}{}
|
||
}
|
||
}
|
||
shops[currentShopID] = shop
|
||
}
|
||
}
|
||
return shops, nil
|
||
}
|
||
|
||
func parseCurrentNPCTradingItemRows(tokens []dnfpvf.Token) (map[uint32]currentNPCTradingItem, error) {
|
||
numbers := make([]float64, 0, len(tokens))
|
||
integers := make([]bool, 0, len(tokens))
|
||
for _, token := range tokens {
|
||
switch token.Kind {
|
||
case dnfpvf.TokenInt:
|
||
numbers = append(numbers, float64(token.Int))
|
||
integers = append(integers, true)
|
||
case dnfpvf.TokenFloat:
|
||
numbers = append(numbers, token.Float)
|
||
integers = append(integers, false)
|
||
}
|
||
}
|
||
if len(numbers)%4 != 0 {
|
||
return nil, fmt.Errorf("numeric field count=%d is not a multiple of 4", len(numbers))
|
||
}
|
||
rows := make(map[uint32]currentNPCTradingItem, len(numbers)/4)
|
||
for index := 0; index < len(numbers); index += 4 {
|
||
itemID, sellGold, multiplier, buyFlag := numbers[index], numbers[index+1], numbers[index+2], numbers[index+3]
|
||
if !integers[index] || !integers[index+1] || !integers[index+3] || itemID <= 0 || itemID > math.MaxUint32 ||
|
||
sellGold <= 0 || sellGold > math.MaxInt32 || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) ||
|
||
buyFlag < 0 || buyFlag > math.MaxInt32 {
|
||
return nil, fmt.Errorf("invalid row item=%v sell=%v multiplier=%v buy_flag=%v", itemID, sellGold, multiplier, buyFlag)
|
||
}
|
||
buyGoldValue := sellGold * multiplier
|
||
if buyGoldValue < 0 || buyGoldValue > math.MaxInt32 {
|
||
return nil, fmt.Errorf("buy price overflow item=%v price=%v", itemID, buyGoldValue)
|
||
}
|
||
rows[uint32(itemID)] = currentNPCTradingItem{
|
||
BuyGold: int64(buyGoldValue),
|
||
SellGold: int64(sellGold),
|
||
Buyable: buyFlag != 0,
|
||
}
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
func resolveCurrentNPCShopPricing(shop *currentNPCShopCatalog, items *pvfDungeonDropCatalog, itemID uint32) (currentNPCShopPricing, error) {
|
||
if shop == nil || shop.source == nil || items == nil || itemID == 0 {
|
||
return currentNPCShopPricing{}, errCurrentNPCShopCatalogUnavailable
|
||
}
|
||
definition, err := items.ResolveItem(itemID)
|
||
if err != nil {
|
||
return currentNPCShopPricing{}, fmt.Errorf("%w: item=%d: %v", errCurrentNPCShopProductUnavailable, itemID, err)
|
||
}
|
||
document, err := parseDungeonCardPVFDocument(shop.source, definition.PVFPath)
|
||
if err != nil {
|
||
return currentNPCShopPricing{}, fmt.Errorf("%w: item=%d path=%s: %v", errCurrentNPCShopProductUnavailable, itemID, definition.PVFPath, err)
|
||
}
|
||
price, priceFound := document.Int("price")
|
||
value, valueFound := document.Int("value")
|
||
buyGold := int64(0)
|
||
if priceFound && price >= 0 {
|
||
buyGold = price
|
||
} else if valueFound && value >= 0 {
|
||
buyGold = value
|
||
}
|
||
if buyGold > math.MaxInt32 {
|
||
return currentNPCShopPricing{}, fmt.Errorf("%w: item=%d buy_gold=%d", errCurrentNPCShopProductUnavailable, itemID, buyGold)
|
||
}
|
||
|
||
sellGold := int64(0)
|
||
switch definition.Kind {
|
||
case dungeonDropItemEquipment:
|
||
base := buyGold
|
||
if valueFound && value >= 0 {
|
||
base = value
|
||
}
|
||
if base > math.MaxInt64/shop.equipmentSellRatePermille {
|
||
return currentNPCShopPricing{}, fmt.Errorf("%w: item=%d sell price overflow", errCurrentNPCShopProductUnavailable, itemID)
|
||
}
|
||
sellGold = base * shop.equipmentSellRatePermille / 1000
|
||
if sellGold < 1 {
|
||
sellGold = 1
|
||
}
|
||
case dungeonDropItemStackable:
|
||
if valueFound && value >= 0 {
|
||
sellGold = value / 5
|
||
} else if priceFound && price > 0 {
|
||
sellGold = price / 5
|
||
}
|
||
default:
|
||
return currentNPCShopPricing{}, fmt.Errorf("%w: item=%d kind=%s", errCurrentNPCShopProductUnavailable, itemID, definition.Kind)
|
||
}
|
||
if sellGold < 0 || sellGold > math.MaxInt32 {
|
||
return currentNPCShopPricing{}, fmt.Errorf("%w: item=%d sell_gold=%d", errCurrentNPCShopProductUnavailable, itemID, sellGold)
|
||
}
|
||
needMaterial := document.Ints("need material")
|
||
materialExchange := len(needMaterial) >= 2 && needMaterial[0] > 0 && needMaterial[0] <= math.MaxUint32 && needMaterial[1] > 0
|
||
_, listed := shop.buyableItems[itemID]
|
||
pricing := currentNPCShopPricing{
|
||
Definition: definition,
|
||
BuyGold: buyGold,
|
||
SellGold: sellGold,
|
||
ZeroGoldDisposal: sellGold == 0 && currentNPCShopZeroGoldUpgradeDisposal(document),
|
||
MaterialExchange: materialExchange,
|
||
RequiresGold: priceFound && price > 0,
|
||
}
|
||
if materialExchange {
|
||
pricing.NeedMaterialItem = uint32(needMaterial[0])
|
||
pricing.NeedMaterialCount = needMaterial[1]
|
||
}
|
||
// Material-exchange goods pay with their [need material] rows; when the same
|
||
// PVF block also carries an explicit [price], BOTH are charged (see
|
||
// RequiresGold and commitCurrentNPCShopMaterialExchange).
|
||
pricing.Buyable = listed && (materialExchange || buyGold > 0)
|
||
return pricing, nil
|
||
}
|
||
|
||
func currentNPCShopZeroGoldUpgradeDisposal(document *dnfpvf.Document) bool {
|
||
if document == nil {
|
||
return false
|
||
}
|
||
if len(document.Ints("equipment reinforcement ticket")) > 0 ||
|
||
len(document.Ints("equipment amplify reinforcement ticket")) > 0 {
|
||
return true
|
||
}
|
||
action, found := document.Text("action type")
|
||
if !found {
|
||
return false
|
||
}
|
||
action = strings.ToLower(strings.Trim(strings.TrimSpace(action), "` "))
|
||
return action == "[portable upgrade]" || action == "[portable amplify]"
|
||
}
|
||
|
||
// resolveCurrentNPCShopPricingForContext overlays the context-scoped
|
||
// etc/tradingsystem.etc rules on top of ordinary itemshop pricing. The time
|
||
// stone itself has no [price]/[value] in its .stk file, while refined time
|
||
// stones are intentionally absent from itemshop.lst; both are authorized by
|
||
// the matching trading NPC row instead.
|
||
func resolveCurrentNPCShopPricingForContext(shop *currentNPCShopCatalog, items *pvfDungeonDropCatalog, itemID uint32, shopContext uint32, actorContext uint32) (currentNPCShopPricing, error) {
|
||
pricing, err := resolveCurrentNPCShopPricing(shop, items, itemID)
|
||
if err != nil {
|
||
return currentNPCShopPricing{}, err
|
||
}
|
||
tradingShopID, tradingShop, found := currentNPCTradingShopForContext(shop, shopContext, actorContext)
|
||
if !found {
|
||
return pricing, nil
|
||
}
|
||
if row, listed := tradingShop.TradeItems[itemID]; listed {
|
||
pricing.BuyGold = row.BuyGold
|
||
pricing.SellGold = row.SellGold
|
||
pricing.Buyable = row.Buyable && row.BuyGold > 0
|
||
pricing.MaterialExchange = false
|
||
pricing.NeedMaterialItem = 0
|
||
pricing.NeedMaterialCount = 0
|
||
pricing.RequiresGold = pricing.BuyGold > 0
|
||
pricing.TradingShopID = tradingShopID
|
||
return pricing, nil
|
||
}
|
||
if _, listed := tradingShop.ShopItems[itemID]; listed {
|
||
pricing.Buyable = pricing.MaterialExchange || pricing.BuyGold > 0
|
||
pricing.TradingShopID = tradingShopID
|
||
}
|
||
return pricing, nil
|
||
}
|
||
|
||
func currentNPCTradingShopForContext(shop *currentNPCShopCatalog, shopContext uint32, actorContext uint32) (uint32, currentNPCTradingShop, bool) {
|
||
if shop == nil || len(shop.tradingShops) == 0 {
|
||
return 0, currentNPCTradingShop{}, false
|
||
}
|
||
// Current op21 special-trading requests identify the NPC in actorContext
|
||
// and carry the 0xFFFFFFFF shop sentinel. op22 sell requests carry the
|
||
// same actorContext and no shop field. Never accept a trading-system NPC
|
||
// supplied only through shopContext: doing so lets a forged ordinary-shop
|
||
// request bypass the actor authorization that opened the special UI.
|
||
if actorContext == 0 || shopContext != 0 && shopContext != math.MaxUint32 {
|
||
return 0, currentNPCTradingShop{}, false
|
||
}
|
||
if tradingShop, found := shop.tradingShops[actorContext]; found {
|
||
return actorContext, tradingShop, true
|
||
}
|
||
return 0, currentNPCTradingShop{}, false
|
||
}
|
||
|
||
func currentNPCShopContainsItem(catalog *currentNPCShopCatalog, itemID uint32) bool {
|
||
if catalog == nil {
|
||
return false
|
||
}
|
||
_, found := catalog.buyableItems[itemID]
|
||
return found
|
||
}
|
||
|
||
// currentNPCShopItemTradingListed reports whether a special-trading NPC row owns
|
||
// this item's sell price. Time stones and refined time stones carry no
|
||
// [price]/[value] in their own scripts and are priced by the matching
|
||
// etc/tradingsystem.etc row instead, so a zero price for one of them means the
|
||
// request came from a non-matching NPC rather than that the goods are worthless.
|
||
func currentNPCShopItemTradingListed(catalog *currentNPCShopCatalog, itemID uint32) bool {
|
||
if catalog == nil || itemID == 0 {
|
||
return false
|
||
}
|
||
for _, tradingShop := range catalog.tradingShops {
|
||
if _, listed := tradingShop.TradeItems[itemID]; listed {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|