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

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

324 lines
9.2 KiB
Go

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
}