服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
606 lines
25 KiB
Go
606 lines
25 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
dnfcollectbox "longheng.io/server/internal/modules/dnf/collectbox"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
// currentCollectboxSceneProjectionEnabled 是"进图后是否推送收集箱投影"的开关。
|
|
//
|
|
// 2026-10-19 排查结论:把进图这组 op1027 停掉后剑魔形态仍未出现,且账号里根本
|
|
// 没有任何 SAO 收集箱宝珠——收集箱状态不是该问题的原因。默认恢复推送;
|
|
// DNF90_COLLECTBOX_SCENE_PROJECTION=off 仅作为以后排查用的开关。
|
|
func currentCollectboxSceneProjectionEnabled() bool {
|
|
return !strings.EqualFold(strings.TrimSpace(os.Getenv("DNF90_COLLECTBOX_SCENE_PROJECTION")), "off")
|
|
}
|
|
|
|
// currentCollectboxProjectionEnabled 是"是否推送收集箱状态"的全局开关(登录 + 选角 +
|
|
// 进图三条路径共用)。默认恢复推送;DNF90_COLLECTBOX_PROJECTION=off 可整体停掉做对照。
|
|
func currentCollectboxProjectionEnabled() bool {
|
|
return !strings.EqualFold(strings.TrimSpace(os.Getenv("DNF90_COLLECTBOX_PROJECTION")), "off")
|
|
}
|
|
|
|
const (
|
|
collectboxNotificationMsgID = uint16(1027)
|
|
collectboxErrorCode = byte(0x12)
|
|
// The current PVF maps index 1 to SAO and index 5 to 兽人守护神. The
|
|
// current client also sends op1021 body 05 when that tab is selected. Keep
|
|
// the login/actor-generation default explicit so catalog ordering cannot
|
|
// silently return the window and its native stat owner to SAO.
|
|
defaultCollectboxIndex = byte(5)
|
|
)
|
|
|
|
func (s *Service) handleCollectboxCommand(session *gameSession, request gameplayRequest) error {
|
|
if s == nil || session == nil {
|
|
return nil
|
|
}
|
|
catalog, err := s.currentCollectboxCatalog()
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-collectbox-rejected", "opcode", request.Opcode, "reason", err)
|
|
return s.sendGameUpperFailure(session, request.Opcode, collectboxErrorCode)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), currentRepositorySnapshotTimeout)
|
|
defer cancel()
|
|
switch request.Opcode {
|
|
case uint16(dnfenum.CmdPacketSelectCollectbox):
|
|
return s.handleCollectboxSelect(ctx, session, catalog, request.Body)
|
|
case uint16(dnfenum.CmdPacketAddCollectboxItem):
|
|
return s.handleCollectboxInsert(ctx, session, catalog, request.Body)
|
|
case uint16(dnfenum.CmdPacketRemoveCollectboxItem):
|
|
return s.handleCollectboxRemove(ctx, session, catalog, request.Body)
|
|
case uint16(dnfenum.CmdPacketExtendCollectboxExpiryDate):
|
|
return s.handleCollectboxExtend(ctx, session, catalog, request.Body)
|
|
default:
|
|
return s.sendGameUpperFailure(session, request.Opcode, collectboxErrorCode)
|
|
}
|
|
}
|
|
|
|
func (s *Service) handleCollectboxSelect(ctx context.Context, session *gameSession, catalog *currentCollectboxCatalog, body []byte) error {
|
|
if len(body) != 1 {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketSelectCollectbox), collectboxErrorCode)
|
|
}
|
|
index := body[0]
|
|
definition, ok := collectboxDefinition(catalog, index)
|
|
if !ok || session.selectedCharacterID == 0 {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketSelectCollectbox), collectboxErrorCode)
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketSelectCollectbox), []byte{index}); err != nil {
|
|
return err
|
|
}
|
|
session.selectedCollectboxIndex = index
|
|
session.selectedCollectboxCharacterID = session.selectedCharacterID
|
|
// The client applies only the selected box's attributes, so the player's
|
|
// choice has to outlive the session. A persistence failure must not break
|
|
// the click: the live selection is already applied below.
|
|
if err := s.persistCollectboxSelection(ctx, session, index); err != nil {
|
|
s.logGameEvent(session, "game-collectbox-selection-persist-failed",
|
|
"char_id", session.selectedCharacterID,
|
|
"box_index", index,
|
|
"reason", err)
|
|
}
|
|
return s.sendCollectboxState(ctx, session, definition, true)
|
|
}
|
|
|
|
func (s *Service) handleCollectboxInsert(ctx context.Context, session *gameSession, catalog *currentCollectboxCatalog, body []byte) error {
|
|
if len(body) != 8 {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAddCollectboxItem), collectboxErrorCode)
|
|
}
|
|
indexRaw := binary.LittleEndian.Uint16(body[0:2])
|
|
if indexRaw == 0 || indexRaw > math.MaxUint8 || !s.collectboxSessionSelected(session, byte(indexRaw)) {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAddCollectboxItem), collectboxErrorCode)
|
|
}
|
|
index := byte(indexRaw)
|
|
definition, ok := collectboxDefinition(catalog, index)
|
|
if !ok {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAddCollectboxItem), collectboxErrorCode)
|
|
}
|
|
sourceSlot := binary.LittleEndian.Uint16(body[2:4])
|
|
if sourceSlot > math.MaxInt16 {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAddCollectboxItem), collectboxErrorCode)
|
|
}
|
|
itemID := binary.LittleEndian.Uint32(body[4:8])
|
|
owner, err := s.collectboxOwner()
|
|
if err == nil {
|
|
definition.Slots = collectboxSingleItemSlots(definition.Slots)
|
|
_, err = owner.Insert(ctx, dnfcollectbox.InsertCommand{
|
|
CharacterID: strconv.FormatUint(uint64(session.selectedCharacterID), 10),
|
|
Definition: definition,
|
|
SourceSlot: int16(sourceSlot),
|
|
ItemID: itemID,
|
|
UpdatedAt: time.Now().UTC(),
|
|
})
|
|
}
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-collectbox-insert-rejected", "box_index", index, "source_slot", sourceSlot, "item_id", itemID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAddCollectboxItem), collectboxErrorCode)
|
|
}
|
|
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, "collectbox_insert", []alignedcmd.ItemSlotRefresh{{ListType: dnfrepo.MainInventoryListType, SlotIndex: int16(sourceSlot)}}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketAddCollectboxItem), nil); err != nil {
|
|
return err
|
|
}
|
|
return s.sendCollectboxState(ctx, session, definition, true)
|
|
}
|
|
|
|
func (s *Service) handleCollectboxRemove(ctx context.Context, session *gameSession, catalog *currentCollectboxCatalog, body []byte) error {
|
|
if len(body) != 5 || session.selectedCharacterID == 0 {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketRemoveCollectboxItem), collectboxErrorCode)
|
|
}
|
|
itemID := binary.LittleEndian.Uint32(body[1:5])
|
|
definition, ok := collectboxDefinitionForItem(catalog, itemID)
|
|
if !ok {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketRemoveCollectboxItem), collectboxErrorCode)
|
|
}
|
|
returnPlacement, err := s.currentCollectboxReturnPlacement(itemID)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-collectbox-remove-rejected", "box_index", definition.Index, "item_id", itemID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketRemoveCollectboxItem), collectboxErrorCode)
|
|
}
|
|
owner, err := s.collectboxOwner()
|
|
var result dnfcollectbox.RemoveResult
|
|
if err == nil {
|
|
definition.Slots = collectboxSingleItemSlots(definition.Slots)
|
|
result, err = owner.Remove(ctx, dnfcollectbox.RemoveCommand{
|
|
CharacterID: strconv.FormatUint(uint64(session.selectedCharacterID), 10),
|
|
Definition: definition,
|
|
ItemID: itemID,
|
|
ReturnPlacement: returnPlacement,
|
|
UpdatedAt: time.Now().UTC(),
|
|
})
|
|
}
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-collectbox-remove-rejected", "box_index", definition.Index, "item_id", itemID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketRemoveCollectboxItem), collectboxErrorCode)
|
|
}
|
|
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, "collectbox_remove", []alignedcmd.ItemSlotRefresh{{ListType: dnfrepo.MainInventoryListType, SlotIndex: result.TargetSlot}}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketRemoveCollectboxItem), nil); err != nil {
|
|
return err
|
|
}
|
|
return s.sendCollectboxState(ctx, session, definition, true)
|
|
}
|
|
|
|
// currentCollectboxReturnPlacement resolves the same active-PVF page that
|
|
// ordinary item grants use. Collection-box entries are stackables; accepting
|
|
// equipment here would recreate the old bug of returning a jewel to 9..64.
|
|
func (s *Service) currentCollectboxReturnPlacement(itemID uint32) (dnfcollectbox.ReturnPlacement, error) {
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return dnfcollectbox.ReturnPlacement{}, err
|
|
}
|
|
definition, err := catalog.ResolveItem(itemID)
|
|
if err != nil {
|
|
return dnfcollectbox.ReturnPlacement{}, err
|
|
}
|
|
return collectboxReturnPlacementFromPVF(definition)
|
|
}
|
|
|
|
func collectboxReturnPlacementFromPVF(definition dungeonDropItemDefinition) (dnfcollectbox.ReturnPlacement, error) {
|
|
if definition.ItemID == 0 || definition.Kind != dungeonDropItemStackable ||
|
|
definition.SlotStart < 9 || definition.SlotEnd < definition.SlotStart ||
|
|
definition.SlotEnd >= dnfrepo.CrystalWarehouseFirstSlot {
|
|
return dnfcollectbox.ReturnPlacement{}, fmt.Errorf("collect box item=%d has unsupported active-PVF placement kind=%s page=%d..%d", definition.ItemID, definition.Kind, definition.SlotStart, definition.SlotEnd)
|
|
}
|
|
return dnfcollectbox.ReturnPlacement{
|
|
SlotStart: definition.SlotStart,
|
|
SlotEnd: definition.SlotEnd,
|
|
StackLimit: definition.StackLimit,
|
|
}, nil
|
|
}
|
|
|
|
func collectboxSingleItemSlots(slots []dnfcollectbox.Slot) []dnfcollectbox.Slot {
|
|
copySlots := append([]dnfcollectbox.Slot(nil), slots...)
|
|
for index := range copySlots {
|
|
copySlots[index].Count = 1
|
|
}
|
|
return copySlots
|
|
}
|
|
|
|
func (s *Service) handleCollectboxExtend(ctx context.Context, session *gameSession, catalog *currentCollectboxCatalog, body []byte) error {
|
|
if len(body) != 5 || !s.collectboxSessionSelected(session, body[0]) {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketExtendCollectboxExpiryDate), collectboxErrorCode)
|
|
}
|
|
definition, ok := collectboxDefinition(catalog, body[0])
|
|
itemID := binary.LittleEndian.Uint32(body[1:5])
|
|
if !ok || definition.ExtendItemID == 0 || itemID != definition.ExtendItemID {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketExtendCollectboxExpiryDate), collectboxErrorCode)
|
|
}
|
|
owner, err := s.collectboxOwner()
|
|
var result dnfcollectbox.ExtendResult
|
|
if err == nil {
|
|
result, err = owner.Extend(ctx, dnfcollectbox.ExtendCommand{
|
|
CharacterID: strconv.FormatUint(uint64(session.selectedCharacterID), 10),
|
|
Definition: definition,
|
|
ItemID: itemID,
|
|
UpdatedAt: time.Now().UTC(),
|
|
})
|
|
}
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-collectbox-extend-rejected", "box_index", body[0], "item_id", itemID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketExtendCollectboxExpiryDate), collectboxErrorCode)
|
|
}
|
|
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, "collectbox_extend", []alignedcmd.ItemSlotRefresh{{ListType: dnfrepo.MainInventoryListType, SlotIndex: result.SourceSlot}}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketExtendCollectboxExpiryDate), nil); err != nil {
|
|
return err
|
|
}
|
|
return s.sendCollectboxState(ctx, session, definition, true)
|
|
}
|
|
|
|
func (s *Service) collectboxOwner() (*dnfcollectbox.Owner, error) {
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return nil, fmt.Errorf("collect box repositories unavailable")
|
|
}
|
|
return dnfcollectbox.NewOwner(repositories)
|
|
}
|
|
|
|
func (s *Service) collectboxSessionSelected(session *gameSession, index byte) bool {
|
|
return index != 0 && currentCollectboxSessionSelection(session) == index
|
|
}
|
|
|
|
// currentCollectboxSessionSelection returns the box this session selected for
|
|
// the character that is currently logged in. The pair is keyed by character ID
|
|
// and survives 返回选角, so a same-character re-entry restores the player's own
|
|
// box instead of the login default.
|
|
func currentCollectboxSessionSelection(session *gameSession) byte {
|
|
if session == nil || session.selectedCharacterID == 0 ||
|
|
session.selectedCollectboxCharacterID != session.selectedCharacterID {
|
|
return 0
|
|
}
|
|
return session.selectedCollectboxIndex
|
|
}
|
|
|
|
func (s *Service) sendCollectboxState(ctx context.Context, session *gameSession, definition dnfcollectbox.Definition, refreshSelected bool) error {
|
|
state, err := s.loadCollectboxSessionState(ctx, session)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.sendCollectboxStateForSnapshot(session, definition, state, refreshSelected)
|
|
}
|
|
|
|
// loadCollectboxSessionState resolves the authoritative box contents once for a
|
|
// caller that publishes several boxes in one pass.
|
|
func (s *Service) loadCollectboxSessionState(ctx context.Context, session *gameSession) (dnfcollectbox.State, error) {
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || session == nil || session.selectedCharacterID == 0 {
|
|
return dnfcollectbox.State{}, fmt.Errorf("collect box state repositories unavailable")
|
|
}
|
|
return loadCollectboxState(ctx, repositories, s.accountIDForSession(session), uint64(session.selectedCharacterID))
|
|
}
|
|
|
|
func (s *Service) sendCollectboxStateForSnapshot(session *gameSession, definition dnfcollectbox.Definition, state dnfcollectbox.State, refreshSelected bool) error {
|
|
body := buildCollectboxStateBodyAt(definition, state, refreshSelected, time.Now().UTC())
|
|
s.logPacketEvent("game-collectbox-state-send",
|
|
"conn_id", session.connID,
|
|
"char_id", session.selectedCharacterID,
|
|
"box_index", definition.Index,
|
|
"item_count", body[7],
|
|
"refresh_selected", refreshSelected,
|
|
"body_len", len(body))
|
|
return s.sendGameUpperRawClass(session, collectboxNotificationMsgID, body, 0)
|
|
}
|
|
|
|
// collectboxPopulatedIndexes lists the boxes that currently hold collected
|
|
// items, in catalog order.
|
|
func collectboxPopulatedIndexes(catalog *currentCollectboxCatalog, state dnfcollectbox.State) []byte {
|
|
if catalog == nil {
|
|
return nil
|
|
}
|
|
populated := make([]byte, 0, len(catalog.indexes))
|
|
for _, index := range catalog.indexes {
|
|
if collectboxItemCount(catalog, state, index) > 0 {
|
|
populated = append(populated, index)
|
|
}
|
|
}
|
|
return populated
|
|
}
|
|
|
|
func collectboxItemCount(catalog *currentCollectboxCatalog, state dnfcollectbox.State, index byte) int {
|
|
definition, ok := collectboxDefinition(catalog, index)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return len(dnfcollectbox.Snapshot(definition, state))
|
|
}
|
|
|
|
// currentCollectboxActiveIndex picks the box that the login/actor-generation
|
|
// publication selects and refreshes last. The native client applies the
|
|
// attributes of the *selected* box only, so the selection is player intent and
|
|
// must be restored, never derived: the session's own choice first, then the
|
|
// durable per-character choice, and only an account that has never picked a box
|
|
// falls back to the most populated box and finally the PVF default. Deriving
|
|
// the box from item counts is what silently moved a player who had picked
|
|
// 生肖之灵 (2 items) back onto SAO (15 items) on every re-entry
|
|
// (live 2026-09-27 15:2x).
|
|
func currentCollectboxActiveIndex(catalog *currentCollectboxCatalog, state dnfcollectbox.State, session *gameSession) byte {
|
|
if catalog == nil || len(catalog.indexes) == 0 {
|
|
return 0
|
|
}
|
|
characterID := ""
|
|
if session != nil && session.selectedCharacterID != 0 {
|
|
characterID = strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
|
}
|
|
for _, remembered := range []byte{
|
|
currentCollectboxSessionSelection(session),
|
|
state.SelectedBox(characterID),
|
|
} {
|
|
if remembered == 0 {
|
|
continue
|
|
}
|
|
if _, ok := collectboxDefinition(catalog, remembered); ok {
|
|
return remembered
|
|
}
|
|
}
|
|
best, bestCount := byte(0), 0
|
|
for _, index := range collectboxPopulatedIndexes(catalog, state) {
|
|
if count := collectboxItemCount(catalog, state, index); count > bestCount {
|
|
best, bestCount = index, count
|
|
}
|
|
}
|
|
if bestCount > 0 {
|
|
return best
|
|
}
|
|
if _, ok := collectboxDefinition(catalog, defaultCollectboxIndex); ok {
|
|
return defaultCollectboxIndex
|
|
}
|
|
return catalog.indexes[0]
|
|
}
|
|
|
|
// persistCollectboxSelection records the player's box through the collection-box
|
|
// owner so the next login - including a client restart, a channel reconnect and
|
|
// a server restart - restores it instead of a default.
|
|
func (s *Service) persistCollectboxSelection(ctx context.Context, session *gameSession, index byte) error {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 || index == 0 {
|
|
return nil
|
|
}
|
|
owner, err := s.collectboxOwner()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return owner.Select(ctx, dnfcollectbox.SelectCommand{
|
|
CharacterID: strconv.FormatUint(uint64(session.selectedCharacterID), 10),
|
|
BoxIndex: index,
|
|
UpdatedAt: time.Now().UTC(),
|
|
})
|
|
}
|
|
|
|
func collectboxIndexList(indexes []byte) string {
|
|
if len(indexes) == 0 {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(indexes))
|
|
for _, index := range indexes {
|
|
parts = append(parts, strconv.Itoa(int(index)))
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
func buildCollectboxStateBody(definition dnfcollectbox.Definition, state dnfcollectbox.State) []byte {
|
|
return buildCollectboxStateBodyAt(definition, state, true, time.Now().UTC())
|
|
}
|
|
|
|
func buildCollectboxStateBodyAt(definition dnfcollectbox.Definition, state dnfcollectbox.State, refreshSelected bool, now time.Time) []byte {
|
|
items := dnfcollectbox.Snapshot(definition, state)
|
|
body := make([]byte, 8+len(items)*4)
|
|
body[0] = definition.Index
|
|
if refreshSelected {
|
|
body[1] = 1
|
|
}
|
|
expiration := state.Expiration[strconv.Itoa(int(definition.Index))]
|
|
if expiration.IsZero() {
|
|
expiration, _ = parseCollectboxExpiration(definition.BaseExpirationDate)
|
|
}
|
|
if expiration.IsZero() {
|
|
expiration, _ = parseCollectboxExpiration(definition.MaxExpirationDate)
|
|
}
|
|
remaining, flags := collectboxExpiryTime(expiration, now)
|
|
binary.LittleEndian.PutUint32(body[2:6], remaining)
|
|
body[6] = flags
|
|
body[7] = byte(len(items))
|
|
for index, itemID := range items {
|
|
binary.LittleEndian.PutUint32(body[8+index*4:12+index*4], itemID)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func (s *Service) sendCollectboxInitializationStates(ctx context.Context, session *gameSession) error {
|
|
return s.sendCollectboxInitializationStatesWithReplay(ctx, session, false)
|
|
}
|
|
|
|
// sendCollectboxSceneInitializationStates publishes the complete collection-
|
|
// box projection once for the current native actor generation. The early
|
|
// select-character seed can populate the expiry cache before an actor exists,
|
|
// but only this post-mode1 publication makes the native collection-box owner
|
|
// recompute its five combat-stat bonuses. Dungeon entry and town return reset
|
|
// the cursor because both routes create a replacement actor generation.
|
|
func (s *Service) sendCollectboxSceneInitializationStates(ctx context.Context, session *gameSession) error {
|
|
if session != nil && session.collectboxSceneProjectionSent {
|
|
return nil
|
|
}
|
|
if err := s.sendCollectboxInitializationStatesWithReplay(ctx, session, true); err != nil {
|
|
return err
|
|
}
|
|
session.collectboxSceneProjectionSent = true
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) sendCollectboxInitializationStatesWithReplay(ctx context.Context, session *gameSession, sceneReady bool) error {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return fmt.Errorf("collect box initialization requires a selected character")
|
|
}
|
|
// 2026-10-19 排查(剑魔蛇腹剑形态):现场反馈怀疑与收集箱(SAO)有关。除了进图
|
|
// 那一组,登录/选角路径也会推送整组收集箱状态并触发客户端原生 owner 重算;
|
|
// 这里用同一个开关把两条路径一起停掉,用来做“完全不推收集箱”的对照实验。
|
|
// 默认停;DNF90_COLLECTBOX_PROJECTION=on 恢复原行为。
|
|
if !currentCollectboxProjectionEnabled() {
|
|
s.logGameEvent(session, "game-collectbox-projection-skipped",
|
|
"character_id", session.selectedCharacterID,
|
|
"scene_ready", sceneReady,
|
|
"reason", "diagnostic_switch_off")
|
|
if sceneReady {
|
|
session.collectboxSceneProjectionSent = true
|
|
}
|
|
return nil
|
|
}
|
|
if session.collectboxInitializationSent && !sceneReady {
|
|
return nil
|
|
}
|
|
catalog, err := s.currentCollectboxCatalog()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state, err := s.loadCollectboxSessionState(ctx, session)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
populated := collectboxPopulatedIndexes(catalog, state)
|
|
selectedIndex := currentCollectboxActiveIndex(catalog, state, session)
|
|
selectedDefinition, ok := collectboxDefinition(catalog, selectedIndex)
|
|
if !ok {
|
|
return fmt.Errorf("collect box definition %d unavailable", selectedIndex)
|
|
}
|
|
for _, index := range catalog.indexes {
|
|
definition, ok := collectboxDefinition(catalog, index)
|
|
if ok {
|
|
// Initial states populate the client's cache without changing its
|
|
// current selection. The explicit selection is sent below.
|
|
if err := s.sendCollectboxStateForSnapshot(session, definition, state, false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
// A populated box must never stay cache-only: the native owner recomputes
|
|
// its five combat-stat bonuses from a refreshed box, and the client's own
|
|
// op1021 select is the only other producer of one. Leaving the player's box
|
|
// at refresh_selected=0 made its attributes vanish on every 小退/重上 until
|
|
// the box was clicked again. These run before the selected refresh below so
|
|
// the window and its stat owner still end on the selected box.
|
|
for _, index := range populated {
|
|
if index == selectedIndex {
|
|
continue
|
|
}
|
|
if definition, ok := collectboxDefinition(catalog, index); ok {
|
|
if err := s.sendCollectboxStateForSnapshot(session, definition, state, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
// The archived current-client collection-box trace requires the ordinary
|
|
// class-1 op1021 success body [1, index] between cache population and the
|
|
// selected op1027 refresh. op1027 alone refreshes the state table but does
|
|
// not change the window's active tab, which leaves the client on SAO.
|
|
if err := s.sendGameUpperSuccess(
|
|
session,
|
|
uint16(dnfenum.CmdPacketSelectCollectbox),
|
|
[]byte{selectedIndex},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
session.selectedCollectboxIndex = selectedIndex
|
|
session.selectedCollectboxCharacterID = session.selectedCharacterID
|
|
if err := s.sendCollectboxStateForSnapshot(session, selectedDefinition, state, true); err != nil {
|
|
return err
|
|
}
|
|
session.collectboxInitializationSent = true
|
|
s.logGameEvent(session, "game-collectbox-initialization-sent",
|
|
"char_id", session.selectedCharacterID,
|
|
"box_count", len(catalog.indexes),
|
|
"selected_box_index", selectedIndex,
|
|
"selected_box_item_count", collectboxItemCount(catalog, state, selectedIndex),
|
|
"populated_box_indexes", collectboxIndexList(populated),
|
|
"scene_ready_replay", sceneReady,
|
|
"wire_contract", "class0_op1027_cache_then_populated_refresh_then_class1_op1021_selection_ack_then_selected_refresh")
|
|
return nil
|
|
}
|
|
|
|
// replayCollectboxSelectedRefreshAfterMode1 republishes the selected box once
|
|
// per actor generation, immediately after the complete mode1 that binds the
|
|
// final town actor. The town routes publish the collection-box projection
|
|
// between mode0 and that mode1 because the select/return ACK boundary needs it
|
|
// there; the native collection-box stat owner only exists with the final actor,
|
|
// so without this replay the refreshed state that carries the combat-stat
|
|
// bonuses is discarded until the player clicks the box by hand.
|
|
func (s *Service) replayCollectboxSelectedRefreshAfterMode1(ctx context.Context, session *gameSession, source string) error {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 || session.collectboxSelectionRefreshSent {
|
|
return nil
|
|
}
|
|
catalog, err := s.currentCollectboxCatalog()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state, err := s.loadCollectboxSessionState(ctx, session)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
selectedIndex := currentCollectboxActiveIndex(catalog, state, session)
|
|
definition, ok := collectboxDefinition(catalog, selectedIndex)
|
|
if !ok {
|
|
return fmt.Errorf("collect box definition %d unavailable", selectedIndex)
|
|
}
|
|
if err := s.sendCollectboxStateForSnapshot(session, definition, state, true); err != nil {
|
|
return err
|
|
}
|
|
session.selectedCollectboxIndex = selectedIndex
|
|
session.selectedCollectboxCharacterID = session.selectedCharacterID
|
|
session.collectboxSelectionRefreshSent = true
|
|
s.logGameEvent(session, "game-collectbox-selection-refresh-after-mode1",
|
|
"char_id", session.selectedCharacterID,
|
|
"box_index", selectedIndex,
|
|
"item_count", collectboxItemCount(catalog, state, selectedIndex),
|
|
"source", source,
|
|
"reason", "native_collection_box_stat_owner_is_created_with_the_final_actor")
|
|
return nil
|
|
}
|
|
|
|
func collectboxExpiry(raw string, now time.Time) (uint32, byte) {
|
|
raw = strings.Trim(strings.TrimSpace(raw), "`")
|
|
if raw == "" {
|
|
return 0, 1
|
|
}
|
|
expiry, ok := parseCollectboxExpiration(raw)
|
|
if !ok {
|
|
return 0, 1
|
|
}
|
|
return collectboxExpiryTime(expiry, now)
|
|
}
|
|
|
|
func parseCollectboxExpiration(raw string) (time.Time, bool) {
|
|
raw = strings.Trim(strings.TrimSpace(raw), "`")
|
|
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02", "2006.01.02 15:04:05", "2006.01.02"} {
|
|
parsed, err := time.ParseInLocation(layout, raw, time.Local)
|
|
if err == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func collectboxExpiryTime(expiry, now time.Time) (uint32, byte) {
|
|
if expiry.IsZero() {
|
|
return 0, 1
|
|
}
|
|
seconds := expiry.Sub(now).Seconds()
|
|
if seconds <= 0 {
|
|
return 0, 0
|
|
}
|
|
if seconds >= math.MaxUint32 {
|
|
return math.MaxUint32, 0
|
|
}
|
|
return uint32(seconds), 0
|
|
}
|