服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
181 lines
5.4 KiB
Go
181 lines
5.4 KiB
Go
package itemgrade
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/itemquality"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
var (
|
|
ErrOwnerUnavailable = errors.New("item grade owner unavailable")
|
|
ErrCharacterRequired = errors.New("selected character id required")
|
|
ErrTargetMissing = errors.New("item grade target missing")
|
|
ErrMaterialMissing = errors.New("item grade material missing")
|
|
)
|
|
|
|
type ItemDefinition struct {
|
|
StackableType string
|
|
}
|
|
|
|
type ItemCatalog interface {
|
|
ResolveItem(uint32) (ItemDefinition, error)
|
|
}
|
|
|
|
type SeedGenerator func() (uint32, error)
|
|
|
|
type Command struct {
|
|
SelectedCharacterID uint16
|
|
TargetSlot int16
|
|
TargetItemID int32
|
|
MaterialSlot int16
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type Result struct {
|
|
CharacterID string
|
|
TargetSlot int16
|
|
TargetItemID int32
|
|
TargetStack dnfrepo.ItemStack
|
|
MaterialSlot int16
|
|
MaterialItemID int64
|
|
MaterialRemaining int64
|
|
MaterialRemoved bool
|
|
NewSeed uint32
|
|
GoldKaleido bool
|
|
}
|
|
|
|
// Owner owns kaleido validation, quality selection, material consumption, and
|
|
// the durable inventory write.
|
|
type Owner struct {
|
|
inventory dnfrepo.InventoryRepository
|
|
characterItems dnfrepo.CharacterItemUnitOfWork
|
|
accountItems dnfrepo.AccountCharacterItemUnitOfWork
|
|
catalog ItemCatalog
|
|
newSeed SeedGenerator
|
|
}
|
|
|
|
func NewOwner(repos dnfrepo.Group, catalog ItemCatalog, newSeed SeedGenerator) (*Owner, error) {
|
|
if repos.Inventory == nil {
|
|
return nil, ErrOwnerUnavailable
|
|
}
|
|
if newSeed == nil {
|
|
newSeed = itemquality.NewRandomSeed
|
|
}
|
|
return &Owner{
|
|
inventory: repos.Inventory, characterItems: repos.CharacterItems,
|
|
accountItems: repos.AccountItems, catalog: catalog, newSeed: newSeed,
|
|
}, nil
|
|
}
|
|
|
|
func (o *Owner) Adjust(ctx context.Context, cmd Command) (Result, error) {
|
|
if o == nil || o.inventory == nil || o.newSeed == nil {
|
|
return Result{}, ErrOwnerUnavailable
|
|
}
|
|
if cmd.SelectedCharacterID == 0 {
|
|
return Result{}, ErrCharacterRequired
|
|
}
|
|
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
|
inventory, found, err := o.inventory.Load(ctx, characterID)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
|
return Result{}, ErrOwnerUnavailable
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
|
|
targetKey := inventoryKey(cmd.TargetSlot)
|
|
target, found := inventory.Slots[targetKey]
|
|
if !found || target.ItemID != int64(cmd.TargetItemID) || target.ItemID <= 0 {
|
|
return Result{}, fmt.Errorf("%w: slot=%d item=%d", ErrTargetMissing, cmd.TargetSlot, cmd.TargetItemID)
|
|
}
|
|
materialKey := inventoryKey(cmd.MaterialSlot)
|
|
material, found := inventory.Slots[materialKey]
|
|
if !found || material.ItemID <= 0 || material.Count <= 0 {
|
|
return Result{}, fmt.Errorf("%w: slot=%d", ErrMaterialMissing, cmd.MaterialSlot)
|
|
}
|
|
|
|
// The current client sends the occupied box slot as the op81 material.
|
|
// Ordinary adjustment boxes in the active PVF are legacy [etc] items, so
|
|
// their stackable type is not a safe allow-list. Preserve the working op81
|
|
// contract: every non-empty client-selected material slot is an ordinary
|
|
// box unless the PVF positively identifies it as a golden kaleido box.
|
|
goldKaleido := o.isGoldKaleido(material.ItemID)
|
|
seed := itemquality.TopSeed
|
|
if !goldKaleido {
|
|
seed, err = o.newSeed()
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
}
|
|
if err := itemquality.Apply(&target, seed); err != nil {
|
|
return Result{}, err
|
|
}
|
|
// A grade-adjust target is necessarily equipment. Normalize legacy rows so
|
|
// every later inventory projection keeps using the quality seed instead of
|
|
// falling back to the ordinary stack count.
|
|
target.Extra["item_kind"] = "equipment"
|
|
now := cmd.UpdatedAt
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
} else {
|
|
now = now.UTC()
|
|
}
|
|
target.Extra["last_grade_adjust"] = now.Format(time.RFC3339)
|
|
if goldKaleido {
|
|
target.Extra["grade_adjust_type"] = "gold_kaleido"
|
|
} else {
|
|
target.Extra["grade_adjust_type"] = "standard_kaleido"
|
|
}
|
|
inventory.Slots[targetKey] = target
|
|
|
|
material.Count--
|
|
materialRemoved := material.Count == 0
|
|
if materialRemoved {
|
|
delete(inventory.Slots, materialKey)
|
|
} else {
|
|
inventory.Slots[materialKey] = material
|
|
}
|
|
inventory.UpdatedAt = now
|
|
if err := dnfrepo.SaveInventoryFields(ctx, o.inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{
|
|
CharacterID: characterID,
|
|
TargetSlot: cmd.TargetSlot,
|
|
TargetItemID: cmd.TargetItemID,
|
|
TargetStack: target,
|
|
MaterialSlot: cmd.MaterialSlot,
|
|
MaterialItemID: material.ItemID,
|
|
MaterialRemaining: material.Count,
|
|
MaterialRemoved: materialRemoved,
|
|
NewSeed: seed,
|
|
GoldKaleido: goldKaleido,
|
|
}, nil
|
|
}
|
|
|
|
func (o *Owner) isGoldKaleido(itemID int64) bool {
|
|
if o.catalog == nil || itemID <= 0 {
|
|
return false
|
|
}
|
|
definition, err := o.catalog.ResolveItem(uint32(itemID))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
normalized := strings.ToLower(strings.ReplaceAll(definition.StackableType, "`", ""))
|
|
// Restore the known-good rule from internal.zip. The active PVF contains
|
|
// localized/special golden variants, so the proven gold marker selects the
|
|
// top-quality seed while every other box remains an ordinary random roll.
|
|
return strings.Contains(normalized, "gold")
|
|
}
|
|
|
|
func inventoryKey(slot int16) string {
|
|
return fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)
|
|
}
|