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

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

344 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.<id>.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()
}