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