Files
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

358 lines
14 KiB
Go
Raw Permalink 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"
"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{}
}