服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
365 lines
15 KiB
Go
365 lines
15 KiB
Go
// Package statbuff 保存"城镇增益药剂"落下的**角色级限时效果**。
|
||
//
|
||
// 第一个(也是目前唯一一个)消费者是深渊派对的史诗掉落率:
|
||
//
|
||
// PVF `[stackable type] [expert town potion]` + 没有 `[action type]` +
|
||
// `[stat change duration] 600000 myself` 的一族药剂(史诗Buff药剂 / 欧皇药水 /
|
||
// 史派克的欧气药水 / 小灯笼的大瓶·小瓶史诗Buff药剂 …)在客户端里是**自施放增益**:
|
||
// 客户端自己画 buff 图标,而"深渊派对史诗掉落率"是**服务端**才知道的量,
|
||
// 所以服务端必须自己记住这个窗口,否则玩家看到的是"药吃了、掉落没变"。
|
||
//
|
||
// 现场 2026-10-03(服主报,角色 测试x = char 11):史诗Buff药剂 490002458 连点 15 次
|
||
// 全部被 op44 静默拒绝(error 17),连"吃掉了"这一步都没发生;本包负责第二步。
|
||
//
|
||
// 存储位置:角色记录的 Stats(与 dnfepicwish 的 [wish list]、fatigue 等同一张表)。
|
||
// 选它而不是账号元数据的原因很实际:深渊掉落那条路径手上只有 `runtimeDungeonState.Character`
|
||
// 这个角色快照,用 Stats 就能直接读到,不必在掉落热路径上多读一次账号。
|
||
//
|
||
// 覆盖语义:**后来者覆盖**,绝不叠乘 —— 与道具说明的"效果不与其他史诗Buff药剂叠加"
|
||
// 一致;同族药剂的 10 分钟共享冷却(`[cooltime group] 251`)本身也挡住了连续使用。
|
||
package statbuff
|
||
|
||
import (
|
||
"errors"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
const (
|
||
// EpicDropRatePercentStatKey 是掉落率倍率(百分比,200 = 2 倍)。
|
||
EpicDropRatePercentStatKey = "epic_drop_rate_buff_percent"
|
||
// EpicDropRateExpireStatKey 是窗口到期的 Unix 秒。
|
||
EpicDropRateExpireStatKey = "epic_drop_rate_buff_expire_at"
|
||
// EpicDropRateItemStatKey 是施加它的道具 id(日志与排查用)。
|
||
EpicDropRateItemStatKey = "epic_drop_rate_buff_item_id"
|
||
// EpicDropRateBuffIDStatKey 是掷点得到的 buff id(etc/variousbufflist.etc 的
|
||
// [index];1136=史诗Buff药剂、1090/1091/1092=赛丽亚三档、1239/1240=小灯笼大/小瓶)。
|
||
// 客户端画图标就是按这个 id 去取 [icon index] + [tooltip text],所以服务端把它
|
||
// 一起记下来:投影和排查都不用再猜。
|
||
EpicDropRateBuffIDStatKey = "epic_drop_rate_buff_id"
|
||
)
|
||
|
||
var ErrEffectInvalid = errors.New("stat buff effect is invalid")
|
||
|
||
// Effect 是一个正在生效的掉落率窗口。
|
||
type Effect struct {
|
||
ItemID int64
|
||
BuffID int64
|
||
MultiplierPercent int64
|
||
ExpireAt time.Time
|
||
}
|
||
|
||
// ActiveEpicDropRate 读角色身上**当前有效**的掉落率增益。
|
||
//
|
||
// 任何一个字段缺失/非法、或者已经过期,都按"没有增益"处理(返回 false),
|
||
// 绝不 fail-open:宁可少给一次加成,也不能让一个过期的记录永久加掉落。
|
||
func ActiveEpicDropRate(character dnfrepo.CharacterRecord, now time.Time) (Effect, bool) {
|
||
if character.Stats == nil {
|
||
return Effect{}, false
|
||
}
|
||
percent, percentErr := strconv.ParseInt(strings.TrimSpace(statValue(character, EpicDropRatePercentStatKey)), 10, 64)
|
||
expireUnix, expireErr := strconv.ParseInt(strings.TrimSpace(statValue(character, EpicDropRateExpireStatKey)), 10, 64)
|
||
if percentErr != nil || expireErr != nil || percent <= 0 || expireUnix <= 0 {
|
||
return Effect{}, false
|
||
}
|
||
expireAt := time.Unix(expireUnix, 0).UTC()
|
||
if !expireAt.After(now.UTC()) {
|
||
return Effect{}, false
|
||
}
|
||
itemID, _ := strconv.ParseInt(strings.TrimSpace(statValue(character, EpicDropRateItemStatKey)), 10, 64)
|
||
buffID, _ := strconv.ParseInt(strings.TrimSpace(statValue(character, EpicDropRateBuffIDStatKey)), 10, 64)
|
||
return Effect{ItemID: itemID, BuffID: buffID, MultiplierPercent: percent, ExpireAt: expireAt}, true
|
||
}
|
||
|
||
// ActivateEpicDropRate 写入(或覆盖)掉落率窗口。
|
||
//
|
||
// buffID 是掷点得到的 buff id(0 = 走 `[explain]` 回退路径,没有 buff id)。
|
||
func ActivateEpicDropRate(
|
||
character *dnfrepo.CharacterRecord,
|
||
itemID int64,
|
||
buffID int64,
|
||
multiplierPercent int64,
|
||
duration time.Duration,
|
||
now time.Time,
|
||
) (Effect, error) {
|
||
if character == nil || itemID <= 0 || multiplierPercent <= 0 || duration <= 0 {
|
||
return Effect{}, ErrEffectInvalid
|
||
}
|
||
now = now.UTC()
|
||
if character.Stats == nil {
|
||
character.Stats = make(map[string]int64, 4)
|
||
}
|
||
effect := Effect{
|
||
ItemID: itemID,
|
||
BuffID: buffID,
|
||
MultiplierPercent: multiplierPercent,
|
||
ExpireAt: now.Add(duration).UTC(),
|
||
}
|
||
character.Stats[EpicDropRatePercentStatKey] = effect.MultiplierPercent
|
||
character.Stats[EpicDropRateExpireStatKey] = effect.ExpireAt.Unix()
|
||
character.Stats[EpicDropRateItemStatKey] = effect.ItemID
|
||
character.Stats[EpicDropRateBuffIDStatKey] = effect.BuffID
|
||
character.UpdatedAt = now
|
||
return effect, nil
|
||
}
|
||
|
||
func statValue(character dnfrepo.CharacterRecord, key string) string {
|
||
if character.Stats == nil {
|
||
return ""
|
||
}
|
||
return strconv.FormatInt(character.Stats[key], 10)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 第二类消费者:强化 / 增幅成功率药剂(「强化秘药」「强化的奥秘」「永恒的增幅的秘密」
|
||
// 「赛丽亚的幸运强化药剂」…)。
|
||
//
|
||
// 套路与爆率药剂**完全一样**(服主原话:"套路都是一样的"):
|
||
//
|
||
// PVF 侧:etc/variousbufflist.etc 的 `[effect][name] item_usual_upgrade_success_rate`
|
||
// 加上 `[param] N` —— N 就是"成功率增加多少个百分点":
|
||
// 1097=10(下级)/1098=20(中级)/1099=30(上级),1180..1187=1..8(活动)
|
||
// 道具→buff 的掷点写在 etc/variousbuffstackablelist.etc
|
||
// (赛丽亚的幸运强化药剂 10008220 → `1097 60 1098 30 1099 10`)
|
||
// 服务端侧:把窗口记在角色记录里,在**掷点之前**把成功率按 N 放大。
|
||
//
|
||
// 强化与增幅各用一套键:它们是两件不同的道具、也该各自生效(不像爆率那样共享一族)。
|
||
// 语义与爆率窗口一致:后来者覆盖、绝不叠加;任何字段缺失/非法/过期都按"没有增益"处理。
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Kind 是限时道具增益的种类。
|
||
type Kind string
|
||
|
||
const (
|
||
// KindUpgradeSuccess 是"强化成功率 +N%"。
|
||
KindUpgradeSuccess Kind = "upgrade_success"
|
||
// KindAmplifySuccess 是"增幅成功率 +N%"。
|
||
KindAmplifySuccess Kind = "amplify_success"
|
||
// KindDungeonClearExp 是"地下城通关经验值 +N%"(PVF 效果名 `dungeon_clear_exp_rate`;
|
||
// 赛丽亚的幸运经验药剂 10008222 的三档是 50/100/200)。这一条**必须服务端算**:
|
||
// 通关经验是服务端发的,客户端算不出来。
|
||
KindDungeonClearExp Kind = "dungeon_clear_exp_rate"
|
||
)
|
||
|
||
// RateWindow 是一个正在生效的"成功率 +N%"窗口。
|
||
type RateWindow struct {
|
||
ItemID int64
|
||
BuffID int64
|
||
Percent int64
|
||
ExpireAt time.Time
|
||
}
|
||
|
||
// KindForUpgradeMode 把 alignedcmd 里的 UpgradeKind("reinforce"/"amplify",来自 PVF 的
|
||
// `[stackable type]`)翻译成窗口种类。
|
||
func KindForUpgradeMode(mode string) (Kind, bool) {
|
||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||
case "reinforce":
|
||
return KindUpgradeSuccess, true
|
||
case "amplify":
|
||
return KindAmplifySuccess, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func rateWindowKeys(kind Kind) (percentKey, expireKey, itemKey, buffKey string, ok bool) {
|
||
switch kind {
|
||
case KindUpgradeSuccess:
|
||
return "upgrade_success_rate_buff_percent",
|
||
"upgrade_success_rate_buff_expire_at",
|
||
"upgrade_success_rate_buff_item_id",
|
||
"upgrade_success_rate_buff_id", true
|
||
case KindAmplifySuccess:
|
||
return "amplify_success_rate_buff_percent",
|
||
"amplify_success_rate_buff_expire_at",
|
||
"amplify_success_rate_buff_item_id",
|
||
"amplify_success_rate_buff_id", true
|
||
case KindDungeonClearExp:
|
||
return "dungeon_clear_exp_rate_buff_percent",
|
||
"dungeon_clear_exp_rate_buff_expire_at",
|
||
"dungeon_clear_exp_rate_buff_item_id",
|
||
"dungeon_clear_exp_rate_buff_id", true
|
||
case KindClientBuff:
|
||
// 客户端自施放的那几族(城镇移速、进图属性…)按 family 各自一套键,
|
||
// 见下面 ActiveClientBuff/ActivateClientBuff。
|
||
return "", "", "", "", false
|
||
}
|
||
return "", "", "", "", false
|
||
}
|
||
|
||
// KindClientBuff 是"客户端自己生效"的一族:服务端只负责把掷到的 buff 注册给客户端
|
||
// (图标 + 客户端按 buff 表施加效果),所以键位按 buff 的 effect name(family)分开。
|
||
const KindClientBuff Kind = "client_buff"
|
||
|
||
// ClientBuffWindow 是"客户端自施放 buff"的窗口记录。Percent 只用于日志/排查:
|
||
// 真正生效的数值由客户端从 etc/variousbufflist.etc 自己读(它认得这个 buff id)。
|
||
type ClientBuffWindow struct {
|
||
Family string
|
||
ItemID int64
|
||
BuffID int64
|
||
Percent int64
|
||
ExpireAt time.Time
|
||
}
|
||
|
||
func clientBuffKeys(family string) (percentKey, expireKey, itemKey, buffKey string, ok bool) {
|
||
family = strings.ToLower(strings.TrimSpace(family))
|
||
if family == "" {
|
||
return "", "", "", "", false
|
||
}
|
||
// effect name 本身只含 ASCII 字母/下划线,直接做键前缀即可(可读、可直接查库)。
|
||
base := family + "_client_buff"
|
||
return base + "_percent", base + "_expire_at", base + "_item_id", base + "_buff_id", true
|
||
}
|
||
|
||
// ActiveClientBuff 读某个 family 的客户端 buff 窗口(fail-closed 同其它窗口)。
|
||
func ActiveClientBuff(character dnfrepo.CharacterRecord, family string, now time.Time) (ClientBuffWindow, bool) {
|
||
percentKey, expireKey, itemKey, buffKey, ok := clientBuffKeys(family)
|
||
if !ok || character.Stats == nil {
|
||
return ClientBuffWindow{}, false
|
||
}
|
||
expireUnix, expireErr := strconv.ParseInt(strings.TrimSpace(statValue(character, expireKey)), 10, 64)
|
||
if expireErr != nil || expireUnix <= 0 {
|
||
return ClientBuffWindow{}, false
|
||
}
|
||
expireAt := time.Unix(expireUnix, 0).UTC()
|
||
if !expireAt.After(now.UTC()) {
|
||
return ClientBuffWindow{}, false
|
||
}
|
||
buffID, _ := strconv.ParseInt(strings.TrimSpace(statValue(character, buffKey)), 10, 64)
|
||
if buffID <= 0 {
|
||
// 没有 buff id 就没有可注册的东西,按"没有窗口"处理。
|
||
return ClientBuffWindow{}, false
|
||
}
|
||
itemID, _ := strconv.ParseInt(strings.TrimSpace(statValue(character, itemKey)), 10, 64)
|
||
percent, _ := strconv.ParseInt(strings.TrimSpace(statValue(character, percentKey)), 10, 64)
|
||
return ClientBuffWindow{
|
||
Family: strings.ToLower(strings.TrimSpace(family)),
|
||
ItemID: itemID,
|
||
BuffID: buffID,
|
||
Percent: percent,
|
||
ExpireAt: expireAt,
|
||
}, true
|
||
}
|
||
|
||
// ActivateClientBuff 写入(或覆盖)某个 family 的客户端 buff 窗口。
|
||
func ActivateClientBuff(
|
||
character *dnfrepo.CharacterRecord,
|
||
family string,
|
||
itemID int64,
|
||
buffID int64,
|
||
percent int64,
|
||
duration time.Duration,
|
||
now time.Time,
|
||
) (ClientBuffWindow, error) {
|
||
percentKey, expireKey, itemKey, buffKey, ok := clientBuffKeys(family)
|
||
if !ok || character == nil || itemID <= 0 || buffID <= 0 || duration <= 0 {
|
||
return ClientBuffWindow{}, ErrEffectInvalid
|
||
}
|
||
now = now.UTC()
|
||
if character.Stats == nil {
|
||
character.Stats = make(map[string]int64, 4)
|
||
}
|
||
window := ClientBuffWindow{
|
||
Family: strings.ToLower(strings.TrimSpace(family)),
|
||
ItemID: itemID,
|
||
BuffID: buffID,
|
||
Percent: percent,
|
||
ExpireAt: now.Add(duration).UTC(),
|
||
}
|
||
character.Stats[percentKey] = window.Percent
|
||
character.Stats[expireKey] = window.ExpireAt.Unix()
|
||
character.Stats[itemKey] = window.ItemID
|
||
character.Stats[buffKey] = window.BuffID
|
||
character.UpdatedAt = now
|
||
return window, nil
|
||
}
|
||
|
||
// RawRateWindowKeys 返回某个族**未经有效性判断**的 (到期 Unix 秒, buff id)。
|
||
//
|
||
// 与 ActiveRate 的区别:ActiveRate 过期就 fail-closed(返回 false),而这里要的正是
|
||
// "已经过期、但记录里还写着 buff id" 的那一条 —— 服务端靠它精确摘掉到点的那几个图标。
|
||
func RawRateWindowKeys(character dnfrepo.CharacterRecord, kind Kind) (int64, int64, bool) {
|
||
_, expireKey, _, buffKey, ok := rateWindowKeys(kind)
|
||
if !ok || character.Stats == nil {
|
||
return 0, 0, false
|
||
}
|
||
expireUnix, expireErr := strconv.ParseInt(strings.TrimSpace(statValue(character, expireKey)), 10, 64)
|
||
buffID, _ := strconv.ParseInt(strings.TrimSpace(statValue(character, buffKey)), 10, 64)
|
||
if expireErr != nil || expireUnix <= 0 {
|
||
return 0, 0, false
|
||
}
|
||
return expireUnix, buffID, true
|
||
}
|
||
|
||
// RawClientBuffKeys 同上,给"客户端自施放"那一族用。
|
||
func RawClientBuffKeys(character dnfrepo.CharacterRecord, family string) (int64, int64, bool) {
|
||
_, expireKey, _, buffKey, ok := clientBuffKeys(family)
|
||
if !ok || character.Stats == nil {
|
||
return 0, 0, false
|
||
}
|
||
expireUnix, expireErr := strconv.ParseInt(strings.TrimSpace(statValue(character, expireKey)), 10, 64)
|
||
buffID, _ := strconv.ParseInt(strings.TrimSpace(statValue(character, buffKey)), 10, 64)
|
||
if expireErr != nil || expireUnix <= 0 {
|
||
return 0, 0, false
|
||
}
|
||
return expireUnix, buffID, true
|
||
}
|
||
|
||
// ActiveRate 读角色身上**当前有效**的成功率增益(fail-closed,与 ActiveEpicDropRate 同)。
|
||
func ActiveRate(character dnfrepo.CharacterRecord, kind Kind, now time.Time) (RateWindow, bool) {
|
||
percentKey, expireKey, itemKey, buffKey, ok := rateWindowKeys(kind)
|
||
if !ok || character.Stats == nil {
|
||
return RateWindow{}, false
|
||
}
|
||
percent, percentErr := strconv.ParseInt(strings.TrimSpace(statValue(character, percentKey)), 10, 64)
|
||
expireUnix, expireErr := strconv.ParseInt(strings.TrimSpace(statValue(character, expireKey)), 10, 64)
|
||
if percentErr != nil || expireErr != nil || percent <= 0 || expireUnix <= 0 {
|
||
return RateWindow{}, false
|
||
}
|
||
expireAt := time.Unix(expireUnix, 0).UTC()
|
||
if !expireAt.After(now.UTC()) {
|
||
return RateWindow{}, false
|
||
}
|
||
itemID, _ := strconv.ParseInt(strings.TrimSpace(statValue(character, itemKey)), 10, 64)
|
||
buffID, _ := strconv.ParseInt(strings.TrimSpace(statValue(character, buffKey)), 10, 64)
|
||
return RateWindow{ItemID: itemID, BuffID: buffID, Percent: percent, ExpireAt: expireAt}, true
|
||
}
|
||
|
||
// ActivateRate 写入(或覆盖)一个成功率窗口。buffID 允许为 0(PVF 无候选表的那几件道具
|
||
// 没有 buff id 可掷,但窗口照样要生效)。
|
||
func ActivateRate(
|
||
character *dnfrepo.CharacterRecord,
|
||
kind Kind,
|
||
itemID int64,
|
||
buffID int64,
|
||
percent int64,
|
||
duration time.Duration,
|
||
now time.Time,
|
||
) (RateWindow, error) {
|
||
percentKey, expireKey, itemKey, buffKey, ok := rateWindowKeys(kind)
|
||
if !ok {
|
||
return RateWindow{}, ErrEffectInvalid
|
||
}
|
||
if character == nil || itemID <= 0 || percent <= 0 || duration <= 0 {
|
||
return RateWindow{}, ErrEffectInvalid
|
||
}
|
||
now = now.UTC()
|
||
if character.Stats == nil {
|
||
character.Stats = make(map[string]int64, 4)
|
||
}
|
||
window := RateWindow{
|
||
ItemID: itemID,
|
||
BuffID: buffID,
|
||
Percent: percent,
|
||
ExpireAt: now.Add(duration).UTC(),
|
||
}
|
||
character.Stats[percentKey] = window.Percent
|
||
character.Stats[expireKey] = window.ExpireAt.Unix()
|
||
character.Stats[itemKey] = window.ItemID
|
||
character.Stats[buffKey] = window.BuffID
|
||
character.UpdatedAt = now
|
||
return window, nil
|
||
}
|