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,364 @@
package dnfbridge
import (
"context"
"fmt"
"strconv"
"sync"
"time"
dnffatigue "longheng.io/server/internal/modules/dnf/fatigue"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const currentDailyFatigueResetRetryDelay = time.Minute
var currentDailyFatigueResetLocation = time.FixedZone("DNF-Beijing", 8*60*60)
type currentDailyFatigueResetClock struct {
mu sync.Mutex
generation uint64
timerName string
dueAt time.Time
characterID uint16
characterGeneration uint64
}
func (s *Service) reconcileCurrentDailyFatigueSelection(
ctx context.Context,
session *gameSession,
) (dnffatigue.ReconcileDailyResult, error) {
return s.reconcileCurrentDailyFatigueSelectionAt(ctx, session, s.gameplayNow().UTC())
}
func (s *Service) reconcileCurrentDailyFatigueSelectionAt(
ctx context.Context,
session *gameSession,
now time.Time,
) (dnffatigue.ReconcileDailyResult, error) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return dnffatigue.ReconcileDailyResult{}, nil
}
repositories, ok := s.repositoryGroup()
if !ok {
return dnffatigue.ReconcileDailyResult{}, nil
}
owner, err := dnffatigue.NewOwner(repositories)
if err != nil {
return dnffatigue.ReconcileDailyResult{}, err
}
result, err := owner.ReconcileDaily(ctx, dnffatigue.ReconcileDailyCommand{
AccountID: s.accountIDForSession(session),
CharacterID: strconv.FormatUint(uint64(session.selectedCharacterID), 10),
UpdatedAt: now.UTC(),
})
if err != nil {
return dnffatigue.ReconcileDailyResult{}, fmt.Errorf("reconcile daily fatigue selection: %w", err)
}
return result, nil
}
func (s *Service) reconcileCurrentDailyFatigueSelectionIfNeeded(
ctx context.Context,
session *gameSession,
character dnfrepo.CharacterRecord,
) (dnfrepo.CharacterRecord, bool, error) {
if character.Stats == nil {
return character, false, nil
}
_, recoveryInitialized := character.Stats[dnffatigue.DailyRecoveryDayStatKey]
_, potionInitialized := character.Stats[dnffatigue.PotionUsageDayStatKey]
if !recoveryInitialized && !potionInitialized {
return character, false, nil
}
dayID := premium.ServiceDayID(s.gameplayNow())
if character.Stats[dnffatigue.DailyRecoveryDayStatKey] == dayID &&
character.Stats[dnffatigue.PotionUsageDayStatKey] == dayID {
return character, false, nil
}
result, err := s.reconcileCurrentDailyFatigueSelection(ctx, session)
if err != nil || result.Character.CharacterID == "" {
return character, false, err
}
character.Stats = result.Character.Stats
character.UpdatedAt = result.Character.UpdatedAt
return character, result.Changed, nil
}
func nextCurrentDailyFatigueResetBoundary(now time.Time) time.Time {
if now.IsZero() {
now = time.Now().UTC()
}
local := now.In(currentDailyFatigueResetLocation)
boundary := time.Date(local.Year(), local.Month(), local.Day(), 6, 0, 0, 0, currentDailyFatigueResetLocation)
if !local.Before(boundary) {
boundary = boundary.AddDate(0, 0, 1)
}
return boundary.UTC()
}
func (s *Service) armCurrentDailyFatigueReset(session *gameSession) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return errGameplayTimeQueueUnavailable
}
return s.scheduleCurrentDailyFatigueResetAt(
session,
nextCurrentDailyFatigueResetBoundary(queue.Now()),
"next_beijing_0600",
)
}
func (s *Service) retryCurrentDailyFatigueReset(session *gameSession) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return errGameplayTimeQueueUnavailable
}
return s.scheduleCurrentDailyFatigueResetAt(
session,
queue.Now().Add(currentDailyFatigueResetRetryDelay),
"repository_retry",
)
}
func (s *Service) scheduleCurrentDailyFatigueResetAt(
session *gameSession,
dueAt time.Time,
source string,
) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return errGameplayTimeQueueUnavailable
}
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.selectedCharacterID)
if err != nil {
return err
}
now := queue.Now().UTC()
dueAt = dueAt.UTC()
if dueAt.Before(now) {
dueAt = now
}
delay := dueAt.Sub(now)
clock := &session.dailyFatigueReset
clock.mu.Lock()
oldTimerName := clock.timerName
clock.generation++
if clock.generation == 0 {
clock.generation++
}
timerGeneration := clock.generation
timerName := fmt.Sprintf(
"dnf-daily-fatigue:%s:char:%d:session:%d:timer:%d",
session.connID,
characterID,
characterGeneration,
timerGeneration,
)
clock.timerName = timerName
clock.dueAt = dueAt
clock.characterID = characterID
clock.characterGeneration = characterGeneration
clock.mu.Unlock()
if oldTimerName != "" {
queue.Cancel(oldTimerName)
}
err = queue.ScheduleAfter(timerName, delay, func(callbackTime time.Time) {
if callbackTime.IsZero() || callbackTime.Before(dueAt) {
callbackTime = dueAt
}
postErr := s.postGameSessionCharacterEvent(
session,
"daily-fatigue-reset-timequeue",
characterID,
characterGeneration,
func() error {
s.runCurrentDailyFatigueReset(session, timerGeneration, callbackTime.UTC())
return nil
},
)
if postErr != nil && !isClosedGameSessionEventError(postErr) {
s.logPacketEvent("game-session-event-submit-failed",
"conn_id", session.connID,
"source", "daily-fatigue-reset-timequeue",
"char_id", characterID,
"character_generation", characterGeneration,
"error", postErr)
}
})
if err != nil {
clock.mu.Lock()
if clock.generation == timerGeneration {
clock.timerName = ""
clock.dueAt = time.Time{}
clock.characterID = 0
clock.characterGeneration = 0
}
clock.mu.Unlock()
return err
}
s.logGameEvent(session, "game-daily-fatigue-reset-timer-scheduled",
"char_id", characterID,
"character_generation", characterGeneration,
"timer_name", timerName,
"due_at_utc", dueAt.Format(time.RFC3339Nano),
"source", source)
return nil
}
func (s *Service) runCurrentDailyFatigueReset(
session *gameSession,
timerGeneration uint64,
now time.Time,
) {
if s == nil || session == nil {
return
}
clock := &session.dailyFatigueReset
clock.mu.Lock()
if clock.generation != timerGeneration ||
clock.characterID == 0 ||
clock.characterID != session.selectedCharacterID ||
clock.characterGeneration != session.characterGeneration {
clock.mu.Unlock()
return
}
clock.timerName = ""
clock.dueAt = time.Time{}
clock.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
_, _, selected, found := s.characterForEnter(ctx, session, session.selectedCharacterID)
if !found {
cancel()
s.deferCurrentDailyFatigueReset(session, "selected_character_not_found", nil)
return
}
if selected.Stats != nil {
limit := selected.Stats["fatigue_limit"]
if limit == dnffatigue.DefaultLimit || limit == dnffatigue.BlackDiamondLimit {
if _, err := s.reconcileCurrentBlackDiamondSelection(ctx, session, selected); err != nil {
cancel()
s.deferCurrentDailyFatigueReset(session, "black_diamond_reconcile_failed", err)
return
}
}
}
result, err := s.reconcileCurrentDailyFatigueSelectionAt(ctx, session, now)
if err != nil || result.Character.CharacterID == "" {
cancel()
s.deferCurrentDailyFatigueReset(session, "daily_reconcile_failed", err)
return
}
dailyDungeonResult, dailyDungeonErr := s.reconcileCurrentDailyDungeonSelectionAt(ctx, session, now)
cancel()
if dailyDungeonErr != nil {
s.deferCurrentDailyFatigueReset(session, "daily_dungeon_reconcile_failed", dailyDungeonErr)
return
}
if result.Changed {
if err := s.sendGameUpperRawClass(
session,
currentFatigueMsgID,
buildCurrentFatigueBody(result.Character, true),
0,
); err != nil {
s.logGameEvent(session, "game-daily-fatigue-reset-projection-deferred",
"char_id", session.selectedCharacterID,
"service_day_id", result.ServiceDayID,
"error", err)
}
}
if refreshes := currentDailyDungeonInvitationRefreshes(dailyDungeonResult); len(refreshes) != 0 {
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, "daily_dungeon_invitation_grant", refreshes); err != nil {
s.logGameEvent(session, "game-daily-dungeon-invitation-projection-deferred",
"char_id", session.selectedCharacterID,
"service_window_start", dailyDungeonResult.Window.Start.Format(time.RFC3339),
"refresh_count", len(refreshes),
"error", err)
}
}
if !dailyDungeonResult.Window.Start.IsZero() {
if err := s.sendCurrentRaidDailyAvailability(session, "beijing_0600_daily_reset"); err != nil {
s.logGameEvent(session, "game-daily-dungeon-count-projection-deferred",
"char_id", session.selectedCharacterID,
"service_window_start", dailyDungeonResult.Window.Start.Format(time.RFC3339),
"error", err)
}
// The same service-window boundary rolls the account AND character weekly
// admission counters back to zero (dungeonadmission resets the weekly
// policies on this window), and the client keeps whatever the last op1206
// record wrote. Re-push it, or a player who is already online at 06:00
// keeps reading last week's number -- the mirror image of the bug this
// packet fixes, and just as misleading.
if err := s.sendCurrentRaidWeeklySchedules(session, "beijing_0600_daily_reset"); err != nil {
s.logGameEvent(session, "game-raid-weekly-schedule-projection-deferred",
"char_id", session.selectedCharacterID,
"service_window_start", dailyDungeonResult.Window.Start.Format(time.RFC3339),
"error", err)
}
}
s.logGameEvent(session, "game-daily-fatigue-reset-timequeue-reconciled",
"char_id", session.selectedCharacterID,
"service_day_id", result.ServiceDayID,
"changed", result.Changed,
"fatigue_restored", result.FatigueRestored,
"potion_usage_reset", result.PotionUsageReset,
"fatigue", result.Character.Stats["fatigue"],
"fatigue_limit", result.Character.Stats["fatigue_limit"],
"daily_dungeon_counters_changed", dailyDungeonResult.CountersChanged,
"daily_dungeon_reset_policy_count", len(dailyDungeonResult.ResetPolicyKeys),
"daily_invitation_inventory_changed", dailyDungeonResult.InventoryChanged)
if err := s.armCurrentDailyFatigueReset(session); err != nil {
s.logGameEvent(session, "game-daily-fatigue-reset-timer-rearm-deferred",
"char_id", session.selectedCharacterID,
"error", err)
}
}
func (s *Service) deferCurrentDailyFatigueReset(session *gameSession, reason string, reconcileErr error) {
s.logGameEvent(session, "game-daily-fatigue-reset-timequeue-deferred",
"char_id", session.selectedCharacterID,
"reason", reason,
"error", reconcileErr)
if err := s.retryCurrentDailyFatigueReset(session); err != nil {
s.logGameEvent(session, "game-daily-fatigue-reset-retry-schedule-failed",
"char_id", session.selectedCharacterID,
"reason", reason,
"error", err)
}
}
func (s *Service) stopCurrentDailyFatigueReset(session *gameSession, source string) {
if s == nil || session == nil {
return
}
clock := &session.dailyFatigueReset
clock.mu.Lock()
timerName := clock.timerName
characterID := clock.characterID
clock.generation++
if clock.generation == 0 {
clock.generation++
}
clock.timerName = ""
clock.dueAt = time.Time{}
clock.characterID = 0
clock.characterGeneration = 0
clock.mu.Unlock()
if timerName != "" {
if queue := s.ensureGameplayTimeQueue(); queue != nil {
queue.Cancel(timerName)
}
s.logGameEvent(session, "game-daily-fatigue-reset-timer-stopped",
"char_id", characterID,
"timer_name", timerName,
"source", source)
}
}