服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
179 lines
7.3 KiB
Go
179 lines
7.3 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
cryptorand "crypto/rand"
|
||
"fmt"
|
||
"math/big"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
)
|
||
|
||
// 抽奖类消耗品(`[monster tarot card]`)的奖池解析。
|
||
//
|
||
// PVF 形状(真档 `stackable/10159001/10159697.stk`,卢克塔罗牌「占星师罗赛洛
|
||
// 塔罗牌」):
|
||
//
|
||
// [stackable type]
|
||
// `[monster tarot card]` 19
|
||
// [lottery use cost]
|
||
// 100000
|
||
// [int data]
|
||
// 100250574 116666 1 102040539 116666 1 106030594 116666 1 10159145 350000 1 10159142 300000 250
|
||
// [/int data]
|
||
//
|
||
// `[int data]` 是一条**变长的 (item, weight, count) 三元组**列表 —— 与 [booster info]
|
||
// 的奖励项同一个形状(dungeon_drop_catalog.go 里解析 [booster info] 的注释就这么写)。
|
||
// 尾部的残留值(凑不满一个三元组)直接丢弃,不猜、不补零。
|
||
//
|
||
// 这一层只做**解析**:没有任何使用路径消费 LotteryUseCost / LotteryEntries 之前,
|
||
// 这些字段是纯数据,行为与改动前逐字相同。
|
||
|
||
// dungeonDropLotteryEntry is one weighted candidate of a `[int data]` lottery row.
|
||
type dungeonDropLotteryEntry struct {
|
||
ItemID int64
|
||
Weight int64
|
||
Count int64
|
||
}
|
||
|
||
// parseDungeonDropLotteryEntries chunks a flat `[int data]` value list into
|
||
// (item, weight, count) triples. A trailing partial triple is dropped: a table
|
||
// that does not describe whole rows is not a table this server will draw from.
|
||
func parseDungeonDropLotteryEntries(values []int64) []dungeonDropLotteryEntry {
|
||
if len(values) < 3 {
|
||
return nil
|
||
}
|
||
entries := make([]dungeonDropLotteryEntry, 0, len(values)/3)
|
||
for index := 0; index+2 < len(values); index += 3 {
|
||
entries = append(entries, dungeonDropLotteryEntry{
|
||
ItemID: values[index],
|
||
Weight: values[index+1],
|
||
Count: values[index+2],
|
||
})
|
||
}
|
||
return entries
|
||
}
|
||
|
||
// dungeonDropLotteryTotalWeight is the pool's total weight. Zero means "not
|
||
// drawable": an empty table or one whose weights are all non-positive.
|
||
func dungeonDropLotteryTotalWeight(entries []dungeonDropLotteryEntry) int64 {
|
||
total := int64(0)
|
||
for _, entry := range entries {
|
||
if entry.ItemID <= 0 || entry.Weight <= 0 || entry.Count <= 0 {
|
||
continue
|
||
}
|
||
total += entry.Weight
|
||
}
|
||
return total
|
||
}
|
||
|
||
// dungeonDropLotteryPick is the deterministic half of the draw: given a roll in
|
||
// [0, dungeonDropLotteryTotalWeight), it returns the entry that roll lands on.
|
||
// The caller owns randomness; this keeps the selection testable.
|
||
func dungeonDropLotteryPick(entries []dungeonDropLotteryEntry, roll int64) (dungeonDropLotteryEntry, bool) {
|
||
if roll < 0 {
|
||
return dungeonDropLotteryEntry{}, false
|
||
}
|
||
remaining := roll
|
||
for _, entry := range entries {
|
||
if entry.ItemID <= 0 || entry.Weight <= 0 || entry.Count <= 0 {
|
||
continue
|
||
}
|
||
if remaining < entry.Weight {
|
||
return entry, true
|
||
}
|
||
remaining -= entry.Weight
|
||
}
|
||
return dungeonDropLotteryEntry{}, false
|
||
}
|
||
|
||
// resolveCurrentTarotLotteryCard 把一张塔罗牌的 PVF 数据变成使用契约:金币花费 +
|
||
// **已经抽中的那一项**。
|
||
//
|
||
// 抽取放在这里(服务端解析阶段)而不是 Owner 里:Owner 侧因此是纯确定性的(同一份
|
||
// 契约必然产生同一笔提交,事务里没有随机源),而随机源用服务自身可注入的那一个
|
||
// (`dungeonChoice`,与迷宫/图案抽取同一个口子),测试可以直接注入确定值。
|
||
//
|
||
// 落空区(roll 落在 [total_weight, ...) 之外,真档 10159697/10159698 合计
|
||
// 999998/1000000 就有 2/100 万的空隙)在契约里标成 Missed,由 Owner 中止整次使用
|
||
// —— 见 inventory.ErrTarotLotteryMissed 的注释(我们与官服可能不同的保守选择)。
|
||
func (s *Service) resolveCurrentTarotLotteryCard(definition dungeonDropItemDefinition) (*alignedcmd.TarotLotteryResolution, error) {
|
||
entries := definition.LotteryEntries
|
||
total := dungeonDropLotteryTotalWeight(entries)
|
||
if definition.LotteryUseCost <= 0 || total <= 0 {
|
||
// fail closed:没有金币花费或没有可抽的奖池,就不是一张可用的卡。
|
||
return nil, fmt.Errorf("%w: item=%d path=%s cost=%d total_weight=%d",
|
||
errDungeonDropItemUnresolved, definition.ItemID, definition.PVFPath,
|
||
definition.LotteryUseCost, total)
|
||
}
|
||
catalog, err := s.currentPVFItemCatalog()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 中奖道具的 PVF 文档必须从**解析这批 item id 的同一份 PVF** 里读,否则会出现
|
||
// "用 PVF A 解析出的 id 去 PVF B 里找 [attach type]" 的错配。罐子那条抽奖路就
|
||
// 是这么取源的(booster_pvf.go:67 `resolveCurrentMagicBox(catalog, catalog.source, ...)`),
|
||
// 这里对齐同一口径。顺带去掉对 initialEquipmentArchive 的依赖:那张卡的使用
|
||
// 不该再触发一次整档加载,测试也能用与罐子同一套内存 PVF 夹具跑通。
|
||
source := catalog.source
|
||
if source == nil {
|
||
return nil, errDungeonDropSourceRequired
|
||
}
|
||
roll := int64(0)
|
||
if s != nil && s.dungeonChoice != nil {
|
||
chosen, choiceErr := s.dungeonChoice(int(total))
|
||
if choiceErr != nil {
|
||
return nil, choiceErr
|
||
}
|
||
if chosen < 0 || int64(chosen) >= total {
|
||
return nil, fmt.Errorf("tarot lottery card=%d roll=%d outside [0,%d)", definition.ItemID, chosen, total)
|
||
}
|
||
roll = int64(chosen)
|
||
} else {
|
||
// **没有注入随机源时必须自己掷,不能默认 0。**
|
||
//
|
||
// 现场 2026-10-03(服主报「塔罗牌用不了」,角色 1 礼白666 连点 6 次):
|
||
// 6 次全部命中奖池的**第一项** 108040415(血红战斧,装备行),而装备行会被
|
||
// Owner 的发奖契约拒掉 —— 于是每次都「点了没反应」。
|
||
//
|
||
// 6 次同一个 item 不可能是运气:dungeonChoice 只在**测试**里被赋值
|
||
// (全仓非测试代码里没有任何一处给它赋值),线上它永远是 nil,而这里原本
|
||
// 把 roll 留在 0,`dungeonDropLotteryPick(entries, 0)` 就永远返回第一项。
|
||
// 换句话说这张卡**从来没有真正抽过**,只是每次都被第一条记录挡下。
|
||
//
|
||
// 兜底与 chooseDungeonIndex 同口径(dungeon_runtime_state.go:1213):
|
||
// 注入源优先,没有就用 crypto/rand。随机性必须在服务端,不能靠客户端。
|
||
chosen, choiceErr := cryptorand.Int(cryptorand.Reader, big.NewInt(total))
|
||
if choiceErr != nil {
|
||
return nil, fmt.Errorf("tarot lottery card=%d roll: %w", definition.ItemID, choiceErr)
|
||
}
|
||
roll = chosen.Int64()
|
||
}
|
||
resolution := &alignedcmd.TarotLotteryResolution{
|
||
SourceItemID: int64(definition.ItemID),
|
||
SourcePVFPath: definition.PVFPath,
|
||
StackableType: definition.StackableType,
|
||
GoldCost: definition.LotteryUseCost,
|
||
Roll: roll,
|
||
TotalWeight: total,
|
||
}
|
||
if roll < 0 || roll >= total {
|
||
// 掷到区间外(含落空区):不抽、不扣、不发放,整次中止。
|
||
resolution.Missed = true
|
||
return resolution, nil
|
||
}
|
||
entry, ok := dungeonDropLotteryPick(entries, roll)
|
||
if !ok {
|
||
resolution.Missed = true
|
||
return resolution, nil
|
||
}
|
||
reward, err := resolveCurrentMagicBoxRewardItem(catalog, source, entry.ItemID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("resolve tarot card=%d reward=%d: %w", definition.ItemID, entry.ItemID, err)
|
||
}
|
||
if reward.ItemID != entry.ItemID || reward.Kind == "" {
|
||
return nil, fmt.Errorf("resolve tarot card=%d reward=%d is unresolved", definition.ItemID, entry.ItemID)
|
||
}
|
||
resolution.Drawn = &alignedcmd.TarotLotteryOutcome{Reward: reward, Count: entry.Count}
|
||
return resolution, nil
|
||
}
|