DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,428 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user