DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,262 @@
|
||||
// 本文件拥有限购计数的读写与消费。计数按“归属者 × 周期 × 道具”一条,落点和
|
||||
// 本服其它限次系统一致:
|
||||
//
|
||||
// - character 维度 → 角色记录的 Stats(int64 map)
|
||||
// - account 维度 → 账号记录的 Metadata(string map)
|
||||
//
|
||||
// 每条计数配一个窗口起点:窗口变了就等于 0,不需要定时清零任务(与
|
||||
// dungeonadmission/epic_production 的做法一致)。
|
||||
package purchaselimit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
countKeyPrefix = "purchase_limit_count_"
|
||||
windowKeyPrefix = "purchase_limit_window_"
|
||||
)
|
||||
|
||||
// CountKey 返回一条计数在 Stats/Metadata 里的键。作用域由容器本身决定,因此
|
||||
// 键里只带命名空间、道具与周期。
|
||||
func CountKey(p Policy) string {
|
||||
return countKeyPrefix + p.KeyTag + strconv.FormatUint(uint64(p.ItemID), 10) + "_" + p.Period.String()
|
||||
}
|
||||
|
||||
// WindowKey 返回该计数窗口起点的键。
|
||||
func WindowKey(p Policy) string {
|
||||
return windowKeyPrefix + p.KeyTag + strconv.FormatUint(uint64(p.ItemID), 10) + "_" + p.Period.String()
|
||||
}
|
||||
|
||||
// LimitError 在超出限购时返回,并保留当时的快照供日志使用。
|
||||
type LimitError struct {
|
||||
Policy Policy
|
||||
State State
|
||||
Requested uint32
|
||||
}
|
||||
|
||||
func (e *LimitError) Error() string {
|
||||
if e == nil {
|
||||
return ErrLimitReached.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: item=%d scope=%s period=%s used=%d requested=%d limit=%d",
|
||||
ErrLimitReached, e.Policy.ItemID, e.Policy.Scope, e.Policy.Period, e.State.Used, e.Requested, e.Policy.Limit)
|
||||
}
|
||||
|
||||
func (e *LimitError) Unwrap() error { return ErrLimitReached }
|
||||
|
||||
// State 是一条计数当前的权威投影。
|
||||
type State struct {
|
||||
Used uint32
|
||||
WindowStart int64
|
||||
Limit uint32
|
||||
Remaining uint32
|
||||
}
|
||||
|
||||
func stateFromUsed(p Policy, used uint32) State {
|
||||
state := State{Used: used, Limit: p.Limit}
|
||||
if used < p.Limit {
|
||||
state.Remaining = p.Limit - used
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func windowStartUnix(window Window) int64 {
|
||||
if window.Start.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return window.Start.Unix()
|
||||
}
|
||||
|
||||
// ReadCharacter 读取角色维度的计数。
|
||||
func ReadCharacter(stats map[string]int64, p Policy, now time.Time) State {
|
||||
if err := p.Valid(); err != nil || p.Scope != ScopeCharacter {
|
||||
return stateFromUsed(p, 0)
|
||||
}
|
||||
used, _ := stats[CountKey(p)]
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
stored := int64(0)
|
||||
if window := p.Window(now); !window.Start.IsZero() {
|
||||
stored, _ = stats[WindowKey(p)]
|
||||
if stored != windowStartUnix(window) {
|
||||
used = 0
|
||||
}
|
||||
}
|
||||
if used > int64(p.Limit) {
|
||||
used = int64(p.Limit)
|
||||
}
|
||||
return stateFromUsed(p, uint32(used))
|
||||
}
|
||||
|
||||
// ReadAccount 读取账号维度的计数。
|
||||
func ReadAccount(metadata map[string]string, p Policy, now time.Time) State {
|
||||
if err := p.Valid(); err != nil || p.Scope != ScopeAccount {
|
||||
return stateFromUsed(p, 0)
|
||||
}
|
||||
used := int64(0)
|
||||
if raw, ok := metadata[CountKey(p)]; ok {
|
||||
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 {
|
||||
used = parsed
|
||||
}
|
||||
}
|
||||
if window := p.Window(now); !window.Start.IsZero() {
|
||||
stored := int64(0)
|
||||
if raw, ok := metadata[WindowKey(p)]; ok {
|
||||
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
stored = parsed
|
||||
}
|
||||
}
|
||||
if stored != windowStartUnix(window) {
|
||||
used = 0
|
||||
}
|
||||
}
|
||||
if used > int64(p.Limit) {
|
||||
used = int64(p.Limit)
|
||||
}
|
||||
return stateFromUsed(p, uint32(used))
|
||||
}
|
||||
|
||||
// Read 按策略作用域选择容器读取。
|
||||
func Read(characterStats map[string]int64, accountMetadata map[string]string, p Policy, now time.Time) State {
|
||||
if p.Scope == ScopeAccount {
|
||||
return ReadAccount(accountMetadata, p, now)
|
||||
}
|
||||
return ReadCharacter(characterStats, p, now)
|
||||
}
|
||||
|
||||
// ConsumeCharacter 在角色容器里消费 count 次;超出限购返回 *LimitError。
|
||||
func ConsumeCharacter(stats map[string]int64, p Policy, count uint32, now time.Time) error {
|
||||
if err := p.Valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Scope != ScopeCharacter {
|
||||
return fmt.Errorf("%w: consume character with scope=%s", ErrPolicyInvalid, p.Scope)
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
state := ReadCharacter(stats, p, now)
|
||||
if uint64(state.Used)+uint64(count) > uint64(p.Limit) {
|
||||
return &LimitError{Policy: p, State: state, Requested: count}
|
||||
}
|
||||
if stats == nil {
|
||||
return fmt.Errorf("%w: character stats map is nil", ErrPolicyInvalid)
|
||||
}
|
||||
stats[CountKey(p)] = int64(state.Used) + int64(count)
|
||||
if window := p.Window(now); !window.Start.IsZero() {
|
||||
stats[WindowKey(p)] = windowStartUnix(window)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConsumeAccount 在账号容器里消费 count 次;超出限购返回 *LimitError。
|
||||
func ConsumeAccount(metadata map[string]string, p Policy, count uint32, now time.Time) error {
|
||||
if err := p.Valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Scope != ScopeAccount {
|
||||
return fmt.Errorf("%w: consume account with scope=%s", ErrPolicyInvalid, p.Scope)
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
state := ReadAccount(metadata, p, now)
|
||||
if uint64(state.Used)+uint64(count) > uint64(p.Limit) {
|
||||
return &LimitError{Policy: p, State: state, Requested: count}
|
||||
}
|
||||
if metadata == nil {
|
||||
return fmt.Errorf("%w: account metadata map is nil", ErrPolicyInvalid)
|
||||
}
|
||||
metadata[CountKey(p)] = strconv.FormatInt(int64(state.Used)+int64(count), 10)
|
||||
if window := p.Window(now); !window.Start.IsZero() {
|
||||
metadata[WindowKey(p)] = strconv.FormatInt(windowStartUnix(window), 10)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consume 按策略作用域选择容器消费。
|
||||
func Consume(characterStats map[string]int64, accountMetadata map[string]string, p Policy, count uint32, now time.Time) error {
|
||||
if p.Scope == ScopeAccount {
|
||||
return ConsumeAccount(accountMetadata, p, count, now)
|
||||
}
|
||||
return ConsumeCharacter(characterStats, p, count, now)
|
||||
}
|
||||
|
||||
// ParseCountKey 解析一条计数键,返回道具与周期。
|
||||
//
|
||||
// 带 KeyTag 命名空间的键在这里解析失败(道具段不是纯数字):这是**刻意**的——
|
||||
// UsedCounts 只服务 NPC 商店的 op715“4 张表”,商城商品维度的计数不能混进那 4 张
|
||||
// 表里,否则那个窗口会把商品号当道具号显示。
|
||||
func ParseCountKey(key string) (uint32, Period, bool) {
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(key), countKeyPrefix)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
index := strings.LastIndex(rest, "_")
|
||||
if index <= 0 || index+1 >= len(rest) {
|
||||
return 0, 0, false
|
||||
}
|
||||
itemID, err := strconv.ParseUint(rest[:index], 10, 32)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
period, ok := ParsePeriod(rest[index+1:])
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return uint32(itemID), period, true
|
||||
}
|
||||
|
||||
// Entry 是容器里的一条限购计数投影。
|
||||
type Entry struct {
|
||||
ItemID uint32
|
||||
Scope Scope
|
||||
Period Period
|
||||
Used uint32
|
||||
}
|
||||
|
||||
// UsedCounts 枚举容器里所有限购计数,并按窗口判定过期(过期的按 0 处理,跳过)。
|
||||
// 用于 op715 回包:把权威的已购次数下发给客户端。
|
||||
func UsedCounts(characterStats map[string]int64, accountMetadata map[string]string, now time.Time) []Entry {
|
||||
entries := make([]Entry, 0, len(characterStats)+len(accountMetadata))
|
||||
for key, value := range characterStats {
|
||||
itemID, period, ok := ParseCountKey(key)
|
||||
if !ok || value <= 0 {
|
||||
continue
|
||||
}
|
||||
policy := Policy{ItemID: itemID, Scope: ScopeCharacter, Period: period, Limit: ^uint32(0)}
|
||||
if used := ReadCharacter(characterStats, policy, now).Used; used > 0 {
|
||||
entries = append(entries, Entry{ItemID: itemID, Scope: ScopeCharacter, Period: period, Used: used})
|
||||
}
|
||||
}
|
||||
for key, raw := range accountMetadata {
|
||||
itemID, period, ok := ParseCountKey(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
continue
|
||||
}
|
||||
policy := Policy{ItemID: itemID, Scope: ScopeAccount, Period: period, Limit: ^uint32(0)}
|
||||
if used := ReadAccount(accountMetadata, policy, now).Used; used > 0 {
|
||||
entries = append(entries, Entry{ItemID: itemID, Scope: ScopeAccount, Period: period, Used: used})
|
||||
}
|
||||
}
|
||||
sort.Slice(entries, func(left, right int) bool {
|
||||
if entries[left].Scope != entries[right].Scope {
|
||||
return entries[left].Scope < entries[right].Scope
|
||||
}
|
||||
if entries[left].Period != entries[right].Period {
|
||||
return entries[left].Period < entries[right].Period
|
||||
}
|
||||
return entries[left].ItemID < entries[right].ItemID
|
||||
})
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// 本文件定义 PVF `[purchase limit]` / `[daily purchase limit]` 的限购策略、
|
||||
// 服务窗口与作用域/周期枚举。
|
||||
//
|
||||
// PVF 现场(全库 Script.pvf 扫描:420 个文件、41 种组合):
|
||||
//
|
||||
// [purchase limit]
|
||||
// `account` `weekly` 10 ← 新格式:作用域 周期 次数
|
||||
//
|
||||
// 旧格式是另一套字段,只有作用域+次数,周期固定为“每天”:
|
||||
//
|
||||
// [daily purchase limit]
|
||||
// `character` 1
|
||||
//
|
||||
// 作用域 2 种(account/character)× 周期 4 种(accumulate/weekly/daily/
|
||||
// agitwarseason)。客户端 UI 上的“账号永久/账号每周/角色永久/角色每周”4 行是
|
||||
// 按 (作用域, 是否周期) 分组的展示,不是 PVF 的取值。
|
||||
package purchaselimit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPolicyInvalid = errors.New("dnf purchase limit policy is invalid")
|
||||
ErrLimitReached = errors.New("dnf purchase limit reached")
|
||||
)
|
||||
|
||||
// Scope 是限购计数的持久化归属者。account 维度的计数落在账号记录上,character
|
||||
// 维度落在角色记录上;两者可以同时存在于同一件道具(PVF 只写一条,所以实际
|
||||
// 只会命中一条)。
|
||||
type Scope byte
|
||||
|
||||
const (
|
||||
ScopeCharacter Scope = iota + 1
|
||||
ScopeAccount
|
||||
)
|
||||
|
||||
func (s Scope) String() string {
|
||||
switch s {
|
||||
case ScopeCharacter:
|
||||
return "character"
|
||||
case ScopeAccount:
|
||||
return "account"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Period 是一条计数使用的服务时间窗口。
|
||||
type Period byte
|
||||
|
||||
const (
|
||||
// PeriodAccumulate 是 PVF 的 `accumulate`:永不清零(永久限购)。
|
||||
PeriodAccumulate Period = iota + 1
|
||||
// PeriodDaily 是 PVF 的 `daily` 与旧字段 `[daily purchase limit]`。
|
||||
PeriodDaily
|
||||
// PeriodWeekly 是 PVF 的 `weekly`。
|
||||
PeriodWeekly
|
||||
// PeriodSeason 是 PVF 的 `agitwarseason`(公会争夺战赛季)。
|
||||
PeriodSeason
|
||||
)
|
||||
|
||||
func (p Period) String() string {
|
||||
switch p {
|
||||
case PeriodAccumulate:
|
||||
return "accumulate"
|
||||
case PeriodDaily:
|
||||
return "daily"
|
||||
case PeriodWeekly:
|
||||
return "weekly"
|
||||
case PeriodSeason:
|
||||
return "season"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic 报告该周期是否会按窗口重置。
|
||||
func (p Period) Periodic() bool {
|
||||
return p == PeriodDaily || p == PeriodWeekly || p == PeriodSeason
|
||||
}
|
||||
|
||||
// Policy 是一件道具的限购策略。
|
||||
type Policy struct {
|
||||
ItemID uint32
|
||||
Scope Scope
|
||||
Period Period
|
||||
Limit uint32
|
||||
// Section 记录来源字段,便于日志区分新字段与旧字段。
|
||||
Section string
|
||||
// KeyTag 是计数键里的命名空间。道具维度(PVF `[purchase limit]`)留空;商城
|
||||
// 商品维度必须带前缀,因为商城是按**商品号**限购的,而商品号与道具号共用同
|
||||
// 一个数值空间(例如商品 104412 与某件道具 104412 会撞同一条计数)。
|
||||
KeyTag string
|
||||
}
|
||||
|
||||
func (p Policy) Valid() error {
|
||||
if p.ItemID == 0 || p.Limit == 0 ||
|
||||
(p.Scope != ScopeCharacter && p.Scope != ScopeAccount) ||
|
||||
(p.Period != PeriodAccumulate && p.Period != PeriodDaily && p.Period != PeriodWeekly && p.Period != PeriodSeason) {
|
||||
return fmt.Errorf("%w: item=%d scope=%d period=%d limit=%d", ErrPolicyInvalid, p.ItemID, p.Scope, p.Period, p.Limit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseScope 解析 PVF 的作用域文本(反引号内的内容)。
|
||||
//
|
||||
// 真实 PVF 里除标准写法外还有作者笔误,全库扫描(2026-09-14)实测到的:
|
||||
// - `charac` 截断,出现在 40+ 个文件(如 100060533.equ、chn_490003157.stk)
|
||||
// - `chraracter` 错拼,出现在 chn_490002849.stk
|
||||
//
|
||||
// 这两种只可能是 character(另一取值 account 无法由它们拼出),因此按 character
|
||||
// 接受。其它取值(例如 `trade`,见 chn_thief_chnagejob_10006348.stk)语义不明,
|
||||
// 一律判为“不认识的字段”→ 不限购,绝不猜。
|
||||
func ParseScope(raw string) (Scope, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "account":
|
||||
return ScopeAccount, true
|
||||
case "character", "charac", "chraracter":
|
||||
return ScopeCharacter, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePeriod 解析 PVF 的周期文本。
|
||||
//
|
||||
// 必须同时接受 Period.String() 的输出("season")与 PVF 的原始写法
|
||||
// ("agitwarseason"):计数键是用 Period.String() 拼的,ParseCountKey 再把它读回来。
|
||||
// 只认 PVF 写法会让赛季计数键读不回来(op715 下发时被判成"没有这条计数")。
|
||||
func ParsePeriod(raw string) (Period, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "accumulate":
|
||||
return PeriodAccumulate, true
|
||||
case "daily":
|
||||
return PeriodDaily, true
|
||||
case "weekly":
|
||||
return PeriodWeekly, true
|
||||
case "agitwarseason", "season":
|
||||
return PeriodSeason, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// serviceCalendar 与 dungeonadmission 使用同一个北京服务时区:DNF 的服务日从
|
||||
// 06:00 开始,周重置同样落在 06:00。
|
||||
var serviceCalendar = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// DefaultWeeklyResetWeekday 是商店限购的周重置日。DNF 的周重置统一在周四
|
||||
// 06:00(与 raid 周重置一致)。PVF 不声明商店限购的重置日,所以这里是本服
|
||||
// 择定的常量,改这里即可整体调整。
|
||||
const DefaultWeeklyResetWeekday = time.Thursday
|
||||
|
||||
// Window 是一条计数当前所属的服务时间区间。Accumulate/Season 这类不按窗口
|
||||
// 重置的策略返回零值窗口。
|
||||
type Window struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
// Window 计算 now 落在的服务窗口。零值 now 取当前时间。
|
||||
func (p Policy) Window(now time.Time) Window {
|
||||
if !p.Period.Periodic() {
|
||||
return Window{}
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
local := now.In(serviceCalendar)
|
||||
serviceDate := local.Add(-6 * time.Hour)
|
||||
start := time.Date(serviceDate.Year(), serviceDate.Month(), serviceDate.Day(), 6, 0, 0, 0, serviceCalendar)
|
||||
switch p.Period {
|
||||
case PeriodDaily:
|
||||
return Window{Start: start, End: start.AddDate(0, 0, 1)}
|
||||
case PeriodWeekly:
|
||||
days := (int(serviceDate.Weekday()) - int(DefaultWeeklyResetWeekday) + 7) % 7
|
||||
start = start.AddDate(0, 0, -days)
|
||||
return Window{Start: start, End: start.AddDate(0, 0, 7)}
|
||||
case PeriodSeason:
|
||||
// 本服没有区域占领战赛季日历,赛季窗口按“永不清零”处理(见
|
||||
// PeriodSeason 的说明);这里返回零值窗口,计数不会因为换周被清掉。
|
||||
return Window{}
|
||||
default:
|
||||
return Window{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package purchaselimit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseScopeAndPeriodCoverPVFValues(t *testing.T) {
|
||||
for raw, want := range map[string]Scope{
|
||||
"account": ScopeAccount,
|
||||
"`account`": 0,
|
||||
"character": ScopeCharacter,
|
||||
"ACCOUNT": ScopeAccount,
|
||||
} {
|
||||
got, ok := ParseScope(raw)
|
||||
if raw == "`account`" {
|
||||
if ok {
|
||||
t.Fatalf("ParseScope(%q) accepted backticks", raw)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok || got != want {
|
||||
t.Fatalf("ParseScope(%q)=%v,%t want %v", raw, got, ok, want)
|
||||
}
|
||||
}
|
||||
for raw, want := range map[string]Period{
|
||||
"accumulate": PeriodAccumulate,
|
||||
"weekly": PeriodWeekly,
|
||||
"daily": PeriodDaily,
|
||||
"agitwarseason": PeriodSeason,
|
||||
} {
|
||||
got, ok := ParsePeriod(raw)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("ParsePeriod(%q)=%v,%t want %v", raw, got, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := ParsePeriod("monthly"); ok {
|
||||
t.Fatal("ParsePeriod accepted an unknown period")
|
||||
}
|
||||
}
|
||||
|
||||
// 服务日 06:00 边界:北京 05:59 仍属于前一天,06:00 起属于新的一天。
|
||||
func TestDailyWindowRollsAtBeijingSixAM(t *testing.T) {
|
||||
policy := Policy{ItemID: 1, Scope: ScopeCharacter, Period: PeriodDaily, Limit: 1}
|
||||
before := time.Date(2026, 9, 14, 5, 59, 0, 0, serviceCalendar)
|
||||
after := time.Date(2026, 9, 14, 6, 0, 1, 0, serviceCalendar)
|
||||
beforeWindow := policy.Window(before)
|
||||
afterWindow := policy.Window(after)
|
||||
if !beforeWindow.Start.Equal(time.Date(2026, 9, 13, 6, 0, 0, 0, serviceCalendar)) {
|
||||
t.Fatalf("before window start=%s", beforeWindow.Start)
|
||||
}
|
||||
if !afterWindow.Start.Equal(time.Date(2026, 9, 14, 6, 0, 0, 0, serviceCalendar)) {
|
||||
t.Fatalf("after window start=%s", afterWindow.Start)
|
||||
}
|
||||
}
|
||||
|
||||
// 周窗口按周四 06:00 重置(与 raid 周重置一致)。
|
||||
func TestWeeklyWindowResetsOnThursdaySixAM(t *testing.T) {
|
||||
policy := Policy{ItemID: 1, Scope: ScopeAccount, Period: PeriodWeekly, Limit: 10}
|
||||
wednesday := time.Date(2026, 9, 16, 20, 0, 0, 0, serviceCalendar)
|
||||
thursday := time.Date(2026, 9, 17, 6, 0, 1, 0, serviceCalendar)
|
||||
if got := policy.Window(wednesday).Start; !got.Equal(time.Date(2026, 9, 10, 6, 0, 0, 0, serviceCalendar)) {
|
||||
t.Fatalf("wednesday window start=%s", got)
|
||||
}
|
||||
if got := policy.Window(thursday).Start; !got.Equal(time.Date(2026, 9, 17, 6, 0, 0, 0, serviceCalendar)) {
|
||||
t.Fatalf("thursday window start=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// accumulate 与赛季都没有窗口(永不清零)。
|
||||
func TestAccumulateAndSeasonHaveNoWindow(t *testing.T) {
|
||||
for _, period := range []Period{PeriodAccumulate, PeriodSeason} {
|
||||
policy := Policy{ItemID: 1, Scope: ScopeCharacter, Period: period, Limit: 1}
|
||||
if window := policy.Window(time.Now()); !window.Start.IsZero() {
|
||||
t.Fatalf("period=%s window start=%s want zero", period, window.Start)
|
||||
}
|
||||
if period.Periodic() && period != PeriodSeason {
|
||||
t.Fatalf("period=%s reported periodic", period)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyValidRejectsZeroLimit(t *testing.T) {
|
||||
if err := (Policy{ItemID: 1, Scope: ScopeAccount, Period: PeriodWeekly}).Valid(); err == nil {
|
||||
t.Fatal("zero limit accepted")
|
||||
}
|
||||
if err := (Policy{ItemID: 1, Scope: ScopeAccount, Period: PeriodWeekly, Limit: 1}).Valid(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeCharacterAccumulateNeverResets(t *testing.T) {
|
||||
stats := map[string]int64{}
|
||||
policy := Policy{ItemID: 490004806, Scope: ScopeCharacter, Period: PeriodAccumulate, Limit: 2}
|
||||
now := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)
|
||||
if err := ConsumeCharacter(stats, policy, 1, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ConsumeCharacter(stats, policy, 1, now.AddDate(1, 0, 0)); err != nil {
|
||||
t.Fatalf("accumulate reset unexpectedly: %v", err)
|
||||
}
|
||||
err := ConsumeCharacter(stats, policy, 1, now.AddDate(2, 0, 0))
|
||||
var limitErr *LimitError
|
||||
if !errors.As(err, &limitErr) || !errors.Is(err, ErrLimitReached) {
|
||||
t.Fatalf("第三件未被拦截: %v", err)
|
||||
}
|
||||
if limitErr.State.Used != 2 || limitErr.Policy.Scope != ScopeCharacter {
|
||||
t.Fatalf("limit error state=%+v", limitErr.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeAccountWeeklyResetsInNextWindow(t *testing.T) {
|
||||
metadata := map[string]string{}
|
||||
policy := Policy{ItemID: 490004806, Scope: ScopeAccount, Period: PeriodWeekly, Limit: 10}
|
||||
first := time.Date(2026, 9, 14, 12, 0, 0, 0, serviceCalendar)
|
||||
if err := ConsumeAccount(metadata, policy, 10, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ConsumeAccount(metadata, policy, 1, first); !errors.Is(err, ErrLimitReached) {
|
||||
t.Fatalf("同周第 11 件未被拦截: %v", err)
|
||||
}
|
||||
if state := ReadAccount(metadata, policy, first); state.Used != 10 || state.Remaining != 0 {
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
// 下一周(跨周四 06:00)窗口变化即归零。
|
||||
next := time.Date(2026, 9, 21, 12, 0, 0, 0, serviceCalendar)
|
||||
if state := ReadAccount(metadata, policy, next); state.Used != 0 || state.Remaining != 10 {
|
||||
t.Fatalf("next week state=%+v", state)
|
||||
}
|
||||
if err := ConsumeAccount(metadata, policy, 10, next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCharacterIgnoresStaleWindowCounter(t *testing.T) {
|
||||
policy := Policy{ItemID: 7, Scope: ScopeCharacter, Period: PeriodDaily, Limit: 5}
|
||||
stats := map[string]int64{
|
||||
CountKey(policy): 5,
|
||||
WindowKey(policy): time.Date(2026, 9, 13, 6, 0, 0, 0, serviceCalendar).Unix(),
|
||||
}
|
||||
if state := ReadCharacter(stats, policy, time.Date(2026, 9, 14, 12, 0, 0, 0, serviceCalendar)); state.Used != 0 {
|
||||
t.Fatalf("stale window kept used=%d", state.Used)
|
||||
}
|
||||
}
|
||||
|
||||
// 计数键必须对每一种周期都能"写得进、读得回"。
|
||||
//
|
||||
// 这个往返曾经断在季节上:CountKey 用 Period.String()("season")拼键,
|
||||
// 而 ParsePeriod 当时只认 PVF 的 "agitwarseason",于是赛季计数键读不回来,
|
||||
// op715 下发时被当成"没有这条计数"——拦截仍生效,但客户端永远显示 0/N。
|
||||
func TestCountKeyRoundTripsForEveryPeriod(t *testing.T) {
|
||||
for _, period := range []Period{PeriodAccumulate, PeriodDaily, PeriodWeekly, PeriodSeason} {
|
||||
policy := Policy{ItemID: 490004806, Scope: ScopeAccount, Period: period, Limit: 10}
|
||||
itemID, got, ok := ParseCountKey(CountKey(policy))
|
||||
if !ok || itemID != policy.ItemID || got != period {
|
||||
t.Fatalf("period=%s key=%s -> ok=%t item=%d period=%s", period, CountKey(policy), ok, itemID, got)
|
||||
}
|
||||
if _, _, ok := ParseCountKey(WindowKey(policy)); ok {
|
||||
t.Fatalf("window key %s should not parse as a count key", WindowKey(policy))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsedCountsEnumeratesBothScopesAndSkipsExpired(t *testing.T) {
|
||||
accountPolicy := Policy{ItemID: 490004806, Scope: ScopeAccount, Period: PeriodWeekly, Limit: 10}
|
||||
characterPolicy := Policy{ItemID: 10154547, Scope: ScopeCharacter, Period: PeriodDaily, Limit: 1}
|
||||
now := time.Date(2026, 9, 14, 12, 0, 0, 0, serviceCalendar)
|
||||
stats := map[string]int64{CountKey(characterPolicy): 1, WindowKey(characterPolicy): accountPolicy.Window(now).Start.Unix()}
|
||||
metadata := map[string]string{
|
||||
CountKey(accountPolicy): "3",
|
||||
WindowKey(accountPolicy): strconv.FormatInt(accountPolicy.Window(now).Start.Unix(), 10),
|
||||
}
|
||||
// 角色侧窗口写成别的周 → 视为过期,不再出现在枚举里。
|
||||
stats[WindowKey(characterPolicy)] = now.AddDate(0, 0, -30).Unix()
|
||||
entries := UsedCounts(stats, metadata, now)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries=%+v", entries)
|
||||
}
|
||||
if entries[0].ItemID != 490004806 || entries[0].Scope != ScopeAccount || entries[0].Used != 3 {
|
||||
t.Fatalf("entry=%+v", entries[0])
|
||||
}
|
||||
itemID, period, ok := ParseCountKey(CountKey(accountPolicy))
|
||||
if !ok || itemID != 490004806 || period != PeriodWeekly {
|
||||
t.Fatalf("ParseCountKey ok=%t item=%d period=%s", ok, itemID, period)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user