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

301 lines
12 KiB
Go

package dnfbridge
import (
"context"
"fmt"
"math"
"sort"
"strconv"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/statbuff"
)
// 城镇增益药剂(史诗Buff药剂一族)的**到期清除**定时器。
//
// 为什么必须有它:buff 注册(class0/op487)本身**不带时长** —— 三个状态字段参考实现
// 一律填 0,也就是说"这条 buff 什么时候结束"只有服务端知道。参考服务端的做法正是
// `EpicBuffPotionBuffNotifier.SendAddAsync` 之后紧跟 `ScheduleRemoveForCurrentEffect`
// (到期定时器 → CHARACTER_DEL_BUFF)。
//
// 我们原来只在"登录 / 重登 / 换频道 / 回城 / 重选"这些检查点上清残留图标,后果是:
// 人一直待在城里或副本里不换场景时,效果早就到点了、buff 栏图标却还挂着。
//
// 定时器是**会话级**的(药剂可以在城镇生效,不止副本),并在每次"使用 / 恢复补发"之后
// 重新武装:换频道与重登会新建会话 → 走恢复路径重新武装;同族换档(后来者覆盖窗口)
// 会武装到**新的**到期时间(上一发被 Cancel 掉,并用 generation 防止旧回调误触发)。
//
// 到点时**必须重新读一次角色**再决定:窗口可能已经被另一瓶续上了。只有确实过期才清图标
//(清图标 = CHARACTER_DEL_BUFF(这一族全部 id) + CHARACTER_BUFF_DUNGEON(空列表))。
//
// 注意"效果时间结束"那行聊天文字不归服务端管:客户端本地登记的效果道具自带时长
//(首次使用按 PVF 的 [stat change duration],重登由 op174 给出剩余秒),到期时由客户端
// 自己打 dstr 42036「[%s]的效果时间结束」。服务端这一发只负责把**图标**摘掉。
const currentStatChangePotionTimerSource = "stat-change-potion-expiry"
func nextCurrentStatChangePotionTimerGeneration(previous uint64) uint64 {
next := previous + 1
if next == 0 {
next = 1
}
return next
}
// armCurrentStatChangePotionTimer 把"到期清图标"排进游戏时间队列。
//
// expireAt 来自角色记录里的窗口(statbuff.Effect.ExpireAt),不是猜的时长。
// 排不进去也不致命:检查点(登录/回城/重选)上的兜底清除仍然在。
func (s *Service) armCurrentStatChangePotionTimer(
session *gameSession,
characterID uint16,
expireAt time.Time,
reason string,
) {
if s == nil || session == nil || characterID == 0 || expireAt.IsZero() {
return
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
s.logGameEvent(session, "game-stat-change-potion-timer-unavailable",
"char_id", characterID, "reason", reason)
return
}
// 定时器回调必须能证明"还是同一个角色、同一个会话代"。这里取一次身份,
// 之后由 postGameSessionCharacterEvent 在会话事件循环上复核。
eventCharacterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, characterID)
if err != nil {
s.logGameEvent(session, "game-stat-change-potion-timer-skipped",
"char_id", characterID, "reason", reason, "error", err)
return
}
now := s.gameplayNow().UTC()
delay := expireAt.UTC().Sub(now)
if delay < 0 {
delay = 0
}
session.statChangePotionTimerMu.Lock()
if session.statChangePotionTimerActive && session.statChangePotionTimerName != "" {
queue.Cancel(session.statChangePotionTimerName)
}
session.statChangePotionTimerGeneration = nextCurrentStatChangePotionTimerGeneration(
session.statChangePotionTimerGeneration)
generation := session.statChangePotionTimerGeneration
timerName := fmt.Sprintf("dnf-stat-change-potion:%s:char:%d:gen:%d",
session.connID, characterID, generation)
session.statChangePotionTimerActive = true
session.statChangePotionTimerName = timerName
session.statChangePotionTimerCharacterID = characterID
session.statChangePotionTimerDueAt = now.Add(delay)
session.statChangePotionTimerMu.Unlock()
s.logGameEvent(session, "game-stat-change-potion-timer-scheduled",
"char_id", characterID,
"reason", reason,
"timer_generation", generation,
"timer_name", timerName,
"due_at_utc", expireAt.UTC().Format(time.RFC3339Nano),
"delay_seconds", int64(delay/time.Second))
if err := queue.ScheduleAfter(timerName, delay, func(due time.Time) {
postErr := s.postGameSessionCharacterEvent(
session,
currentStatChangePotionTimerSource,
eventCharacterID,
characterGeneration,
func() error {
s.fireCurrentStatChangePotionTimer(session, generation, characterID)
return nil
},
)
if postErr != nil && !isClosedGameSessionEventError(postErr) {
s.logPacketEvent("game-session-event-submit-failed",
"conn_id", session.connID,
"source", currentStatChangePotionTimerSource,
"char_id", characterID,
"timer_generation", generation,
"error", postErr)
}
}); err != nil {
s.cancelCurrentStatChangePotionTimer(session, "timer_schedule_failed")
s.logGameEvent(session, "game-stat-change-potion-timer-schedule-failed",
"char_id", characterID, "reason", reason, "error", err)
}
}
// fireCurrentStatChangePotionTimer 在窗口到期的那一刻跑:确认真的过期了就摘图标。
//
// 三种情况:
// - generation 已被更新的武装/取消作废 → 什么都不做(旧回调);
// - 窗口被另一瓶续上了 → 按新的到期时间重新武装,图标不动;
// - 确实过期 → CHARACTER_DEL_BUFF + CHARACTER_BUFF_DUNGEON(空),图标消失。
func (s *Service) fireCurrentStatChangePotionTimer(
session *gameSession,
generation uint64,
characterID uint16,
) {
if s == nil || session == nil || characterID == 0 {
return
}
session.statChangePotionTimerMu.Lock()
stale := !session.statChangePotionTimerActive ||
session.statChangePotionTimerGeneration != generation ||
session.statChangePotionTimerCharacterID != characterID
if !stale {
session.statChangePotionTimerActive = false
session.statChangePotionTimerName = ""
session.statChangePotionTimerDueAt = time.Time{}
}
session.statChangePotionTimerMu.Unlock()
if stale {
return
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
s.logGameEvent(session, "game-stat-change-potion-timer-abandoned",
"char_id", characterID, "reason", "repositories_unavailable")
return
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
character, found, err := repositories.Character.Load(ctx, strconv.FormatUint(uint64(characterID), 10))
if err != nil || !found {
s.logGameEvent(session, "game-stat-change-potion-timer-abandoned",
"char_id", characterID, "reason", "character_unavailable", "error", err)
return
}
// 重新读角色,并且**逐族**判断:这一族现在有五个 family(掉落率 / 强化 / 增幅 /
// 通关经验 / 客户端自施放),各族互不覆盖、各按自己的到期时间结束。
//
// 还有族没到期 → 只摘**已经过期那些**的图标(精确到 buff id),并按剩下最早的那个
// 到期时间重新武装;
// 全都没有了 → 一次清干净(含兜底:客户端可能还残留着清过的旧图标)。
now := s.gameplayNow().UTC()
remaining := s.currentStatChangePotionActiveWindows(character, now)
expiredBuffIDs := s.currentStatChangePotionExpiredBuffIDs(character, now)
if len(remaining) > 0 {
next := remaining[0].ExpireAt
for _, window := range remaining {
if window.ExpireAt.Before(next) {
next = window.ExpireAt
}
}
if len(expiredBuffIDs) > 0 {
if err := s.sendCurrentCharacterRemoveBuffIDs(session, expiredBuffIDs, "expiry_timer_partial"); err != nil {
s.logGameEvent(session, "game-stat-change-potion-expiry-clear-failed",
"char_id", characterID, "error", err)
return
}
}
s.logGameEvent(session, "game-stat-change-potion-timer-rearmed",
"char_id", characterID,
"remaining_windows", len(remaining),
"expired_buffs", len(expiredBuffIDs),
"next_expire_at_utc", next.Format(time.RFC3339Nano),
"reason", "another_family_still_active")
s.armCurrentStatChangePotionTimer(session, characterID, next, "rearmed_after_partial_expiry")
return
}
if err := s.clearCurrentStatChangePotionBuffs(session, "expiry_timer"); err != nil {
s.logGameEvent(session, "game-stat-change-potion-expiry-clear-failed",
"char_id", characterID, "error", err)
return
}
s.logGameEvent(session, "game-stat-change-potion-timer-fired",
"char_id", characterID,
"reason", "window_expired")
}
// currentStatChangePotionExpiredBuffIDs 返回"窗口已经过期"的那些 buff id(精确摘图标用)。
//
// 为什么不能一律用全量清除:玩家身上可能同时挂着两族(例如掉落率 10 分钟 + 城镇移速
// 30 分钟),掉落率到点时若把全量 buff 都删掉,会把还没到期的移速图标一起误摘。
// 每条窗口在生效时都记了掷到的 buff id,所以这里能精确列出该摘的那几个。
func (s *Service) currentStatChangePotionExpiredBuffIDs(
character dnfrepo.CharacterRecord,
now time.Time,
) []int32 {
if character.Stats == nil {
return nil
}
// 先列出所有"到期时间已经过去"的族,再取它们各自记下的 buff id。
expired := make([]int32, 0, 4)
appendIfExpired := func(buffID int64, expireUnix int64) {
if buffID <= 0 || buffID > math.MaxInt32 || expireUnix <= 0 {
return
}
if time.Unix(expireUnix, 0).UTC().After(now) {
return
}
expired = append(expired, int32(buffID))
}
appendIfExpired(character.Stats[statbuff.EpicDropRateBuffIDStatKey],
character.Stats[statbuff.EpicDropRateExpireStatKey])
for _, kind := range []statbuff.Kind{statbuff.KindUpgradeSuccess, statbuff.KindAmplifySuccess,
statbuff.KindDungeonClearExp} {
expireUnix, buffID, ok := statbuff.RawRateWindowKeys(character, kind)
if !ok {
continue
}
appendIfExpired(buffID, expireUnix)
}
for _, family := range s.currentClientBuffFamilies() {
expireUnix, buffID, ok := statbuff.RawClientBuffKeys(character, family)
if !ok {
continue
}
appendIfExpired(buffID, expireUnix)
}
sort.Slice(expired, func(i, j int) bool { return expired[i] < expired[j] })
return expired
}
// sendCurrentCharacterRemoveBuffIDs 发一发 CHARACTER_DEL_BUFF(精确列表)。
func (s *Service) sendCurrentCharacterRemoveBuffIDs(
session *gameSession,
buffIDs []int32,
source string,
) error {
body := buildCurrentCharacterRemoveBuffBody(buffIDs)
if len(body) == 0 {
return nil
}
s.logPacketEvent("game-character-remove-buff-send",
"conn_id", session.connID,
"char_id", session.selectedCharacterID,
"source", source,
"buff_count", int(body[0]),
"msg_id", currentCharacterRemoveBuffMsgID,
"classification", 0,
"body_len", len(body),
"sequence", "partial_expiry_removes_only_the_windows_that_ended")
return s.sendGameUpperRawClass(session, currentCharacterRemoveBuffMsgID, body, 0)
}
// cancelCurrentStatChangePotionTimer 作废当前定时器(换档、窗口已过期、排不进队列等)。
func (s *Service) cancelCurrentStatChangePotionTimer(session *gameSession, reason string) {
if s == nil || session == nil {
return
}
session.statChangePotionTimerMu.Lock()
timerName := session.statChangePotionTimerName
session.statChangePotionTimerActive = false
session.statChangePotionTimerName = ""
session.statChangePotionTimerDueAt = time.Time{}
session.statChangePotionTimerGeneration = nextCurrentStatChangePotionTimerGeneration(
session.statChangePotionTimerGeneration)
session.statChangePotionTimerMu.Unlock()
if timerName == "" {
return
}
if queue := s.ensureGameplayTimeQueue(); queue != nil {
queue.Cancel(timerName)
}
s.logGameEvent(session, "game-stat-change-potion-timer-cancelled",
"timer_name", timerName, "reason", reason)
}