服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
331 lines
14 KiB
Go
331 lines
14 KiB
Go
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
|
||
}
|