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

246 lines
9.4 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// The current EXE sends op237 (ENUM_CMDPACKET_TELEPORT) when a teleport
// potion is used. Body layout (86JP TownHandler.Handle_ENUM_CMDPACKET_TELEPORT
// and the current-EXE S2C op237 reader contract):
//
// i16 potionSlot, i32 itemCode, u8 reserved, u8 townID, u8 areaID,
// i16le positionX, i16le positionY
//
// The live current-client body is exactly 13 bytes. It already contains the
// target selected in the teleport UI; do not replace it with a town gate.
// On success the server adapts that target into the same op36/op24 transport
// chain used by personal (aura) teleport, consumes one potion, refreshes the
// bag, and returns the op237 ACK {01, i16 slot, i32 code}. The failure ACK
// {00, 23} shows the client's text 36288 (current-EXE op237 error branch).
type currentTeleportPotionRequest struct {
PotionSlotIndex int16
ItemCode int32
Reserved byte
TownID byte
AreaID byte
PositionX int16
PositionY int16
}
const currentTeleportPotionErrorUnsupported byte = 23
const currentTeleportPotionRequestWireSize = 13
func parseCurrentTeleportPotionRequest(body []byte) (currentTeleportPotionRequest, error) {
if len(body) != currentTeleportPotionRequestWireSize {
return currentTeleportPotionRequest{}, fmt.Errorf("current teleport potion body length %d, want %d", len(body), currentTeleportPotionRequestWireSize)
}
return currentTeleportPotionRequest{
PotionSlotIndex: int16(binary.LittleEndian.Uint16(body[0:2])),
ItemCode: int32(binary.LittleEndian.Uint32(body[2:6])),
Reserved: body[6],
TownID: body[7],
AreaID: body[8],
PositionX: int16(binary.LittleEndian.Uint16(body[9:11])),
PositionY: int16(binary.LittleEndian.Uint16(body[11:13])),
}, nil
}
func (s *Service) handleTeleportPotion(session *gameSession, body []byte) error {
if session == nil {
return nil
}
request, err := parseCurrentTeleportPotionRequest(body)
if err != nil {
s.logGameEvent(session, "game-town-teleport-potion-blocked",
"body_len", len(body),
"reason", "current_exe_op237_writer_boundary_mismatch",
"error", err)
return nil
}
if session.selectedCharacterID == 0 {
return s.sendCurrentTeleportPotionFailure(session, request, "selected_character_missing")
}
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return s.sendCurrentTeleportPotionFailure(session, request, "runtime_pvf_catalog_unavailable")
}
if !currentTeleportPotionIsValid(catalog, request.ItemCode) {
return s.sendCurrentTeleportPotionFailure(session, request, "item_is_not_a_pvf_teleport_potion")
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterItems == nil || repositories.Character == nil {
return s.sendCurrentTeleportPotionFailure(session, request, "character_item_repository_unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), currentConsumableWriteTimeout)
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 {
return s.sendCurrentTeleportPotionFailure(session, request, "selected_character_location_missing")
}
currentTown, ok := character.Stats["town_id"]
if !ok || currentTown < 0 {
return s.sendCurrentTeleportPotionFailure(session, request, "selected_character_town_id_missing")
}
potionKey, err := currentTeleportPotionSlot(ctx, repositories, characterID, request)
if err != nil {
return s.sendCurrentTeleportPotionFailure(session, request, "teleport_potion_missing_from_main_inventory")
}
// Reuse the exact personal/aura transport adapter. Both features carry a
// client-selected town area and coordinates, and the op36 owner validates
// the route against the PVF before it emits the op24 transition and commits
// the new location.
transport := currentSoloTeleportRequest{
TownID: request.TownID,
AreaID: request.AreaID,
PositionX: request.PositionX,
PositionY: request.PositionY,
}
if err := s.handleTownSetUserArea(session, buildCurrentSoloTeleportMoveBody(transport, uint16(currentTown))); err != nil {
return err
}
moved, found, err := repositories.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found || moved.Stats["town_id"] != int64(request.TownID) ||
moved.Stats["area_id"] != int64(request.AreaID) ||
moved.Stats["pos_x"] != int64(request.PositionX) ||
moved.Stats["pos_y"] != int64(request.PositionY) {
return s.sendCurrentTeleportPotionFailure(session, request, "town_transition_not_committed")
}
consumeCtx, consumeCancel := context.WithTimeout(context.Background(), currentConsumableWriteTimeout)
defer consumeCancel()
if err := consumeCurrentTeleportPotion(consumeCtx, repositories, characterID, potionKey); err != nil {
return err
}
if err := s.sendSelectedCurrentContainerListsWithRefresh(session, "current_exe_op237_teleport_potion_consumed_after_op24", true); err != nil {
return err
}
s.logGameEvent(session, "game-town-teleport-potion-committed",
"char_id", session.selectedCharacterID,
"item_code", request.ItemCode,
"potion_slot", potionKey,
"town_id", request.TownID,
"area_id", request.AreaID,
"position_x", request.PositionX,
"position_y", request.PositionY)
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketTeleport), buildCurrentTeleportPotionSuccessAck(request), dnfproto.DefaultChannelClassification)
}
func currentTeleportPotionIsValid(catalog *pvfDungeonDropCatalog, itemCode int32) bool {
if catalog == nil || itemCode <= 0 {
return false
}
definition, err := catalog.ResolveItem(uint32(itemCode))
if err != nil || definition.Kind != dungeonDropItemStackable {
return false
}
return strings.EqualFold(strings.TrimSpace(definition.StackableType), "[teleport potion]")
}
// currentTeleportPotionSlot verifies the claimed slot first, then falls back
// to an id-based scan, matching the 86JP slot-mismatch fallback.
func currentTeleportPotionSlot(ctx context.Context, repositories dnfrepo.Group, characterID string, request currentTeleportPotionRequest) (string, error) {
var matched string
err := func() error {
record, found, err := repositories.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("inventory record missing: %s", characterID)
}
claimed := fmt.Sprintf("0:%d", request.PotionSlotIndex)
if stack, ok := record.Slots[claimed]; ok && stack.Count > 0 && stack.ItemID == int64(request.ItemCode) {
matched = claimed
return nil
}
keys := make([]string, 0, len(record.Slots))
for key, stack := range record.Slots {
if strings.HasPrefix(key, "0:") && stack.ItemID == int64(request.ItemCode) && stack.Count > 0 {
keys = append(keys, key)
}
}
if len(keys) == 0 {
return fmt.Errorf("teleport potion %d not found", request.ItemCode)
}
for _, key := range keys {
if matched == "" || key < matched {
matched = key
}
}
return nil
}()
return matched, err
}
func consumeCurrentTeleportPotion(ctx context.Context, repositories dnfrepo.Group, characterID string, potionKey string) error {
return dnfrepo.WithinCharacterItemsScoped(ctx, repositories.CharacterItems, characterID, func(inventory dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
record, found, err := inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("inventory record missing: %s", characterID)
}
record = dnfrepo.CloneInventory(record)
stack, ok := record.Slots[potionKey]
if !ok || stack.Count <= 0 {
return fmt.Errorf("teleport potion slot %s empty", potionKey)
}
stack.Count--
if stack.Count <= 0 {
delete(record.Slots, potionKey)
} else {
if stack.Extra == nil {
stack.Extra = make(map[string]string, 4)
}
stack.Extra["amount_or_count"] = strconv.FormatInt(stack.Count, 10)
stack.Extra["amount"] = strconv.FormatInt(stack.Count, 10)
if len(stack.RawEntry) == currentItemListEntryWireSize {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
value := uint32(stack.Count)
stack.RawEntry[0x06] = byte(value)
stack.RawEntry[0x07] = byte(value >> 8)
stack.RawEntry[0x08] = byte(value >> 16)
stack.RawEntry[0x09] = byte(value >> 24)
}
record.Slots[potionKey] = stack
}
record.UpdatedAt = time.Now().UTC()
return dnfrepo.SaveInventoryFields(ctx, inventory, record, dnfrepo.InventoryFieldSlots)
})
}
func buildCurrentTeleportPotionSuccessAck(request currentTeleportPotionRequest) []byte {
return []byte{
1,
byte(request.PotionSlotIndex), byte(request.PotionSlotIndex >> 8),
byte(request.ItemCode), byte(request.ItemCode >> 8), byte(request.ItemCode >> 16), byte(request.ItemCode >> 24),
}
}
func (s *Service) sendCurrentTeleportPotionFailure(session *gameSession, request currentTeleportPotionRequest, reason string) error {
s.logGameEvent(session, "game-town-teleport-potion-blocked",
"char_id", session.selectedCharacterID,
"item_code", request.ItemCode,
"town_id", request.TownID,
"reason", reason)
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketTeleport), []byte{0, currentTeleportPotionErrorUnsupported}, dnfproto.DefaultChannelClassification)
}