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

237 lines
8.5 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"
"fmt"
"strconv"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfequip "longheng.io/server/internal/modules/dnf/equip"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 魔王契约「自动修理」的服务端触发点。
//
// 触发语义(用户实测的官方口径):
// - 只在身上任意一件装备的耐久**被消耗到 0** 的那一刻触发:本来就挂着一件 0 耐久的
// 装备不会触发,因为那不是一次 >0 → 0 的转变;
// - 触发后把全身装备(含快捷栏 3..8)修满,不收金币;
// - 每个角色每天 6 次,当天用完该角色就不再享受,而且**不会退化成收费修理**
// (玩家并没有点过修理)。
//
// 为什么之前完全不生效:客户端唯一的耐久消耗入口是 legacy cmd=1/type=48
// (handleDungeonDecreaseDurability)。那条路径原先只减 1 点、回一行 op14 就结束了,
// 从不查契约。2026-09-13 的线上证据:
//
// EVENT kind=game-dungeon-durability-decreased slot=12 item_id=101040517 before=1 after=0
//
// 之后就再没有任何修理动作 —— 契约、次数、免费修理三条逻辑其实都已存在
// (premium.DevilSlotAutoRepair / TryConsumeDaily 上限 6 / Owner.RepairAll(AutoRepair)),
// 缺的只是这个触发点。
const currentDevilAutoRepairTriggerSource = "dungeon_op48_consumed_to_zero"
// applyCurrentDevilAutoRepairOnZeroDurability 在"耐久被消耗到 0"之后尝试发动契约自动
// 修理。它是 best-effort:失败只记日志,绝不破坏 op48 原有的耐久上报链路。
func (s *Service) applyCurrentDevilAutoRepairOnZeroDurability(
session *gameSession,
catalog *currentRepairCostCatalog,
decrease dnfequip.DecreaseDurabilityResult,
) {
if s == nil || session == nil || session.selectedCharacterID == 0 || catalog == nil {
return
}
// 只认消耗性归零:本就为 0 的装备(Changed=false)与没有真的掉耐久的报告都不触发。
if !decrease.Changed || decrease.OldDurability == 0 || decrease.NewDurability != 0 {
return
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Equipment == nil || repositories.Character == nil || repositories.Account == nil {
s.logGameEvent(session, "game-devil-auto-repair-blocked",
"slot", decrease.SlotIndex,
"item_id", decrease.ItemID,
"reason", "repositories_unavailable")
return
}
owner, err := dnfequip.NewOwner(repositories)
if err != nil {
s.logGameEvent(session, "game-devil-auto-repair-blocked",
"slot", decrease.SlotIndex,
"item_id", decrease.ItemID,
"reason", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
repair, err := owner.RepairAll(ctx, dnfequip.RepairCommand{
SelectedCharacterID: session.selectedCharacterID,
AccountID: s.accountIDForSession(session),
SlotIndex: -1,
AutoRepair: true,
ContractAutoRepair: true,
}, catalog.resolve)
if err != nil {
s.logGameEvent(session, "game-devil-auto-repair-failed",
"slot", decrease.SlotIndex,
"item_id", decrease.ItemID,
"reason", err)
return
}
if repair.ContractSkipped || !repair.FreeRepair {
// 契约不在有效期,或该角色当天 6 次已用完:什么都不做,也绝不收费。
s.logGameEvent(session, "game-devil-auto-repair-skipped",
"slot", decrease.SlotIndex,
"item_id", decrease.ItemID,
"daily_limit", premium.DailyLimit(premium.DevilSlotAutoRepair),
"reason", "contract_inactive_or_daily_limit_reached")
return
}
if repair.RepairedCount == 0 {
s.logGameEvent(session, "game-devil-auto-repair-noop",
"slot", decrease.SlotIndex,
"item_id", decrease.ItemID,
"reason", "no_repairable_equipment_target")
return
}
equippedCount, bagCount := 0, 0
for _, repaired := range repair.RepairedSlots {
if repaired.Equipped {
equippedCount++
continue
}
bagCount++
}
// 只发变更行:这个客户端在副本里收到整张 list 3 会崩,所以沿用 op48 的 delta 载体。
published, publishErr := s.sendCurrentDevilAutoRepairRows(ctx, session, repair.RepairedSlots)
usedAfter := int64(0)
if character, found, loadErr := repositories.Character.Load(ctx, strconv.Itoa(int(session.selectedCharacterID))); loadErr == nil && found {
usedAfter = premium.DailyUsage(character, premium.DevilSlotAutoRepair, s.gameplayNow())
}
s.logGameEvent(session, "game-devil-auto-repair-applied",
"slot", decrease.SlotIndex,
"item_id", decrease.ItemID,
"durability_before", decrease.OldDurability,
"durability_after", decrease.NewDurability,
"repaired_count", repair.RepairedCount,
"equipped_count", equippedCount,
"bag_count", bagCount,
"published_rows", published,
"daily_used_after", usedAfter,
"daily_limit", premium.DailyLimit(premium.DevilSlotAutoRepair),
"cost", repair.Cost,
"gold_after", repair.UpdatedGold,
"free_repair", repair.FreeRepair,
"source", currentDevilAutoRepairTriggerSource)
if publishErr != nil {
s.logGameEvent(session, "game-devil-auto-repair-publish-failed",
"slot", decrease.SlotIndex,
"item_id", decrease.ItemID,
"reason", publishErr)
return
}
// 让客户端的魔王契约面板显示用掉一次(与其它契约服务同一口径)。
if stateErr := s.sendCurrentPremiumServiceState(session, "devil_auto_repair_after_zero_durability", currentPremiumServiceTypeForSession(session)); stateErr != nil {
s.logGameEvent(session, "game-devil-auto-repair-state-refresh-failed",
"slot", decrease.SlotIndex,
"reason", stateErr)
}
}
// sendCurrentDevilAutoRepairRows 把修好的行按容器分组发 op14 delta,返回发出的行数。
// 装备行走 list 3(currentSocketListEquipment,和 op48 同一个载体),
// 快捷栏行走 list 0。两种 list 的 delta 载体都是 msg 14。
func (s *Service) sendCurrentDevilAutoRepairRows(
ctx context.Context,
session *gameSession,
slots []dnfequip.RepairSlot,
) (int, error) {
if s == nil || session == nil || session.selectedCharacterID == 0 || len(slots) == 0 {
return 0, nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Equipment == nil {
return 0, fmt.Errorf("devil auto repair requires equipment repository")
}
characterID := strconv.Itoa(int(session.selectedCharacterID))
equippedSlots := make([]int16, 0, len(slots))
bagSlots := make([]int16, 0, len(slots))
for _, repaired := range slots {
if repaired.Equipped {
equippedSlots = append(equippedSlots, repaired.SlotIndex)
continue
}
bagSlots = append(bagSlots, repaired.SlotIndex)
}
published := 0
if len(equippedSlots) > 0 {
equipment, found, err := repositories.Equipment.Load(ctx, characterID)
if err != nil {
return published, fmt.Errorf("load equipment for auto repair publish: %w", err)
}
rows := make([]currentItemListEntry, 0, len(equippedSlots))
if found {
for _, slot := range equippedSlots {
entry, entryFound := equipment.Entries[strconv.FormatInt(int64(slot), 10)]
if !entryFound || entry.SlotIndex != slot {
continue
}
projected, projectOK := currentItemListEntryFromEquipment(entry)
if !projectOK {
continue
}
// op48 上报的是当前客户端的 actor 槽号;历史行可能带旧编号,
// 这里保留同一个身份,避免客户端把行插到别的格子上。
projected.setUint16(0, uint16(slot))
rows = append(rows, projected)
}
}
if len(rows) > 0 {
body := buildCurrentItemUpdateBody(currentSocketListEquipment, rows)
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketWalkoutPartyMember),
body,
0,
); err != nil {
return published, err
}
published += len(rows)
}
}
if len(bagSlots) > 0 && repositories.Inventory != nil {
inventory, found, err := repositories.Inventory.Load(ctx, characterID)
if err != nil {
return published, fmt.Errorf("load inventory for auto repair publish: %w", err)
}
rows := make([]currentItemListEntry, 0, len(bagSlots))
if found {
for _, slot := range bagSlots {
stack, stackFound := inventory.Slots[currentDungeonPickupMainSlotKey(slot)]
if !stackFound || stack.ItemID <= 0 {
continue
}
rows = append(rows, currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack))
}
}
if len(rows) > 0 {
body := buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, rows)
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketWalkoutPartyMember),
body,
0,
); err != nil {
return published, err
}
published += len(rows)
}
}
return published, nil
}