服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
189 lines
7.4 KiB
Go
189 lines
7.4 KiB
Go
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)
|
||
}
|
||
}
|