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,301 @@
package inventory
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfcargo "longheng.io/server/internal/modules/dnf/cargo"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 扩容券(物品栏 / 金库 / 帐号金库)的服务端接线。
//
// 现场 2026-10-03(服主报,角色 测试x = char 11):
//
// 490701690 物品栏扩展券 [action type] `[upgrade inventory]` 背包槽 66
// 490701691 金库扩展券 (1阶段) [action type] `[upgrade cargo]` 背包槽 68
// 490701692 帐号金库升级工具. [action type] `[upgrade account cargo]` 背包槽 67
//
// 三件"使用后没效果"。加密包日志(conn game-000124,2026-10-03)证明客户端**有发包**:
//
// 09:56:57 RECV type=430 ENUM_CMDPACKET_UPGRADE_INVENTORY body=`4200` ×11
// 09:58:42 RECV type=408 ENUM_CMDPACKET_UPGRADE_CARGO body=`4400`
// 09:58:44 RECV type=515 ENUM_CMDPACKET_USE_STACKABLE_ACTION
// body=`4300000000000092…`(槽 0x43=67 + action index 0x92=146)
// 服务端当场的真实回包是 op515 错误 17:
// "use stackable action index 146 unsupported"
//
// 前两个 opcode 在服务端**完全没有路由**(不在 aligned 表里、没有 handler,
// DispatchUpper 对未注册 opcode 直接 return false ⇒ 静默),第三个被 action 白名单拒绝。
//
// 官方服务端(同目录 df_game_r(1),带 .symtab)给出了三种券的完整业务判据,
// 见 _investigation_顶号_official/upgrade_dis.txt:
//
// Dispatcher_UpgradeInventory::dispatch_sig 读 PacketBuf::get_short 槽位 →
// 校验槽上物品数量>0 → 校验物品(官方硬编码 2660298/8251 两个 id)→
// get_inventory_capacity()>15 则拒绝(0x13) → delete_item(1,slot,1,reason3) →
// 发 class0/op14 空行清槽 → capacity+=8 → op66{u16 11,u16 capacity}
// Dispatcher_UpgradeCargo::dispatch_sig 同样读槽位 → 数量>0 →
// verifyStackableItemActionPlace(StackableAction::Type 0x2b, itemID)(**PVF 判定**)→
// CCargo::isUpgradable(capacity+16) → delete_item → op14 空行 →
// CCargo::UpgradeCargo(capacity+16) → op66{u16 1,u16 capacity}
//
// 我们**不照抄官方的硬编码物品 id**:那是官服自己的白名单,本服的道具是自制的
// (490701690/691/692),所以判据取 PVF 自己的 `[action type]` —— 与官方金库那条
// `verifyStackableItemActionPlace` 同一个思路,而且覆盖全库 100 件同类道具
// (40 / 47 / 13 件),换一件券也照样能用。
//
// 容量推进口径(三处都与既有代码同一张表):
//
// 物品栏 main_list_param16 0..24 步长 8(repository.currentMainInventoryExpansion)
// —— 券自己的说明:"物品栏的空间增加8格。物品栏最多可以增加到56格。"(32+24)
// 金库 personal_cargo_list_param16 8..200 步长 16(currentPersonalCargoSlotCount)
// 帐号金库 account_cargo_level 走 dnfcargo.NextAccountCargoTier 的 16 档阶梯
//
// 帐号金库这一档**不收**金币/无色小晶块:券本身就是代价(金库窗口那条 op306 才收费)。
const (
// accountCargoUpgradeActionIndex 是 `[action type] [upgrade account cargo]`
// 在 op515 里的 action index。现场实测 0x92=146(见文件头日志),与
// [rent point]=105、[add fatigue]=58、任务完成券=167 同一张编号表。
accountCargoUpgradeActionIndex uint32 = 146
// containerUpgradeMainStep / containerUpgradeMaxMainExpansion 见文件头。
containerUpgradeMainStep = 8
containerUpgradeMaxMainExpansion = 24
// containerUpgradeCargoStep / containerUpgradeMaxCargoSlots 见文件头。
containerUpgradeCargoStep = 16
containerUpgradeMaxCargoSlots = 200
)
var (
ErrContainerUpgradeContract = errors.New("inventory container-upgrade contract is invalid")
ErrContainerUpgradeItemMismatch = errors.New("container-upgrade voucher's PVF action type does not match the requested container")
ErrContainerUpgradeMaxStage = errors.New("container is already at its maximum upgrade stage")
ErrContainerUpgradeStateMissing = errors.New("character container state is missing")
ErrContainerUpgradeAccountMissing = errors.New("account cargo has not been created")
ErrContainerUpgradeAccountBroken = errors.New("account cargo metadata is invalid")
)
// UpgradeContainerFromVoucher 用一张扩容券推进一个容器档位。
//
// cmd.SourceSlotIndex 是客户端声明的券槽位(op430/op408 的 i16 body,或 op515 的槽位);
// 券的身份一律从**持久行 + 运行时 PVF** 反查:请求体里没有 item id,客户端说什么不算数。
// wantKind 由调用方按 opcode(或 action index)给出,PVF 解析出的 kind 必须与它一致,
// 所以"拿金库券去点物品栏"这类错位组合会被当场拒掉。
func (o *Owner) UpgradeContainerFromVoucher(
ctx context.Context,
cmd Command,
resolver alignedcmd.StackableUseResolver,
wantKind alignedcmd.ContainerUpgradeKind,
now time.Time,
) (UseStackableResult, error) {
if o == nil || o.settlements == nil || o.settings == nil || resolver == nil {
return UseStackableResult{}, ErrContainerUpgradeContract
}
if cmd.SelectedCharacterID == 0 || cmd.SourceListType != listTypeMain || cmd.SourceSlotIndex < 0 {
return UseStackableResult{}, ErrContainerUpgradeContract
}
switch wantKind {
case alignedcmd.ContainerUpgradeInventory, alignedcmd.ContainerUpgradeCargo, alignedcmd.ContainerUpgradeAccountCargo:
default:
return UseStackableResult{}, ErrContainerUpgradeContract
}
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
var result UseStackableResult
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if tx.Inventory == nil || tx.Character == nil {
return ErrContainerUpgradeContract
}
character, found, err := tx.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.CharacterID) != characterID {
return ErrCharacterRequired
}
if cmd.AccountID != "" && strings.TrimSpace(character.AccountID) != strings.TrimSpace(cmd.AccountID) {
return fmt.Errorf("%w: character=%s account=%q want=%q",
ErrContainerUpgradeContract, characterID, character.AccountID, cmd.AccountID)
}
inventory, found, err := tx.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
key := slotKey(cmd.SourceListType, cmd.SourceSlotIndex)
stack, found := inventory.Slots[key]
if !found || stack.Count <= 0 || stack.ItemID <= 0 {
return fmt.Errorf("%w: list=%d slot=%d is empty", ErrSlotNotFound, cmd.SourceListType, cmd.SourceSlotIndex)
}
resolution, err := resolver(stack.ItemID)
if err != nil {
return fmt.Errorf("%w: item=%d: %v", ErrContainerUpgradeContract, stack.ItemID, err)
}
upgrade := resolution.ContainerUpgrade
if resolution.ItemID != stack.ItemID || upgrade == nil || upgrade.ItemID != stack.ItemID || upgrade.Kind != wantKind {
return fmt.Errorf("%w: item=%d want=%s resolution=%+v pvf_path=%q",
ErrContainerUpgradeItemMismatch, stack.ItemID, wantKind, resolution.ContainerUpgrade, resolution.PVFPath)
}
before, after := int64(0), int64(0)
listType := listTypeMain
cargoPad := false
switch wantKind {
case alignedcmd.ContainerUpgradeInventory, alignedcmd.ContainerUpgradeCargo:
if tx.Settings == nil {
return ErrContainerUpgradeContract
}
record, found, err := tx.Settings.Load(ctx, dnfrepo.CharacterContainerStateScope(characterID))
if err != nil {
return err
}
if !found {
return fmt.Errorf("%w: character=%s", ErrContainerUpgradeStateMissing, characterID)
}
state, err := dnfrepo.CharacterContainerStateFromSettings(record, characterID)
if err != nil {
return err
}
record = dnfrepo.CloneSettings(record)
if record.Values == nil {
record.Values = make(map[string]string)
}
switch wantKind {
case alignedcmd.ContainerUpgradeInventory:
before = int64(state.MainSlotCount)
if before+containerUpgradeMainStep > containerUpgradeMaxMainExpansion {
return fmt.Errorf("%w: main_list_param16=%d", ErrContainerUpgradeMaxStage, before)
}
after = before + containerUpgradeMainStep
record.Values["main_list_param16"] = strconv.FormatInt(after, 10)
record.Values["main_inventory_last_upgrade_commodity"] = strconv.FormatInt(stack.ItemID, 10)
record.Values["main_inventory_last_upgrade_from"] = strconv.FormatInt(before, 10)
record.Values["main_inventory_last_upgrade_to"] = strconv.FormatInt(after, 10)
record.Values["source"] = "current_exe_container_upgrade_voucher"
listType = listTypeMain
default:
before = int64(state.PersonalCargoSlotCount)
if before+containerUpgradeCargoStep > containerUpgradeMaxCargoSlots {
return fmt.Errorf("%w: personal_cargo_list_param16=%d", ErrContainerUpgradeMaxStage, before)
}
after = before + containerUpgradeCargoStep
record.Values["personal_cargo_list_param16"] = strconv.FormatInt(after, 10)
record.Values["personal_cargo_last_upgrade_commodity"] = strconv.FormatInt(stack.ItemID, 10)
record.Values["personal_cargo_last_upgrade_from"] = strconv.FormatInt(before, 10)
record.Values["personal_cargo_last_upgrade_to"] = strconv.FormatInt(after, 10)
record.Values["source"] = "current_exe_container_upgrade_voucher"
listType = listTypePersonalCargo
cargoPad = true
}
record.UpdatedAt = now
// 落库之前先用仓储自己的校验器审一遍:仓储是这几张合法表的唯一权威
// (背包 0..24 步长 8、金库 8..200 步长 16),写坏了这里就会先炸。
if _, err := dnfrepo.CharacterContainerStateFromSettings(record, characterID); err != nil {
return err
}
if err := tx.Settings.Save(ctx, record); err != nil {
return err
}
default:
if tx.Account == nil {
return ErrContainerUpgradeContract
}
accountID := strings.TrimSpace(character.AccountID)
if accountID == "" {
return fmt.Errorf("%w: character=%s", ErrContainerUpgradeContract, characterID)
}
account, found, err := tx.Account.Load(ctx, accountID)
if err != nil {
return err
}
if !found || strings.TrimSpace(account.AccountID) != accountID {
return fmt.Errorf("%w: account=%q", ErrContainerUpgradeContract, accountID)
}
account = dnfrepo.CloneAccount(account)
if account.Metadata == nil {
account.Metadata = make(map[string]string, 4)
}
raw := strings.TrimSpace(account.Metadata["account_cargo_level"])
if raw == "" {
return fmt.Errorf("%w: account=%q", ErrContainerUpgradeAccountMissing, accountID)
}
level, parseErr := strconv.ParseInt(raw, 10, 64)
if parseErr != nil {
return fmt.Errorf("%w: account=%q account_cargo_level=%q", ErrContainerUpgradeAccountBroken, accountID, raw)
}
if level <= 0 {
return fmt.Errorf("%w: account=%q level=%d", ErrContainerUpgradeAccountMissing, accountID, level)
}
next, ok := dnfcargo.NextAccountCargoTier(level)
if !ok || next <= level {
return fmt.Errorf("%w: account_cargo_level=%d", ErrContainerUpgradeMaxStage, level)
}
// 券付账:这里刻意**不调** applyCargoUpgradeCost 的那三档收费
// (1->8 金币、16/24 无色小晶块、其余点券),否则等于让玩家付两次。
account.Metadata["account_cargo_created"] = "true"
account.Metadata["account_cargo_level"] = strconv.FormatInt(next, 10)
if strings.TrimSpace(account.Metadata["account_cargo_gold"]) == "" {
account.Metadata["account_cargo_gold"] = "0"
}
account.UpdatedAt = now
if err := tx.Account.Save(ctx, account); err != nil {
return err
}
before, after = level, next
listType = listTypeAccountCargo
cargoPad = true
}
remaining := stack.Count - 1
if remaining == 0 {
delete(inventory.Slots, key)
} else {
stack = cloneStack(stack)
stack.Count = remaining
inventory.Slots[key] = stack
}
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
result = UseStackableResult{
CharacterID: characterID,
AccountID: strings.TrimSpace(character.AccountID),
ListType: cmd.SourceListType,
SlotIndex: cmd.SourceSlotIndex,
ItemID: stack.ItemID,
RemainingCount: remaining,
Changed: true,
PVFPath: strings.TrimSpace(resolution.PVFPath),
StackableType: resolution.StackableType,
ContainerUpgradeApplied: true,
ContainerUpgradeKind: wantKind,
ContainerUpgradeBefore: before,
ContainerUpgradeAfter: after,
ContainerUpgradeListType: listType,
ContainerUpgradeNeedsCargoPad: cargoPad,
}
return nil
})
if err != nil {
return UseStackableResult{}, err
}
return result, nil
}