Files
90CNServer/server/internal/services/dnfbridge/town_position.go
T
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

593 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}