服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
194 lines
8.1 KiB
Go
194 lines
8.1 KiB
Go
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)
|
||
}
|