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