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,4 @@
|
||||
// Package monster 负责 DNF 怪物 PVF 的项目层强类型加载。
|
||||
//
|
||||
// 本包只解析怪物静态配置,不处理怪物 AI tick、仇恨和战斗伤害结算。
|
||||
package monster
|
||||
@@ -0,0 +1,53 @@
|
||||
// 本文件负责从 DNF PVF 内存索引加载怪物强类型表。
|
||||
package monster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// Load 从已构建的 DNF PVF 内存索引加载怪物表。
|
||||
// 这里只解析怪物静态数据,不创建战斗实体,也不推进 AI tick。
|
||||
func Load(ctx context.Context, index *dnfpvf.Index, options Options) (*Table, error) {
|
||||
if index == nil {
|
||||
return nil, ErrIndexRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
listPath := options.ListPath
|
||||
if listPath == "" {
|
||||
listPath = DefaultList
|
||||
}
|
||||
entries := index.List(listPath)
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
|
||||
}
|
||||
table := &Table{
|
||||
monsters: make([]Monster, 0, len(entries)),
|
||||
byID: make(map[int64]int, len(entries)),
|
||||
byPath: make(map[string]int, len(entries)),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, ok := index.Document(entry.Path)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrDocMissing, entry.Path)
|
||||
}
|
||||
monster := parse(entry.ID, entry.Path, doc)
|
||||
idx := len(table.monsters)
|
||||
table.monsters = append(table.monsters, monster)
|
||||
if _, exists := table.byID[monster.ID]; !exists {
|
||||
table.byID[monster.ID] = idx
|
||||
}
|
||||
key := pathKey(monster.Path)
|
||||
if key != "" {
|
||||
table.byPath[key] = idx
|
||||
}
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package monster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
func TestLoadBuildsMonsterTable(t *testing.T) {
|
||||
table, err := Load(context.Background(), testIndex(t), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("load monster: %v", err)
|
||||
}
|
||||
if snapshot := table.Snapshot(); snapshot.Monsters != 1 {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
monster, ok := table.Find(3001)
|
||||
if !ok {
|
||||
t.Fatalf("expected monster")
|
||||
}
|
||||
if monster.Name != "training dummy" || monster.Kind != "normal" || monster.Level != 7 || monster.HP != 120 {
|
||||
t.Fatalf("unexpected monster: %+v", monster)
|
||||
}
|
||||
if monster.Scalars["attack"] != 21 || monster.Scalars["move_speed"] != 2.5 {
|
||||
t.Fatalf("unexpected scalars: %+v", monster.Scalars)
|
||||
}
|
||||
monster.Scalars["attack"] = 1
|
||||
again, _ := table.FindPath("./MONSTER/training_dummy.mob")
|
||||
if again.Scalars["attack"] != 21 {
|
||||
t.Fatalf("table returned mutable scalars: %+v", again.Scalars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresIndex(t *testing.T) {
|
||||
_, err := Load(context.Background(), nil, Options{})
|
||||
if !errors.Is(err, ErrIndexRequired) {
|
||||
t.Fatalf("expected ErrIndexRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReportsEmptyList(t *testing.T) {
|
||||
index, err := dnfpvf.Build(context.Background(), textSource{
|
||||
"monster/monster.lst": "3001 `monster/missing.mob`\n",
|
||||
}, dnfpvf.BuildOptions{Paths: []string{"monster/monster.lst"}})
|
||||
if err != nil {
|
||||
t.Fatalf("build index: %v", err)
|
||||
}
|
||||
_, err = Load(context.Background(), index, Options{})
|
||||
if !errors.Is(err, ErrListEmpty) {
|
||||
t.Fatalf("expected ErrListEmpty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testIndex(t *testing.T) *dnfpvf.Index {
|
||||
t.Helper()
|
||||
index, err := dnfpvf.Build(context.Background(), textSource{
|
||||
"monster/monster.lst": "3001 `monster/training_dummy.mob`\n",
|
||||
"monster/training_dummy.mob": `
|
||||
[name]
|
||||
` + "`training dummy`" + `
|
||||
[monster type]
|
||||
` + "`normal`" + `
|
||||
[level]
|
||||
7
|
||||
[hp]
|
||||
120
|
||||
[attack]
|
||||
21
|
||||
[move speed]
|
||||
2.5
|
||||
[exp]
|
||||
8
|
||||
`,
|
||||
}, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
|
||||
if err != nil {
|
||||
t.Fatalf("build index: %v", err)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
type textSource map[string]string
|
||||
|
||||
func (s textSource) ReadText(relativePath string) (string, error) {
|
||||
for key, value := range s {
|
||||
if pathKey(key) == pathKey(relativePath) {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return "", dnfpvf.ErrDocNotFound
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 本文件负责把通用 PVF Document 转成怪物领域字段。
|
||||
package monster
|
||||
|
||||
import dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
|
||||
// parse 把单个怪物 PVF 文档收敛成稳定字段。
|
||||
// 怪物出生、AI、受击和死亡事件由 dungeon/battle room 使用这些静态字段驱动。
|
||||
func parse(id int64, monsterPath string, doc *dnfpvf.Document) Monster {
|
||||
monster := Monster{
|
||||
ID: id,
|
||||
Path: monsterPath,
|
||||
Name: firstText(doc, "name", "display name"),
|
||||
Kind: firstText(doc, "monster type", "type", "kind"),
|
||||
Rank: firstText(doc, "rank", "monster rank", "grade"),
|
||||
Level: firstInt(doc, "level", "monster level"),
|
||||
HP: firstInt(doc, "hp", "hit point", "max hp"),
|
||||
Attack: firstInt(doc, "attack", "physical attack"),
|
||||
Defense: firstInt(doc, "defense", "physical defense"),
|
||||
Move: firstNumberOrZero(doc, "move speed", "movement speed"),
|
||||
Speed: firstNumberOrZero(doc, "attack speed"),
|
||||
Exp: firstInt(doc, "exp", "experience"),
|
||||
AI: firstText(doc, "ai", "ai pattern"),
|
||||
Icon: firstText(doc, "icon", "icon path"),
|
||||
Scalars: scalars(doc),
|
||||
Sections: sectionNames(doc),
|
||||
}
|
||||
if monster.Name == "" {
|
||||
monster.Name = monster.Path
|
||||
}
|
||||
return monster
|
||||
}
|
||||
|
||||
// scalars 提取怪物基础数值,供战斗房间启动后直接内存查询。
|
||||
func scalars(doc *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 {
|
||||
if value, ok := firstNumber(doc, aliases...); ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sectionNames 保留源文档段落名,便于真实 PVF 样本接入时排查字段覆盖范围。
|
||||
func sectionNames(doc *dnfpvf.Document) []string {
|
||||
if doc == nil || len(doc.Sections) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(doc.Sections))
|
||||
for _, section := range doc.Sections {
|
||||
out = append(out, section.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstText(doc *dnfpvf.Document, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value, ok := doc.Text(name); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstInt(doc *dnfpvf.Document, names ...string) int64 {
|
||||
for _, name := range names {
|
||||
if value, ok := doc.Int(name); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNumber(doc *dnfpvf.Document, names ...string) (float64, bool) {
|
||||
for _, name := range names {
|
||||
if value, ok := doc.Number(name); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func firstNumberOrZero(doc *dnfpvf.Document, names ...string) float64 {
|
||||
value, _ := firstNumber(doc, names...)
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package monster
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func pathKey(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
for strings.HasPrefix(value, "./") || strings.HasPrefix(value, "/") {
|
||||
value = strings.TrimPrefix(value, "./")
|
||||
value = strings.TrimPrefix(value, "/")
|
||||
}
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
clean := strings.TrimSuffix(path.Clean(value), "/")
|
||||
if clean == "." {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(clean)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 本文件定义 DNF 怪物表的内存视图和查询接口。
|
||||
package monster
|
||||
|
||||
import "errors"
|
||||
|
||||
const DefaultList = "monster/monster.lst"
|
||||
|
||||
var (
|
||||
ErrIndexRequired = errors.New("dnf monster index is required")
|
||||
ErrListEmpty = errors.New("dnf monster list is empty")
|
||||
ErrDocMissing = errors.New("dnf monster document is missing")
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ListPath string
|
||||
}
|
||||
|
||||
type Monster struct {
|
||||
ID int64 `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Rank string `json:"rank,omitempty"`
|
||||
Level int64 `json:"level,omitempty"`
|
||||
HP int64 `json:"hp,omitempty"`
|
||||
Attack int64 `json:"attack,omitempty"`
|
||||
Defense int64 `json:"defense,omitempty"`
|
||||
Move float64 `json:"move,omitempty"`
|
||||
Speed float64 `json:"speed,omitempty"`
|
||||
Exp int64 `json:"exp,omitempty"`
|
||||
AI string `json:"ai,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Scalars map[string]float64 `json:"scalars,omitempty"`
|
||||
Sections []string `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
monsters []Monster
|
||||
byID map[int64]int
|
||||
byPath map[string]int
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Monsters int `json:"monsters"`
|
||||
}
|
||||
|
||||
// Monsters 返回怪物表副本,避免调用方改坏内存索引。
|
||||
func (t *Table) Monsters() []Monster {
|
||||
if t == nil || len(t.monsters) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Monster, len(t.monsters))
|
||||
for idx, monster := range t.monsters {
|
||||
out[idx] = cloneMonster(monster)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Find 按 `.lst` 中的怪物 id 查询内存表。
|
||||
func (t *Table) Find(id int64) (Monster, bool) {
|
||||
if t == nil {
|
||||
return Monster{}, false
|
||||
}
|
||||
idx, ok := t.byID[id]
|
||||
if !ok {
|
||||
return Monster{}, false
|
||||
}
|
||||
return cloneMonster(t.monsters[idx]), true
|
||||
}
|
||||
|
||||
// FindPath 按 PVF 相对路径查询内存表。
|
||||
func (t *Table) FindPath(monsterPath string) (Monster, bool) {
|
||||
if t == nil {
|
||||
return Monster{}, false
|
||||
}
|
||||
idx, ok := t.byPath[pathKey(monsterPath)]
|
||||
if !ok {
|
||||
return Monster{}, false
|
||||
}
|
||||
return cloneMonster(t.monsters[idx]), true
|
||||
}
|
||||
|
||||
// Snapshot 返回怪物表当前规模,用于启动日志和 debug 面板。
|
||||
func (t *Table) Snapshot() Snapshot {
|
||||
if t == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
return Snapshot{Monsters: len(t.monsters)}
|
||||
}
|
||||
|
||||
// cloneMonster 复制切片和 map,保证加载后的表只读。
|
||||
func cloneMonster(monster Monster) Monster {
|
||||
if len(monster.Scalars) > 0 {
|
||||
scalars := make(map[string]float64, len(monster.Scalars))
|
||||
for key, value := range monster.Scalars {
|
||||
scalars[key] = value
|
||||
}
|
||||
monster.Scalars = scalars
|
||||
}
|
||||
monster.Sections = append([]string(nil), monster.Sections...)
|
||||
return monster
|
||||
}
|
||||
Reference in new issue
Block a user