服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
166 lines
6.8 KiB
Go
166 lines
6.8 KiB
Go
package inventory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
// 塔罗牌类道具(PVF `[stackable type]` = `[monster tarot card]`)的使用。
|
||
//
|
||
// 为什么它**不能**照抄 useRandomRewardItem:那张卡要同时改**金币**和**背包**。
|
||
// useRandomRewardItem 走 withinInventoryTransaction(只给 inventory/equipment 两个
|
||
// 仓库),金币根本拿不到;必须在 withinCharacterAssetTransaction 里做,它给的是
|
||
// (characters, inventory, equipment) 三个仓库,同一笔事务提交 —— 这正是
|
||
// 「要么全成要么全不成」的边界:任何一步 return error,整笔回滚,金币、道具、
|
||
// 背包三者一个都没变。
|
||
//
|
||
// 金币从哪减:PVF `[lottery use cost]`(**从文档读,不硬编码**;服主 2026-09-29 确认
|
||
// 这个字段扣的是金币,塔罗牌这次是 100000)。写法和修理路径一致
|
||
// (owner.go:1854 `character.Stats["gold"] = gold - cost` +
|
||
// dnfrepo.SaveCharacterFields(..., CharacterFieldStats))。
|
||
var (
|
||
// ErrTarotLotteryContractRequired 契约不完整(源道具不匹配 / 无路径 / 类型不对 /
|
||
// 没有金币花费 / 既没抽中也没落空)。
|
||
ErrTarotLotteryContractRequired = errors.New("tarot lottery card contract is required")
|
||
// ErrTarotLotteryInsufficientGold 金币不足:整次使用中止,什么都不改。
|
||
ErrTarotLotteryInsufficientGold = errors.New("tarot lottery card gold is insufficient")
|
||
// ErrTarotLotteryMissed 掷到了奖池的空隙。**这是我们与官服可能不同的一处保守
|
||
// 选择**:官方"照收钱不给东西"或"整次中止"两种做法都说得通,这里选整次中止
|
||
// (不扣钱、不扣道具),因为宁可这次不生效,也不能让玩家花了钱拿到空气。
|
||
ErrTarotLotteryMissed = errors.New("tarot lottery card roll landed in the table gap")
|
||
)
|
||
|
||
// UseTarotLotteryCard opens one tarot card: charge its gold, consume the card and
|
||
// grant the drawn item, all inside one character-asset transaction.
|
||
func (o *Owner) UseTarotLotteryCard(
|
||
ctx context.Context,
|
||
cmd Command,
|
||
resolution alignedcmd.TarotLotteryResolution,
|
||
) (UseStackableResult, error) {
|
||
if o == nil {
|
||
return UseStackableResult{}, ErrOwnerUnavailable
|
||
}
|
||
if cmd.SelectedCharacterID == 0 {
|
||
return UseStackableResult{}, ErrCharacterRequired
|
||
}
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
if !o.inItemTransaction {
|
||
// 金币 + 背包必须同一笔:withinCharacterAssetTransaction 会把 characters /
|
||
// inventory / equipment 三个仓库一起换进 txOwner,并在 apply 返回 error 时
|
||
// 整笔回滚。
|
||
var result UseStackableResult
|
||
err := o.withinCharacterAssetTransaction(ctx, characterID, func(txOwner *Owner) error {
|
||
var err error
|
||
result, err = txOwner.useTarotLotteryCard(ctx, cmd, resolution)
|
||
return err
|
||
})
|
||
return result, err
|
||
}
|
||
return o.useTarotLotteryCard(ctx, cmd, resolution)
|
||
}
|
||
|
||
func (o *Owner) useTarotLotteryCard(
|
||
ctx context.Context,
|
||
cmd Command,
|
||
resolution alignedcmd.TarotLotteryResolution,
|
||
) (UseStackableResult, error) {
|
||
// --- 契约校验:任何一项不成立都直接中止(fail closed,什么都不改) ---
|
||
if resolution.SourceItemID != int64(cmd.ItemTemplateID) ||
|
||
strings.TrimSpace(resolution.SourcePVFPath) == "" ||
|
||
!strings.EqualFold(strings.Trim(resolution.StackableType, "` []"), "monster tarot card") ||
|
||
resolution.GoldCost <= 0 {
|
||
return UseStackableResult{}, fmt.Errorf("%w: item=%d", ErrTarotLotteryContractRequired, cmd.ItemTemplateID)
|
||
}
|
||
if resolution.Drawn == nil || resolution.Drawn.Reward.ItemID <= 0 || resolution.Drawn.Count <= 0 {
|
||
if resolution.Missed {
|
||
return UseStackableResult{}, fmt.Errorf("%w: item=%d roll=%d total=%d",
|
||
ErrTarotLotteryMissed, cmd.ItemTemplateID, resolution.Roll, resolution.TotalWeight)
|
||
}
|
||
return UseStackableResult{}, fmt.Errorf("%w: item=%d has nothing drawn", ErrTarotLotteryContractRequired, cmd.ItemTemplateID)
|
||
}
|
||
|
||
// --- 金币:先读再判,不足就在动任何东西之前中止 ---
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
character, found, err := o.characters.Load(ctx, characterID)
|
||
if err != nil {
|
||
return UseStackableResult{}, err
|
||
}
|
||
if !found {
|
||
return UseStackableResult{}, ErrInventoryNotFound
|
||
}
|
||
if character.Stats["gold"] < resolution.GoldCost {
|
||
return UseStackableResult{}, fmt.Errorf("%w: have=%d need=%d",
|
||
ErrTarotLotteryInsufficientGold, character.Stats["gold"], resolution.GoldCost)
|
||
}
|
||
|
||
// --- 背包:源槽校验 + 消耗 1 个 ---
|
||
record, ok, err := o.repo.Load(ctx, characterID)
|
||
if err != nil {
|
||
return UseStackableResult{}, err
|
||
}
|
||
if !ok {
|
||
return UseStackableResult{}, ErrInventoryNotFound
|
||
}
|
||
record = dnfrepo.CloneInventory(record)
|
||
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
|
||
source, ok := record.Slots[key]
|
||
if !ok || source.Count <= 0 {
|
||
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
|
||
}
|
||
if source.ItemID != int64(cmd.ItemTemplateID) {
|
||
return UseStackableResult{}, fmt.Errorf("%w: list=%d slot=%d want=%d got=%d",
|
||
ErrItemMismatch, cmd.SourceListType, cmd.SourceSlotIndex, cmd.ItemTemplateID, source.ItemID)
|
||
}
|
||
// 真档没有 [usable count]/[stack limit],op44 这条框架路径默认**消耗 1 个**。
|
||
remaining := source.Count - 1
|
||
if remaining == 0 {
|
||
delete(record.Slots, key)
|
||
} else {
|
||
source = cloneStack(source)
|
||
source.Count = remaining
|
||
updateStackRawAmount(&source)
|
||
record.Slots[key] = source
|
||
}
|
||
|
||
// --- 发放中奖物品(复用既有发奖;数量来自 [int data] 的 count 列) ---
|
||
slots, err := grantRandomRewardItem(record.Slots, resolution.Drawn.Reward, resolution.Drawn.Count)
|
||
if err != nil {
|
||
return UseStackableResult{}, err
|
||
}
|
||
|
||
result := UseStackableResult{
|
||
CharacterID: characterID,
|
||
AccountID: cmd.AccountID,
|
||
ListType: cmd.SourceListType,
|
||
SlotIndex: cmd.SourceSlotIndex,
|
||
InstanceValue: cmd.SourceInstanceValue,
|
||
ItemID: int64(cmd.ItemTemplateID),
|
||
RemainingCount: remaining,
|
||
Changed: true,
|
||
PVFPath: resolution.SourcePVFPath,
|
||
StackableType: resolution.StackableType,
|
||
|
||
RandomRewardItemID: resolution.Drawn.Reward.ItemID,
|
||
RandomRewardSlots: slots,
|
||
}
|
||
|
||
// --- 一次提交:金币与道具同笔。任一失败都回滚,金币不会被扣。 ---
|
||
character.Stats["gold"] -= resolution.GoldCost
|
||
character.UpdatedAt = time.Now()
|
||
if err := dnfrepo.SaveCharacterFields(ctx, o.characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||
return UseStackableResult{}, err
|
||
}
|
||
record.UpdatedAt = time.Now()
|
||
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, dnfrepo.InventoryFieldSlots); err != nil {
|
||
return UseStackableResult{}, err
|
||
}
|
||
return result, nil
|
||
}
|