DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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