服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
240 lines
8.1 KiB
Go
240 lines
8.1 KiB
Go
package pet
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
)
|
|
|
|
const MaxCreatureExperience int64 = math.MaxInt32
|
|
|
|
const petSatietyScale int64 = 1_000_000
|
|
|
|
var (
|
|
ErrPetGrowthCatalogRequired = errors.New("pet growth catalog is required")
|
|
ErrPetGrowthStateInvalid = errors.New("pet growth state is invalid")
|
|
ErrPetGrowthElapsedInvalid = errors.New("pet growth elapsed duration is invalid")
|
|
)
|
|
|
|
// PetGrowthState contains the typed fields changed by the pure growth domain.
|
|
type PetGrowthState struct {
|
|
ItemID int64
|
|
Experience int64
|
|
Level int
|
|
Satiety int
|
|
// SatietyMicros is the authoritative continuous gauge. A zero value with a
|
|
// positive Satiety is treated as a legacy integer-only state.
|
|
SatietyMicros int64
|
|
}
|
|
|
|
// PetEvolutionUpdate distinguishes an automatic item transition from an
|
|
// eligible quest-gated transition.
|
|
type PetEvolutionUpdate struct {
|
|
Definition PetEvolutionDefinition
|
|
Changed bool
|
|
QuestEligible bool
|
|
}
|
|
|
|
// PetGrowthUpdate is a pure before/after result; persistence is owned by the
|
|
// caller's aggregate transaction.
|
|
type PetGrowthUpdate struct {
|
|
Before PetGrowthState
|
|
After PetGrowthState
|
|
ExperienceGained int64
|
|
Evolution PetEvolutionUpdate
|
|
}
|
|
|
|
// PetSatietyUpdate is the pure result of one elapsed-time calculation.
|
|
type PetSatietyUpdate struct {
|
|
Before PetGrowthState
|
|
After PetGrowthState
|
|
Elapsed time.Duration
|
|
SatietyDelta int
|
|
Changed bool
|
|
FoodMultiplier float64
|
|
}
|
|
|
|
// PetGrowthEngine applies 86JP's domain rules using only typed PVF catalog
|
|
// inputs. It has no repository, clock, timer, or protocol responsibility.
|
|
type PetGrowthEngine struct {
|
|
experience CreatureExperienceTable
|
|
evolution PetEvolutionResolver
|
|
creatures PetCreatureResolver
|
|
}
|
|
|
|
func NewPetGrowthEngine(catalog *PVFCatalog) (*PetGrowthEngine, error) {
|
|
if catalog == nil || catalog.source == nil {
|
|
return nil, ErrPetGrowthCatalogRequired
|
|
}
|
|
return &PetGrowthEngine{experience: catalog.ExperienceTable(), evolution: catalog, creatures: catalog}, nil
|
|
}
|
|
|
|
func (e *PetGrowthEngine) resolveCreature(itemID int64) (PetCreatureDefinition, error) {
|
|
if e == nil || e.creatures == nil {
|
|
return PetCreatureDefinition{}, ErrPetGrowthCatalogRequired
|
|
}
|
|
definition, err := e.creatures.ResolveCreature(itemID)
|
|
if err != nil {
|
|
return PetCreatureDefinition{}, err
|
|
}
|
|
if definition.ItemID != itemID || definition.PVFPath == "" {
|
|
return PetCreatureDefinition{}, fmt.Errorf("%w: item_id=%d definition=%+v", ErrPetGrowthCatalogRequired, itemID, definition)
|
|
}
|
|
return definition, nil
|
|
}
|
|
|
|
// ApplyDungeonClear grants cumulative creature experience equal to consumed
|
|
// fatigue only while satiety is positive. A single clear can trigger at most
|
|
// one automatic evolution, matching the 86JP domain owner.
|
|
func (e *PetGrowthEngine) ApplyDungeonClear(state PetGrowthState, consumedFatigue int) (PetGrowthUpdate, error) {
|
|
return e.applyDungeonClear(state, consumedFatigue, true)
|
|
}
|
|
|
|
// applyDungeonClear keeps current online progression independent from the
|
|
// still opt-in evolution route. Runtime callers pass resolveEvolution=false
|
|
// until the current-client evolution wire is closed; that path must not lose
|
|
// earned experience merely because a valid creature equipment document has no
|
|
// creature.lst identity.
|
|
func (e *PetGrowthEngine) applyDungeonClear(state PetGrowthState, consumedFatigue int, resolveEvolution bool) (PetGrowthUpdate, error) {
|
|
if e == nil || (resolveEvolution && e.evolution == nil) {
|
|
return PetGrowthUpdate{}, ErrPetGrowthCatalogRequired
|
|
}
|
|
if err := validatePetGrowthState(state); err != nil {
|
|
return PetGrowthUpdate{}, err
|
|
}
|
|
update := PetGrowthUpdate{Before: state, After: state}
|
|
if consumedFatigue <= 0 || petSatietyMicros(state) <= 0 || state.Level >= MaxCreatureLevel {
|
|
return update, nil
|
|
}
|
|
gained := int64(consumedFatigue)
|
|
afterExperience := state.Experience
|
|
if gained >= MaxCreatureExperience-afterExperience {
|
|
afterExperience = MaxCreatureExperience
|
|
} else {
|
|
afterExperience += gained
|
|
}
|
|
afterLevel := e.experience.LevelForExperience(afterExperience)
|
|
if afterLevel < state.Level {
|
|
afterLevel = state.Level
|
|
}
|
|
if afterLevel > MaxCreatureLevel {
|
|
afterLevel = MaxCreatureLevel
|
|
}
|
|
update.After.Experience = afterExperience
|
|
update.After.Level = afterLevel
|
|
update.ExperienceGained = afterExperience - state.Experience
|
|
if !resolveEvolution || afterLevel <= state.Level {
|
|
return update, nil
|
|
}
|
|
definition, found, err := e.evolution.ResolveEvolution(state.ItemID)
|
|
if err != nil {
|
|
return PetGrowthUpdate{}, fmt.Errorf("resolve pet evolution: %w", err)
|
|
}
|
|
if !found || afterLevel < definition.RequiredLevel {
|
|
return update, nil
|
|
}
|
|
update.Evolution.Definition = definition
|
|
if definition.RequiresQuest {
|
|
update.Evolution.QuestEligible = true
|
|
return update, nil
|
|
}
|
|
update.After.ItemID = definition.TargetItemID
|
|
update.Evolution.Changed = true
|
|
return update, nil
|
|
}
|
|
|
|
// ApplyDungeonElapsed consumes one satiety point per elapsed minute multiplied
|
|
// by the typed artifact modifier. Positive fractional remainder is truncated;
|
|
// a still-positive creature remains visible with at least one satiety.
|
|
func (e *PetGrowthEngine) ApplyDungeonElapsed(state PetGrowthState, elapsed time.Duration, modifiers PetSatietyModifiers) (PetSatietyUpdate, error) {
|
|
if e == nil {
|
|
return PetSatietyUpdate{}, ErrPetGrowthCatalogRequired
|
|
}
|
|
if err := validatePetGrowthState(state); err != nil {
|
|
return PetSatietyUpdate{}, err
|
|
}
|
|
if elapsed < 0 {
|
|
return PetSatietyUpdate{}, ErrPetGrowthElapsedInvalid
|
|
}
|
|
multiplier := modifiers.FoodConsumeMultiplier()
|
|
update := PetSatietyUpdate{Before: state, After: state, Elapsed: elapsed, FoodMultiplier: multiplier}
|
|
beforeMicros := petSatietyMicros(state)
|
|
if elapsed == 0 || beforeMicros <= 0 {
|
|
return update, nil
|
|
}
|
|
consumedMicros := int64(math.Round(elapsed.Seconds() / 60 * multiplier * float64(petSatietyScale)))
|
|
afterMicros := beforeMicros - consumedMicros
|
|
if afterMicros < 0 {
|
|
afterMicros = 0
|
|
}
|
|
after := visiblePetSatietyFromMicros(afterMicros, true)
|
|
update.After.Satiety = after
|
|
update.After.SatietyMicros = afterMicros
|
|
update.SatietyDelta = after - state.Satiety
|
|
update.Changed = afterMicros != beforeMicros
|
|
return update, nil
|
|
}
|
|
|
|
// ApplyTownElapsed restores one satiety point per 360 elapsed seconds, capped
|
|
// at 100. Artifact modifiers do not affect recovery.
|
|
func (e *PetGrowthEngine) ApplyTownElapsed(state PetGrowthState, elapsed time.Duration) (PetSatietyUpdate, error) {
|
|
if e == nil {
|
|
return PetSatietyUpdate{}, ErrPetGrowthCatalogRequired
|
|
}
|
|
if err := validatePetGrowthState(state); err != nil {
|
|
return PetSatietyUpdate{}, err
|
|
}
|
|
if elapsed < 0 {
|
|
return PetSatietyUpdate{}, ErrPetGrowthElapsedInvalid
|
|
}
|
|
update := PetSatietyUpdate{Before: state, After: state, Elapsed: elapsed, FoodMultiplier: 1}
|
|
beforeMicros := petSatietyMicros(state)
|
|
if elapsed == 0 || beforeMicros >= 100*petSatietyScale {
|
|
return update, nil
|
|
}
|
|
recoveredMicros := int64(math.Round(elapsed.Seconds() / 360 * float64(petSatietyScale)))
|
|
afterMicros := beforeMicros + recoveredMicros
|
|
if afterMicros > 100*petSatietyScale {
|
|
afterMicros = 100 * petSatietyScale
|
|
}
|
|
after := visiblePetSatietyFromMicros(afterMicros, false)
|
|
update.After.Satiety = after
|
|
update.After.SatietyMicros = afterMicros
|
|
update.SatietyDelta = after - state.Satiety
|
|
update.Changed = afterMicros != beforeMicros
|
|
return update, nil
|
|
}
|
|
|
|
func petSatietyMicros(state PetGrowthState) int64 {
|
|
if state.SatietyMicros > 0 || state.Satiety == 0 {
|
|
return state.SatietyMicros
|
|
}
|
|
return int64(state.Satiety) * petSatietyScale
|
|
}
|
|
|
|
func visiblePetSatietyFromMicros(value int64, clampAliveMinimum bool) int {
|
|
if value <= 0 {
|
|
return 0
|
|
}
|
|
if value >= 100*petSatietyScale {
|
|
return 100
|
|
}
|
|
visible := int(value / petSatietyScale)
|
|
if visible == 0 && clampAliveMinimum {
|
|
return 1
|
|
}
|
|
return visible
|
|
}
|
|
|
|
func validatePetGrowthState(state PetGrowthState) error {
|
|
if state.ItemID <= 0 || state.ItemID > math.MaxUint32 ||
|
|
state.Experience < 0 || state.Experience > MaxCreatureExperience ||
|
|
state.Level < 1 || state.Level > MaxCreatureLevel ||
|
|
state.Satiety < 0 || state.Satiety > 100 ||
|
|
state.SatietyMicros < 0 || state.SatietyMicros > 100*petSatietyScale {
|
|
return fmt.Errorf("%w: %+v", ErrPetGrowthStateInvalid, state)
|
|
}
|
|
return nil
|
|
}
|