package dnfbridge import ( "context" "encoding/json" "fmt" "strings" "time" "longheng.io/server/internal/modules/dnf/adventuregroup" dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) // ============================================================================ // 神秘异次元遗迹「双组计数」只读快照(GM 用) // // 报告要求同时下发两组计数:**账号总用量** 与 **各角色各图用量**。 // 服务端侧这两组由 abisEntryPolicies 生成的两条 Policy 承载: // // abis.weekly_day.account.daily Scope=账号,Limit = 5 + 冒险团分档加成 // abis.weekly_day..character.daily Scope=角色,Limit = 2/1/1/1/1 // // 这里用 Owner.Preflight(**只读、不写库**)把两组的 used/limit/remaining 读出来, // 通过 GET /dnf/gm/abis-weekly-dungeons 暴露。它既是运维观测手段,也是"两组计数 // 确实独立生效"的可复现证据 —— 不需要客户端配合即可验证。 // // 客户端的界面下发另见 abis_guarantee_rewards.go 顶部对本树 op1294/op546 通道的记录。 // ============================================================================ // GMAbisWeeklyDungeonCounterGroup 是一组计数(账号组或角色组)。 type GMAbisWeeklyDungeonCounterGroup struct { Scope string `json:"scope"` // account | character PolicyKey string `json:"policy_key"` Used uint32 `json:"used"` Limit uint32 `json:"limit"` Remaining uint32 `json:"remaining"` Source string `json:"source"` } // GMAbisWeeklyDungeon 是单张周常图的角色侧计数视图。 // // 账号额度是**整组共享**的一个池(见 abisSharedAccountPolicyKey),不属于任何 // 单张图,因此它只出现在 overview 顶层(Account),这里不再重复 —— 否则每张图 // 都打印一遍"0/5",会被误读成"每图各 5 次"。 type GMAbisWeeklyDungeon struct { DungeonID uint32 `json:"dungeon_id"` Name string `json:"name"` OpenToday bool `json:"open_today"` OpenDays []string `json:"open_days"` MinLevel uint32 `json:"min_level"` Character GMAbisWeeklyDungeonCounterGroup `json:"character"` DayStart string `json:"service_day_start"` PolicyCount int `json:"policy_count"` } // GMAbisWeeklyDungeonOverview 是五图的整体视图。Account 是**跨五图共享**的账号池。 type GMAbisWeeklyDungeonOverview struct { CharacterID uint16 `json:"character_id"` AccountID string `json:"account_id"` AdventureGroupLevel uint32 `json:"adventure_group_level"` AccountDailyBase uint32 `json:"account_daily_base"` AdventureGroupBonus uint32 `json:"adventure_group_bonus"` AccountEffectiveCap uint32 `json:"account_effective_cap"` Account GMAbisWeeklyDungeonCounterGroup `json:"account"` ServiceDayStart string `json:"service_day_start"` EnforceOpenDay bool `json:"enforce_open_day"` IndependentRewardIDs []uint32 `json:"guarantee_reward_item_ids"` Dungeons []GMAbisWeeklyDungeon `json:"dungeons"` } const currentAbisWeeklyDungeonMinLevel uint32 = 50 // GMAbisWeeklyDungeons 读取某个角色的五图双组计数快照(只读,不写库)。 func (s *Service) GMAbisWeeklyDungeons(ctx context.Context, characterID uint16, now time.Time) (GMAbisWeeklyDungeonOverview, error) { if s == nil || characterID == 0 { return GMAbisWeeklyDungeonOverview{}, ErrGMUnavailable } repositories, ok := s.repositoryGroup() if !ok || repositories.Character == nil || repositories.Account == nil { return GMAbisWeeklyDungeonOverview{}, ErrGMUnavailable } characterIDText := fmt.Sprint(characterID) character, found, err := repositories.Character.Load(ctx, characterIDText) if err != nil { return GMAbisWeeklyDungeonOverview{}, err } if !found { return GMAbisWeeklyDungeonOverview{}, fmt.Errorf("%w: character %d", dnfdungeonadmission.ErrCharacterMissing, characterID) } accountID := strings.TrimSpace(character.AccountID) if accountID == "" { return GMAbisWeeklyDungeonOverview{}, dnfdungeonadmission.ErrAccountMissing } account, found, err := repositories.Account.Load(ctx, accountID) if err != nil { return GMAbisWeeklyDungeonOverview{}, err } if !found { return GMAbisWeeklyDungeonOverview{}, fmt.Errorf("%w: account %s", dnfdungeonadmission.ErrAccountMissing, accountID) } manageLevel := abisManageLevelFromAccount(account) owner, err := dnfdungeonadmission.NewOwner(repositories) if err != nil { return GMAbisWeeklyDungeonOverview{}, err } if now.IsZero() { now = s.gameplayNow() } overview := GMAbisWeeklyDungeonOverview{ CharacterID: characterID, AccountID: accountID, AdventureGroupLevel: manageLevel, AccountDailyBase: abisAccountDailyBase, AdventureGroupBonus: abisAdventureGroupAddition(manageLevel), AccountEffectiveCap: abisAccountDailyBase + abisAdventureGroupAddition(manageLevel), EnforceOpenDay: abisEnforceOpenDay, Dungeons: make([]GMAbisWeeklyDungeon, 0, len(abisDungeonSpecs)), } for _, spec := range currentAbisGuaranteeRewardItemIDs { overview.IndependentRewardIDs = append(overview.IndependentRewardIDs, spec.ItemID) } for _, spec := range abisDungeonSpecs { policies, policyErr := abisEntryPolicies(spec, manageLevel) if policyErr != nil { return GMAbisWeeklyDungeonOverview{}, policyErr } window, windowErr := abisPolicyWindow(policies[0], now) if windowErr != nil { return GMAbisWeeklyDungeonOverview{}, windowErr } entry := GMAbisWeeklyDungeon{ DungeonID: spec.id, Name: spec.name, OpenToday: abisOpenToday(spec, window), MinLevel: currentAbisWeeklyDungeonMinLevel, DayStart: window.Start.Format(time.RFC3339), PolicyCount: len(policies), } for _, day := range spec.days { entry.OpenDays = append(entry.OpenDays, day.String()) } overview.ServiceDayStart = entry.DayStart // 用 ReconcileDailyReset 读取两组快照,而**不是** Preflight: // evaluatePolicies 在 used >= limit 时会提前返回 LimitError 并**丢弃已构造的 // 快照**,那样恰好读不到"已用满"的那一组 —— 而那正是运维最需要看到的。 // ReconcileDailyReset 只做窗口归一化(跨日时把计数清到 0),不消耗额度、 // 不写回执,且每张图的 Limit 都已按当前冒险团等级给出,因此两组 used/limit/ // remaining 都能读全。它不会因为额度用尽而报错。 reset, resetErr := owner.ReconcileDailyReset(ctx, characterIDText, policies, nil, now) if resetErr != nil { return GMAbisWeeklyDungeonOverview{}, resetErr } for _, snapshot := range reset.Snapshots { group := GMAbisWeeklyDungeonCounterGroup{ PolicyKey: snapshot.Policy.Key, Used: snapshot.Used, Limit: snapshot.EffectiveLimit, Remaining: snapshot.Remaining, Source: snapshot.Policy.SourceSection, } if snapshot.Policy.Scope == dnfdungeonadmission.ScopeAccount { // 共享账号池:只记顶层一次(每张图都会带同一条账号策略)。 group.Scope = "account" overview.Account = group continue } group.Scope = "character" entry.Character = group } overview.Dungeons = append(overview.Dungeons, entry) } return overview, nil } // abisManageLevelFromAccount 从账号 Metadata 的 adventure_group_growth_v1 解析 // 冒险团等级;缺失或不可解析时返回 0(纯基础额度)。 func abisManageLevelFromAccount(account dnfrepo.AccountRecord) uint32 { raw := strings.TrimSpace(account.Metadata[adventuregroup.GrowthMetadataKey]) if raw == "" { return 0 } var growth struct { Level int `json:"level"` } if err := json.Unmarshal([]byte(raw), &growth); err != nil || growth.Level <= 0 { return 0 } return uint32(growth.Level) }