DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,236 @@
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
}