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

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

331 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}