package dnfbridge import ( "context" "errors" "fmt" "strings" "time" dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission" ) // ============================================================================ // 神秘异次元遗迹「每日五图」准入(4906~4910) // // 五张图:4906 南部溪谷秘密祭坛、4907 牛头怪军团、4908 突袭赫拉斯、 // 4909 暗精灵英雄的考验、4910 怪虫栖息地。 // // 为什么不能直接吃 catalog 现成的 policy: // etc/accountcharacdungeonincountchn.etc 只声明**周**周期(周四重置), // 而实际准入是**日**额度 —— 账号每天共 5 次(再加冒险团分档 0~5), // 每张图还有独立的角色上限 2/1/1/1/1,并按开放日开门。 // catalog.loadExplicitWeekly 对这份 PVF 只产出 Evidence、不产出 Policy, // 所以 PoliciesForDungeon(4906) 在本轮改造前返回空 —— 这五图从未被计入准入。 // // 这里改用一个**纯数据**的做法:把 PVF 里的两组额度翻译成 // dungeonadmission.Policy,交给现成的 Owner.Preflight / Owner.Commit。 // 这样账号资产事务串行化、预检不写库、回执防重复扣次、跨日重置 // 全部复用既有实现,不新造旁路账本。 // ============================================================================ // abisDungeonSpec 描述一张周常图。开放日按 // etc/accountcharacdungeonincountchn.etc 的 [weekly dungeon index] 推导: // // 0 4907 1 4906 2 4910 3 4908 4 4907 5 4909 6 4906 // 0 4909 6 4910 6 4908 // // (索引 0=周一 … 6=周日)交叉后得到下表的两天。 // 副本自身也能佐证:chn_daydungeon_01.dgn 声明 [schedule] 2418 2423, // 即 2418(周一)~2423(周六) 之间开放,与 4906→周一/周六 一致。 type abisDungeonSpec struct { id uint32 name string limit uint32 // [dungeon enter max count] 的角色上限 days []time.Weekday } var abisDungeonSpecs = [...]abisDungeonSpec{ {4906, "南部溪谷秘密祭坛", 2, []time.Weekday{time.Monday, time.Saturday}}, {4907, "牛头怪军团", 1, []time.Weekday{time.Sunday, time.Thursday}}, {4908, "突袭赫拉斯", 1, []time.Weekday{time.Wednesday, time.Saturday}}, {4909, "暗精灵英雄的考验", 1, []time.Weekday{time.Sunday, time.Friday}}, {4910, "怪虫栖息地", 1, []time.Weekday{time.Tuesday, time.Saturday}}, } // abisEnforceOpenDay 控制服务端是否按开放日拒绝入场。 // // 【2026-10-12 实测后置为 false】现场证据:2026-10-12 是星期一,客户端让角色 // 进入了 4909 暗精灵英雄的考验(runtime/logs/records/dungeon-20261012-001.jsonl // 有完整 enter 记录,character_level=90,5 秒后 exit)。这说明**开放日本身由 // 客户端管**,而且 [weekly dungeon index] 的索引到星期几的换算尚未实证 // (按 (索引,图) 配对 4909 落在索引 0 与 5;0 到底是周一还是周日没有证据, // 报告给的"周一/六"等日表与本次实测也不一致)。 // // 因此服务端只用【次数】这一条确定性规则做准入 —— 额度数字直接来自 PVF // (账号 5 + 冒险团分档,各图角色 2/1/1/1/1),并叠加 06:00 重置与回执防重。 // 开放日一律放行,绝不因为一个未实证的日表把已经能进的图拦下来。 // 等抓到客户端"非开放日"的真实表现后再打开这个开关。 const abisEnforceOpenDay = false // abisAccountDailyBase 是 [default enterable count]:账号每天基础 5 次。 // 注意:这是**账号**共享额度,不是每角色额度 —— 报告里的"账号每日基础 5 次"。 const abisAccountDailyBase uint32 = 5 // abisAdventureGroupBonus 是 [adventure group add enterable count chn]: // // 1 5 0 6 10 1 11 15 2 16 20 3 21 25 4 26 30 5 // // 即冒险团等级落在 [lower, upper] 时,账号额度额外 +bonus。 var abisAdventureGroupBonus = [...]struct { lower, upper uint32 bonus uint32 }{ {1, 5, 0}, {6, 10, 1}, {11, 15, 2}, {16, 20, 3}, {21, 25, 4}, {26, 30, 5}, } var ( // ErrAbisNotOpenDay 非开放日。客户端表现就是"现在不是地下城的开放时间"。 ErrAbisNotOpenDay = errors.New("abis weekly dungeon is not open today") ) func abisDungeonSpecFor(dungeonID uint32) (abisDungeonSpec, bool) { for _, spec := range abisDungeonSpecs { if spec.id == dungeonID { return spec, true } } return abisDungeonSpec{}, false } // isCurrentAbisWeeklyDungeon 判断该副本是否属于这五张周常图。 func isCurrentAbisWeeklyDungeon(dungeonID uint32) bool { _, found := abisDungeonSpecFor(dungeonID) return found } // abisAdventureGroupAddition 按冒险团等级取账号额度加成。 func abisAdventureGroupAddition(manageLevel uint32) uint32 { for _, row := range abisAdventureGroupBonus { if manageLevel >= row.lower && manageLevel <= row.upper { return row.bonus } } return 0 } // abisServiceWindow 返回当日服务窗口。ServiceWindow 按北京时区把 // 「本地时间 −6 小时」后的 06:00 作为一天起点,正好是报告要求的每日 06:00 重置。 // 预检与提交都用同一个 now 推导,保证跨日时两侧看到同一个窗口。 func abisPolicyWindow(policy dnfdungeonadmission.Policy, now time.Time) (dnfdungeonadmission.Window, error) { return dnfdungeonadmission.ServiceWindow(policy, now) } // abisOpenToday 判断该图今天是否开放。按窗口起点取星期,与计数窗口同源, // 避免凌晨 0~6 点因"日期变了但服务日没变"而错判开放日。 // // 仅当 abisEnforceOpenDay 打开时才参与准入;关闭时恒返回 true(见该常量的说明)。 func abisOpenToday(spec abisDungeonSpec, window dnfdungeonadmission.Window) bool { if !abisEnforceOpenDay { return true } weekday := window.Start.Weekday() for _, day := range spec.days { if day == weekday { return true } } return false } // abisEntryPolicies 生成该图当日的两条 Policy: // // 账号侧:abis.weekly_day.account.daily Limit = 5 + 冒险团加成 ← **五图共享** // 角色侧:abis.weekly_day..character.daily Limit = 该图上限 2/1/1/1/1 // // 账号侧**不带 dungeonID**:etc/accountcharacdungeonincountchn.etc 的 // [default enterable count] 5 是整组周常图共用的账号日额度,不是一个图一份。 // 若把 id 写进 key,每张图都会各自得到 5 次,五图合计 25 次,与 PVF 语义不符。 // 角色侧带 id:各图各有独立的角色上限(4906=2,其余=1)。 // // 两者都是 PeriodDaily,因此都走 06:00 服务日边界,并由 Owner 统一重置。 func abisEntryPolicies(spec abisDungeonSpec, manageLevel uint32) ([]dnfdungeonadmission.Policy, error) { accountLimit := abisAccountDailyBase + abisAdventureGroupAddition(manageLevel) windowProbe := dnfdungeonadmission.Policy{ Key: "abis.weekly_day.window", Scope: dnfdungeonadmission.ScopeAccount, Period: dnfdungeonadmission.PeriodDaily, Limit: 1, } if _, err := dnfdungeonadmission.ServiceWindow(windowProbe, time.Now()); err != nil { return nil, err } return []dnfdungeonadmission.Policy{ { Key: abisSharedAccountPolicyKey, SourcePath: "etc/accountcharacdungeonincountchn.etc", SourceSection: "default enterable count", Scope: dnfdungeonadmission.ScopeAccount, Period: dnfdungeonadmission.PeriodDaily, Limit: accountLimit, // 五图都归属这条共享账号额度,索引时就能覆盖全部。 DungeonIDs: []uint32{4906, 4907, 4908, 4909, 4910}, }, { Key: fmt.Sprintf("abis.weekly_day.%d.character.daily", spec.id), SourcePath: "etc/accountcharacdungeonincountchn.etc", SourceSection: "dungeon enter max count", Scope: dnfdungeonadmission.ScopeCharacter, Period: dnfdungeonadmission.PeriodDaily, Limit: spec.limit, DungeonIDs: []uint32{spec.id}, }, }, nil } // abisSharedAccountPolicyKey 是五张周常图共享的账号日额度策略键。 // **不能带 dungeonID** —— 见 abisEntryPolicies 的说明。 const abisSharedAccountPolicyKey = "abis.weekly_day.account.daily" // previewCurrentAbisEntry 是只读预检:不写库、不扣次。 // 先用开放日短路(省掉一次数据库往返),再交给 Owner.Preflight 复核额度。 func (s *Service) previewCurrentAbisEntry( ctx context.Context, characterID uint16, dungeonID uint32, manageLevel uint32, now time.Time, ) error { spec, found := abisDungeonSpecFor(dungeonID) if !found { return nil // 不属于这五图,交给既有 Admission 路径处理 } policies, err := abisEntryPolicies(spec, manageLevel) if err != nil { return err } window, err := abisPolicyWindow(policies[0], now) if err != nil { return err } if !abisOpenToday(spec, window) { return fmt.Errorf("%w: dungeon=%d name=%s", ErrAbisNotOpenDay, spec.id, spec.name) } owner, err := s.currentAbisAdmissionOwner() if err != nil { return err } if _, err := owner.Preflight(ctx, fmt.Sprint(characterID), policies, now); err != nil { return err } return nil } // commitCurrentAbisEntry 在同一事务里提交入场计次。owner.Commit 内部会: // 1. 重新读账号 Metadata 与角色 Settings(权威值,不受并发影响) // 2. 复核两组额度上限,不通过则返回错误且不落库 // 3. 写入 used 与回执(同一回执重复提交不会重复扣次) // // 报告要求的"提交放在副本及回城数据预检完成后的 runtime 提交点、 // 预检不写库、重复提交不重复扣次"由这套既有语义直接满足。 func (s *Service) commitCurrentAbisEntry( ctx context.Context, characterID uint16, dungeonID uint32, manageLevel uint32, receipt string, now time.Time, ) error { spec, found := abisDungeonSpecFor(dungeonID) if !found { return nil } policies, err := abisEntryPolicies(spec, manageLevel) if err != nil { return err } window, err := abisPolicyWindow(policies[0], now) if err != nil { return err } if !abisOpenToday(spec, window) { return fmt.Errorf("%w: dungeon=%d name=%s", ErrAbisNotOpenDay, spec.id, spec.name) } owner, err := s.currentAbisAdmissionOwner() if err != nil { return err } if _, err := owner.Commit(ctx, fmt.Sprint(characterID), policies, receipt, now); err != nil { return err } return nil } // restoreCurrentAbisEntry 补偿一次已经落库的提交(SQL 提交结果不确定时使用)。 func (s *Service) restoreCurrentAbisEntry( ctx context.Context, characterID uint16, dungeonID uint32, manageLevel uint32, receipt string, now time.Time, ) error { spec, found := abisDungeonSpecFor(dungeonID) if !found { return nil } policies, err := abisEntryPolicies(spec, manageLevel) if err != nil { return err } owner, err := s.currentAbisAdmissionOwner() if err != nil { return err } _, err = owner.Restore(ctx, fmt.Sprint(characterID), policies, receipt, now) return err } func (s *Service) currentAbisAdmissionOwner() (*dnfdungeonadmission.Owner, error) { repositories, ok := s.repositoryGroup() if !ok { return nil, dnfdungeonadmission.ErrOwnerUnavailable } return dnfdungeonadmission.NewOwner(repositories) } // currentAbisAdventureGroupLevel 返回缓存的当前账号冒险团等级(manage level)。 // // 冒险团等级本身是**账号**属性:它存在账号 Metadata 的 // adventure_group_growth_v1({"level":N,...}),由 // adventuregroup.GrowthMetadataKey 拥有。所以把它读出来烘进账号策略上限是 // 语义正确的 —— 同一账号的所有角色看到同一个额度。 // // 读取由 refreshAbisAdventureGroupLevel(带 session 的路径)刷新;这里只读缓存, // 因为策略生成入口是同步且拿不到账号。读不到时返回 0,也就是纯基础 5 次, // 只会少给、不会多给。 func (s *Service) currentAbisAdventureGroupLevel() uint32 { if s == nil { return 0 } s.abisAdventureGroupMu.Lock() level := s.abisAdventureGroupLevel s.abisAdventureGroupMu.Unlock() return level } // refreshAbisAdventureGroupLevel 从账号 Metadata 读 adventure_group_growth_v1, // 解析出 level 并写进缓存。解析失败或字段缺失时置 0(安全方向)。 func (s *Service) refreshAbisAdventureGroupLevel(session *gameSession) { if s == nil || session == nil || session.selectedCharacterID == 0 { return } repositories, ok := s.repositoryGroup() if !ok || repositories.Character == nil || repositories.Account == nil { return } ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout) defer cancel() character, found, err := repositories.Character.Load(ctx, fmt.Sprint(session.selectedCharacterID)) if err != nil || !found { return } accountID := strings.TrimSpace(character.AccountID) if accountID == "" { return } account, found, err := repositories.Account.Load(ctx, accountID) if err != nil || !found { return } level := abisManageLevelFromAccount(account) s.abisAdventureGroupMu.Lock() s.abisAdventureGroupLevel = level s.abisAdventureGroupMu.Unlock() }