服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
400 lines
13 KiB
Go
400 lines
13 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"path"
|
||
"strings"
|
||
"sync"
|
||
|
||
"longheng.io/server/internal/modules/dnf/actionobject"
|
||
dnfmonster "longheng.io/server/internal/modules/dnf/monster"
|
||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||
)
|
||
|
||
var (
|
||
errDungeonMonsterSourceRequired = errors.New("dnf dungeon monster PVF source is required")
|
||
errDungeonMonsterListEmpty = errors.New("dnf dungeon monster list is empty")
|
||
errDungeonPassiveObjectListEmpty = errors.New("dnf dungeon passive-object list is empty")
|
||
)
|
||
|
||
const currentDungeonPassiveObjectList = "passiveobject/passiveobject.lst"
|
||
|
||
type pvfDungeonMonsterCatalog struct {
|
||
mu sync.Mutex
|
||
source dnfpvf.Source
|
||
paths map[int64]string
|
||
cache map[int64]dnfmonster.Monster
|
||
dropCatalog *pvfDungeonDropCatalog
|
||
dropCatalogErr error
|
||
dropCatalogLoaded bool
|
||
passiveObjectIDs map[int64]struct{}
|
||
passiveObjectPaths map[int64]string
|
||
passiveObjectGroups map[int64]int64
|
||
passiveObjectKnown map[int64]bool
|
||
passiveObjectErr error
|
||
passiveObjectLoaded bool
|
||
// actionScripts caches the `[basic action]` script a placed passive object
|
||
// runs. Only objects a room actually places are resolved, so the whole
|
||
// passiveobject tree is never parsed.(上游 b402882)
|
||
actionScripts map[int64]actionobject.Script
|
||
actionScriptLoaded map[int64]bool
|
||
hellMonster map[int64]bool
|
||
hellMonsterKnown map[int64]bool
|
||
}
|
||
|
||
func (c *pvfDungeonMonsterCatalog) ensurePassiveObjectIndexLocked() {
|
||
if c.passiveObjectLoaded {
|
||
return
|
||
}
|
||
c.passiveObjectLoaded = true
|
||
text, err := c.source.ReadText(currentDungeonPassiveObjectList)
|
||
if err != nil {
|
||
c.passiveObjectErr = fmt.Errorf("read %s: %w", currentDungeonPassiveObjectList, err)
|
||
return
|
||
}
|
||
document, err := dnfpvf.Parse(currentDungeonPassiveObjectList, text)
|
||
if err != nil {
|
||
c.passiveObjectErr = fmt.Errorf("parse %s: %w", currentDungeonPassiveObjectList, err)
|
||
return
|
||
}
|
||
entries := dnfpvf.ParseList(document)
|
||
if len(entries) == 0 {
|
||
c.passiveObjectErr = fmt.Errorf("%w: %s", errDungeonPassiveObjectListEmpty, currentDungeonPassiveObjectList)
|
||
return
|
||
}
|
||
c.passiveObjectIDs = make(map[int64]struct{}, len(entries))
|
||
c.passiveObjectPaths = make(map[int64]string, len(entries))
|
||
c.passiveObjectGroups = make(map[int64]int64)
|
||
c.passiveObjectKnown = make(map[int64]bool)
|
||
for _, entry := range entries {
|
||
c.passiveObjectIDs[entry.ID] = struct{}{}
|
||
if _, exists := c.passiveObjectPaths[entry.ID]; !exists {
|
||
c.passiveObjectPaths[entry.ID] = cleanDungeonPassiveObjectPath(entry.Path)
|
||
}
|
||
}
|
||
}
|
||
|
||
// specialObjectStartActorCompatibility distinguishes runtime-created action
|
||
// objects from legacy map objects already instantiated by the client's map
|
||
// loader. Re-emitting a MapObject row as an op29 type-9 actor asks the current
|
||
// client to resolve it through the action-object registry, where no matching
|
||
// runtime definition exists.
|
||
func (c *pvfDungeonMonsterCatalog) specialObjectStartActorCompatibility(id int64) (bool, string, error) {
|
||
if c == nil || c.source == nil {
|
||
return false, "", errDungeonMonsterCatalogUnavailable
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.ensurePassiveObjectIndexLocked()
|
||
if c.passiveObjectErr != nil {
|
||
return false, "", c.passiveObjectErr
|
||
}
|
||
listedPath, found := c.passiveObjectPaths[id]
|
||
if !found {
|
||
return false, "", nil
|
||
}
|
||
compatibilityPath := strings.ToLower(cleanDungeonPassiveObjectPath(listedPath))
|
||
compatibilityPath = strings.TrimPrefix(compatibilityPath, "passiveobject/")
|
||
if strings.HasPrefix(compatibilityPath, "mapobject/") {
|
||
return false, listedPath, nil
|
||
}
|
||
return true, listedPath, nil
|
||
}
|
||
|
||
// passiveObjectRewardGroup returns only the PVF mapping proven for ordinary
|
||
// quest-item passive objects. Unknown reward types remain ungrouped.
|
||
func (c *pvfDungeonMonsterCatalog) passiveObjectRewardGroup(id int64) (int64, bool, error) {
|
||
if c == nil || c.source == nil {
|
||
return 0, false, errDungeonMonsterCatalogUnavailable
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.ensurePassiveObjectIndexLocked()
|
||
if c.passiveObjectErr != nil {
|
||
return 0, false, c.passiveObjectErr
|
||
}
|
||
listedPath, listed := c.passiveObjectPaths[id]
|
||
if !listed {
|
||
return 0, false, nil
|
||
}
|
||
if known, loaded := c.passiveObjectKnown[id]; loaded {
|
||
return c.passiveObjectGroups[id], known, nil
|
||
}
|
||
docPath, text, err := readDungeonPassiveObjectText(c.source, listedPath)
|
||
if err != nil {
|
||
// Some current-PVF passive-object list entries reference absent object
|
||
// definitions. They cannot establish a quest-item reward group, so leave
|
||
// them ungrouped rather than blocking a room transition.
|
||
if isDungeonPassiveObjectDefinitionMissing(err) {
|
||
c.passiveObjectKnown[id] = false
|
||
return 0, false, nil
|
||
}
|
||
return 0, false, fmt.Errorf("read passive object id=%d path=%s: %w", id, listedPath, err)
|
||
}
|
||
document, err := dnfpvf.Parse(docPath, text)
|
||
if err != nil {
|
||
return 0, false, fmt.Errorf("parse passive object id=%d path=%s: %w", id, docPath, err)
|
||
}
|
||
rewardType, _ := document.Text("reward type")
|
||
c.passiveObjectKnown[id] = false
|
||
if normalizeDungeonPVFSymbol(rewardType) == "quest item" {
|
||
c.passiveObjectGroups[id] = 1
|
||
c.passiveObjectKnown[id] = true
|
||
}
|
||
return c.passiveObjectGroups[id], c.passiveObjectKnown[id], nil
|
||
}
|
||
|
||
func isDungeonPassiveObjectDefinitionMissing(err error) bool {
|
||
return errors.Is(err, dnfpvf.ErrDocNotFound) || errors.Is(err, platformpvf.ErrFileNotFound)
|
||
}
|
||
|
||
func readDungeonPassiveObjectText(source dnfpvf.Source, listedPath string) (string, string, error) {
|
||
candidates := []string{cleanDungeonPassiveObjectPath(listedPath)}
|
||
if !strings.HasPrefix(strings.ToLower(candidates[0]), "passiveobject/") {
|
||
candidates = append(candidates, path.Join("passiveobject", candidates[0]))
|
||
}
|
||
var lastErr error
|
||
for _, candidate := range candidates {
|
||
text, err := source.ReadText(candidate)
|
||
if err == nil {
|
||
return candidate, text, nil
|
||
}
|
||
lastErr = err
|
||
}
|
||
return "", "", lastErr
|
||
}
|
||
|
||
func cleanDungeonPassiveObjectPath(value string) string {
|
||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||
value = strings.TrimPrefix(value, "./")
|
||
return strings.TrimPrefix(value, "/")
|
||
}
|
||
|
||
func newPVFDungeonMonsterCatalog(source dnfpvf.Source) (*pvfDungeonMonsterCatalog, error) {
|
||
if source == nil {
|
||
return nil, errDungeonMonsterSourceRequired
|
||
}
|
||
text, err := source.ReadText(dnfmonster.DefaultList)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read %s: %w", dnfmonster.DefaultList, err)
|
||
}
|
||
document, err := dnfpvf.Parse(dnfmonster.DefaultList, text)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse %s: %w", dnfmonster.DefaultList, err)
|
||
}
|
||
entries := dnfpvf.ParseList(document)
|
||
if len(entries) == 0 {
|
||
return nil, fmt.Errorf("%w: %s", errDungeonMonsterListEmpty, dnfmonster.DefaultList)
|
||
}
|
||
catalog := &pvfDungeonMonsterCatalog{
|
||
source: source,
|
||
paths: make(map[int64]string, len(entries)),
|
||
cache: make(map[int64]dnfmonster.Monster),
|
||
hellMonster: make(map[int64]bool),
|
||
hellMonsterKnown: make(map[int64]bool),
|
||
}
|
||
for _, entry := range entries {
|
||
if _, exists := catalog.paths[entry.ID]; !exists {
|
||
catalog.paths[entry.ID] = cleanDungeonMonsterPath(entry.Path)
|
||
}
|
||
}
|
||
return catalog, nil
|
||
}
|
||
|
||
func (c *pvfDungeonMonsterCatalog) Count() int {
|
||
if c == nil {
|
||
return 0
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return len(c.paths)
|
||
}
|
||
|
||
// randomizedObjectFamily resolves the current op29 family field. Some PVFs
|
||
// list the same numeric id in both monster.lst and passiveobject.lst; the
|
||
// current client resolves the monster table first, which is required for the
|
||
// AT-5T ridable monster (61235).
|
||
func (c *pvfDungeonMonsterCatalog) randomizedObjectFamily(id int64) (uint32, bool, error) {
|
||
if c == nil || c.source == nil {
|
||
return 0, false, errDungeonMonsterCatalogUnavailable
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if _, found := c.paths[id]; found {
|
||
return currentDungeonRandomizedMonsterFamily, true, nil
|
||
}
|
||
c.ensurePassiveObjectIndexLocked()
|
||
if c.passiveObjectErr != nil {
|
||
return 0, false, c.passiveObjectErr
|
||
}
|
||
if _, found := c.passiveObjectIDs[id]; found {
|
||
return currentDungeonRandomizedPassiveFamily, true, nil
|
||
}
|
||
return 0, false, nil
|
||
}
|
||
|
||
func (c *pvfDungeonMonsterCatalog) Find(id int64) (dnfmonster.Monster, bool, error) {
|
||
if c == nil || c.source == nil {
|
||
return dnfmonster.Monster{}, false, errDungeonMonsterCatalogUnavailable
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if cached, ok := c.cache[id]; ok {
|
||
return cloneDungeonMonsterDefinition(cached), true, nil
|
||
}
|
||
listedPath, ok := c.paths[id]
|
||
if !ok {
|
||
return dnfmonster.Monster{}, false, nil
|
||
}
|
||
docPath, text, err := readDungeonMonsterText(c.source, listedPath)
|
||
if err != nil {
|
||
return dnfmonster.Monster{}, false, fmt.Errorf("read monster id=%d path=%s: %w", id, listedPath, err)
|
||
}
|
||
document, err := worldmap.ParseDocument(docPath, text)
|
||
if err != nil {
|
||
return dnfmonster.Monster{}, false, fmt.Errorf("parse monster id=%d path=%s: %w", id, docPath, err)
|
||
}
|
||
definition := parseDungeonMonsterDefinition(id, docPath, document)
|
||
c.cache[id] = definition
|
||
hellMonsterValue, hellMonsterFound := document.Int("hell monster")
|
||
c.hellMonster[id] = hellMonsterFound && hellMonsterValue == 1
|
||
c.hellMonsterKnown[id] = true
|
||
return cloneDungeonMonsterDefinition(definition), true, nil
|
||
}
|
||
|
||
func (c *pvfDungeonMonsterCatalog) HellMonster(id int64) (bool, error) {
|
||
if c == nil || c.source == nil {
|
||
return false, errDungeonMonsterCatalogUnavailable
|
||
}
|
||
c.mu.Lock()
|
||
if c.hellMonsterKnown[id] {
|
||
value := c.hellMonster[id]
|
||
c.mu.Unlock()
|
||
return value, nil
|
||
}
|
||
c.mu.Unlock()
|
||
_, found, err := c.Find(id)
|
||
if err != nil || !found {
|
||
return false, err
|
||
}
|
||
c.mu.Lock()
|
||
value := c.hellMonster[id]
|
||
c.mu.Unlock()
|
||
return value, nil
|
||
}
|
||
|
||
func readDungeonMonsterText(source dnfpvf.Source, listedPath string) (string, string, error) {
|
||
candidates := []string{cleanDungeonMonsterPath(listedPath)}
|
||
if !strings.HasPrefix(strings.ToLower(candidates[0]), "monster/") {
|
||
candidates = append(candidates, path.Join("monster", candidates[0]))
|
||
}
|
||
var lastErr error
|
||
for _, candidate := range candidates {
|
||
text, err := source.ReadText(candidate)
|
||
if err == nil {
|
||
return candidate, text, nil
|
||
}
|
||
lastErr = err
|
||
}
|
||
return "", "", lastErr
|
||
}
|
||
|
||
func cleanDungeonMonsterPath(value string) string {
|
||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||
value = strings.TrimPrefix(value, "./")
|
||
return strings.TrimPrefix(value, "/")
|
||
}
|
||
|
||
func parseDungeonMonsterDefinition(id int64, docPath string, document *dnfpvf.Document) dnfmonster.Monster {
|
||
definition := dnfmonster.Monster{
|
||
ID: id,
|
||
Path: docPath,
|
||
Name: dungeonMonsterText(document, "name", "display name"),
|
||
Kind: dungeonMonsterText(document, "monster type", "type", "kind"),
|
||
Rank: dungeonMonsterText(document, "rank", "monster rank", "grade"),
|
||
Level: dungeonMonsterInt(document, "level", "monster level"),
|
||
HP: dungeonMonsterInt(document, "hp", "hit point", "max hp"),
|
||
Attack: dungeonMonsterInt(document, "attack", "physical attack"),
|
||
Defense: dungeonMonsterInt(document, "defense", "physical defense"),
|
||
Move: dungeonMonsterNumber(document, "move speed", "movement speed"),
|
||
Speed: dungeonMonsterNumber(document, "attack speed"),
|
||
Exp: dungeonMonsterInt(document, "exp", "experience"),
|
||
AI: dungeonMonsterText(document, "ai", "ai pattern"),
|
||
Icon: dungeonMonsterText(document, "icon", "icon path"),
|
||
Scalars: dungeonMonsterScalars(document),
|
||
}
|
||
if definition.Name == "" {
|
||
definition.Name = definition.Path
|
||
}
|
||
if document != nil {
|
||
definition.Sections = make([]string, 0, len(document.Sections))
|
||
for _, section := range document.Sections {
|
||
definition.Sections = append(definition.Sections, section.Name)
|
||
}
|
||
}
|
||
return definition
|
||
}
|
||
|
||
func dungeonMonsterText(document *dnfpvf.Document, names ...string) string {
|
||
for _, name := range names {
|
||
if value, ok := document.Text(name); ok {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func dungeonMonsterInt(document *dnfpvf.Document, names ...string) int64 {
|
||
for _, name := range names {
|
||
if value, ok := document.Int(name); ok {
|
||
return value
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func dungeonMonsterNumber(document *dnfpvf.Document, names ...string) float64 {
|
||
for _, name := range names {
|
||
if value, ok := document.Number(name); ok {
|
||
return value
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func dungeonMonsterScalars(document *dnfpvf.Document) map[string]float64 {
|
||
names := map[string][]string{
|
||
"hp": {"hp", "hit point", "max hp"},
|
||
"attack": {"attack", "physical attack"},
|
||
"magic_attack": {"magical attack", "magic attack"},
|
||
"defense": {"defense", "physical defense"},
|
||
"magic_defense": {"magical defense", "magic defense"},
|
||
"move_speed": {"move speed", "movement speed"},
|
||
"attack_speed": {"attack speed"},
|
||
"hit_recovery": {"hit recovery"},
|
||
"exp": {"exp", "experience"},
|
||
}
|
||
out := make(map[string]float64)
|
||
for key, aliases := range names {
|
||
for _, alias := range aliases {
|
||
if value, ok := document.Number(alias); ok {
|
||
out[key] = value
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cloneDungeonMonsterDefinition(definition dnfmonster.Monster) dnfmonster.Monster {
|
||
definition.Scalars = cloneFloatMap(definition.Scalars)
|
||
definition.Sections = append([]string(nil), definition.Sections...)
|
||
return definition
|
||
}
|