服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
277 lines
12 KiB
Go
277 lines
12 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
dnfenum "longheng.io/server/internal/modules/dnf/dnfenum"
|
|
)
|
|
|
|
// currentSelectInventoryBootstrapListTypes is the complete selected-character
|
|
// container set installed after the local actor has been bound. The bodies and
|
|
// envelope are built with the current NoPack op13 reader contract (16-byte
|
|
// fixed upper header and current 0x77 item rows).
|
|
var currentSelectInventoryBootstrapListTypes = []byte{0, 1, 2, 7, 12, currentGuildMedalInventoryListType}
|
|
|
|
type currentSelectInventoryBootstrapPacket struct {
|
|
listType byte
|
|
body []byte
|
|
entrySource string
|
|
entryCount int
|
|
}
|
|
|
|
// sendCurrentSelectInventoryBootstrap owns the one-shot inventory bootstrap
|
|
// for a selected character. It must be called after the scene has created and
|
|
// bound the selected actor, and before the full equipment-bearing mode1 packet.
|
|
// Runtime mutations continue to use the native ACK path, op14, or an explicit
|
|
// op13 refresh where the command contract requires one.
|
|
func (s *Service) sendCurrentSelectInventoryBootstrap(session *gameSession, source string) error {
|
|
if session == nil || session.selectedCharacterID == 0 {
|
|
return nil
|
|
}
|
|
if session.selectedItemListBootstrapCharacterID == session.selectedCharacterID {
|
|
s.logPacketEvent("game-upper-select-inventory-bootstrap-skipped",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "already_sent")
|
|
// Entering a dungeon recreates the local actor and its item objects but
|
|
// intentionally reuses the selected-character container snapshot. The
|
|
// native sort-lock icon model is separate from those rows, so reassert
|
|
// the durable blue-lock markers at that scene boundary.
|
|
return s.sendCurrentSortLockReplayForSession(session, source+"_reassert_after_existing_container_bootstrap")
|
|
}
|
|
|
|
s.cleanupSelectedExpiredRentalEquipment(session, source)
|
|
// PVF `[daily delete item]` 的物品跨过服务日边界(北京 06:00)就删掉。必须在
|
|
// 第一份 list-0 快照**之前**跑,否则客户端会先收到一份还带着它的容器。
|
|
s.sweepSelectedDailyDeleteItems(session, source)
|
|
ctx, cancel := context.WithTimeout(context.Background(), currentRepositorySnapshotTimeout)
|
|
defer cancel()
|
|
// 公会徽章 rows an earlier build left in list-0 have to move to the
|
|
// PVF-declared list-38 slots before the first list-0 snapshot goes out,
|
|
// otherwise the client renders them on the wrong page and the badge can be
|
|
// neither equipped nor compounded. Deferred on failure: a placement repair
|
|
// must never make selected-character login fatal.
|
|
if err := s.migrateCurrentGuildItemPlacement(ctx, session); err != nil {
|
|
s.logGameEvent(session, "game-guild-item-placement-migration-deferred", "error", err)
|
|
}
|
|
// Characters saved before task-manual completion carried story items
|
|
// forward may be stuck on an epic whose required item was never granted.
|
|
// Repair before the first list-0 snapshot so the row rides in it.
|
|
carryForwardRepair, carryForwardErr := s.repairCurrentAutoCompletedQuestCarryForwardItems(ctx, session)
|
|
if carryForwardErr != nil {
|
|
s.logPacketEvent("game-upper-select-inventory-carry-forward-repair-deferred",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"err", carryForwardErr)
|
|
} else if len(carryForwardRepair.Items) > 0 {
|
|
s.logPacketEvent("game-upper-select-inventory-carry-forward-repaired",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"item_count", len(carryForwardRepair.Items),
|
|
"repair_source", "completed_no_reward_producer_and_outstanding_story_consumer")
|
|
}
|
|
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
|
inventoryChanged, equipmentChanged, customAbilityRefreshEntries, customAbilityStateErr := s.ensureCurrentCustomAbilityState(ctx, characterID)
|
|
if customAbilityStateErr != nil {
|
|
// Compatibility repair for rows created before custom-ability projection
|
|
// existed must not make selected-character login a fatal dependency.
|
|
s.logPacketEvent("game-upper-custom-ability-bootstrap-skipped",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", customAbilityStateErr)
|
|
} else if inventoryChanged != 0 || equipmentChanged != 0 {
|
|
s.logPacketEvent("game-upper-custom-ability-bootstrap-committed",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"inventory_changed", inventoryChanged,
|
|
"equipment_changed", equipmentChanged)
|
|
}
|
|
var customAbilitySetSnapshot currentCustomAbilitySetSnapshot
|
|
customAbilitySetFound := false
|
|
if repos, repositoriesOK := s.repositoryGroup(); repositoriesOK {
|
|
loaded, found, loadErr := loadCurrentCustomAbilitySetSnapshot(ctx, repos, characterID)
|
|
if loadErr != nil {
|
|
s.logPacketEvent("game-upper-custom-ability-set-login-restore-skipped",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", loadErr)
|
|
} else {
|
|
customAbilitySetSnapshot = loaded
|
|
customAbilitySetFound = found
|
|
}
|
|
}
|
|
|
|
// Build the complete character snapshot before writing its first packet. A
|
|
// later repository failure must not leave the client with only a subset of
|
|
// its selected-character containers installed.
|
|
plan := make([]currentSelectInventoryBootstrapPacket, 0, len(currentSelectInventoryBootstrapListTypes))
|
|
for _, listType := range currentSelectInventoryBootstrapListTypes {
|
|
body, entrySource, entryCount, ok := s.buildCurrentSelectInventoryBootstrapBody(ctx, session, listType)
|
|
if !ok {
|
|
s.logPacketEvent("game-upper-select-inventory-bootstrap-deferred",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"list_type", listType,
|
|
"reason", "repository_snapshot_unavailable")
|
|
return fmt.Errorf("selected character %d inventory list %d snapshot unavailable", session.selectedCharacterID, listType)
|
|
}
|
|
plan = append(plan, currentSelectInventoryBootstrapPacket{
|
|
listType: listType,
|
|
body: body,
|
|
entrySource: entrySource,
|
|
entryCount: entryCount,
|
|
})
|
|
}
|
|
// The current client keeps a separate equipment-lock model. It does not
|
|
// derive that model from op13 item rows, so build the complete durable
|
|
// snapshot before any container write and deliver it immediately after all
|
|
// six containers. An explicit empty list is required to clear a stale local
|
|
// locked state before an item-use request such as op338 can be generated.
|
|
lockSnapshot, lockSnapshotOK := s.buildCurrentItemLockSnapshotForSession(ctx, session)
|
|
if !lockSnapshotOK {
|
|
s.logPacketEvent("game-upper-select-inventory-bootstrap-deferred",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "item_lock_snapshot_unavailable")
|
|
return fmt.Errorf("selected character %d item-lock snapshot unavailable", session.selectedCharacterID)
|
|
}
|
|
sortLockPositions, sortLockPositionsOK := s.buildCurrentSortLockPositionsForSession(ctx, session)
|
|
if !sortLockPositionsOK {
|
|
s.logPacketEvent("game-upper-select-inventory-bootstrap-deferred",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "item_sort_lock_snapshot_unavailable")
|
|
return fmt.Errorf("selected character %d item-sort-lock snapshot unavailable", session.selectedCharacterID)
|
|
}
|
|
for index, packet := range plan {
|
|
s.logPacketEvent("game-upper-select-inventory-bootstrap-send",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"packet_index", index,
|
|
"msg_id", uint16(dnfenum.CmdPacketLeaveParty),
|
|
"list_type", packet.listType,
|
|
"entry_count", packet.entryCount,
|
|
"body_len", len(packet.body),
|
|
"body_source", packet.entrySource,
|
|
"entry_size", currentItemListEntrySizeForType(packet.listType),
|
|
"wire_contract", "current_exe_sub_1D72380_fixed16_0x77",
|
|
"lifecycle", "current_exe_actor_bound_then_character_containers_before_full_mode1")
|
|
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), packet.body); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.sendCurrentCharacterDailyUseStates(session, source+"_daily_use_state"); err != nil {
|
|
return err
|
|
}
|
|
if len(customAbilityRefreshEntries) != 0 {
|
|
rowSummary := make([]string, 0, len(customAbilityRefreshEntries))
|
|
for _, entry := range customAbilityRefreshEntries {
|
|
rowSummary = append(rowSummary, fmt.Sprintf(
|
|
"%d:%d:custom=%x",
|
|
binary.LittleEndian.Uint16(entry.data[0x00:0x02]),
|
|
binary.LittleEndian.Uint32(entry.data[0x02:0x06]),
|
|
entry.data[currentCustomAbilityRawBlockStart:currentCustomAbilityRawBlockEnd],
|
|
))
|
|
}
|
|
body := buildCurrentItemUpdateBody(currentExeInventoryMainListType, customAbilityRefreshEntries)
|
|
s.logPacketEvent("game-upper-custom-ability-inventory-refresh-send",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"msg_id", uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
"classification", 0,
|
|
"list_type", currentExeInventoryMainListType,
|
|
"entry_count", len(customAbilityRefreshEntries),
|
|
"body_len", len(body),
|
|
"row_summary", strings.Join(rowSummary, ";"),
|
|
"sequence", "op13_selected_containers_then_targeted_op14_custom_ability_object_refresh")
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if customAbilitySetFound {
|
|
if err := s.sendCurrentCustomAbilitySetNotification(session, customAbilitySetSnapshot, source+"_after_item_containers"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.sendCurrentSelectSortLockReplay(
|
|
session,
|
|
source+"_after_selected_containers_before_item_lock_snapshot",
|
|
sortLockPositions,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendCurrentSelectItemLockSnapshot(
|
|
session,
|
|
source+"_after_selected_containers_before_full_mode1",
|
|
lockSnapshot,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendCurrentEpicBookSnapshot(session, source+"_after_selected_containers"); err != nil {
|
|
return err
|
|
}
|
|
session.selectedItemListRefreshSent = true
|
|
session.selectedItemListBootstrapCharacterID = session.selectedCharacterID
|
|
s.logPacketEvent("game-upper-select-inventory-bootstrap-committed",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"packet_count", len(plan)+len(sortLockPositions)+1,
|
|
"item_sort_lock_replay_count", len(sortLockPositions),
|
|
"item_lock_snapshot_body_len", len(lockSnapshot))
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) buildCurrentSelectInventoryBootstrapBody(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
listType byte,
|
|
) ([]byte, string, int, bool) {
|
|
body, source, count, ok := s.buildCurrentItemListBodyForSession(ctx, session, listType)
|
|
if ok {
|
|
return body, source, count, true
|
|
}
|
|
|
|
// An absent inventory record is a real empty inventory. 86JP still creates
|
|
// every client container on selection, so emit an empty current-EXE body;
|
|
// repository errors remain fail-closed and are never disguised as empty.
|
|
repos, repositoriesOK := s.repositoryGroup()
|
|
if !repositoriesOK || repos.Inventory == nil {
|
|
return nil, source, count, false
|
|
}
|
|
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
|
_, found, err := repos.Inventory.Load(ctx, characterID)
|
|
if err != nil || found {
|
|
return nil, source, count, false
|
|
}
|
|
state := s.loadCurrentItemListContainerState(ctx, repos.Settings, characterID, listType)
|
|
return buildCurrentItemListBody(listType, nil, state), "inventory_absent_empty", 0, true
|
|
}
|