服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
354 lines
11 KiB
Go
354 lines
11 KiB
Go
package itemgrade
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
type ResealRangeWeight struct {
|
|
Minimum int64
|
|
Maximum int64
|
|
Weight int64
|
|
}
|
|
|
|
type ResealConfig struct {
|
|
GradeWeights []ResealRangeWeight
|
|
RarityWeights map[int64]float64
|
|
ExtendedWeights map[int64]float64
|
|
SoulItemIDs map[int64]uint32
|
|
SoulCostWeights map[int64]float64
|
|
ResealConstants map[int64]float64
|
|
ResealCountLimits []ResealRangeWeight
|
|
}
|
|
|
|
type ResealItemDefinition struct {
|
|
Equipment bool
|
|
MinimumLevel int64
|
|
Rarity int64
|
|
Wax bool
|
|
WaxOneShot bool
|
|
ResealLevelMin int64
|
|
ResealLevelMax int64
|
|
}
|
|
|
|
type ResealCatalog interface {
|
|
ResolveResealItem(uint32) (ResealItemDefinition, error)
|
|
}
|
|
|
|
type ResealCommand struct {
|
|
AccountID string
|
|
CharacterID string
|
|
TargetSlot int16
|
|
TargetItemID uint32
|
|
MaterialSlot int16
|
|
Mode uint16
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type ResealResult struct {
|
|
TargetSlot int16
|
|
TargetStack dnfrepo.ItemStack
|
|
MaterialSlot int16
|
|
MaterialRemaining int64
|
|
AccountMaterial bool
|
|
ResealCount byte
|
|
}
|
|
|
|
func (o *Owner) Reseal(ctx context.Context, catalog ResealCatalog, config ResealConfig, cmd ResealCommand) (ResealResult, error) {
|
|
if o == nil || catalog == nil || o.characterItems == nil || o.accountItems == nil {
|
|
return ResealResult{}, ErrOwnerUnavailable
|
|
}
|
|
accountID := strings.TrimSpace(cmd.AccountID)
|
|
characterID := strings.TrimSpace(cmd.CharacterID)
|
|
if characterID == "" || cmd.TargetItemID == 0 {
|
|
return ResealResult{}, ErrCharacterRequired
|
|
}
|
|
definition, err := catalog.ResolveResealItem(cmd.TargetItemID)
|
|
if err != nil || !definition.Equipment {
|
|
return ResealResult{}, fmt.Errorf("%w: reseal target definition", ErrTargetMissing)
|
|
}
|
|
now := cmd.UpdatedAt
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
} else {
|
|
now = now.UTC()
|
|
}
|
|
var committed ResealResult
|
|
switch cmd.Mode {
|
|
case 1:
|
|
if accountID == "" {
|
|
return ResealResult{}, ErrOwnerUnavailable
|
|
}
|
|
err = o.accountItems.WithinAccountCharacterItems(ctx, accountID, characterID, func(accounts dnfrepo.AccountInventoryRepository, inventories dnfrepo.InventoryRepository) error {
|
|
inventory, found, loadErr := inventories.Load(ctx, characterID)
|
|
if loadErr != nil || !found {
|
|
return resealFallbackError(loadErr, errors.New("reseal inventory missing"))
|
|
}
|
|
account, found, loadErr := accounts.Load(ctx, accountID)
|
|
if loadErr != nil || !found {
|
|
return resealFallbackError(loadErr, errors.New("reseal account inventory missing"))
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
account = dnfrepo.CloneAccountInventory(account)
|
|
target, oldCount, mutationErr := prepareResealTarget(inventory, cmd, definition, config, false)
|
|
if mutationErr != nil {
|
|
return mutationErr
|
|
}
|
|
materialID, cost, supported := config.materialAndCost(definition, oldCount)
|
|
if !supported {
|
|
return errors.New("reseal material rule unavailable")
|
|
}
|
|
materialSlot, remaining, consumeErr := consumeResealItemByID(account.Slots, materialID, cost)
|
|
if consumeErr != nil {
|
|
return consumeErr
|
|
}
|
|
inventory.Slots[resealInventoryKey(cmd.TargetSlot)] = target
|
|
inventory.UpdatedAt = now
|
|
account.UpdatedAt = now
|
|
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
if saveErr := accounts.Save(ctx, account); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
committed = ResealResult{TargetSlot: cmd.TargetSlot, TargetStack: target, MaterialSlot: materialSlot, MaterialRemaining: remaining, AccountMaterial: true, ResealCount: ResealCount(target)}
|
|
return nil
|
|
})
|
|
case 0:
|
|
err = o.characterItems.WithinCharacterItems(ctx, characterID, func(inventories dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
|
inventory, found, loadErr := inventories.Load(ctx, characterID)
|
|
if loadErr != nil || !found {
|
|
return resealFallbackError(loadErr, errors.New("reseal inventory missing"))
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
materialKey := resealInventoryKey(cmd.MaterialSlot)
|
|
material, found := inventory.Slots[materialKey]
|
|
if !found || material.ItemID <= 0 || material.ItemID > math.MaxUint32 || material.Count <= 0 {
|
|
return ErrMaterialMissing
|
|
}
|
|
wax, resolveErr := catalog.ResolveResealItem(uint32(material.ItemID))
|
|
if resolveErr != nil || !wax.Wax {
|
|
return ErrMaterialMissing
|
|
}
|
|
target, oldCount, mutationErr := prepareResealTarget(inventory, cmd, definition, config, wax.WaxOneShot)
|
|
if mutationErr != nil {
|
|
return mutationErr
|
|
}
|
|
if wax.ResealLevelMin > 0 && definition.MinimumLevel < wax.ResealLevelMin || wax.ResealLevelMax > 0 && definition.MinimumLevel > wax.ResealLevelMax {
|
|
return errors.New("reseal wax level mismatch")
|
|
}
|
|
cost := int64(1)
|
|
if !wax.WaxOneShot {
|
|
var supported bool
|
|
cost, supported = config.waxCost(definition, oldCount)
|
|
if !supported {
|
|
return errors.New("reseal wax cost unavailable")
|
|
}
|
|
}
|
|
if material.Count < cost {
|
|
return ErrMaterialMissing
|
|
}
|
|
material.Count -= cost
|
|
if material.Count == 0 {
|
|
delete(inventory.Slots, materialKey)
|
|
} else {
|
|
inventory.Slots[materialKey] = material
|
|
}
|
|
inventory.Slots[resealInventoryKey(cmd.TargetSlot)] = target
|
|
inventory.UpdatedAt = now
|
|
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
committed = ResealResult{TargetSlot: cmd.TargetSlot, TargetStack: target, MaterialSlot: cmd.MaterialSlot, MaterialRemaining: material.Count, ResealCount: ResealCount(target)}
|
|
return nil
|
|
})
|
|
default:
|
|
return ResealResult{}, errors.New("unsupported reseal mode")
|
|
}
|
|
if err != nil {
|
|
return ResealResult{}, err
|
|
}
|
|
return committed, nil
|
|
}
|
|
|
|
func prepareResealTarget(inventory dnfrepo.InventoryRecord, cmd ResealCommand, definition ResealItemDefinition, config ResealConfig, oneShot bool) (dnfrepo.ItemStack, byte, error) {
|
|
target, found := inventory.Slots[resealInventoryKey(cmd.TargetSlot)]
|
|
if !found || target.ItemID != int64(cmd.TargetItemID) || target.ItemID <= 0 {
|
|
return dnfrepo.ItemStack{}, 0, ErrTargetMissing
|
|
}
|
|
if SealFlag(target) != 0 {
|
|
return dnfrepo.ItemStack{}, 0, errors.New("reseal target already sealed")
|
|
}
|
|
oldCount := ResealCount(target)
|
|
limit := config.countLimit(definition.MinimumLevel)
|
|
if !oneShot && (limit == 0 || oldCount >= limit) {
|
|
return dnfrepo.ItemStack{}, oldCount, errors.New("reseal count limit reached")
|
|
}
|
|
newCount := oldCount
|
|
if !oneShot {
|
|
newCount++
|
|
}
|
|
return applyResealState(target, newCount), oldCount, nil
|
|
}
|
|
|
|
func applyResealState(stack dnfrepo.ItemStack, count byte) dnfrepo.ItemStack {
|
|
if stack.Extra == nil {
|
|
stack.Extra = make(map[string]string)
|
|
}
|
|
packed := packedResealByte(stack)
|
|
packed = (packed & 0x1f) | ((count & 0x07) << 5)
|
|
stack.Extra["ext_data0"] = strconv.Itoa(int(packed))
|
|
stack.Extra["packed_flag_byte"] = strconv.Itoa(int(packed))
|
|
stack.Extra["reseal_count"] = strconv.Itoa(int(count))
|
|
stack.Extra["seal_flag"] = "1"
|
|
stack.Extra["seal"] = "1"
|
|
stack.Bind = true
|
|
if len(stack.RawEntry) > 0x0d {
|
|
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
|
stack.RawEntry[0x0a] = packed
|
|
stack.RawEntry[0x0d] = 1
|
|
}
|
|
return stack
|
|
}
|
|
|
|
func SealFlag(stack dnfrepo.ItemStack) byte {
|
|
if len(stack.RawEntry) > 0x0d {
|
|
return stack.RawEntry[0x0d]
|
|
}
|
|
for _, key := range []string{"seal_flag", "seal", "sealed"} {
|
|
if value, err := strconv.ParseUint(strings.TrimSpace(stack.Extra[key]), 10, 8); err == nil && value != 0 {
|
|
return byte(value)
|
|
}
|
|
}
|
|
if stack.Bind {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func ResealCount(stack dnfrepo.ItemStack) byte {
|
|
if value, err := strconv.ParseUint(strings.TrimSpace(stack.Extra["reseal_count"]), 10, 8); err == nil && value <= 7 {
|
|
return byte(value)
|
|
}
|
|
return packedResealByte(stack) >> 5
|
|
}
|
|
|
|
func packedResealByte(stack dnfrepo.ItemStack) byte {
|
|
if len(stack.RawEntry) > 0x0a {
|
|
return stack.RawEntry[0x0a]
|
|
}
|
|
value, _ := strconv.ParseUint(strings.TrimSpace(stack.Extra["packed_flag_byte"]), 10, 8)
|
|
if value == 0 {
|
|
value, _ = strconv.ParseUint(strings.TrimSpace(stack.Extra["ext_data0"]), 10, 8)
|
|
}
|
|
return byte(value)
|
|
}
|
|
|
|
func (c ResealConfig) materialAndCost(definition ResealItemDefinition, count byte) (uint32, int64, bool) {
|
|
if definition.Rarity < 0 || definition.Rarity >= 7 || definition.MinimumLevel <= 0 || count >= 7 {
|
|
return 0, 0, false
|
|
}
|
|
grade := resealRangeWeight(c.GradeWeights, definition.MinimumLevel)
|
|
weight := c.RarityWeights[definition.Rarity] * c.ExtendedWeights[definition.Rarity] * c.SoulCostWeights[definition.Rarity]
|
|
constant := c.ResealConstants[definition.Rarity]
|
|
cost := int64(float64(grade) * weight * math.Pow(constant, float64(count)))
|
|
itemID := c.SoulItemIDs[definition.Rarity]
|
|
return itemID, cost, grade > 0 && weight > 0 && constant > 0 && itemID > 0 && cost > 0
|
|
}
|
|
|
|
func (c ResealConfig) waxCost(definition ResealItemDefinition, count byte) (int64, bool) {
|
|
if definition.Rarity < 0 || definition.Rarity >= 7 || definition.MinimumLevel <= 0 || count >= 7 {
|
|
return 0, false
|
|
}
|
|
grade := resealRangeWeight(c.GradeWeights, definition.MinimumLevel)
|
|
weight := c.RarityWeights[definition.Rarity] * c.ExtendedWeights[definition.Rarity]
|
|
constant := c.ResealConstants[definition.Rarity]
|
|
cost := int64(float64(grade) * weight * math.Pow(constant, float64(count)))
|
|
return cost, grade > 0 && weight > 0 && constant > 0 && cost > 0
|
|
}
|
|
|
|
func (c ResealConfig) countLimit(level int64) byte {
|
|
value := resealRangeWeight(c.ResealCountLimits, level)
|
|
if value < 0 {
|
|
return 0
|
|
}
|
|
if value > 7 {
|
|
value = 7
|
|
}
|
|
return byte(value)
|
|
}
|
|
|
|
func resealRangeWeight(rows []ResealRangeWeight, value int64) int64 {
|
|
for _, row := range rows {
|
|
if value >= row.Minimum && value <= row.Maximum {
|
|
return row.Weight
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func consumeResealItemByID(slots map[string]dnfrepo.ItemStack, itemID uint32, count int64) (int16, int64, error) {
|
|
if count <= 0 {
|
|
return 0, 0, errors.New("invalid reseal material count")
|
|
}
|
|
keys := make([]string, 0)
|
|
available := int64(0)
|
|
for key, stack := range slots {
|
|
if stack.ItemID == int64(itemID) && stack.Count > 0 {
|
|
keys = append(keys, key)
|
|
available += stack.Count
|
|
}
|
|
}
|
|
if available < count {
|
|
return 0, 0, ErrMaterialMissing
|
|
}
|
|
sort.Strings(keys)
|
|
need := count
|
|
var lastSlot int16
|
|
var lastRemaining int64
|
|
for _, key := range keys {
|
|
stack := slots[key]
|
|
parts := strings.Split(key, ":")
|
|
if len(parts) == 2 {
|
|
if value, err := strconv.ParseInt(parts[1], 10, 16); err == nil {
|
|
lastSlot = int16(value)
|
|
}
|
|
}
|
|
used := stack.Count
|
|
if used > need {
|
|
used = need
|
|
}
|
|
stack.Count -= used
|
|
need -= used
|
|
lastRemaining = stack.Count
|
|
if stack.Count == 0 {
|
|
delete(slots, key)
|
|
} else {
|
|
slots[key] = stack
|
|
}
|
|
if need == 0 {
|
|
break
|
|
}
|
|
}
|
|
return lastSlot, lastRemaining, nil
|
|
}
|
|
|
|
func resealInventoryKey(slot int16) string {
|
|
return fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)
|
|
}
|
|
|
|
func resealFallbackError(err, fallback error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fallback
|
|
}
|