Files
90CNServer/server/internal/services/dnfbridge/abis_gm_counters.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

194 lines
8.1 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"
"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)
}