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

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

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

No files matched your search

@@ -0,0 +1,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
}