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

492 lines
19 KiB
Go

package dnfbridge
import (
"context"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentTownPrevVillageTownStat = "prev_village_town_id"
currentTownPrevVillageAreaStat = "prev_village_area_id"
currentTownPrevVillagePosXStat = "prev_village_pos_x"
currentTownPrevVillagePosYStat = "prev_village_pos_y"
currentTownPrevVillageDirectionStat = "prev_village_direction"
)
func townMapPathLooksLikeSeria(mapPath string) bool {
normalizedPath := strings.ToLower(strings.ReplaceAll(mapPath, "\\", "/"))
return strings.Contains(normalizedPath, "seria")
}
// currentRaidGateAreaIsSeriaRoom reports whether this session is standing in the
// Seria-room equivalent of a raid channel.
//
// townMapPathLooksLikeSeria matches on the map file name, and every raid gate
// names its own file while importing the shared Seria-room art instead:
//
// map/town/battleship/(f)gate_battleship.map -> [import script] Common/(F)Gate_Seria.map
// map/town/jelva_raid/(f)gate_jelva.map -> [import script] Common/(R)Gate_Seria.map
// map/town/battleship2/(f)gate_battleship.map -> [import script] Common/(F)Gate_Seria.map
//
// None of those paths contains "seria", so the name check misses them and the
// room is published as an ordinary public area: every player standing in a raid
// channel's Seria room becomes mutually visible. Ordinary towns stay private
// only because their map happens to be (F)new_seria_room.map.
//
// The raid town spec is the authoritative discriminator. A raid town's areaID is
// its gate area (Anton 19/0, Luke 32/0), reached both at login and by walking
// through the town map, and the raid transition gate rejects every other town, so
// this identifies exactly the room the client renders as Seria's.
func currentRaidGateAreaIsSeriaRoom(session *gameSession, townID, areaID byte) bool {
spec, ok := currentRaidTownSpecForSession(session)
if !ok {
return false
}
return int64(townID) == spec.townID && int64(areaID) == spec.areaID
}
func currentTownPrevVillageOriginLocked(
session *gameSession,
characterID uint16,
character dnfrepo.CharacterRecord,
) (currentTownPositionSnapshot, bool) {
if session == nil || characterID == 0 {
return currentTownPositionSnapshot{}, false
}
townID, areaID, err := currentSceneTransitionLocation(character, true)
if err != nil {
return currentTownPositionSnapshot{}, false
}
if snapshot := session.townPositionSnapshot; snapshot.CharacterID == characterID &&
snapshot.TownID == townID &&
snapshot.AreaID == areaID &&
snapshot.PositionValid {
return snapshot, true
}
posX, okX := character.Stats["pos_x"]
posY, okY := character.Stats["pos_y"]
direction, okDirection := character.Stats["direction"]
if !okX || !okY || !okDirection ||
posX < 0 || posX > int64(^uint16(0)) ||
posY < 0 || posY > int64(^uint16(0)) ||
direction < 0 || direction > int64(^byte(0)) {
return currentTownPositionSnapshot{}, false
}
return currentTownPositionSnapshot{
CharacterID: characterID,
TownID: townID,
AreaID: areaID,
PositionX: uint16(posX),
PositionY: uint16(posY),
MovementCode: byte(direction),
PositionValid: true,
}, true
}
func currentTownPrevVillageOriginFromPersistedStats(
characterID uint16,
character dnfrepo.CharacterRecord,
) (currentTownPositionSnapshot, bool) {
if characterID == 0 || character.Stats == nil {
return currentTownPositionSnapshot{}, false
}
townID, okTown := character.Stats[currentTownPrevVillageTownStat]
areaID, okArea := character.Stats[currentTownPrevVillageAreaStat]
posX, okX := character.Stats[currentTownPrevVillagePosXStat]
posY, okY := character.Stats[currentTownPrevVillagePosYStat]
direction, okDirection := character.Stats[currentTownPrevVillageDirectionStat]
if !okTown || !okArea || !okX || !okY || !okDirection ||
townID < 0 || townID > int64(^byte(0)) ||
areaID < 0 || areaID > int64(^byte(0)) ||
posX < 0 || posX > int64(^uint16(0)) ||
posY < 0 || posY > int64(^uint16(0)) ||
direction < 0 || direction > int64(^byte(0)) {
return currentTownPositionSnapshot{}, false
}
return currentTownPositionSnapshot{
CharacterID: characterID,
TownID: byte(townID),
AreaID: byte(areaID),
PositionX: uint16(posX),
PositionY: uint16(posY),
MovementCode: byte(direction),
PositionValid: true,
}, true
}
func currentTownPrevVillageApplyOriginStats(stats map[string]int64, origin currentTownPositionSnapshot) bool {
if stats == nil || origin.CharacterID == 0 || !origin.PositionValid {
return false
}
values := map[string]int64{
currentTownPrevVillageTownStat: int64(origin.TownID),
currentTownPrevVillageAreaStat: int64(origin.AreaID),
currentTownPrevVillagePosXStat: int64(origin.PositionX),
currentTownPrevVillagePosYStat: int64(origin.PositionY),
currentTownPrevVillageDirectionStat: int64(origin.MovementCode),
}
changed := false
for key, value := range values {
if current, ok := stats[key]; !ok || current != value {
stats[key] = value
changed = true
}
}
return changed
}
// currentTownPrevVillageClearAntonOriginStats removes the fields written by
// an older build when it incorrectly persisted the temporary Anton town as a
// normal-channel return location. They must be cleared atomically with the
// normal Seria recovery; merely refusing to use them leaves the client
// replaying op1425 with the stale team map on every reconnect.
func currentTownPrevVillageClearAntonOriginStats(stats map[string]int64) bool {
if stats == nil || stats[currentTownPrevVillageTownStat] != int64(currentAntonRaidTownID) {
return false
}
changed := false
for _, key := range []string{
currentTownPrevVillageTownStat,
currentTownPrevVillageAreaStat,
currentTownPrevVillagePosXStat,
currentTownPrevVillagePosYStat,
currentTownPrevVillageDirectionStat,
} {
if _, ok := stats[key]; ok {
delete(stats, key)
changed = true
}
}
return changed
}
func (s *Service) currentTownPrevVillageOriginMapPath(origin currentTownPositionSnapshot) (string, bool) {
if s == nil || origin.CharacterID == 0 || !origin.PositionValid {
return "", false
}
area, found := s.townArea(int64(origin.TownID), int64(origin.AreaID))
if !found || area.MapPath == "" {
return "", false
}
return area.MapPath, true
}
func (s *Service) currentTownPrevVillageOriginIsPersistable(origin currentTownPositionSnapshot) (string, bool) {
mapPath, ok := s.currentTownPrevVillageOriginMapPath(origin)
// 婚房(101)必须排除:它是一间私密房间,把它记成"上个城镇"会让下次 op1425
// 把玩家送回婚房里(上游同一处理见 currentWeddingRoomTownID)。
if !ok || townMapPathLooksLikeSeria(mapPath) || origin.TownID == byte(currentAntonRaidTownID) ||
origin.TownID == currentWeddingRoomTownID {
return mapPath, false
}
return mapPath, true
}
// currentWeddingPrevVillageOrigin 修复"被婚房污染过的回城点"。
//
// 触发条件(任一):
// - 角色当前就在婚房(101)——刚办完仪式,记忆里的回城点可能已经被覆盖成婚房;
// - 记忆的回城点本身就指向 101——直接放行会把玩家送回婚房。
//
// 修复方式与上游一致:换成运行时 PVF 推导出的赛丽亚房间门边安全落点。
// 这样 op1425 既不会循环回婚房,也不会因为"拒绝"而变成一个什么都不做的死键。
// 返回的落点由调用方豁免 currentTownPrevVillageOriginIsPersistable(赛丽亚房间
// 本身按定义是不可持久化的,但作为一次性到达锚点是正确的)。
func (s *Service) currentWeddingPrevVillageOrigin(
characterID uint16, origin currentTownPositionSnapshot, character dnfrepo.CharacterRecord,
) (currentTownPositionSnapshot, bool) {
if s == nil || characterID == 0 {
return currentTownPositionSnapshot{}, false
}
contaminated := character.Stats["town_id"] == currentWeddingRoomTownID ||
character.Stats[currentTownPrevVillageTownStat] == currentWeddingRoomTownID ||
origin.TownID == currentWeddingRoomTownID
if !contaminated {
return currentTownPositionSnapshot{}, false
}
return s.currentWeddingPublicReturnOrigin(characterID)
}
func (s *Service) handleCurrentPrevVillage(session *gameSession, requestBody []byte) error {
if session == nil {
return nil
}
if len(requestBody) != 0 {
s.logCurrentPrevVillageBlocked(session, currentTownPositionSnapshot{}, "current_exe_op1425_body_boundary_mismatch")
return nil
}
characterID := session.selectedCharacterID
if characterID == 0 {
s.logCurrentPrevVillageBlocked(session, currentTownPositionSnapshot{}, "selected_character_missing")
return nil
}
session.dungeon.mu.Lock()
hasDungeonRuntime := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if hasDungeonRuntime {
s.logCurrentPrevVillageBlocked(session, currentTownPositionSnapshot{}, "active_dungeon_runtime_rejects_prev_village")
return nil
}
session.townMu.Lock()
readyCharacterID := session.townSceneReadyCharacterID
origin := session.townPrevVillageSnapshot
session.townMu.Unlock()
if readyCharacterID != characterID {
s.logCurrentPrevVillageBlocked(session, origin, "town_scene_player_state_not_finalized")
return nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
s.logCurrentPrevVillageBlocked(session, origin, "character_repository_unavailable")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
characterKey := strconv.FormatUint(uint64(characterID), 10)
character, found, err := repositories.Character.Load(ctx, characterKey)
if err != nil {
return err
}
if !found || character.Stats == nil {
s.logCurrentPrevVillageBlocked(session, origin, "selected_character_location_missing")
return nil
}
if origin.CharacterID != characterID || !origin.PositionValid {
if persistedOrigin, ok := currentTownPrevVillageOriginFromPersistedStats(characterID, character); ok {
origin = persistedOrigin
session.townMu.Lock()
session.townPrevVillageSnapshot = origin
session.townMu.Unlock()
s.logTownPrevVillageOriginBound(session, origin, "current_exe_op1425_restored_from_persisted_prev_village")
}
}
if origin.CharacterID != characterID || !origin.PositionValid {
s.logCurrentPrevVillageBlocked(session, origin, "prev_village_origin_missing")
return nil
}
// 婚房污染修复。必须在 persistable 检查之前:婚房的地图不是赛丽亚房间也不是
// 安东攻坚城镇,persistable 会**放行**它,于是玩家被送回婚房而不是上个城镇。
// 恢复出来的锚点是一次性到达点,所以下面豁免 persistable 检查。
weddingRecovered := false
if recovered, ok := s.currentWeddingPrevVillageOrigin(characterID, origin, character); ok {
origin = recovered
weddingRecovered = true
session.townMu.Lock()
session.townPrevVillageSnapshot = origin
session.townMu.Unlock()
s.logTownPrevVillageOriginBound(session, origin,
"current_exe_op1425_wedding_room_stale_origin_runtime_pvf_seria_arrival")
}
if _, persistable := s.currentTownPrevVillageOriginIsPersistable(origin); !persistable && !weddingRecovered {
s.logCurrentPrevVillageBlocked(session, origin, "prev_village_origin_is_temporary_or_non_persistable")
return nil
}
if doorOrigin, mapPath, projected := s.currentTownPrevVillageDoorOrigin(origin); projected {
origin = doorOrigin
currentTownPrevVillageApplyOriginStats(character.Stats, origin)
s.logTownPrevVillageOriginBound(session, origin, "current_exe_op1425_runtime_pvf_seria_door")
s.logGameEvent(session, "game-town-prev-village-door-projected",
"char_id", characterID,
"town_id", origin.TownID,
"area_id", origin.AreaID,
"position_x", origin.PositionX,
"position_y", origin.PositionY,
"map_path", mapPath,
"source", "runtime_pvf_reverse_portal_to_town_38_area_1")
}
currentTownID, currentAreaID, err := currentSceneTransitionLocation(character, true)
if err != nil {
s.logCurrentPrevVillageBlocked(session, origin, err.Error())
return nil
}
currentArea, found := s.townArea(int64(currentTownID), int64(currentAreaID))
if !found || !townMapPathLooksLikeSeria(currentArea.MapPath) {
s.logCurrentPrevVillageBlocked(session, origin, "current_area_is_not_seria_room")
return nil
}
targetArea, found := s.townArea(int64(origin.TownID), int64(origin.AreaID))
if !found {
s.logCurrentPrevVillageBlocked(session, origin, "prev_village_target_area_missing_from_runtime_pvf")
return nil
}
areaState, ok := character.Stats["area_state"]
if !ok || areaState < 0 || areaState > int64(^byte(0)) {
s.logCurrentPrevVillageBlocked(session, origin, "selected_character_area_state_missing_or_invalid")
return nil
}
direction := origin.MovementCode
previous := dnfrepo.CloneCharacter(character)
character = dnfrepo.CloneCharacter(character)
character.Stats["town_id"] = int64(origin.TownID)
character.Stats["area_id"] = int64(origin.AreaID)
character.Stats["pos_x"] = int64(origin.PositionX)
character.Stats["pos_y"] = int64(origin.PositionY)
character.Stats["direction"] = int64(direction)
character.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveCharacterFields(ctx, repositories.Character, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
row := currentSceneTransitionRow{
ObjectOrResourceKey: currentSceneActorObjectKey(characterID),
Value1: origin.PositionX,
Value2: origin.PositionY,
Value3: byte(direction),
Value4: byte(areaState),
}
response, err := buildCurrentSceneTransitionBody(origin.TownID, origin.AreaID, []currentSceneTransitionRow{row})
if err != nil {
return err
}
rollbackLocation := func(writeErr error) error {
rollbackCtx, rollbackCancel := context.WithTimeout(context.Background(), createWriteTimeout)
rollbackErr := dnfrepo.SaveCharacterFields(rollbackCtx, repositories.Character, previous, dnfrepo.CharacterFieldStats)
rollbackCancel()
if rollbackErr != nil {
s.logGameEvent(session, "game-town-prev-village-rollback-failed",
"char_id", characterID,
"town_id", origin.TownID,
"area_id", origin.AreaID,
"write_error", writeErr,
"rollback_error", rollbackErr)
}
return writeErr
}
s.logGameEvent(session, "game-town-prev-village-transition-send",
"char_id", characterID,
"request_msg_id", uint16(1425),
"response_msg_ids", []uint16{
currentTownUserPositionNotificationMsgID,
currentTownUserAreaNotificationMsgID,
currentClearQuestListMsgID,
currentSceneTransitionMsgID,
},
"town_id", origin.TownID,
"area_id", origin.AreaID,
"position_x", origin.PositionX,
"position_y", origin.PositionY,
"direction", int64(direction),
"area_state", areaState,
"map_path", targetArea.MapPath,
"op24_body_len", len(response),
"sequence", "op22_user_position_then_op23_user_area_then_optional_op356_completed_quest_state_then_op24_area_users",
"body_source", "current_exe_op22_sub_1D83990_op23_sub_1D89590_op356_sub_1D58470_op24_sub_1D901D0_from_legacy_op1425_prev_village")
// A direct Seria-room return creates the same public-town scene as an op36
// portal move. AREA_USERS/op24 alone only installs the roster; the current
// client also needs op22/op23 to bind the controlled actor and active area,
// plus the persisted completed-quest bitmap before op24 constructs map gates.
// Omitting that prefix leaves the dungeon selector with a valid permission
// table but no visible map rows until an unrelated town portal repairs it.
if err := s.sendCurrentTownLocationNotifications(
session,
characterID,
origin.TownID,
origin.AreaID,
row,
"current_exe_op1425_prev_village_transition",
); err != nil {
return rollbackLocation(err)
}
if _, err := s.sendCurrentPersistedClearQuestListIfCompleted(
ctx,
session,
repositories,
"current_exe_op1425_after_op23_before_op24_seed_completed_quest_scene_state",
); err != nil {
return rollbackLocation(err)
}
if err := s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, response, 0); err != nil {
return rollbackLocation(err)
}
if err := s.sendSelectedActorUserStateRefreshIfNonDefault(
session,
"current_exe_op1425_after_op24_reapply_persisted_visibility",
); err != nil {
return err
}
session.townMu.Lock()
setCurrentTownPositionSceneLocked(session, characterID, origin.TownID, origin.AreaID)
session.townPrevVillageSnapshot = currentTownPositionSnapshot{}
session.initialTownRouteCharacterID = 0
session.initialTownRouteStage = currentInitialTownRouteIdle
session.completedTownReturnBuffRegistrationPending = false
session.initialTownLocationNotificationsSent = false
session.initialTownQuestSnapshotsSent = false
session.initialTownQuestHUDRefreshSent = false
session.initialTownGuildStateProjectionSent = false
session.initialTownMode3RefreshPending = false
session.currentFinishLoadingStateSent = false
session.currentFinishLoadingCompletionSent = false
session.postFinishLoadingPlayerStateSent = false
session.initialTownLegacySceneReadyAccepted = false
clearCurrentGameOptionRestoreSnapshot(session)
if !currentCharacterOptionRestoreSnapshotIsInitializationProtected(session) {
clearCurrentCharacterOptionRestoreSnapshot(session)
}
session.initialTownAdventureOverheadRefreshSent = false
session.initialTownCombatPowerAffixesSent = false
session.initialTownMoodProjectionSent = false
session.enterSelectDungeonSent = false
session.enterSelectDungeonAckSent = false
session.enterSelectDungeonPassiveParty = false
session.enterSelectDungeonContextSent = false
session.backToVillageSelectorDuplicateGuard = false
session.townSceneReadyCharacterID = characterID
session.townMu.Unlock()
s.publishCurrentTownPresence(
session,
character,
origin.TownID,
origin.AreaID,
row,
0,
targetArea.MapPath,
"prev_village_return_from_private_seria_room",
)
s.logGameEvent(session, "game-town-prev-village-committed",
"char_id", characterID,
"town_id", origin.TownID,
"area_id", origin.AreaID,
"position_x", origin.PositionX,
"position_y", origin.PositionY,
"map_path", targetArea.MapPath,
"finish_loading_state_rearmed", true)
return nil
}
func (s *Service) logCurrentPrevVillageBlocked(session *gameSession, origin currentTownPositionSnapshot, reason string) {
s.logGameEvent(session, "game-town-prev-village-blocked",
"char_id", session.selectedCharacterID,
"origin_character_id", origin.CharacterID,
"origin_town_id", origin.TownID,
"origin_area_id", origin.AreaID,
"origin_position_x", origin.PositionX,
"origin_position_y", origin.PositionY,
"origin_position_valid", origin.PositionValid,
"reason", reason)
}
func (s *Service) logTownPrevVillageOriginBound(session *gameSession, origin currentTownPositionSnapshot, source string) {
s.logGameEvent(session, "game-town-prev-village-origin-bound",
"char_id", session.selectedCharacterID,
"origin_town_id", origin.TownID,
"origin_area_id", origin.AreaID,
"origin_position_x", origin.PositionX,
"origin_position_y", origin.PositionY,
"origin_position_valid", origin.PositionValid,
"source", source)
}
func (s *Service) logTownPrevVillageOriginBindFailed(session *gameSession, characterID uint16, reason string) {
s.logGameEvent(session, "game-town-prev-village-origin-bind-failed",
"char_id", characterID,
"reason", reason)
}