服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
332 lines
14 KiB
Go
332 lines
14 KiB
Go
package dungeonadmission
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
"longheng.io/server/internal/modules/dnf/repository/memory"
|
|
)
|
|
|
|
func TestServiceWindowDailyRollsAtBeijingSix(t *testing.T) {
|
|
policy := Policy{Key: "tower", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 3}
|
|
before := time.Date(2026, 8, 31, 5, 59, 59, 0, serviceCalendar)
|
|
after := time.Date(2026, 8, 31, 6, 0, 0, 0, serviceCalendar)
|
|
beforeWindow, err := ServiceWindow(policy, before)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
afterWindow, err := ServiceWindow(policy, after)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := beforeWindow.Start.Format(time.RFC3339), "2026-08-30T06:00:00+08:00"; got != want {
|
|
t.Fatalf("before reset start=%s want=%s", got, want)
|
|
}
|
|
if got, want := afterWindow.Start.Format(time.RFC3339), "2026-08-31T06:00:00+08:00"; got != want {
|
|
t.Fatalf("after reset start=%s want=%s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestServiceWindowWeeklyRollsThursdayAtBeijingSix(t *testing.T) {
|
|
policy := Policy{Key: "weekly", Scope: ScopeCharacter, Period: PeriodWeekly, Limit: 6, ResetWeekday: time.Thursday}
|
|
before := time.Date(2026, 9, 3, 5, 59, 59, 0, serviceCalendar)
|
|
after := time.Date(2026, 9, 3, 6, 0, 0, 0, serviceCalendar)
|
|
beforeWindow, err := ServiceWindow(policy, before)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
afterWindow, err := ServiceWindow(policy, after)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := beforeWindow.Start.Format(time.RFC3339), "2026-08-27T06:00:00+08:00"; got != want {
|
|
t.Fatalf("before weekly reset start=%s want=%s", got, want)
|
|
}
|
|
if got, want := afterWindow.Start.Format(time.RFC3339), "2026-09-03T06:00:00+08:00"; got != want {
|
|
t.Fatalf("after weekly reset start=%s want=%s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOwnerCommitIsIdempotentAndRejectsFourthTowerAdmission(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner, repositories := newOwnerTest(t, "account-1", "11")
|
|
policy := Policy{Key: "tower_of_despair.character.daily", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 3}
|
|
now := time.Date(2026, 8, 31, 12, 0, 0, 0, serviceCalendar)
|
|
|
|
first, err := owner.Commit(ctx, "11", []Policy{policy}, "run-1", now)
|
|
if err != nil || len(first) != 1 || first[0].Used != 1 || first[0].Remaining != 2 || first[0].Replayed {
|
|
t.Fatalf("first commit=%+v err=%v", first, err)
|
|
}
|
|
replay, err := owner.Commit(ctx, "11", []Policy{policy}, "run-1", now)
|
|
if err != nil || len(replay) != 1 || replay[0].Used != 1 || !replay[0].Replayed {
|
|
t.Fatalf("replay commit=%+v err=%v", replay, err)
|
|
}
|
|
for index := 2; index <= 3; index++ {
|
|
if _, err := owner.Commit(ctx, "11", []Policy{policy}, fmt.Sprintf("run-%d", index), now); err != nil {
|
|
t.Fatalf("commit %d: %v", index, err)
|
|
}
|
|
}
|
|
if _, err := owner.Preflight(ctx, "11", []Policy{policy}, now); !errors.Is(err, ErrLimitReached) {
|
|
t.Fatalf("full preflight err=%v want limit", err)
|
|
}
|
|
if _, err := owner.Commit(ctx, "11", []Policy{policy}, "run-4", now); !errors.Is(err, ErrLimitReached) {
|
|
t.Fatalf("fourth commit err=%v want limit", err)
|
|
}
|
|
|
|
settings, found, err := repositories.Settings.Load(ctx, characterCounterSettingsScope("11"))
|
|
if err != nil || !found {
|
|
t.Fatalf("settings found=%t err=%v", found, err)
|
|
}
|
|
used, ok := parseStoredUint(settings.Values[storagePrefix+digestString(policy.Key)+":used"])
|
|
if !ok || used != 3 {
|
|
t.Fatalf("persisted used=%d ok=%t values=%v", used, ok, settings.Values)
|
|
}
|
|
}
|
|
|
|
func TestOwnerDailyResetClearsOldReceipts(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner, repositories := newOwnerTest(t, "account-1", "11")
|
|
policy := Policy{Key: "tower", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 3}
|
|
before := time.Date(2026, 8, 31, 5, 59, 0, 0, serviceCalendar)
|
|
after := time.Date(2026, 8, 31, 6, 0, 0, 0, serviceCalendar)
|
|
if _, err := owner.Commit(ctx, "11", []Policy{policy}, "same-runtime-token", before); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := owner.Commit(ctx, "11", []Policy{policy}, "same-runtime-token", after)
|
|
if err != nil || len(result) != 1 || result[0].Used != 1 || result[0].Replayed {
|
|
t.Fatalf("post-reset commit=%+v err=%v", result, err)
|
|
}
|
|
settings, _, _ := repositories.Settings.Load(ctx, characterCounterSettingsScope("11"))
|
|
prefix := storagePrefix + digestString(policy.Key) + ":receipt:"
|
|
receipts := 0
|
|
for key := range settings.Values {
|
|
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
|
|
receipts++
|
|
}
|
|
}
|
|
if receipts != 1 {
|
|
t.Fatalf("receipt keys=%d values=%v", receipts, settings.Values)
|
|
}
|
|
}
|
|
|
|
func TestOwnerRestoreIsIdempotentAndRequiresFreshRetryReceipt(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner, _ := newOwnerTest(t, "account-1", "11")
|
|
policy := Policy{Key: "tower", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 3}
|
|
now := time.Date(2026, 8, 31, 12, 0, 0, 0, serviceCalendar)
|
|
if _, err := owner.Commit(ctx, "11", []Policy{policy}, "attempt-1", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restored, err := owner.Restore(ctx, "11", []Policy{policy}, "attempt-1", now)
|
|
if err != nil || len(restored) != 1 || restored[0].Used != 0 || !restored[0].Restored || restored[0].Replayed {
|
|
t.Fatalf("restore=%+v err=%v", restored, err)
|
|
}
|
|
replay, err := owner.Restore(ctx, "11", []Policy{policy}, "attempt-1", now)
|
|
if err != nil || len(replay) != 1 || replay[0].Used != 0 || !replay[0].Restored || !replay[0].Replayed {
|
|
t.Fatalf("restore replay=%+v err=%v", replay, err)
|
|
}
|
|
if _, err := owner.Commit(ctx, "11", []Policy{policy}, "attempt-1", now); !errors.Is(err, ErrReceiptRestored) {
|
|
t.Fatalf("reusing restored receipt err=%v want restored", err)
|
|
}
|
|
if result, err := owner.Commit(ctx, "11", []Policy{policy}, "attempt-2", now); err != nil || len(result) != 1 || result[0].Used != 1 {
|
|
t.Fatalf("fresh retry=%+v err=%v", result, err)
|
|
}
|
|
}
|
|
|
|
func TestOwnerAccountPolicyIsSharedAcrossCharacters(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner, repositories := newOwnerTest(t, "account-shared", "11")
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "12", AccountID: "account-shared"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
policy := Policy{Key: "tower_of_despair.account.daily", Scope: ScopeAccount, Period: PeriodDaily, Limit: 3}
|
|
now := time.Date(2026, 8, 31, 12, 0, 0, 0, serviceCalendar)
|
|
for index, characterID := range []string{"11", "12", "11"} {
|
|
if _, err := owner.Commit(ctx, characterID, []Policy{policy}, fmt.Sprintf("shared-%d", index), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := owner.Commit(ctx, "12", []Policy{policy}, "shared-4", now); !errors.Is(err, ErrLimitReached) {
|
|
t.Fatalf("shared fourth err=%v want limit", err)
|
|
}
|
|
account, found, err := repositories.Account.Load(ctx, "account-shared")
|
|
if err != nil || !found {
|
|
t.Fatalf("account found=%t err=%v", found, err)
|
|
}
|
|
used, ok := parseStoredUint(account.Metadata[storagePrefix+digestString(policy.Key)+":used"])
|
|
if !ok || used != 3 {
|
|
t.Fatalf("account used=%d ok=%t metadata=%v", used, ok, account.Metadata)
|
|
}
|
|
}
|
|
|
|
func TestOwnerConcurrentCommitNeverExceedsLimit(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner, _ := newOwnerTest(t, "account-1", "11")
|
|
policy := Policy{Key: "tower", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 3}
|
|
now := time.Date(2026, 8, 31, 12, 0, 0, 0, serviceCalendar)
|
|
var admitted atomic.Int32
|
|
var limited atomic.Int32
|
|
var unexpected atomic.Value
|
|
var wait sync.WaitGroup
|
|
for index := 0; index < 12; index++ {
|
|
wait.Add(1)
|
|
go func(index int) {
|
|
defer wait.Done()
|
|
_, err := owner.Commit(ctx, "11", []Policy{policy}, fmt.Sprintf("concurrent-%d", index), now)
|
|
switch {
|
|
case err == nil:
|
|
admitted.Add(1)
|
|
case errors.Is(err, ErrLimitReached):
|
|
limited.Add(1)
|
|
default:
|
|
unexpected.Store(err)
|
|
}
|
|
}(index)
|
|
}
|
|
wait.Wait()
|
|
if value := unexpected.Load(); value != nil {
|
|
t.Fatalf("unexpected concurrent error=%v", value)
|
|
}
|
|
if admitted.Load() != 3 || limited.Load() != 9 {
|
|
t.Fatalf("admitted=%d limited=%d want=3,9", admitted.Load(), limited.Load())
|
|
}
|
|
}
|
|
|
|
func TestOwnerCharacterAndAccountPoliciesCommitTogether(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner, _ := newOwnerTest(t, "account-1", "11")
|
|
policies := []Policy{
|
|
{Key: "tower.character", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 3},
|
|
{Key: "tower.account", Scope: ScopeAccount, Period: PeriodDaily, Limit: 1},
|
|
}
|
|
now := time.Date(2026, 8, 31, 12, 0, 0, 0, serviceCalendar)
|
|
result, err := owner.Commit(ctx, "11", policies, "run-1", now)
|
|
if err != nil || len(result) != 2 {
|
|
t.Fatalf("dual commit=%+v err=%v", result, err)
|
|
}
|
|
if _, err := owner.Commit(ctx, "11", policies, "run-2", now); !errors.Is(err, ErrLimitReached) {
|
|
t.Fatalf("second dual err=%v want account limit", err)
|
|
}
|
|
characterOnly, err := owner.Preflight(ctx, "11", []Policy{policies[0]}, now)
|
|
if err != nil || len(characterOnly) != 1 || characterOnly[0].Used != 1 {
|
|
t.Fatalf("failed dual attempt changed character counter: snapshot=%+v err=%v", characterOnly, err)
|
|
}
|
|
}
|
|
|
|
func TestOwnerAllowanceRaisesEffectiveDailyLimitAndResetsAtSix(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner, _ := newOwnerTest(t, "account-1", "11")
|
|
policy := Policy{Key: "dimension_gate.character.daily", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 5}
|
|
now := time.Date(2026, 9, 1, 12, 0, 0, 0, serviceCalendar)
|
|
for index := 0; index < 5; index++ {
|
|
if _, err := owner.Commit(ctx, "11", []Policy{policy}, fmt.Sprintf("base-%d", index), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := owner.Preflight(ctx, "11", []Policy{policy}, now); !errors.Is(err, ErrLimitReached) {
|
|
t.Fatalf("base limit preflight err=%v", err)
|
|
}
|
|
granted, err := owner.GrantAllowances(ctx, "11", []AllowanceGrant{{Policy: policy, Amount: 5, MaximumBonus: 5}}, AllowanceUseLimit{}, now, func(dnfrepo.Group) error { return nil })
|
|
if err != nil || len(granted.Snapshots) != 1 || granted.Snapshots[0].Used != 5 || granted.Snapshots[0].Bonus != 5 ||
|
|
granted.Snapshots[0].EffectiveLimit != 10 || granted.Snapshots[0].Remaining != 5 {
|
|
t.Fatalf("grant=%+v err=%v", granted, err)
|
|
}
|
|
for index := 0; index < 5; index++ {
|
|
if _, err := owner.Commit(ctx, "11", []Policy{policy}, fmt.Sprintf("bonus-%d", index), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := owner.Commit(ctx, "11", []Policy{policy}, "over", now); !errors.Is(err, ErrLimitReached) {
|
|
t.Fatalf("expanded limit err=%v", err)
|
|
}
|
|
nextDay := now.Add(24 * time.Hour)
|
|
reset, err := owner.Preflight(ctx, "11", []Policy{policy}, nextDay)
|
|
if err != nil || len(reset) != 1 || reset[0].Used != 0 || reset[0].Bonus != 0 || reset[0].EffectiveLimit != 5 || reset[0].Remaining != 5 {
|
|
t.Fatalf("reset snapshot=%+v err=%v", reset, err)
|
|
}
|
|
}
|
|
|
|
func TestOwnerAllowanceCharacterAndAccountGrantIsAtomicAndCapped(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner, repositories := newOwnerTest(t, "account-1", "11")
|
|
policies := []Policy{
|
|
{Key: "tower_of_grief.character.daily", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 2},
|
|
{Key: "tower_of_grief.account.daily", Scope: ScopeAccount, Period: PeriodDaily, Limit: 1},
|
|
}
|
|
grants := []AllowanceGrant{
|
|
{Policy: policies[0], Amount: 1, MaximumBonus: 2},
|
|
{Policy: policies[1], Amount: 1, MaximumBonus: 2},
|
|
}
|
|
useLimit := AllowanceUseLimit{Key: "tower_of_grief.recommendation.character.daily", Limit: 2}
|
|
now := time.Date(2026, 9, 1, 12, 0, 0, 0, serviceCalendar)
|
|
for use := 1; use <= 2; use++ {
|
|
result, err := owner.GrantAllowances(ctx, "11", grants, useLimit, now, func(dnfrepo.Group) error { return nil })
|
|
if err != nil || len(result.Snapshots) != 2 || result.Uses != uint32(use) || result.RemainingUses != uint32(2-use) {
|
|
t.Fatalf("use=%d result=%+v err=%v", use, result, err)
|
|
}
|
|
}
|
|
mutated := false
|
|
if _, err := owner.GrantAllowances(ctx, "11", grants, useLimit, now, func(dnfrepo.Group) error { mutated = true; return nil }); !errors.Is(err, ErrAllowanceLimit) || mutated {
|
|
t.Fatalf("third grant err=%v mutated=%t", err, mutated)
|
|
}
|
|
characterSnapshot, err := owner.Preflight(ctx, "11", []Policy{policies[0]}, now)
|
|
if err != nil || characterSnapshot[0].Bonus != 2 || characterSnapshot[0].EffectiveLimit != 4 {
|
|
t.Fatalf("character snapshot=%+v err=%v", characterSnapshot, err)
|
|
}
|
|
accountSnapshot, err := owner.Preflight(ctx, "11", []Policy{policies[1]}, now)
|
|
if err != nil || accountSnapshot[0].Bonus != 2 || accountSnapshot[0].EffectiveLimit != 3 {
|
|
t.Fatalf("account snapshot=%+v err=%v", accountSnapshot, err)
|
|
}
|
|
settings, _, _ := repositories.Settings.Load(ctx, characterCounterSettingsScope("11"))
|
|
if settings.Values[storagePrefix+"allowance_use:"+digestString(useLimit.Key)+":used"] != "2" {
|
|
t.Fatalf("usage state=%v", settings.Values)
|
|
}
|
|
}
|
|
|
|
func TestOwnerAllowanceMutationFailureRollsBackEveryScope(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner, _ := newOwnerTest(t, "account-1", "11")
|
|
policies := []Policy{
|
|
{Key: "tower.character", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 2},
|
|
{Key: "tower.account", Scope: ScopeAccount, Period: PeriodDaily, Limit: 1},
|
|
}
|
|
grants := []AllowanceGrant{
|
|
{Policy: policies[0], Amount: 1, MaximumBonus: 2},
|
|
{Policy: policies[1], Amount: 1, MaximumBonus: 2},
|
|
}
|
|
now := time.Date(2026, 9, 1, 12, 0, 0, 0, serviceCalendar)
|
|
wantErr := errors.New("source inventory mutation failed")
|
|
if _, err := owner.GrantAllowances(ctx, "11", grants, AllowanceUseLimit{Key: "tower.ticket", Limit: 2}, now, func(dnfrepo.Group) error { return wantErr }); !errors.Is(err, wantErr) {
|
|
t.Fatalf("grant err=%v", err)
|
|
}
|
|
for _, policy := range policies {
|
|
snapshot, err := owner.Preflight(ctx, "11", []Policy{policy}, now)
|
|
if err != nil || len(snapshot) != 1 || snapshot[0].Bonus != 0 || snapshot[0].EffectiveLimit != policy.Limit {
|
|
t.Fatalf("policy=%s snapshot=%+v err=%v", policy.Key, snapshot, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newOwnerTest(t *testing.T, accountID, characterID string) (*Owner, dnfrepo.Group) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
repositories := memory.NewMemoryGroup()
|
|
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: accountID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: characterID, AccountID: accountID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
owner, err := NewOwner(repositories)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return owner, repositories
|
|
}
|