服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
731 lines
25 KiB
Go
731 lines
25 KiB
Go
package dungeon
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/carrygold"
|
|
"longheng.io/server/internal/modules/dnf/premium"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
|
|
)
|
|
|
|
const (
|
|
LuckyStarMetadataKey = "rental_lucky_stars"
|
|
cardRewardReceiptPrefix = "dungeon_card_reward_receipt_"
|
|
cardRewardReceiptVersion int64 = 1
|
|
cardRewardReceiptOverflowMarker = "durable-card-reward-receipt"
|
|
)
|
|
|
|
var (
|
|
ErrOwnerUnavailable = errors.New("dungeon asset owner is unavailable")
|
|
ErrCharacterNotFound = errors.New("dungeon character is not found")
|
|
ErrAccountNotFound = errors.New("dungeon account is not found")
|
|
ErrRewardInvalid = errors.New("dungeon reward is invalid")
|
|
ErrInventoryFull = errors.New("dungeon reward inventory is full")
|
|
ErrGoldOverflow = errors.New("dungeon gold would overflow")
|
|
ErrStackProjectorRequired = errors.New("dungeon expiring stack projector is required")
|
|
ErrPremiumDailyLimit = errors.New("dungeon premium service daily limit reached")
|
|
ErrCardRewardReceiptInvalid = errors.New("dungeon card reward receipt is invalid")
|
|
// ErrCardRewardGoldShort refuses a paid-card flip the character cannot pay
|
|
// for. 86JP's TryBuildRewardPlan rejects the plan before any mutation when
|
|
// the commission exceeds the carried gold, so the card is never revealed.
|
|
ErrCardRewardGoldShort = errors.New("dungeon card reward gold is insufficient")
|
|
)
|
|
|
|
// StackProjector lets the transport compatibility layer project current-client
|
|
// raw-entry details while the dungeon owner retains the durable transaction and
|
|
// inventory rules.
|
|
type StackProjector func(dnfrepo.ItemStack, time.Time) (dnfrepo.ItemStack, error)
|
|
|
|
type CardItemReward struct {
|
|
Stack dnfrepo.ItemStack
|
|
Stackable bool
|
|
StackLimit int64
|
|
SlotStart int16
|
|
SlotEnd int16
|
|
ExpireAt time.Time
|
|
}
|
|
|
|
type CardRewardBundle struct {
|
|
Gold int64
|
|
Items []CardItemReward
|
|
}
|
|
|
|
type CardRewardCommand struct {
|
|
CharacterID string
|
|
// CompletionKey is optional. When present, the owner writes a compact
|
|
// receipt into Character.Stats in the same asset/mailbox transaction and
|
|
// replays it after an indeterminate commit outcome. Empty preserves the
|
|
// legacy one-shot caller-owned behavior.
|
|
CompletionKey string
|
|
MainSlots uint16
|
|
// Cost is the gold spent before the bundle is credited. Only the paid card
|
|
// row sets it: the amount is the commission op35 printed for that card.
|
|
Cost int64
|
|
Bundle CardRewardBundle
|
|
UpdatedAt time.Time
|
|
Project StackProjector
|
|
ConsumePremiumDaily bool
|
|
PremiumDailySlot int64
|
|
}
|
|
|
|
type CardRewardResult struct {
|
|
GoldBefore int64
|
|
GoldAfter int64
|
|
// GoldSpent is the commission deducted before the bundle was credited.
|
|
GoldSpent int64
|
|
// GoldDiscarded is the part of the reward the carry cap refused.
|
|
GoldDiscarded int64
|
|
ItemSlots []int16
|
|
// OverflowMailID is set only when one or more card items could not fit in
|
|
// the real main inventory and were committed as a system-mail attachment.
|
|
OverflowMailID string
|
|
Replayed bool
|
|
}
|
|
|
|
type GoldPickupCommand struct {
|
|
CharacterID string
|
|
Amount uint32
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type GoldPickupResult struct {
|
|
GoldBefore int64
|
|
GoldAfter int64
|
|
// GoldDiscarded is the part of the pickup the carry cap refused.
|
|
GoldDiscarded int64
|
|
}
|
|
|
|
// Owner is the durable mutation boundary for dungeon rewards. Packet parsing,
|
|
// runtime room state, packet ordering, and current-client raw projection stay
|
|
// in dnfbridge.
|
|
type Owner struct {
|
|
assets dnfrepo.CharacterAssetUnitOfWork
|
|
accounts dnfrepo.AccountRepository
|
|
settlements dnfrepo.CharacterSettlementUnitOfWork
|
|
items dnfrepo.CharacterItemUnitOfWork
|
|
inventory dnfrepo.InventoryRepository
|
|
characters dnfrepo.CharacterRepository
|
|
mailboxAssets dnfrepo.MailboxAssetUnitOfWork
|
|
accountAssets dnfrepo.AccountCharacterAssetUnitOfWork
|
|
}
|
|
|
|
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
|
|
if repos.CharacterAssets == nil && repos.Account == nil && repos.CharacterSettlement == nil &&
|
|
repos.CharacterItems == nil && repos.Inventory == nil && repos.Character == nil &&
|
|
repos.AccountAssets == nil {
|
|
return nil, ErrOwnerUnavailable
|
|
}
|
|
return &Owner{
|
|
assets: repos.CharacterAssets,
|
|
accounts: repos.Account,
|
|
settlements: repos.CharacterSettlement,
|
|
items: repos.CharacterItems,
|
|
inventory: repos.Inventory,
|
|
characters: repos.Character,
|
|
mailboxAssets: repos.MailboxAssets,
|
|
accountAssets: repos.AccountAssets,
|
|
}, nil
|
|
}
|
|
|
|
func (o *Owner) GrantCardReward(ctx context.Context, cmd CardRewardCommand) (CardRewardResult, error) {
|
|
if o == nil || o.assets == nil || strings.TrimSpace(cmd.CharacterID) == "" {
|
|
return CardRewardResult{}, ErrOwnerUnavailable
|
|
}
|
|
if err := validateCardReward(cmd); err != nil {
|
|
return CardRewardResult{}, err
|
|
}
|
|
ctx = contextOrBackground(ctx)
|
|
now := updatedAtOrNow(cmd.UpdatedAt)
|
|
completionKey := strings.TrimSpace(cmd.CompletionKey)
|
|
var receiptHash [sha256.Size]byte
|
|
durableReceipt := completionKey != ""
|
|
if durableReceipt {
|
|
receiptHash = sha256.Sum256([]byte(completionKey))
|
|
}
|
|
|
|
var result CardRewardResult
|
|
apply := func(
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
mailboxes dnfrepo.MailboxRepository,
|
|
accountInventories dnfrepo.AccountInventoryRepository,
|
|
) error {
|
|
character, found, err := characters.Load(ctx, cmd.CharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return ErrCharacterNotFound
|
|
}
|
|
character = dnfrepo.CloneCharacter(character)
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
if durableReceipt && cardRewardReceiptMatches(character.Stats, receiptHash) {
|
|
replayed, receiptErr := cardRewardReceiptResult(character.Stats)
|
|
if receiptErr != nil {
|
|
return receiptErr
|
|
}
|
|
result = replayed
|
|
result.Replayed = true
|
|
return nil
|
|
}
|
|
if cmd.ConsumePremiumDaily && !premium.TryConsumeDaily(&character, cmd.PremiumDailySlot, now) {
|
|
return ErrPremiumDailyLimit
|
|
}
|
|
currentGold := character.Stats["gold"]
|
|
result.GoldBefore = currentGold
|
|
if cmd.Cost < 0 {
|
|
return ErrRewardInvalid
|
|
}
|
|
if cmd.Cost > 0 {
|
|
if currentGold < cmd.Cost {
|
|
return ErrCardRewardGoldShort
|
|
}
|
|
currentGold -= cmd.Cost
|
|
character.Stats["gold"] = currentGold
|
|
result.GoldSpent = cmd.Cost
|
|
}
|
|
if cmd.Bundle.Gold > 0 && currentGold > math.MaxInt64-cmd.Bundle.Gold {
|
|
return ErrGoldOverflow
|
|
}
|
|
// The carry cap the character has bought bites here: a reward cannot be
|
|
// refused without losing the whole bundle, so the excess is discarded
|
|
// instead. carrygold.Credit never lowers a balance that is already
|
|
// above the cap.
|
|
result.GoldAfter, result.GoldDiscarded = carrygold.Credit(character, cmd.Bundle.Gold)
|
|
character.Stats["gold"] = result.GoldAfter
|
|
|
|
inventory, found, err := inventories.Load(ctx, cmd.CharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
inventory = dnfrepo.InventoryRecord{CharacterID: cmd.CharacterID}
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
if inventory.Slots == nil {
|
|
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
// 无色小晶块 / 灵魂 这两族通关奖励必须先落进账号共享仓库的固定格
|
|
// (晶体仓库 354..359 + 灵魂仓库 360..365,权威表见
|
|
// repository/account_shared_materials.go)。翻牌这条路过去直接 addCardItem
|
|
// 写角色背包 —— 与 2026-09-21 NPC 商店金币购买落错槽是同一类漏点。
|
|
accountInventory := dnfrepo.AccountInventoryRecord{}
|
|
accountInventoryLoaded := false
|
|
// accountInventoryReady 才代表"账号记录真的读到了":读不到(没有账号仓库仓库、
|
|
// 或角色记录里没有 AccountID)就必须整条退回角色背包那条既有路径,
|
|
// 绝不能拿一个空 AccountID 去写账号记录。
|
|
accountInventoryReady := false
|
|
accountDirty := false
|
|
loadAccountInventory := func() error {
|
|
if accountInventoryLoaded || accountInventories == nil {
|
|
return nil
|
|
}
|
|
accountInventoryLoaded = true
|
|
accountID := strings.TrimSpace(character.AccountID)
|
|
if accountID == "" {
|
|
return nil
|
|
}
|
|
record, _, loadErr := accountInventories.Load(ctx, accountID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
accountInventory = dnfrepo.CloneAccountInventory(record)
|
|
accountInventory.AccountID = accountID
|
|
if accountInventory.Slots == nil {
|
|
accountInventory.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
accountInventoryReady = true
|
|
return nil
|
|
}
|
|
overflow := make([]dnfrepo.MailAttachment, 0)
|
|
for _, reward := range cmd.Bundle.Items {
|
|
pending := reward
|
|
if accountInventories != nil {
|
|
if err := loadAccountInventory(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if accountInventoryReady {
|
|
// 账号记录可用:无色/灵魂类奖励先落它的固定格,余量才交给角色背包。
|
|
_, remainder, accountChanged := grantCardRewardAccountShared(&accountInventory, reward)
|
|
if accountChanged {
|
|
accountDirty = true
|
|
if fixedSlot, member := dnfrepo.AccountSharedWarehouseSlotForItem(reward.Stack.ItemID); member {
|
|
result.ItemSlots = append(result.ItemSlots, fixedSlot)
|
|
}
|
|
}
|
|
if remainder <= 0 {
|
|
continue
|
|
}
|
|
if accountChanged {
|
|
// 只剩余量要落角色背包:数量换成余量,行本身沿用奖励的投影。
|
|
pending.Stack = cloneStack(reward.Stack)
|
|
pending.Stack.Count = remainder
|
|
}
|
|
}
|
|
slot, err := addCardItem(&inventory, cmd.MainSlots, pending, cmd.Project)
|
|
if err != nil {
|
|
if !errors.Is(err, ErrInventoryFull) || mailboxes == nil {
|
|
return err
|
|
}
|
|
attachment, attachmentErr := cardRewardMailAttachment(pending, cmd.Project)
|
|
if attachmentErr != nil {
|
|
return attachmentErr
|
|
}
|
|
overflow = append(overflow, attachment)
|
|
result.ItemSlots = append(result.ItemSlots, -1)
|
|
continue
|
|
}
|
|
result.ItemSlots = append(result.ItemSlots, slot)
|
|
}
|
|
|
|
if durableReceipt {
|
|
writeCardRewardReceipt(character.Stats, receiptHash, result, len(overflow) != 0)
|
|
}
|
|
character.UpdatedAt = now
|
|
inventory.UpdatedAt = now
|
|
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
|
return err
|
|
}
|
|
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
|
return err
|
|
}
|
|
if accountDirty {
|
|
accountInventory.UpdatedAt = now
|
|
if err := accountInventories.Save(ctx, accountInventory); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(overflow) == 0 {
|
|
return nil
|
|
}
|
|
mailID, mailErr := dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{
|
|
RecipientCharacterID: cmd.CharacterID,
|
|
Title: "背包已满:通关奖励",
|
|
Body: "背包空间不足,通关奖励已通过邮件发送。请清理对应道具分页后领取。",
|
|
Source: "dungeon_card_reward_inventory_full",
|
|
Attachments: overflow,
|
|
CreatedAt: now,
|
|
})
|
|
if mailErr != nil {
|
|
return mailErr
|
|
}
|
|
result.OverflowMailID = mailID
|
|
return nil
|
|
}
|
|
var err error
|
|
if o.mailboxAssets != nil {
|
|
err = o.mailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(
|
|
_ dnfrepo.AccountRepository,
|
|
accountInventories dnfrepo.AccountInventoryRepository,
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
mailboxes dnfrepo.MailboxRepository,
|
|
) error {
|
|
return apply(characters, inventories, mailboxes, accountInventories)
|
|
})
|
|
} else {
|
|
// 这条兜底事务拿不到账号仓库记录,无色/灵魂类奖励只能照旧落角色背包。
|
|
// 生产装配(memory / mysql)都提供 MailboxAssets,所以走的是上面那条。
|
|
err = o.assets.WithinCharacterAssets(ctx, cmd.CharacterID, func(
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
_ dnfrepo.EquipmentRepository,
|
|
) error {
|
|
return apply(characters, inventories, nil, nil)
|
|
})
|
|
}
|
|
if err != nil {
|
|
return CardRewardResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func cardRewardMailAttachment(reward CardItemReward, project StackProjector) (dnfrepo.MailAttachment, error) {
|
|
stack := cloneStack(reward.Stack)
|
|
if reward.Stackable && !reward.ExpireAt.IsZero() {
|
|
if project == nil {
|
|
return dnfrepo.MailAttachment{}, ErrStackProjectorRequired
|
|
}
|
|
var err error
|
|
stack, err = project(stack, reward.ExpireAt)
|
|
if err != nil {
|
|
return dnfrepo.MailAttachment{}, err
|
|
}
|
|
}
|
|
return dnfrepo.MailAttachment{
|
|
ItemID: stack.ItemID,
|
|
Count: stack.Count,
|
|
Bind: stack.Bind,
|
|
ExpireAt: stack.ExpireAt,
|
|
RawEntry: append([]byte(nil), stack.RawEntry...),
|
|
Extra: cloneCardRewardExtra(stack.Extra),
|
|
}, nil
|
|
}
|
|
|
|
func cloneCardRewardExtra(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for key, value := range in {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (o *Owner) GrantPickupGold(ctx context.Context, cmd GoldPickupCommand) (GoldPickupResult, error) {
|
|
if o == nil || o.assets == nil || strings.TrimSpace(cmd.CharacterID) == "" {
|
|
return GoldPickupResult{}, ErrOwnerUnavailable
|
|
}
|
|
if cmd.Amount == 0 {
|
|
return GoldPickupResult{}, ErrRewardInvalid
|
|
}
|
|
ctx = contextOrBackground(ctx)
|
|
now := updatedAtOrNow(cmd.UpdatedAt)
|
|
|
|
var result GoldPickupResult
|
|
err := dnfrepo.WithinCharacterAssetsScoped(ctx, o.assets, cmd.CharacterID, func(
|
|
characters dnfrepo.CharacterRepository,
|
|
_ dnfrepo.InventoryRepository,
|
|
_ dnfrepo.EquipmentRepository,
|
|
) error {
|
|
character, found, err := characters.Load(ctx, cmd.CharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return ErrCharacterNotFound
|
|
}
|
|
character = dnfrepo.CloneCharacter(character)
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
result.GoldBefore = character.Stats["gold"]
|
|
delta := int64(cmd.Amount)
|
|
if result.GoldBefore < 0 || result.GoldBefore > math.MaxInt64-delta {
|
|
return ErrGoldOverflow
|
|
}
|
|
// A gold drop the player walks over cannot be handed back, so the carry
|
|
// cap discards what does not fit instead of refusing the pickup.
|
|
result.GoldAfter, result.GoldDiscarded = carrygold.Credit(character, delta)
|
|
character.Stats["gold"] = result.GoldAfter
|
|
character.UpdatedAt = now
|
|
return dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats)
|
|
})
|
|
if err != nil {
|
|
return GoldPickupResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateCardReward(cmd CardRewardCommand) error {
|
|
if cmd.Bundle.Gold < 0 ||
|
|
cmd.ConsumePremiumDaily && (cmd.PremiumDailySlot < 0 || cmd.PremiumDailySlot >= premium.DevilSlotCount) ||
|
|
strings.TrimSpace(cmd.CompletionKey) != "" && len(cmd.Bundle.Items) > math.MaxUint16 {
|
|
return ErrRewardInvalid
|
|
}
|
|
for _, reward := range cmd.Bundle.Items {
|
|
if reward.Stack.ItemID <= 0 || reward.Stack.Count <= 0 ||
|
|
!reward.Stackable && reward.Stack.Count != 1 {
|
|
return ErrRewardInvalid
|
|
}
|
|
hasPVFRange := reward.SlotStart >= 3 && reward.SlotEnd >= reward.SlotStart
|
|
if !hasPVFRange && cmd.MainSlots == 0 {
|
|
return ErrRewardInvalid
|
|
}
|
|
if reward.Stackable && !reward.ExpireAt.IsZero() && cmd.Project == nil {
|
|
return ErrStackProjectorRequired
|
|
}
|
|
if reward.StackLimit < 0 || (reward.Stackable && reward.StackLimit > 0 && reward.Stack.Count > reward.StackLimit) {
|
|
return ErrRewardInvalid
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cardRewardReceiptMatches(stats map[string]int64, want [sha256.Size]byte) bool {
|
|
if stats == nil || stats[cardRewardReceiptPrefix+"version"] != cardRewardReceiptVersion {
|
|
return false
|
|
}
|
|
for index := 0; index < 4; index++ {
|
|
if uint64(stats[fmt.Sprintf("%shash_%d", cardRewardReceiptPrefix, index)]) !=
|
|
binary.LittleEndian.Uint64(want[index*8:(index+1)*8]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeCardRewardReceipt(
|
|
stats map[string]int64,
|
|
hash [sha256.Size]byte,
|
|
result CardRewardResult,
|
|
overflow bool,
|
|
) {
|
|
stats[cardRewardReceiptPrefix+"version"] = cardRewardReceiptVersion
|
|
for index := 0; index < 4; index++ {
|
|
stats[fmt.Sprintf("%shash_%d", cardRewardReceiptPrefix, index)] =
|
|
int64(binary.LittleEndian.Uint64(hash[index*8 : (index+1)*8]))
|
|
}
|
|
stats[cardRewardReceiptPrefix+"gold_before"] = result.GoldBefore
|
|
stats[cardRewardReceiptPrefix+"gold_after"] = result.GoldAfter
|
|
stats[cardRewardReceiptPrefix+"gold_spent"] = result.GoldSpent
|
|
stats[cardRewardReceiptPrefix+"item_slot_count"] = int64(len(result.ItemSlots))
|
|
for index, slot := range result.ItemSlots {
|
|
stats[fmt.Sprintf("%sitem_slot_%d", cardRewardReceiptPrefix, index)] = int64(slot)
|
|
}
|
|
stats[cardRewardReceiptPrefix+"overflow"] = 0
|
|
if overflow {
|
|
stats[cardRewardReceiptPrefix+"overflow"] = 1
|
|
}
|
|
}
|
|
|
|
func cardRewardReceiptResult(stats map[string]int64) (CardRewardResult, error) {
|
|
goldBefore, beforeFound := stats[cardRewardReceiptPrefix+"gold_before"]
|
|
goldAfter, afterFound := stats[cardRewardReceiptPrefix+"gold_after"]
|
|
count, countFound := stats[cardRewardReceiptPrefix+"item_slot_count"]
|
|
overflow, overflowFound := stats[cardRewardReceiptPrefix+"overflow"]
|
|
// Receipts written before the paid-card commission existed carry no
|
|
// gold_spent key; they describe a flip that spent nothing, so a missing key
|
|
// reads as zero instead of invalidating an already-committed transaction.
|
|
goldSpent, spentFound := stats[cardRewardReceiptPrefix+"gold_spent"]
|
|
if !spentFound {
|
|
goldSpent = 0
|
|
}
|
|
if !beforeFound || !afterFound || !countFound || !overflowFound ||
|
|
goldSpent < 0 || goldSpent > math.MaxInt64/2 || goldAfter < goldBefore-goldSpent ||
|
|
count < 0 || count > math.MaxUint16 || (overflow != 0 && overflow != 1) {
|
|
return CardRewardResult{}, ErrCardRewardReceiptInvalid
|
|
}
|
|
result := CardRewardResult{
|
|
GoldBefore: goldBefore,
|
|
GoldAfter: goldAfter,
|
|
GoldSpent: goldSpent,
|
|
ItemSlots: make([]int16, 0, int(count)),
|
|
}
|
|
for index := 0; index < int(count); index++ {
|
|
value, found := stats[fmt.Sprintf("%sitem_slot_%d", cardRewardReceiptPrefix, index)]
|
|
if !found || value < -1 || value > math.MaxInt16 {
|
|
return CardRewardResult{}, ErrCardRewardReceiptInvalid
|
|
}
|
|
result.ItemSlots = append(result.ItemSlots, int16(value))
|
|
}
|
|
if overflow == 1 {
|
|
result.OverflowMailID = cardRewardReceiptOverflowMarker
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// grantCardRewardAccountShared 把一件通关奖励并进账号共享仓库的固定格。
|
|
//
|
|
// 六个晶块(354..359)与六个灵魂(360..365)在同一个账号里只有那一个家(权威表见
|
|
// repository/account_shared_materials.go)。翻牌这条路过去直接把这些奖励写进角色
|
|
// 背包 —— 2026-09-21 的同类漏点(NPC 商店金币购买)已经证明"每条路径自己判一次"
|
|
// 迟早会漏,所以这里不再自己判,交给唯一入库原语。
|
|
//
|
|
// 顺序上有个讲究:**先用奖励自带的那一行占住固定格**(保留 bridge 投影好的
|
|
// RawEntry / 绑定 / 到期,原语不认识线格式),再把数量交给原语加 —— 额度与并数归原语,
|
|
// 行本身仍是投影出来的那一条。数量变了以后由包内的 updateDungeonRewardRawAmount
|
|
// 补盖 0x77 行里的数量字节。
|
|
//
|
|
// 返回 (放进账号仓的数量, 需要交给角色背包的余量, 账号记录是否被改动)。
|
|
// 该道具不属于账号共享仓、或账号记录不可用时,原样退回给角色背包那条既有路径。
|
|
func grantCardRewardAccountShared(accountInventory *dnfrepo.AccountInventoryRecord, reward CardItemReward) (int64, int64, bool) {
|
|
if accountInventory == nil || !reward.Stackable || reward.Stack.ItemID <= 0 || reward.Stack.Count <= 0 {
|
|
return 0, reward.Stack.Count, false
|
|
}
|
|
fixedSlot, member := dnfrepo.AccountSharedWarehouseSlotForItem(reward.Stack.ItemID)
|
|
if !member {
|
|
return 0, reward.Stack.Count, false
|
|
}
|
|
key := dnfrepo.AccountSharedInventorySlotKey(fixedSlot)
|
|
seeded := false
|
|
if existing, exists := accountInventory.Slots[key]; !exists || existing.ItemID <= 0 {
|
|
seededStack := cloneStack(reward.Stack)
|
|
// 数量归零:全部交给原语去加,免得这里和原语各加一次。
|
|
seededStack.Count = 0
|
|
accountInventory.Slots[key] = seededStack
|
|
seeded = true
|
|
}
|
|
grant := dnfrepo.GrantAccountSharedWarehouseStack(
|
|
accountInventory,
|
|
reward.Stack.ItemID,
|
|
reward.StackLimit,
|
|
reward.Stack.Count,
|
|
dnfrepo.AccountSharedGrantMeta{
|
|
Source: "dungeon_card_reward",
|
|
ItemKind: reward.Stack.Extra["item_kind"],
|
|
PVFPath: reward.Stack.Extra["pvf_path"],
|
|
StackableType: reward.Stack.Extra["stackable_type"],
|
|
},
|
|
)
|
|
if !grant.Changed {
|
|
// 固定格被别人占着、或容器里同 id 已到 [stack limit]:整件退回角色背包。
|
|
// 绝不驱赶别人的行,也不留下一个空壳行。
|
|
if seeded {
|
|
delete(accountInventory.Slots, key)
|
|
}
|
|
return 0, reward.Stack.Count, false
|
|
}
|
|
finalKey := dnfrepo.AccountSharedInventorySlotKey(grant.Slot)
|
|
stack := accountInventory.Slots[finalKey]
|
|
updateDungeonRewardRawAmount(&stack)
|
|
accountInventory.Slots[finalKey] = stack
|
|
return grant.Placed, grant.Remainder, true
|
|
}
|
|
|
|
func addCardItem(
|
|
record *dnfrepo.InventoryRecord,
|
|
mainSlots uint16,
|
|
reward CardItemReward,
|
|
project StackProjector,
|
|
) (int16, error) {
|
|
if record == nil {
|
|
return 0, ErrRewardInvalid
|
|
}
|
|
// Card-flip revive coins belong in the fixed slot-1 wallet, like every
|
|
// other channel (see dnfrevivecoin.Grant).
|
|
if dnfrevivecoin.IsReward(reward.Stack.ItemID) && reward.Stack.Count > 0 {
|
|
if _, _, err := dnfrevivecoin.GrantSlots(
|
|
record.Slots, reward.Stack.Count, "revive_coin_card_reward"); err != nil {
|
|
return 0, err
|
|
}
|
|
return dnfrevivecoin.WalletSlot, nil
|
|
}
|
|
slotStart := int16(0)
|
|
slotEnd := int16(mainSlots) - 1
|
|
if reward.SlotStart >= 3 && reward.SlotEnd >= reward.SlotStart {
|
|
slotStart = reward.SlotStart
|
|
slotEnd = reward.SlotEnd
|
|
}
|
|
// PVF [stack limit] 是这个容器里该道具的总量上限:额度按容器里同 id 总量算,
|
|
// 容器里已经有该道具的行时不能再开第二行。装备(每件一行)与没写 [stack limit]
|
|
// 的道具沿用旧行为。
|
|
newRowStackLimit := int64(0)
|
|
space := int64(math.MaxInt32)
|
|
if reward.Stackable && reward.StackLimit > 0 {
|
|
newRowStackLimit = reward.StackLimit
|
|
space = dnfrepo.ContainerItemSpace(
|
|
record.Slots,
|
|
dnfrepo.MainInventoryListType,
|
|
reward.Stack.ItemID,
|
|
reward.StackLimit,
|
|
)
|
|
}
|
|
if reward.Stackable {
|
|
for slot := slotStart; slot <= slotEnd; slot++ {
|
|
key := mainSlotKey(uint16(slot))
|
|
stack, ok := record.Slots[key]
|
|
if !ok || stack.ItemID != reward.Stack.ItemID || stack.Bind != reward.Stack.Bind {
|
|
continue
|
|
}
|
|
if reward.ExpireAt.IsZero() {
|
|
if !stack.ExpireAt.IsZero() {
|
|
continue
|
|
}
|
|
} else {
|
|
var err error
|
|
stack, err = project(stack, reward.ExpireAt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
if reward.StackLimit == 1 || (reward.StackLimit > 0 && stack.Count > reward.StackLimit-reward.Stack.Count) ||
|
|
reward.Stack.Count > space {
|
|
continue
|
|
}
|
|
if stack.Count > math.MaxInt64-reward.Stack.Count {
|
|
return 0, fmt.Errorf("%w: item=%d", ErrRewardInvalid, reward.Stack.ItemID)
|
|
}
|
|
stack.Count += reward.Stack.Count
|
|
updateDungeonRewardRawAmount(&stack)
|
|
record.Slots[key] = stack
|
|
return int16(slot), nil
|
|
}
|
|
}
|
|
for slot := slotStart; slot <= slotEnd; slot++ {
|
|
key := mainSlotKey(uint16(slot))
|
|
if _, occupied := record.Slots[key]; occupied {
|
|
continue
|
|
}
|
|
if !dnfrepo.CanOpenContainerItemRow(record.Slots, dnfrepo.MainInventoryListType, reward.Stack.ItemID, newRowStackLimit) {
|
|
break
|
|
}
|
|
stack := cloneStack(reward.Stack)
|
|
if reward.Stackable && !reward.ExpireAt.IsZero() {
|
|
var err error
|
|
stack, err = project(stack, reward.ExpireAt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
record.Slots[key] = stack
|
|
return int16(slot), nil
|
|
}
|
|
return 0, ErrInventoryFull
|
|
}
|
|
|
|
func updateDungeonRewardRawAmount(stack *dnfrepo.ItemStack) {
|
|
if stack == nil || stack.Count <= 0 || stack.Count > math.MaxUint32 || len(stack.RawEntry) != 0x77 {
|
|
return
|
|
}
|
|
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
|
binary.LittleEndian.PutUint32(stack.RawEntry[0x06:0x0a], uint32(stack.Count))
|
|
}
|
|
|
|
func mainSlotKey(slot uint16) string {
|
|
return "0:" + strconv.FormatUint(uint64(slot), 10)
|
|
}
|
|
|
|
func cloneStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
|
|
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
|
if len(stack.Extra) == 0 {
|
|
stack.Extra = nil
|
|
return stack
|
|
}
|
|
extra := make(map[string]string, len(stack.Extra))
|
|
for key, value := range stack.Extra {
|
|
extra[key] = value
|
|
}
|
|
stack.Extra = extra
|
|
return stack
|
|
}
|
|
|
|
func parseLuckyStars(raw string) (uint32, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return 0, nil
|
|
}
|
|
value, err := strconv.ParseUint(raw, 10, 32)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return uint32(value), nil
|
|
}
|
|
|
|
func contextOrBackground(ctx context.Context) context.Context {
|
|
if ctx == nil {
|
|
return context.Background()
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func updatedAtOrNow(value time.Time) time.Time {
|
|
if value.IsZero() {
|
|
return time.Now().UTC()
|
|
}
|
|
return value
|
|
}
|