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,678 @@
|
||||
package dungeonadmission
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const (
|
||||
CrackOfDimensionPath = "etc/crackofdimensionlist.etc"
|
||||
DimensionDungeonInfoPath = "etc/dungeonetc/dimensiondungeoninfo.etc"
|
||||
LicenseDungeonInfoPath = "etc/dungeonetc/licensedungeoninfo.etc"
|
||||
DimensionGatePath = "etc/dimensiongate.etc"
|
||||
WeeklyDungeonPath = "etc/weeklydungeon.etc"
|
||||
AccountWeeklyDungeonPath = "etc/accountcharacdungeonincountchn.etc"
|
||||
TowerOfDespairPath = "etc/towerofdespair.etc"
|
||||
TowerOfDespairInvitationPath = "stackable/invitation_drapyus2.stk"
|
||||
SAOTowerOfDespairPath = "etc/sao_towerofdespair.etc"
|
||||
TowerOfGriefPath = "etc/towerofgrief.etc"
|
||||
BeastDungeonPath = "etc/beastdungeon/beastdungeon.etc"
|
||||
AntonRaidPath = "etc/raid/anton.etc"
|
||||
LukeRaidPath = "etc/raid/luke/luke.etc"
|
||||
FiendWarRaidPath = "etc/raid/fiendwar/fiendwar.etc"
|
||||
DungeonListPath = "dungeon/dungeon.lst"
|
||||
)
|
||||
|
||||
// Raid weekly admission policy keys, one pair (account + character) per raid.
|
||||
//
|
||||
// These are raid-START limits: like the raid daily keys they carry no
|
||||
// DungeonIDs, so they are looked up by semantic key and are never applied once
|
||||
// per internal raid dungeon. dnfbridge charges them at start
|
||||
// (raid_admission_counter.go) and renders them in the team-channel window
|
||||
// (raid_daily_availability.go).
|
||||
const (
|
||||
AntonWeeklyAccountPolicyKey = "raid.anton.account.weekly"
|
||||
AntonWeeklyCharacterPolicyKey = "raid.anton.character.weekly"
|
||||
LukeWeeklyAccountPolicyKey = "raid.luke.account.weekly"
|
||||
// LukeWeeklyCharacterPolicyKey owns Luke's per-character weekly entry count.
|
||||
// Kept as a named constant because dnfbridge's per-gate observer
|
||||
// (raid_luke_weekly_observation.go) still quotes it; it is now charged and
|
||||
// rendered like every other raid weekly policy.
|
||||
LukeWeeklyCharacterPolicyKey = "raid.luke.character.weekly"
|
||||
FiendWarWeeklyAccountPolicyKey = "raid.fiend_war.account.weekly"
|
||||
FiendWarWeeklyCharacterPolicyKey = "raid.fiend_war.character.weekly"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCatalogSourceRequired = errors.New("dungeon admission catalog source is required")
|
||||
ErrCatalogInvalid = errors.New("dungeon admission catalog is invalid")
|
||||
)
|
||||
|
||||
// Evidence records a structured limit found in the active PVF. A zero Period
|
||||
// means the available source set does not prove a daily/weekly reset and is
|
||||
// deliberately not converted into an enforceable Policy. This prevents values
|
||||
// such as `[recently enter dungeon count] 3` from being guessed as limits; the
|
||||
// two tower families become daily only because their companion recommendation
|
||||
// actions carry service-window use counters for those exact entry pools.
|
||||
type Evidence struct {
|
||||
Key string
|
||||
SourcePath string
|
||||
SourceSection string
|
||||
Scope Scope
|
||||
Period Period
|
||||
Limit uint32
|
||||
ResetWeekday time.Weekday
|
||||
DungeonIDs []uint32
|
||||
Enforced bool
|
||||
Reason string
|
||||
RawValues []int64
|
||||
}
|
||||
|
||||
// Catalog is the immutable active-PVF admission-limit view. Policies without
|
||||
// DungeonIDs (currently raid-start limits) are looked up by semantic key and
|
||||
// must not be applied once per internal raid dungeon.
|
||||
type Catalog struct {
|
||||
policies map[string]Policy
|
||||
byDungeon map[uint32][]string
|
||||
evidence []Evidence
|
||||
towerRoute map[TowerFamily]TowerRoute
|
||||
}
|
||||
|
||||
func LoadCatalog(source dnfpvf.Source) (*Catalog, error) {
|
||||
if source == nil {
|
||||
return nil, ErrCatalogSourceRequired
|
||||
}
|
||||
documents := make(map[string]*dnfpvf.Document)
|
||||
for _, sourcePath := range []string{
|
||||
CrackOfDimensionPath,
|
||||
DimensionDungeonInfoPath,
|
||||
LicenseDungeonInfoPath,
|
||||
DimensionGatePath,
|
||||
WeeklyDungeonPath,
|
||||
AccountWeeklyDungeonPath,
|
||||
TowerOfDespairPath,
|
||||
TowerOfDespairInvitationPath,
|
||||
SAOTowerOfDespairPath,
|
||||
TowerOfGriefPath,
|
||||
BeastDungeonPath,
|
||||
AntonRaidPath,
|
||||
LukeRaidPath,
|
||||
FiendWarRaidPath,
|
||||
DungeonListPath,
|
||||
} {
|
||||
text, err := source.ReadText(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: read %s: %v", ErrCatalogInvalid, sourcePath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(sourcePath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: parse %s: %v", ErrCatalogInvalid, sourcePath, err)
|
||||
}
|
||||
documents[sourcePath] = document
|
||||
}
|
||||
|
||||
catalog := &Catalog{
|
||||
policies: make(map[string]Policy),
|
||||
byDungeon: make(map[uint32][]string),
|
||||
towerRoute: make(map[TowerFamily]TowerRoute),
|
||||
}
|
||||
if err := catalog.loadExplicitDaily(documents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := catalog.loadExplicitWeekly(documents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := catalog.loadUnprovedPeriodEvidence(documents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog.sort()
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) PoliciesForDungeon(dungeonID uint32) []Policy {
|
||||
if c == nil || dungeonID == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := c.byDungeon[dungeonID]
|
||||
out := make([]Policy, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if policy, ok := c.policies[key]; ok {
|
||||
out = append(out, clonePolicy(policy))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TowerRoute returns a detached copy of the PVF-authored ordered layer map.
|
||||
func (c *Catalog) TowerRoute(family TowerFamily) (TowerRoute, bool) {
|
||||
if c == nil {
|
||||
return TowerRoute{}, false
|
||||
}
|
||||
route, found := c.towerRoute[family]
|
||||
if !found {
|
||||
return TowerRoute{}, false
|
||||
}
|
||||
route.DungeonIDs = append([]uint32(nil), route.DungeonIDs...)
|
||||
route.UseAllowItems = append([]TowerUseItem(nil), route.UseAllowItems...)
|
||||
return route, true
|
||||
}
|
||||
|
||||
func (c *Catalog) Policy(key string) (Policy, bool) {
|
||||
if c == nil {
|
||||
return Policy{}, false
|
||||
}
|
||||
policy, ok := c.policies[strings.TrimSpace(key)]
|
||||
return clonePolicy(policy), ok
|
||||
}
|
||||
|
||||
func (c *Catalog) Policies() []Policy {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(c.policies))
|
||||
for key := range c.policies {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]Policy, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
out = append(out, clonePolicy(c.policies[key]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Catalog) Evidence() []Evidence {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Evidence, len(c.evidence))
|
||||
for index, evidence := range c.evidence {
|
||||
evidence.DungeonIDs = append([]uint32(nil), evidence.DungeonIDs...)
|
||||
evidence.RawValues = append([]int64(nil), evidence.RawValues...)
|
||||
out[index] = evidence
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Catalog) EvidenceForDungeon(dungeonID uint32) []Evidence {
|
||||
if c == nil || dungeonID == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Evidence, 0)
|
||||
for _, evidence := range c.evidence {
|
||||
for _, candidate := range evidence.DungeonIDs {
|
||||
if candidate == dungeonID {
|
||||
evidence.DungeonIDs = append([]uint32(nil), evidence.DungeonIDs...)
|
||||
evidence.RawValues = append([]int64(nil), evidence.RawValues...)
|
||||
out = append(out, evidence)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Catalog) loadExplicitDaily(documents map[string]*dnfpvf.Document) error {
|
||||
crackID, err := crackOfDimensionDungeonID(documents[DungeonListPath])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.addSectionPolicy(documents[CrackOfDimensionPath], Policy{
|
||||
Key: "crack_of_dimension.character.daily", SourcePath: CrackOfDimensionPath, SourceSection: "daily enter count",
|
||||
Scope: ScopeCharacter, Period: PeriodDaily, DungeonIDs: []uint32{crackID},
|
||||
}, "daily enter count"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dimensionIDs, err := tupleDungeonIDs(documents[DimensionDungeonInfoPath], "dimension dungeon info", 2, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.addSectionPolicy(documents[DimensionDungeonInfoPath], Policy{
|
||||
Key: "dimension_dungeon.character.daily", SourcePath: DimensionDungeonInfoPath, SourceSection: "max daily enter count",
|
||||
Scope: ScopeCharacter, Period: PeriodDaily, DungeonIDs: dimensionIDs,
|
||||
}, "max daily enter count"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
licenseIDs, err := tupleDungeonIDs(documents[LicenseDungeonInfoPath], "dungeon license info", 5, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.addSectionPolicy(documents[LicenseDungeonInfoPath], Policy{
|
||||
Key: "license_dungeon.character.daily", SourcePath: LicenseDungeonInfoPath, SourceSection: "daily enter count",
|
||||
Scope: ScopeCharacter, Period: PeriodDaily, DungeonIDs: licenseIDs,
|
||||
}, "daily enter count"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dimensionGateIDs, err := tupleDungeonIDs(documents[DimensionGatePath], "dimensiondungeon list", 3, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.addSectionPolicy(documents[DimensionGatePath], Policy{
|
||||
Key: "dimension_gate.character.daily", SourcePath: DimensionGatePath, SourceSection: "daily default enter count",
|
||||
Scope: ScopeCharacter, Period: PeriodDaily, DungeonIDs: dimensionGateIDs,
|
||||
}, "daily default enter count"); err != nil {
|
||||
return err
|
||||
}
|
||||
// This nearby value is a selector-history length, not an admission limit.
|
||||
c.addEvidence(Evidence{
|
||||
Key: "dimension_gate.recent_history", SourcePath: DimensionGatePath, SourceSection: "recently enter dungeon count",
|
||||
Limit: positiveSectionValue(documents[DimensionGatePath], "recently enter dungeon count"), DungeonIDs: dimensionGateIDs,
|
||||
Reason: "selector recent-history count has no limit/reset ownership",
|
||||
RawValues: documents[DimensionGatePath].Ints("recently enter dungeon count"),
|
||||
})
|
||||
|
||||
// The current PVF's recommendation actions carry daily use counters and add
|
||||
// the matching tower entry pools. Tower of Despair additionally proves its
|
||||
// same-day boundary in the required invitation's [explain] text. Together
|
||||
// these close the period gap left by the two tower ETC files alone while
|
||||
// preserving their authored character/account caps and exact membership.
|
||||
if err := validateTowerOfDespairDailyEvidence(documents[TowerOfDespairInvitationPath]); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tower := range []struct {
|
||||
path string
|
||||
family TowerFamily
|
||||
}{
|
||||
{path: TowerOfDespairPath, family: TowerFamilyDespair},
|
||||
{path: TowerOfGriefPath, family: TowerFamilyGrief},
|
||||
} {
|
||||
route, towerErr := parseTowerRoute(documents[tower.path], tower.family)
|
||||
if towerErr != nil {
|
||||
return towerErr
|
||||
}
|
||||
c.towerRoute[tower.family] = route
|
||||
for _, policy := range []Policy{
|
||||
{
|
||||
Key: string(tower.family) + ".character.daily", SourcePath: tower.path, SourceSection: "enterable count",
|
||||
Scope: ScopeCharacter, Period: PeriodDaily, DungeonIDs: route.DungeonIDs,
|
||||
},
|
||||
{
|
||||
Key: string(tower.family) + ".account.daily", SourcePath: tower.path, SourceSection: "account enterable max count",
|
||||
Scope: ScopeAccount, Period: PeriodDaily, DungeonIDs: route.DungeonIDs,
|
||||
},
|
||||
} {
|
||||
if err := c.addSectionPolicy(documents[tower.path], policy, policy.SourceSection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, raid := range []struct {
|
||||
key string
|
||||
path string
|
||||
}{
|
||||
{key: "raid.anton.account.daily", path: AntonRaidPath},
|
||||
{key: "raid.luke.account.daily", path: LukeRaidPath},
|
||||
{key: "raid.fiend_war.account.daily", path: FiendWarRaidPath},
|
||||
} {
|
||||
if err := c.addSectionPolicy(documents[raid.path], Policy{
|
||||
Key: raid.key, SourcePath: raid.path, SourceSection: "daily account enter limit", Scope: ScopeAccount, Period: PeriodDaily,
|
||||
}, "daily account enter limit"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTowerRoute(document *dnfpvf.Document, family TowerFamily) (TowerRoute, error) {
|
||||
if document == nil || (family != TowerFamilyDespair && family != TowerFamilyGrief) {
|
||||
return TowerRoute{}, fmt.Errorf("%w: invalid tower route family=%q", ErrCatalogInvalid, family)
|
||||
}
|
||||
values := document.Ints("each layer matching dungeon")
|
||||
if len(values) == 0 || len(values)%3 != 0 || len(values)/3 > int(^uint16(0)) {
|
||||
return TowerRoute{}, catalogSectionError(document.Path, "each layer matching dungeon", values)
|
||||
}
|
||||
route := TowerRoute{Family: family, DungeonIDs: make([]uint32, 0, len(values)/3)}
|
||||
seen := make(map[uint32]struct{}, len(values)/3)
|
||||
for offset := 0; offset < len(values); offset += 3 {
|
||||
floor := offset/3 + 1
|
||||
dungeonID := values[offset+1]
|
||||
if values[offset] != int64(floor) || dungeonID <= 0 || dungeonID > int64(^uint32(0)) {
|
||||
return TowerRoute{}, catalogSectionError(document.Path, "each layer matching dungeon", values)
|
||||
}
|
||||
id := uint32(dungeonID)
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
return TowerRoute{}, fmt.Errorf("%w: %s duplicate tower dungeon=%d", ErrCatalogInvalid, document.Path, id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
route.DungeonIDs = append(route.DungeonIDs, id)
|
||||
}
|
||||
itemValues := document.Ints("use allow item list in dungeon")
|
||||
if family == TowerFamilyGrief && (len(itemValues) == 0 || len(itemValues)%2 != 0 || len(itemValues)/2 > int(^uint8(0))) {
|
||||
return TowerRoute{}, catalogSectionError(document.Path, "use allow item list in dungeon", itemValues)
|
||||
}
|
||||
if len(itemValues)%2 != 0 || len(itemValues)/2 > int(^uint8(0)) {
|
||||
return TowerRoute{}, catalogSectionError(document.Path, "use allow item list in dungeon", itemValues)
|
||||
}
|
||||
for offset := 0; offset < len(itemValues); offset += 2 {
|
||||
itemID, count := itemValues[offset], itemValues[offset+1]
|
||||
if itemID <= 0 || itemID > int64(^uint32(0)) || count < int64(-1<<31) || count > int64(1<<31-1) {
|
||||
return TowerRoute{}, catalogSectionError(document.Path, "use allow item list in dungeon", itemValues)
|
||||
}
|
||||
route.UseAllowItems = append(route.UseAllowItems, TowerUseItem{ItemID: uint32(itemID), Count: int32(count)})
|
||||
}
|
||||
return route, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) loadExplicitWeekly(documents map[string]*dnfpvf.Document) error {
|
||||
resetValues := documents[WeeklyDungeonPath].Ints("reset day index")
|
||||
if len(resetValues) != 1 || resetValues[0] < int64(time.Sunday) || resetValues[0] > int64(time.Saturday) {
|
||||
return catalogSectionError(WeeklyDungeonPath, "reset day index", resetValues)
|
||||
}
|
||||
resetDay := time.Weekday(resetValues[0])
|
||||
maxValues := documents[AccountWeeklyDungeonPath].Ints("dungeon enter max count")
|
||||
if len(maxValues) == 0 || len(maxValues)%2 != 0 {
|
||||
return catalogSectionError(AccountWeeklyDungeonPath, "dungeon enter max count", maxValues)
|
||||
}
|
||||
weeklyIndex := documents[AccountWeeklyDungeonPath].Ints("weekly dungeon index")
|
||||
if len(weeklyIndex) == 0 || len(weeklyIndex)%2 != 0 {
|
||||
return catalogSectionError(AccountWeeklyDungeonPath, "weekly dungeon index", weeklyIndex)
|
||||
}
|
||||
indexed := make(map[uint32]struct{})
|
||||
for offset := 0; offset < len(weeklyIndex); offset += 2 {
|
||||
if weeklyIndex[offset] < 0 || weeklyIndex[offset] > 6 || weeklyIndex[offset+1] <= 0 || weeklyIndex[offset+1] > int64(^uint32(0)) {
|
||||
return catalogSectionError(AccountWeeklyDungeonPath, "weekly dungeon index", weeklyIndex)
|
||||
}
|
||||
indexed[uint32(weeklyIndex[offset+1])] = struct{}{}
|
||||
}
|
||||
for offset := 0; offset < len(maxValues); offset += 2 {
|
||||
dungeonID, limit := maxValues[offset], maxValues[offset+1]
|
||||
if dungeonID <= 0 || dungeonID > int64(^uint32(0)) || limit <= 0 || limit > int64(^uint32(0)) {
|
||||
return catalogSectionError(AccountWeeklyDungeonPath, "dungeon enter max count", maxValues)
|
||||
}
|
||||
id := uint32(dungeonID)
|
||||
if _, ok := indexed[id]; !ok {
|
||||
return fmt.Errorf("%w: %s dungeon=%d absent from weekly dungeon index", ErrCatalogInvalid, AccountWeeklyDungeonPath, id)
|
||||
}
|
||||
c.addEvidence(Evidence{
|
||||
Key: fmt.Sprintf("account_character_weekly_dungeon.%d.owner_unproved.weekly", id), SourcePath: AccountWeeklyDungeonPath,
|
||||
SourceSection: "dungeon enter max count", Period: PeriodWeekly, Limit: uint32(limit),
|
||||
ResetWeekday: resetDay, DungeonIDs: []uint32{id},
|
||||
Reason: "PVF proves the weekly period, cap, and dungeon membership but does not declare whether the durable counter owner is character or account",
|
||||
RawValues: []int64{dungeonID, limit},
|
||||
})
|
||||
}
|
||||
c.addEvidence(Evidence{
|
||||
Key: "weekly_dungeon.default_enter_count", SourcePath: WeeklyDungeonPath, SourceSection: "weekly default enter count",
|
||||
Period: PeriodWeekly, Limit: positiveSectionValue(documents[WeeklyDungeonPath], "weekly default enter count"), ResetWeekday: resetDay,
|
||||
Reason: "no dungeon identity or durable owner is declared in this document",
|
||||
RawValues: documents[WeeklyDungeonPath].Ints("weekly default enter count"),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) loadUnprovedPeriodEvidence(documents map[string]*dnfpvf.Document) error {
|
||||
for _, tower := range []struct {
|
||||
key, path string
|
||||
scope Scope
|
||||
section string
|
||||
idSection string
|
||||
}{
|
||||
{key: "sao_tower_of_despair.character", path: SAOTowerOfDespairPath, scope: ScopeCharacter, section: "enterable count", idSection: "each layer matching dungeon"},
|
||||
} {
|
||||
ids, err := tupleDungeonIDs(documents[tower.path], tower.idSection, 3, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values := documents[tower.path].Ints(tower.section)
|
||||
limit, err := onePositive(values)
|
||||
if err != nil {
|
||||
return catalogSectionError(tower.path, tower.section, values)
|
||||
}
|
||||
c.addEvidence(Evidence{
|
||||
Key: tower.key, SourcePath: tower.path, SourceSection: tower.section, Scope: tower.scope, Limit: limit, DungeonIDs: ids,
|
||||
Reason: "PVF proves the cap and dungeon membership but does not declare a daily/weekly reset period",
|
||||
RawValues: values,
|
||||
})
|
||||
}
|
||||
beastValues := documents[BeastDungeonPath].Ints("limit enter count")
|
||||
beastLimit, err := onePositive(beastValues)
|
||||
if err != nil {
|
||||
return catalogSectionError(BeastDungeonPath, "limit enter count", beastValues)
|
||||
}
|
||||
c.addEvidence(Evidence{
|
||||
Key: "beast_dungeon.unbound", SourcePath: BeastDungeonPath, SourceSection: "limit enter count", Limit: beastLimit,
|
||||
Reason: "PVF declares a cap but no daily/weekly reset period in the same structured config", RawValues: beastValues,
|
||||
})
|
||||
// [WEEKLY ENTER COUNT] is a two-operand row on every raid document, and the
|
||||
// operand meaning is now READ OFF THE SHIPPED CLIENT rather than inferred:
|
||||
//
|
||||
// etc/raid/anton.etc [WEEKLY ENTER COUNT] 30 3
|
||||
// etc/raid/luke/luke.etc [WEEKLY ENTER COUNT] 20 2
|
||||
// etc/raid/fiendwar/….etc [WEEKLY ENTER COUNT] 20 2
|
||||
//
|
||||
// and the client's two raid windows render exactly those pairs:
|
||||
//
|
||||
// window A "选择团队频道" 帐号周入场场数: 30/30 | 20/20 | 20/20
|
||||
// window B "团队模式入场信息" 角色周入场次数: 3/3 | 2/2 | 2/2
|
||||
//
|
||||
// (Window screenshots supplied by the server owner 2026-09-30; the earlier
|
||||
// reading -- first operand = account-wide ceiling, second = per-character
|
||||
// share, from a 2017-11 China-service notice -- is thereby confirmed by the
|
||||
// client itself rather than by the notice alone.)
|
||||
//
|
||||
// So each raid gets BOTH scopes as real Policies. Two properties are
|
||||
// deliberate:
|
||||
//
|
||||
// - No DungeonIDs, exactly like the raid daily policies: these are
|
||||
// raid-START limits looked up by semantic key, and PoliciesForDungeon must
|
||||
// never apply them once per internal raid dungeon.
|
||||
// - ResetWeekday is this repository's established weekly boundary
|
||||
// (Thursday 06:00, the same node etc/weeklydungeon.etc [reset day index]
|
||||
// feeds). The raid documents declare no reset weekday of their own, so
|
||||
// this remains an explicit assumption -- but a CHARGED one now, so every
|
||||
// raid start logs the window it was charged to and the real node can be
|
||||
// falsified from production traffic.
|
||||
for _, raid := range []struct {
|
||||
path string
|
||||
accountKey string
|
||||
characterKey string
|
||||
}{
|
||||
{path: AntonRaidPath, accountKey: AntonWeeklyAccountPolicyKey, characterKey: AntonWeeklyCharacterPolicyKey},
|
||||
{path: LukeRaidPath, accountKey: LukeWeeklyAccountPolicyKey, characterKey: LukeWeeklyCharacterPolicyKey},
|
||||
{path: FiendWarRaidPath, accountKey: FiendWarWeeklyAccountPolicyKey, characterKey: FiendWarWeeklyCharacterPolicyKey},
|
||||
} {
|
||||
accountLimit, characterLimit, values, err := raidWeeklyEnterCounts(documents[raid.path])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range []Policy{
|
||||
{
|
||||
Key: raid.accountKey, SourcePath: raid.path, SourceSection: "weekly enter count",
|
||||
Scope: ScopeAccount, Period: PeriodWeekly, Limit: accountLimit, ResetWeekday: time.Thursday,
|
||||
},
|
||||
{
|
||||
Key: raid.characterKey, SourcePath: raid.path, SourceSection: "weekly enter count",
|
||||
Scope: ScopeCharacter, Period: PeriodWeekly, Limit: characterLimit, ResetWeekday: time.Thursday,
|
||||
},
|
||||
} {
|
||||
if err := c.addPolicy(policy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.addEvidence(Evidence{
|
||||
Key: "raid_weekly_enter_count." + strings.ReplaceAll(raid.path, "/", "."), SourcePath: raid.path,
|
||||
SourceSection: "weekly enter count", Period: PeriodWeekly, ResetWeekday: time.Thursday,
|
||||
Limit: accountLimit, RawValues: values, Enforced: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// raidWeeklyEnterCounts reads one raid document's [WEEKLY ENTER COUNT] row and
|
||||
// returns (account weekly, character weekly).
|
||||
//
|
||||
// The shipped fiendwar.etc declares the section TWICE, so Ints() yields
|
||||
// `20 2 20 2`. Repeating the same pair is harmless and is tolerated; a repeated
|
||||
// section whose operands DISAGREE is not a document this server will guess
|
||||
// about, so it fails loud instead of silently taking one of the two readings.
|
||||
func raidWeeklyEnterCounts(document *dnfpvf.Document) (uint32, uint32, []int64, error) {
|
||||
if document == nil {
|
||||
return 0, 0, nil, fmt.Errorf("%w: raid weekly document is missing", ErrCatalogInvalid)
|
||||
}
|
||||
values := document.Ints("weekly enter count")
|
||||
if len(values) < 2 || len(values)%2 != 0 {
|
||||
return 0, 0, values, catalogSectionError(document.Path, "weekly enter count", values)
|
||||
}
|
||||
accountLimit, characterLimit := values[0], values[1]
|
||||
for offset := 2; offset < len(values); offset += 2 {
|
||||
if values[offset] != accountLimit || values[offset+1] != characterLimit {
|
||||
return 0, 0, values, catalogSectionError(document.Path, "weekly enter count", values)
|
||||
}
|
||||
}
|
||||
if accountLimit <= 0 || accountLimit > int64(^uint32(0)) ||
|
||||
characterLimit <= 0 || characterLimit > int64(^uint32(0)) {
|
||||
return 0, 0, values, catalogSectionError(document.Path, "weekly enter count", values)
|
||||
}
|
||||
return uint32(accountLimit), uint32(characterLimit), values, nil
|
||||
}
|
||||
|
||||
func validateTowerOfDespairDailyEvidence(document *dnfpvf.Document) error {
|
||||
if document == nil {
|
||||
return fmt.Errorf("%w: %s document is missing", ErrCatalogInvalid, TowerOfDespairInvitationPath)
|
||||
}
|
||||
stackableType, found := document.Text("stackable type")
|
||||
if !found || !strings.EqualFold(strings.TrimSpace(stackableType), "[material]") {
|
||||
return fmt.Errorf("%w: %s [stackable type]=%q want [material]", ErrCatalogInvalid, TowerOfDespairInvitationPath, stackableType)
|
||||
}
|
||||
explain := strings.Join(document.Texts("explain"), "\n")
|
||||
if !strings.Contains(explain, "绝望之塔") || !strings.Contains(explain, "当日") {
|
||||
return fmt.Errorf("%w: %s does not prove same-day Tower of Despair entry semantics", ErrCatalogInvalid, TowerOfDespairInvitationPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) addSectionPolicy(document *dnfpvf.Document, policy Policy, section string) error {
|
||||
values := document.Ints(section)
|
||||
limit, err := onePositive(values)
|
||||
if err != nil {
|
||||
return catalogSectionError(policy.SourcePath, section, values)
|
||||
}
|
||||
policy.Limit = limit
|
||||
return c.addPolicy(policy)
|
||||
}
|
||||
|
||||
func (c *Catalog) addPolicy(policy Policy) error {
|
||||
if err := validatePolicy(policy); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrCatalogInvalid, err)
|
||||
}
|
||||
if _, duplicate := c.policies[policy.Key]; duplicate {
|
||||
return fmt.Errorf("%w: duplicate policy=%s", ErrCatalogInvalid, policy.Key)
|
||||
}
|
||||
seen := make(map[uint32]struct{}, len(policy.DungeonIDs))
|
||||
for _, dungeonID := range policy.DungeonIDs {
|
||||
if dungeonID == 0 {
|
||||
return fmt.Errorf("%w: policy=%s contains dungeon 0", ErrCatalogInvalid, policy.Key)
|
||||
}
|
||||
if _, duplicate := seen[dungeonID]; duplicate {
|
||||
return fmt.Errorf("%w: policy=%s duplicate dungeon=%d", ErrCatalogInvalid, policy.Key, dungeonID)
|
||||
}
|
||||
seen[dungeonID] = struct{}{}
|
||||
}
|
||||
policy.DungeonIDs = sortedUint32(policy.DungeonIDs)
|
||||
c.policies[policy.Key] = policy
|
||||
for _, dungeonID := range policy.DungeonIDs {
|
||||
c.byDungeon[dungeonID] = append(c.byDungeon[dungeonID], policy.Key)
|
||||
}
|
||||
c.addEvidence(Evidence{
|
||||
Key: policy.Key, SourcePath: policy.SourcePath, SourceSection: policy.SourceSection, Scope: policy.Scope,
|
||||
Period: policy.Period, Limit: policy.Limit, ResetWeekday: policy.ResetWeekday, DungeonIDs: policy.DungeonIDs, Enforced: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) addEvidence(evidence Evidence) {
|
||||
evidence.DungeonIDs = sortedUint32(evidence.DungeonIDs)
|
||||
evidence.RawValues = append([]int64(nil), evidence.RawValues...)
|
||||
c.evidence = append(c.evidence, evidence)
|
||||
}
|
||||
|
||||
func (c *Catalog) sort() {
|
||||
for dungeonID := range c.byDungeon {
|
||||
sort.Strings(c.byDungeon[dungeonID])
|
||||
}
|
||||
sort.Slice(c.evidence, func(left, right int) bool {
|
||||
if c.evidence[left].SourcePath != c.evidence[right].SourcePath {
|
||||
return c.evidence[left].SourcePath < c.evidence[right].SourcePath
|
||||
}
|
||||
if c.evidence[left].SourceSection != c.evidence[right].SourceSection {
|
||||
return c.evidence[left].SourceSection < c.evidence[right].SourceSection
|
||||
}
|
||||
return c.evidence[left].Key < c.evidence[right].Key
|
||||
})
|
||||
}
|
||||
|
||||
func tupleDungeonIDs(document *dnfpvf.Document, section string, width, dungeonOffset int) ([]uint32, error) {
|
||||
values := document.Ints(section)
|
||||
if width <= 0 || dungeonOffset < 0 || dungeonOffset >= width || len(values) == 0 || len(values)%width != 0 {
|
||||
return nil, catalogSectionError(document.Path, section, values)
|
||||
}
|
||||
ids := make([]uint32, 0, len(values)/width)
|
||||
seen := make(map[uint32]struct{})
|
||||
for offset := 0; offset < len(values); offset += width {
|
||||
value := values[offset+dungeonOffset]
|
||||
if value <= 0 || value > int64(^uint32(0)) {
|
||||
return nil, catalogSectionError(document.Path, section, values)
|
||||
}
|
||||
id := uint32(value)
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
return nil, fmt.Errorf("%w: %s section=%s duplicate dungeon=%d", ErrCatalogInvalid, document.Path, section, id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return sortedUint32(ids), nil
|
||||
}
|
||||
|
||||
func crackOfDimensionDungeonID(document *dnfpvf.Document) (uint32, error) {
|
||||
var found uint32
|
||||
for _, entry := range dnfpvf.ParseList(document) {
|
||||
path := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(entry.Path), "\\", "/"))
|
||||
if path != "crackofdimension/crackofdimension.dgn" {
|
||||
continue
|
||||
}
|
||||
if found != 0 || entry.ID <= 0 || entry.ID > int64(^uint32(0)) {
|
||||
return 0, fmt.Errorf("%w: ambiguous crack-of-dimension dungeon list row", ErrCatalogInvalid)
|
||||
}
|
||||
found = uint32(entry.ID)
|
||||
}
|
||||
if found == 0 {
|
||||
return 0, fmt.Errorf("%w: crack-of-dimension dungeon list row missing", ErrCatalogInvalid)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func positiveSectionValue(document *dnfpvf.Document, section string) uint32 {
|
||||
value, _ := onePositive(document.Ints(section))
|
||||
return value
|
||||
}
|
||||
|
||||
func onePositive(values []int64) (uint32, error) {
|
||||
if len(values) != 1 || values[0] <= 0 || values[0] > int64(^uint32(0)) {
|
||||
return 0, ErrCatalogInvalid
|
||||
}
|
||||
return uint32(values[0]), nil
|
||||
}
|
||||
|
||||
func catalogSectionError(path, section string, values []int64) error {
|
||||
return fmt.Errorf("%w: %s section=%s values=%v", ErrCatalogInvalid, path, section, values)
|
||||
}
|
||||
|
||||
func sortedUint32(values []uint32) []uint32 {
|
||||
out := append([]uint32(nil), values...)
|
||||
sort.Slice(out, func(left, right int) bool { return out[left] < out[right] })
|
||||
return out
|
||||
}
|
||||
|
||||
func clonePolicy(policy Policy) Policy {
|
||||
policy.DungeonIDs = append([]uint32(nil), policy.DungeonIDs...)
|
||||
return policy
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dungeonadmission
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestRealScriptPVFAdmissionLimitCensus(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to run the admission-limit census")
|
||||
}
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := LoadCatalog(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertDungeonPolicy(t, catalog, 8000, "crack_of_dimension.character.daily", 10, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 7165, "dimension_dungeon.character.daily", 10, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 7179, "dimension_dungeon.character.daily", 10, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 5006, "license_dungeon.character.daily", 2, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 5029, "license_dungeon.character.daily", 2, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 62, "dimension_gate.character.daily", 5, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 5115, "tower_of_grief.character.daily", 2, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 5214, "tower_of_grief.account.daily", 1, PeriodDaily, 0)
|
||||
for dungeonID, limit := range map[uint32]uint32{4906: 2, 4910: 1} {
|
||||
if policies := catalog.PoliciesForDungeon(dungeonID); len(policies) != 0 {
|
||||
t.Fatalf("real weekly owner was guessed dungeon=%d policies=%+v", dungeonID, policies)
|
||||
}
|
||||
evidence := catalog.EvidenceForDungeon(dungeonID)
|
||||
if len(evidence) != 1 || evidence[0].Enforced || evidence[0].Scope != 0 || evidence[0].Period != PeriodWeekly ||
|
||||
evidence[0].Limit != limit || evidence[0].ResetWeekday != time.Thursday {
|
||||
t.Fatalf("real weekly evidence dungeon=%d rows=%+v", dungeonID, evidence)
|
||||
}
|
||||
}
|
||||
|
||||
towerEvidence := catalog.EvidenceForDungeon(11008)
|
||||
if len(towerEvidence) != 2 {
|
||||
t.Fatalf("real tower 11008 evidence=%+v", towerEvidence)
|
||||
}
|
||||
for _, evidence := range towerEvidence {
|
||||
if evidence.Period != PeriodDaily || !evidence.Enforced || evidence.Limit != 3 || len(evidence.DungeonIDs) != 100 || evidence.DungeonIDs[99] != 11107 {
|
||||
t.Fatalf("real tower evidence=%+v", evidence)
|
||||
}
|
||||
}
|
||||
assertDungeonPolicy(t, catalog, 11008, "tower_of_despair.character.daily", 3, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 11107, "tower_of_despair.account.daily", 3, PeriodDaily, 0)
|
||||
despair, found := catalog.TowerRoute(TowerFamilyDespair)
|
||||
if !found || len(despair.DungeonIDs) != 100 || despair.DungeonIDs[0] != 11008 || despair.DungeonIDs[99] != 11107 || len(despair.UseAllowItems) != 0 {
|
||||
t.Fatalf("real despair tower route=%+v found=%t", despair, found)
|
||||
}
|
||||
grief, found := catalog.TowerRoute(TowerFamilyGrief)
|
||||
wantGriefItems := []TowerUseItem{
|
||||
{ItemID: 10160849, Count: 10}, {ItemID: 10160850, Count: 10},
|
||||
{ItemID: 10163185, Count: -1}, {ItemID: 10163183, Count: -1},
|
||||
{ItemID: 10163184, Count: -1}, {ItemID: 10163187, Count: -1},
|
||||
{ItemID: 10163188, Count: -1}, {ItemID: 10163189, Count: -1},
|
||||
}
|
||||
if !found || len(grief.DungeonIDs) != 100 || grief.DungeonIDs[0] != 5115 || grief.DungeonIDs[99] != 5214 || len(grief.UseAllowItems) != len(wantGriefItems) {
|
||||
t.Fatalf("real grief tower route=%+v found=%t", grief, found)
|
||||
}
|
||||
for index := range wantGriefItems {
|
||||
if grief.UseAllowItems[index] != wantGriefItems[index] {
|
||||
t.Fatalf("real grief tower use item[%d]=%+v want=%+v", index, grief.UseAllowItems[index], wantGriefItems[index])
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"raid.anton.account.daily", "raid.luke.account.daily", "raid.fiend_war.account.daily"} {
|
||||
policy, found := catalog.Policy(key)
|
||||
if !found || policy.Limit != 10 || len(policy.DungeonIDs) != 0 {
|
||||
t.Fatalf("real raid policy %s found=%t policy=%+v", key, found, policy)
|
||||
}
|
||||
}
|
||||
t.Logf("active PVF admission policies=%d structured evidence rows=%d", len(catalog.Policies()), len(catalog.Evidence()))
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package dungeonadmission
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type catalogTestSource map[string]string
|
||||
|
||||
func (s catalogTestSource) ReadText(path string) (string, error) {
|
||||
for candidate, text := range s {
|
||||
if strings.EqualFold(candidate, path) {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("missing %s", path)
|
||||
}
|
||||
|
||||
func TestCatalogBindsOnlyPVFProvedPeriodsAndMembership(t *testing.T) {
|
||||
catalog, err := LoadCatalog(syntheticCatalogSource())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertDungeonPolicy(t, catalog, 8000, "crack_of_dimension.character.daily", 10, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 7165, "dimension_dungeon.character.daily", 10, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 5006, "license_dungeon.character.daily", 2, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 62, "dimension_gate.character.daily", 5, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 5115, "tower_of_grief.character.daily", 2, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 5115, "tower_of_grief.account.daily", 1, PeriodDaily, 0)
|
||||
for dungeonID, limit := range map[uint32]uint32{4906: 2, 4907: 1} {
|
||||
if policies := catalog.PoliciesForDungeon(dungeonID); len(policies) != 0 {
|
||||
t.Fatalf("weekly owner was guessed dungeon=%d policies=%+v", dungeonID, policies)
|
||||
}
|
||||
evidence := catalog.EvidenceForDungeon(dungeonID)
|
||||
if len(evidence) != 1 || evidence[0].Enforced || evidence[0].Scope != 0 || evidence[0].Period != PeriodWeekly ||
|
||||
evidence[0].Limit != limit || evidence[0].ResetWeekday != time.Thursday || !strings.Contains(evidence[0].Reason, "does not declare") {
|
||||
t.Fatalf("weekly evidence dungeon=%d rows=%+v", dungeonID, evidence)
|
||||
}
|
||||
}
|
||||
|
||||
assertDungeonPolicy(t, catalog, 11008, "tower_of_despair.character.daily", 3, PeriodDaily, 0)
|
||||
assertDungeonPolicy(t, catalog, 11008, "tower_of_despair.account.daily", 3, PeriodDaily, 0)
|
||||
evidence := catalog.EvidenceForDungeon(11008)
|
||||
if len(evidence) != 2 {
|
||||
t.Fatalf("tower evidence=%+v want character/account", evidence)
|
||||
}
|
||||
for _, row := range evidence {
|
||||
if !row.Enforced || row.Period != PeriodDaily || row.Limit != 3 {
|
||||
t.Fatalf("tower evidence incorrectly enforceable=%+v", row)
|
||||
}
|
||||
}
|
||||
if history := findEvidence(catalog, "dimension_gate.recent_history"); history.Enforced || history.Period != 0 || history.Limit != 3 {
|
||||
t.Fatalf("recent-history count became a limit=%+v", history)
|
||||
}
|
||||
|
||||
for _, key := range []string{"raid.anton.account.daily", "raid.luke.account.daily", "raid.fiend_war.account.daily"} {
|
||||
policy, found := catalog.Policy(key)
|
||||
if !found || policy.Scope != ScopeAccount || policy.Period != PeriodDaily || policy.Limit != 10 || len(policy.DungeonIDs) != 0 {
|
||||
t.Fatalf("raid-start policy %s found=%t policy=%+v", key, found, policy)
|
||||
}
|
||||
}
|
||||
// Every raid's [WEEKLY ENTER COUNT] row is now BOTH scopes, not evidence:
|
||||
// the shipped documents declare `30 3` / `20 2` / `20 2` and the client's two
|
||||
// team-mode windows render exactly those as 帐号周入场场数 (first operand) and
|
||||
// 角色周入场次数 (second operand), so the operand meaning is read off the
|
||||
// client rather than inferred. All six carry no dungeon ids for the same
|
||||
// reason the daily policies do not: these are raid-START limits looked up by
|
||||
// semantic key, so PoliciesForDungeon must never apply them per gate.
|
||||
for _, weekly := range []struct {
|
||||
key string
|
||||
path string
|
||||
scope Scope
|
||||
limit uint32
|
||||
}{
|
||||
{AntonWeeklyAccountPolicyKey, AntonRaidPath, ScopeAccount, 30},
|
||||
{AntonWeeklyCharacterPolicyKey, AntonRaidPath, ScopeCharacter, 3},
|
||||
{LukeWeeklyAccountPolicyKey, LukeRaidPath, ScopeAccount, 20},
|
||||
{LukeWeeklyCharacterPolicyKey, LukeRaidPath, ScopeCharacter, 2},
|
||||
{FiendWarWeeklyAccountPolicyKey, FiendWarRaidPath, ScopeAccount, 20},
|
||||
{FiendWarWeeklyCharacterPolicyKey, FiendWarRaidPath, ScopeCharacter, 2},
|
||||
} {
|
||||
policy, found := catalog.Policy(weekly.key)
|
||||
if !found || policy.Scope != weekly.scope || policy.Period != PeriodWeekly ||
|
||||
policy.Limit != weekly.limit || policy.ResetWeekday != time.Thursday ||
|
||||
len(policy.DungeonIDs) != 0 || policy.SourcePath != weekly.path ||
|
||||
policy.SourceSection != "weekly enter count" {
|
||||
t.Fatalf("raid weekly policy %s found=%t policy=%+v", weekly.key, found, policy)
|
||||
}
|
||||
}
|
||||
// The shipped fiendwar.etc repeats the [WEEKLY ENTER COUNT] section verbatim.
|
||||
// A repeated-but-identical pair is tolerated; the pair that lands in the
|
||||
// catalog is the first one and both scopes must agree with it.
|
||||
if repeated, found := catalog.Policy(FiendWarWeeklyAccountPolicyKey); !found || repeated.Limit != 20 {
|
||||
t.Fatalf("duplicated fiendwar weekly section produced %+v found=%t", repeated, found)
|
||||
}
|
||||
if beast := findEvidence(catalog, "beast_dungeon.unbound"); beast.Enforced || beast.Period != 0 || beast.Limit != 2 {
|
||||
t.Fatalf("beast period was guessed=%+v", beast)
|
||||
}
|
||||
|
||||
// Luke's [WEEKLY ENTER COUNT] `20 2` additionally gets a real Policy, because its
|
||||
// per-character share is corroborated for this client's version window instead of
|
||||
// merely guessed. It owns the counter and the window, and it carries NO dungeon
|
||||
// ids on purpose: PoliciesForDungeon must keep returning nothing for Luke's gates
|
||||
// so that nothing starts refusing until the reset node is confirmed.
|
||||
weekly, found := catalog.Policy(LukeWeeklyCharacterPolicyKey)
|
||||
if !found || weekly.Scope != ScopeCharacter || weekly.Period != PeriodWeekly || weekly.Limit != 2 ||
|
||||
weekly.ResetWeekday != time.Thursday || len(weekly.DungeonIDs) != 0 ||
|
||||
weekly.SourcePath != LukeRaidPath || weekly.SourceSection != "weekly enter count" {
|
||||
t.Fatalf("luke weekly policy found=%t policy=%+v", found, weekly)
|
||||
}
|
||||
for _, dungeonID := range []uint32{5054, 5057, 5061, 5323} {
|
||||
if policies := catalog.PoliciesForDungeon(dungeonID); len(policies) != 0 {
|
||||
t.Fatalf("luke weekly policy became enforceable dungeon=%d policies=%+v", dungeonID, policies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRejectsWeeklyDungeonMissingFromDayIndex(t *testing.T) {
|
||||
source := syntheticCatalogSource()
|
||||
source[AccountWeeklyDungeonPath] = "[dungeon enter max count]\n4906 2 4907 1\n[/dungeon enter max count]\n" +
|
||||
"[weekly dungeon index]\n0 4906\n[/weekly dungeon index]\n"
|
||||
if _, err := LoadCatalog(source); err == nil || !strings.Contains(err.Error(), "4907 absent") {
|
||||
t.Fatalf("weekly mismatch err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogPoliciesAreImmutableCopies(t *testing.T) {
|
||||
catalog, err := LoadCatalog(syntheticCatalogSource())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policies := catalog.PoliciesForDungeon(7165)
|
||||
policies[0].DungeonIDs[0] = 99
|
||||
again := catalog.PoliciesForDungeon(7165)
|
||||
if len(again) != 1 || again[0].DungeonIDs[0] != 7165 {
|
||||
t.Fatalf("catalog mutated through copy=%+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogKeepsOrderedTowerLayersAndEntryPairs(t *testing.T) {
|
||||
catalog, err := LoadCatalog(syntheticCatalogSource())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
despair, found := catalog.TowerRoute(TowerFamilyDespair)
|
||||
if !found || len(despair.DungeonIDs) != 2 || despair.DungeonIDs[0] != 11008 || despair.DungeonIDs[1] != 11009 || len(despair.UseAllowItems) != 0 {
|
||||
t.Fatalf("despair route=%+v found=%t", despair, found)
|
||||
}
|
||||
grief, found := catalog.TowerRoute(TowerFamilyGrief)
|
||||
if !found || len(grief.DungeonIDs) != 1 || grief.DungeonIDs[0] != 5115 || len(grief.UseAllowItems) != 2 ||
|
||||
grief.UseAllowItems[0] != (TowerUseItem{ItemID: 10160849, Count: 10}) ||
|
||||
grief.UseAllowItems[1] != (TowerUseItem{ItemID: 10163185, Count: -1}) {
|
||||
t.Fatalf("grief route=%+v found=%t", grief, found)
|
||||
}
|
||||
despair.DungeonIDs[0] = 1
|
||||
again, _ := catalog.TowerRoute(TowerFamilyDespair)
|
||||
if again.DungeonIDs[0] != 11008 {
|
||||
t.Fatalf("tower route leaked mutable slice=%+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDungeonPolicy(t *testing.T, catalog *Catalog, dungeonID uint32, key string, limit uint32, period Period, weekday time.Weekday) {
|
||||
t.Helper()
|
||||
policies := catalog.PoliciesForDungeon(dungeonID)
|
||||
for _, policy := range policies {
|
||||
if policy.Key == key {
|
||||
if policy.Limit != limit || policy.Period != period || policy.ResetWeekday != weekday {
|
||||
t.Fatalf("dungeon=%d policy=%+v", dungeonID, policy)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("dungeon=%d policies=%+v missing=%s", dungeonID, policies, key)
|
||||
}
|
||||
|
||||
func findEvidence(catalog *Catalog, key string) Evidence {
|
||||
for _, evidence := range catalog.Evidence() {
|
||||
if evidence.Key == key {
|
||||
return evidence
|
||||
}
|
||||
}
|
||||
return Evidence{}
|
||||
}
|
||||
|
||||
func syntheticCatalogSource() catalogTestSource {
|
||||
return catalogTestSource{
|
||||
DungeonListPath: "8000 `Crackofdimension/Crackofdimension.dgn`\n",
|
||||
CrackOfDimensionPath: "[daily enter count]\n10\n[/daily enter count]\n",
|
||||
DimensionDungeonInfoPath: "[dimension dungeon info]\n0 7165 1 7166 2 7167\n[/dimension dungeon info]\n" +
|
||||
"[max daily enter count]\n10\n",
|
||||
LicenseDungeonInfoPath: "[dungeon license info]\n1 5006 1 10 2 1 5007 2 7 4\n[/dungeon license info]\n" +
|
||||
"[daily enter count]\n2\n",
|
||||
DimensionGatePath: "[daily default enter count]\n5\n[dimensiondungeon list]\n62 25 0 64 25 0\n[/dimensiondungeon list]\n" +
|
||||
"[recently enter dungeon count]\n3\n",
|
||||
WeeklyDungeonPath: "[reset day index]\n4\n[weekly default enter count]\n6\n",
|
||||
AccountWeeklyDungeonPath: "[dungeon enter max count]\n4906 2 4907 1\n[/dungeon enter max count]\n" +
|
||||
"[weekly dungeon index]\n0 4907 1 4906\n[/weekly dungeon index]\n",
|
||||
TowerOfDespairPath: "[enterable count]\n3\n[account enterable max count]\n3\n" +
|
||||
"[each layer matching dungeon]\n1 11008 -1 2 11009 -1\n[/each layer matching dungeon]\n",
|
||||
TowerOfDespairInvitationPath: "[name]\n`绝望之邀请函`\n[explain]\n`通关成功后当日无法再继续挑战绝望之塔。`\n[stackable type]\n`[material]` 0\n",
|
||||
SAOTowerOfDespairPath: "[enterable count]\n3\n[each layer matching dungeon]\n1 3600 -1\n[/each layer matching dungeon]\n",
|
||||
TowerOfGriefPath: "[enterable count]\n2\n[account enterable max count]\n1\n" +
|
||||
"[each layer matching dungeon]\n1 5115 -1\n[/each layer matching dungeon]\n" +
|
||||
"[use allow item list in dungeon]\n10160849 10 10163185 -1\n[/use allow item list in dungeon]\n",
|
||||
BeastDungeonPath: "[limit enter count]\n2\n",
|
||||
AntonRaidPath: "[daily account enter limit]\n10\n[weekly enter count]\n30 3\n",
|
||||
LukeRaidPath: "[daily account enter limit]\n10\n[weekly enter count]\n20 2\n",
|
||||
FiendWarRaidPath: "[daily account enter limit]\n10\n[weekly enter count]\n20 2\n",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package dungeonadmission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDailyInvitationInvalid = errors.New("daily dungeon invitation definition is invalid")
|
||||
ErrDailyInvitationInventoryFull = errors.New("daily dungeon invitation inventory page is full")
|
||||
ErrDailyInvitationInventoryAbsent = errors.New("daily dungeon invitation inventory is missing")
|
||||
)
|
||||
|
||||
// DailyInvitation is an active-PVF-resolved admission item that is checked
|
||||
// once per Beijing service day. Minimum is a floor, not an additive reward:
|
||||
// an existing stack satisfies the daily login grant without increasing it.
|
||||
type DailyInvitation struct {
|
||||
ItemID int64
|
||||
Minimum int64
|
||||
StackLimit int64
|
||||
ListType byte
|
||||
CheckSlotStart int16
|
||||
CheckSlotEnd int16
|
||||
SlotStart int16
|
||||
SlotEnd int16
|
||||
PVFPath string
|
||||
StackableType string
|
||||
}
|
||||
|
||||
type DailyInvitationResult struct {
|
||||
Invitation DailyInvitation
|
||||
HeldBefore int64
|
||||
Granted int64
|
||||
ChangedSlots []int16
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
// DailyResetResult describes one idempotent login/06:00 reconciliation. Only
|
||||
// PeriodDaily policies are normalized; weekly state is deliberately ignored.
|
||||
type DailyResetResult struct {
|
||||
Window Window
|
||||
Snapshots []Snapshot
|
||||
ResetPolicyKeys []string
|
||||
Invitations []DailyInvitationResult
|
||||
CountersChanged bool
|
||||
InvitationChecksChanged bool
|
||||
InventoryChanged bool
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// ReconcileDailyReset atomically advances every supplied daily admission
|
||||
// counter to the current Beijing-06:00 window and performs the once-per-day
|
||||
// login invitation check. A service-day marker is committed with the item
|
||||
// mutation, so consuming the item and relogging cannot mint another copy.
|
||||
func (o *Owner) ReconcileDailyReset(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
policies []Policy,
|
||||
invitations []DailyInvitation,
|
||||
now time.Time,
|
||||
) (DailyResetResult, error) {
|
||||
if o == nil || o.repositories.CharacterSettlement == nil {
|
||||
return DailyResetResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if characterID == "" {
|
||||
return DailyResetResult{}, fmt.Errorf("%w: character is required", ErrCommandInvalid)
|
||||
}
|
||||
dailyPolicies, err := normalizeDailyResetPolicies(policies)
|
||||
if err != nil {
|
||||
return DailyResetResult{}, err
|
||||
}
|
||||
for _, invitation := range invitations {
|
||||
if err := validateDailyInvitation(invitation); err != nil {
|
||||
return DailyResetResult{}, err
|
||||
}
|
||||
}
|
||||
if len(dailyPolicies) == 0 && len(invitations) == 0 {
|
||||
return DailyResetResult{}, nil
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
window, err := ServiceWindow(Policy{
|
||||
Key: "daily_reset.service_window", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 1,
|
||||
}, now)
|
||||
if err != nil {
|
||||
return DailyResetResult{}, err
|
||||
}
|
||||
result := DailyResetResult{Window: window}
|
||||
err = o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Character == nil || tx.Account == nil || tx.Settings == nil || tx.Inventory == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
character, found, loadErr := tx.Character.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found {
|
||||
return ErrCharacterMissing
|
||||
}
|
||||
accountID := strings.TrimSpace(character.AccountID)
|
||||
if accountID == "" {
|
||||
return ErrAccountMissing
|
||||
}
|
||||
account, found, loadErr := tx.Account.Load(ctx, accountID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found {
|
||||
return ErrAccountMissing
|
||||
}
|
||||
account = dnfrepo.CloneAccount(account)
|
||||
if account.Metadata == nil {
|
||||
account.Metadata = make(map[string]string)
|
||||
}
|
||||
|
||||
settingsScope := characterCounterSettingsScope(characterID)
|
||||
settings, settingsFound, loadErr := tx.Settings.Load(ctx, settingsScope)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
settings = dnfrepo.CloneSettings(settings)
|
||||
if !settingsFound {
|
||||
settings.Scope = settingsScope
|
||||
}
|
||||
if settings.Values == nil {
|
||||
settings.Values = make(map[string]string)
|
||||
}
|
||||
|
||||
result.Snapshots = make([]Snapshot, 0, len(dailyPolicies))
|
||||
for _, policy := range dailyPolicies {
|
||||
policyWindow, windowErr := ServiceWindow(policy, now)
|
||||
if windowErr != nil {
|
||||
return windowErr
|
||||
}
|
||||
values := settings.Values
|
||||
if policy.Scope == ScopeAccount {
|
||||
values = account.Metadata
|
||||
}
|
||||
prefix := storagePrefix + digestString(policy.Key) + ":"
|
||||
periodUnix := policyWindow.Start.Unix()
|
||||
storedPeriod, periodOK := parseStoredInt(values[prefix+"period"])
|
||||
used64, usedOK := parseStoredUint(values[prefix+"used"])
|
||||
bonus64, bonusOK := parseStoredUint(values[prefix+"bonus"])
|
||||
periodCurrent := periodOK && storedPeriod == periodUnix && usedOK
|
||||
if !periodCurrent {
|
||||
clearPolicyState(values, prefix)
|
||||
values[prefix+"period"] = strconv.FormatInt(periodUnix, 10)
|
||||
values[prefix+"used"] = "0"
|
||||
used64 = 0
|
||||
bonus64 = 0
|
||||
bonusOK = true
|
||||
result.CountersChanged = true
|
||||
result.ResetPolicyKeys = append(result.ResetPolicyKeys, policy.Key)
|
||||
}
|
||||
if !bonusOK || bonus64 > uint64(math.MaxUint32-policy.Limit) {
|
||||
bonus64 = 0
|
||||
if _, present := values[prefix+"bonus"]; present {
|
||||
delete(values, prefix+"bonus")
|
||||
result.CountersChanged = true
|
||||
}
|
||||
}
|
||||
effectiveLimit := policy.Limit + uint32(bonus64)
|
||||
if used64 > uint64(effectiveLimit) {
|
||||
used64 = uint64(effectiveLimit)
|
||||
values[prefix+"used"] = strconv.FormatUint(used64, 10)
|
||||
result.CountersChanged = true
|
||||
}
|
||||
result.Snapshots = append(result.Snapshots, Snapshot{
|
||||
Policy: policy, Window: policyWindow, Used: uint32(used64), Bonus: uint32(bonus64),
|
||||
EffectiveLimit: effectiveLimit, Remaining: effectiveLimit - uint32(used64),
|
||||
})
|
||||
}
|
||||
|
||||
inventory, inventoryFound, loadErr := tx.Inventory.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !inventoryFound {
|
||||
return ErrDailyInvitationInventoryAbsent
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
if inventory.Slots == nil {
|
||||
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
result.Invitations = make([]DailyInvitationResult, 0, len(invitations))
|
||||
for _, invitation := range invitations {
|
||||
markerKey := dailyInvitationMarkerKey(invitation)
|
||||
markerPeriod, markerOK := parseStoredInt(settings.Values[markerKey])
|
||||
if markerOK && markerPeriod == window.Start.Unix() {
|
||||
result.Invitations = append(result.Invitations, DailyInvitationResult{Invitation: invitation, Replayed: true})
|
||||
continue
|
||||
}
|
||||
invitationResult, ensureErr := ensureDailyInvitation(&inventory, invitation)
|
||||
if ensureErr != nil {
|
||||
return ensureErr
|
||||
}
|
||||
settings.Values[markerKey] = strconv.FormatInt(window.Start.Unix(), 10)
|
||||
result.InvitationChecksChanged = true
|
||||
result.Invitations = append(result.Invitations, invitationResult)
|
||||
if invitationResult.Granted > 0 {
|
||||
result.InventoryChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
result.Changed = result.CountersChanged || result.InvitationChecksChanged || result.InventoryChanged
|
||||
if result.InventoryChanged {
|
||||
inventory.UpdatedAt = now
|
||||
if saveErr := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
// A first daily invitation check changes only its marker when the item
|
||||
// already exists, so settings must still be saved even without a grant.
|
||||
if result.CountersChanged || result.InvitationChecksChanged {
|
||||
settings.UpdatedAt = now
|
||||
if saveErr := dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
if result.CountersChanged {
|
||||
account.UpdatedAt = now
|
||||
if saveErr := tx.Account.Save(ctx, account); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return DailyResetResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeDailyResetPolicies(policies []Policy) ([]Policy, error) {
|
||||
if len(policies) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
normalized, err := normalizePolicies(policies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
daily := make([]Policy, 0, len(normalized))
|
||||
for _, policy := range normalized {
|
||||
if policy.Period == PeriodDaily {
|
||||
daily = append(daily, policy)
|
||||
}
|
||||
}
|
||||
return daily, nil
|
||||
}
|
||||
|
||||
func validateDailyInvitation(invitation DailyInvitation) error {
|
||||
checkSlotStart, checkSlotEnd := dailyInvitationCheckSlotRange(invitation)
|
||||
if invitation.ItemID <= 0 || invitation.Minimum <= 0 || invitation.StackLimit < 0 ||
|
||||
invitation.ListType != dnfrepo.MainInventoryListType || invitation.SlotStart < 0 || invitation.SlotEnd < invitation.SlotStart ||
|
||||
checkSlotStart < 0 || checkSlotEnd < checkSlotStart ||
|
||||
strings.TrimSpace(invitation.PVFPath) == "" || strings.TrimSpace(invitation.StackableType) == "" {
|
||||
return fmt.Errorf("%w: item=%d minimum=%d stack_limit=%d list=%d check_slots=%d..%d target_slots=%d..%d path=%q type=%q",
|
||||
ErrDailyInvitationInvalid, invitation.ItemID, invitation.Minimum, invitation.StackLimit,
|
||||
invitation.ListType, checkSlotStart, checkSlotEnd, invitation.SlotStart, invitation.SlotEnd,
|
||||
invitation.PVFPath, invitation.StackableType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dailyInvitationCheckSlotRange(invitation DailyInvitation) (int16, int16) {
|
||||
if invitation.CheckSlotStart == 0 && invitation.CheckSlotEnd == 0 {
|
||||
return invitation.SlotStart, invitation.SlotEnd
|
||||
}
|
||||
return invitation.CheckSlotStart, invitation.CheckSlotEnd
|
||||
}
|
||||
|
||||
func dailyInvitationMarkerKey(invitation DailyInvitation) string {
|
||||
identity := strconv.FormatInt(invitation.ItemID, 10) + ":" + strings.ToLower(strings.TrimSpace(invitation.PVFPath))
|
||||
return storagePrefix + "daily_invitation:" + digestString(identity) + ":period"
|
||||
}
|
||||
|
||||
func ensureDailyInvitation(inventory *dnfrepo.InventoryRecord, invitation DailyInvitation) (DailyInvitationResult, error) {
|
||||
result := DailyInvitationResult{Invitation: invitation}
|
||||
checkSlotStart, checkSlotEnd := dailyInvitationCheckSlotRange(invitation)
|
||||
for slot := checkSlotStart; slot <= checkSlotEnd; slot++ {
|
||||
stack, found := inventory.Slots[dailyInvitationSlotKey(invitation.ListType, slot)]
|
||||
if found && stack.ItemID == invitation.ItemID && stack.Count > 0 {
|
||||
if result.HeldBefore > math.MaxInt64-stack.Count {
|
||||
result.HeldBefore = math.MaxInt64
|
||||
} else {
|
||||
result.HeldBefore += stack.Count
|
||||
}
|
||||
}
|
||||
}
|
||||
remaining := invitation.Minimum - result.HeldBefore
|
||||
if remaining <= 0 {
|
||||
return result, nil
|
||||
}
|
||||
// PVF [stack limit] 是这个容器里该道具的总量上限:额度按整个容器里同 id 总量算
|
||||
// (不能只看 check 范围),容器里已经有该道具的行时不能再开第二行。
|
||||
newRowStackLimit := int64(0)
|
||||
space := dnfrepo.ContainerUncappedSpace
|
||||
if invitation.StackLimit > 0 {
|
||||
newRowStackLimit = invitation.StackLimit
|
||||
space = dnfrepo.ContainerItemSpace(inventory.Slots, invitation.ListType, invitation.ItemID, invitation.StackLimit)
|
||||
}
|
||||
markChanged := func(slot int16) {
|
||||
for _, changed := range result.ChangedSlots {
|
||||
if changed == slot {
|
||||
return
|
||||
}
|
||||
}
|
||||
result.ChangedSlots = append(result.ChangedSlots, slot)
|
||||
}
|
||||
for slot := invitation.SlotStart; slot <= invitation.SlotEnd && remaining > 0; slot++ {
|
||||
key := dailyInvitationSlotKey(invitation.ListType, slot)
|
||||
stack, found := inventory.Slots[key]
|
||||
if !found || stack.ItemID != invitation.ItemID || stack.Count <= 0 || invitation.StackLimit == 1 {
|
||||
continue
|
||||
}
|
||||
capacity := remaining
|
||||
if invitation.StackLimit > 0 {
|
||||
capacity = invitation.StackLimit - stack.Count
|
||||
}
|
||||
if capacity > space {
|
||||
capacity = space
|
||||
}
|
||||
if capacity <= 0 {
|
||||
continue
|
||||
}
|
||||
add := remaining
|
||||
if add > capacity {
|
||||
add = capacity
|
||||
}
|
||||
stack = cloneDailyInvitationStack(stack)
|
||||
stack.Count += add
|
||||
inventory.Slots[key] = stack
|
||||
remaining -= add
|
||||
space -= add
|
||||
result.Granted += add
|
||||
markChanged(slot)
|
||||
}
|
||||
for slot := invitation.SlotStart; slot <= invitation.SlotEnd && remaining > 0; slot++ {
|
||||
key := dailyInvitationSlotKey(invitation.ListType, slot)
|
||||
if _, occupied := inventory.Slots[key]; occupied {
|
||||
continue
|
||||
}
|
||||
if !dnfrepo.CanOpenContainerItemRow(inventory.Slots, invitation.ListType, invitation.ItemID, newRowStackLimit) {
|
||||
break
|
||||
}
|
||||
insert := remaining
|
||||
if invitation.StackLimit > 0 && insert > invitation.StackLimit {
|
||||
insert = invitation.StackLimit
|
||||
}
|
||||
if insert > space {
|
||||
insert = space
|
||||
}
|
||||
if insert <= 0 {
|
||||
break
|
||||
}
|
||||
inventory.Slots[key] = dnfrepo.ItemStack{
|
||||
ItemID: invitation.ItemID,
|
||||
Count: insert,
|
||||
Extra: map[string]string{
|
||||
"item_kind": "stackable",
|
||||
"pvf_path": invitation.PVFPath,
|
||||
"stackable_type": invitation.StackableType,
|
||||
},
|
||||
}
|
||||
remaining -= insert
|
||||
space -= insert
|
||||
result.Granted += insert
|
||||
markChanged(slot)
|
||||
}
|
||||
if remaining > 0 {
|
||||
return DailyInvitationResult{}, fmt.Errorf("%w: item=%d remaining=%d slots=%d..%d",
|
||||
ErrDailyInvitationInventoryFull, invitation.ItemID, remaining, invitation.SlotStart, invitation.SlotEnd)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func dailyInvitationSlotKey(listType byte, slot int16) string {
|
||||
return fmt.Sprintf("%d:%d", listType, slot)
|
||||
}
|
||||
|
||||
func cloneDailyInvitationStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
|
||||
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
||||
if stack.Extra != nil {
|
||||
extra := make(map[string]string, len(stack.Extra))
|
||||
for key, value := range stack.Extra {
|
||||
extra[key] = value
|
||||
}
|
||||
stack.Extra = extra
|
||||
}
|
||||
return stack
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package dungeonadmission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestReconcileDailyResetResetsOnlyDailyPoliciesAndGrantsMissingInvitationsOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := memory.NewMemoryGroup()
|
||||
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "11", AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "11",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:121": {ItemID: 10161065, Count: 2},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dailyCharacter := Policy{Key: "tower.character.daily", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 3}
|
||||
dailyAccount := Policy{Key: "tower.account.daily", Scope: ScopeAccount, Period: PeriodDaily, Limit: 3}
|
||||
weekly := Policy{Key: "raid.weekly", Scope: ScopeCharacter, Period: PeriodWeekly, Limit: 2, ResetWeekday: time.Thursday}
|
||||
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{dailyCharacter, dailyAccount, weekly}, "before-reset", before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invitations := []DailyInvitation{
|
||||
{ItemID: 4473, Minimum: 1, ListType: dnfrepo.MainInventoryListType, CheckSlotStart: 121, CheckSlotEnd: 232, SlotStart: 177, SlotEnd: 232, PVFPath: "stackable/invitation_drapyus2.stk", StackableType: "[material]"},
|
||||
{ItemID: 10161065, Minimum: 1, ListType: dnfrepo.MainInventoryListType, CheckSlotStart: 121, CheckSlotEnd: 232, SlotStart: 177, SlotEnd: 232, PVFPath: "stackable/10161001/10161065.stk", StackableType: "[material]"},
|
||||
}
|
||||
result, err := owner.ReconcileDailyReset(ctx, "11", []Policy{dailyCharacter, dailyAccount, weekly}, invitations, after)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Changed || !result.CountersChanged || !result.InventoryChanged || !result.InvitationChecksChanged ||
|
||||
len(result.ResetPolicyKeys) != 2 || len(result.Snapshots) != 2 || len(result.Invitations) != 2 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
if result.Invitations[0].Granted != 1 || len(result.Invitations[0].ChangedSlots) != 1 || result.Invitations[0].ChangedSlots[0] != 177 {
|
||||
t.Fatalf("despair invitation=%+v", result.Invitations[0])
|
||||
}
|
||||
if result.Invitations[1].HeldBefore != 2 || result.Invitations[1].Granted != 0 || len(result.Invitations[1].ChangedSlots) != 0 {
|
||||
t.Fatalf("grief invitation=%+v", result.Invitations[1])
|
||||
}
|
||||
for _, policy := range []Policy{dailyCharacter, dailyAccount} {
|
||||
snapshot, err := owner.Preflight(ctx, "11", []Policy{policy}, after)
|
||||
if err != nil || len(snapshot) != 1 || snapshot[0].Used != 0 || snapshot[0].Remaining != 3 {
|
||||
t.Fatalf("daily policy=%s snapshot=%+v err=%v", policy.Key, snapshot, err)
|
||||
}
|
||||
}
|
||||
weeklySnapshot, err := owner.Preflight(ctx, "11", []Policy{weekly}, after)
|
||||
if err != nil || len(weeklySnapshot) != 1 || weeklySnapshot[0].Used != 1 {
|
||||
t.Fatalf("weekly snapshot=%+v err=%v", weeklySnapshot, err)
|
||||
}
|
||||
|
||||
replay, err := owner.ReconcileDailyReset(ctx, "11", []Policy{dailyCharacter, dailyAccount, weekly}, invitations, after.Add(time.Hour))
|
||||
if err != nil || replay.Changed || replay.InventoryChanged || len(replay.Invitations) != 2 || !replay.Invitations[0].Replayed || !replay.Invitations[1].Replayed {
|
||||
t.Fatalf("replay=%+v err=%v", replay, err)
|
||||
}
|
||||
inventory, found, err := repositories.Inventory.Load(ctx, "11")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("inventory found=%t err=%v", found, err)
|
||||
}
|
||||
delete(inventory.Slots, "0:177")
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, repositories.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterConsume, err := owner.ReconcileDailyReset(ctx, "11", []Policy{dailyCharacter, dailyAccount, weekly}, invitations, after.Add(2*time.Hour))
|
||||
if err != nil || afterConsume.InventoryChanged || !afterConsume.Invitations[0].Replayed {
|
||||
t.Fatalf("after consume=%+v err=%v", afterConsume, err)
|
||||
}
|
||||
inventory, _, _ = repositories.Inventory.Load(ctx, "11")
|
||||
if _, regenerated := inventory.Slots["0:177"]; regenerated {
|
||||
t.Fatalf("same-day relog regenerated consumed invitation: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package dungeonadmission owns PVF-backed daily and weekly dungeon-entry
|
||||
// counters. Packet layout and dungeon runtime integration remain in dnfbridge.
|
||||
package dungeonadmission
|
||||
@@ -0,0 +1,608 @@
|
||||
package dungeonadmission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
characterSettingsSuffix = ":dungeon_admission_counters_v1"
|
||||
storagePrefix = "dungeon_admission_counter_v1:"
|
||||
)
|
||||
|
||||
const (
|
||||
receiptActive = "active"
|
||||
receiptRestored = "restored"
|
||||
)
|
||||
|
||||
// Owner is the database-neutral admission-counter transaction boundary. It
|
||||
// reuses the existing character-settlement unit of work so account and
|
||||
// character policies for one admission commit together in memory, SQLite and
|
||||
// MySQL without adding a backend-specific side table.
|
||||
type Owner struct {
|
||||
repositories dnfrepo.Group
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.Character == nil || repositories.Account == nil || repositories.Settings == nil || repositories.CharacterSettlement == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{repositories: repositories}, nil
|
||||
}
|
||||
|
||||
// Preflight performs a read-only early rejection. Commit repeats the same
|
||||
// limit check inside the durable transaction and remains authoritative under
|
||||
// concurrent admissions.
|
||||
func (o *Owner) Preflight(ctx context.Context, characterID string, policies []Policy, now time.Time) ([]Snapshot, error) {
|
||||
if o == nil || o.repositories.Character == nil || o.repositories.Account == nil || o.repositories.Settings == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
policies, err := normalizePolicies(policies)
|
||||
if err != nil || characterID == "" {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("%w: character is required", ErrCommandInvalid)
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
character, found, err := o.repositories.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrCharacterMissing
|
||||
}
|
||||
accountID := strings.TrimSpace(character.AccountID)
|
||||
if accountID == "" {
|
||||
return nil, ErrAccountMissing
|
||||
}
|
||||
account, found, err := o.repositories.Account.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrAccountMissing
|
||||
}
|
||||
settingsScope := characterCounterSettingsScope(characterID)
|
||||
settings, _, err := o.repositories.Settings.Load(ctx, settingsScope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return evaluatePolicies(policies, now, settings.Values, account.Metadata, "", false)
|
||||
}
|
||||
|
||||
// Commit atomically increments every supplied policy once. Receipt must be a
|
||||
// stable successful-admission identity (the dungeon lifecycle token is the
|
||||
// intended bridge input). Repeating the same receipt in one service window is
|
||||
// read-only and returns Replayed snapshots.
|
||||
func (o *Owner) Commit(ctx context.Context, characterID string, policies []Policy, receipt string, now time.Time) ([]Snapshot, error) {
|
||||
if o == nil || o.repositories.CharacterSettlement == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
receipt = strings.TrimSpace(receipt)
|
||||
policies, err := normalizePolicies(policies)
|
||||
if err != nil || characterID == "" || receipt == "" {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("%w: character and receipt are required", ErrCommandInvalid)
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
var snapshots []Snapshot
|
||||
err = o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Character == nil || tx.Account == nil || tx.Settings == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
character, found, loadErr := tx.Character.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found {
|
||||
return ErrCharacterMissing
|
||||
}
|
||||
accountID := strings.TrimSpace(character.AccountID)
|
||||
if accountID == "" {
|
||||
return ErrAccountMissing
|
||||
}
|
||||
account, found, loadErr := tx.Account.Load(ctx, accountID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found {
|
||||
return ErrAccountMissing
|
||||
}
|
||||
account = dnfrepo.CloneAccount(account)
|
||||
if account.Metadata == nil {
|
||||
account.Metadata = make(map[string]string)
|
||||
}
|
||||
|
||||
settingsScope := characterCounterSettingsScope(characterID)
|
||||
settings, settingsFound, loadErr := tx.Settings.Load(ctx, settingsScope)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
settings = dnfrepo.CloneSettings(settings)
|
||||
if !settingsFound {
|
||||
settings.Scope = settingsScope
|
||||
}
|
||||
if settings.Values == nil {
|
||||
settings.Values = make(map[string]string)
|
||||
}
|
||||
|
||||
var evaluateErr error
|
||||
snapshots, evaluateErr = evaluatePolicies(policies, now, settings.Values, account.Metadata, receipt, true)
|
||||
if evaluateErr != nil {
|
||||
return evaluateErr
|
||||
}
|
||||
settings.UpdatedAt = now
|
||||
account.UpdatedAt = now
|
||||
if saveErr := dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return tx.Account.Save(ctx, account)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
func evaluatePolicies(
|
||||
policies []Policy,
|
||||
now time.Time,
|
||||
characterValues map[string]string,
|
||||
accountValues map[string]string,
|
||||
receipt string,
|
||||
mutate bool,
|
||||
) ([]Snapshot, error) {
|
||||
snapshots := make([]Snapshot, 0, len(policies))
|
||||
receiptDigest := digestString(receipt)
|
||||
for _, policy := range policies {
|
||||
window, err := ServiceWindow(policy, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := characterValues
|
||||
if policy.Scope == ScopeAccount {
|
||||
values = accountValues
|
||||
}
|
||||
if values == nil {
|
||||
values = map[string]string{}
|
||||
}
|
||||
prefix := storagePrefix + digestString(policy.Key) + ":"
|
||||
periodKey := prefix + "period"
|
||||
usedKey := prefix + "used"
|
||||
periodUnix := window.Start.Unix()
|
||||
storedPeriod, periodOK := parseStoredInt(values[periodKey])
|
||||
used64, usedOK := parseStoredUint(values[usedKey])
|
||||
periodCurrent := periodOK && storedPeriod == periodUnix && usedOK
|
||||
if !periodCurrent {
|
||||
used64 = 0
|
||||
if mutate {
|
||||
clearPolicyState(values, prefix)
|
||||
values[periodKey] = strconv.FormatInt(periodUnix, 10)
|
||||
values[usedKey] = "0"
|
||||
}
|
||||
}
|
||||
bonus64, bonusOK := parseStoredUint(values[prefix+"bonus"])
|
||||
if !periodCurrent || !bonusOK || bonus64 > uint64(^uint32(0)-policy.Limit) {
|
||||
bonus64 = 0
|
||||
}
|
||||
effectiveLimit := policy.Limit + uint32(bonus64)
|
||||
if used64 > uint64(effectiveLimit) {
|
||||
used64 = uint64(effectiveLimit)
|
||||
}
|
||||
replayed := false
|
||||
receiptKey := prefix + "receipt:" + receiptDigest
|
||||
receiptValue := values[receiptKey]
|
||||
activeReceiptValue := formatReceiptValue(periodUnix, receiptActive)
|
||||
restoredReceiptValue := formatReceiptValue(periodUnix, receiptRestored)
|
||||
if receipt != "" && receiptValue == activeReceiptValue {
|
||||
replayed = true
|
||||
} else if receipt != "" && receiptValue == restoredReceiptValue {
|
||||
return nil, fmt.Errorf("%w: policy=%s", ErrReceiptRestored, policy.Key)
|
||||
}
|
||||
used := uint32(used64)
|
||||
snapshot := Snapshot{
|
||||
Policy: policy, Window: window, Used: used, Bonus: uint32(bonus64),
|
||||
EffectiveLimit: effectiveLimit, Remaining: effectiveLimit - used, Replayed: replayed,
|
||||
}
|
||||
if !replayed && used >= effectiveLimit {
|
||||
return nil, &LimitError{Snapshot: snapshot}
|
||||
}
|
||||
if mutate && !replayed {
|
||||
used++
|
||||
values[usedKey] = strconv.FormatUint(uint64(used), 10)
|
||||
values[receiptKey] = activeReceiptValue
|
||||
snapshot.Used = used
|
||||
snapshot.Remaining = effectiveLimit - used
|
||||
}
|
||||
snapshots = append(snapshots, snapshot)
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// GrantAllowances atomically applies every bounded service-window allowance
|
||||
// and the caller's source-item mutation. A rejected cap, invalid character,
|
||||
// inventory mutation failure, or repository save failure commits neither side.
|
||||
func (o *Owner) GrantAllowances(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
grants []AllowanceGrant,
|
||||
useLimit AllowanceUseLimit,
|
||||
now time.Time,
|
||||
mutate func(dnfrepo.Group) error,
|
||||
) (AllowanceResult, error) {
|
||||
if o == nil || o.repositories.CharacterSettlement == nil || mutate == nil {
|
||||
return AllowanceResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if characterID == "" || len(grants) == 0 {
|
||||
return AllowanceResult{}, fmt.Errorf("%w: character and grants are required", ErrCommandInvalid)
|
||||
}
|
||||
policies := make([]Policy, 0, len(grants))
|
||||
grantByIdentity := make(map[string]AllowanceGrant, len(grants))
|
||||
for _, grant := range grants {
|
||||
if grant.Amount == 0 || grant.MaximumBonus == 0 || grant.Amount > grant.MaximumBonus {
|
||||
return AllowanceResult{}, fmt.Errorf("%w: invalid allowance amount=%d maximum=%d", ErrCommandInvalid, grant.Amount, grant.MaximumBonus)
|
||||
}
|
||||
policies = append(policies, grant.Policy)
|
||||
grantByIdentity[policyIdentity(grant.Policy)] = grant
|
||||
}
|
||||
var err error
|
||||
policies, err = normalizePolicies(policies)
|
||||
if err != nil {
|
||||
return AllowanceResult{}, err
|
||||
}
|
||||
useLimit.Key = strings.TrimSpace(useLimit.Key)
|
||||
if (useLimit.Key == "") != (useLimit.Limit == 0) {
|
||||
return AllowanceResult{}, fmt.Errorf("%w: allowance use key and limit must both be set", ErrCommandInvalid)
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
var result AllowanceResult
|
||||
err = o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Character == nil || tx.Account == nil || tx.Settings == nil || tx.Inventory == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
character, found, loadErr := tx.Character.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found {
|
||||
return ErrCharacterMissing
|
||||
}
|
||||
accountID := strings.TrimSpace(character.AccountID)
|
||||
if accountID == "" {
|
||||
return ErrAccountMissing
|
||||
}
|
||||
account, found, loadErr := tx.Account.Load(ctx, accountID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found {
|
||||
return ErrAccountMissing
|
||||
}
|
||||
account = dnfrepo.CloneAccount(account)
|
||||
if account.Metadata == nil {
|
||||
account.Metadata = make(map[string]string)
|
||||
}
|
||||
settingsScope := characterCounterSettingsScope(characterID)
|
||||
settings, settingsFound, loadErr := tx.Settings.Load(ctx, settingsScope)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
settings = dnfrepo.CloneSettings(settings)
|
||||
if !settingsFound {
|
||||
settings.Scope = settingsScope
|
||||
}
|
||||
if settings.Values == nil {
|
||||
settings.Values = make(map[string]string)
|
||||
}
|
||||
|
||||
if useLimit.Limit > 0 {
|
||||
window, windowErr := ServiceWindow(Policy{Key: useLimit.Key, Scope: ScopeCharacter, Period: PeriodDaily, Limit: useLimit.Limit}, now)
|
||||
if windowErr != nil {
|
||||
return windowErr
|
||||
}
|
||||
prefix := storagePrefix + "allowance_use:" + digestString(useLimit.Key) + ":"
|
||||
periodUnix := window.Start.Unix()
|
||||
storedPeriod, periodOK := parseStoredInt(settings.Values[prefix+"period"])
|
||||
used64, usedOK := parseStoredUint(settings.Values[prefix+"used"])
|
||||
if !periodOK || storedPeriod != periodUnix || !usedOK {
|
||||
clearPolicyState(settings.Values, prefix)
|
||||
settings.Values[prefix+"period"] = strconv.FormatInt(periodUnix, 10)
|
||||
used64 = 0
|
||||
}
|
||||
if used64 >= uint64(useLimit.Limit) {
|
||||
return fmt.Errorf("%w: ticket=%s used=%d limit=%d", ErrAllowanceLimit, useLimit.Key, used64, useLimit.Limit)
|
||||
}
|
||||
used64++
|
||||
settings.Values[prefix+"used"] = strconv.FormatUint(used64, 10)
|
||||
result.Uses = uint32(used64)
|
||||
result.RemainingUses = useLimit.Limit - result.Uses
|
||||
}
|
||||
|
||||
result.Snapshots = make([]Snapshot, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
grant, ok := grantByIdentity[policyIdentity(policy)]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: allowance policy %s is missing", ErrCommandInvalid, policy.Key)
|
||||
}
|
||||
window, windowErr := ServiceWindow(policy, now)
|
||||
if windowErr != nil {
|
||||
return windowErr
|
||||
}
|
||||
values := settings.Values
|
||||
if policy.Scope == ScopeAccount {
|
||||
values = account.Metadata
|
||||
}
|
||||
prefix := storagePrefix + digestString(policy.Key) + ":"
|
||||
periodUnix := window.Start.Unix()
|
||||
storedPeriod, periodOK := parseStoredInt(values[prefix+"period"])
|
||||
used64, usedOK := parseStoredUint(values[prefix+"used"])
|
||||
bonus64, bonusOK := parseStoredUint(values[prefix+"bonus"])
|
||||
if !periodOK || storedPeriod != periodUnix || !usedOK {
|
||||
clearPolicyState(values, prefix)
|
||||
values[prefix+"period"] = strconv.FormatInt(periodUnix, 10)
|
||||
values[prefix+"used"] = "0"
|
||||
used64 = 0
|
||||
bonus64 = 0
|
||||
} else if !bonusOK {
|
||||
bonus64 = 0
|
||||
}
|
||||
if bonus64 > uint64(grant.MaximumBonus) || uint64(grant.Amount) > uint64(grant.MaximumBonus)-bonus64 {
|
||||
return fmt.Errorf("%w: policy=%s bonus=%d add=%d maximum=%d", ErrAllowanceLimit, policy.Key, bonus64, grant.Amount, grant.MaximumBonus)
|
||||
}
|
||||
newBonus := uint32(bonus64) + grant.Amount
|
||||
if newBonus > ^uint32(0)-policy.Limit {
|
||||
return fmt.Errorf("%w: policy=%s allowance overflow", ErrCommandInvalid, policy.Key)
|
||||
}
|
||||
effectiveLimit := policy.Limit + newBonus
|
||||
if used64 > uint64(effectiveLimit) {
|
||||
return fmt.Errorf("%w: policy=%s used=%d effective_limit=%d", ErrReceiptState, policy.Key, used64, effectiveLimit)
|
||||
}
|
||||
values[prefix+"bonus"] = strconv.FormatUint(uint64(newBonus), 10)
|
||||
result.Snapshots = append(result.Snapshots, Snapshot{
|
||||
Policy: policy, Window: window, Used: uint32(used64), Bonus: newBonus,
|
||||
EffectiveLimit: effectiveLimit, Remaining: effectiveLimit - uint32(used64),
|
||||
})
|
||||
}
|
||||
|
||||
if mutationErr := mutate(tx); mutationErr != nil {
|
||||
return mutationErr
|
||||
}
|
||||
settings.UpdatedAt = now
|
||||
account.UpdatedAt = now
|
||||
if saveErr := dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return tx.Account.Save(ctx, account)
|
||||
})
|
||||
if err != nil {
|
||||
return AllowanceResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func policyIdentity(policy Policy) string {
|
||||
return strings.TrimSpace(policy.Key) + ":" + strconv.Itoa(int(policy.Scope))
|
||||
}
|
||||
|
||||
// Restore compensates a committed counter reservation when a later material,
|
||||
// party-member, runtime-attach, or runtime-commit step fails. It decrements
|
||||
// only the exact active receipt in the same service window and leaves a
|
||||
// restored tombstone so repeating the compensation cannot decrement twice.
|
||||
// A caller must allocate a fresh receipt before retrying the admission.
|
||||
func (o *Owner) Restore(ctx context.Context, characterID string, policies []Policy, receipt string, now time.Time) ([]Snapshot, error) {
|
||||
if o == nil || o.repositories.CharacterSettlement == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
receipt = strings.TrimSpace(receipt)
|
||||
policies, err := normalizePolicies(policies)
|
||||
if err != nil || characterID == "" || receipt == "" {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("%w: character and receipt are required", ErrCommandInvalid)
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
var snapshots []Snapshot
|
||||
err = o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Character == nil || tx.Account == nil || tx.Settings == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
character, found, loadErr := tx.Character.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found {
|
||||
return ErrCharacterMissing
|
||||
}
|
||||
accountID := strings.TrimSpace(character.AccountID)
|
||||
if accountID == "" {
|
||||
return ErrAccountMissing
|
||||
}
|
||||
account, found, loadErr := tx.Account.Load(ctx, accountID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found {
|
||||
return ErrAccountMissing
|
||||
}
|
||||
account = dnfrepo.CloneAccount(account)
|
||||
if account.Metadata == nil {
|
||||
account.Metadata = make(map[string]string)
|
||||
}
|
||||
settingsScope := characterCounterSettingsScope(characterID)
|
||||
settings, settingsFound, loadErr := tx.Settings.Load(ctx, settingsScope)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
settings = dnfrepo.CloneSettings(settings)
|
||||
if !settingsFound {
|
||||
settings.Scope = settingsScope
|
||||
}
|
||||
if settings.Values == nil {
|
||||
settings.Values = make(map[string]string)
|
||||
}
|
||||
|
||||
snapshots, loadErr = restorePolicies(policies, now, settings.Values, account.Metadata, receipt)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
settings.UpdatedAt = now
|
||||
account.UpdatedAt = now
|
||||
if saveErr := dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return tx.Account.Save(ctx, account)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
func restorePolicies(policies []Policy, now time.Time, characterValues, accountValues map[string]string, receipt string) ([]Snapshot, error) {
|
||||
type restoreState struct {
|
||||
policy Policy
|
||||
window Window
|
||||
values map[string]string
|
||||
usedKey string
|
||||
receiptKey string
|
||||
used uint32
|
||||
bonus uint32
|
||||
status string
|
||||
}
|
||||
states := make([]restoreState, 0, len(policies))
|
||||
receiptDigest := digestString(receipt)
|
||||
activeCount := 0
|
||||
restoredCount := 0
|
||||
missingCount := 0
|
||||
for _, policy := range policies {
|
||||
window, err := ServiceWindow(policy, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := characterValues
|
||||
if policy.Scope == ScopeAccount {
|
||||
values = accountValues
|
||||
}
|
||||
prefix := storagePrefix + digestString(policy.Key) + ":"
|
||||
periodUnix := window.Start.Unix()
|
||||
storedPeriod, periodOK := parseStoredInt(values[prefix+"period"])
|
||||
used64, usedOK := parseStoredUint(values[prefix+"used"])
|
||||
bonus64, bonusOK := parseStoredUint(values[prefix+"bonus"])
|
||||
periodCurrent := periodOK && storedPeriod == periodUnix && usedOK
|
||||
if !periodCurrent || !bonusOK || bonus64 > uint64(^uint32(0)-policy.Limit) {
|
||||
bonus64 = 0
|
||||
}
|
||||
effectiveLimit := policy.Limit + uint32(bonus64)
|
||||
if !periodCurrent || used64 > uint64(effectiveLimit) {
|
||||
used64 = 0
|
||||
}
|
||||
receiptKey := prefix + "receipt:" + receiptDigest
|
||||
status := ""
|
||||
switch values[receiptKey] {
|
||||
case formatReceiptValue(periodUnix, receiptActive):
|
||||
status = receiptActive
|
||||
activeCount++
|
||||
case formatReceiptValue(periodUnix, receiptRestored):
|
||||
status = receiptRestored
|
||||
restoredCount++
|
||||
default:
|
||||
missingCount++
|
||||
}
|
||||
states = append(states, restoreState{policy: policy, window: window, values: values, usedKey: prefix + "used", receiptKey: receiptKey, used: uint32(used64), bonus: uint32(bonus64), status: status})
|
||||
}
|
||||
if activeCount != 0 && (restoredCount != 0 || missingCount != 0) || restoredCount != 0 && missingCount != 0 {
|
||||
return nil, fmt.Errorf("%w: active=%d restored=%d missing=%d", ErrReceiptState, activeCount, restoredCount, missingCount)
|
||||
}
|
||||
snapshots := make([]Snapshot, 0, len(states))
|
||||
for _, state := range states {
|
||||
effectiveLimit := state.policy.Limit + state.bonus
|
||||
snapshot := Snapshot{
|
||||
Policy: state.policy, Window: state.window, Used: state.used, Bonus: state.bonus,
|
||||
EffectiveLimit: effectiveLimit, Remaining: effectiveLimit - state.used,
|
||||
}
|
||||
switch state.status {
|
||||
case receiptActive:
|
||||
if state.used == 0 {
|
||||
return nil, fmt.Errorf("%w: policy=%s active receipt has zero use", ErrReceiptState, state.policy.Key)
|
||||
}
|
||||
state.used--
|
||||
state.values[state.usedKey] = strconv.FormatUint(uint64(state.used), 10)
|
||||
state.values[state.receiptKey] = formatReceiptValue(state.window.Start.Unix(), receiptRestored)
|
||||
snapshot.Used = state.used
|
||||
snapshot.Remaining = effectiveLimit - state.used
|
||||
snapshot.Restored = true
|
||||
case receiptRestored:
|
||||
snapshot.Replayed = true
|
||||
snapshot.Restored = true
|
||||
default:
|
||||
snapshot.Replayed = true
|
||||
}
|
||||
snapshots = append(snapshots, snapshot)
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
func formatReceiptValue(periodUnix int64, status string) string {
|
||||
return strconv.FormatInt(periodUnix, 10) + ":" + status
|
||||
}
|
||||
|
||||
func clearPolicyState(values map[string]string, prefix string) {
|
||||
for key := range values {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(values, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func characterCounterSettingsScope(characterID string) string {
|
||||
return "character:" + strings.TrimSpace(characterID) + characterSettingsSuffix
|
||||
}
|
||||
|
||||
func digestString(value string) string {
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(digest[:16])
|
||||
}
|
||||
|
||||
func parseStoredInt(value string) (int64, bool) {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func parseStoredUint(value string) (uint64, bool) {
|
||||
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 32)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func contextOrBackground(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package dungeonadmission
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("dungeon admission counter owner is unavailable")
|
||||
ErrCommandInvalid = errors.New("dungeon admission counter command is invalid")
|
||||
ErrLimitReached = errors.New("dungeon admission counter limit reached")
|
||||
ErrReceiptRestored = errors.New("dungeon admission counter receipt was restored")
|
||||
ErrReceiptState = errors.New("dungeon admission counter receipt state is inconsistent")
|
||||
ErrAllowanceLimit = errors.New("dungeon admission allowance limit reached")
|
||||
ErrCharacterMissing = errors.New("dungeon admission counter character is missing")
|
||||
ErrAccountMissing = errors.New("dungeon admission counter account is missing")
|
||||
)
|
||||
|
||||
// Scope identifies the durable owner of one admission counter. Character and
|
||||
// account limits may both apply to the same dungeon; callers must pass every
|
||||
// policy returned by Catalog.PoliciesForDungeon to Preflight and Commit.
|
||||
type Scope byte
|
||||
|
||||
const (
|
||||
ScopeCharacter Scope = iota + 1
|
||||
ScopeAccount
|
||||
)
|
||||
|
||||
// Period identifies the service-time window used by one counter.
|
||||
type Period byte
|
||||
|
||||
const (
|
||||
PeriodDaily Period = iota + 1
|
||||
PeriodWeekly
|
||||
)
|
||||
|
||||
// Policy is one typed, PVF-backed admission limit. Key is a stable semantic
|
||||
// identity shared by all dungeons that consume the same counter.
|
||||
type Policy struct {
|
||||
Key string
|
||||
SourcePath string
|
||||
SourceSection string
|
||||
Scope Scope
|
||||
Period Period
|
||||
Limit uint32
|
||||
ResetWeekday time.Weekday
|
||||
DungeonIDs []uint32
|
||||
}
|
||||
|
||||
// Window is the exact Beijing service-time interval owning a counter value.
|
||||
// Daily and weekly boundaries both roll at 06:00.
|
||||
type Window struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
// Snapshot is the authoritative counter projection before or after a commit.
|
||||
type Snapshot struct {
|
||||
Policy Policy
|
||||
Window Window
|
||||
Used uint32
|
||||
Bonus uint32
|
||||
EffectiveLimit uint32
|
||||
Remaining uint32
|
||||
Replayed bool
|
||||
Restored bool
|
||||
}
|
||||
|
||||
// AllowanceGrant adds a bounded, service-window-scoped amount to one authored
|
||||
// admission policy. The base Policy limit remains immutable; Preflight and
|
||||
// Commit use Policy.Limit plus the durable Bonus until the next reset window.
|
||||
type AllowanceGrant struct {
|
||||
Policy Policy
|
||||
Amount uint32
|
||||
MaximumBonus uint32
|
||||
}
|
||||
|
||||
// AllowanceUseLimit is an optional per-character daily-use cap for one ticket
|
||||
// family. It is independent of the source inventory slot so moving or sorting
|
||||
// the ticket cannot reset the count.
|
||||
type AllowanceUseLimit struct {
|
||||
Key string
|
||||
Limit uint32
|
||||
}
|
||||
|
||||
// AllowanceResult describes one committed ticket grant.
|
||||
type AllowanceResult struct {
|
||||
Snapshots []Snapshot
|
||||
Uses uint32
|
||||
RemainingUses uint32
|
||||
}
|
||||
|
||||
// TowerFamily identifies one current-PVF single-floor tower progression.
|
||||
// The ordered dungeon list is authored by [each layer matching dungeon].
|
||||
type TowerFamily string
|
||||
|
||||
const (
|
||||
TowerFamilyDespair TowerFamily = "tower_of_despair"
|
||||
TowerFamilyGrief TowerFamily = "tower_of_grief"
|
||||
)
|
||||
|
||||
// TowerUseItem is one item/count pair carried by the current client's tower
|
||||
// entry projection. Negative counts are authored sentinels and are preserved.
|
||||
type TowerUseItem struct {
|
||||
ItemID uint32
|
||||
Count int32
|
||||
}
|
||||
|
||||
// TowerRoute is the immutable active-PVF layer mapping for one tower family.
|
||||
type TowerRoute struct {
|
||||
Family TowerFamily
|
||||
DungeonIDs []uint32
|
||||
UseAllowItems []TowerUseItem
|
||||
}
|
||||
|
||||
// LimitError retains the exhausted policy and current service-time snapshot
|
||||
// while remaining compatible with errors.Is(err, ErrLimitReached).
|
||||
type LimitError struct {
|
||||
Snapshot Snapshot
|
||||
}
|
||||
|
||||
func (e *LimitError) Error() string {
|
||||
if e == nil {
|
||||
return ErrLimitReached.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: policy=%s used=%d limit=%d", ErrLimitReached, e.Snapshot.Policy.Key, e.Snapshot.Used, e.Snapshot.EffectiveLimit)
|
||||
}
|
||||
|
||||
func (e *LimitError) Unwrap() error { return ErrLimitReached }
|
||||
|
||||
var serviceCalendar = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// ServiceWindow applies the project's established DNF service boundary: a
|
||||
// Beijing service day begins at 06:00. Weekly policies begin at 06:00 on the
|
||||
// PVF-authored reset weekday.
|
||||
func ServiceWindow(policy Policy, now time.Time) (Window, error) {
|
||||
if err := validatePolicy(policy); err != nil {
|
||||
return Window{}, err
|
||||
}
|
||||
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 policy.Period {
|
||||
case PeriodDaily:
|
||||
return Window{Start: start, End: start.AddDate(0, 0, 1)}, nil
|
||||
case PeriodWeekly:
|
||||
days := (int(serviceDate.Weekday()) - int(policy.ResetWeekday) + 7) % 7
|
||||
start = start.AddDate(0, 0, -days)
|
||||
return Window{Start: start, End: start.AddDate(0, 0, 7)}, nil
|
||||
default:
|
||||
return Window{}, ErrCommandInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePolicies(policies []Policy) ([]Policy, error) {
|
||||
if len(policies) == 0 {
|
||||
return nil, fmt.Errorf("%w: no policies", ErrCommandInvalid)
|
||||
}
|
||||
out := make([]Policy, len(policies))
|
||||
for index, policy := range policies {
|
||||
policy.Key = strings.TrimSpace(policy.Key)
|
||||
policy.SourcePath = strings.TrimSpace(policy.SourcePath)
|
||||
policy.SourceSection = strings.TrimSpace(policy.SourceSection)
|
||||
policy.DungeonIDs = append([]uint32(nil), policy.DungeonIDs...)
|
||||
if err := validatePolicy(policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[index] = policy
|
||||
}
|
||||
sort.Slice(out, func(left, right int) bool {
|
||||
if out[left].Key != out[right].Key {
|
||||
return out[left].Key < out[right].Key
|
||||
}
|
||||
return out[left].Scope < out[right].Scope
|
||||
})
|
||||
for index := 1; index < len(out); index++ {
|
||||
if out[index-1].Key == out[index].Key && out[index-1].Scope == out[index].Scope {
|
||||
return nil, fmt.Errorf("%w: duplicate policy=%s scope=%d", ErrCommandInvalid, out[index].Key, out[index].Scope)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validatePolicy(policy Policy) error {
|
||||
if strings.TrimSpace(policy.Key) == "" || policy.Limit == 0 ||
|
||||
(policy.Scope != ScopeCharacter && policy.Scope != ScopeAccount) ||
|
||||
(policy.Period != PeriodDaily && policy.Period != PeriodWeekly) ||
|
||||
(policy.Period == PeriodWeekly && (policy.ResetWeekday < time.Sunday || policy.ResetWeekday > time.Saturday)) {
|
||||
return fmt.Errorf("%w: policy=%q scope=%d period=%d limit=%d reset_weekday=%d", ErrCommandInvalid, policy.Key, policy.Scope, policy.Period, policy.Limit, policy.ResetWeekday)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user