Files
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

179 lines
7.3 KiB
Go
Raw Permalink 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 (
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
}