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