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