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,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{}
}