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

185 lines
6.4 KiB
Go

package dnfbridge
import (
"context"
"encoding/hex"
"fmt"
"strconv"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfequip "longheng.io/server/internal/modules/dnf/equip"
)
const currentDungeonDurabilityReportSize = 1
func isCurrentDungeonDurabilitySlot(slot int16) bool {
switch slot {
case 12, 14, 15, 16, 17, 18:
return true
default:
return false
}
}
// handleDungeonDecreaseDurability owns legacy cmd=1/type=48. The one-byte
// body is the worn slot, not a loss amount or a bitmap. Durability is changed
// only while that character owns an active dungeon runtime.
func (s *Service) handleDungeonDecreaseDurability(session *gameSession, body []byte) error {
if session == nil {
return nil
}
if len(body) != currentDungeonDurabilityReportSize {
s.logGameEvent(session, "game-dungeon-durability-blocked",
"body_len", len(body),
"reason", "current_exe_op48_body_must_be_one_slot_byte")
return nil
}
slot := int16(body[0])
if !isCurrentDungeonDurabilitySlot(slot) {
s.logGameEvent(session, "game-dungeon-durability-blocked",
"slot", slot,
"reason", "current_exe_op48_slot_not_durable_equipment")
return nil
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
owned := runtime != nil && runtime.Session != nil && runtime.Room != nil &&
dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID)
session.dungeon.mu.Unlock()
if !owned {
s.logGameEvent(session, "game-dungeon-durability-blocked",
"slot", slot,
"reason", "active_dungeon_runtime_owner_invalid")
return nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Equipment == nil {
s.logGameEvent(session, "game-dungeon-durability-blocked",
"slot", slot,
"reason", "equipment_repository_unavailable")
return nil
}
catalog, err := s.currentRepairCostCatalog()
if err != nil {
return fmt.Errorf("load durability PVF evidence: %w", err)
}
owner, err := dnfequip.NewOwner(repositories)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
result, err := owner.DecreaseDurability(ctx, dnfequip.DecreaseDurabilityCommand{
SelectedCharacterID: session.selectedCharacterID,
SlotIndex: slot,
Amount: 1,
Resolve: catalog.resolve,
})
if err != nil {
return err
}
if !result.Changed {
s.logGameEvent(session, "game-dungeon-durability-ignored",
"slot", slot,
"item_id", result.ItemID,
"durability", result.OldDurability,
"reason", "slot_missing_zero_or_nonrepairable")
return nil
}
s.logGameEvent(session, "game-dungeon-durability-decreased",
"slot", result.SlotIndex,
"item_id", result.ItemID,
"before", result.OldDurability,
"after", result.NewDurability,
"source", "legacy_op48_slot_report")
if err := s.sendDungeonDurabilitySlotUpdate(
session,
result.SlotIndex,
result.OldDurability,
result.NewDurability,
); err != nil {
s.logPacketEvent("game-upper-dungeon-durability-slot-refresh-failed",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"char_id", session.selectedCharacterID,
"slot", result.SlotIndex,
"before", result.OldDurability,
"after", result.NewDurability,
"err", err)
return fmt.Errorf("send dungeon durability slot %d refresh: %w", result.SlotIndex, err)
}
// 魔王契约「自动修理」的触发点:耐久被消耗到 0(>0 → 0,而不是本来就 0)。
// 放在单行 delta 之后:客户端先看到掉到 0,再收到全身修满的变更行。
// best-effort —— 契约/次数/仓库任何一环出问题都只记日志,不影响 op48 本身。
s.applyCurrentDevilAutoRepairOnZeroDurability(session, catalog, result)
return nil
}
// sendDungeonDurabilitySlotUpdate updates the one client-side equipment object
// reported by op48. Sending the complete list-3 table while dungeon objects are
// active crashes this client build, so this path must remain a one-row delta.
func (s *Service) sendDungeonDurabilitySlotUpdate(
session *gameSession,
slot int16,
before uint16,
after uint16,
) error {
if session == nil || session.selectedCharacterID == 0 {
return fmt.Errorf("dungeon durability refresh requires a selected character")
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Equipment == nil {
return fmt.Errorf("dungeon durability refresh requires equipment repository")
}
ctx, cancel := context.WithTimeout(context.Background(), currentRepositorySnapshotTimeout)
defer cancel()
characterID := strconv.Itoa(int(session.selectedCharacterID))
equipment, found, err := repositories.Equipment.Load(ctx, characterID)
if err != nil {
return fmt.Errorf("load character %s equipment: %w", characterID, err)
}
if !found {
return fmt.Errorf("character %s equipment not found", characterID)
}
equipped, found := equipment.Entries[strconv.FormatInt(int64(slot), 10)]
if !found || equipped.SlotIndex != slot {
return fmt.Errorf("character %s equipment slot %d not found", characterID, slot)
}
entry, ok := currentItemListEntryFromEquipment(equipped)
if !ok {
return fmt.Errorf("character %s equipment slot %d is not projectable", characterID, slot)
}
// op48 reports the current client actor slot. Preserve that exact identity
// even for historical rows whose bootstrap projection uses legacy numbering.
entry.setUint16(0, uint16(slot))
body := buildCurrentItemUpdateBody(currentSocketListEquipment, []currentItemListEntry{entry})
s.logPacketEvent("game-upper-dungeon-durability-slot-refresh-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", "dungeon_op48_durability_decreased",
"char_id", session.selectedCharacterID,
"msg_id", uint16(dnfenum.CmdPacketWalkoutPartyMember),
"classification", 0,
"list_type", currentSocketListEquipment,
"entry_count", 1,
"entry_size", currentItemListEntryWireSize,
"slot", slot,
"before", before,
"after", after,
"body_len", len(body),
// 崩溃取证用:2026-09-29 三次闪退(char 51 鬼鬼,22:30:37 / 22:32:39 /
// 22:37:07 +08:00)的崩溃边缘都是"他发 op48 → 我们回这一包",而这条日志
// 以前只记 body_len,拿不到字节,无法与客户端 trace 的 traced_body 对照。
// 只加日志,不改任何发送逻辑。
"body_hex", hex.EncodeToString(body),
"sequence", "durability_commit_then_op14_list3_single_changed_row")
return s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketWalkoutPartyMember),
body,
0,
)
}