Files
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

1010 lines
40 KiB
Go

package dnfbridge
import (
"fmt"
"math"
"path"
"strconv"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
)
func currentCeraShopProductDefinitionForGrantAt(
definition dungeonDropItemDefinition,
product currentCeraShopProduct,
now time.Time,
) (dungeonDropItemDefinition, error) {
resolved, err := currentPVFItemDefinitionForGrantAt(definition, now)
if err != nil {
return dungeonDropItemDefinition{}, err
}
if product.DurationDays == 0 {
return resolved, nil
}
if now.IsZero() {
now = time.Now().UTC()
}
nowUnix := now.Unix()
durationSeconds := uint64(product.DurationDays) * uint64((24*time.Hour)/time.Second)
if nowUnix <= 0 || nowUnix > math.MaxUint32 || durationSeconds > uint64(math.MaxUint32)-uint64(nowUnix) {
return dungeonDropItemDefinition{}, fmt.Errorf(
"%w: commodity=%d duration_days=%d now=%d",
errCurrentCeraShopProductUnavailable,
product.CommodityID,
product.DurationDays,
nowUnix,
)
}
resolved.ExpirationDate = time.Unix(nowUnix+int64(durationSeconds), 0).UTC()
return resolved, nil
}
func consumeCurrentCeraShopAvatarCoupon(inventory *dnfrepo.InventoryRecord, couponItemID int64) (currentItemListEntry, error) {
if inventory == nil || couponItemID <= 0 {
return currentItemListEntry{}, errCurrentCeraShopOwnerUnavailable
}
bestKey := ""
bestSlot := int16(math.MaxInt16)
var bestStack dnfrepo.ItemStack
for key, stack := range inventory.Slots {
listType, slot, ok := parseSceneInventorySlotKey(key)
if !ok || listType != dnfrepo.MainInventoryListType || slot < 0 || dnfrepo.IsAccountSharedInventorySlot(listType, slot) {
continue
}
if stack.ItemID != couponItemID || stack.Count <= 0 {
continue
}
if bestKey == "" || slot < bestSlot {
bestKey = key
bestSlot = slot
bestStack = stack
}
}
if bestKey == "" {
return currentItemListEntry{}, fmt.Errorf("%w: avatar coupon item=%d", errCurrentCeraShopCeraInsufficient, couponItemID)
}
bestStack.Count--
if bestStack.Count <= 0 {
delete(inventory.Slots, bestKey)
var removed currentItemListEntry
removed.patchCore(bestSlot, math.MaxUint32, 0)
return removed, nil
}
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, bestSlot, bestStack)
bestStack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[bestKey] = bestStack
return entry, nil
}
// currentCeraShopMainInventoryUpgradeStage recognizes the three main-bag
// expansion products through their runtime PVF paths. The returned value is
// the Cera-shop stage (1..3); the persisted op13 list-0 expansion is stage*8.
func currentCeraShopMainInventoryUpgradeStage(definition dungeonDropItemDefinition) (uint16, bool) {
if definition.Kind != dungeonDropItemStackable {
return 0, false
}
cleanPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(definition.PVFPath, "\\", "/")))
cleanPath = strings.TrimPrefix(cleanPath, "stackable/")
switch cleanPath {
case "cash/inven_upgradekit1.stk":
return 1, true
case "cash/inven_upgradekit2.stk":
return 2, true
case "cash/chn_20140617_new_sales_item/chn_inventory_3rd_expansion_2683675.stk":
return 3, true
default:
return 0, false
}
}
func (c *pvfCeraShopCatalog) isCharacterSlotExpansionTicket(definition dungeonDropItemDefinition) bool {
if c == nil || c.source == nil || definition.Kind != dungeonDropItemStackable {
return false
}
cleanPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(definition.PVFPath, "\\", "/")))
if cleanPath != "stackable/cash/character_slotkit.stk" {
return false
}
document, err := parseDungeonCardPVFDocument(c.source, definition.PVFPath)
if err != nil {
return false
}
stackableType, typeFound := document.Text("stackable type")
return typeFound && strings.EqualFold(strings.TrimSpace(stackableType), "[etc]")
}
// characterRenameCardKind reports whether a stackable is a character rename card.
//
// Recognition is the PVF contract, not a file-name allowlist:
//
// [action type]
// `[character name change]`
// [/action type]
//
// Any item carrying that action type can rename a character. The runtime PVF
// currently declares it on 18 stackables - the Cera shop card, the event cards
// (角色更名卡 / 角色更名卡 (魔枪士) / 冒险家征集令), the account-bound cards and the
// localization-key variants whose [name] reads `name_<id>` - and all of them are
// items the client lets the player use exactly like the shop card.
//
// autoUse stays path-based on purpose: it only says whether the Cera shop
// purchase consumes the item and arms the rename directly (the 8800-Cera product)
// instead of delivering the card to the bag. Widening recognition must not change
// what any existing Cera shop product delivers.
//
// The shop product is recognized by its path alone, without the action type.
// The runtime PVF proves that requirement cannot be met by this item:
// stackable/cash/chn_character_rename.stk is 529 bytes and carries no
// [action type] block at all (unlike its bag sibling
// stackable/cash/charcter_rename_card.stk, which declares
// `[character name change]` and is used from the bag through op509). Its
// [explain] ends with `购买后将自动使用。` - the shop consumes it at checkout.
//
// Requiring the action type here is what broke the 8800-Cera purchase: the path
// matched, the declaration did not, so the product was classified as an ordinary
// stackable, delivered to the bag, and the character never received the
// pending-rename entitlement. The card is client-side unusable in a bag (the
// client only sends op509 for items that declare the action type), so the
// purchase bought nothing.
//
// Accepting the exact auto-use path is not a loosening: an item that lives at
// that path is the shop's rename product by construction, and every other
// stackable - including 宠物更名卡 at cash/creature/rename_card.stk - still has
// to declare the action type.
func (c *pvfCeraShopCatalog) characterRenameCardKind(definition dungeonDropItemDefinition) (autoUse bool, ok bool) {
if c == nil || c.source == nil || definition.Kind != dungeonDropItemStackable {
return false, false
}
autoUse = currentCharacterRenameAutoUsePVFPath(definition.PVFPath)
document, err := parseDungeonCardPVFDocument(c.source, definition.PVFPath)
if err != nil {
return false, false
}
if autoUse || currentItemDeclaresActionType(document, currentItemActionTypeCharacterNameChange) {
return autoUse, true
}
return false, false
}
// currentCharacterRenameAutoUsePVFPath recognizes the Cera shop rename product
// whose purchase arms the rename immediately. Every other rename card, including
// every other card the shop may deliver, is used from the bag through
// USE_RENAME_CARD.
func currentCharacterRenameAutoUsePVFPath(pvfPath string) bool {
clean := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(pvfPath, "\\", "/")))
return clean == "stackable/cash/chn_character_rename.stk"
}
// avatarInventoryUpgradeStage identifies a real avatar-inventory ticket from
// the PVF item that the Cera catalog resolved. Every supported ticket says it
// adds seven avatar slots and embeds the same target stage in its name and
// explanation; the item ID and Cera commodity are deliberately not part of
// the contract.
func (c *pvfCeraShopCatalog) avatarInventoryUpgradeStage(definition dungeonDropItemDefinition) (uint16, bool, error) {
if c == nil || c.source == nil || definition.Kind != dungeonDropItemStackable {
return 0, false, nil
}
document, err := parseDungeonCardPVFDocument(c.source, definition.PVFPath)
if err != nil {
return 0, false, err
}
name, nameFound := document.Text("name")
stage, namedTicket := currentCeraShopAvatarInventoryStageFromName(name)
if !nameFound || !namedTicket {
return 0, false, nil
}
explain, explainFound := document.Text("explain")
explainedStage, explainedTicket := currentCeraShopAvatarInventoryStageFromExplain(explain)
if !explainFound || !explainedTicket || stage == 0 || stage != explainedStage {
return 0, true, nil
}
return stage, true, nil
}
func currentCeraShopAvatarInventoryStageFromName(name string) (uint16, bool) {
clean := currentCeraShopCompactPVFText(name)
const (
prefix = "装扮栏扩展券("
suffix = "阶段)"
)
if !strings.HasPrefix(clean, prefix) || !strings.HasSuffix(clean, suffix) {
return 0, false
}
stage, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimPrefix(clean, prefix), suffix), 10, 16)
if err != nil || stage == 0 || stage > 20 {
return 0, true
}
return uint16(stage), true
}
func currentCeraShopAvatarInventoryStageFromExplain(explain string) (uint16, bool) {
clean := currentCeraShopCompactPVFText(explain)
if !strings.Contains(clean, "装扮栏") || !strings.Contains(clean, "增加7格") {
return 0, false
}
for _, marker := range []string{"最多可扩展至", "最多可扩展到", "可以扩展至", "可以扩展到", "可扩展至", "可扩展到"} {
start := strings.Index(clean, marker)
if start < 0 {
continue
}
rest := clean[start+len(marker):]
end := strings.Index(rest, "阶段")
if end <= 0 {
return 0, true
}
stage, err := strconv.ParseUint(rest[:end], 10, 16)
if err != nil || stage == 0 || stage > 20 {
return 0, true
}
return uint16(stage), true
}
return 0, true
}
func currentCeraShopCompactPVFText(value string) string {
return strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "").Replace(strings.TrimSpace(value))
}
// currentCeraShopPersonalCargoUpgradeTarget recognizes the original personal
// cargo tools from their real PVF paths. The no-suffix tool is the first tier
// (8 -> 24); numeric suffixes identify later 16-slot tiers. The later live
// catalog uses a separate numeric directory, so catalog.personalCargoUpgradeTarget
// augments this path-only recognition with the item's own PVF name/explain
// fields rather than with a copied commodity-ID table.
func currentCeraShopPersonalCargoUpgradeTarget(definition dungeonDropItemDefinition) (uint16, bool) {
if definition.Kind != dungeonDropItemStackable {
return 0, false
}
cleanPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(definition.PVFPath, "\\", "/")))
if cleanPath == "" || strings.Contains(cleanPath, "cash/chn_account_cargo/") {
return 0, false
}
base := path.Base(cleanPath)
if path.Ext(base) != ".stk" {
return 0, false
}
stem := strings.TrimSuffix(base, ".stk")
const prefix = "safe_upgradekit"
if !strings.HasPrefix(stem, prefix) {
return 0, false
}
tierText := strings.TrimPrefix(stem, prefix)
if tierText != "" {
for _, digit := range tierText {
if digit < '0' || digit > '9' {
return 0, false
}
}
}
tier := 0
if tierText != "" {
parsed, err := strconv.ParseUint(tierText, 10, 16)
if err != nil {
return 0, true
}
tier = int(parsed)
}
const (
firstPersonalCargoUpgradeSlots = 24
personalCargoUpgradeStep = 16
maximumPersonalCargoUpgrade = 200
)
target := firstPersonalCargoUpgradeSlots + tier*personalCargoUpgradeStep
if target > maximumPersonalCargoUpgrade {
return 0, true
}
return uint16(target), true
}
// isAccountCargoUpgradeTool recognizes the account-cargo tool through the
// active PVF path, not through a copied commodity or item-template ID. The
// ordinary personal-cargo recognizer explicitly excludes this directory.
func (c *pvfCeraShopCatalog) isAccountCargoUpgradeTool(definition dungeonDropItemDefinition) bool {
if c == nil || definition.Kind != dungeonDropItemStackable {
return false
}
cleanPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(definition.PVFPath, "\\", "/")))
return path.Ext(cleanPath) == ".stk" && strings.Contains(cleanPath, "cash/chn_account_cargo/account_cargo")
}
// personalCargoUpgradeTarget recognizes every real personal-cargo tool in
// the active PVF. The post-152 products are not named safe_upgradekit*.stk:
// their stable contract is the stackable item's exact name plus an explicit
// target capacity in [explain]. Both values are read from the PVF that also
// supplied the requested Cera product; neither a client field nor a static
// commodity-ID map can turn an ordinary item into a warehouse upgrade.
func (c *pvfCeraShopCatalog) personalCargoUpgradeTarget(definition dungeonDropItemDefinition) (uint16, bool) {
if target, isUpgrade := currentCeraShopPersonalCargoUpgradeTarget(definition); isUpgrade {
return target, true
}
if c == nil || c.source == nil || definition.Kind != dungeonDropItemStackable {
return 0, false
}
cleanPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(definition.PVFPath, "\\", "/")))
if cleanPath == "" || strings.Contains(cleanPath, "cash/chn_account_cargo/") || path.Ext(cleanPath) != ".stk" {
return 0, false
}
document, err := parseDungeonCardPVFDocument(c.source, definition.PVFPath)
if err != nil {
return 0, false
}
name, nameFound := document.Text("name")
if !nameFound || strings.TrimSpace(name) != "金库升级工具" {
return 0, false
}
explain, explainFound := document.Text("explain")
target, valid := currentCeraShopPersonalCargoTargetFromExplain(explain)
if !explainFound || !valid {
// This is positively a warehouse tool but its target is not a current
// EXE-valid capacity. Reject it at checkout instead of silently placing
// an unusable tool in the bag.
return 0, true
}
return target, true
}
func currentCeraShopPersonalCargoTargetFromExplain(explain string) (uint16, bool) {
const (
prefix = "可以使金库的空间增加到"
suffix = "格"
max = uint64(200)
)
clean := strings.TrimSpace(explain)
if !strings.HasPrefix(clean, prefix) {
return 0, false
}
remaining := strings.TrimPrefix(clean, prefix)
end := strings.Index(remaining, suffix)
if end <= 0 {
return 0, false
}
target, err := strconv.ParseUint(strings.TrimSpace(remaining[:end]), 10, 16)
if err != nil || target > max || target < uint64(currentExeInitialPersonalCargoSlotCount) {
return 0, false
}
value := uint16(target)
if (value-uint16(currentExeInitialPersonalCargoSlotCount))%16 != 0 {
return 0, false
}
return value, true
}
// currentCeraShopPrepareContainerUpgrades validates and projects the persisted
// current EXE list-0/list-1/list-2 headers inside the Cera-shop asset transaction.
func currentCeraShopPrepareContainerUpgrades(
record *dnfrepo.SettingsRecord,
found bool,
characterID string,
purchases []currentCeraShopResolvedPurchase,
now time.Time,
) (dnfrepo.SettingsRecord, uint16, uint16, uint16, bool, error) {
hasUpgrade := false
for _, purchase := range purchases {
if purchase.MainInventoryUpgradeStage != 0 || purchase.AvatarInventoryUpgradeStage != 0 || purchase.PersonalCargoUpgradeTarget != 0 {
hasUpgrade = true
break
}
}
if !hasUpgrade {
return dnfrepo.SettingsRecord{}, 0, 0, 0, false, nil
}
if record == nil || strings.TrimSpace(characterID) == "" {
return dnfrepo.SettingsRecord{}, 0, 0, 0, false, errCurrentCeraShopOwnerUnavailable
}
before := dnfrepo.CloneSettings(*record)
currentMainExpansion := uint16(currentExeInitialMainSlotCount)
currentAvatarExpansion := uint16(0)
currentSlots := uint16(currentExeInitialPersonalCargoSlotCount)
if !found {
before = newCharacterContainerStateSettings(characterID, now)
} else {
state, stateErr := dnfrepo.CharacterContainerStateFromSettings(before, characterID)
if stateErr != nil {
return dnfrepo.SettingsRecord{}, 0, 0, 0, false, stateErr
}
currentMainExpansion = state.MainSlotCount
currentAvatarExpansion = state.AvatarExpansion
currentSlots = state.PersonalCargoSlotCount
}
targetMainExpansion := currentMainExpansion
targetAvatarExpansion := currentAvatarExpansion
targetSlots := currentSlots
lastMainCommodityID := uint32(0)
lastAvatarCommodityID := uint32(0)
lastPersonalCargoCommodityID := uint32(0)
for _, purchase := range purchases {
if purchase.MainInventoryUpgradeStage != 0 {
targetExpansion := purchase.MainInventoryUpgradeStage * 8
if purchase.MainInventoryUpgradeStage > 3 || targetExpansion != targetMainExpansion+8 || targetExpansion > 24 {
return dnfrepo.SettingsRecord{}, 0, 0, 0, false, fmt.Errorf("%w: commodity=%d target_main_expansion=%d current_main_expansion=%d", errCurrentCeraShopProductUnavailable, purchase.Product.CommodityID, targetExpansion, targetMainExpansion)
}
targetMainExpansion = targetExpansion
lastMainCommodityID = purchase.Product.CommodityID
}
if purchase.AvatarInventoryUpgradeStage != 0 {
targetExpansion := purchase.AvatarInventoryUpgradeStage * 7
if purchase.AvatarInventoryUpgradeStage > 20 || targetExpansion != targetAvatarExpansion+7 || targetExpansion > 140 {
return dnfrepo.SettingsRecord{}, 0, 0, 0, false, fmt.Errorf("%w: commodity=%d target_avatar_expansion=%d current_avatar_expansion=%d", errCurrentCeraShopProductUnavailable, purchase.Product.CommodityID, targetExpansion, targetAvatarExpansion)
}
targetAvatarExpansion = targetExpansion
lastAvatarCommodityID = purchase.Product.CommodityID
}
if purchase.PersonalCargoUpgradeTarget != 0 {
if purchase.PersonalCargoUpgradeTarget <= targetSlots {
return dnfrepo.SettingsRecord{}, 0, 0, 0, false, fmt.Errorf("%w: commodity=%d target_slots=%d current_slots=%d", errCurrentCeraShopProductUnavailable, purchase.Product.CommodityID, purchase.PersonalCargoUpgradeTarget, targetSlots)
}
targetSlots = purchase.PersonalCargoUpgradeTarget
lastPersonalCargoCommodityID = purchase.Product.CommodityID
}
}
after := dnfrepo.CloneSettings(before)
if after.Values == nil {
after.Values = make(map[string]string)
}
mainInventoryExpansion := uint16(0)
avatarInventoryExpansion := uint16(0)
personalCargoSlotCount := uint16(0)
if targetMainExpansion != currentMainExpansion {
after.Values["main_list_param16"] = strconv.Itoa(int(targetMainExpansion))
after.Values["main_inventory_last_upgrade_commodity"] = strconv.FormatUint(uint64(lastMainCommodityID), 10)
after.Values["main_inventory_last_upgrade_from"] = strconv.Itoa(int(currentMainExpansion))
after.Values["main_inventory_last_upgrade_to"] = strconv.Itoa(int(targetMainExpansion))
mainInventoryExpansion = targetMainExpansion
}
if targetAvatarExpansion != currentAvatarExpansion {
after.Values["avatar_list_param16"] = strconv.Itoa(int(targetAvatarExpansion))
after.Values["avatar_inventory_last_upgrade_commodity"] = strconv.FormatUint(uint64(lastAvatarCommodityID), 10)
after.Values["avatar_inventory_last_upgrade_from"] = strconv.Itoa(int(currentAvatarExpansion))
after.Values["avatar_inventory_last_upgrade_to"] = strconv.Itoa(int(targetAvatarExpansion))
avatarInventoryExpansion = targetAvatarExpansion
}
if targetSlots != currentSlots {
after.Values["personal_cargo_list_param16"] = strconv.Itoa(int(targetSlots))
after.Values["personal_cargo_last_upgrade_commodity"] = strconv.FormatUint(uint64(lastPersonalCargoCommodityID), 10)
after.Values["personal_cargo_last_upgrade_from"] = strconv.Itoa(int(currentSlots))
after.Values["personal_cargo_last_upgrade_to"] = strconv.Itoa(int(targetSlots))
personalCargoSlotCount = targetSlots
}
upgradeCount := 0
for _, value := range []uint16{mainInventoryExpansion, avatarInventoryExpansion, personalCargoSlotCount} {
if value != 0 {
upgradeCount++
}
}
switch {
case upgradeCount > 1:
after.Values["source"] = "current_exe_cera_shop_container_upgrade"
case mainInventoryExpansion != 0:
after.Values["source"] = "current_exe_cera_shop_main_inventory_upgrade"
case avatarInventoryExpansion != 0:
after.Values["source"] = "current_exe_cera_shop_avatar_inventory_upgrade"
default:
after.Values["source"] = "current_exe_cera_shop_personal_cargo_upgrade"
}
after.UpdatedAt = now.UTC()
return after, mainInventoryExpansion, avatarInventoryExpansion, personalCargoSlotCount, true, nil
}
// mainSlotCount 是必填参数,不是可选变参。
//
// 它决定 limitCurrentMainInventoryDefinitionSlots 是否被调用,从而决定新物品能否
// 落进未解锁的背包扩展行。早先它被写成 `...uint16`,结果 15 个调用点静默地一个都
// 没传,物品就此落进客户端不渲染的格子。改成必填后,任何新调用点漏传都会编译失败。
func grantCurrentCeraShopProduct(
inventory *dnfrepo.InventoryRecord,
definition dungeonDropItemDefinition,
count uint32,
mainSlotCount uint16,
) ([]uint16, error) {
return grantCurrentCeraShopProductWithCarryLimit(inventory, definition, count, mainSlotCount, true)
}
// grantCurrentVendingShopProduct 是贩卖机出奖:它没有"溢出邮件"这条兜底,超额部分
// 一旦被拒就会整笔失败(硬币已经扣掉),所以在它接入邮件溢出之前沿用旧的按行语义。
func grantCurrentVendingShopProduct(
inventory *dnfrepo.InventoryRecord,
definition dungeonDropItemDefinition,
count uint32,
mainSlotCount uint16,
) ([]uint16, error) {
return grantCurrentCeraShopProductWithCarryLimit(inventory, definition, count, mainSlotCount, false)
}
// grantCurrentCeraShopProductWithCarryLimit 把奖励放进角色背包。
// enforceCarryLimit=true 时按 PVF [stack limit] 的"每个容器里该道具总量上限"口径封顶,
// 放不下就整笔回滚交给调用方走溢出(邮件);false 时保持旧的按行语义(仅贩卖机使用)。
func grantCurrentCeraShopProductWithCarryLimit(
inventory *dnfrepo.InventoryRecord,
definition dungeonDropItemDefinition,
count uint32,
mainSlotCount uint16,
enforceCarryLimit bool,
) ([]uint16, error) {
definition = limitCurrentMainInventoryDefinitionSlots(definition, mainSlotCount)
if inventory == nil || count == 0 {
return nil, errCurrentCeraShopProductUnavailable
}
// Current runtime PVF and repository-backed wallet regressions establish
// item 1 as the fixed slot-1 revive-coin wallet, while item 42
// (cash/coin_general.stk) grants one wallet unit. Neither reward may be
// spilled into an ordinary backpack slot.
if dnfrevivecoin.IsReward(int64(definition.ItemID)) {
if _, err := dnfrevivecoin.Grant(
inventory,
int64(count),
"current_cera_reward_revive_coin_wallet",
); err != nil {
return nil, fmt.Errorf("%w: revive coin reward item=%d: %v", errCurrentCeraShopProductUnavailable, definition.ItemID, err)
}
return []uint16{uint16(dnfrevivecoin.WalletSlot)}, nil
}
remaining := count
// PVF [stack limit] 是这个容器(角色背包 list 0)里该道具的总量上限:额度按容器里
// 同 id 总量算,容器里已经有该道具的行时只能并进那一行,不能开第二行。
// 放不下的部分不能在背包里留半截 —— 这里先冻结背包快照,失败时整笔回滚,
// 由调用方按原有"溢出(邮件)"分支处理,避免"背包放进去一部分 + 邮件又发一整份"。
snapshot := make(map[string]dnfrepo.ItemStack, len(inventory.Slots))
for key, stack := range inventory.Slots {
snapshot[key] = stack
}
restoreSnapshot := func() {
inventory.Slots = make(map[string]dnfrepo.ItemStack, len(snapshot))
for key, stack := range snapshot {
inventory.Slots[key] = stack
}
}
slots := make([]uint16, 0, 1)
touched := make(map[uint16]struct{})
appendTouched := func(slot uint16) {
if _, duplicate := touched[slot]; duplicate {
return
}
touched[slot] = struct{}{}
slots = append(slots, slot)
}
// Package and booster rewards can be a full PVF stack while the player
// already owns a partial stack. Fill every existing compatible stack
// first, then spill only the remainder into new slots. Passing the full
// reward directly to addCurrentDungeonPickupToInventory required the whole
// chunk to fit and therefore left the partial row untouched.
// PVF [stack limit] 是这个容器(角色背包 list 0)里该道具的总量上限:额度按容器里
// 同 id 总量算,不是一个格子。没有声明的可叠加道具按 MaxInt32 处理,行为不变。
space := dnfrepo.ContainerItemSpace(inventory.Slots, dnfrepo.MainInventoryListType, int64(definition.ItemID), definition.StackLimit)
if !enforceCarryLimit {
space = math.MaxInt32
}
fillRange := func(start, end int16) {
if definition.Kind != dungeonDropItemStackable || remaining == 0 {
return
}
for slot := start; slot <= end && remaining > 0; slot++ {
key := currentDungeonPickupMainSlotKey(slot)
stack, exists := inventory.Slots[key]
if !exists ||
stack.ItemID != int64(definition.ItemID) ||
stack.Bind ||
stack.Count <= 0 ||
!currentItemStackExpirationMatches(stack, definition.ExpirationDate) {
continue
}
available := int64(math.MaxInt32) - stack.Count
if definition.StackLimit > 0 {
if stack.Count >= definition.StackLimit {
continue
}
available = definition.StackLimit - stack.Count
}
if available > space {
available = space
}
if available <= 0 {
continue
}
added := int64(remaining)
if added > available {
added = available
}
stack.Count += added
if !definition.ExpirationDate.IsZero() {
stack, _ = applyCurrentPVFItemExpiration(stack, definition)
}
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[key] = stack
remaining -= uint32(added)
space -= added
appendTouched(uint16(slot))
}
}
if definition.Kind == dungeonDropItemStackable &&
dungeonDropStackablePrefersItemQuickSlots(definition.StackableType) {
fillRange(currentDungeonPickupQuickSlotStart, currentDungeonPickupQuickSlotEnd)
}
fillRange(definition.SlotStart, definition.SlotEnd)
for remaining > 0 {
if space <= 0 {
// 容器里该道具已经到 [stack limit]:不能再开第二行。整笔回滚,由调用方走
// 溢出分支(邮件),绝不留下"背包放进去一部分 + 邮件又发一整份"的半截状态。
restoreSnapshot()
return nil, fmt.Errorf(
"%w: container stack limit reached item=%d limit=%d",
errDungeonPickupInventoryFull,
definition.ItemID,
definition.StackLimit,
)
}
chunk := remaining
switch definition.Kind {
case dungeonDropItemEquipment:
chunk = 1
case dungeonDropItemStackable:
if definition.StackLimit > 0 && int64(chunk) > definition.StackLimit {
chunk = uint32(definition.StackLimit)
}
default:
return nil, errCurrentCeraShopProductUnavailable
}
slot, err := addCurrentDungeonPickupToInventory(inventory, definition, chunk)
if err != nil {
return nil, err
}
appendTouched(slot)
remaining -= chunk
}
return slots, nil
}
func grantCurrentCeraShopAvatar(
inventory *dnfrepo.InventoryRecord,
source dnfpvf.Source,
definition dungeonDropItemDefinition,
product currentCeraShopProduct,
attributeValue byte,
) (int16, error) {
if inventory == nil || source == nil || definition.ItemID == 0 || definition.Kind != dungeonDropItemEquipment || product.Section != "avatar" {
return 0, errCurrentCeraShopProductUnavailable
}
document, err := parseDungeonCardPVFDocument(source, definition.PVFPath)
if err != nil {
return 0, fmt.Errorf("%w: avatar item=%d PVF=%s: %v", errCurrentCeraShopProductUnavailable, definition.ItemID, definition.PVFPath, err)
}
equipmentType, found := document.Text("equipment type")
if !found {
return 0, fmt.Errorf("%w: avatar item=%d missing equipment type", errCurrentCeraShopProductUnavailable, definition.ItemID)
}
rule, ok := currentEquipmentPlacementRuleForPVFType(equipmentType)
if !ok || rule.class != currentEquipmentPlacementClassAvatar {
return 0, fmt.Errorf("%w: avatar item=%d type=%q", errCurrentCeraShopProductUnavailable, definition.ItemID, equipmentType)
}
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
const avatarSlotStart int16 = 0
const avatarSlotEnd int16 = 500
var slot int16 = -1
for candidate := avatarSlotStart; candidate <= avatarSlotEnd; candidate++ {
if _, occupied := inventory.Slots[currentCeraShopInventorySlotKey(1, candidate)]; !occupied {
slot = candidate
break
}
}
if slot < 0 {
return 0, fmt.Errorf("%w: avatar item=%d range=%d..%d", errDungeonPickupInventoryFull, definition.ItemID, avatarSlotStart, avatarSlotEnd)
}
extra := map[string]string{
"source": "cera_shop_avatar",
"item_kind": "avatar",
"pvf_path": definition.PVFPath,
"equipment_type": rule.pvfType,
// The client's row selector is the avatar AbilityNo. In the current
// item core that is the uint16 field at raw+0x0B, which ordinary
// equipment calls durability. raw+0x0A is Attr (upgrade/reseal bits)
// and must not receive the avatar selection.
"avatar_ability_no": strconv.FormatUint(uint64(attributeValue), 10),
"durability": strconv.FormatUint(uint64(attributeValue), 10),
"amount_or_count": "0", // permanent avatars have no remaining-seconds countdown.
"avatar_duration_days": strconv.FormatUint(uint64(product.AvatarDurationDays), 10),
"avatar_duration_selector_index": strconv.FormatUint(uint64(product.AvatarDurationIndex), 10),
}
// Logical count is 1 like every other equipment-kind stack; the wire
// amount still renders as 0 through the amount_or_count override above
// (permanent avatars have no remaining-seconds countdown). Consumers such
// as avatar disjoint require Count == 1 for a single equipment instance.
stack := dnfrepo.ItemStack{ItemID: int64(definition.ItemID), Count: 1, Extra: extra}
now := time.Now().UTC()
if product.AvatarDurationDays > 0 {
stack.ExpireAt = now.Add(time.Duration(product.AvatarDurationDays) * 24 * time.Hour)
expireUnix := strconv.FormatInt(stack.ExpireAt.Unix(), 10)
stack.Extra["expire_time"] = expireUnix
stack.Extra["expire_unix"] = expireUnix
} else if !definition.ExpirationDate.IsZero() {
stack, _ = applyCurrentPVFItemExpirationAt(stack, definition, now)
}
// Runtime PVF marks the National Day aura choices as [aurora avatar]
// with [emblem socket default], while the 2018 Spring Festival package
// skins are [skin avatar] items whose <2018春节礼包> flavor and [avatar
// type select] rows author two M sockets. Project only those authored
// creation-time sockets without consuming the manual opening material.
// Other skins and ordinary avatar parts remain unopened.
flavorText, _ := document.Text("flavor text")
if rule.targetSlot == 9 || currentIs2018SpringFestivalSkin(rule.pvfType, flavorText) {
text, readErr := source.ReadText(definition.PVFPath)
if readErr != nil {
return 0, fmt.Errorf("%w: socketed avatar item=%d PVF=%s: %v", errCurrentCeraShopProductUnavailable, definition.ItemID, definition.PVFPath, readErr)
}
socketTypes := currentParseAvatarDefaultSocketTypes(text)
if currentIs2018SpringFestivalSkin(rule.pvfType, flavorText) {
socketTypes = currentParseAvatarSocketTypes(text)
}
if len(socketTypes) > 0 {
var socketData [currentAvatarSocketBytes]byte
currentSetAvatarSocketTypes(&socketData, socketTypes)
currentApplyAvatarSocketDataToStack(&stack, 1, slot, socketData)
}
}
entry := currentItemListEntryFromStack(1, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[currentCeraShopInventorySlotKey(1, slot)] = stack
return slot, nil
}
func currentIs2018SpringFestivalSkin(pvfType, flavorText string) bool {
return normalizeEquipmentPlacementPVFType(pvfType) == "[skin avatar]" &&
strings.Contains(strings.TrimSpace(flavorText), "2018春节礼包")
}
func currentCeraShopInventorySlotKey(listType byte, slot int16) string {
return strconv.Itoa(int(listType)) + ":" + strconv.FormatInt(int64(slot), 10)
}
// isCurrentCeraShopCreatureItem returns true when the item is a pet/creature
// egg that belongs in the Pet list (list 7) rather than the main backpack.
// PVF rule: equipment type contains [creature].
func isCurrentCeraShopCreatureItem(definition dungeonDropItemDefinition) bool {
if definition.Kind != dungeonDropItemEquipment {
return false
}
return strings.Contains(strings.ToLower(definition.EquipmentType), "creature")
}
// isCurrentCeraShopPetArtifactItem identifies creature artifact equipment.
// Those rows share list 7 with pets but occupy the dedicated 140..188 segment.
func isCurrentCeraShopPetArtifactItem(definition dungeonDropItemDefinition) bool {
if definition.Kind != dungeonDropItemEquipment {
return false
}
t := strings.ToLower(strings.TrimSpace(definition.EquipmentType))
pathText := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(definition.PVFPath), "\\", "/"))
return strings.Contains(t, "artifact red") || strings.Contains(t, "artifact blue") || strings.Contains(t, "artifact green") ||
strings.Contains(pathText, "/artifact_red/") || strings.Contains(pathText, "/artifact_blue/") || strings.Contains(pathText, "/artifact_green/")
}
// grantCurrentCeraShopPetArtifact grants a single non-stackable artifact into
// the current client's pet-equipment segment.
func grantCurrentCeraShopPetArtifact(inventory *dnfrepo.InventoryRecord, definition dungeonDropItemDefinition) (int16, error) {
if inventory == nil || definition.ItemID == 0 || !isCurrentCeraShopPetArtifactItem(definition) {
return 0, errCurrentCeraShopProductUnavailable
}
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
for slot := currentBoosterPetArtifactStart; slot <= currentBoosterPetArtifactEnd; slot++ {
key := currentCeraShopInventorySlotKey(currentPetInventoryListType, slot)
if stack, occupied := inventory.Slots[key]; occupied && stack.ItemID > 0 && stack.Count > 0 {
continue
}
extra := map[string]string{
"source": "cera_shop_pet_artifact",
"item_kind": string(dungeonDropItemEquipment),
"pvf_path": definition.PVFPath,
"equipment_type": definition.EquipmentType,
"amount_or_count": "0",
}
if !definition.ExpirationDate.IsZero() {
extra["expire_time"] = strconv.FormatInt(definition.ExpirationDate.Unix(), 10)
extra["expire_unix"] = strconv.FormatInt(definition.ExpirationDate.Unix(), 10)
}
stack := dnfrepo.ItemStack{ItemID: int64(definition.ItemID), Count: 1, Extra: extra}
if !definition.ExpirationDate.IsZero() {
stack.ExpireAt = definition.ExpirationDate
}
entry := currentItemListEntryFromStack(currentPetInventoryListType, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[key] = stack
return slot, nil
}
return 0, fmt.Errorf("%w: pet artifact slots %d..%d full", errCurrentCeraShopProductUnavailable, currentBoosterPetArtifactStart, currentBoosterPetArtifactEnd)
}
// isCurrentCeraShopPetConsumable identifies the runtime-PVF pet-consumable
// class. In the active Script.pvf, creature food uses exactly
// [stackable type] [feed]; this is a type rule rather than an item-id/name
// exception. The pet rename card uses the same list-7 consumable segment.
func isCurrentCeraShopPetConsumable(definition dungeonDropItemDefinition) bool {
if definition.Kind != dungeonDropItemStackable {
return false
}
if normalizeDungeonDropStackableType(definition.StackableType) == "[feed]" {
return true
}
cleanPath := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(definition.PVFPath, "\\", "/")))
return strings.HasSuffix(cleanPath, "/creature/rename_card.stk")
}
// grantCurrentCeraShopPetConsumable places a PVF [feed] stack into list 7's
// current-EXE pet-consumable segment. It deliberately cannot merge with an
// identically numbered row in list 0.
func grantCurrentCeraShopPetConsumable(
inventory *dnfrepo.InventoryRecord,
definition dungeonDropItemDefinition,
count uint32,
) ([]int16, error) {
if inventory == nil || count == 0 || definition.ItemID == 0 || !isCurrentCeraShopPetConsumable(definition) {
return nil, errCurrentCeraShopProductUnavailable
}
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
remaining := count
changedSlots := make([]int16, 0, 1)
// PVF [stack limit] 是这个容器(宠物物品栏 list 7)里该道具的总量上限:额度按
// 容器里同 id 总量算,容器里已经有该道具的行时不能再开第二行。
petSpace := dnfrepo.ContainerItemSpace(
inventory.Slots,
currentPetInventoryListType,
int64(definition.ItemID),
definition.StackLimit,
)
for remaining > 0 {
chunk := remaining
if definition.StackLimit > 0 && int64(chunk) > definition.StackLimit {
chunk = uint32(definition.StackLimit)
}
if definition.Kind == dungeonDropItemStackable && int64(chunk) > petSpace {
chunk = uint32(petSpace)
}
if chunk == 0 {
return nil, fmt.Errorf(
"%w: pet consumable item=%d range=%d..%d",
errDungeonPickupInventoryFull,
definition.ItemID,
currentCeraShopPetConsumableSlotStart,
currentCeraShopPetConsumableSlotEnd,
)
}
if definition.Kind == dungeonDropItemStackable {
for slot := currentCeraShopPetConsumableSlotStart; slot <= currentCeraShopPetConsumableSlotEnd; slot++ {
key := currentCeraShopInventorySlotKey(currentPetInventoryListType, slot)
stack, found := inventory.Slots[key]
if !found || stack.ItemID != int64(definition.ItemID) || stack.Bind || stack.Count < 0 {
continue
}
if !currentItemStackExpirationMatches(stack, definition.ExpirationDate) {
continue
}
if !definition.ExpirationDate.IsZero() {
stack, _ = applyCurrentPVFItemExpiration(stack, definition)
}
limit := int64(math.MaxInt32)
if definition.StackLimit > 0 {
limit = definition.StackLimit
}
if int64(chunk) > limit || stack.Count > limit-int64(chunk) || stack.Count > math.MaxInt64-int64(chunk) {
continue
}
stack.Count += int64(chunk)
entry := currentItemListEntryFromStack(currentPetInventoryListType, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[key] = stack
changedSlots = append(changedSlots, slot)
remaining -= chunk
petSpace -= int64(chunk)
chunk = 0
break
}
if chunk == 0 {
continue
}
}
if !dnfrepo.CanOpenContainerItemRow(
inventory.Slots,
currentPetInventoryListType,
int64(definition.ItemID),
definition.StackLimit,
) {
return nil, fmt.Errorf(
"%w: pet consumable item=%d range=%d..%d",
errDungeonPickupInventoryFull,
definition.ItemID,
currentCeraShopPetConsumableSlotStart,
currentCeraShopPetConsumableSlotEnd,
)
}
var slot int16 = -1
for candidate := currentCeraShopPetConsumableSlotStart; candidate <= currentCeraShopPetConsumableSlotEnd; candidate++ {
if _, occupied := inventory.Slots[currentCeraShopInventorySlotKey(currentPetInventoryListType, candidate)]; !occupied {
slot = candidate
break
}
}
if slot < 0 {
return nil, fmt.Errorf(
"%w: pet consumable item=%d range=%d..%d",
errDungeonPickupInventoryFull,
definition.ItemID,
currentCeraShopPetConsumableSlotStart,
currentCeraShopPetConsumableSlotEnd,
)
}
extra := map[string]string{
"source": "cera_shop_pet_consumable",
"item_kind": string(definition.Kind),
"pvf_path": definition.PVFPath,
"stackable_type": definition.StackableType,
}
if definition.StackLimit > 0 {
extra["stack_limit"] = strconv.FormatInt(definition.StackLimit, 10)
}
stack := dnfrepo.ItemStack{ItemID: int64(definition.ItemID), Count: int64(chunk), Extra: extra}
if !definition.ExpirationDate.IsZero() {
stack, _ = applyCurrentPVFItemExpiration(stack, definition)
}
entry := currentItemListEntryFromStack(currentPetInventoryListType, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[currentCeraShopInventorySlotKey(currentPetInventoryListType, slot)] = stack
changedSlots = append(changedSlots, slot)
remaining -= chunk
petSpace -= int64(chunk)
}
return changedSlots, nil
}
// grantCurrentCeraShopPet places a creature/pet egg into the Pet list (list 7)
// at the first available slot in the pet-body range (0-139).
func grantCurrentCeraShopPet(inventory *dnfrepo.InventoryRecord, definition dungeonDropItemDefinition) (int16, error) {
if inventory == nil || definition.ItemID == 0 {
return 0, errCurrentCeraShopProductUnavailable
}
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
const petBodySlotStart int16 = 0
const petBodySlotEnd int16 = 139
for slot := petBodySlotStart; slot <= petBodySlotEnd; slot++ {
key := fmt.Sprintf("%d:%d", currentPetInventoryListType, slot)
if _, occupied := inventory.Slots[key]; occupied {
continue
}
stack := dnfrepo.ItemStack{
ItemID: int64(definition.ItemID),
Count: 1,
Extra: map[string]string{
"source": "cera_shop_creature",
"item_kind": string(dungeonDropItemEquipment),
"pvf_path": definition.PVFPath,
"equipment_type": definition.EquipmentType,
},
}
if !definition.ExpirationDate.IsZero() {
stack.ExpireAt = definition.ExpirationDate
stack.Extra["expire_time"] = strconv.FormatInt(definition.ExpirationDate.Unix(), 10)
}
inventory.Slots[key] = stack
return slot, nil
}
return 0, fmt.Errorf("%w: pet body slots 0-139 full", errCurrentCeraShopProductUnavailable)
}