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,253 @@
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
}