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

166 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}