DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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
}