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