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

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
}