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,417 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/statbuff"
|
||||
)
|
||||
|
||||
// 城镇自施放增益药剂(史诗Buff药剂一族)的落地。
|
||||
//
|
||||
// 现场 2026-10-03(服主报,角色 测试x = char 11):史诗Buff药剂 490002458 连点 15 次,
|
||||
// op44 每一次都被 useStackableWithResolvers 的 "[waste] provenance" 门静默拒绝
|
||||
// (error 17),所以玩家看到的是"没反应"。
|
||||
//
|
||||
// 这一族(`[stackable type] [expert town potion]` + 无 `[action type]` +
|
||||
// `[stat change duration]`)与普通 [waste] 消耗品的区别在于:**它的效果是服务端量**。
|
||||
// 客户端自己会画 buff 图标,但"深渊派对史诗掉落率"只有服务端说了算,所以服务端要
|
||||
// 自己落一个限时窗口(statbuff),出史诗前查它、把它翻倍。
|
||||
//
|
||||
// 为什么单独开一个 owner 而不是挂在通用 [waste] 路径上:
|
||||
// - 通用路径的写边界是 withinInventoryTransaction(只有背包 + 装备),
|
||||
// 而这里必须和**角色记录**同笔(背包减 1 与增益窗口同时生效或同时不生效)。
|
||||
// 这与 useExperienceConsumable 的选择一致,同样用 WithinRentalAssetsScoped
|
||||
// (账号 + 角色 + 背包 + 装备同一条事务)。
|
||||
// - 不放松原来那道 [waste] 门:普通消耗品通道的判据保持原样,本族走自己的分支。
|
||||
|
||||
var ErrStatChangePotionContract = errors.New("stat-change potion contract is invalid")
|
||||
|
||||
// useStatChangePotion 消耗一张城镇增益药剂,并在同一笔事务里落下增益窗口。
|
||||
func (o *Owner) useStatChangePotion(
|
||||
ctx context.Context,
|
||||
cmd Command,
|
||||
resolution alignedcmd.StackableUseResolution,
|
||||
) (UseStackableResult, error) {
|
||||
potion := resolution.StatChangePotion
|
||||
if o == nil || o.rentalAssets == nil || potion == nil ||
|
||||
cmd.SelectedCharacterID == 0 || cmd.SourceListType != listTypeMain ||
|
||||
cmd.SourceSlotIndex < 0 || cmd.ItemTemplateID <= 0 ||
|
||||
resolution.ItemID != int64(cmd.ItemTemplateID) ||
|
||||
strings.TrimSpace(resolution.PVFPath) == "" ||
|
||||
potion.EffectDurationSeconds <= 0 {
|
||||
return UseStackableResult{}, ErrStatChangePotionContract
|
||||
}
|
||||
accountID := strings.TrimSpace(cmd.AccountID)
|
||||
if accountID == "" {
|
||||
return UseStackableResult{}, ErrStatChangePotionContract
|
||||
}
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
now := time.Now().UTC()
|
||||
if o.now != nil {
|
||||
now = o.now().UTC()
|
||||
}
|
||||
duration := time.Duration(potion.EffectDurationSeconds) * time.Second
|
||||
|
||||
var result UseStackableResult
|
||||
err := dnfrepo.WithinRentalAssetsScoped(ctx, o.rentalAssets, accountID, characterID, func(
|
||||
_ dnfrepo.AccountRepository,
|
||||
characters dnfrepo.CharacterRepository,
|
||||
inventoryRepo dnfrepo.InventoryRepository,
|
||||
_ dnfrepo.EquipmentRepository,
|
||||
) error {
|
||||
character, found, err := characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != characterID {
|
||||
return ErrCharacterRequired
|
||||
}
|
||||
if owner := strings.TrimSpace(character.AccountID); owner != "" && owner != accountID {
|
||||
return ErrStatChangePotionContract
|
||||
}
|
||||
record, found, err := inventoryRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrInventoryNotFound
|
||||
}
|
||||
record = dnfrepo.CloneInventory(record)
|
||||
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
|
||||
stack, found := record.Slots[key]
|
||||
if !found || stack.Count <= 0 || stack.ItemID != resolution.ItemID {
|
||||
return fmt.Errorf("%w: list=%d slot=%d item=%d", ErrItemMismatch, cmd.SourceListType, cmd.SourceSlotIndex, resolution.ItemID)
|
||||
}
|
||||
// 持久行自己的 PVF 路径必须与运行期解析一致:旧行可能带着别的道具的路径。
|
||||
if durablePath := strings.TrimSpace(stack.Extra["pvf_path"]); durablePath != "" &&
|
||||
!strings.EqualFold(durablePath, strings.TrimSpace(resolution.PVFPath)) {
|
||||
return fmt.Errorf("%w: item=%d durable_path=%q resolved=%q",
|
||||
ErrStatChangePotionContract, resolution.ItemID, durablePath, resolution.PVFPath)
|
||||
}
|
||||
|
||||
// 增益窗口先算出来:ActivateEpicDropRate 会改 character,必须在保存之前调用。
|
||||
//
|
||||
// 数值来源有两条路(见 alignedcmd.StatChangePotionResolution):
|
||||
// 1. 权威:buff 表掷点 —— etc/variousbuffstackablelist.etc 的 (buff id, 权重)
|
||||
// 配上 etc/variousbufflist.etc 的 `epic_rarity_item_drop_rate` param。
|
||||
// 赛丽亚的幸运掉落药剂就是三档 60/30/10,只能服务端掷。
|
||||
// 2. 回退:表里没有条目(490000413 这类 2016 老活动券)⇒ 用说明文字的 200。
|
||||
// 换档/换药剂时要把旧 buff 撤掉(参考服务端同一族只用一条 buff 槽)。
|
||||
previousBuffID := int64(0)
|
||||
if previous, previouslyActive := statbuff.ActiveEpicDropRate(character, now); previouslyActive {
|
||||
previousBuffID = previous.BuffID
|
||||
}
|
||||
appliedPercent, appliedBuffID := currentStatChangePotionRoll(potion, o.statChangeRandom)
|
||||
expireAt := int64(0)
|
||||
if appliedPercent > 0 {
|
||||
effect, activateErr := statbuff.ActivateEpicDropRate(
|
||||
&character, resolution.ItemID, appliedBuffID, appliedPercent, duration, now)
|
||||
if activateErr != nil {
|
||||
return fmt.Errorf("%w: item=%d: %v", ErrStatChangePotionContract, resolution.ItemID, activateErr)
|
||||
}
|
||||
appliedPercent = effect.MultiplierPercent
|
||||
appliedBuffID = effect.BuffID
|
||||
expireAt = effect.ExpireAt.Unix()
|
||||
}
|
||||
|
||||
// 强化/增幅成功率窗口:同一族药剂、同一笔事务、同一套投影,只是作用对象不同。
|
||||
// 服主 2026-10-03:"看看增幅的秘密 强化秘药这类增加强化增幅成功率的道具 也一并修复了"。
|
||||
// 加成来源:buff 表掷点(赛丽亚的幸运强化药剂 1097/1098/1099 = 10/20/30),
|
||||
// 或者 PVF 没有数字时的固定常量(强化秘药/强化的奥秘/永恒的增幅的秘密)。
|
||||
upgradeKind := ""
|
||||
upgradePercent := int64(0)
|
||||
upgradeBuffID := int64(0)
|
||||
upgradeExpireAt := int64(0)
|
||||
previousUpgradeBuffID := int64(0)
|
||||
if statKind, kindOK := statbuff.KindForUpgradeMode(potion.UpgradeKind); kindOK {
|
||||
if previous, previouslyActive := statbuff.ActiveRate(character, statKind, now); previouslyActive {
|
||||
previousUpgradeBuffID = previous.BuffID
|
||||
}
|
||||
bonus, buffID := currentUpgradeRatePotionRoll(potion, o.statChangeRandom)
|
||||
if bonus > 0 {
|
||||
window, activateErr := statbuff.ActivateRate(
|
||||
&character, statKind, resolution.ItemID, buffID, bonus, duration, now)
|
||||
if activateErr != nil {
|
||||
return fmt.Errorf("%w: item=%d upgrade: %v", ErrStatChangePotionContract, resolution.ItemID, activateErr)
|
||||
}
|
||||
upgradeKind = potion.UpgradeKind
|
||||
upgradePercent = window.Percent
|
||||
upgradeBuffID = window.BuffID
|
||||
upgradeExpireAt = window.ExpireAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
// 地下城通关经验窗口(赛丽亚的幸运经验药剂 10008222,三档 50/100/200)。
|
||||
// 与掉落率同构:经验是服务端发的,所以必须服务端记窗口。
|
||||
clearExpPercent := int64(0)
|
||||
clearExpBuffID := int64(0)
|
||||
clearExpExpireAt := int64(0)
|
||||
if len(potion.DungeonClearExpRolls) > 0 {
|
||||
bonus, buffID := currentWeightedStatChangeRoll(potion.DungeonClearExpRolls, o.statChangeRandom)
|
||||
if bonus > 0 {
|
||||
window, activateErr := statbuff.ActivateRate(&character, statbuff.KindDungeonClearExp,
|
||||
resolution.ItemID, buffID, bonus, duration, now)
|
||||
if activateErr != nil {
|
||||
return fmt.Errorf("%w: item=%d clear-exp: %v", ErrStatChangePotionContract, resolution.ItemID, activateErr)
|
||||
}
|
||||
clearExpPercent, clearExpBuffID, clearExpExpireAt = window.Percent, window.BuffID, window.ExpireAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端自施放的那一族(城镇移速 / 进图属性…):服务端只掷点 + 记住窗口,
|
||||
// 效果由客户端按同一份 buff 表自己施加,窗口按 family 分开存。
|
||||
clientBuffFamily := ""
|
||||
clientBuffID := int64(0)
|
||||
clientBuffPercent := int64(0)
|
||||
clientBuffExpireAt := int64(0)
|
||||
if strings.TrimSpace(potion.ClientBuffFamily) != "" && len(potion.ClientBuffRolls) > 0 {
|
||||
bonus, buffID := currentWeightedStatChangeRoll(potion.ClientBuffRolls, o.statChangeRandom)
|
||||
if buffID > 0 {
|
||||
window, activateErr := statbuff.ActivateClientBuff(&character, potion.ClientBuffFamily,
|
||||
resolution.ItemID, buffID, bonus, duration, now)
|
||||
if activateErr != nil {
|
||||
return fmt.Errorf("%w: item=%d client buff %q: %v",
|
||||
ErrStatChangePotionContract, resolution.ItemID, potion.ClientBuffFamily, activateErr)
|
||||
}
|
||||
clientBuffFamily = window.Family
|
||||
clientBuffID, clientBuffPercent = window.BuffID, window.Percent
|
||||
clientBuffExpireAt = window.ExpireAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
remaining := stack.Count
|
||||
if !cmd.ConsumableFree {
|
||||
remaining = stack.Count - 1
|
||||
if remaining == 0 {
|
||||
delete(record.Slots, key)
|
||||
} else {
|
||||
stack = cloneStack(stack)
|
||||
stack.Count = remaining
|
||||
updateStackRawAmount(&stack)
|
||||
record.Slots[key] = stack
|
||||
}
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, record, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
if appliedPercent > 0 || upgradePercent > 0 || clearExpPercent > 0 || clientBuffID > 0 {
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result = UseStackableResult{
|
||||
CharacterID: characterID,
|
||||
AccountID: accountID,
|
||||
ListType: cmd.SourceListType,
|
||||
SlotIndex: cmd.SourceSlotIndex,
|
||||
InstanceValue: cmd.SourceInstanceValue,
|
||||
ItemID: stack.ItemID,
|
||||
RemainingCount: remaining,
|
||||
Changed: true,
|
||||
PVFPath: strings.TrimSpace(resolution.PVFPath),
|
||||
StackableType: strings.TrimSpace(strings.ReplaceAll(resolution.StackableType, "`", "")),
|
||||
StatChangePotionApplied: true,
|
||||
StatChangePotionSeconds: potion.EffectDurationSeconds,
|
||||
EpicDropRatePercent: appliedPercent,
|
||||
EpicDropRateBuffID: appliedBuffID,
|
||||
PreviousEpicDropRateBuffID: previousBuffID,
|
||||
EpicDropRateExpireAt: expireAt,
|
||||
UpgradeRateKind: upgradeKind,
|
||||
UpgradeRatePercent: upgradePercent,
|
||||
UpgradeRateBuffID: upgradeBuffID,
|
||||
PreviousUpgradeRateBuffID: previousUpgradeBuffID,
|
||||
UpgradeRateExpireAt: upgradeExpireAt,
|
||||
DungeonClearExpPercent: clearExpPercent,
|
||||
DungeonClearExpBuffID: clearExpBuffID,
|
||||
DungeonClearExpExpireAt: clearExpExpireAt,
|
||||
ClientBuffFamily: clientBuffFamily,
|
||||
ClientBuffID: clientBuffID,
|
||||
ClientBuffPercent: clientBuffPercent,
|
||||
ClientBuffExpireAt: clientBuffExpireAt,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return UseStackableResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// currentStatChangePotionRoll 按权重从 buff 表里掷一个结果。
|
||||
//
|
||||
// 返回 (倍率百分比, buff id):倍率 = 100 + buff 的 `epic_rarity_item_drop_rate` param
|
||||
// (1136 param=100 ⇒ 200 = 2 倍;1239 param=10 ⇒ 110;1240 param=5 ⇒ 105)。
|
||||
// 表里没有条目时回退到 resolution.EpicDropRatePercent(说明文字口径),buff id 记 0。
|
||||
//
|
||||
// random 传 nil(或表为空/权重非法)时按"第一条有效的"取:宁可确定性,也不要为了掷点
|
||||
// 让一条已经证明可用的路径随机失败。
|
||||
func currentStatChangePotionRoll(
|
||||
potion *alignedcmd.StatChangePotionResolution,
|
||||
random func(int) int,
|
||||
) (int64, int64) {
|
||||
if potion == nil {
|
||||
return 0, 0
|
||||
}
|
||||
weighted := make([]alignedcmd.StatChangePotionBuffRoll, 0, len(potion.BuffRolls))
|
||||
totalWeight := 0
|
||||
for _, roll := range potion.BuffRolls {
|
||||
if roll.Weight <= 0 || roll.BonusPercent <= 0 {
|
||||
continue
|
||||
}
|
||||
weighted = append(weighted, roll)
|
||||
totalWeight += int(roll.Weight)
|
||||
}
|
||||
if len(weighted) > 0 {
|
||||
pick := 0
|
||||
if random != nil && totalWeight > 0 {
|
||||
pick = random(totalWeight)
|
||||
if pick < 0 || pick >= totalWeight {
|
||||
pick = 0
|
||||
}
|
||||
}
|
||||
accumulated := 0
|
||||
for _, roll := range weighted {
|
||||
accumulated += int(roll.Weight)
|
||||
if pick < accumulated {
|
||||
return 100 + roll.BonusPercent, roll.BuffID
|
||||
}
|
||||
}
|
||||
last := weighted[len(weighted)-1]
|
||||
return 100 + last.BonusPercent, last.BuffID
|
||||
}
|
||||
if potion.EpicDropRatePercent > 0 {
|
||||
return potion.EpicDropRatePercent, 0
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// applyCurrentUpgradeSuccessWindow 把"强化/增幅成功率药剂"的窗口作用到成功权重上。
|
||||
//
|
||||
// 服主 2026-10-03 问:"具体逻辑应该是 在服务端决定是否成功前增加成功率?成功率应该提升
|
||||
// 多少?" —— 对,就是在**掷点之前**放大成功权重,位置与既有"高级炉岩炭"完全一致
|
||||
// (applyUpgradeHighUpgradeTerms,handler.go)。
|
||||
//
|
||||
// 算法:weight × (100 + N) / 100,N 取 PVF 的 param(赛丽亚三档 10/20/30)或常量 10。
|
||||
// 为什么是**乘法**而不是"直接加 N 个百分点":本仓库已有先例就是乘法 —— 高级炉岩炭的
|
||||
// PVF 原文是"使用高级炉岩炭时的强化成功率为普通炉岩炭的110%",实现即
|
||||
// `weight × 110 / 100`。乘法也是两者中较保守的一侧(+30 在 40% 的成功率上得到 52%,
|
||||
// 而不是 70%)。若服主要改成"直接加 N 个百分点",把这里换成
|
||||
// `weight + weight*0 + N*currentUpgradeSuccessBase/100` 即可,是一行的事。
|
||||
//
|
||||
// 权重是万分率(currentUpgradeSuccessBase = 100000),上限封在必成(= 100000)。
|
||||
// 窗口过期/模式不匹配/字段缺失一律原样返回 —— 绝不 fail-open。
|
||||
func applyCurrentUpgradeSuccessWindow(
|
||||
weight int,
|
||||
character dnfrepo.CharacterRecord,
|
||||
mode string,
|
||||
now time.Time,
|
||||
) int {
|
||||
if weight <= 0 {
|
||||
return weight
|
||||
}
|
||||
kind, ok := statbuff.KindForUpgradeMode(mode)
|
||||
if !ok {
|
||||
return weight
|
||||
}
|
||||
window, active := statbuff.ActiveRate(character, kind, now)
|
||||
if !active || window.Percent <= 0 {
|
||||
return weight
|
||||
}
|
||||
scaled := weight * int(100+window.Percent) / 100
|
||||
if scaled > currentUpgradeSuccessBase {
|
||||
scaled = currentUpgradeSuccessBase
|
||||
}
|
||||
return scaled
|
||||
}
|
||||
|
||||
// currentWeightedStatChangeRoll 按权重从任意一组"服务端效果候选"里掷一个结果。
|
||||
//
|
||||
// 强化成功率、通关经验、客户端 family 三族共用(形状完全一样)。返回 (加成值, buff id):
|
||||
// 表里有候选就掷点;random 为 nil(或权重全非法)时按第一条有效的取 —— 宁可确定性,
|
||||
// 也不要为了掷点让一条已经证明可用的路径随机失败。
|
||||
func currentWeightedStatChangeRoll(
|
||||
rolls []alignedcmd.StatChangePotionWeightedRoll,
|
||||
random func(int) int,
|
||||
) (int64, int64) {
|
||||
weighted := make([]alignedcmd.StatChangePotionWeightedRoll, 0, len(rolls))
|
||||
totalWeight := 0
|
||||
for _, roll := range rolls {
|
||||
if roll.Weight <= 0 || roll.BuffID <= 0 {
|
||||
continue
|
||||
}
|
||||
weighted = append(weighted, roll)
|
||||
totalWeight += int(roll.Weight)
|
||||
}
|
||||
if len(weighted) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
pick := 0
|
||||
if random != nil && totalWeight > 0 {
|
||||
pick = random(totalWeight)
|
||||
if pick < 0 || pick >= totalWeight {
|
||||
pick = 0
|
||||
}
|
||||
}
|
||||
accumulated := 0
|
||||
for _, roll := range weighted {
|
||||
accumulated += int(roll.Weight)
|
||||
if pick < accumulated {
|
||||
return roll.BonusPercent, roll.BuffID
|
||||
}
|
||||
}
|
||||
last := weighted[len(weighted)-1]
|
||||
return last.BonusPercent, last.BuffID
|
||||
}
|
||||
|
||||
// currentUpgradeRatePotionRoll 按权重从**强化/增幅成功率**候选里掷一个结果。
|
||||
//
|
||||
// 返回 (加成百分点, buff id):与掉落率那条同构 —— 表里有候选就掷点,没有就用固定常量。
|
||||
// - 赛丽亚的幸运强化药剂:1097 权重60(+10) / 1098 权重30(+20) / 1099 权重10(+30)
|
||||
// - 强化秘药 / 强化的奥秘 / 永恒的增幅的秘密:PVF 无数值 ⇒ UpgradeBonusPercent(10)
|
||||
func currentUpgradeRatePotionRoll(
|
||||
potion *alignedcmd.StatChangePotionResolution,
|
||||
random func(int) int,
|
||||
) (int64, int64) {
|
||||
if potion == nil {
|
||||
return 0, 0
|
||||
}
|
||||
weighted := make([]alignedcmd.StatChangePotionWeightedRoll, 0, len(potion.UpgradeRolls))
|
||||
totalWeight := 0
|
||||
for _, roll := range potion.UpgradeRolls {
|
||||
if roll.Weight <= 0 || roll.BonusPercent <= 0 {
|
||||
continue
|
||||
}
|
||||
weighted = append(weighted, roll)
|
||||
totalWeight += int(roll.Weight)
|
||||
}
|
||||
if len(weighted) > 0 {
|
||||
pick := 0
|
||||
if random != nil && totalWeight > 0 {
|
||||
pick = random(totalWeight)
|
||||
if pick < 0 || pick >= totalWeight {
|
||||
pick = 0
|
||||
}
|
||||
}
|
||||
accumulated := 0
|
||||
for _, roll := range weighted {
|
||||
accumulated += int(roll.Weight)
|
||||
if pick < accumulated {
|
||||
return roll.BonusPercent, roll.BuffID
|
||||
}
|
||||
}
|
||||
last := weighted[len(weighted)-1]
|
||||
return last.BonusPercent, last.BuffID
|
||||
}
|
||||
if potion.UpgradeBonusPercent > 0 {
|
||||
return potion.UpgradeBonusPercent, 0
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
Reference in new issue
Block a user