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

1019 lines
47 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 (
"context"
"encoding/binary"
"fmt"
"sort"
"strconv"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const currentTownSetUserAreaBodySize = 16
type currentTownSetUserAreaRequest struct {
TownID byte
AreaID byte
PositionX int16
PositionY int16
Direction byte
OpaqueU16A uint16
OpaqueU16B uint16
OpaqueU32 uint32
OpaqueTailU8 byte
}
func parseCurrentTownSetUserAreaRequest(body []byte) (currentTownSetUserAreaRequest, error) {
if len(body) != currentTownSetUserAreaBodySize {
return currentTownSetUserAreaRequest{}, fmt.Errorf("current town op36 body length %d, want %d", len(body), currentTownSetUserAreaBodySize)
}
return currentTownSetUserAreaRequest{
TownID: body[0],
AreaID: body[1],
PositionX: int16(binary.LittleEndian.Uint16(body[2:4])),
PositionY: int16(binary.LittleEndian.Uint16(body[4:6])),
Direction: body[6],
OpaqueU16A: binary.LittleEndian.Uint16(body[7:9]),
OpaqueU16B: binary.LittleEndian.Uint16(body[9:11]),
OpaqueU32: binary.LittleEndian.Uint32(body[11:15]),
OpaqueTailU8: body[15],
}, nil
}
func (s *Service) handleTownSetUserArea(session *gameSession, body []byte) error {
return s.handleTownSetUserAreaAfterEncounter(session, body, false)
}
// handleTownSetUserAreaAfterEncounter 是 handleTownSetUserArea 加一个"遇敌是否已经
// 由调用方尝试过"的标志。
//
// 只有一处读它:下面 fiendWarMoveBefore 那段。之所以需要这个标志,是因为超时空的
// 走位有两个入口 —— 客户端的 op36 直接进来(encounterAttempted=false,需要自己尝试
// 开战),以及队长的遇敌流程把跟随者的那次走位排队、事后重放
// (queueRuntimeFiendWarMoveEncounterLocked,encounterAttempted=true,再试一次会自
// 己套自己)。
//
// 安东/卢克/普通频道永远走 false,且两个插入点都被 isCurrentFiendWarRaidChannel
// 守卫,所以那些路径与改动前逐字等价。
func (s *Service) handleTownSetUserAreaAfterEncounter(session *gameSession, body []byte, encounterAttempted bool) error {
if session == nil {
return nil
}
request, err := parseCurrentTownSetUserAreaRequest(body)
if err != nil {
s.logGameEvent(session, "game-town-set-user-area-blocked",
"body_len", len(body),
"reason", "current_exe_op36_writer_boundary_mismatch",
"error", err)
return nil
}
if session.selectedCharacterID == 0 {
s.logTownSetUserAreaBlocked(session, request, "selected_character_missing")
return nil
}
session.dungeon.mu.Lock()
pendingDungeonReturn := session.dungeon.runtime != nil &&
session.dungeon.runtime.Session != nil &&
session.dungeon.runtime.townReturnPending
session.dungeon.mu.Unlock()
if !pendingDungeonReturn {
session.townMu.Lock()
townSceneReadyCharacterID := session.townSceneReadyCharacterID
session.townMu.Unlock()
if townSceneReadyCharacterID != session.selectedCharacterID {
s.logTownSetUserAreaBlocked(session, request, "town_scene_player_state_not_finalized")
return nil
}
}
refreshQuestsAfterDungeonReturn := pendingDungeonReturn
if err := s.commitPendingDungeonReturnForSceneRequest(session, "current_exe_op36_town_area_request"); err != nil {
return err
}
session.dungeon.mu.Lock()
hasDungeonRuntime := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if hasDungeonRuntime {
s.logTownSetUserAreaBlocked(session, request, "active_dungeon_runtime_rejects_town_area_request")
return nil
}
session.townMu.Lock()
defer session.townMu.Unlock()
clientRequestedTownID := request.TownID
clientRequestedAreaID := request.AreaID
raidTownSpec, raidTownSessionScoped := currentRaidTownSpecForSession(session)
pvpTownSessionScoped := isPVPChannel(session.channel)
// RAID towns are session-scoped scenes only. Never persist or escape them
// through a transport-shaped op36 while the session remains in that owner.
if raidTownSessionScoped && request.TownID != byte(raidTownSpec.townID) {
s.logTownSetUserAreaBlocked(session, request, raidTownSpec.ownerName+"_raid_channel_rejects_non_raid_town_transition")
return nil
}
if pvpTownSessionScoped && request.TownID != byte(currentPVPTownID) {
s.logTownSetUserAreaBlocked(session, request, "pvp_channel_rejects_non_pvp_town_transition")
return nil
}
raidInstanceKey := uint32(0)
if raidTownSessionScoped &&
request.TownID == byte(raidTownSpec.townID) &&
request.AreaID == raidTownSpec.waitingArea {
state, member := s.runtimeRaidForMember(session.selectedCharacterID)
if !member || state.RaidKey == 0 {
s.logTownSetUserAreaBlocked(session, request, raidTownSpec.ownerName+"_waiting_instance_requires_raid_membership")
return nil
}
raidInstanceKey = state.RaidKey
}
// 超时空:走进任何一个区域(>=2)都要求自己是攻坚成员,而且除等待室之外必须
// 处于进行中。MoveMember 把"这个人走到哪个区域"记进战场 —— 它与下面
// moveRuntimeFiendWarGroup 的分工是:MoveMember 记个人位置并受区域人数上限与
// 死亡禁锢约束,moveRuntimeFiendWarGroup 记小队整体占位并校验相邻关系。
// 安东/卢克不进这里(频道类型不同)。
if isCurrentFiendWarRaidChannel(session) && request.AreaID >= 2 {
state, member := s.runtimeRaidForMember(session.selectedCharacterID)
if !member {
s.logTownSetUserAreaBlocked(session, request, "fiendwar_membership_required")
return nil
}
raidInstanceKey = state.RaidKey
if int(request.AreaID) != int(currentFiendWarWaitingAreaID) && state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
s.logTownSetUserAreaBlocked(session, request, "fiendwar_not_active")
s.refuseRuntimeFiendWarMoveLocked(session, request, "fiendwar_not_active")
return nil
}
if state.Lifecycle.FiendWar != nil && state.Lifecycle.Status == runtimeRaidLifecyclePhaseActive {
// **live-first**:账本里的个人区域一旦滞后,相邻判定就会拿**旧格子**去算,
// 把玩家挡在隔壁门口 —— 真机 2026-10-02 14:20 礼白666:人在 11 点 7,
// 被按"16 与 7 不相邻"拒(area 11 的 next 明明含 7)。账本滞后的来源包括
// "被队友带进副本"(他没走过 op36)与服务端搬运,所以每一脚移动前都先对齐。
if position := session.townPositionSnapshot; position.PositionValid &&
int(position.TownID) == 98 && int(position.AreaID) >= 2 {
if synced := s.syncRuntimeFiendWarMemberAreaFromLivePosition(
session.selectedCharacterID, int(position.AreaID), "fiendwar_move"); synced.RaidKey != 0 {
state = synced
}
}
frozen, _ := runtimeRaidFrozenMember(state, session.selectedCharacterID)
if err := state.Lifecycle.FiendWar.MoveMember(frozen.CharID, runtimeRaidNormalizedGroup(frozen.GroupIndex), int(request.AreaID), s.gameplayNow()); err != nil {
s.logTownSetUserAreaBlocked(session, request, err.Error())
// 拒绝也要回话:不然客户端会永远停在"正在进入"上(真机卡死过)。
s.refuseRuntimeFiendWarMoveLocked(session, request, err.Error())
return nil
}
}
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
s.logTownSetUserAreaBlocked(session, request, "character_repository_unavailable")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found || character.Stats == nil {
s.logTownSetUserAreaBlocked(session, request, "selected_character_location_missing")
return nil
}
// 从婚房(101/0)走左门出来的原生形状是 (300,200)/方向5,落点恰好落在传送门
// 触发矩形里;客户端重叠 latch 不解除就会立刻把人送回房间。这里把它归一化到
// 赛丽亚房间的门边安全落点(见 wedding_room_exit.go)。
// 必须早于下面的 prev-village 重定向与 currentTown 读取,否则下游拿到的是
// 未修正的目标。
if normalized, weddingRoomExit, exitErr := s.normalizeCurrentWeddingRoomExit(session, character, request); exitErr != nil {
s.logTownSetUserAreaBlocked(session, request, exitErr.Error())
return nil
} else if weddingRoomExit {
request = normalized
s.logGameEvent(session, "game-wedding-room-exit-normalized",
"char_id", session.selectedCharacterID,
"requested_town_id", clientRequestedTownID, "requested_area_id", clientRequestedAreaID,
"town_id", request.TownID, "area_id", request.AreaID,
"position_x", request.PositionX, "position_y", request.PositionY,
"source", "current_exe_3410D33_minus101_portal_runtime_pvf_seria_wedding_door")
}
currentTown, ok := character.Stats["town_id"]
if !ok {
s.logTownSetUserAreaBlocked(session, request, "selected_character_town_id_missing")
return nil
}
seriaExitPrevVillageRedirect := false
if origin, redirect := s.currentTownSetUserAreaPrevVillageRedirectLocked(session, character, request); redirect {
request.TownID = origin.TownID
request.AreaID = origin.AreaID
request.PositionX = int16(origin.PositionX)
request.PositionY = int16(origin.PositionY)
request.Direction = origin.MovementCode
session.townPrevVillageSnapshot = origin
seriaExitPrevVillageRedirect = true
s.logGameEvent(session, "game-town-set-user-area-seria-exit-prev-village-redirected",
"char_id", session.selectedCharacterID,
"requested_town_id", clientRequestedTownID,
"requested_area_id", clientRequestedAreaID,
"target_town_id", request.TownID,
"target_area_id", request.AreaID,
"target_position_x", request.PositionX,
"target_position_y", request.PositionY,
"target_direction", request.Direction,
// The opaque fields are what separates the exit door from the
// teleport array; without them a misrouted exit is unreadable.
"opaque_u16_a", request.OpaqueU16A,
"opaque_u16_b", request.OpaqueU16B,
"opaque_u32", request.OpaqueU32,
"opaque_tail_u8", request.OpaqueTailU8,
"source", "current_exe_op36_fixed_seria_room_exit_alias")
}
teleportArrayRequest := request.LooksLikeTeleportArraySelection()
townMapStationRequest := request.LooksLikeTownMapStationSelection()
townTransportRequest := request.LooksLikeTownTransportSelection()
townSeriaRoomPortalRequest := false
townCrossTownPortalRequest := false
townNPCEnterRequest := false
area, found := s.townArea(int64(request.TownID), int64(request.AreaID))
if !found {
s.logTownSetUserAreaBlocked(session, request, "target_town_area_missing_from_runtime_pvf")
return nil
}
// Seria maps, a raid channel's Seria room, and the PVP channel lobby (town 10
// / area 0) are all private scenes: their visitors never join town
// co-presence, so each client sees an empty scene like its own Seria room.
// Other PVP channel areas stay public. The map check is authoritative for a
// named Seria map, the raid spec for a raid gate, and the town/area check for
// the PVP lobby because that map path is not a "seria" file either.
seriaMap := townMapPathLooksLikeSeria(area.MapPath)
raidGateRoom := currentRaidGateAreaIsSeriaRoom(session, request.TownID, request.AreaID)
pvpLobbyRoom := isPVPChannel(session.channel) &&
request.TownID == byte(currentPVPTownID) && request.AreaID == byte(currentPVPAreaID)
privateRoomScene := seriaMap || raidGateRoom || pvpLobbyRoom
privateRoomKind := "private_seria_room"
if privateRoomScene && !seriaMap && !raidGateRoom {
privateRoomKind = "private_pvp_lobby"
}
// A type-23 login/reconnect uses the PVF Anton town as a session-scoped
// scene while the character record retains its ordinary town. Once the
// scene is ready, its snapshot is the authoritative source for op36 route
// validation; falling back to the durable town would misclassify an Anton
// portal as an unproven cross-town request.
sceneSnapshot := session.townPositionSnapshot
sceneReadyCharacterID := session.townSceneReadyCharacterID
if raidTownSessionScoped &&
sceneReadyCharacterID == session.selectedCharacterID &&
sceneSnapshot.CharacterID == session.selectedCharacterID &&
sceneSnapshot.TownID == byte(raidTownSpec.townID) {
currentTown = int64(sceneSnapshot.TownID)
}
if pvpTownSessionScoped &&
sceneReadyCharacterID == session.selectedCharacterID &&
sceneSnapshot.CharacterID == session.selectedCharacterID &&
sceneSnapshot.TownID == byte(currentPVPTownID) {
currentTown = int64(sceneSnapshot.TownID)
}
townCrossTownPortalRequest = request.LooksLikeTownCrossTownPortalSelection(currentTown)
townSeriaRoomPortalRequest = request.LooksLikeTownSeriaRoomPortalSelection(currentTown, area.MapPath)
townNPCEnterRequest = request.LooksLikeTownNPCEnterSelection(currentTown) &&
s.townAllowsNPCEnter(int64(request.TownID), int64(request.AreaID), int64(request.OpaqueU32))
// 【2026-10-12 接通】阿比斯入口:神秘异次元遗迹 107/0。
//
// 为什么 townAllowsNPCEnter 恒为 false(运行时 PVF 实测):
// 入口 NPC 是 **20275** `npc/chn_DayDungeon_SeriaRoom_01.npc`,
// 它的角色是 `` `[enter town]` 20276 `` —— 这里 20276 是**目标城镇号 107**
// (town.lst: 107 `chn_DayDungeon.twn`),不是 NPC 号。
// 它位于赛丽亚房间(38/1 `Cataclysm/Town/Elvengard/new_seria_room.map`)。
// 而 107/0 的地图 `Town/DayDungeon/DayDungeon_town.map` 只摆放了
// 20276(阿比斯 `[exit town]`) 与 20277~20283(七个门),**没有 20275**。
// town.TownExitTargetNPCIDs 的装载条件是"该 NPC 同时声明 [enter town] 与
// [exit town],且被摆放在**目标区域**地图里"(town/load.go:136-148),
// 所以 20275 永远进不了 107/0 的白名单 → 请求落到
// cross_town_route_not_proven_by_current_pvf_topology 被拒绝。
//
// 这里的放行范围**只限这一个目标**:城镇 107 且区域 0,且运行时 PVF 给出的
// 地图路径必须精确匹配 `Town/DayDungeon/DayDungeon_town.map`。其它城镇传送
// 白名单不放宽;目标侧的 50 级门槛与任务 8618 校验仍走下面既有的 area.MinLevel
// 与 area.NeedQuests 分支,不放宽。
mysteriousRuinsEntryRequest := currentTown != int64(request.TownID) &&
!townNPCEnterRequest &&
isCurrentMysteriousRuinsTownArea(request.TownID, request.AreaID) &&
isCurrentMysteriousRuinsTownMapPath(area.MapPath)
robotPublicPlacementRequest := session.robotSessionBound &&
s.currentAutomaticRobotPublicTownAreaAllowed(request.TownID, request.AreaID)
townTransportRequest = townTransportRequest || townCrossTownPortalRequest || townSeriaRoomPortalRequest ||
townNPCEnterRequest || mysteriousRuinsEntryRequest || robotPublicPlacementRequest ||
seriaExitPrevVillageRedirect
if currentTown != int64(request.TownID) && !townTransportRequest {
s.logTownSetUserAreaBlocked(session, request, "cross_town_route_not_proven_by_current_pvf_topology")
return nil
}
if area.MinLevel > int64(character.Level) {
s.logTownSetUserAreaBlocked(session, request, "target_area_min_level_not_met")
return nil
}
areaState, ok := character.Stats["area_state"]
if !ok || areaState < 0 || areaState > int64(^byte(0)) {
s.logTownSetUserAreaBlocked(session, request, "selected_character_area_state_missing_or_invalid")
return nil
}
if len(area.NeedQuests) > 0 && !robotPublicPlacementRequest {
needQuestsCompleted, questErr := s.townAreaNeedQuestsCompleted(ctx, repositories, characterID, area.NeedQuests)
if questErr != nil {
s.logGameEvent(session, "game-town-set-user-area-need-quest-check-failed",
"char_id", session.selectedCharacterID,
"town_id", request.TownID,
"area_id", request.AreaID,
"target_need_quests", area.NeedQuests,
"town_transport_request", townTransportRequest,
"error", questErr)
}
if !needQuestsCompleted {
s.logTownSetUserAreaBlocked(session, request, "target_area_has_pvf_quest_requirement_without_completed_quest_owner")
return nil
}
}
prevVillageOrigin := currentTownPositionSnapshot{}
prevVillageOriginBound := false
if townMapPathLooksLikeSeria(area.MapPath) {
if origin, ok := currentTownPrevVillageOriginLocked(session, session.selectedCharacterID, character); ok {
// 不要把婚房(101)绑成"上个城镇"。
//
// currentTownPrevVillageOriginLocked 返回的是角色**当前**位置;从婚房
// 走左门出来时归一化的目标正好是赛丽亚房间(38/1,也是 Seria 地图),
// 于是这条绑定会把回城点覆盖成婚房本身,op1425 就会把玩家送回婚房。
// 保持旧值(进婚房之前记住的那个城镇)才是原生行为。
if origin.TownID == currentWeddingRoomTownID {
s.logTownPrevVillageOriginBindFailed(session, session.selectedCharacterID,
"current_location_is_wedding_room_keeps_earlier_prev_village")
} else {
originSource := "current_exe_op36_enter_seria_room"
if doorOrigin, _, projected := s.currentTownPrevVillageDoorOrigin(origin); projected {
origin = doorOrigin
originSource = "current_exe_op36_enter_seria_room_runtime_pvf_door"
}
prevVillageOrigin = origin
prevVillageOriginBound = true
session.townPrevVillageSnapshot = origin
s.logTownPrevVillageOriginBound(session, origin, originSource)
}
} else {
s.logTownPrevVillageOriginBindFailed(session, session.selectedCharacterID, "current_location_or_position_unavailable_before_enter_seria_room")
}
}
// 婚姻场景按夫妻对分实例(见 wedding_presence.go):教堂默认房(39/8..38)与
// 婚房(101)都是"按对分实例的公共场景",复用 raidInstanceKey 这条实例键通道,
// 于是下面 EnterArea 注册进去的就是夫妻实例。无权进入者必须直接拒,否则会被
// 塞进别人的夫妻实例里。
weddingTownSessionScoped := false
if key, scoped, admitted := s.currentWeddingTownInstanceKey(
character, request.TownID, request.AreaID, uint16(townPresenceChannelID(session))); !admitted {
s.logTownSetUserAreaBlocked(session, request, "wedding_reservation_or_marriage_required")
return nil
} else if scoped {
// 只有教堂默认房是会话作用域(预约只在本次登录有效)。婚房(101)相反:
// 它和普通城镇一样要落库,玩家重登仍然应该在婚房里 —— 与上游同口径。
weddingTownSessionScoped = request.TownID != currentWeddingRoomTownID
raidInstanceKey = key
}
previous := dnfrepo.CharacterRecord{}
// 教堂是会话作用域的:不该把预约落成持久化的 town_id/area_id/坐标。婚房与
// 普通城镇一样持久化(见上)。
persistedTownLocation := !raidTownSessionScoped && !pvpTownSessionScoped && !weddingTownSessionScoped
if persistedTownLocation {
previous = dnfrepo.CloneCharacter(character)
character = dnfrepo.CloneCharacter(character)
if prevVillageOriginBound {
currentTownPrevVillageApplyOriginStats(character.Stats, prevVillageOrigin)
}
character.Stats["town_id"] = int64(request.TownID)
character.Stats["area_id"] = int64(request.AreaID)
character.Stats["pos_x"] = int64(request.PositionX)
character.Stats["pos_y"] = int64(request.PositionY)
character.Stats["direction"] = int64(request.Direction)
character.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveCharacterFields(ctx, repositories.Character, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
}
selfRow := currentSceneTransitionRow{
ObjectOrResourceKey: currentSceneActorObjectKey(session.selectedCharacterID),
Value1: uint16(request.PositionX),
Value2: uint16(request.PositionY),
Value3: request.Direction,
Value4: byte(areaState),
}
transitionRows := make([]currentSceneTransitionRow, 0, 1)
if s.onlinePlayers != nil {
areaPlayers := s.onlinePlayers.GetAreaInstancePlayers(
townPresenceChannelID(session),
request.TownID, request.AreaID, raidInstanceKey)
sort.Slice(areaPlayers, func(i, j int) bool {
return areaPlayers[i].CharacterID < areaPlayers[j].CharacterID
})
for i := range areaPlayers {
if areaPlayers[i].CharacterID == 0 ||
areaPlayers[i].CharacterID == session.selectedCharacterID {
continue
}
transitionRows = append(transitionRows, currentSceneTransitionRow{
ObjectOrResourceKey: currentSceneActorObjectKey(areaPlayers[i].CharacterID),
Value1: areaPlayers[i].PositionX,
Value2: areaPlayers[i].PositionY,
Value3: areaPlayers[i].Direction,
Value4: areaPlayers[i].AreaState,
})
}
}
// Keep the selected actor last. The current EXE's op24 parser binds the
// final row as the camera/selected actor while earlier rows form the peer
// roster, matching the full-area roster role used by 86JP after it sends
// peer appearance and user-info records.
transitionRows = append(transitionRows, selfRow)
response, err := buildCurrentSceneTransitionBody(request.TownID, request.AreaID, transitionRows)
if err != nil {
return err
}
// 超时空:这一步只把"队伍走到哪个区域"记进战场。真正的开战在下面
// encounterAttempted 的守卫里尝试。这里失败要整笔拒绝 —— 客户端即将收到的场景
// 切换与战场里的区域归属必须一致,否则会出现"人在 B 区、战场以为在 A 区"。
var fiendWarMoveBefore runtimeRaidState
if isCurrentFiendWarRaidChannel(session) && request.AreaID >= 2 {
if state, ok := s.runtimeRaidForMember(session.selectedCharacterID); ok && state.Lifecycle.Status == runtimeRaidLifecyclePhaseActive {
if _, moveErr := s.moveRuntimeFiendWarGroup(session.selectedCharacterID, int(request.AreaID)); moveErr != nil {
s.logTownSetUserAreaBlocked(session, request, moveErr.Error())
// 真机 2026-10-01:这里曾经静默 return,客户端于是卡在"正在进入"。
s.refuseRuntimeFiendWarMoveLocked(session, request, moveErr.Error())
return nil
}
fiendWarMoveBefore = state
}
}
rollbackLocation := func(writeErr error) error {
if fiendWarMoveBefore.RaidKey != 0 {
s.rollbackRuntimeFiendWarMove(fiendWarMoveBefore, session.selectedCharacterID, int(request.AreaID))
}
if !persistedTownLocation {
return writeErr
}
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-set-user-area-rollback-failed",
"char_id", session.selectedCharacterID,
"town_id", request.TownID,
"area_id", request.AreaID,
"write_error", writeErr,
"rollback_error", rollbackErr)
}
return writeErr
}
if !encounterAttempted && fiendWarMoveBefore.RaidKey != 0 {
// op24 构造的是野外场景。如果这一脚正好踩进一个有敌军的区域,就走既有的
// op27/28/29 副本加载序列直接进图,不再发场景切换 —— 否则客户端会先被画进
// 空地再被拖进副本。
if handled, encErr := s.queueRuntimeFiendWarMoveEncounterLocked(session, request, selfRow, body); handled || encErr != nil {
if encErr != nil {
session.dungeon.mu.Lock()
entered := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if entered {
return encErr
}
return rollbackLocation(encErr)
}
return nil
}
}
s.logGameEvent(session, "game-town-set-user-area-transition-send",
"char_id", session.selectedCharacterID,
"request_msg_id", uint16(dnfenum.CmdPacketSetUserArea),
"response_msg_ids", []uint16{
currentTownUserPositionNotificationMsgID,
currentTownUserAreaNotificationMsgID,
currentClearQuestListMsgID,
currentSceneTransitionMsgID,
},
"town_id", request.TownID,
"area_id", request.AreaID,
"position_x", request.PositionX,
"position_y", request.PositionY,
"direction", request.Direction,
"area_state", areaState,
"map_path", area.MapPath,
"teleport_array_request", teleportArrayRequest,
"town_map_station_request", townMapStationRequest,
"town_seria_room_portal_request", townSeriaRoomPortalRequest,
"town_cross_town_portal_request", townCrossTownPortalRequest,
"town_npc_enter_request", townNPCEnterRequest,
"robot_public_placement_request", robotPublicPlacementRequest,
"town_transport_request", townTransportRequest,
"seria_exit_prev_village_redirect", seriaExitPrevVillageRedirect,
"client_requested_town_id", clientRequestedTownID,
"client_requested_area_id", clientRequestedAreaID,
"durable_character_location_written", persistedTownLocation,
"target_need_quests", area.NeedQuests,
"opaque_u16_a", request.OpaqueU16A,
"opaque_u16_b", request.OpaqueU16B,
"opaque_u32", request.OpaqueU32,
"opaque_tail_u8", request.OpaqueTailU8,
"row_count", len(transitionRows),
"peer_row_count", len(transitionRows)-1,
"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")
if err := s.sendCurrentTownLocationNotifications(
session,
session.selectedCharacterID,
request.TownID,
request.AreaID,
selfRow,
"current_exe_op36_town_area_transition",
); err != nil {
return rollbackLocation(err)
}
// Initial town bootstrap installs the completed-quest bitmap before op24.
// Ordinary town transitions need the same order because op24 constructs the
// destination scene; a later op356 cannot restore an omitted quest gate.
if _, err := s.sendCurrentPersistedClearQuestListIfCompleted(
ctx,
session,
repositories,
"current_exe_op36_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)
}
// 【2026-10-12 接通】神秘异次元遗迹(107/0):场景已经切过去之后,必须补发 op855
// 副本日程通知,否则客户端不认为 4906~4910 处于开放状态,走到入口只会弹
// 「现在不是地下城的开放时间」。
//
// sendCurrentMysteriousRuinsSchedule 早就写好(mysterious_ruins_schedule.go,
// 会先拿运行时 PVF 逐条校验 5 个副本的路径再发 5 条 op855),但**全代码库
// 没有任何地方调用它** —— 与本次修好的 op632 是同一类"实现了却没接线"的缺陷。
// 它对非 107/0 的目标会自己 return nil,所以放在这里对其它城镇没有影响。
if err := s.sendCurrentMysteriousRuinsSchedule(
session, request.TownID, request.AreaID, "current_exe_op36_after_op24",
); err != nil {
// 日程通知缺失只是门不可点,不该让已经落库的走位回滚。
s.logGameEvent(session, "game-mysterious-ruins-schedule-send-failed",
"char_id", session.selectedCharacterID,
"town_id", request.TownID,
"area_id", request.AreaID,
"error", err)
}
// 进婚房要把房间现状(已摆放家具 + 情侣树)推给客户端;op36 这条路径不走
// publishCurrentTownPresence,所以在这里补一次,否则进去看到空房间。
if request.TownID == currentWeddingRoomTownID {
if err := s.sendWeddingRoomState(session, character); err != nil {
s.logGameEvent(session, "game-wedding-room-restore-failed", "error", err)
return rollbackLocation(err)
}
}
if err := s.sendSelectedActorUserStateRefreshIfNonDefault(
session,
"current_exe_op36_after_op24_reapply_persisted_visibility",
); err != nil {
return err
}
// 区域切换和回城一样会重建客户端场景:buff 栏 / 冷却表是进程内内存,op24 之后必须
// 重放一次,否则公会属性(op487 VariousBuff 167/169)只要过一次图就消失,玩家要
// 小退重上才恢复(2026-10-08 现场:回城 43.083 补过 buff,45.625 的区域切换又清掉)。
// 位置已经落库、场景已经切换,所以这里只记日志不回滚:少一个图标不该让走位失败。
if err := s.restoreCurrentSceneHudProjections(session, "town_area_transition_after_op24"); err != nil {
s.logGameEvent(session, "game-town-area-transition-hud-restore-failed",
"char_id", session.selectedCharacterID,
"town_id", request.TownID,
"area_id", request.AreaID,
"error", err)
}
setCurrentTownPositionSceneLocked(session, session.selectedCharacterID, request.TownID, request.AreaID)
if seriaExitPrevVillageRedirect {
// The redirected exit consumed the preserved origin exactly like op1425.
session.townPrevVillageSnapshot = currentTownPositionSnapshot{}
}
if townTransportRequest {
// A world-map/teleport request already carries the authoritative arrival
// coordinates. Freeze them now so the first gate op15 can bind op27
// without waiting for an incidental later op35 movement report.
session.townPositionSnapshot = currentTownPositionSnapshot{
CharacterID: session.selectedCharacterID,
TownID: request.TownID,
AreaID: request.AreaID,
PositionX: uint16(request.PositionX),
PositionY: uint16(request.PositionY),
MovementCode: request.Direction,
PositionValid: true,
}
}
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
// The redirected Seria exit lands on a public town like op1425 does; it
// produces no client-owned op15 transport finalizer.
session.townTransportEnterSelectPending = townTransportRequest && !seriaExitPrevVillageRedirect
session.townSceneReadyCharacterID = session.selectedCharacterID
// 超时空:场景已经提交,把新的区域归属推给全团。放在这里(而不是紧接
// moveRuntimeFiendWarGroup 之后)是因为客户端此刻才真正站在新区域里 ——
// 早发会让别人看到你先于实际落点换了区域。
if fiendWarMoveBefore.RaidKey != 0 {
if moved, ok := s.runtimeRaidForMember(session.selectedCharacterID); ok {
if err := s.broadcastRuntimeFiendWarBoard(moved, false, s.gameplayNow()); err != nil {
s.logGameEvent(session, "game-fiendwar-move-board-publish-failed", "error", err)
}
}
}
// Register player in the new area and broadcast to others.
var newAreaOthers []onlinePlayerInfo
if s.onlinePlayers != nil {
characterID := session.selectedCharacterID
partyStateBeforeTownMove := runtimePartyStateSnapshot(session)
playerInfo := &onlinePlayerInfo{
CharacterID: characterID,
AccountID: character.AccountID,
Name: character.Name,
Job: byte(numericCharacterStat(character.Job)),
GrowType: byte(numericCharacterStatValue(character, "grow_type")),
Level: byte(character.Level),
ChannelID: townPresenceChannelID(session),
TownID: request.TownID,
AreaID: request.AreaID,
InstanceKey: raidInstanceKey,
PositionX: uint16(request.PositionX),
PositionY: uint16(request.PositionY),
Direction: request.Direction,
AreaState: byte(areaState),
Session: session,
}
// A player store is scoped to one concrete town scene. Close it before
// removing the owner's old-area presence so every old peer receives the
// native close notification and stale visitors are detached.
s.closeCurrentExpertJobStoreSession(session, true)
s.closeCurrentPrivateStoreSession(session, true)
// SET_USER_AREA re-registers the character below on its own, so a
// dungeon-selector retirement memo is spent here rather than left for a
// later selector return to replay against the old area.
clearTownSelectorRetiredPresence(session)
// Leave the old area. Ordinary peers receive op6. Party peers retain the
// same actor identity and receive only its op23 area change, because op6
// also removes that member from the current client's party manager.
_, oldOthers := s.onlinePlayers.LeaveArea(characterID)
if len(oldOthers) > 0 {
s.broadcastTownPlayerAreaChange(playerInfo, oldOthers, partyStateBeforeTownMove)
}
s.promoteResidentGameSession(session, characterID)
if privateRoomScene {
// Every Seria room is a character-private instance; the PVP lobby is
// likewise actor-invisible so rivals cannot see who idles there.
// Party ownership remains session-scoped, but the town-presence
// directory must contain no actor for this room and therefore emits
// no co-presence creation.
s.logGameEvent(session, "game-town-presence-suppressed",
"source", "town_set_user_area_"+privateRoomKind,
"char_id", characterID,
"town_id", request.TownID,
"area_id", request.AreaID,
"map_path", area.MapPath,
"retired_peer_count", len(oldOthers),
"reason", privateRoomKind)
} else {
// Enter a public destination area only after the old scene actor has
// been retired from its previous observers.
newOthers := s.onlinePlayers.EnterArea(playerInfo)
newAreaOthers = append(newAreaOthers, newOthers...)
s.broadcastTownPlayerEnter(playerInfo, newOthers)
s.replayCurrentExpertJobStores(session, request.TownID, request.AreaID)
s.replayCurrentPrivateStores(session, request.TownID, request.AreaID)
}
// Same-channel peers standing in another area of this town get their actor
// here, in BOTH branches. A private room suppresses this character own
// co-presence, but must not suppress the record of everyone else: the client
// resolves a peer target only through the actor record it was sent
// (sub_1A089E0 -> sub_1C5B070 looks the key up in its own table and silently
// does nothing when it misses). Skipping this call in a private room meant an
// applicant standing in the raid channel Seria room could not resolve the
// leader and never sent the join application -- measured: stepping out to the
// deck made the same application succeed. The call only installs actors for
// players in OTHER areas, so it cannot make a private room visible.
s.sendChannelWidePeerActors(session, playerInfo, "channel_wide_peer_actor_after_town_area_enter")
}
// SET_USER_AREA rebuilds the current EXE's scene-owned party manager. Peer
// actor projections alone are not sufficient: whichever member crosses the
// town boundary last can otherwise retain only the selected actor row and
// lose its own leader/member binding. Replay only the authoritative op9
// roster after the new-area actors have been installed. The op11 endpoint
// exchange belongs to party formation and must not be repeated here.
partyStateAfterTownMove := runtimePartyStateSnapshot(session)
if partyStateAfterTownMove.PartyID > 0 {
if !privateRoomScene {
if err := s.sendTownRemotePartyActors(session, partyStateAfterTownMove); err != nil {
return err
}
}
if err := s.sendRuntimePartyRosterLocal(session, partyStateAfterTownMove, "town_set_user_area_local_roster_rebind"); err != nil {
return err
}
// Existing same-area party clients have just received the mover's new
// mode0/mode1 actor. Refresh their selected local roster as the final
// step so the HUD/minimap keeps the authoritative slot mapping.
for i := range newAreaOthers {
peer := newAreaOthers[i].Session
if peer == nil || peer == session ||
!containsRuntimePartyMember(runtimePartyMembers(partyStateAfterTownMove), newAreaOthers[i].CharacterID) {
continue
}
peerState := runtimePartyStateSnapshot(peer)
if peerState.PartyID != partyStateAfterTownMove.PartyID {
continue
}
if err := s.sendRuntimePartyRosterLocal(peer, partyStateAfterTownMove, "town_set_user_area_same_area_peer_roster_rebind"); err != nil {
return err
}
}
}
s.logGameEvent(session, "game-town-set-user-area-committed",
"char_id", session.selectedCharacterID,
"town_id", request.TownID,
"area_id", request.AreaID,
"raid_instance_key", raidInstanceKey,
"position_x", request.PositionX,
"position_y", request.PositionY,
"map_path", area.MapPath,
"teleport_array_request", teleportArrayRequest,
"town_map_station_request", townMapStationRequest,
"town_seria_room_portal_request", townSeriaRoomPortalRequest,
"town_cross_town_portal_request", townCrossTownPortalRequest,
"town_npc_enter_request", townNPCEnterRequest,
"robot_public_placement_request", robotPublicPlacementRequest,
"town_transport_request", townTransportRequest,
"seria_exit_prev_village_redirect", seriaExitPrevVillageRedirect,
"client_requested_town_id", clientRequestedTownID,
"client_requested_area_id", clientRequestedAreaID,
"target_need_quests", area.NeedQuests,
"finish_loading_state_rearmed", true,
"quest_refresh_after_dungeon_return", refreshQuestsAfterDungeonReturn)
// The actor-bound inventory bootstrap owns the normal cold-login op898.
// Keep the first client-authored op36 as a one-shot fallback for routes that
// bypassed that bootstrap; by this point the current NoPack class0 handler
// sub_1E7FAD0 can also see the inventory-panel owner. Keep this optional:
// town movement must not fail merely because a minimal test runtime has no
// premium/PVF catalog.
if err := s.sendCurrentCrystalContractTownUIReadyState(session); err != nil {
s.logGameEvent(session, "game-crystal-contract-town-area-state-deferred",
"char_id", session.selectedCharacterID,
"town_id", request.TownID,
"area_id", request.AreaID,
"reason", "runtime_pvf_or_repository_state_unavailable",
"error", err)
}
// 卢克攻坚队待机室入场:官方文案「塔罗卡牌在团队频道入场时可以看见1次」,
// 所以 1181 的下发点就在这里——raidInstanceKey != 0 只有当请求的
// (town, area) 正好是本人所在攻坚队的待机室、且本人确实是该团成员时才成立
// (见上面 waiting_instance_requires_raid_membership 那条拒绝)。
// 安徒恩与其它场景在这一层就被 owner 检查挡掉,见 raid_luke_card_display.go。
if raidInstanceKey != 0 {
s.publishRuntimeRaidLukeCardDisplay(session, raidInstanceKey, "town_set_user_area_raid_waiting_room")
}
if refreshQuestsAfterDungeonReturn {
return s.sendCurrentAcceptableQuestListForSession(session, "current_exe_op36_after_dungeon_return_committed")
}
return nil
}
// currentTownSetUserAreaPrevVillageRedirectLocked recognizes the current EXE's
// alternate exit from the shared Elvengard Seria-room map. Depending on the
// scene bootstrap path the client may send op36 for town 38 instead of op1425;
// treating that fixed map exit literally loses the character's preserved town.
// Route only that exact Seria-room exit shape to the durable prev-village
// origin. The caller holds session.townMu.
func (s *Service) currentTownSetUserAreaPrevVillageRedirectLocked(
session *gameSession,
character dnfrepo.CharacterRecord,
request currentTownSetUserAreaRequest,
) (currentTownPositionSnapshot, bool) {
if s == nil || session == nil || session.selectedCharacterID == 0 ||
request.TownID != byte(newCharacterInitialTownID) ||
request.AreaID == byte(newCharacterInitialAreaID) {
return currentTownPositionSnapshot{}, false
}
// The redirect exists for the Seria-room exit door, whose op36 always
// names town 38 no matter which town the room was entered from. The
// teleport array inside the same room names a destination the player
// picked, and 埃尔文防线 *is* town 38 - overriding that one sent the
// player back to their previous town instead of the town they chose.
if request.LooksLikeTeleportArraySelection() {
return currentTownPositionSnapshot{}, false
}
currentTownID, currentAreaID, err := currentSceneTransitionLocation(character, true)
if err != nil {
return currentTownPositionSnapshot{}, false
}
currentArea, found := s.townArea(int64(currentTownID), int64(currentAreaID))
if !found || !townMapPathLooksLikeSeria(currentArea.MapPath) {
return currentTownPositionSnapshot{}, false
}
clientTargetArea, found := s.townArea(int64(request.TownID), int64(request.AreaID))
if !found || townMapPathLooksLikeSeria(clientTargetArea.MapPath) {
return currentTownPositionSnapshot{}, false
}
origin := session.townPrevVillageSnapshot
if origin.CharacterID != session.selectedCharacterID || !origin.PositionValid {
var ok bool
origin, ok = currentTownPrevVillageOriginFromPersistedStats(session.selectedCharacterID, character)
if !ok {
return currentTownPositionSnapshot{}, false
}
}
if origin.TownID == request.TownID && origin.AreaID == request.AreaID {
return currentTownPositionSnapshot{}, false
}
if _, persistable := s.currentTownPrevVillageOriginIsPersistable(origin); !persistable {
return currentTownPositionSnapshot{}, false
}
targetArea, found := s.townArea(int64(origin.TownID), int64(origin.AreaID))
if !found || townMapPathLooksLikeSeria(targetArea.MapPath) {
return currentTownPositionSnapshot{}, false
}
return origin, true
}
func (s *Service) townAreaNeedQuestsCompleted(
ctx context.Context,
repositories dnfrepo.Group,
characterID string,
needQuests []int64,
) (bool, error) {
if len(needQuests) == 0 {
return true, nil
}
if repositories.Quest == nil {
return false, dnfrepo.ErrRepoMissing
}
record, found, err := repositories.Quest.Load(ctx, characterID)
if err != nil {
return false, err
}
if !found {
return false, nil
}
for _, questID := range needQuests {
if questID <= 0 {
continue
}
if !townQuestStateCompleted(record, questID) {
return false, nil
}
}
return true, nil
}
func townQuestStateCompleted(record dnfrepo.QuestRecord, questID int64) bool {
completed := false
if state, ok := record.Progress[questID]; ok {
completed = isTownQuestCompletedStatus(state.Status)
}
if state, ok := record.States[questID]; ok {
completed = isTownQuestCompletedStatus(state.Status)
}
return completed
}
func isTownQuestCompletedStatus(status string) bool {
switch normalizeDungeonQuestStatus(status) {
case "complete", "completed", "cleared", "finished", "done":
return true
default:
return false
}
}
// LooksLikeTeleportArraySelection identifies the current client's town
// teleport-array selection shape observed from the live 23.4.15.0 EXE. The
// destination itself is still validated against the runtime PVF town catalog;
// this only prevents server-side quest/topology guards from rejecting a
// client-visible teleport destination before op24 can be emitted.
//
// Live op36 bodies captured from the Seria-room array (opaqueA = the town the
// array stands in, opaqueB = 1, opaqueU32 = 0):
//
// town 40 area 1 -> tail 5 town 39 area 2 -> tail 5
// town 38 area 0 -> tail 6 (the array's own town)
//
// Only the tail moves, so pinning it to 5 misread a selection of the array's
// own town as an ordinary route body. Every non-array body in the same capture
// - route clicks, the Seria-room exit door, cross-town portals, map stations -
// carries tail 0, so the two tails stay disjoint from all of them.
func (request currentTownSetUserAreaRequest) LooksLikeTeleportArraySelection() bool {
if request.OpaqueTailU8 != 5 && request.OpaqueTailU8 != 6 {
return false
}
return request.OpaqueU32 == 0 && request.OpaqueU16A != 0
}
// LooksLikeTownMapStationSelection identifies the current EXE world-map /
// town transfer-station op36 shape observed live from West Coast:
// town=40, area=0, opaqueA=40, opaqueB=1, opaqueU32=0, tail=0. It is distinct
// from ordinary route-click bodies whose opaque fields are zero.
func (request currentTownSetUserAreaRequest) LooksLikeTownMapStationSelection() bool {
return request.OpaqueTailU8 == 0 &&
request.OpaqueU32 == 0 &&
request.OpaqueU16A != 0 &&
request.OpaqueU16B != 0
}
// LooksLikeTownSeriaRoomPortalSelection identifies the current EXE town
// in-scene Seria-room portal shape observed live from West Coast:
// source town=40, target town=38/area=1, opaqueA=40, opaqueB=0, opaqueU32=0,
// tail=0. The target area is still resolved from runtime PVF and must be a
// Seria-room map; this only admits the client's explicit portal request across
// town ownership boundaries.
func (request currentTownSetUserAreaRequest) LooksLikeTownSeriaRoomPortalSelection(currentTown int64, targetMapPath string) bool {
if currentTown <= 0 {
return false
}
return request.LooksLikeTownCrossTownPortalSelection(currentTown) &&
townMapPathLooksLikeSeria(targetMapPath)
}
// LooksLikeTownCrossTownPortalSelection identifies the current EXE in-scene
// cross-town portal shape observed from West Coast to HendonMyre:
// target town=39/area=0, opaqueA=current town 40, opaqueB=0, opaqueU32=0,
// tail=0. The destination is still resolved from the runtime PVF town catalog;
// this only admits a client-emitted portal body whose source-town ownership is
// explicit in the request.
func (request currentTownSetUserAreaRequest) LooksLikeTownCrossTownPortalSelection(currentTown int64) bool {
if currentTown <= 0 {
return false
}
return request.OpaqueTailU8 == 0 &&
request.OpaqueU32 == 0 &&
request.OpaqueU16A != 0 &&
int64(request.OpaqueU16A) == currentTown &&
request.OpaqueU16B == 0 &&
int64(request.TownID) != currentTown
}
// LooksLikeTownNPCEnterSelection identifies the current EXE NPC [enter town]
// op36 shape. The target NPC must still be proven against the runtime PVF town
// catalog before this request is admitted as a cross-town route.
func (request currentTownSetUserAreaRequest) LooksLikeTownNPCEnterSelection(currentTown int64) bool {
if currentTown <= 0 {
return false
}
return request.OpaqueTailU8 == 0 &&
request.OpaqueU32 != 0 &&
request.OpaqueU16B == 1 &&
int64(request.OpaqueU16A) == currentTown &&
int64(request.TownID) != currentTown
}
func (request currentTownSetUserAreaRequest) LooksLikeTownTransportSelection() bool {
return request.LooksLikeTeleportArraySelection() || request.LooksLikeTownMapStationSelection()
}
func (s *Service) logTownSetUserAreaBlocked(session *gameSession, request currentTownSetUserAreaRequest, reason string) {
s.logGameEvent(session, "game-town-set-user-area-blocked",
"char_id", session.selectedCharacterID,
"town_id", request.TownID,
"area_id", request.AreaID,
"position_x", request.PositionX,
"position_y", request.PositionY,
"direction", request.Direction,
"opaque_u16_a", request.OpaqueU16A,
"opaque_u16_b", request.OpaqueU16B,
"opaque_u32", request.OpaqueU32,
"opaque_tail_u8", request.OpaqueTailU8,
"reason", reason)
}