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,330 @@
package dnfbridge
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfinventory "longheng.io/server/internal/modules/dnf/inventory"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// tarot_lottery_item.go —— 塔罗牌类消耗品(PVF `[stackable type]` =
// `[monster tarot card]`,例:stackable/10159001/10159697.stk「占星师罗赛洛 塔罗牌」)
// 在**现网客户端的 op27 通道**上的使用。
//
// 为什么必须挂 op27,而不是 op44:
//
// 现场 2026-09-30T11:52:47Z(本地 19:52:47)的实机包日志
// (runtime/logs/packet/20260930/packet_log-20260930-002.log)里,玩家右键
// stackable/10159001/10159698.stk 发出的请求是
//
// RECV kind=game-legacy cmd=1 type=27 ENUM_CMDPACKET_USE_LOTTERY_ITEM
// header_seq=43 body_len=4 hex=011b0011000000a07499d82b0000006e00
// ⇒ body = (u16 mode=0, u16 slot=110)
//
// 而服务端在 booster 侧把它当"非随机罐"拒了:
//
// EVENT kind=game-lottery-item-rejected slot=110 stackable_type=
// reason=dnf booster runtime PVF data is invalid:
// source item=10159698 type="[monster tarot card]" is not random
//
// 拒绝点是 booster_pvf.go:64(op27 → lottery_item.go:51 prepareCurrentBooster)。
// 上一轮实现把塔罗牌分支挂在 stackable_use_pvf.go:104 的 **op44** 解析器里,
// 而 alignedStackableUseResolverForCommand(stackable_use_pvf.go:47-53)只对
// 44/515/418/30 生效 —— op27 永远到不了那里。这就是"修了但是没修好"的根因:
// 解析器、Owner、事务边界都写对了,只是**挂错了 opcode**。
//
// 本文件只做**加法**:它是 handleCurrentLotteryItem 最前面的一个显式早退分支,
// 只有源道具确实是 `[monster tarot card]` 时才接管这次 op27;任何其它道具都原样
// 落回既有的 booster/罐子路径(那条路径一个字节都没有改动)。
//
// 回包形状沿用既有的 op27 契约(lottery_item.go:107-114 记录的 NoPack.exe
// sub_1D31260):u8 成功位 + u16 源槽 + 一条 0x77 字节道具行 + u32。
// 敢复用的理由是客户端只有**一个** op27 读取器,塔罗牌与抽奖罐共用它。
//
// 刻意**不做**的两件事(都有证据边界,不该猜):
//
// - 不消费"魔王契约双倍"(op27 body 的 mode=1)。塔罗牌的 PVF 里没有任何
// 双倍/契约声明,凭空扣一次契约日次数比不生效更糟;这里只把 mode 记进日志。
// - 不补发 op14/op13 道具表。lottery_item.go:107-114 的既有教训:op27 结果包
// 之后插入道具表包,会替换客户端动画队列仍握着的道具对象,下一次自动开罐
// 可以解引用陈旧状态并把进程打崩。发奖落点已经由结果包那一行给出。
// currentTarotLotteryStackableType 是 PVF 里塔罗牌类消耗品的 `[stackable type]`
// 归一化文本(normalizeDungeonDropStackableType 之后)。
const currentTarotLotteryStackableType = "[monster tarot card]"
// currentTarotLotterySourceStack 只读地取出 op27 源槽里的道具。
//
// 任何读不到的情况都返回 ok=false:那种情况下必须落回既有路径,让既有的错误与
// 日志原样发生,本文件不能改变"非塔罗牌 op27"的任何行为。
func currentTarotLotterySourceStack(
ctx context.Context,
repositories dnfrepo.Group,
characterID string,
slot int16,
) (dnfrepo.ItemStack, uint32, bool) {
if repositories.Inventory == nil || characterID == "" {
return dnfrepo.ItemStack{}, 0, false
}
record, found, err := repositories.Inventory.Load(ctx, characterID)
if err != nil || !found {
return dnfrepo.ItemStack{}, 0, false
}
// 槽键必须与 booster 路径同源(prepareCurrentBooster 用的是同一个函数),
// 否则两条路会对"slot"给出不同的理解。
stack, found := record.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)]
if !found || stack.ItemID <= 0 || stack.ItemID > int64(^uint32(0)) || stack.Count <= 0 {
return dnfrepo.ItemStack{}, 0, false
}
return stack, uint32(stack.ItemID), true
}
// currentTarotLotteryDefinition 报告 op27 源槽里的道具是不是塔罗牌。它是本文件
// 与既有罐子路径之间**唯一**的分岔条件:返回 false 时调用方必须原样继续。
func (s *Service) currentTarotLotteryDefinition(
ctx context.Context,
session *gameSession,
catalog *pvfDungeonDropCatalog,
slot int16,
) (dungeonDropItemDefinition, uint32, string, bool) {
if s == nil || session == nil || catalog == nil || slot < 0 || session.selectedCharacterID == 0 {
return dungeonDropItemDefinition{}, 0, "", false
}
repositories, ok := s.repositoryGroup()
if !ok {
return dungeonDropItemDefinition{}, 0, "", false
}
characterID := fmt.Sprint(session.selectedCharacterID)
_, itemID, ok := currentTarotLotterySourceStack(ctx, repositories, characterID, slot)
if !ok {
return dungeonDropItemDefinition{}, 0, "", false
}
definition, err := catalog.ResolveItem(itemID)
if err != nil {
// 解析不了的源道具交回既有路径:由它给出与改动前逐字相同的错误。
return dungeonDropItemDefinition{}, 0, "", false
}
if normalizeDungeonDropStackableType(definition.StackableType) != currentTarotLotteryStackableType {
return dungeonDropItemDefinition{}, 0, "", false
}
return definition, itemID, characterID, true
}
// sendCurrentTarotLotteryFailure 与既有 op27 拒绝回包完全同形(lottery_item.go:30-36:
// 首字节成功位 0,次字节原生失败原因 4)。走同一个构造函数,避免两条路给出不同的
// 失败字节。
func (s *Service) sendCurrentTarotLotteryFailure(session *gameSession) error {
return s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketUseLotteryItem),
buildCurrentLotteryErrorBody(),
dnfproto.DefaultChannelClassification,
)
}
// tryHandleCurrentTarotLotteryItem 是 handleCurrentLotteryItem 的塔罗牌分派。
//
// 返回 handled=true 表示这次 op27 已由本函数处理完(成功或已回拒绝包),调用方必须
// 直接返回;handled=false 表示源道具不是塔罗牌,调用方必须继续走原来那条一字未改的
// 罐子路径。所有"读不到 / 不是塔罗牌"的路径都只读、不产生任何包与事务。
func (s *Service) tryHandleCurrentTarotLotteryItem(
ctx context.Context,
session *gameSession,
catalog *pvfDungeonDropCatalog,
wantsDouble bool,
slot int16,
) (bool, error) {
definition, itemID, characterID, ok := s.currentTarotLotteryDefinition(ctx, session, catalog, slot)
if !ok {
return false, nil
}
// —— 到这里为止全是只读;下面才真正接管这次 op27。 ——
//
// 失败一律回 op27 拒绝包并返回 handled=true:既不落回罐子路径(它必然再拒一次
// 并多打一条 is not random 的误导日志),也不把错误抛给上层。
reject := func(stage string, reason error) (bool, error) {
// 沿用既有的 game-lottery-item-rejected 名字(reason 里带 route 前缀),
// 这样任何盯着 op27 拒绝的既有查询不会因为这次分派而失明。
s.logGameEvent(session, "game-lottery-item-rejected",
"slot", slot,
"stackable_type", definition.StackableType,
"route", "tarot_lottery",
"stage", stage,
"reason", fmt.Sprintf("tarot lottery route: %v", reason))
s.logGameEvent(session, "game-tarot-lottery-item-rejected",
"slot", slot,
"item", itemID,
"pvf_path", definition.PVFPath,
"stackable_type", definition.StackableType,
"stage", stage,
"reason", reason)
return true, s.sendCurrentTarotLotteryFailure(session)
}
// int32 溢出守卫:Command.ItemTemplateID 是 int32,而 Owner 会拿它与契约里的
// uint32 源道具比。截断过的 id 会让契约校验永远不成立,这里显式挡住。
if itemID > math.MaxInt32 {
return reject("item_id", fmt.Errorf("item=%d exceeds the command's int32 item field", itemID))
}
// PVF 契约:`[lottery use cost]`(金币)+ `[int data]` 加权 (item, weight, count)
// 三元组。抽中哪一项在**解析阶段**就定下来(随机源是服务自身可注入的
// s.dungeonChoice,与迷宫/图案抽取同一个口子),Owner 侧因此是纯确定性的。
// 解析失败/落空区都在这里 fail closed。
resolution, resolveErr := s.resolveCurrentTarotLotteryCard(definition)
if resolveErr != nil {
return reject("contract", resolveErr)
}
if resolution == nil || resolution.Drawn == nil {
return reject("contract", fmt.Errorf("item=%d produced no drawn reward (missed=%t)", itemID, resolution != nil && resolution.Missed))
}
drawnItemID := resolution.Drawn.Reward.ItemID
drawnCount := resolution.Drawn.Count
if drawnItemID <= 0 || drawnItemID > int64(^uint32(0)) || drawnCount <= 0 || drawnCount > int64(^uint32(0)) {
return reject("reward", fmt.Errorf("item=%d drawn reward id=%d count=%d is out of range", itemID, drawnItemID, drawnCount))
}
repositories, ok := s.repositoryGroup()
if !ok {
return reject("owner", dnfinventory.ErrOwnerUnavailable)
}
owner, ownerErr := dnfinventory.NewOwner(repositories)
if ownerErr != nil {
return reject("owner", ownerErr)
}
// 金币与道具必须同一笔事务:UseTarotLotteryCard 走
// withinCharacterAssetTransaction(characters + inventory + equipment 三个仓库
// 同事务),任一失败整笔回滚。见 inventory/tarot_lottery_card.go 的文件头。
command := dnfinventory.Command{
Operation: "use_tarot_lottery_card",
AccountID: s.accountIDForSession(session),
SelectedCharacterID: session.selectedCharacterID,
SourceListType: dnfrepo.MainInventoryListType,
SourceSlotIndex: slot,
ItemTemplateID: int32(itemID),
}
result, useErr := owner.UseTarotLotteryCard(ctx, command, *resolution)
if useErr != nil {
if errors.Is(useErr, dnfinventory.ErrTarotLotteryMissed) {
// 真档 10159697/10159698 的权重合计 999998/1000000,存在 2/100 万的
// 空隙;现有实现选择"整次中止、不扣钱不扣道具"(见
// inventory/tarot_lottery_card.go:34-37)。把 roll 打进日志,空隙频率
// 可被真实流量证实或否证。
s.logGameEvent(session, "game-tarot-lottery-item-gap",
"slot", slot, "item", itemID,
"roll", resolution.Roll, "total_weight", resolution.TotalWeight,
"gold_cost", resolution.GoldCost)
}
return reject("commit", useErr)
}
// 结果行:单条道具行,数量是**本次抽中的数量**(不是合并后的堆叠总数)——
// 与既有罐子路径的注释同口径(gameplay_module_booster.go:541-555)。
commit := currentBoosterCommitResult{
SourceItemID: itemID,
SourceSlot: slot,
SourceRemaining: uint32(result.RemainingCount),
Rewards: []currentBoosterGrantedReward{{
ItemID: uint32(drawnItemID),
Count: uint32(drawnCount),
}},
}
if len(result.RandomRewardSlots) > 0 {
// 落点用 Owner 真正写进去的那一格,而不是源槽:源槽在还剩多张牌时仍然
// 被源道具占着,把奖励画在源槽上会让客户端把两张牌混在一格。
//
// 这一行的**形状必须跟着奖励的 kind 走**:客户端拿这一行直接建对象,
// 装备行(真档 4 条里有 3 条是装备)要带 item_kind/equipment_type/耐久,
// 否则它会按可叠加物去建,画出来是一件 0 耐久的错东西。
// 数据全部来自契约里的 reward(resolveCurrentMagicBoxRewardItem 已经
// 从同一份 PVF 读好),这里不另做一次 PVF 查询。
displayStack := dnfrepo.ItemStack{ItemID: drawnItemID, Count: drawnCount}
if strings.EqualFold(strings.TrimSpace(resolution.Drawn.Reward.Kind), "equipment") {
displayStack.Count = 1
displayStack.Extra = map[string]string{
"item_kind": "equipment",
"pvf_path": resolution.Drawn.Reward.PVFPath,
"equipment_type": resolution.Drawn.Reward.EquipmentType,
"amount_or_count": "0",
}
if resolution.Drawn.Reward.Seal {
displayStack.Extra["seal_flag"] = "1"
}
if durability := resolution.Drawn.Reward.Durability; durability > 0 {
value := strconv.FormatUint(uint64(durability), 10)
displayStack.Extra["durability"] = value
displayStack.Extra["max_durability"] = value
}
}
commit.LotteryDisplayEntry = currentItemListEntryFromStack(
dnfrepo.MainInventoryListType,
result.RandomRewardSlots[0],
displayStack,
)
commit.LotteryDisplayEntrySet = true
}
resultBody, bodyErr := buildCurrentLotteryResultBody(commit)
if bodyErr != nil {
return reject("result_body", bodyErr)
}
if sendErr := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketUseLotteryItem),
resultBody,
dnfproto.DefaultChannelClassification,
); sendErr != nil {
return true, sendErr
}
// `[lottery use cost]` 是**服务端**扣的金币(inventory/tarot_lottery_card.go:155
// 在同一个事务里 `character.Stats["gold"] -= GoldCost`)。客户端不知道这笔花费,
// 所以必须把权威余额推给它。
//
// 形状与既有路径完全一致:罐子开出金币时 lottery_item.go:115-124 就是在 op27
// 结果包之后发这**一个**钱包包。buildCurrentGoldStateBody 是**绝对值**
// (current_item_update_protocol.go:5),与客户端是否本地扣过无关,重复下发也
// 不会算错。仍然不发 op14/op13 道具表(见文件头)。
goldAfter := int64(0)
if resolution.GoldCost > 0 {
if character, found, loadErr := repositories.Character.Load(ctx, characterID); loadErr == nil && found {
goldAfter = character.Stats["gold"]
if sendErr := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketWalkoutPartyMember),
buildCurrentGoldStateBody(goldAfter),
0,
); sendErr != nil {
return true, sendErr
}
}
}
s.logGameEvent(session, "game-tarot-lottery-item-open-committed",
"slot", slot,
"source_item", itemID,
"source_remaining", result.RemainingCount,
"source_pvf_path", definition.PVFPath,
"stackable_type", definition.StackableType,
"gold_cost", resolution.GoldCost,
"gold_after", goldAfter,
"roll", resolution.Roll,
"total_weight", resolution.TotalWeight,
"reward_item", drawnItemID,
"reward_count", drawnCount,
"reward_slots", fmt.Sprint(result.RandomRewardSlots),
"requested_double", wantsDouble,
"double_honored", false,
"op27_body_len", len(resultBody),
"state_source", "client_op27_use_lottery_item_same_reader_as_the_jars",
"inventory_sync", "native_op27_only_then_one_absolute_gold_wallet_packet")
return true, nil
}