服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
133 lines
4.5 KiB
Go
133 lines
4.5 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strconv"
|
|
)
|
|
|
|
const currentSoloTeleportRequestWireSize = 15
|
|
|
|
// currentSoloTeleportRequest is written by current NoPack.exe
|
|
// SendSoloTeleportRequest470. The first two values are stable -1 in the live
|
|
// personal-teleport samples, but their meaning is not required for routing.
|
|
type currentSoloTeleportRequest struct {
|
|
OpaqueI32A int32
|
|
OpaqueI32B int32
|
|
TownID byte
|
|
AreaID byte
|
|
PositionX int16
|
|
PositionY int16
|
|
Direction byte
|
|
}
|
|
|
|
func parseCurrentSoloTeleportRequest(body []byte) (currentSoloTeleportRequest, error) {
|
|
if len(body) != currentSoloTeleportRequestWireSize {
|
|
return currentSoloTeleportRequest{}, fmt.Errorf(
|
|
"current solo teleport op470 body length %d, want %d",
|
|
len(body),
|
|
currentSoloTeleportRequestWireSize,
|
|
)
|
|
}
|
|
return currentSoloTeleportRequest{
|
|
OpaqueI32A: int32(binary.LittleEndian.Uint32(body[0:4])),
|
|
OpaqueI32B: int32(binary.LittleEndian.Uint32(body[4:8])),
|
|
TownID: body[8],
|
|
AreaID: body[9],
|
|
PositionX: int16(binary.LittleEndian.Uint16(body[10:12])),
|
|
PositionY: int16(binary.LittleEndian.Uint16(body[12:14])),
|
|
Direction: body[14],
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentSoloTeleport(session *gameSession, body []byte) error {
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
request, err := parseCurrentSoloTeleportRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-town-solo-teleport-blocked",
|
|
"body_len", len(body),
|
|
"reason", "current_exe_op470_writer_boundary_mismatch",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
|
|
// op470 has a client-side failure callback, so a successful request must
|
|
// advance through the established op24 scene-transition owner without an
|
|
// op470 ACK. That owner also validates the target against the runtime PVF,
|
|
// character level/quest state, active-dungeon state, and current town.
|
|
s.logGameEvent(session, "game-town-solo-teleport-request",
|
|
"char_id", session.selectedCharacterID,
|
|
"town_id", request.TownID,
|
|
"area_id", request.AreaID,
|
|
"position_x", request.PositionX,
|
|
"position_y", request.PositionY,
|
|
"direction", request.Direction,
|
|
"opaque_i32_a", request.OpaqueI32A,
|
|
"opaque_i32_b", request.OpaqueI32B)
|
|
|
|
// The personal teleport is a five-minute lock. The client refuses on its own
|
|
// timer before writing op470; this gate is what makes the same rule hold for
|
|
// a request that arrives anyway.
|
|
if remaining, cooling := s.currentSoloTeleportCooldownRemaining(
|
|
session.selectedCharacterID); cooling {
|
|
s.refuseCurrentSoloTeleportDuringCooldown(session, request, remaining)
|
|
return nil
|
|
}
|
|
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil || session.selectedCharacterID == 0 {
|
|
s.logGameEvent(session, "game-town-solo-teleport-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "selected_character_location_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
|
|
}
|
|
currentTown, townFound := character.Stats["town_id"]
|
|
if !found || character.Stats == nil || !townFound ||
|
|
currentTown <= 0 || currentTown > int64(^uint16(0)) {
|
|
s.logGameEvent(session, "game-town-solo-teleport-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "selected_character_town_id_missing_or_invalid")
|
|
return nil
|
|
}
|
|
|
|
// Personal teleport is an explicit transport command, not an ordinary
|
|
// op36 portal click. Preserve the persisted current town as the transport
|
|
// source so the PVF-validated route can admit a cross-town destination
|
|
// without weakening normal op36 movement checks.
|
|
if err := s.handleTownSetUserArea(
|
|
session,
|
|
buildCurrentSoloTeleportMoveBody(request, uint16(currentTown)),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
// Arm the five-minute lock and publish its countdown, but only once the
|
|
// destination is actually committed.
|
|
s.armCurrentSoloTeleportCooldownAfterMove(session, request)
|
|
return nil
|
|
}
|
|
|
|
func buildCurrentSoloTeleportMoveBody(
|
|
request currentSoloTeleportRequest,
|
|
sourceTownID uint16,
|
|
) []byte {
|
|
body := make([]byte, currentTownSetUserAreaBodySize)
|
|
body[0] = request.TownID
|
|
body[1] = request.AreaID
|
|
binary.LittleEndian.PutUint16(body[2:4], uint16(request.PositionX))
|
|
binary.LittleEndian.PutUint16(body[4:6], uint16(request.PositionY))
|
|
body[6] = request.Direction
|
|
binary.LittleEndian.PutUint16(body[7:9], sourceTownID)
|
|
body[15] = 5
|
|
return body
|
|
}
|