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