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

429 lines
15 KiB
Go

package inventory
import (
"context"
"fmt"
"math"
"math/rand"
"sort"
"strconv"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
monsterCardUpgradeErrorInventoryFull byte = 0x04
monsterCardUpgradeErrorGold byte = 0x0a
monsterCardUpgradeErrorInvalid byte = 0x13
monsterCardUpgradeProbabilityBase = 100000
)
// UpgradeMonsterCard applies one current NoPack op603 attempt inside the
// character wallet+inventory transaction. The target and material identities
// come only from the repository; the request contributes locations and count.
func (o *Owner) UpgradeMonsterCard(ctx context.Context, cmd Command, resolver alignedcmd.MonsterCardUpgradeResolver, policy alignedcmd.MonsterCardUpgradePolicy) (MonsterCardUpgradeResult, error) {
base := MonsterCardUpgradeResult{
ErrorCode: monsterCardUpgradeErrorInvalid,
TargetSlot: cmd.TargetSlotIndex,
MaterialSlot: cmd.MaterialSlotIndex,
ResultSlot: cmd.TargetSlotIndex,
}
if o == nil || o.repo == nil || o.characters == nil {
return MonsterCardUpgradeResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return MonsterCardUpgradeResult{}, ErrCharacterRequired
}
if resolver == nil {
return MonsterCardUpgradeResult{}, ErrMonsterCardUpgradeResolverRequired
}
if cmd.SourceListType != listTypeMain || cmd.Count != 1 || cmd.TargetSlotIndex < 0 || cmd.MaterialSlotIndex < 0 {
return base, nil
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
base.CharacterID = characterID
if !o.inItemTransaction {
var result MonsterCardUpgradeResult
err := o.withinCharacterAssetTransaction(ctx, characterID, func(txOwner *Owner) error {
var applyErr error
result, applyErr = txOwner.UpgradeMonsterCard(ctx, cmd, resolver, policy)
return applyErr
})
if err != nil {
return MonsterCardUpgradeResult{}, err
}
if result.CharacterID == "" {
result = base
}
return result, nil
}
record, found, err := o.repo.Load(ctx, characterID)
if err != nil {
return MonsterCardUpgradeResult{}, err
}
if !found {
return MonsterCardUpgradeResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
if record.Slots == nil {
return base, nil
}
targetKey := slotKey(listTypeMain, cmd.TargetSlotIndex)
materialKey := slotKey(listTypeMain, cmd.MaterialSlotIndex)
target, targetFound := record.Slots[targetKey]
material, materialFound := record.Slots[materialKey]
if !targetFound || !materialFound || target.Count <= 0 || material.Count <= 0 ||
(cmd.TargetSlotIndex == cmd.MaterialSlotIndex && target.Count < 2) {
return base, nil
}
targetPolicy, err := resolver(target.ItemID)
if err != nil {
return MonsterCardUpgradeResult{}, fmt.Errorf("resolve target monster card %d: %w", target.ItemID, err)
}
materialPolicy, err := resolver(material.ItemID)
if err != nil {
return MonsterCardUpgradeResult{}, fmt.Errorf("resolve material monster card %d: %w", material.ItemID, err)
}
if targetPolicy.ItemID != target.ItemID || targetPolicy.PVFPath == "" ||
materialPolicy.ItemID != material.ItemID || materialPolicy.PVFPath == "" ||
targetPolicy.Rarity < 0 || targetPolicy.Rarity > 6 ||
materialPolicy.Rarity < 0 || materialPolicy.Rarity > 6 ||
targetPolicy.SlotStart < 0 || targetPolicy.SlotEnd < targetPolicy.SlotStart {
return base, nil
}
currentUpgrade := monsterCardUpgradeCountOf(target)
if targetPolicy.MaximumUpgrade == 0 || currentUpgrade >= targetPolicy.MaximumUpgrade {
return base, nil
}
character, found, err := o.characters.Load(ctx, characterID)
if err != nil {
return MonsterCardUpgradeResult{}, err
}
if !found {
return MonsterCardUpgradeResult{}, fmt.Errorf("%w: character=%s", ErrWalletTxnRequired, characterID)
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
return MonsterCardUpgradeResult{}, fmt.Errorf("%w: character=%s has no stats", ErrWalletTxnRequired, characterID)
}
gold := character.Stats["gold"]
if targetPolicy.GoldCost < 0 || gold < targetPolicy.GoldCost {
base.ErrorCode = monsterCardUpgradeErrorGold
return base, nil
}
// Prove the successful branch can be placed before rolling. Otherwise a
// full inventory could be used to reroll indefinitely without payment.
preflight := cloneItemMap(record.Slots)
if _, _, applyErr := applyMonsterCardUpgradeAttempt(preflight, cmd.TargetSlotIndex, cmd.MaterialSlotIndex, targetPolicy, true); applyErr != nil {
base.ErrorCode = monsterCardUpgradeErrorInventoryFull
return base, nil
}
chance := monsterCardUpgradeChanceFor(policy, targetPolicy, materialPolicy, target.ItemID == material.ItemID)
random := o.cardUpgradeRandom
if random == nil {
random = rand.Intn
}
upgradeSucceeded := chance >= monsterCardUpgradeProbabilityBase ||
(chance > 0 && random(monsterCardUpgradeProbabilityBase) < chance)
resultSlot, changedSlots, err := applyMonsterCardUpgradeAttempt(
record.Slots,
cmd.TargetSlotIndex,
cmd.MaterialSlotIndex,
targetPolicy,
upgradeSucceeded,
)
if err != nil {
return MonsterCardUpgradeResult{}, err
}
character.Stats["gold"] = gold - targetPolicy.GoldCost
// The same [monstercard exp] row that sets the odds also says what one
// attempt feeds back into the enchanter job, win or lose.
experienceGain := monsterCardUpgradeExperienceGain(policy, random)
if experienceGain > 0 {
character.Stats[expertJobExperienceStat] += int64(experienceGain)
}
now := time.Now()
character.UpdatedAt = now
record.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, o.characters, character, dnfrepo.CharacterFieldStats); err != nil {
return MonsterCardUpgradeResult{}, err
}
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, dnfrepo.InventoryFieldSlots); err != nil {
return MonsterCardUpgradeResult{}, err
}
return MonsterCardUpgradeResult{
CharacterID: characterID,
Success: true,
TargetSlot: cmd.TargetSlotIndex,
MaterialSlot: cmd.MaterialSlotIndex,
ResultSlot: resultSlot,
TargetItemID: target.ItemID,
UpgradeSucceeded: upgradeSucceeded,
UpgradeCount: currentUpgrade + boolByte(upgradeSucceeded),
Chance: chance,
GoldCost: targetPolicy.GoldCost,
UpdatedGold: character.Stats["gold"],
ChangedSlots: changedSlots,
Changed: true,
}, nil
}
// expertJobExperienceStat is the character column the expert-job level is
// derived from; dnfbridge reads the same key when it projects the job panel.
const expertJobExperienceStat = "expert_job_exp"
// monsterCardUpgradeChanceFor prefers the enchanter's own [monstercard exp]
// row. That table is the game's authority: it is indexed by the character's
// enchanter level and then by the target card's rarity, so a level-1 enchanter
// reads 100/70/60/50/0/0 while level 11 reads 100 across the board. Until it
// was wired in, every same-card upgrade succeeded outright regardless of job
// level, which is why a fresh enchanter never failed.
//
// The old rarity-gap estimate stays as the fallback for when the enchanter
// config cannot be loaded, so a PVF problem degrades instead of failing closed
// on a player mid-upgrade.
func monsterCardUpgradeChanceFor(policy alignedcmd.MonsterCardUpgradePolicy, target, material alignedcmd.MonsterCardUpgradeResolution, sameItem bool) int {
if !policy.Known {
return monsterCardUpgradeChance(target, material, sameItem)
}
rarity := target.Rarity
if rarity < 0 {
rarity = 0
}
if rarity >= len(policy.SuccessRates) {
rarity = len(policy.SuccessRates) - 1
}
rate := policy.SuccessRates[rarity]
if rate <= 0 {
return 0
}
if rate >= 100 {
return monsterCardUpgradeProbabilityBase
}
return rate * (monsterCardUpgradeProbabilityBase / 100)
}
// monsterCardUpgradeExperienceGain rolls the row's [min,max] inclusive.
func monsterCardUpgradeExperienceGain(policy alignedcmd.MonsterCardUpgradePolicy, random func(int) int) int {
if !policy.Known || policy.MaximumExperienceGain <= 0 {
return 0
}
low, high := policy.MinimumExperienceGain, policy.MaximumExperienceGain
if low < 0 {
low = 0
}
if high < low {
high = low
}
if high == low {
return low
}
return low + random(high-low+1)
}
func monsterCardUpgradeChance(target, material alignedcmd.MonsterCardUpgradeResolution, sameItem bool) int {
if sameItem || material.Rarity > target.Rarity {
return monsterCardUpgradeProbabilityBase
}
if target.Rarity < 0 || target.Rarity > 6 || material.Rarity < 0 || material.Rarity > 6 || target.CalculateConst <= 0 {
return 0
}
// The retained table owns four probability tiers. Current 90CN cards use
// rarity 4..6 as higher presentation grades, so probability calculation
// saturates at tier 3 while identity/ordering above remains exact.
targetTier := min(target.Rarity, 3)
materialTier := min(material.Rarity, 3)
chance := int64((80 - targetTier*10) * 1000)
for gap := targetTier - materialTier; gap > 0; gap-- {
chance /= 2
}
chance = chance * int64(target.CalculateConst) / 10
if chance > monsterCardUpgradeProbabilityBase {
chance = monsterCardUpgradeProbabilityBase
}
if chance < 0 {
return 0
}
return int(chance)
}
func applyMonsterCardUpgradeAttempt(items map[string]dnfrepo.ItemStack, targetSlot, materialSlot int16, policy alignedcmd.MonsterCardUpgradeResolution, success bool) (int16, []int16, error) {
targetKey := slotKey(listTypeMain, targetSlot)
materialKey := slotKey(listTypeMain, materialSlot)
target, targetFound := items[targetKey]
material, materialFound := items[materialKey]
if !targetFound || !materialFound || target.Count <= 0 || material.Count <= 0 {
return targetSlot, nil, ErrSlotNotFound
}
targetSnapshot := cloneStack(target)
changed := map[int16]struct{}{materialSlot: {}}
if material.Count == 1 {
delete(items, materialKey)
} else {
material = cloneStack(material)
material.Count--
updateStackRawAmount(&material)
items[materialKey] = material
}
if !success {
return targetSlot, sortedMonsterCardSlots(changed), nil
}
upgraded := cloneStack(targetSnapshot)
upgraded.Count = 1
setMonsterCardUpgradeCount(&upgraded, monsterCardUpgradeCountOf(targetSnapshot)+1)
updateStackRawAmount(&upgraded)
// Spend the target card. When the target and the material share one stack
// -- which is the normal case, since a pile of identical cards upgrades
// against itself -- the material step above may already have emptied the
// slot.
if remaining, remains := items[targetKey]; remains {
if remaining.Count <= 1 {
delete(items, targetKey)
} else {
remaining = cloneStack(remaining)
remaining.Count--
updateStackRawAmount(&remaining)
items[targetKey] = remaining
}
}
changed[targetSlot] = struct{}{}
// Merge before placing. This used to drop the result straight back into the
// target slot whenever that slot had gone empty, which is exactly what the
// last upgrade of a pile does: the new card landed in its own cell beside
// the stack it belonged to, and only a full bag refresh merged them.
// Merging cannot pick the target's own leftovers up by mistake, because
// those still carry the old upgrade count.
resultSlot, err := placeUpgradedMonsterCard(items, upgraded, policy, targetSlot)
if err != nil {
return targetSlot, nil, err
}
changed[resultSlot] = struct{}{}
return resultSlot, sortedMonsterCardSlots(changed), nil
}
// placeUpgradedMonsterCard merges the result into an existing identical stack
// when there is one, and otherwise takes a free cell. preferred is the slot the
// upgrade came from: the merge pass always skips it, and it is then used first
// among the empty cells so a card with no stack to join stays where the player
// was working instead of jumping to the front of the tab.
//
// Skipping the source slot in the merge pass is load-bearing, not defensive.
// It was briefly removed on the theory that the upgrade-count comparison below
// already separated the result from its own leftovers; it does not, and the
// whole remaining pile inherited the upgraded card's state -- eleven cards all
// reading +1 after a single successful upgrade.
func placeUpgradedMonsterCard(items map[string]dnfrepo.ItemStack, upgraded dnfrepo.ItemStack, policy alignedcmd.MonsterCardUpgradeResolution, preferred int16) (int16, error) {
upgradeCount := monsterCardUpgradeCountOf(upgraded)
limit := policy.StackLimit
if limit == 0 {
limit = math.MaxInt32
}
// 升级结果行同样是"入包":容器总量不得越过 PVF [stack limit]。这里升级本身
// 消耗一张同 id 卡(调用方已经扣减),所以正常情况下容器总量不变,这条只拦
// 历史超限数据。行的身份还包括卡片升级级数(不同级数必须是不同的行),因此
// 这里不做"同 id 只能一行"的合并,见报告说明。
if dnfrepo.ContainerItemSpace(items, listTypeMain, upgraded.ItemID, policy.StackLimit) <= 0 {
return -1, fmt.Errorf(
"monster card upgrade would exceed the container stack limit: item=%d",
upgraded.ItemID,
)
}
for slot := policy.SlotStart; slot <= policy.SlotEnd; slot++ {
if slot == preferred {
continue
}
key := slotKey(listTypeMain, slot)
stack, found := items[key]
if !found || stack.ItemID != upgraded.ItemID || stack.Count <= 0 || stack.Count >= limit ||
stack.Bind != upgraded.Bind || !stack.ExpireAt.Equal(upgraded.ExpireAt) ||
monsterCardUpgradeCountOf(stack) != upgradeCount {
continue
}
stack = cloneStack(stack)
stack.Count++
updateStackRawAmount(&stack)
items[key] = stack
return slot, nil
}
if preferred >= policy.SlotStart && preferred <= policy.SlotEnd {
key := slotKey(listTypeMain, preferred)
if _, occupied := items[key]; !occupied {
items[key] = upgraded
return preferred, nil
}
}
for slot := policy.SlotStart; slot <= policy.SlotEnd; slot++ {
key := slotKey(listTypeMain, slot)
if _, occupied := items[key]; occupied {
continue
}
items[key] = upgraded
return slot, nil
}
return -1, fmt.Errorf("monster card upgrade result has no free slot in %d..%d", policy.SlotStart, policy.SlotEnd)
}
func monsterCardUpgradeCountOf(stack dnfrepo.ItemStack) byte {
value := firstExtraInt(stack.Extra, -1, "enchant_upgrade_count", "byte_12", "value_12")
if value >= 0 && value <= int64(^byte(0)) {
return byte(value)
}
if len(stack.RawEntry) > 0x12 {
return stack.RawEntry[0x12]
}
return 0
}
func setMonsterCardUpgradeCount(stack *dnfrepo.ItemStack, value byte) {
if stack == nil {
return
}
if stack.Extra == nil {
stack.Extra = make(map[string]string, 2)
} else {
stack.Extra = cloneStringMap(stack.Extra)
}
stack.Extra["enchant_upgrade_count"] = strconv.Itoa(int(value))
stack.Extra["byte_12"] = strconv.Itoa(int(value))
if len(stack.RawEntry) == currentItemListEntrySize {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
stack.RawEntry[0x12] = value
}
}
func cloneStringMap(values map[string]string) map[string]string {
out := make(map[string]string, len(values)+2)
for key, value := range values {
out[key] = value
}
return out
}
func sortedMonsterCardSlots(values map[int16]struct{}) []int16 {
out := make([]int16, 0, len(values))
for slot := range values {
out = append(out, slot)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func boolByte(value bool) byte {
if value {
return 1
}
return 0
}