服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
358 lines
14 KiB
Go
358 lines
14 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"longheng.io/server/internal/modules/dnf/adventuregroup"
|
||
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
var (
|
||
errAdventureGroupTableUnavailable = errors.New("dnf adventure-group table is unavailable")
|
||
errAdventureGroupRosterUnavailable = errors.New("dnf adventure-group account roster is unavailable")
|
||
errAdventureGroupOwnerMismatch = errors.New("dnf adventure-group selected character owner mismatch")
|
||
)
|
||
|
||
type accountAdventureGroupState struct {
|
||
Characters []dnfrepo.CharacterRecord
|
||
Summary adventuregroup.Summary
|
||
Projection adventuregroup.Projection
|
||
}
|
||
|
||
// preloadAdventureGroupTable makes the current runtime PVF authoritative for
|
||
// adventure-group calculation. Production cannot start with a guessed table.
|
||
func (s *Service) preloadAdventureGroupTable(ctx context.Context) error {
|
||
if _, err := s.loadAdventureGroupTable(ctx); err != nil {
|
||
return fmt.Errorf("preload dnf adventure-group table: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) loadAdventureGroupTable(ctx context.Context) (*adventuregroup.Tables, error) {
|
||
if s == nil {
|
||
return nil, errAdventureGroupTableUnavailable
|
||
}
|
||
s.adventureGroupMu.Lock()
|
||
defer s.adventureGroupMu.Unlock()
|
||
if s.adventureGroupTable != nil {
|
||
return s.adventureGroupTable, nil
|
||
}
|
||
if s.adventureGroupLoadErr != nil {
|
||
return nil, s.adventureGroupLoadErr
|
||
}
|
||
|
||
s.initialEquipmentMu.Lock()
|
||
archive, err := s.initialEquipmentArchiveLocked()
|
||
s.initialEquipmentMu.Unlock()
|
||
if err != nil {
|
||
s.adventureGroupLoadErr = err
|
||
return nil, err
|
||
}
|
||
table, err := adventuregroup.LoadComplete(ctx, archive)
|
||
if err != nil {
|
||
s.adventureGroupLoadErr = err
|
||
return nil, err
|
||
}
|
||
s.adventureGroupTable = table
|
||
snapshot := table.Snapshot()
|
||
s.logPacketEvent("dnf-adventure-group-table-loaded",
|
||
"point_ranges", snapshot.PointRanges,
|
||
"manage_thresholds", snapshot.ManageThresholds,
|
||
"manage_level_max", snapshot.ManageLevelMax,
|
||
"exp_bonus_levels", snapshot.ExpBonusLevels,
|
||
"gold_bonus_levels", snapshot.GoldBonusLevels,
|
||
"manage_option_levels", snapshot.ManageOptionLevels)
|
||
return table, nil
|
||
}
|
||
|
||
func (s *Service) currentAccountAdventureGroupSummary(ctx context.Context, selected dnfrepo.CharacterRecord, hasSelected bool, sessions ...*gameSession) (adventuregroup.Summary, error) {
|
||
state, err := s.currentAccountAdventureGroupState(ctx, selected, hasSelected, sessions...)
|
||
return state.Summary, err
|
||
}
|
||
|
||
func (s *Service) currentAccountAdventureGroupState(ctx context.Context, selected dnfrepo.CharacterRecord, hasSelected bool, sessions ...*gameSession) (accountAdventureGroupState, error) {
|
||
if s == nil {
|
||
return accountAdventureGroupState{}, errAdventureGroupTableUnavailable
|
||
}
|
||
s.adventureGroupMu.Lock()
|
||
table := s.adventureGroupTable
|
||
loadErr := s.adventureGroupLoadErr
|
||
s.adventureGroupMu.Unlock()
|
||
if table == nil {
|
||
if loadErr != nil {
|
||
return accountAdventureGroupState{}, loadErr
|
||
}
|
||
return accountAdventureGroupState{}, errAdventureGroupTableUnavailable
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Character == nil {
|
||
return accountAdventureGroupState{}, errAdventureGroupRosterUnavailable
|
||
}
|
||
accountID := s.accountIDForSession(sessions...)
|
||
if hasSelected {
|
||
selectedAccountID := strings.TrimSpace(selected.AccountID)
|
||
if selectedAccountID != "" && selectedAccountID != accountID {
|
||
return accountAdventureGroupState{}, fmt.Errorf("%w: selected=%q current=%q", errAdventureGroupOwnerMismatch, selectedAccountID, accountID)
|
||
}
|
||
}
|
||
characters, err := repositories.Character.ListByAccount(ctx, accountID, maxCharacterSlots)
|
||
if err != nil {
|
||
return accountAdventureGroupState{}, fmt.Errorf("list dnf adventure-group account characters: %w", err)
|
||
}
|
||
rows := make([]adventuregroup.Character, 0, len(characters))
|
||
for _, character := range characters {
|
||
rows = append(rows, adventuregroup.Character{Level: character.Level})
|
||
}
|
||
summary, err := table.Calculate(rows)
|
||
if err != nil {
|
||
return accountAdventureGroupState{}, err
|
||
}
|
||
// 冒险团等级的所有权在账号成长账本:账本一旦存在,名册推导值只能当它的初始
|
||
// 种子。这条覆写必须留在 State 这一层,因为它服务的是**所有**报文与判定路径
|
||
// (mode1/mode3 角色面板、场景、结算、塔次数、商店门槛)。过去只把它写在
|
||
// currentAccountAdventureGroupInfoState(冒险团窗口)里,于是窗口显示账本等级
|
||
// (≤30),其余报文退回名册刻度;而名册刻度被 charactermanage.etc
|
||
// `[manage level point]` 的 13 条阈值钳死(见 tables.go ManageLevelForPoint:
|
||
// 表不够长时最高只能到最后一个阈值),现场表现就是"冒险团 30 级,角色面板还是
|
||
// 13 级的四围"。参考实现(DNF_NEW 的 account_adventure_group.go)同样把覆写
|
||
// 放在这个函数里。
|
||
// 账本读不出来时保留名册值并记一条 deferred:一个坏账本不该打断玩家会话。
|
||
if table.Runtime().Growth.MaximumLevel > 0 && repositories.Account != nil && accountID != "" {
|
||
account, found, loadErr := repositories.Account.Load(ctx, accountID)
|
||
switch {
|
||
case loadErr != nil:
|
||
loadErr = fmt.Errorf("load dnf adventure-group growth account: %w", loadErr)
|
||
case !found:
|
||
loadErr = adventuregroup.ErrAccountNotFound
|
||
default:
|
||
if ledgerSummary, ledgerErr := table.CurrentSummary(account, rows); ledgerErr == nil {
|
||
summary = ledgerSummary
|
||
loadErr = nil
|
||
} else {
|
||
loadErr = ledgerErr
|
||
}
|
||
}
|
||
if loadErr != nil {
|
||
var logSession *gameSession
|
||
if len(sessions) > 0 {
|
||
logSession = sessions[0]
|
||
}
|
||
s.logGameEvent(logSession, "game-adventure-growth-summary-deferred",
|
||
"account_id", accountID, "reason", loadErr)
|
||
}
|
||
}
|
||
return accountAdventureGroupState{
|
||
Characters: characters,
|
||
Summary: summary,
|
||
}, nil
|
||
}
|
||
|
||
// currentAccountAdventureGroupInfoState adds durable account activity needed
|
||
// only by the full 7,420-byte adventure-group payload. The process lock
|
||
// serializes the metadata read/modify/write for this local account profile.
|
||
func (s *Service) currentAccountAdventureGroupInfoState(
|
||
ctx context.Context,
|
||
selected dnfrepo.CharacterRecord,
|
||
hasSelected bool,
|
||
sessions ...*gameSession,
|
||
) (accountAdventureGroupState, error) {
|
||
state, err := s.currentAccountAdventureGroupState(ctx, selected, hasSelected, sessions...)
|
||
if err != nil {
|
||
return accountAdventureGroupState{}, err
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Account == nil {
|
||
state.Projection = adventuregroup.ProjectActivity(0, state.Characters)
|
||
s.projectAdventureTowerCounts(&state.Projection, dnfrepo.AccountRecord{}, false, state.Summary.ManageLevel)
|
||
s.projectAdventureProfessionCounts(&state.Projection, state.Characters)
|
||
return state, nil
|
||
}
|
||
owner, err := adventuregroup.NewOwner(repositories)
|
||
if err != nil {
|
||
state.Projection = adventuregroup.ProjectActivity(0, state.Characters)
|
||
s.projectAdventureTowerCounts(&state.Projection, dnfrepo.AccountRecord{}, false, state.Summary.ManageLevel)
|
||
s.projectAdventureProfessionCounts(&state.Projection, state.Characters)
|
||
return state, nil
|
||
}
|
||
accountID := s.accountIDForSession(sessions...)
|
||
s.adventureGroupLoginMu.Lock()
|
||
login, err := owner.ObserveDailyLogin(ctx, adventuregroup.ObserveDailyLoginCommand{
|
||
AccountID: accountID,
|
||
ObservedAt: s.gameplayNow(),
|
||
})
|
||
s.adventureGroupLoginMu.Unlock()
|
||
if err != nil {
|
||
if errors.Is(err, adventuregroup.ErrAccountNotFound) ||
|
||
errors.Is(err, adventuregroup.ErrAccountRequired) {
|
||
state.Projection = adventuregroup.ProjectActivity(0, state.Characters)
|
||
return state, nil
|
||
}
|
||
return accountAdventureGroupState{}, err
|
||
}
|
||
state.Projection = adventuregroup.ProjectActivity(login.ConsecutiveDays, state.Characters)
|
||
if repositories.Settings != nil {
|
||
state.Projection.RepresentativesEnabled = true
|
||
if representativeOwner, ownerErr := adventuregroup.NewRepresentativeOwner(repositories); ownerErr == nil {
|
||
s.adventureRepresentativeMu.Lock()
|
||
representatives, loadErr := representativeOwner.Load(ctx, accountID)
|
||
s.adventureRepresentativeMu.Unlock()
|
||
if loadErr != nil {
|
||
return accountAdventureGroupState{}, loadErr
|
||
}
|
||
state.Projection.Representatives = representatives
|
||
}
|
||
}
|
||
account, found, loadErr := repositories.Account.Load(ctx, accountID)
|
||
if loadErr != nil {
|
||
return accountAdventureGroupState{}, fmt.Errorf("load adventure-group runtime account: %w", loadErr)
|
||
}
|
||
if found {
|
||
now := s.gameplayNow()
|
||
growth := s.adventureGroupTable.Runtime().Growth
|
||
// sessions is variadic; never index it blindly.
|
||
var logSession *gameSession
|
||
if len(sessions) > 0 {
|
||
logSession = sessions[0]
|
||
}
|
||
// The adventure-group level is owned by the account growth ledger once
|
||
// that ledger exists. Seed it from the roster the first time both the
|
||
// account and its roster are in hand, so a later experience grant can
|
||
// never start the ledger below an established level.
|
||
seeded, seedErr := adventuregroup.SeedGrowthState(&account, growth, state.Summary, now)
|
||
if seedErr != nil {
|
||
s.logGameEvent(logSession, "game-adventure-growth-seed-deferred",
|
||
"account_id", accountID, "reason", seedErr)
|
||
} else if seeded {
|
||
if saveErr := repositories.Account.Save(ctx, account); saveErr != nil {
|
||
return accountAdventureGroupState{}, fmt.Errorf("save seeded adventure-group growth: %w", saveErr)
|
||
}
|
||
}
|
||
// 这一次是**种子落库之后**的重读:State 那层读的是种子之前的账号记录,
|
||
// 而新种下的账本对 Level>1 会把进度清零(见 SeedGrowthState 的注释),
|
||
// 窗口要以种子后的值为准。账本已存在时这次重读与 State 的结果一致。
|
||
rows := make([]adventuregroup.Character, 0, len(state.Characters))
|
||
for _, character := range state.Characters {
|
||
rows = append(rows, adventuregroup.Character{Level: character.Level})
|
||
}
|
||
if summary, summaryErr := s.adventureGroupTable.CurrentSummary(account, rows); summaryErr == nil {
|
||
state.Summary = summary
|
||
} else {
|
||
s.logGameEvent(logSession, "game-adventure-growth-summary-deferred",
|
||
"account_id", accountID, "reason", summaryErr)
|
||
}
|
||
}
|
||
s.projectAdventureTowerCounts(&state.Projection, account, found, state.Summary.ManageLevel)
|
||
// The native adventure-group activity block has six u32 content counters.
|
||
// Index 0 is recommended dungeons, index 1 is PVP, index 2 is Anton raid
|
||
// and index 3 is Luke raid. Keep raid totals account-scoped so all
|
||
// characters on the account see the same values.
|
||
if found {
|
||
for _, entry := range [...]struct {
|
||
index int
|
||
key string
|
||
}{
|
||
{2, "adventure_group_anton_clears"},
|
||
{3, "adventure_group_luke_clears"},
|
||
{4, "adventure_group_despair_100_clears"},
|
||
{5, "adventure_group_grief_100_clears"},
|
||
} {
|
||
if raw := strings.TrimSpace(account.Metadata[entry.key]); raw != "" {
|
||
if count, parseErr := strconv.ParseUint(raw, 10, 32); parseErr == nil {
|
||
state.Projection.ContentCounts[entry.index] = uint32(count)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
s.projectAdventureProfessionCounts(&state.Projection, state.Characters)
|
||
if found {
|
||
config := s.adventureGroupTable.Runtime()
|
||
runtime, parseErr := adventuregroup.ParseRuntimeState(account, config, s.gameplayNow())
|
||
if parseErr != nil {
|
||
return accountAdventureGroupState{}, parseErr
|
||
}
|
||
state.Projection.Runtime = runtime
|
||
// 商店里成长胶囊那一格的进度分母 = `[capsule max exp]`(一格 = 100%)。
|
||
state.Projection.CapsuleGauge = adventuregroup.CapsuleGaugeCapacity(config.Capsule)
|
||
// "可获得的经验值"那一栏的领取次数(一格 10% = 1 次)。
|
||
state.Projection.CapsuleClaimCount = adventuregroup.CapsuleClaimCount(
|
||
config.Capsule,
|
||
runtime.GrowthExperience,
|
||
)
|
||
}
|
||
return state, nil
|
||
}
|
||
|
||
// projectAdventureTowerCounts projects the four native tower counters sent at
|
||
// body offsets 6..9. Limits come from the active PVF; stale account usage is
|
||
// naturally zero for a new service day.
|
||
func (s *Service) projectAdventureTowerCounts(projection *adventuregroup.Projection, account dnfrepo.AccountRecord, found bool, adventureLevel int) {
|
||
if s == nil || projection == nil {
|
||
return
|
||
}
|
||
limits := [adventuregroup.TowerTypeCount]uint32{3, 1}
|
||
s.initialEquipmentMu.Lock()
|
||
archive, _ := s.initialEquipmentArchiveLocked()
|
||
s.initialEquipmentMu.Unlock()
|
||
if archive != nil {
|
||
if value, err := dnfdungeon.TowerDailyEnterableLimit(archive, dnfdungeon.TowerDespair, adventureLevel); err == nil {
|
||
limits[adventuregroup.TowerDespairType] = value
|
||
}
|
||
if value, err := dnfdungeon.TowerDailyEnterableLimit(archive, dnfdungeon.TowerGrief, adventureLevel); err == nil {
|
||
limits[adventuregroup.TowerGriefType] = value
|
||
}
|
||
}
|
||
projection.TowerLimits = limits
|
||
if !found {
|
||
projection.TowerRemaining = limits
|
||
return
|
||
}
|
||
for index, kind := range [...]dnfdungeon.TowerKind{dnfdungeon.TowerDespair, dnfdungeon.TowerGrief} {
|
||
used, err := dnfdungeon.TowerDailyUsed(account, kind, s.gameplayNow())
|
||
if err != nil || used >= limits[index] {
|
||
projection.TowerRemaining[index] = 0
|
||
continue
|
||
}
|
||
projection.TowerRemaining[index] = limits[index] - used
|
||
}
|
||
}
|
||
|
||
// projectAdventureProfessionCounts fills the native 20-row profession
|
||
// descriptor block: row N is how many account characters classify into
|
||
// profession N, per the current runtime PVF's [profession classification].
|
||
func (s *Service) projectAdventureProfessionCounts(projection *adventuregroup.Projection, characters []dnfrepo.CharacterRecord) {
|
||
if s == nil || projection == nil {
|
||
return
|
||
}
|
||
s.adventureGroupMu.Lock()
|
||
table := s.adventureGroupTable
|
||
s.adventureGroupMu.Unlock()
|
||
if table == nil {
|
||
return
|
||
}
|
||
config := table.Runtime()
|
||
for index := range projection.ProfessionCounts {
|
||
projection.ProfessionCounts[index] = currentAdventureProfessionCharacterCount(characters, config, byte(index))
|
||
}
|
||
}
|
||
|
||
func (s *Service) currentAccountAdventureGroupSummaryForPacket(ctx context.Context, session *gameSession, selected dnfrepo.CharacterRecord, hasSelected bool) adventuregroup.Summary {
|
||
summary, err := s.currentAccountAdventureGroupSummary(ctx, selected, hasSelected, session)
|
||
if err == nil {
|
||
return summary
|
||
}
|
||
if session != nil {
|
||
s.logGameEvent(session, "game-adventure-group-summary-unavailable",
|
||
"character_id", selected.CharacterID,
|
||
"error", err)
|
||
}
|
||
// Production Start preloads the strict PVF table. This zero result is only
|
||
// a fail-closed wire value for a transient repository error or direct unit
|
||
// construction that bypasses Start; it is always accompanied by an error.
|
||
return adventuregroup.Summary{}
|
||
}
|