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

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

467 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"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 &currentNPCShopCatalog{
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
}