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,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
|
||||
}
|
||||
Reference in new issue
Block a user