服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
368 lines
14 KiB
Go
368 lines
14 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfitemgrade "longheng.io/server/internal/modules/dnf/itemgrade"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
// currentResetItemAttrOpcode is CMD 81 (0x0051): equipment grade adjustment box.
|
|
const (
|
|
currentResetItemAttrOpcode uint16 = uint16(dnfenum.CmdPacketResetItemAttr)
|
|
currentResetItemAttrGradeResult uint16 = 2
|
|
)
|
|
|
|
type currentResetItemAttrRequest struct {
|
|
TargetSlot int16
|
|
TargetItemID int32
|
|
MaterialSlot int16
|
|
Mode uint16
|
|
}
|
|
|
|
func decodeCurrentResetItemAttrRequest(body []byte) (currentResetItemAttrRequest, error) {
|
|
if len(body) < 8 {
|
|
return currentResetItemAttrRequest{}, fmt.Errorf("reset_item_attr body too short: %d", len(body))
|
|
}
|
|
request := currentResetItemAttrRequest{
|
|
TargetSlot: int16(binary.LittleEndian.Uint16(body[0:2])),
|
|
TargetItemID: int32(binary.LittleEndian.Uint32(body[2:6])),
|
|
MaterialSlot: int16(binary.LittleEndian.Uint16(body[6:8])),
|
|
}
|
|
if len(body) >= 10 {
|
|
request.Mode = binary.LittleEndian.Uint16(body[8:10])
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentResetItemAttr(session *gameSession, body []byte) error {
|
|
request, err := decodeCurrentResetItemAttrRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-reset-item-attr-parse-failed", "body_len", len(body), "err", err)
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 1)
|
|
}
|
|
if len(body) >= 10 {
|
|
reseal, routeErr := s.currentResetItemAttrUsesReseal(session, request)
|
|
if routeErr != nil {
|
|
s.logGameEvent(session, "game-reset-item-attr-route-rejected",
|
|
"mode", request.Mode,
|
|
"target_slot", request.TargetSlot,
|
|
"material_slot", request.MaterialSlot,
|
|
"reason", routeErr)
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 3)
|
|
}
|
|
if reseal {
|
|
return s.handleCurrentEquipmentReseal(session, request)
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
repos, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 2)
|
|
}
|
|
|
|
var catalog dnfitemgrade.ItemCatalog
|
|
if pvfCatalog, catalogErr := s.currentPVFItemCatalog(); catalogErr == nil {
|
|
catalog = currentItemGradeCatalog{catalog: pvfCatalog}
|
|
}
|
|
owner, err := dnfitemgrade.NewOwner(repos, catalog, nil)
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 2)
|
|
}
|
|
result, err := owner.Adjust(ctx, dnfitemgrade.Command{
|
|
SelectedCharacterID: session.selectedCharacterID,
|
|
TargetSlot: request.TargetSlot,
|
|
TargetItemID: request.TargetItemID,
|
|
MaterialSlot: request.MaterialSlot,
|
|
})
|
|
switch {
|
|
case errors.Is(err, dnfitemgrade.ErrTargetMissing):
|
|
s.logGameEvent(session, "game-reset-item-attr-target-missing",
|
|
"target_slot", request.TargetSlot,
|
|
"target_item_id", request.TargetItemID)
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 2)
|
|
case errors.Is(err, dnfitemgrade.ErrMaterialMissing):
|
|
s.logGameEvent(session, "game-reset-item-attr-material-missing",
|
|
"material_slot", request.MaterialSlot)
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 3)
|
|
case err != nil:
|
|
s.logGameEvent(session, "game-reset-item-attr-save-failed", "err", err)
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 4)
|
|
}
|
|
|
|
s.logGameEvent(session, "game-reset-item-attr-success",
|
|
"char_id", result.CharacterID,
|
|
"target_slot", result.TargetSlot,
|
|
"target_item_id", result.TargetItemID,
|
|
"material_slot", result.MaterialSlot,
|
|
"material_item_id", result.MaterialItemID,
|
|
"new_seed", result.NewSeed,
|
|
"is_gold", result.GoldKaleido)
|
|
|
|
// Current EXE sub_1D16480 reads u16 slot + u32 amount + u16 resultType.
|
|
// Those fields describe the consumed adjustment material, not the target
|
|
// equipment: update/remove the material object at its real slot, then select
|
|
// the native grade-adjust result. Result type 1 is the unrelated item-seal
|
|
// announcement ("item packaged successfully") in this client build.
|
|
var w packetWriter
|
|
w.writeUint16(uint16(result.MaterialSlot))
|
|
w.writeUint32(uint32(result.MaterialRemaining))
|
|
w.writeUint16(currentResetItemAttrGradeResult)
|
|
if err := s.sendGameUpperSuccess(session, currentResetItemAttrOpcode, w.bytes()); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The target remains present while op81 consumes only the material. A
|
|
// single-row op14 therefore updates the existing equipment object with the
|
|
// committed full quality seed; unlike the former full op13 refresh it does
|
|
// not rebuild every inventory object or destroy the grade-adjust UI state.
|
|
targetEntry := currentItemListEntryFromStack(0, result.TargetSlot, result.TargetStack)
|
|
refreshBody := buildCurrentItemUpdateBody(0, []currentItemListEntry{targetEntry})
|
|
return s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
refreshBody,
|
|
0,
|
|
)
|
|
}
|
|
|
|
// currentResetItemAttrUsesReseal separates the two native op81 operations.
|
|
// The active PVF's ordinary grade box is item 15 with stackable type [etc], so
|
|
// op81 must retain the older working rule: mode zero is grade adjustment unless
|
|
// the selected material is positively identified as wax. Non-zero modes are
|
|
// the account-soul reseal variants and do not carry an ordinary box item.
|
|
func (s *Service) currentResetItemAttrUsesReseal(
|
|
session *gameSession,
|
|
request currentResetItemAttrRequest,
|
|
) (bool, error) {
|
|
if request.Mode != 0 {
|
|
return true, nil
|
|
}
|
|
if session == nil || session.selectedCharacterID == 0 {
|
|
return false, dnfitemgrade.ErrCharacterRequired
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Inventory == nil {
|
|
return false, dnfitemgrade.ErrOwnerUnavailable
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
inventory, found, err := repositories.Inventory.Load(ctx, selectedCharacterIDString(session))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !found {
|
|
return false, dnfitemgrade.ErrOwnerUnavailable
|
|
}
|
|
material, found := inventory.Slots[currentMainInventorySlotKey(request.MaterialSlot)]
|
|
if !found || material.ItemID <= 0 || material.Count <= 0 {
|
|
return false, dnfitemgrade.ErrMaterialMissing
|
|
}
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
// The known-good implementation did not require PVF classification for
|
|
// ordinary boxes. A missing catalog must not turn a valid box into native
|
|
// error code 3; it only prevents the optional wax route from matching.
|
|
return false, nil
|
|
}
|
|
definition, err := catalog.ResolveItem(uint32(material.ItemID))
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
if _, wax := currentPVFWaxKind(definition); wax {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentEquipmentReseal(session *gameSession, request currentResetItemAttrRequest) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 2)
|
|
}
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 2)
|
|
}
|
|
resealConfig, err := loadCurrentResealConfig(catalog)
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 2)
|
|
}
|
|
owner, err := dnfitemgrade.NewOwner(repositories, nil, nil)
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 2)
|
|
}
|
|
characterID := selectedCharacterIDString(session)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
committed, err := owner.Reseal(ctx, currentResealCatalog{catalog: catalog}, currentDomainResealConfig(resealConfig), dnfitemgrade.ResealCommand{
|
|
AccountID: accountID, CharacterID: characterID, TargetSlot: request.TargetSlot,
|
|
TargetItemID: uint32(request.TargetItemID), MaterialSlot: request.MaterialSlot,
|
|
Mode: request.Mode, UpdatedAt: s.gameplayNow(),
|
|
})
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-equipment-reseal-blocked", "mode", request.Mode, "target_slot", request.TargetSlot, "target_item_id", request.TargetItemID, "err", err)
|
|
return s.sendGameUpperFailure(session, currentResetItemAttrOpcode, 3)
|
|
}
|
|
|
|
// The op81 success ACK is a **row mutation**, not a result descriptor:
|
|
// sub_1916480 (current EXE) reads u16 slot + u32 amount + u16 resultType and
|
|
// then edits the inventory row named by slot -- amount != 0 sets that row's
|
|
// count, amount == 0 REMOVES the row -- before it branches on resultType to
|
|
// pick the 公告 text (1 = dstr 1699 已成功封装该物品, 2/3 = dstr 1700
|
|
// 已成功重调该物品的品级).
|
|
//
|
|
// So the slot/amount pair must name the CONSUMED MATERIAL, exactly like the
|
|
// grade-adjust path above; the target keeps its slot and is repainted by the
|
|
// row/list packet sent below.
|
|
//
|
|
// 2026-10-04 20:08 crash: this path used to send (TargetSlot, 0, 1). The
|
|
// client read that as "the stack in the equipment's own slot is now empty",
|
|
// deleted the just-packaged equipment object, then dereferenced it while
|
|
// finishing the 封装 flow -> C0000005 at DNF.exe+0x311484C (`mov edx,[esi]`,
|
|
// esi = 0) right after 公告窗 0x298 was opened. Both reseal modes (wax and
|
|
// account-soul) were affected; only the account-soul one had been tried.
|
|
var response packetWriter
|
|
response.writeUint16(uint16(committed.MaterialSlot))
|
|
response.writeUint32(uint32(committed.MaterialRemaining))
|
|
response.writeUint16(1)
|
|
if err := s.sendGameUpperSuccess(session, currentResetItemAttrOpcode, response.bytes()); err != nil {
|
|
return err
|
|
}
|
|
if committed.AccountMaterial {
|
|
body, _, _, built := s.buildCurrentItemListBodyForSession(ctx, session, dnfrepo.MainInventoryListType)
|
|
if !built {
|
|
return errors.New("build reseal main inventory refresh")
|
|
}
|
|
return s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), body)
|
|
}
|
|
entries := []currentItemListEntry{
|
|
currentItemListEntryFromStack(dnfrepo.MainInventoryListType, committed.TargetSlot, committed.TargetStack),
|
|
}
|
|
if inventory, found, loadErr := repositories.Inventory.Load(ctx, characterID); loadErr == nil && found {
|
|
material := inventory.Slots[currentMainInventorySlotKey(committed.MaterialSlot)]
|
|
entries = append(entries, currentItemListEntryFromStack(dnfrepo.MainInventoryListType, committed.MaterialSlot, material))
|
|
}
|
|
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, entries), 0)
|
|
}
|
|
|
|
func currentResealCount(stack dnfrepo.ItemStack) byte {
|
|
return dnfitemgrade.ResealCount(stack)
|
|
}
|
|
|
|
func currentItemSealFlag(stack dnfrepo.ItemStack) byte {
|
|
return dnfitemgrade.SealFlag(stack)
|
|
}
|
|
|
|
type currentResealCatalog struct{ catalog *pvfDungeonDropCatalog }
|
|
|
|
func (c currentResealCatalog) ResolveResealItem(itemID uint32) (dnfitemgrade.ResealItemDefinition, error) {
|
|
definition, err := c.catalog.ResolveItem(itemID)
|
|
if err != nil {
|
|
return dnfitemgrade.ResealItemDefinition{}, err
|
|
}
|
|
oneShot, wax := currentPVFWaxKind(definition)
|
|
return dnfitemgrade.ResealItemDefinition{
|
|
Equipment: definition.Kind == dungeonDropItemEquipment, MinimumLevel: definition.MinimumLevel,
|
|
Rarity: definition.Rarity, Wax: wax, WaxOneShot: oneShot,
|
|
ResealLevelMin: definition.ResealLevelMin, ResealLevelMax: definition.ResealLevelMax,
|
|
}, nil
|
|
}
|
|
|
|
func currentDomainResealConfig(config currentResealConfig) dnfitemgrade.ResealConfig {
|
|
convert := func(rows []currentPVFRangeWeight) []dnfitemgrade.ResealRangeWeight {
|
|
out := make([]dnfitemgrade.ResealRangeWeight, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, dnfitemgrade.ResealRangeWeight{Minimum: row.Minimum, Maximum: row.Maximum, Weight: row.Weight})
|
|
}
|
|
return out
|
|
}
|
|
return dnfitemgrade.ResealConfig{
|
|
GradeWeights: convert(config.GradeWeights), RarityWeights: config.RarityWeights,
|
|
ExtendedWeights: config.ExtendedWeights, SoulItemIDs: config.SoulItemIDs,
|
|
SoulCostWeights: config.SoulCostWeights, ResealConstants: config.ResealConstants,
|
|
ResealCountLimits: convert(config.ResealCountLimits),
|
|
}
|
|
}
|
|
|
|
func currentMainInventorySlotKey(slot int16) string {
|
|
return fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)
|
|
}
|
|
|
|
func consumeCurrentItemByID(slots map[string]dnfrepo.ItemStack, itemID uint32, count int64) (int16, int64, error) {
|
|
if count <= 0 {
|
|
return 0, 0, errors.New("invalid consume count")
|
|
}
|
|
keys := make([]string, 0)
|
|
available := int64(0)
|
|
for key, stack := range slots {
|
|
if stack.ItemID == int64(itemID) && stack.Count > 0 {
|
|
keys = append(keys, key)
|
|
available += stack.Count
|
|
}
|
|
}
|
|
if available < count {
|
|
return 0, 0, errors.New("material insufficient")
|
|
}
|
|
sort.Strings(keys)
|
|
remainingNeed := count
|
|
var lastSlot int16
|
|
var lastRemaining int64
|
|
for _, key := range keys {
|
|
stack := slots[key]
|
|
parts := strings.Split(key, ":")
|
|
if len(parts) == 2 {
|
|
if parsed, parseErr := strconv.ParseInt(parts[1], 10, 16); parseErr == nil {
|
|
lastSlot = int16(parsed)
|
|
}
|
|
}
|
|
used := stack.Count
|
|
if used > remainingNeed {
|
|
used = remainingNeed
|
|
}
|
|
stack.Count -= used
|
|
remainingNeed -= used
|
|
lastRemaining = stack.Count
|
|
if stack.Count == 0 {
|
|
delete(slots, key)
|
|
} else {
|
|
slots[key] = stack
|
|
}
|
|
if remainingNeed == 0 {
|
|
break
|
|
}
|
|
}
|
|
return lastSlot, lastRemaining, nil
|
|
}
|
|
|
|
func firstNonNilError(err error, fallback error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
type currentItemGradeCatalog struct {
|
|
catalog *pvfDungeonDropCatalog
|
|
}
|
|
|
|
func (c currentItemGradeCatalog) ResolveItem(itemID uint32) (dnfitemgrade.ItemDefinition, error) {
|
|
if c.catalog == nil {
|
|
return dnfitemgrade.ItemDefinition{}, dnfitemgrade.ErrOwnerUnavailable
|
|
}
|
|
definition, err := c.catalog.ResolveItem(itemID)
|
|
if err != nil {
|
|
return dnfitemgrade.ItemDefinition{}, err
|
|
}
|
|
return dnfitemgrade.ItemDefinition{StackableType: definition.StackableType}, nil
|
|
}
|