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,343 @@
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()
}