服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
254 lines
9.0 KiB
Go
254 lines
9.0 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
// The current EXE registers opcode 0x025b dynamically for Forgotten Land.
|
|
// sub_1D39CE0 consumes exactly two u32 values and stores them in the runtime
|
|
// fields read by sub_17EBEB0 before its countdown UI is initialized. The same
|
|
// numeric opcode has a different meaning in other client modules, so keep this
|
|
// use local to the Lost Land entry path.
|
|
const currentForgottenLandTimeInfoMsgID uint16 = 0x025b
|
|
|
|
func isCurrentForgottenLandDungeon(runtime *runtimeDungeonState) bool {
|
|
return runtime != nil && strings.EqualFold(strings.TrimSpace(runtime.Dungeon.Metadata.DungeonType), "lost land")
|
|
}
|
|
|
|
func currentForgottenLandTimeLimits(runtime *runtimeDungeonState) (uint32, uint32, time.Duration, error) {
|
|
if !isCurrentForgottenLandDungeon(runtime) {
|
|
return 0, 0, 0, nil
|
|
}
|
|
limits := runtime.Dungeon.Metadata.LimitTime
|
|
const maxUint32 = int64(^uint32(0))
|
|
if !limits.Set || limits.PrimarySeconds <= 0 || limits.SecondarySeconds <= 0 ||
|
|
limits.PrimarySeconds > maxUint32 || limits.SecondarySeconds > maxUint32 {
|
|
return 0, 0, 0, fmt.Errorf(
|
|
"forgotten land PVF limit time is missing or invalid: set=%t primary=%d secondary=%d",
|
|
limits.Set,
|
|
limits.PrimarySeconds,
|
|
limits.SecondarySeconds,
|
|
)
|
|
}
|
|
primary := uint32(limits.PrimarySeconds)
|
|
secondary := uint32(limits.SecondarySeconds)
|
|
return primary, secondary, time.Duration(primary) * time.Second, nil
|
|
}
|
|
|
|
func buildCurrentForgottenLandTimeInfoBody(primarySeconds, secondarySeconds uint32) []byte {
|
|
body := make([]byte, 8)
|
|
binary.LittleEndian.PutUint32(body[0:4], primarySeconds)
|
|
binary.LittleEndian.PutUint32(body[4:8], secondarySeconds)
|
|
return body
|
|
}
|
|
|
|
func (s *Service) sendCurrentForgottenLandTimeProjection(
|
|
session *gameSession,
|
|
runtime *runtimeDungeonState,
|
|
) error {
|
|
primary, secondary, _, err := currentForgottenLandTimeLimits(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !isCurrentForgottenLandDungeon(runtime) {
|
|
return nil
|
|
}
|
|
body := buildCurrentForgottenLandTimeInfoBody(primary, secondary)
|
|
s.logGameEvent(session, "game-forgotten-land-time-op603-send",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"msg_id", currentForgottenLandTimeInfoMsgID,
|
|
"classification", 0,
|
|
"primary_seconds", primary,
|
|
"secondary_seconds", secondary,
|
|
"body_len", len(body),
|
|
"body_source", "current_exe_sub_1D39CE0_and_pvf_limit_time")
|
|
return s.sendGameUpperRawClass(session, currentForgottenLandTimeInfoMsgID, body, 0)
|
|
}
|
|
|
|
// armCurrentForgottenLandTimer freezes the town return before entering the
|
|
// queue. The deadline belongs to the whole dungeon runtime and is intentionally
|
|
// idempotent, so room/warp transitions can never reset it.
|
|
func (s *Service) armCurrentForgottenLandTimer(
|
|
session *gameSession,
|
|
runtime *runtimeDungeonState,
|
|
) error {
|
|
_, _, delay, err := currentForgottenLandTimeLimits(runtime)
|
|
if err != nil || !isCurrentForgottenLandDungeon(runtime) {
|
|
return err
|
|
}
|
|
if s == nil || session == nil || runtime == nil {
|
|
return fmt.Errorf("forgotten land timer runtime owner is unavailable")
|
|
}
|
|
queue := s.ensureGameplayTimeQueue()
|
|
if queue == nil {
|
|
return errGameplayTimeQueueUnavailable
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
transition, err := s.prepareCurrentDungeonTownTransitionForSession(ctx, session, session.selectedCharacterID)
|
|
cancel()
|
|
if err != nil {
|
|
return fmt.Errorf("prepare forgotten land timeout return: %w", err)
|
|
}
|
|
|
|
session.dungeon.mu.Lock()
|
|
defer session.dungeon.mu.Unlock()
|
|
return s.armCurrentForgottenLandTimerLocked(session, runtime, transition, delay, queue)
|
|
}
|
|
|
|
func (s *Service) armCurrentForgottenLandTimerLocked(
|
|
session *gameSession,
|
|
runtime *runtimeDungeonState,
|
|
transition currentDungeonTownTransition,
|
|
delay time.Duration,
|
|
queue gameplayTimeQueue,
|
|
) error {
|
|
if session.dungeon.runtime != runtime || runtime.lifecycleToken == 0 || queue == nil {
|
|
return errGameplayTimeQueueUnavailable
|
|
}
|
|
if runtime.forgottenLandTimerActive {
|
|
return nil
|
|
}
|
|
runtime.forgottenLandTimerGeneration = nextCurrentForgottenLandTimerGeneration(runtime.forgottenLandTimerGeneration)
|
|
generation := runtime.forgottenLandTimerGeneration
|
|
timerName := fmt.Sprintf("dnf-forgotten-land:%s:run:%d", session.connID, runtime.lifecycleToken)
|
|
runtime.forgottenLandTimerActive = true
|
|
runtime.forgottenLandTimerName = timerName
|
|
runtime.forgottenLandDeadline = queue.Now().Add(delay)
|
|
runtime.forgottenLandTownTransition = cloneCurrentDungeonTownTransition(transition)
|
|
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.selectedCharacterID)
|
|
if err != nil {
|
|
s.cancelCurrentForgottenLandTimerLocked(session, runtime, "timer_character_identity_changed")
|
|
return err
|
|
}
|
|
dungeonID := runtime.Dungeon.ID
|
|
runToken := runtime.lifecycleToken
|
|
if err := queue.ScheduleAfter(timerName, delay, func(due time.Time) {
|
|
postErr := s.postGameSessionCharacterEvent(
|
|
session,
|
|
"forgotten-land-timequeue",
|
|
characterID,
|
|
characterGeneration,
|
|
func() error {
|
|
s.fireCurrentForgottenLandTimer(session, runtime, generation, due)
|
|
return nil
|
|
},
|
|
)
|
|
if postErr != nil && !isClosedGameSessionEventError(postErr) {
|
|
s.logPacketEvent("game-session-event-submit-failed",
|
|
"conn_id", session.connID,
|
|
"source", "forgotten-land-timequeue",
|
|
"char_id", characterID,
|
|
"character_generation", characterGeneration,
|
|
"dungeon_id", dungeonID,
|
|
"run_token", runToken,
|
|
"error", postErr)
|
|
}
|
|
}); err != nil {
|
|
s.cancelCurrentForgottenLandTimerLocked(session, runtime, "timer_schedule_failed")
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-forgotten-land-timer-scheduled",
|
|
"char_id", characterID,
|
|
"dungeon_id", dungeonID,
|
|
"run_token", runToken,
|
|
"timer_generation", generation,
|
|
"timer_name", timerName,
|
|
"due_at_utc", runtime.forgottenLandDeadline.UTC().Format(time.RFC3339Nano),
|
|
"delay_seconds", int64(delay/time.Second),
|
|
"source", "gameplay_time_queue_one_shot")
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) fireCurrentForgottenLandTimer(
|
|
session *gameSession,
|
|
runtime *runtimeDungeonState,
|
|
generation uint64,
|
|
due time.Time,
|
|
) {
|
|
if s == nil || session == nil || runtime == nil {
|
|
return
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
if session.dungeon.runtime != runtime || !runtime.forgottenLandTimerActive ||
|
|
runtime.forgottenLandTimerGeneration != generation || runtime.lifecycleToken == 0 {
|
|
session.dungeon.mu.Unlock()
|
|
return
|
|
}
|
|
if runtime.Session == nil || runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunActive {
|
|
s.cancelCurrentForgottenLandTimerLocked(session, runtime, "timer_fired_after_runtime_state_changed")
|
|
session.dungeon.mu.Unlock()
|
|
return
|
|
}
|
|
s.logGameEvent(session, "game-forgotten-land-timer-fired",
|
|
"char_id", runtime.Character.CharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"run_token", runtime.lifecycleToken,
|
|
"timer_generation", generation,
|
|
"scheduled_due_utc", due.UTC().Format(time.RFC3339Nano))
|
|
transition := cloneCurrentDungeonTownTransition(runtime.forgottenLandTownTransition)
|
|
// This one-shot has now fired. Consume its queue ownership before asking
|
|
// the current run to transition; an unconfirmed user-initiated return keeps
|
|
// a not-yet-fired timer, while an expired timer must not remain active.
|
|
s.cancelCurrentForgottenLandTimerLocked(session, runtime, "timer_fired")
|
|
session.dungeon.mu.Unlock()
|
|
if err := s.sendCurrentActiveDungeonReturnToTown(
|
|
session,
|
|
runtime,
|
|
transition,
|
|
currentForgottenLandTimeInfoMsgID,
|
|
"current_exe_forgotten_land_pvf_limit_time_expired",
|
|
); err != nil {
|
|
s.logGameEvent(session, "game-forgotten-land-timeout-return-failed", "error", err)
|
|
}
|
|
}
|
|
|
|
// cancelCurrentForgottenLandTimerLocked invalidates an already-dequeued
|
|
// callback before removing the named queue item. The caller holds dungeon.mu.
|
|
func (s *Service) cancelCurrentForgottenLandTimerLocked(
|
|
session *gameSession,
|
|
runtime *runtimeDungeonState,
|
|
source string,
|
|
) {
|
|
if runtime == nil {
|
|
return
|
|
}
|
|
wasActive := runtime.forgottenLandTimerActive
|
|
timerName := runtime.forgottenLandTimerName
|
|
previousGeneration := runtime.forgottenLandTimerGeneration
|
|
runtime.forgottenLandTimerGeneration = nextCurrentForgottenLandTimerGeneration(previousGeneration)
|
|
runtime.forgottenLandTimerActive = false
|
|
runtime.forgottenLandTimerName = ""
|
|
runtime.forgottenLandDeadline = time.Time{}
|
|
runtime.forgottenLandTownTransition = currentDungeonTownTransition{}
|
|
cancelled := false
|
|
if timerName != "" && s != nil && s.gameplayTimers != nil {
|
|
cancelled = s.gameplayTimers.Cancel(timerName)
|
|
}
|
|
if wasActive || timerName != "" {
|
|
s.logGameEvent(session, "game-forgotten-land-timer-cancelled",
|
|
"char_id", runtime.Character.CharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"run_token", runtime.lifecycleToken,
|
|
"previous_generation", previousGeneration,
|
|
"current_generation", runtime.forgottenLandTimerGeneration,
|
|
"timer_name", timerName,
|
|
"queue_item_cancelled", cancelled,
|
|
"source", source)
|
|
}
|
|
}
|
|
|
|
func nextCurrentForgottenLandTimerGeneration(current uint64) uint64 {
|
|
next := current + 1
|
|
if next == 0 {
|
|
next++
|
|
}
|
|
return next
|
|
}
|