Files
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

297 lines
10 KiB
Go

package pet
import (
"errors"
"fmt"
"math"
"path"
"strings"
"sync"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
petEquipmentListPath = "equipment/equipment.lst"
petCreatureExperiencePath = "creature/exptable.tbl"
MaxCreatureLevel = 50
)
var (
ErrPetPVFSourceRequired = errors.New("pet PVF source is required")
ErrPetPVFEquipmentUnresolved = errors.New("pet equipment is not present in equipment.lst")
ErrPetPVFNotCreature = errors.New("pet equipment type is not creature")
ErrPetPVFHatchOutputInvalid = errors.New("pet hatch output index is invalid")
ErrPetPVFExperienceTableShape = errors.New("pet creature experience table is invalid")
)
// PetHatchDefinition is the typed, PVF-proven egg-to-creature mapping used by
// the hatch owner. It contains domain data only and has no packet fields.
type PetHatchDefinition struct {
EggItemID int64
HatchedItemID int64
EggPVFPath string
HatchedPVFPath string
// MinimumLevel is retained as typed output-item metadata. The 86JP hatch
// transaction still creates a new creature instance at level 1.
MinimumLevel int
}
// PetHatchResolver lets the mutation owner depend on a real PVF mapping rather
// than inventory Extra fields or request-body item-id guesses.
type PetHatchResolver interface {
ResolveHatch(eggItemID int64) (PetHatchDefinition, error)
}
// PetCreatureDefinition proves that an equipment-list item is a current
// runtime-PVF creature. It is deliberately small: growth persistence needs an
// item-kind authority, not protocol fields or an inferred slot convention.
type PetCreatureDefinition struct {
ItemID int64
PVFPath string
Name string
}
// PetCreatureResolver is the fail-closed item-kind boundary used before any
// equipped creature growth or satiety mutation. Slot 24 is also a valid
// ordinary support-weapon slot, so position and raw instance bytes alone are
// not sufficient proof that an item is a creature.
type PetCreatureResolver interface {
ResolveCreature(itemID int64) (PetCreatureDefinition, error)
}
// CreatureExperienceTable preserves the 50 values from
// creature/exptable.tbl. Current pet progression is capped at level 50, so
// LevelForExperience consumes the first 49 thresholds exactly like 86JP.
type CreatureExperienceTable struct {
thresholds [MaxCreatureLevel]int64
}
// Thresholds returns a copy of the authoritative PVF values.
func (t CreatureExperienceTable) Thresholds() []int64 {
out := make([]int64, len(t.thresholds))
copy(out, t.thresholds[:])
return out
}
// LevelForExperience maps cumulative creature experience to level 1..50.
func (t CreatureExperienceTable) LevelForExperience(experience int64) int {
if experience < 0 {
experience = 0
}
level := 1
for nextLevel := 2; nextLevel <= MaxCreatureLevel; nextLevel++ {
if t.thresholds[nextLevel-2] > experience {
break
}
level = nextLevel
}
return level
}
// PVFCatalog owns immutable pet item mappings and the creature experience
// table loaded from the runtime Script.pvf.
type PVFCatalog struct {
source dnfpvf.Source
equipmentPaths map[int64]string
experience CreatureExperienceTable
evolutionMu sync.Mutex
evolutionLoaded bool
evolutionIndex petEvolutionIndex
evolutionErr error
}
// NewPVFCatalog loads only the authoritative indexes needed for the first pet
// domain closure: equipment item references and creature experience values.
func NewPVFCatalog(source dnfpvf.Source) (*PVFCatalog, error) {
if source == nil {
return nil, ErrPetPVFSourceRequired
}
equipmentPaths, err := loadPetEquipmentPaths(source)
if err != nil {
return nil, err
}
experience, err := loadCreatureExperienceTable(source)
if err != nil {
return nil, err
}
return &PVFCatalog{
source: source,
equipmentPaths: equipmentPaths,
experience: experience,
}, nil
}
// ExperienceTable returns the immutable-by-value creature experience table.
func (c *PVFCatalog) ExperienceTable() CreatureExperienceTable {
if c == nil {
return CreatureExperienceTable{}
}
return c.experience
}
// ResolveCreature independently verifies the current item against the active
// equipment.lst and its equipment document. The same typed proof is reused by
// hatch/evolution code, while callers cannot inject a request-body item kind.
func (c *PVFCatalog) ResolveCreature(itemID int64) (PetCreatureDefinition, error) {
if c == nil || c.source == nil {
return PetCreatureDefinition{}, ErrPetPVFSourceRequired
}
documentPath, document, err := c.resolveCreatureEquipment(itemID)
if err != nil {
return PetCreatureDefinition{}, err
}
name, _ := document.Text("name")
return PetCreatureDefinition{
ItemID: itemID,
PVFPath: documentPath,
Name: strings.TrimSpace(name),
}, nil
}
// ResolveHatch applies the 86JP domain rule to current runtime PVF data: the
// source must be [creature] equipment and have a positive [output index]
// different from itself. The output is independently resolved and verified as
// creature equipment before the mapping can authorize a mutation.
func (c *PVFCatalog) ResolveHatch(eggItemID int64) (PetHatchDefinition, error) {
if c == nil || c.source == nil {
return PetHatchDefinition{}, ErrPetPVFSourceRequired
}
eggPath, eggDocument, err := c.resolveCreatureEquipment(eggItemID)
if err != nil {
return PetHatchDefinition{}, err
}
outputItemID, found := eggDocument.Int("output index")
if !found || outputItemID <= 0 || outputItemID > math.MaxUint32 || outputItemID == eggItemID {
return PetHatchDefinition{}, fmt.Errorf("%w: item_id=%d output_index=%d", ErrPetPVFHatchOutputInvalid, eggItemID, outputItemID)
}
hatchedPath, hatchedDocument, err := c.resolveCreatureEquipment(outputItemID)
if err != nil {
return PetHatchDefinition{}, fmt.Errorf("resolve pet hatch output item_id=%d: %w", outputItemID, err)
}
minimumLevel := 1
if value, found := hatchedDocument.Int("minimum level"); found {
if value <= 0 || value > MaxCreatureLevel {
return PetHatchDefinition{}, fmt.Errorf("%w: output item_id=%d minimum_level=%d", ErrPetPVFHatchOutputInvalid, outputItemID, value)
}
minimumLevel = int(value)
}
return PetHatchDefinition{
EggItemID: eggItemID,
HatchedItemID: outputItemID,
EggPVFPath: eggPath,
HatchedPVFPath: hatchedPath,
MinimumLevel: minimumLevel,
}, nil
}
func (c *PVFCatalog) resolveCreatureEquipment(itemID int64) (string, *dnfpvf.Document, error) {
if itemID <= 0 || itemID > math.MaxUint32 {
return "", nil, fmt.Errorf("%w: item_id=%d", ErrPetPVFEquipmentUnresolved, itemID)
}
listedPath, found := c.equipmentPaths[itemID]
if !found {
return "", nil, fmt.Errorf("%w: item_id=%d", ErrPetPVFEquipmentUnresolved, itemID)
}
documentPath, text, err := readPetPVFReference(c.source, "equipment", listedPath)
if err != nil {
return "", nil, fmt.Errorf("read pet equipment item_id=%d path=%s: %w", itemID, listedPath, err)
}
document, err := dnfpvf.Parse(documentPath, text)
if err != nil {
return "", nil, fmt.Errorf("parse pet equipment item_id=%d path=%s: %w", itemID, documentPath, err)
}
equipmentType, found := document.Text("equipment type")
if !found || !strings.Contains(normalizePetPVFType(equipmentType), "[creature]") {
return "", nil, fmt.Errorf("%w: item_id=%d equipment_type=%q", ErrPetPVFNotCreature, itemID, equipmentType)
}
return documentPath, document, nil
}
func loadPetEquipmentPaths(source dnfpvf.Source) (map[int64]string, error) {
text, err := source.ReadText(petEquipmentListPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", petEquipmentListPath, err)
}
document, err := dnfpvf.Parse(petEquipmentListPath, text)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", petEquipmentListPath, err)
}
entries := dnfpvf.ParseList(document)
paths := make(map[int64]string, len(entries))
for _, entry := range entries {
listedPath := cleanPetPVFPath(entry.Path)
if entry.ID <= 0 || entry.ID > math.MaxUint32 || listedPath == "" {
continue
}
if previous, exists := paths[entry.ID]; exists && !strings.EqualFold(previous, listedPath) {
return nil, fmt.Errorf("parse %s: duplicate item_id=%d paths=%q,%q", petEquipmentListPath, entry.ID, previous, listedPath)
}
paths[entry.ID] = listedPath
}
if len(paths) == 0 {
return nil, fmt.Errorf("parse %s: no valid item references", petEquipmentListPath)
}
return paths, nil
}
func loadCreatureExperienceTable(source dnfpvf.Source) (CreatureExperienceTable, error) {
if source == nil {
return CreatureExperienceTable{}, ErrPetPVFSourceRequired
}
text, err := source.ReadText(petCreatureExperiencePath)
if err != nil {
return CreatureExperienceTable{}, fmt.Errorf("read %s: %w", petCreatureExperiencePath, err)
}
document, err := dnfpvf.Parse(petCreatureExperiencePath, text)
if err != nil {
return CreatureExperienceTable{}, fmt.Errorf("parse %s: %w", petCreatureExperiencePath, err)
}
values := make([]int64, 0, MaxCreatureLevel)
for _, token := range document.Tokens {
if token.Kind == dnfpvf.TokenInt {
values = append(values, token.Int)
}
}
if len(values) < MaxCreatureLevel {
return CreatureExperienceTable{}, fmt.Errorf("%w: path=%s values=%d want_at_least=%d", ErrPetPVFExperienceTableShape, petCreatureExperiencePath, len(values), MaxCreatureLevel)
}
var table CreatureExperienceTable
for index, value := range values[:MaxCreatureLevel] {
if value <= 0 || (index > 0 && value <= values[index-1]) {
return CreatureExperienceTable{}, fmt.Errorf("%w: path=%s index=%d value=%d", ErrPetPVFExperienceTableShape, petCreatureExperiencePath, index, value)
}
table.thresholds[index] = value
}
return table, nil
}
func readPetPVFReference(source dnfpvf.Source, root, listedPath string) (string, string, error) {
clean := cleanPetPVFPath(listedPath)
candidates := []string{clean}
root = cleanPetPVFPath(root)
if root != "" && !strings.HasPrefix(strings.ToLower(clean), strings.ToLower(root)+"/") {
candidates = append(candidates, path.Join(root, clean))
}
var lastErr error
for _, candidate := range candidates {
text, err := source.ReadText(candidate)
if err == nil {
return candidate, text, nil
}
lastErr = err
}
return "", "", lastErr
}
func cleanPetPVFPath(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
value = strings.TrimPrefix(value, "./")
return strings.TrimPrefix(value, "/")
}
func normalizePetPVFType(value string) string {
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(strings.ReplaceAll(value, "`", ""))), " "))
}