服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
185 lines
6.4 KiB
Go
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,
|
|
)
|
|
}
|