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,4 @@
// Package charstat 从 DNF PVF 角色文件加载职业基础属性和成长属性。
//
// 本包只提供只读静态表和计算结果,不读取玩家数据库,也不写入运行时状态。
package charstat
@@ -0,0 +1,323 @@
package charstat
import (
"context"
"fmt"
"path"
"regexp"
"sort"
"strconv"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
var (
tagLineRE = regexp.MustCompile(`(?is)\[\s*%s\s*\]\s*([-\d.]+)`)
growtypeTagRE = regexp.MustCompile(`(?i)\[\s*growtype\s+([1-6])\s*\]`)
awakeningTagRE = regexp.MustCompile(`(?i)\[\s*awakening\s+([1-2])\s*\]`)
initialValueTag = regexp.MustCompile(`(?i)\[\s*initial\s+value\s*\]`)
)
// Load 从 PVF 文本来源加载 character.lst 和每个职业 chr 文件。
func Load(ctx context.Context, source Source, options Options) (*Table, error) {
if source == nil {
return nil, ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
listPath := cleanPath(options.ListPath)
if listPath == "" {
listPath = DefaultList
}
listText, err := source.ReadText(listPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(listPath, listText)
if err != nil {
return nil, err
}
entries := dnfpvf.ParseList(doc)
if len(entries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
table := &Table{jobs: make(map[byte]jobTables)}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
if entry.ID < 0 || entry.ID > 0xff || strings.TrimSpace(entry.Path) == "" {
continue
}
charPath := resolvePath(path.Dir(listPath), entry.Path)
text, actualPath, err := readTextAny(source, charPath, entry.Path)
if err != nil {
return nil, fmt.Errorf("read character stat job %d: %w", entry.ID, err)
}
tables, err := parseJobTables(actualPath, text)
if err != nil {
return nil, fmt.Errorf("parse character stat job %d %s: %w", entry.ID, actualPath, err)
}
table.jobs[byte(entry.ID)] = tables
}
if len(table.jobs) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
return table, nil
}
// Compute 计算 job/growType/level 对应的职业基础属性。
func (t *Table) Compute(job byte, growType byte, level int) (Vector, error) {
if t == nil {
return Vector{}, ErrSourceRequired
}
tables, ok := t.jobs[job]
if !ok {
return Vector{}, fmt.Errorf("%w: %d", ErrJobMissing, job)
}
first, second := DecodeGrowType(growType)
if first < 0 || first > 5 || second < 0 || second > 2 {
return Vector{}, fmt.Errorf("%w: job=%d grow=%d", ErrGrowthMissing, job, growType)
}
if level < 1 {
level = 1
}
out := tables.base
for current := 1; current < level; current++ {
switch {
case current <= 14:
growth, ok := tables.growth(job, 0, 0)
if !ok {
return Vector{}, fmt.Errorf("%w: job=%d growtype=1", ErrGrowthMissing, job)
}
out.add(growth)
case current <= 49:
growth, ok := tables.growth(job, first, 0)
if !ok {
return Vector{}, fmt.Errorf("%w: job=%d growtype=%d", ErrGrowthMissing, job, first+1)
}
out.add(growth)
default:
growth, ok := tables.growth(job, first, second)
if !ok {
return Vector{}, fmt.Errorf("%w: job=%d growtype=%d awakening=%d", ErrGrowthMissing, job, first+1, second)
}
out.add(growth)
}
}
return out, nil
}
func (t jobTables) growth(_ byte, first int, second int) (Vector, bool) {
growIndex := first + 1
if growIndex < 1 || growIndex >= len(t.growtype) {
return Vector{}, false
}
if second == 0 {
return t.growtype[growIndex], t.hasGrow[growIndex]
}
if second < 0 || second >= len(t.awakening[growIndex]) {
return Vector{}, false
}
return t.awakening[growIndex][second], t.hasAwake[growIndex][second]
}
func parseJobTables(charPath string, text string) (jobTables, error) {
initial := initialValueTag.FindStringIndex(text)
if initial == nil {
return jobTables{}, fmt.Errorf("missing [initial value]")
}
growMatches := growtypeTagRE.FindAllStringSubmatchIndex(text, -1)
firstGrow := len(text)
for _, match := range growMatches {
if match[0] > initial[0] && match[0] < firstGrow {
firstGrow = match[0]
}
}
tables := jobTables{
path: charPath,
base: parseVector(text[initial[0]:firstGrow]),
}
growStarts := make([]struct {
no int
start int
end int
}, 0, len(growMatches))
for _, match := range growMatches {
no, err := strconv.Atoi(text[match[2]:match[3]])
if err != nil || no < 1 || no > 6 {
continue
}
growStarts = append(growStarts, struct {
no int
start int
end int
}{no: no, start: match[0]})
}
sort.Slice(growStarts, func(i, j int) bool { return growStarts[i].start < growStarts[j].start })
for idx := range growStarts {
end := len(text)
if idx+1 < len(growStarts) {
end = growStarts[idx+1].start
}
growStarts[idx].end = end
block := text[growStarts[idx].start:end]
parseGrowthBlock(block, growStarts[idx].no, &tables)
}
if !tables.hasGrow[1] {
return jobTables{}, fmt.Errorf("missing [growtype 1]")
}
return tables, nil
}
func parseGrowthBlock(block string, growNo int, tables *jobTables) {
awakeMatches := awakeningTagRE.FindAllStringSubmatchIndex(block, -1)
growEnd := len(block)
if len(awakeMatches) > 0 {
growEnd = awakeMatches[0][0]
}
tables.growtype[growNo] = parseVector(block[:growEnd])
tables.hasGrow[growNo] = true
for idx, match := range awakeMatches {
no, err := strconv.Atoi(block[match[2]:match[3]])
if err != nil || no < 1 || no > 2 {
continue
}
end := len(block)
if idx+1 < len(awakeMatches) {
end = awakeMatches[idx+1][0]
}
tables.awakening[growNo][no] = parseVector(block[match[0]:end])
tables.hasAwake[growNo][no] = true
}
}
func parseVector(section string) Vector {
physicalAttack := statRaw(section, "physical attack")
physicalDefense := statRaw(section, "physical defense")
magicalAttack := statRaw(section, "magical attack")
magicalDefense := statRaw(section, "magical defense")
strength := statRaw(section, "strength")
intelligence := statRaw(section, "intelligence")
vitality := statRaw(section, "vitality")
spirit := statRaw(section, "spirit")
// 当前 23.4.15.0 PVF 的 etc/avatarabilitystringtable.etc 把
// PHYSICAL_ATTACK/PHYSICAL_DEFENSE/MAGICAL_ATTACK/MAGICAL_DEFENSE 显示为
// 力量/体力/智力/精神;部分 .chr 只写旧标签,所以这里按客户端能力表补齐四维。
if strength == 0 {
strength = physicalAttack
}
if vitality == 0 {
vitality = physicalDefense
}
if intelligence == 0 {
intelligence = magicalAttack
}
if spirit == 0 {
spirit = magicalDefense
}
return Vector{
HPMax: statScaled10(section, "HP MAX"),
MPMax: statScaled10(section, "MP MAX"),
Strength: strength,
Intelligence: intelligence,
Vitality: vitality,
Spirit: spirit,
PhysicalAttack: physicalAttack,
PhysicalDefense: physicalDefense,
MagicalAttack: magicalAttack,
MagicalDefense: magicalDefense,
IndependentAttack: statRaw(section, "independent attack"),
FireResistance: statRaw(section, "fire resistance"),
WaterResistance: statRaw(section, "water resistance"),
DarkResistance: statRaw(section, "dark resistance"),
LightResistance: statRaw(section, "light resistance"),
InventoryLimit: statScaled10(section, "inventory limit"),
HPRegenSpeed: statScaled10(section, "HP regen speed"),
MPRegenSpeed: statScaled10(section, "MP regen speed"),
MoveSpeed: statScaled10(section, "move speed"),
AttackSpeed: statScaled10(section, "attack speed"),
CastSpeed: statScaled10(section, "cast speed"),
HitRecovery: statScaled10(section, "hit recovery"),
JumpPower: statScaled10(section, "jump power"),
Weight: statScaled10(section, "weight"),
}
}
func statRaw(section string, key string) int64 {
return statNumber(section, key, 1)
}
func statScaled10(section string, key string) int64 {
return statNumber(section, key, 10)
}
func statNumber(section string, key string, scale float32) int64 {
pattern := fmt.Sprintf(tagLineRE.String(), regexp.QuoteMeta(key))
matches := regexp.MustCompile(pattern).FindStringSubmatch(section)
if len(matches) < 2 {
return 0
}
value, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return 0
}
return int64(float32(value) * scale)
}
func readTextAny(source Source, candidates ...string) (string, string, error) {
seen := make(map[string]struct{}, len(candidates))
var lastErr error
for _, candidate := range candidates {
clean := cleanPath(candidate)
if clean == "" {
continue
}
key := strings.ToLower(clean)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
text, err := source.ReadText(clean)
if err == nil {
return text, clean, nil
}
lastErr = err
}
if lastErr != nil {
return "", "", lastErr
}
return "", "", fmt.Errorf("pvf path candidates are empty")
}
func resolvePath(base string, ref string) string {
base = cleanPath(base)
ref = cleanPath(ref)
if base == "" || ref == "" {
return ref
}
lowerRef := strings.ToLower(ref)
lowerBase := strings.ToLower(base)
if lowerRef == lowerBase || strings.HasPrefix(lowerRef, lowerBase+"/") {
return ref
}
return cleanPath(path.Join(base, ref))
}
func cleanPath(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 clean
}
@@ -0,0 +1,120 @@
package charstat
import (
"context"
"testing"
)
type memSource map[string]string
func (s memSource) ReadText(relativePath string) (string, error) {
if text, ok := s[cleanPath(relativePath)]; ok {
return text, nil
}
return "", errMissing(relativePath)
}
type errMissing string
func (e errMissing) Error() string { return "missing pvf text: " + string(e) }
func TestLoadComputesJobGrowLevelFromPVF(t *testing.T) {
source := memSource{
"character/character.lst": "11 `female_swordman.chr`\n",
"character/female_swordman.chr": `
[initial value]
[HP MAX] 100
[MP MAX] 50
[strength] 8
[intelligence] 9
[vitality] 7
[spirit] 6
[physical attack] 3
[magical attack] 4
[inventory limit] 48000
[move speed] 850
[growtype 1]
[HP MAX] 10
[MP MAX] 20
[strength] 1
[growtype 2]
[HP MAX] 100
[MP MAX] 200
[strength] 10
[awakening 1]
[HP MAX] 1000
[MP MAX] 2000
[strength] 100
`,
}
table, err := Load(context.Background(), source, Options{})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
got, err := table.Compute(11, 0x11, 51)
if err != nil {
t.Fatalf("Compute() error = %v", err)
}
if got.HPMax != 1000+14*100+35*1000+10000 {
t.Fatalf("HPMax = %d", got.HPMax)
}
if got.MPMax != 500+14*200+35*2000+20000 {
t.Fatalf("MPMax = %d", got.MPMax)
}
if got.Strength != 8+14*1+35*10+100 {
t.Fatalf("Strength = %d", got.Strength)
}
if got.InventoryLimit != 480000 {
t.Fatalf("InventoryLimit = %d", got.InventoryLimit)
}
if got.MoveSpeed != 8500 {
t.Fatalf("MoveSpeed = %d", got.MoveSpeed)
}
}
func TestLoadMapsLegacyAttackDefenseTagsToPrimaryStats(t *testing.T) {
source := memSource{
"character/character.lst": "11 `female_swordman.chr`\n",
"character/female_swordman.chr": `
[initial value]
[HP MAX] 170
[MP MAX] 150
[physical attack] 7
[physical defense] 6
[magical attack] 5
[magical defense] 4
[growtype 1]
[HP MAX] 1
`,
}
table, err := Load(context.Background(), source, Options{})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
got, err := table.Compute(11, 0, 1)
if err != nil {
t.Fatalf("Compute() error = %v", err)
}
if got.Strength != 7 || got.Vitality != 6 || got.Intelligence != 5 || got.Spirit != 4 {
t.Fatalf("primary stats = str:%d vit:%d int:%d spr:%d", got.Strength, got.Vitality, got.Intelligence, got.Spirit)
}
if got.PhysicalAttack != 7 || got.PhysicalDefense != 6 || got.MagicalAttack != 5 || got.MagicalDefense != 4 {
t.Fatalf("combat stats = pa:%d pd:%d ma:%d md:%d", got.PhysicalAttack, got.PhysicalDefense, got.MagicalAttack, got.MagicalDefense)
}
}
func TestComputeReturnsErrorWhenGrowthMissing(t *testing.T) {
source := memSource{
"character/character.lst": "1 `fighter.chr`\n",
"character/fighter.chr": "[initial value]\n[HP MAX] 1\n[growtype 1]\n[HP MAX] 1\n",
}
table, err := Load(context.Background(), source, Options{})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if _, err := table.Compute(1, 0x01, 16); err == nil {
t.Fatalf("Compute() expected missing growth error")
}
}
@@ -0,0 +1,115 @@
package charstat
import "errors"
const DefaultList = "character/character.lst"
var (
ErrSourceRequired = errors.New("dnf character stat source is required")
ErrListEmpty = errors.New("dnf character stat list is empty")
ErrJobMissing = errors.New("dnf character stat job is missing")
ErrGrowthMissing = errors.New("dnf character stat growth is missing")
)
// Source 表示已经加载到内存的 PVF 文本来源。
type Source interface {
ReadText(relativePath string) (string, error)
}
type Options struct {
ListPath string
}
// Vector 是客户端 USERINFO 属性块使用的 10 倍整数属性。
type Vector struct {
HPMax int64
MPMax int64
Strength int64
Intelligence int64
Vitality int64
Spirit int64
PhysicalAttack int64
PhysicalDefense int64
MagicalAttack int64
MagicalDefense int64
IndependentAttack int64
FireResistance int64
WaterResistance int64
DarkResistance int64
LightResistance int64
InventoryLimit int64
HPRegenSpeed int64
MPRegenSpeed int64
MoveSpeed int64
AttackSpeed int64
CastSpeed int64
HitRecovery int64
JumpPower int64
Weight int64
}
func (v *Vector) add(other Vector) {
v.HPMax += other.HPMax
v.MPMax += other.MPMax
v.Strength += other.Strength
v.Intelligence += other.Intelligence
v.Vitality += other.Vitality
v.Spirit += other.Spirit
v.PhysicalAttack += other.PhysicalAttack
v.PhysicalDefense += other.PhysicalDefense
v.MagicalAttack += other.MagicalAttack
v.MagicalDefense += other.MagicalDefense
v.IndependentAttack += other.IndependentAttack
v.FireResistance += other.FireResistance
v.WaterResistance += other.WaterResistance
v.DarkResistance += other.DarkResistance
v.LightResistance += other.LightResistance
v.InventoryLimit += other.InventoryLimit
v.HPRegenSpeed += other.HPRegenSpeed
v.MPRegenSpeed += other.MPRegenSpeed
v.MoveSpeed += other.MoveSpeed
v.AttackSpeed += other.AttackSpeed
v.CastSpeed += other.CastSpeed
v.HitRecovery += other.HitRecovery
v.JumpPower += other.JumpPower
v.Weight += other.Weight
}
// Add merges another stat vector into v. Callers outside this package use it
// to aggregate PVF character growth with item-derived PVF stats.
func (v *Vector) Add(other Vector) {
if v == nil {
return
}
v.add(other)
}
type jobTables struct {
path string
base Vector
growtype [7]Vector
hasGrow [7]bool
awakening [7][3]Vector
hasAwake [7][3]bool
}
// Table 是按 job 编号索引的角色成长表。
type Table struct {
jobs map[byte]jobTables
}
type Snapshot struct {
Jobs int `json:"jobs"`
}
func (t *Table) Snapshot() Snapshot {
if t == nil {
return Snapshot{}
}
return Snapshot{Jobs: len(t.jobs)}
}
// DecodeGrowType 拆出客户端角色记录里的 grow_type 低 4 位转职和高 4 位觉醒。
func DecodeGrowType(growType byte) (first int, second int) {
return int(growType & 0x0f), int((growType >> 4) & 0x0f)
}