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,193 @@
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.<dungeon>.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)
}