服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
475 lines
17 KiB
Go
475 lines
17 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
|
||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||
)
|
||
|
||
// 「城镇增益药剂」(史诗Buff药剂一族)的 buff 数据表。
|
||
//
|
||
// 2026-10-03 现场(服主报,测试x = char 11):史诗Buff药剂 490002458 修好"能用"之后
|
||
// 仍然看不到 buff 图标。翻 PVF 找到两张表,这一族的效果、图标、概率**全在这里**,
|
||
// 不在 .stk 自己的文档里:
|
||
//
|
||
// etc/variousbuffstackablelist.etc 道具 → buff + 概率
|
||
// [index] 490002458
|
||
// [probability buff list] 1136 100 ← buff 1136,权重 100
|
||
// [/index]
|
||
// [index] 10008219
|
||
// [probability buff list] 1090 60 1091 30 1092 10 ← 三档随机
|
||
// [/index]
|
||
//
|
||
// etc/variousbufflist.etc buff → 图标 / 说明 / 效果 / 数值
|
||
// [index] 1136
|
||
// [system group] `random_box_buff`
|
||
// [icon index] 183 ← 客户端画图标用的就是这个
|
||
// [tooltip text] `史诗道具掉落率提升`
|
||
// [effect] [name] `epic_rarity_item_drop_rate` [param] 100 [/param] [/effect]
|
||
// [overlap] 1
|
||
// [apply type] [type] `expire_time` [param] 10 0 [/param] [/apply type]
|
||
// [/index]
|
||
//
|
||
// 于是三件事都有权威依据了:
|
||
// 1. **数值**:`epic_rarity_item_drop_rate` 的 param 就是"增加百分之多少"。
|
||
// 1136=100(2 倍)、1090/1091/1092=100/150/200(赛丽亚三档)、1239=10、1240=5。
|
||
// 以前按 `[explain]` 文字统一取 200 只对史诗Buff药剂是对的,小灯笼大瓶/小瓶其实是
|
||
// +10%/+5%,所以现在改成读这张表;表里查不到的道具(例如 490000413 这种老活动券)
|
||
// 才回退到说明文字。
|
||
// 2. **概率**:`[probability buff list]` 是"按权重随机给一个 buff",所以**服务端**掷点,
|
||
// 客户端不可能自己算 —— 这就是"必须下发状态"的原因。
|
||
// 3. **图标**:`[icon index]` + `[tooltip text]` 由客户端按 buff id 去这张表里取,
|
||
// 服务端只要把"你拿到了哪个 buff、还剩多久"告诉它即可(见
|
||
// stat_change_potion_projection.go 的 class0/op174)。
|
||
const (
|
||
currentVariousBuffStackableList = "etc/variousbuffstackablelist.etc"
|
||
currentVariousBuffList = "etc/variousbufflist.etc"
|
||
// currentEpicRarityDropRateEffect 是掉落率效果的 name;param 是"增加百分比"。
|
||
currentEpicRarityDropRateEffect = "epic_rarity_item_drop_rate"
|
||
// currentUpgradeSuccessRateEffect 是**强化/增幅成功率**效果的 name;param 是
|
||
// "成功率增加多少个百分点"。现场抄录(2026-10-03,cmd/scratch/pvfmember 可复核):
|
||
//
|
||
// 1097 param 10 `强化成功率提升 (下级)`
|
||
// 1098 param 20 `强化成功率提升 (中级)`
|
||
// 1099 param 30 `强化成功率提升 (上级)`
|
||
// 1180..1187 param 1..8(活动 SpecUp_Event_Buff4)
|
||
//
|
||
// 道具→buff 的掷点在同一份 etc/variousbuffstackablelist.etc 里:
|
||
// 赛丽亚的幸运强化药剂 10008220 → `1097 60 1098 30 1099 10`。
|
||
currentUpgradeSuccessRateEffect = "item_usual_upgrade_success_rate"
|
||
// currentDungeonClearExpRateEffect 是"地下城通关经验值 +N%"(赛丽亚的幸运经验药剂
|
||
// 10008222 → buff 1103/1104/1105 概率 30/50/20,param 50/100/200)。
|
||
// 通关经验是服务端发的,所以这一族必须服务端算。
|
||
currentDungeonClearExpRateEffect = "dungeon_clear_exp_rate"
|
||
)
|
||
|
||
// currentVariousBuffRoll 是"道具 → buff"里的一项(buff id + 权重)。
|
||
type currentVariousBuffRoll struct {
|
||
BuffID int64
|
||
Weight int64
|
||
// BonusPercent 只有 clientBuffRollsForItem 会填(厂商那几族把 param 一起带出来,
|
||
// 客户端自己施加效果时用不到它,服务端只用于日志/排查)。解析器不填,保持 0。
|
||
BonusPercent int64
|
||
}
|
||
|
||
// currentVariousBuffEffect 是 buff 定义表里我们关心的字段。
|
||
type currentVariousBuffEffect struct {
|
||
BuffID int64
|
||
SystemGroup string
|
||
IconIndex int64
|
||
Tooltip string
|
||
// EffectName / EffectParams 来自 [effect] 段。掉率增益用 params[0] 表示 +N%。
|
||
EffectName string
|
||
EffectParams []int64
|
||
// ApplyType 来自 [apply type] 的 [type](`expire_time` / `dungeon_one_match` ...)。
|
||
ApplyType string
|
||
}
|
||
|
||
// currentVariousBuffTables 是两张表解析后的结果。
|
||
type currentVariousBuffTables struct {
|
||
// ByItem 道具 id → 候选 buff(按权重)。
|
||
ByItem map[uint32][]currentVariousBuffRoll
|
||
// ByBuff buff id → 定义。
|
||
ByBuff map[int64]currentVariousBuffEffect
|
||
}
|
||
|
||
// loadCurrentVariousBuffTables 读两张表。任一读不到就返回错误,由调用方决定是降级
|
||
// (回退到 `[explain]` 文字)还是让整条路径失败 —— 这里不吞错。
|
||
func loadCurrentVariousBuffTables(source dnfpvf.Source) (currentVariousBuffTables, error) {
|
||
tables := currentVariousBuffTables{
|
||
ByItem: make(map[uint32][]currentVariousBuffRoll),
|
||
ByBuff: make(map[int64]currentVariousBuffEffect),
|
||
}
|
||
stackableText, err := source.ReadText(currentVariousBuffStackableList)
|
||
if err != nil {
|
||
return tables, fmt.Errorf("read %s: %w", currentVariousBuffStackableList, err)
|
||
}
|
||
stackableDoc, err := dnfpvf.Parse(currentVariousBuffStackableList, stackableText)
|
||
if err != nil {
|
||
return tables, fmt.Errorf("parse %s: %w", currentVariousBuffStackableList, err)
|
||
}
|
||
buffText, err := source.ReadText(currentVariousBuffList)
|
||
if err != nil {
|
||
return tables, fmt.Errorf("read %s: %w", currentVariousBuffList, err)
|
||
}
|
||
buffDoc, err := dnfpvf.Parse(currentVariousBuffList, buffText)
|
||
if err != nil {
|
||
return tables, fmt.Errorf("parse %s: %w", currentVariousBuffList, err)
|
||
}
|
||
tables.ByItem = parseCurrentVariousBuffStackables(stackableDoc)
|
||
tables.ByBuff = parseCurrentVariousBuffEffects(buffDoc)
|
||
return tables, nil
|
||
}
|
||
|
||
// indexBlocks 把 token 流按 `[index] ... [/index]` 切段,返回每段的 token 窗口。
|
||
func indexBlocks(document *dnfpvf.Document) [][]dnfpvf.Token {
|
||
if document == nil {
|
||
return nil
|
||
}
|
||
tokens := document.Tokens
|
||
blocks := make([][]dnfpvf.Token, 0, 64)
|
||
for i := 0; i < len(tokens); i++ {
|
||
if !isPVFSectionToken(tokens[i], "index") {
|
||
continue
|
||
}
|
||
for j := i + 1; j < len(tokens); j++ {
|
||
if isPVFSectionToken(tokens[j], "/index") {
|
||
blocks = append(blocks, tokens[i+1:j])
|
||
i = j
|
||
break
|
||
}
|
||
if isPVFSectionToken(tokens[j], "index") {
|
||
i = j - 1
|
||
break
|
||
}
|
||
if j == len(tokens)-1 {
|
||
blocks = append(blocks, tokens[i+1:])
|
||
i = j
|
||
}
|
||
}
|
||
}
|
||
return blocks
|
||
}
|
||
|
||
// parseCurrentVariousBuffStackables 解 [index] <道具> ([buff list]|[probability buff list]) ...
|
||
//
|
||
// 两种段名都要认:
|
||
// - `[buff list]` —— 确定性,一个 buff id
|
||
// - `[probability buff list]` —— (buff id, 权重) 成对出现,服务端按权重掷点
|
||
func parseCurrentVariousBuffStackables(document *dnfpvf.Document) map[uint32][]currentVariousBuffRoll {
|
||
out := make(map[uint32][]currentVariousBuffRoll)
|
||
for _, block := range indexBlocks(document) {
|
||
itemID := int64(0)
|
||
rolls := make([]currentVariousBuffRoll, 0, 2)
|
||
section := ""
|
||
ints := make([]int64, 0, 6)
|
||
flush := func() {
|
||
switch section {
|
||
case "buff list":
|
||
for _, value := range ints {
|
||
rolls = append(rolls, currentVariousBuffRoll{BuffID: value, Weight: 100})
|
||
}
|
||
case "probability buff list":
|
||
if len(ints)%2 == 0 {
|
||
for k := 0; k+1 < len(ints); k += 2 {
|
||
rolls = append(rolls, currentVariousBuffRoll{BuffID: ints[k], Weight: ints[k+1]})
|
||
}
|
||
} else {
|
||
// 退化写法:只有 buff id,没有权重 ⇒ 每项等权。
|
||
for _, value := range ints {
|
||
rolls = append(rolls, currentVariousBuffRoll{BuffID: value, Weight: 100})
|
||
}
|
||
}
|
||
}
|
||
ints = ints[:0]
|
||
}
|
||
for _, token := range block {
|
||
if token.Kind == dnfpvf.TokenSection {
|
||
flush()
|
||
section = strings.ToLower(strings.TrimSpace(token.Value))
|
||
continue
|
||
}
|
||
if token.Kind != dnfpvf.TokenInt {
|
||
continue
|
||
}
|
||
if itemID == 0 && section == "" {
|
||
itemID = token.Int
|
||
continue
|
||
}
|
||
if section == "buff list" || section == "probability buff list" {
|
||
ints = append(ints, token.Int)
|
||
}
|
||
}
|
||
flush()
|
||
if itemID <= 0 || itemID > math.MaxUint32 || len(rolls) == 0 {
|
||
continue
|
||
}
|
||
out[uint32(itemID)] = rolls
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parseCurrentVariousBuffEffects 解 [index] <buff id> 下的图标/说明/效果/数值。
|
||
func parseCurrentVariousBuffEffects(document *dnfpvf.Document) map[int64]currentVariousBuffEffect {
|
||
out := make(map[int64]currentVariousBuffEffect)
|
||
for _, block := range indexBlocks(document) {
|
||
effect := currentVariousBuffEffect{}
|
||
section := ""
|
||
expectName := false
|
||
inEffect := false
|
||
inParam := false
|
||
for _, token := range block {
|
||
if token.Kind == dnfpvf.TokenSection {
|
||
name := strings.ToLower(strings.TrimSpace(token.Value))
|
||
section = name
|
||
inParam = name == "param"
|
||
if name == "effect" {
|
||
inEffect = true
|
||
}
|
||
if name == "/effect" {
|
||
inEffect = false
|
||
}
|
||
if name == "name" {
|
||
expectName = true
|
||
}
|
||
continue
|
||
}
|
||
switch {
|
||
case effect.BuffID == 0 && token.Kind == dnfpvf.TokenInt:
|
||
effect.BuffID = token.Int
|
||
case section == "system group" && token.Kind == dnfpvf.TokenString:
|
||
effect.SystemGroup = cleanCurrentPVFText(token.Value)
|
||
case section == "icon index" && token.Kind == dnfpvf.TokenInt:
|
||
effect.IconIndex = token.Int
|
||
case section == "tooltip text" && token.Kind == dnfpvf.TokenString:
|
||
effect.Tooltip = cleanCurrentPVFText(token.Value)
|
||
case expectName && token.Kind == dnfpvf.TokenString:
|
||
effect.EffectName = cleanCurrentPVFText(token.Value)
|
||
expectName = false
|
||
case inEffect && inParam && token.Kind == dnfpvf.TokenInt:
|
||
effect.EffectParams = append(effect.EffectParams, token.Int)
|
||
case section == "apply type" && token.Kind == dnfpvf.TokenString && effect.ApplyType == "":
|
||
// [apply type] 段里第一个字符串就是 [type] 的值(`expire_time` 等)。
|
||
value := cleanCurrentPVFText(token.Value)
|
||
if !strings.EqualFold(value, "type") {
|
||
effect.ApplyType = value
|
||
}
|
||
}
|
||
}
|
||
if effect.BuffID <= 0 {
|
||
continue
|
||
}
|
||
out[effect.BuffID] = effect
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cleanCurrentPVFText(value string) string {
|
||
return strings.TrimSpace(strings.Trim(value, "`"))
|
||
}
|
||
|
||
// dispatchBonusPercent 把 buff 的效果翻译成"数值":只有当效果名就是 wantEffect 时才算数,
|
||
// param 缺省或非正数按"不认"处理(= 服务端不改任何东西),绝不猜。
|
||
func (effect currentVariousBuffEffect) dispatchBonusPercent(wantEffect string) (int64, bool) {
|
||
if !strings.EqualFold(strings.TrimSpace(effect.EffectName), wantEffect) {
|
||
return 0, false
|
||
}
|
||
if len(effect.EffectParams) == 0 || effect.EffectParams[0] <= 0 {
|
||
return 0, false
|
||
}
|
||
return effect.EffectParams[0], true
|
||
}
|
||
|
||
// dropRateBonusPercent 把 buff 的效果翻译成"掉落率增加百分比"。
|
||
//
|
||
// 只有 `epic_rarity_item_drop_rate` 这一种效果被认;param 缺省或非正数按"不认"处理
|
||
// (= 服务端不改掉落),绝不猜。
|
||
func (effect currentVariousBuffEffect) dropRateBonusPercent() (int64, bool) {
|
||
return effect.dispatchBonusPercent(currentEpicRarityDropRateEffect)
|
||
}
|
||
|
||
// upgradeSuccessBonusPercent 把 buff 的效果翻译成"强化/增幅成功率增加百分点"。
|
||
//
|
||
// 只认 `item_usual_upgrade_success_rate`;param 缺省或非正数按"不认"处理。
|
||
// 强化与增幅在 PVF 里共用这一个效果名(`[item upgrad buff usual upgrade]` /
|
||
// `[item upgrad buff amplify upgrade]` 是**道具侧**的 stackable type,不是 buff 效果名),
|
||
// 所以"加的是强化还是增幅"由道具的 stackable type 决定,见
|
||
// stackable_use_pvf.go 的 currentUpgradeRatePotionStackableKind。
|
||
func (effect currentVariousBuffEffect) upgradeSuccessBonusPercent() (int64, bool) {
|
||
return effect.dispatchBonusPercent(currentUpgradeSuccessRateEffect)
|
||
}
|
||
|
||
// dungeonClearExpBonusPercent 把 buff 的效果翻译成"地下城通关经验 +N%"。
|
||
func (effect currentVariousBuffEffect) dungeonClearExpBonusPercent() (int64, bool) {
|
||
return effect.dispatchBonusPercent(currentDungeonClearExpRateEffect)
|
||
}
|
||
|
||
// serverSideStatChangeEffects 是"服务端必须自己算"的效果名。其余效果(城镇移速、
|
||
// 进图属性…)由客户端按同一份 buff 表自己施加,服务端只负责把掷到的 buff 注册过去。
|
||
func serverSideStatChangeEffect(effectName string) bool {
|
||
for _, want := range []string{
|
||
currentEpicRarityDropRateEffect,
|
||
currentUpgradeSuccessRateEffect,
|
||
currentDungeonClearExpRateEffect,
|
||
} {
|
||
if strings.EqualFold(strings.TrimSpace(effectName), want) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// clientBuffFamilies 列出本表里"客户端自施放"的那些 family(buff effect name)。
|
||
//
|
||
// 用途:登录/重登时要逐个 family 去看角色身上有没有仍然生效的窗口(各族互不覆盖、
|
||
// 各按自己的到期时间结束),所以需要一份可枚举的清单。
|
||
func (tables currentVariousBuffTables) clientBuffFamilies() []string {
|
||
seen := make(map[string]struct{}, 4)
|
||
for _, rolls := range tables.ByItem {
|
||
for _, roll := range rolls {
|
||
effect, found := tables.ByBuff[roll.BuffID]
|
||
if !found {
|
||
continue
|
||
}
|
||
name := strings.ToLower(strings.TrimSpace(effect.EffectName))
|
||
if name == "" || serverSideStatChangeEffect(name) {
|
||
continue
|
||
}
|
||
seen[name] = struct{}{}
|
||
}
|
||
}
|
||
out := make([]string, 0, len(seen))
|
||
for name := range seen {
|
||
out = append(out, name)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// statChangePotionGrantableBuffIDs 列出这一族药剂**可能发出去的**全部 buff id
|
||
// (= etc/variousbuffstackablelist.etc 里出现过的所有 buff)。
|
||
//
|
||
// 用于"窗口全过期时把残留图标一次清干净":两族(服务端算的 + 客户端自施放的)都覆盖,
|
||
// 且只覆盖这一族真会发的 buff,不会误伤别的系统。
|
||
func (tables currentVariousBuffTables) statChangePotionGrantableBuffIDs() []int32 {
|
||
seen := make(map[int64]struct{}, 16)
|
||
for _, rolls := range tables.ByItem {
|
||
for _, roll := range rolls {
|
||
if roll.BuffID > 0 && roll.BuffID <= math.MaxInt32 {
|
||
seen[roll.BuffID] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
out := make([]int32, 0, len(seen))
|
||
for buffID := range seen {
|
||
out = append(out, int32(buffID))
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||
return out
|
||
}
|
||
|
||
// 服务端要算的三种效果之外,剩下的都归到 family(= buff 的 effect name)下。
|
||
//
|
||
// 一件道具只会属于一个 family(赛丽亚那五件各自一族),所以这里取第一组有效候选即可;
|
||
// 若真的出现同道具多族,只认第一个有效 family,宁可少认也不混族。
|
||
func (tables currentVariousBuffTables) clientBuffRollsForItem(itemID int64) (string, []currentVariousBuffRoll) {
|
||
if itemID <= 0 || itemID > math.MaxUint32 {
|
||
return "", nil
|
||
}
|
||
rolls := tables.ByItem[uint32(itemID)]
|
||
if len(rolls) == 0 {
|
||
return "", nil
|
||
}
|
||
out := make([]currentVariousBuffRoll, 0, len(rolls))
|
||
family := ""
|
||
for _, roll := range rolls {
|
||
if roll.BuffID <= 0 || roll.Weight <= 0 {
|
||
continue
|
||
}
|
||
effect, found := tables.ByBuff[roll.BuffID]
|
||
if !found {
|
||
continue
|
||
}
|
||
name := strings.ToLower(strings.TrimSpace(effect.EffectName))
|
||
if name == "" || serverSideStatChangeEffect(name) {
|
||
continue
|
||
}
|
||
if family == "" {
|
||
family = name
|
||
}
|
||
if family != name {
|
||
continue
|
||
}
|
||
bonus := int64(0)
|
||
if len(effect.EffectParams) > 0 {
|
||
bonus = effect.EffectParams[0]
|
||
}
|
||
out = append(out, currentVariousBuffRoll{
|
||
BuffID: roll.BuffID,
|
||
Weight: roll.Weight,
|
||
BonusPercent: bonus,
|
||
})
|
||
}
|
||
if family == "" || len(out) == 0 {
|
||
return "", nil
|
||
}
|
||
return family, out
|
||
}
|
||
|
||
// rollsForItem 返回某道具在**指定效果**上的候选(权重已归一到正数)。
|
||
//
|
||
// 一件道具的概率表里可能混着不同效果(例如掉落率 buff 与强化成功率 buff),
|
||
// 所以按效果名过滤,而不是"返回全部"。
|
||
func (tables currentVariousBuffTables) rollsForItem(itemID int64, wantEffect string) []currentVariousBuffRoll {
|
||
if itemID <= 0 || itemID > math.MaxUint32 {
|
||
return nil
|
||
}
|
||
rolls := tables.ByItem[uint32(itemID)]
|
||
if len(rolls) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]currentVariousBuffRoll, 0, len(rolls))
|
||
for _, roll := range rolls {
|
||
if roll.BuffID <= 0 || roll.Weight <= 0 {
|
||
continue
|
||
}
|
||
effect, found := tables.ByBuff[roll.BuffID]
|
||
if !found {
|
||
continue
|
||
}
|
||
if _, ok := effect.dispatchBonusPercent(wantEffect); !ok {
|
||
continue
|
||
}
|
||
out = append(out, roll)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// epicDropRateRollsForItem 返回某道具的"史诗掉落率"候选。
|
||
func (tables currentVariousBuffTables) epicDropRateRollsForItem(itemID int64) []currentVariousBuffRoll {
|
||
return tables.rollsForItem(itemID, currentEpicRarityDropRateEffect)
|
||
}
|
||
|
||
// currentVariousBuffTables 惰性读两张 buff 表。
|
||
//
|
||
// 读不到(合成测试源、PVF 缺表)返回 ok=false,调用方回退到 `[explain]` 文字判据 ——
|
||
// 这正是 490000413 这种"表里没有条目"的老活动券所依赖的路径。
|
||
func (c *pvfDungeonDropCatalog) currentVariousBuffTables() (currentVariousBuffTables, bool) {
|
||
if c == nil || c.source == nil {
|
||
return currentVariousBuffTables{}, false
|
||
}
|
||
c.variousBuffOnce.Do(func() {
|
||
c.variousBuffs, c.variousBuffErr = loadCurrentVariousBuffTables(c.source)
|
||
})
|
||
if c.variousBuffErr != nil {
|
||
return currentVariousBuffTables{}, false
|
||
}
|
||
if len(c.variousBuffs.ByItem) == 0 && len(c.variousBuffs.ByBuff) == 0 {
|
||
return currentVariousBuffTables{}, false
|
||
}
|
||
return c.variousBuffs, true
|
||
}
|