DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,364 @@
// 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
}
@@ -0,0 +1,101 @@
package statbuff
import (
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestActivateEpicDropRateStoresWindowAndExpires(t *testing.T) {
now := time.Date(2026, time.October, 3, 18, 0, 0, 0, time.UTC)
character := dnfrepo.CharacterRecord{CharacterID: "11", Stats: map[string]int64{}}
effect, err := ActivateEpicDropRate(&character, 490002458, 1136, 200, 10*time.Minute, now)
if err != nil {
t.Fatal(err)
}
if effect.MultiplierPercent != 200 || effect.ItemID != 490002458 ||
!effect.ExpireAt.Equal(now.Add(10*time.Minute)) {
t.Fatalf("effect=%+v", effect)
}
if character.Stats[EpicDropRatePercentStatKey] != 200 ||
character.Stats[EpicDropRateItemStatKey] != 490002458 {
t.Fatalf("stats=%v", character.Stats)
}
active, ok := ActiveEpicDropRate(character, now.Add(9*time.Minute))
if !ok || active.MultiplierPercent != 200 || active.ItemID != 490002458 {
t.Fatalf("within window active=%t effect=%+v", ok, active)
}
// 边界:正好到期不算有效("持续10分钟"),过期更不算。
if _, ok := ActiveEpicDropRate(character, now.Add(10*time.Minute)); ok {
t.Fatalf("exactly-expired window must not be active")
}
if _, ok := ActiveEpicDropRate(character, now.Add(11*time.Minute)); ok {
t.Fatalf("expired window must not be active")
}
}
func TestActiveEpicDropRateFailsClosedOnMissingOrBrokenState(t *testing.T) {
now := time.Date(2026, time.October, 3, 18, 0, 0, 0, time.UTC)
cases := map[string]dnfrepo.CharacterRecord{
"nil stats": {CharacterID: "11"},
"no percent": {CharacterID: "11", Stats: map[string]int64{EpicDropRateExpireStatKey: now.Add(time.Hour).Unix()}},
"no expire": {CharacterID: "11", Stats: map[string]int64{EpicDropRatePercentStatKey: 200}},
"zero percent": {CharacterID: "11", Stats: map[string]int64{EpicDropRatePercentStatKey: 0, EpicDropRateExpireStatKey: now.Add(time.Hour).Unix()}},
"negative percent": {CharacterID: "11", Stats: map[string]int64{EpicDropRatePercentStatKey: -200, EpicDropRateExpireStatKey: now.Add(time.Hour).Unix()}},
"negative expire": {CharacterID: "11", Stats: map[string]int64{EpicDropRatePercentStatKey: 200, EpicDropRateExpireStatKey: -5}},
"zero expire": {CharacterID: "11", Stats: map[string]int64{EpicDropRatePercentStatKey: 200, EpicDropRateExpireStatKey: 0}},
}
for name, character := range cases {
if _, ok := ActiveEpicDropRate(character, now); ok {
t.Fatalf("%s: must not be active", name)
}
}
}
// TestActivateEpicDropRateOverwritesInsteadOfStacking 锁住"不与其他史诗Buff药剂叠加":
// 第二次施加是**覆盖**,绝不会把倍率乘起来。
func TestActivateEpicDropRateOverwritesInsteadOfStacking(t *testing.T) {
now := time.Date(2026, time.October, 3, 18, 0, 0, 0, time.UTC)
character := dnfrepo.CharacterRecord{CharacterID: "11", Stats: map[string]int64{}}
if _, err := ActivateEpicDropRate(&character, 490002458, 1136, 200, 10*time.Minute, now); err != nil {
t.Fatal(err)
}
later := now.Add(5 * time.Minute)
effect, err := ActivateEpicDropRate(&character, 490006963, 1136, 200, 10*time.Minute, later)
if err != nil {
t.Fatal(err)
}
if effect.MultiplierPercent != 200 {
t.Fatalf("percent=%d, 叠加会得到 400", effect.MultiplierPercent)
}
active, ok := ActiveEpicDropRate(character, later.Add(time.Minute))
if !ok || active.MultiplierPercent != 200 || active.ItemID != 490006963 {
t.Fatalf("active=%t effect=%+v", ok, active)
}
if !active.ExpireAt.Equal(later.Add(10 * time.Minute)) {
t.Fatalf("expire=%s, 覆盖时窗口要按最后一次使用重新计时", active.ExpireAt)
}
}
func TestActivateEpicDropRateRejectsInvalidInput(t *testing.T) {
now := time.Date(2026, time.October, 3, 18, 0, 0, 0, time.UTC)
character := dnfrepo.CharacterRecord{CharacterID: "11"}
for _, tc := range []struct {
name string
target *dnfrepo.CharacterRecord
itemID int64
percent int64
duration time.Duration
}{
{name: "nil character", target: nil, itemID: 1, percent: 200, duration: time.Minute},
{name: "zero item", target: &character, itemID: 0, percent: 200, duration: time.Minute},
{name: "zero percent", target: &character, itemID: 1, percent: 0, duration: time.Minute},
{name: "zero duration", target: &character, itemID: 1, percent: 200},
} {
if _, err := ActivateEpicDropRate(tc.target, tc.itemID, 0, tc.percent, tc.duration, now); err == nil {
t.Fatalf("%s: expected rejection", tc.name)
}
}
}
@@ -0,0 +1,95 @@
package statbuff
import (
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 强化/增幅成功率窗口:与爆率窗口同一套 fail-closed 语义,但**按种类分开存**
// (强化与增幅是两件不同的道具,各自生效、互不覆盖)。
func TestRateWindowsAreKindScopedAndFailClosed(t *testing.T) {
now := time.Date(2026, 10, 3, 14, 0, 0, 0, time.UTC)
character := dnfrepo.CharacterRecord{CharacterID: "11"}
if _, ok := ActiveRate(character, KindUpgradeSuccess, now); ok {
t.Fatal("an empty character must not report an active upgrade window")
}
if _, ok := ActiveRate(character, Kind("unknown"), now); ok {
t.Fatal("an unknown kind must not report an active window")
}
upgrade, err := ActivateRate(&character, KindUpgradeSuccess, 10008220, 1099, 30, 10*time.Minute, now)
if err != nil {
t.Fatal(err)
}
amplify, err := ActivateRate(&character, KindAmplifySuccess, 490001534, 0, 10, 30*time.Minute, now)
if err != nil {
t.Fatal(err)
}
if upgrade.Percent != 30 || upgrade.BuffID != 1099 || !upgrade.ExpireAt.Equal(now.Add(10*time.Minute)) {
t.Fatalf("upgrade window=%+v", upgrade)
}
if amplify.Percent != 10 || amplify.BuffID != 0 || !amplify.ExpireAt.Equal(now.Add(30*time.Minute)) {
t.Fatalf("amplify window=%+v", amplify)
}
// 两族互不覆盖:读强化拿到的必须是 30/1099,读增幅拿到的是 10/0。
gotUpgrade, ok := ActiveRate(character, KindUpgradeSuccess, now.Add(time.Minute))
if !ok || gotUpgrade.Percent != 30 || gotUpgrade.BuffID != 1099 || gotUpgrade.ItemID != 10008220 {
t.Fatalf("upgrade read=%+v ok=%t", gotUpgrade, ok)
}
gotAmplify, ok := ActiveRate(character, KindAmplifySuccess, now.Add(time.Minute))
if !ok || gotAmplify.Percent != 10 || gotAmplify.ItemID != 490001534 {
t.Fatalf("amplify read=%+v ok=%t", gotAmplify, ok)
}
// 过期即视为没有(fail-closed),另一种类不受影响。
if _, ok := ActiveRate(character, KindUpgradeSuccess, now.Add(11*time.Minute)); ok {
t.Fatal("an expired upgrade window must not stay active")
}
if _, ok := ActiveRate(character, KindAmplifySuccess, now.Add(11*time.Minute)); !ok {
t.Fatal("the amplify window must survive the upgrade window expiring")
}
// 字段缺失/非法同样按"没有增益"处理,绝不 fail-open。
broken := dnfrepo.CharacterRecord{CharacterID: "11", Stats: map[string]int64{
"upgrade_success_rate_buff_percent": 10, // 没有到期时间
}}
if _, ok := ActiveRate(broken, KindUpgradeSuccess, now); ok {
t.Fatal("a window without an expiry must not be active")
}
negative := dnfrepo.CharacterRecord{CharacterID: "11", Stats: map[string]int64{
"amplify_success_rate_buff_percent": -5,
"amplify_success_rate_buff_expire_at": now.Add(time.Hour).Unix(),
}}
if _, ok := ActiveRate(negative, KindAmplifySuccess, now); ok {
t.Fatal("a non-positive bonus must not be active")
}
if _, err := ActivateRate(&character, KindUpgradeSuccess, 0, 0, 10, time.Minute, now); err == nil {
t.Fatal("activating with a zero item id must fail")
}
if _, err := ActivateRate(&character, KindUpgradeSuccess, 1, 0, 10, 0, now); err == nil {
t.Fatal("activating with a zero duration must fail")
}
}
func TestKindForUpgradeMode(t *testing.T) {
for _, tc := range []struct {
mode string
want Kind
ok bool
}{
{mode: "reinforce", want: KindUpgradeSuccess, ok: true},
{mode: "amplify", want: KindAmplifySuccess, ok: true},
{mode: " Reinforce ", want: KindUpgradeSuccess, ok: true},
{mode: "", ok: false},
{mode: "normal", ok: false},
} {
got, ok := KindForUpgradeMode(tc.mode)
if ok != tc.ok || got != tc.want {
t.Fatalf("KindForUpgradeMode(%q)=%q,%t want %q,%t", tc.mode, got, ok, tc.want, tc.ok)
}
}
}