Files
90CNServer/server/internal/modules/dnf/pet/growth_owner.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

399 lines
14 KiB
Go

package pet
import (
"context"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
equippedCreatureListType byte = 3
equippedCreatureSlot int16 = 26
maxGrowthClearTokenBytes = 256
maxGrowthClearTokenHistory = 128
)
var (
ErrPetGrowthOwnerUnavailable = errors.New("pet growth owner unavailable")
ErrPetGrowthTransaction = errors.New("pet growth transaction unavailable")
ErrPetGrowthTokenRequired = errors.New("authoritative pet growth clear token is required")
ErrPetGrowthEquippedMismatch = errors.New("equipped creature persistence is inconsistent")
)
// DungeonClearGrowthCommand is produced from an authoritative dungeon run.
// ClearToken must be server-owned run identity, never a request-provided value.
type DungeonClearGrowthCommand struct {
SelectedCharacterID uint16
ClearToken string
ConsumedFatigue int
// ApplyEvolution is intentionally opt-in. Current NoPack's incremental
// experience route is proved, while the evolution wire is not. Online
// callers must leave this false until that route is closed.
ApplyEvolution bool
}
// PetElapsedCommand applies elapsed wall-clock state to the currently worn
// creature. Modifiers can only be populated by this package's typed PVF
// catalog (the field itself is private); the zero value means no artifacts.
type PetElapsedCommand struct {
SelectedCharacterID uint16
Elapsed time.Duration
Modifiers PetSatietyModifiers
}
// PetGrowthPersistenceResult describes committed domain state and contains no
// current-client packet fields.
type PetGrowthPersistenceResult struct {
CharacterID string
PetKey string
Equipped bool
Replayed bool
Changed bool
Before PetGrowthState
After PetGrowthState
ExperienceGained int64
Evolution PetEvolutionUpdate
SatietyDelta int
}
// GrowthOwner atomically owns creature experience, evolution and satiety.
type GrowthOwner struct {
characterPets dnfrepo.CharacterPetUnitOfWork
engine *PetGrowthEngine
now func() time.Time
}
func NewGrowthOwner(repos dnfrepo.Group, engine *PetGrowthEngine) (*GrowthOwner, error) {
if engine == nil {
return nil, ErrPetGrowthOwnerUnavailable
}
if repos.CharacterPets == nil {
return nil, ErrPetGrowthTransaction
}
return &GrowthOwner{characterPets: repos.CharacterPets, engine: engine, now: time.Now}, nil
}
// ApplyDungeonClear grants experience to exactly the persisted slot-26
// creature. The durable clear token is checked across every creature entry so
// switching the worn creature cannot replay the same run reward.
func (o *GrowthOwner) ApplyDungeonClear(ctx context.Context, cmd DungeonClearGrowthCommand) (PetGrowthPersistenceResult, error) {
characterID, err := growthCharacterID(cmd.SelectedCharacterID)
if err != nil {
return PetGrowthPersistenceResult{}, err
}
token := strings.TrimSpace(cmd.ClearToken)
if token == "" || token != cmd.ClearToken || len(token) > maxGrowthClearTokenBytes {
return PetGrowthPersistenceResult{}, ErrPetGrowthTokenRequired
}
if cmd.ConsumedFatigue < 0 {
return PetGrowthPersistenceResult{}, ErrPetGrowthStateInvalid
}
return o.withEquipped(ctx, characterID, func(
petRecord *dnfrepo.PetRecord,
petKey string,
entry *dnfrepo.PetEntry,
equipment *dnfrepo.EquipmentRecord,
worn *dnfrepo.EquipmentEntry,
) (PetGrowthPersistenceResult, error) {
for _, candidate := range petRecord.Entries {
if candidate.AppliedClearTokens[token] {
state, stateErr := petGrowthState(*entry)
if stateErr != nil {
return PetGrowthPersistenceResult{}, stateErr
}
return PetGrowthPersistenceResult{
CharacterID: characterID,
PetKey: petKey,
Equipped: true,
Replayed: true,
Before: state,
After: state,
}, nil
}
}
before, err := petGrowthState(*entry)
if err != nil {
return PetGrowthPersistenceResult{}, err
}
update, err := o.engine.applyDungeonClear(before, cmd.ConsumedFatigue, cmd.ApplyEvolution)
if err != nil {
return PetGrowthPersistenceResult{}, err
}
entry.Exp = update.After.Experience
entry.Level = int64(update.After.Level)
rememberGrowthClearToken(entry, token)
if update.Evolution.Changed {
if err := applyWornPetEvolution(entry, worn, update.Before.ItemID, update.After.ItemID); err != nil {
return PetGrowthPersistenceResult{}, err
}
equipment.Entries[strconv.Itoa(int(equippedCreatureSlot))] = *worn
}
petRecord.Entries[petKey] = *entry
return PetGrowthPersistenceResult{
CharacterID: characterID,
PetKey: petKey,
Equipped: true,
Changed: true,
Before: before,
After: update.After,
ExperienceGained: update.ExperienceGained,
Evolution: update.Evolution,
}, nil
})
}
// rememberGrowthClearToken keeps a bounded, ordered idempotency window. Older
// rows that only contain the legacy map are normalized deterministically on
// their next successful clear, so existing JSON remains readable without
// allowing the entries blob to grow forever.
func rememberGrowthClearToken(entry *dnfrepo.PetEntry, token string) {
if entry == nil || token == "" {
return
}
active := make(map[string]bool, len(entry.AppliedClearTokens)+1)
order := make([]string, 0, len(entry.AppliedClearTokens)+1)
for _, candidate := range entry.AppliedClearTokenOrder {
if candidate == "" || active[candidate] || !entry.AppliedClearTokens[candidate] {
continue
}
active[candidate] = true
order = append(order, candidate)
}
legacy := make([]string, 0, len(entry.AppliedClearTokens))
for candidate, applied := range entry.AppliedClearTokens {
if applied && candidate != "" && !active[candidate] {
legacy = append(legacy, candidate)
}
}
sort.Strings(legacy)
for _, candidate := range legacy {
active[candidate] = true
order = append(order, candidate)
}
if !active[token] {
active[token] = true
order = append(order, token)
}
if overflow := len(order) - maxGrowthClearTokenHistory; overflow > 0 {
order = append([]string(nil), order[overflow:]...)
active = make(map[string]bool, len(order))
for _, candidate := range order {
active[candidate] = true
}
}
entry.AppliedClearTokens = active
entry.AppliedClearTokenOrder = order
}
func (o *GrowthOwner) ApplyDungeonElapsed(ctx context.Context, cmd PetElapsedCommand) (PetGrowthPersistenceResult, error) {
return o.applySatiety(ctx, cmd, true)
}
func (o *GrowthOwner) ApplyTownElapsed(ctx context.Context, cmd PetElapsedCommand) (PetGrowthPersistenceResult, error) {
return o.applySatiety(ctx, cmd, false)
}
func (o *GrowthOwner) applySatiety(ctx context.Context, cmd PetElapsedCommand, dungeon bool) (PetGrowthPersistenceResult, error) {
characterID, err := growthCharacterID(cmd.SelectedCharacterID)
if err != nil {
return PetGrowthPersistenceResult{}, err
}
return o.withEquipped(ctx, characterID, func(
petRecord *dnfrepo.PetRecord,
petKey string,
entry *dnfrepo.PetEntry,
_ *dnfrepo.EquipmentRecord,
_ *dnfrepo.EquipmentEntry,
) (PetGrowthPersistenceResult, error) {
before, err := petGrowthState(*entry)
if err != nil {
return PetGrowthPersistenceResult{}, err
}
var update PetSatietyUpdate
if dungeon {
update, err = o.engine.ApplyDungeonElapsed(before, cmd.Elapsed, cmd.Modifiers)
} else {
update, err = o.engine.ApplyTownElapsed(before, cmd.Elapsed)
}
if err != nil {
return PetGrowthPersistenceResult{}, err
}
if update.Changed {
entry.Satiety = byte(update.After.Satiety)
entry.SatietyMicros = update.After.SatietyMicros
petRecord.Entries[petKey] = *entry
}
return PetGrowthPersistenceResult{
CharacterID: characterID,
PetKey: petKey,
Equipped: true,
Changed: update.Changed,
Before: before,
After: update.After,
SatietyDelta: update.SatietyDelta,
}, nil
})
}
type equippedGrowthMutation func(
*dnfrepo.PetRecord,
string,
*dnfrepo.PetEntry,
*dnfrepo.EquipmentRecord,
*dnfrepo.EquipmentEntry,
) (PetGrowthPersistenceResult, error)
func (o *GrowthOwner) withEquipped(ctx context.Context, characterID string, mutate equippedGrowthMutation) (PetGrowthPersistenceResult, error) {
if o == nil || o.engine == nil {
return PetGrowthPersistenceResult{}, ErrPetGrowthOwnerUnavailable
}
if o.characterPets == nil {
return PetGrowthPersistenceResult{}, ErrPetGrowthTransaction
}
var result PetGrowthPersistenceResult
err := o.characterPets.WithinCharacterPets(ctx, characterID, func(
_ dnfrepo.InventoryRepository,
equipmentRepo dnfrepo.EquipmentRepository,
petRepo dnfrepo.PetRepository,
) error {
petRecord, petFound, err := petRepo.Load(ctx, characterID)
if err != nil {
return err
}
equipment, equipmentFound, err := equipmentRepo.Load(ctx, characterID)
if err != nil {
return err
}
if !petFound && !equipmentFound {
result = PetGrowthPersistenceResult{CharacterID: characterID}
return nil
}
petRecord = dnfrepo.ClonePet(petRecord)
petRecord.CharacterID = characterID
equipment = dnfrepo.CloneEquipment(equipment)
equipment.CharacterID = characterID
petKey := strings.TrimSpace(petRecord.EquippedKey)
worn, wornFound := equipment.Entries[strconv.Itoa(int(equippedCreatureSlot))]
if petKey == "" && !wornFound {
result = PetGrowthPersistenceResult{CharacterID: characterID}
return nil
}
if petKey == "" {
// A slot-26 row without PetRecord ownership is not automatically a
// creature. Keep ordinary/unclassified equipment a no-op, but reject a
// real creature row that lacks the matching durable pet key.
if _, kindErr := o.engine.resolveCreature(worn.ItemID); errors.Is(kindErr, ErrPetPVFNotCreature) {
result = PetGrowthPersistenceResult{CharacterID: characterID}
return nil
} else if kindErr != nil {
return fmt.Errorf("%w: unclassified slot26 item=%d: %w", ErrPetGrowthEquippedMismatch, worn.ItemID, kindErr)
}
return fmt.Errorf("%w: slot26 creature item=%d has no equipped pet key", ErrPetGrowthEquippedMismatch, worn.ItemID)
}
if !wornFound {
return ErrPetGrowthEquippedMismatch
}
entry, entryFound := petRecord.Entries[petKey]
if !entryFound {
return fmt.Errorf("%w: pet_key=%s missing", ErrPetGrowthEquippedMismatch, petKey)
}
if err := validateWornPetEntry(petKey, entry, worn); err != nil {
return err
}
if _, err := o.engine.resolveCreature(entry.ItemID); err != nil {
return fmt.Errorf("%w: pet_key=%s item=%d: %w", ErrPetGrowthEquippedMismatch, petKey, entry.ItemID, err)
}
result, err = mutate(&petRecord, petKey, &entry, &equipment, &worn)
if err != nil {
return err
}
if !result.Changed {
return nil
}
now := o.now()
petRecord.UpdatedAt = now
if err := dnfrepo.SavePetFields(ctx, petRepo, petRecord, dnfrepo.PetFieldEntries); err != nil {
return err
}
if result.Evolution.Changed {
equipment.UpdatedAt = now
if err := dnfrepo.SaveEquipmentFields(ctx, equipmentRepo, equipment, dnfrepo.EquipmentFieldEntries); err != nil {
return err
}
}
return nil
})
return result, err
}
func growthCharacterID(selected uint16) (string, error) {
if selected == 0 {
return "", ErrCharacterRequired
}
return strconv.FormatUint(uint64(selected), 10), nil
}
func petGrowthState(entry dnfrepo.PetEntry) (PetGrowthState, error) {
if entry.Level > math.MaxInt || entry.Exp > math.MaxInt64 {
return PetGrowthState{}, ErrPetGrowthStateInvalid
}
state := PetGrowthState{
ItemID: entry.ItemID,
Experience: entry.Exp,
Level: int(entry.Level),
Satiety: int(entry.Satiety),
SatietyMicros: entry.SatietyMicros,
}
if err := validatePetGrowthState(state); err != nil {
return PetGrowthState{}, err
}
return state, nil
}
func validateWornPetEntry(petKey string, entry dnfrepo.PetEntry, worn dnfrepo.EquipmentEntry) error {
if entry.SourceListType != equippedCreatureListType || entry.SourceSlotIndex != equippedCreatureSlot ||
worn.SlotIndex != equippedCreatureSlot || entry.ItemID <= 0 || entry.ItemID > math.MaxUint32 || worn.ItemID != entry.ItemID ||
entry.CreatureKey == 0 || strconv.FormatUint(uint64(entry.CreatureKey), 10) != petKey {
return fmt.Errorf("%w: pet_key=%s item=%d worn_item=%d source=(%d,%d)", ErrPetGrowthEquippedMismatch, petKey, entry.ItemID, worn.ItemID, entry.SourceListType, entry.SourceSlotIndex)
}
if len(worn.RawEntry) < 28 || int16(worn.RawEntry[0]) != equippedCreatureSlot ||
binary.LittleEndian.Uint32(worn.RawEntry[1:5]) != uint32(entry.ItemID) ||
binary.LittleEndian.Uint32(worn.RawEntry[5:9]) != entry.CreatureKey ||
binary.LittleEndian.Uint32(worn.RawEntry[24:28]) != entry.CreatureKey {
return fmt.Errorf("%w: pet_key=%s invalid slot26 raw", ErrPetGrowthEquippedMismatch, petKey)
}
return nil
}
func applyWornPetEvolution(entry *dnfrepo.PetEntry, worn *dnfrepo.EquipmentEntry, beforeItemID, afterItemID int64) error {
if entry == nil || worn == nil || beforeItemID <= 0 || afterItemID <= 0 || afterItemID > math.MaxUint32 ||
entry.ItemID != beforeItemID || worn.ItemID != beforeItemID {
return ErrPetGrowthEquippedMismatch
}
if err := validateWornPetEntry(entry.PetKey, *entry, *worn); err != nil {
return err
}
entry.ItemID = afterItemID
worn.ItemID = afterItemID
worn.RawEntry = append([]byte(nil), worn.RawEntry...)
binary.LittleEndian.PutUint32(worn.RawEntry[1:5], uint32(afterItemID))
if worn.Extra != nil {
cloned := make(map[string]string, len(worn.Extra))
for key, value := range worn.Extra {
cloned[key] = value
}
worn.Extra = cloned
worn.Extra["raw_entry_hex"] = hex.EncodeToString(worn.RawEntry)
}
return nil
}