DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,605 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user