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,592 @@
package dnfbridge
import (
"encoding/binary"
"errors"
"fmt"
)
const currentTownSetUserPositionBodySize = 7
var errCurrentDungeonTownReturnOriginUnavailable = errors.New("current dungeon town-return origin is unavailable")
// currentTownSetUserPositionRequest is the exact plaintext written by the
// current EXE's sub_23E0660: coordinates, direction, then actor+0x748 scaled
// by 100. That final u16 is the mover's current town movement rate, not an
// opaque field: preserve it in op22 because sub_23D9DE0 applies it to peers.
type currentTownSetUserPositionRequest struct {
PositionX uint16
PositionY uint16
MovementCode byte
OpaqueScaledU16 uint16
}
// currentTownPositionSnapshot is session-owned. The repository remains the
// durable area owner; high-frequency op35 reports are not written to the DB.
// A snapshot can be reused only for the exact character/town/area scene that
// established it.
type currentTownPositionSnapshot struct {
CharacterID uint16
TownID byte
AreaID byte
PositionX uint16
PositionY uint16
MovementCode byte
OpaqueScaledU16 uint16
PositionValid bool
}
func parseCurrentTownSetUserPositionRequest(body []byte) (currentTownSetUserPositionRequest, error) {
if len(body) != currentTownSetUserPositionBodySize {
return currentTownSetUserPositionRequest{}, fmt.Errorf(
"current town op35 body length %d, want %d",
len(body),
currentTownSetUserPositionBodySize,
)
}
return currentTownSetUserPositionRequest{
PositionX: binary.LittleEndian.Uint16(body[0:2]),
PositionY: binary.LittleEndian.Uint16(body[2:4]),
MovementCode: body[4],
OpaqueScaledU16: binary.LittleEndian.Uint16(body[5:7]),
}, nil
}
func (s *Service) handleTownSetUserPosition(session *gameSession, body []byte) error {
if session == nil {
return nil
}
s.pvpMu.Lock()
inAssault := session.assault != nil
s.pvpMu.Unlock()
if inAssault {
return nil
}
request, err := parseCurrentTownSetUserPositionRequest(body)
if err != nil {
s.logGameEvent(session, "game-town-set-user-position-blocked",
"body_len", len(body),
"reason", "current_exe_op35_writer_boundary_mismatch",
"error", err)
return nil
}
characterID := session.selectedCharacterID
if characterID == 0 {
s.logTownSetUserPositionBlocked(session, request, "selected_character_missing")
return nil
}
if err := s.commitPendingDungeonReturnBeforeTownPosition(session, request); err != nil {
return err
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime != nil {
runtime.actorPositionX = request.PositionX
runtime.actorPositionY = request.PositionY
runtime.actorPositionValid = true
}
session.dungeon.mu.Unlock()
if runtime != nil {
return nil
}
session.townMu.Lock()
readyCharacterID := session.townSceneReadyCharacterID
snapshot := session.townPositionSnapshot
if readyCharacterID == characterID && snapshot.CharacterID == characterID {
snapshot.PositionX = request.PositionX
snapshot.PositionY = request.PositionY
snapshot.MovementCode = request.MovementCode
snapshot.OpaqueScaledU16 = request.OpaqueScaledU16
snapshot.PositionValid = true
session.townPositionSnapshot = snapshot
}
session.townMu.Unlock()
if readyCharacterID != characterID {
s.logTownSetUserPositionBlocked(session, request, "town_scene_player_state_not_finalized")
return nil
}
if snapshot.CharacterID != characterID {
s.logTownSetUserPositionBlocked(session, request, "town_scene_location_owner_missing")
return nil
}
s.logGameEvent(session, "game-town-set-user-position-captured",
"char_id", characterID,
"town_id", snapshot.TownID,
"area_id", snapshot.AreaID,
"position_x", request.PositionX,
"position_y", request.PositionY,
"movement_code", request.MovementCode,
"opaque_scaled_u16", request.OpaqueScaledU16,
"persisted", false,
"source", "current_exe_sub_23E0660_exact_plaintext")
// A town-side op35 with a confirmed position proves this member rebuilt its
// town actor and resumed movement, so consume the back-to-village
// selector-return marker here. Every party member sends this arrival op35
// (a follower never walks to the gate and so never sends op15), which is the
// only way currentPartyTownReturnSceneReady can pass for all of them and the
// party co-presence can be rebuilt.
if err := s.consumeBackToVillageEnterSelectFinalizerAfterTownPosition(
session,
"current_exe_op35_position_after_pending_town_return",
); err != nil {
return err
}
// Broadcast position to other players in the same area.
if s.onlinePlayers != nil {
others := s.onlinePlayers.UpdatePosition(characterID, request.PositionX, request.PositionY)
if len(others) > 0 {
mover := &onlinePlayerInfo{
CharacterID: characterID,
ChannelID: townPresenceChannelID(session),
TownID: snapshot.TownID,
AreaID: snapshot.AreaID,
PositionX: request.PositionX,
PositionY: request.PositionY,
Direction: request.MovementCode,
Session: session,
}
s.broadcastTownPlayerMove(mover, request.OpaqueScaledU16, others)
}
}
if err := s.reconcileCurrentTownNPCQuestMeetings(session, snapshot); err != nil {
return err
}
return nil
}
// commitPendingDungeonReturnBeforeTownPosition accepts the first town-side
// op35 position report as proof that a previously written typed op24 return
// was consumed by the client. Live Tower death logs show exactly this shape:
// op40 -> op32 -> client 10s window -> server 12s watchdog -> op24, then the client sends op35 with the frozen
// return coordinates. Blocking that op35 leaves the active dungeon runtime
// attached forever and the corpse never reaches a usable town scene.
func (s *Service) commitPendingDungeonReturnBeforeTownPosition(
session *gameSession,
request currentTownSetUserPositionRequest,
) error {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
characterID := session.selectedCharacterID
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
shouldCommit := runtime != nil && runtime.Session != nil &&
runtime.townReturnPending && runtime.townReturnOp24Sent
transition := currentDungeonTownTransition{}
if shouldCommit {
transition = cloneCurrentDungeonTownTransition(runtime.townReturnTransition)
}
session.dungeon.mu.Unlock()
if !shouldCommit {
// The runtime is detached before the staged town actor/HUD chain is
// written. If an earlier confirmation reached that boundary but a
// later mode0/mode1/creature/skill write failed, op35 is the next
// current-client town proof and must resume only the unfinished suffix.
return s.ensureCurrentConfirmedDungeonReturnPlayerState(
session,
"current_exe_op35_position_without_retained_runtime",
)
}
if err := s.commitPendingDungeonReturnForSceneRequest(session, "current_exe_op35_position_after_pending_town_return"); err != nil {
return err
}
session.dungeon.mu.Lock()
stillHasRuntime := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if stillHasRuntime || session.selectedCharacterID != characterID {
s.logGameEvent(session, "game-town-set-user-position-pending-return-committed",
"char_id", characterID,
"selected_character", session.selectedCharacterID,
"runtime_retained", stillHasRuntime,
"reason", "pending_return_committed_but_scene_owner_changed")
return nil
}
session.townMu.Lock()
clearCurrentTownSelectorOriginLocked(session)
session.townSceneReadyCharacterID = characterID
session.townPositionSnapshot = currentTownPositionSnapshot{
CharacterID: characterID,
TownID: transition.TownID,
AreaID: transition.AreaID,
PositionX: request.PositionX,
PositionY: request.PositionY,
MovementCode: request.MovementCode,
OpaqueScaledU16: request.OpaqueScaledU16,
PositionValid: true,
}
session.townMu.Unlock()
s.logGameEvent(session, "game-town-set-user-position-pending-return-committed",
"char_id", characterID,
"town_id", transition.TownID,
"area_id", transition.AreaID,
"position_x", request.PositionX,
"position_y", request.PositionY,
"movement_code", request.MovementCode,
"opaque_scaled_u16", request.OpaqueScaledU16,
"position_source", transition.PositionSource,
"reason", "op35_is_first_town_side_confirmation_after_typed_op24_return")
return nil
}
// setCurrentTownPositionSceneLocked establishes the exact town scene that may
// own later op35 reports. The caller must hold session.townMu. A scene change
// invalidates the previous coordinate report even when the same character is
// moving between two areas of the same town.
func setCurrentTownPositionSceneLocked(session *gameSession, characterID uint16, townID, areaID byte) {
if session == nil {
return
}
clearCurrentTownSelectorOriginLocked(session)
session.townMeetingNearbyNPCs = nil
session.townPositionSnapshot = currentTownPositionSnapshot{
CharacterID: characterID,
TownID: townID,
AreaID: areaID,
}
}
// setCurrentTownPositionSceneFromTransitionLocked seeds the position owner
// from the exact typed op24 actor row. Character switching can open the
// dungeon selector before the first client op35 movement report; retaining an
// invalid position in that window acknowledges op15 but cannot build op27,
// leaving the selector blank until a teleport supplies a later op35. The
// transition row is already the authoritative spawn used by the current
// client, so it is safe to bind immediately when it belongs to this actor.
func setCurrentTownPositionSceneFromTransitionLocked(session *gameSession, characterID uint16, townID, areaID byte, row currentSceneTransitionRow) {
setCurrentTownPositionSceneLocked(session, characterID, townID, areaID)
if session == nil || characterID == 0 || row.ObjectOrResourceKey != currentSceneActorObjectKey(characterID) {
return
}
session.townPositionSnapshot.PositionX = row.Value1
session.townPositionSnapshot.PositionY = row.Value2
session.townPositionSnapshot.MovementCode = row.Value3
session.townPositionSnapshot.PositionValid = true
}
// bindCurrentTownSelectorOrigin freezes the town origin owned by the exact
// op15/op27 selector context. A duplicate op15 keeps the first binding. This
// prevents a later town-position report from silently changing where op132
// returns, while the same frozen snapshot remains available after op16 has
// committed an active dungeon runtime.
func bindCurrentTownSelectorOrigin(session *gameSession) (currentTownPositionSnapshot, bool) {
if session == nil || session.selectedCharacterID == 0 {
return currentTownPositionSnapshot{}, false
}
characterID := session.selectedCharacterID
session.townMu.Lock()
defer session.townMu.Unlock()
if session.townSelectorOriginBound {
if session.townSelectorOriginSnapshot.CharacterID == characterID {
return session.townSelectorOriginSnapshot, true
}
clearCurrentTownSelectorOriginLocked(session)
}
snapshot := session.townPositionSnapshot
if session.townSceneReadyCharacterID != characterID || snapshot.CharacterID != characterID || !snapshot.PositionValid {
return currentTownPositionSnapshot{}, false
}
session.townSelectorOriginSnapshot = snapshot
session.townSelectorOriginBound = true
return snapshot, true
}
// freezeCurrentDungeonTownReturnOrigin copies the exact op35 origin into an
// ordinary dungeon runtime before that runtime becomes visible. Tutorial
// dungeons deliberately retain their independent persisted birth-town return
// owner because a new character can enter them before any town op35 exists.
func freezeCurrentDungeonTownReturnOrigin(session *gameSession, runtime *runtimeDungeonState) error {
if session == nil || runtime == nil || runtime.Session == nil {
return fmt.Errorf("%w: runtime owner missing", errCurrentDungeonTownReturnOriginUnavailable)
}
if isPVFTutorialDungeon(runtime) {
runtime.townReturnOrigin = currentTownPositionSnapshot{}
return nil
}
characterID, townID, areaID, err := currentDungeonTownReturnOriginOwner(session, runtime)
if err != nil {
return err
}
session.townMu.Lock()
origin := session.townSelectorOriginSnapshot
bound := session.townSelectorOriginBound
session.townMu.Unlock()
if !bound {
// The current client's direct-entry buttons (training room 5000 and the
// job-change trial experience 7142) can be re-entered immediately after
// a town return: the op15 back-to-village finalizer consumed the
// selector context (clearing the bound origin) before the next op16
// SELECT_DUNGEON arrives. The post-return town position snapshot is
// still valid and is exactly where the character stands, so freeze it
// as the return origin instead of blocking the direct entry (which the
// client experiences as a silent hang followed by a crash).
if isCurrentDirectDungeonEntryRequest(runtime.Request) &&
origin.CharacterID == characterID && origin.PositionValid {
bound = true
}
}
if !bound {
return fmt.Errorf("%w: selector origin not bound", errCurrentDungeonTownReturnOriginUnavailable)
}
if err := validateCurrentDungeonTownReturnOrigin(origin, characterID, townID, areaID); err != nil {
return err
}
runtime.townReturnOrigin = origin
return nil
}
// currentDungeonTownReturnOriginOwner resolves the scene that owns the frozen
// selector origin. Ordinary dungeons use the durable character location. An
// Anton raid dungeon is entered from the session-scoped waiting area instead;
// that temporary 19/2 scene is deliberately never persisted over the
// character's ordinary town. Both freeze and commit must use this same owner,
// otherwise commit reinterprets 19/2 as a mismatch against (for example) 38/1
// after the request has already passed its raid-entry checks.
func currentDungeonTownReturnOriginOwner(
session *gameSession,
runtime *runtimeDungeonState,
) (uint16, byte, byte, error) {
if session == nil || runtime == nil || runtime.Session == nil {
return 0, 0, 0, fmt.Errorf("%w: runtime owner missing", errCurrentDungeonTownReturnOriginUnavailable)
}
characterID := session.selectedCharacterID
if characterID == 0 || !dungeonRuntimeOwnsCharacter(runtime, characterID) {
return 0, 0, 0, fmt.Errorf("%w: runtime character owner mismatch", errCurrentDungeonTownReturnOriginUnavailable)
}
if spec, ok := currentRaidTownSpecForSession(session); ok && isAntonRaidEntryDungeonID(runtime.Dungeon.ID) {
// 超时空的返回点不是固定等待室,而是这个队伍此刻所在的作战区域。
// 它必须与 freeze/commit 两侧校验用的 selector origin 是同一个值,否则
// 进图会被 ordinary_dungeon_town_return_origin_unavailable 静默拒绝。
//
// 实测 2026-10-01: 队伍走到 area 9 后每秒重试一次开战、每次 entered=false,
// 客户端只看到"队伍已解散"反复刷屏 —— 就是因为这里返回了等待室 98/2,而
// 会话绑定的 selector origin 是 98/9,两边对不上。
if isCurrentFiendWarRaidChannel(session) {
if area, ok := currentFiendWarOperationArea(session, characterID, spec, runtime); ok {
return characterID, byte(spec.townID), area, nil
}
}
return characterID, byte(spec.townID), spec.waitingArea, nil
}
townID, areaID, err := currentSceneTransitionLocation(runtime.Character, true)
if err != nil {
return 0, 0, 0, fmt.Errorf("%w: %v", errCurrentDungeonTownReturnOriginUnavailable, err)
}
return characterID, townID, areaID, nil
}
// currentFiendWarOperationArea 给出超时空这次副本尝试的返回区域。
//
// 取值顺序:
// 1. 副本运行时已记下的 origin —— 这个队伍已经从这里回过城一次;
// 2. 会话已绑定的 selector origin —— 刚走进这个区域、还没回过城;
// 3. 都没有(还在等待室) -> false,调用方退回等待室。
//
// 第 2 条是关键:遭遇是从城镇移动里发起的,那一刻 session.townPositionSnapshot
// 还没有提交到新区域(切换包还没发),但 selector origin 已经在进入该区域时就绑好了,
// 而 freeze/commit 的校验读的正是它。
func currentFiendWarOperationArea(
session *gameSession,
characterID uint16,
spec runtimeRaidTownSpec,
runtime *runtimeDungeonState,
) (byte, bool) {
if runtime != nil {
origin := runtime.townReturnOrigin
if origin.CharacterID == characterID && origin.TownID == byte(spec.townID) && origin.AreaID >= 2 {
return origin.AreaID, true
}
}
if session == nil {
return 0, false
}
session.townMu.Lock()
selector := session.townSelectorOriginSnapshot
bound := session.townSelectorOriginBound
session.townMu.Unlock()
if bound && selector.CharacterID == characterID &&
selector.TownID == byte(spec.townID) && selector.AreaID >= 2 {
return selector.AreaID, true
}
return 0, false
}
func validateCurrentDungeonRuntimeTownReturnOrigin(session *gameSession, runtime *runtimeDungeonState) error {
if isPVFTutorialDungeon(runtime) {
return nil
}
characterID, townID, areaID, err := currentDungeonTownReturnOriginOwner(session, runtime)
if err != nil {
return err
}
return validateCurrentDungeonTownReturnOrigin(runtime.townReturnOrigin, characterID, townID, areaID)
}
func validateCurrentDungeonTownReturnOrigin(
origin currentTownPositionSnapshot,
characterID uint16,
townID byte,
areaID byte,
) error {
if !origin.PositionValid {
return fmt.Errorf("%w: op35 position was not reported", errCurrentDungeonTownReturnOriginUnavailable)
}
if origin.CharacterID != characterID || origin.TownID != townID || origin.AreaID != areaID {
return fmt.Errorf(
"%w: origin character/town/area %d/%d/%d does not match runtime %d/%d/%d",
errCurrentDungeonTownReturnOriginUnavailable,
origin.CharacterID,
origin.TownID,
origin.AreaID,
characterID,
townID,
areaID,
)
}
return nil
}
func clearCurrentTownSelectorOrigin(session *gameSession) {
if session == nil {
return
}
session.townMu.Lock()
clearCurrentTownSelectorOriginLocked(session)
session.townMu.Unlock()
}
func clearCurrentTownSelectorOriginLocked(session *gameSession) {
if session == nil {
return
}
session.townSelectorOriginSnapshot = currentTownPositionSnapshot{}
session.townSelectorOriginBound = false
}
func currentTownPositionForTransition(
session *gameSession,
characterID uint16,
townID byte,
areaID byte,
) (currentTownPositionSnapshot, string, bool, error) {
if session == nil || characterID == 0 {
return currentTownPositionSnapshot{}, "", false, nil
}
session.townMu.Lock()
selectorSnapshot := session.townSelectorOriginSnapshot
selectorBound := session.townSelectorOriginBound
session.townMu.Unlock()
if selectorBound {
if selectorSnapshot.CharacterID != characterID ||
selectorSnapshot.TownID != townID ||
selectorSnapshot.AreaID != areaID {
return currentTownPositionSnapshot{}, "", false, fmt.Errorf(
"selector origin character/town/area %d/%d/%d does not match transition %d/%d/%d",
selectorSnapshot.CharacterID,
selectorSnapshot.TownID,
selectorSnapshot.AreaID,
characterID,
townID,
areaID,
)
}
if !selectorSnapshot.PositionValid {
return currentTownPositionSnapshot{}, "", false, fmt.Errorf(
"%w: bound selector origin has no op35 position",
errCurrentDungeonTownReturnOriginUnavailable,
)
}
return selectorSnapshot, "current_exe_op35_selector_origin_snapshot", true, nil
}
return currentTownPositionSnapshot{}, "", false, nil
}
func (s *Service) applyCurrentTownPositionToTransition(
session *gameSession,
characterID uint16,
transition currentDungeonTownTransition,
) (currentDungeonTownTransition, bool, error) {
snapshot, positionSource, ok, err := currentTownPositionForTransition(
session,
characterID,
transition.TownID,
transition.AreaID,
)
if err != nil {
return currentDungeonTownTransition{}, false, err
}
if !ok {
return transition, false, nil
}
transition, err = applyCurrentTownPositionSnapshotToTransition(
transition,
snapshot,
characterID,
positionSource,
)
if err != nil {
return currentDungeonTownTransition{}, false, err
}
return transition, true, nil
}
func applyCurrentTownPositionSnapshotToTransition(
transition currentDungeonTownTransition,
snapshot currentTownPositionSnapshot,
characterID uint16,
positionSource string,
) (currentDungeonTownTransition, error) {
if err := validateCurrentDungeonTownReturnOrigin(
snapshot,
characterID,
transition.TownID,
transition.AreaID,
); err != nil {
return currentDungeonTownTransition{}, err
}
row := currentSceneTransitionRow{
ObjectOrResourceKey: transition.ActorObjectKey,
Value1: snapshot.PositionX,
Value2: snapshot.PositionY,
Value3: transition.Direction,
Value4: transition.AreaState,
}
body, err := buildCurrentSceneTransitionBody(
transition.TownID,
transition.AreaID,
[]currentSceneTransitionRow{row},
)
if err != nil {
return currentDungeonTownTransition{}, err
}
transition.PositionX = int16(snapshot.PositionX)
transition.PositionY = int16(snapshot.PositionY)
transition.PositionSource = positionSource
transition.Body = body
return transition, nil
}
func (s *Service) logTownSetUserPositionBlocked(
session *gameSession,
request currentTownSetUserPositionRequest,
reason string,
) {
s.logGameEvent(session, "game-town-set-user-position-blocked",
"char_id", session.selectedCharacterID,
"position_x", request.PositionX,
"position_y", request.PositionY,
"movement_code", request.MovementCode,
"opaque_scaled_u16", request.OpaqueScaledU16,
"reason", reason)
}