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