DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,969 @@
package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"math"
"strconv"
"strings"
"sync"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfonlineevent "longheng.io/server/internal/modules/dnf/onlineevent"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentSpendTimeStageCount = 4
currentSpendTimeObserveTick = 30 * time.Second
currentSpendTimeResetHour = 6
currentSpendTimeWriteTimeout = 10 * time.Second
)
// The established 2347 definition retains the durable daily observation clock.
// Production loads nativePCRoom from its separate ETC file and activates 228,
// whose manual mail rewards replace the old automatic inventory awards.
type currentSpendTimeRuntimeCatalog struct {
laborDay *dnfonlineevent.LaborDayCatalog
nativePCRoom *dnfonlineevent.PCRoomTimePointCatalog
definition dnfonlineevent.Definition
rewardItemIDs []uint32
sumRewardItemIDs []uint32
totalStageSeconds uint32
}
// currentSpendTimeClockState owns one selected-character observation cursor.
// Its timer callback is routed through the per-session event loop and also
// carries characterGeneration, so a stale callback cannot credit a replacement
// character or write to its socket.
type currentSpendTimeClockState struct {
laborDayActive bool
mu sync.Mutex
eventInfoSent bool
eventInfoDescriptorGeneration uint64
eventInfoReplayAfterTeamReturn bool
characterID uint16
characterGeneration uint64
initializedCharacterGeneration uint64
accountID string
anchor time.Time
generation uint64
timerName string
onlineSeconds uint64
completedStages uint32
catalog *currentSpendTimeRuntimeCatalog
}
type currentSpendTimeSettlementResult struct {
changed bool
rewardItems []dnfonlineevent.CommittedItem
claimErr error
}
func buildCurrentSpendTimeRuntimeCatalog(catalog *dnfonlineevent.AttendancePVFCatalog) (*currentSpendTimeRuntimeCatalog, error) {
if catalog == nil {
return nil, fmt.Errorf("current spend-time attendance catalog is nil")
}
snapshot := catalog.Snapshot()
if len(snapshot.ProcessDurationsSeconds) != currentSpendTimeStageCount ||
len(snapshot.RewardItems) != currentSpendTimeStageCount {
return nil, fmt.Errorf(
"current spend-time PVF shape durations=%d rewards=%d want=%d/%d",
len(snapshot.ProcessDurationsSeconds),
len(snapshot.RewardItems),
currentSpendTimeStageCount,
currentSpendTimeStageCount,
)
}
definition := dnfonlineevent.Definition{
ID: strconv.FormatUint(uint64(currentSpendTimeEventID), 10),
Stages: make([]dnfonlineevent.Stage, 0, currentSpendTimeStageCount),
}
rewardItemIDs := make([]uint32, 0, currentSpendTimeStageCount)
sumRewardItemIDs := make([]uint32, 0, currentSpendTimeStageCount)
var cumulative uint64
for index := 0; index < currentSpendTimeStageCount; index++ {
duration := snapshot.ProcessDurationsSeconds[index]
reward := snapshot.RewardItems[index]
if duration <= 0 || reward.ItemID <= 0 || reward.ItemID > math.MaxUint32 || reward.Count <= 0 {
return nil, fmt.Errorf(
"current spend-time PVF stage=%d duration=%d item=%d count=%d",
index,
duration,
reward.ItemID,
reward.Count,
)
}
cumulative += uint64(duration)
if cumulative > math.MaxUint32 {
return nil, fmt.Errorf("current spend-time cumulative seconds=%d overflows u32", cumulative)
}
definition.Stages = append(definition.Stages, dnfonlineevent.Stage{
ID: fmt.Sprintf("stage-%d", index+1),
RequiredSeconds: cumulative,
Items: []dnfonlineevent.ItemReward{{
ItemID: reward.ItemID,
Count: reward.Count,
}},
})
rewardItemIDs = append(rewardItemIDs, uint32(reward.ItemID))
}
for _, reward := range snapshot.RewardItemsForSum {
if reward.ItemID <= 0 || reward.ItemID > math.MaxUint32 || reward.Count <= 0 {
return nil, fmt.Errorf("current spend-time sum reward item=%d count=%d", reward.ItemID, reward.Count)
}
sumRewardItemIDs = append(sumRewardItemIDs, uint32(reward.ItemID))
}
if err := definition.Validate(); err != nil {
return nil, fmt.Errorf("validate current spend-time definition: %w", err)
}
return &currentSpendTimeRuntimeCatalog{
definition: definition,
rewardItemIDs: append([]uint32(nil), rewardItemIDs...),
sumRewardItemIDs: append([]uint32(nil), sumRewardItemIDs...),
totalStageSeconds: uint32(cumulative),
}, nil
}
// currentSpendTimeRuntimeCatalogIfAvailable lazily projects the same archive
// already loaded by the startup PVF stages. Hand-assembled unit-test services
// without an archive keep the feature absent unless the test injects a proved
// projection; production Start always establishes the archive first.
func (s *Service) currentSpendTimeRuntimeCatalogIfAvailable(ctx context.Context) (*currentSpendTimeRuntimeCatalog, bool, error) {
if s == nil {
return nil, false, nil
}
// 「累计在线得好礼」(event 2347,AttendanceEventPVFPath)已按运营要求关闭:
// 这里直接声明"活动运行时目录不可用",于是:
// - 结算时钟(settleCurrentSpendTimeClockLocked)永不启动,不会计入在线时长;
// - 不会领取/发放任何阶段奖励,也不会发奖励邮件;
// - 进度包(op1206)与目录行都不再下发,客户端不显示该活动 HUD;
// - 同一目录也被「全职LV预约得好礼」(level_aiming)复用,因此后者一并停用。
// 解析实现(buildCurrentSpendTimeRuntimeCatalog 等)保留为死代码,便于日后恢复。
return nil, false, nil
}
// 以下为活动关闭前的原始实现,保留备查(unused;恢复活动时把上面函数体的
// 提前 return 去掉、并把本函数改回 currentSpendTimeRuntimeCatalogIfAvailable 即可)。
// 因为整个函数体被注释掉,Go 不会编译它。
/*
func (s *Service) currentSpendTimeRuntimeCatalogLoaderDisabledForClosedActivity(ctx context.Context) (*currentSpendTimeRuntimeCatalog, bool, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, true, err
}
s.spendTimeCatalogMu.Lock()
defer s.spendTimeCatalogMu.Unlock()
if s.spendTimeCatalogState != nil {
return s.spendTimeCatalogState, true, nil
}
if s.spendTimeCatalogLoadErr != nil {
return nil, true, s.spendTimeCatalogLoadErr
}
s.initialEquipmentMu.Lock()
archive := s.initialEquipmentArchive
s.initialEquipmentMu.Unlock()
if archive == nil {
return nil, false, nil
}
rawCatalog, err := dnfonlineevent.LoadAttendancePVFCatalog(ctx, archive)
if err == nil {
s.spendTimeCatalogState, err = buildCurrentSpendTimeRuntimeCatalog(rawCatalog)
}
if err == nil {
s.spendTimeCatalogState.nativePCRoom, err = dnfonlineevent.LoadPCRoomTimePointCatalog(ctx, archive)
if err != nil {
s.spendTimeCatalogState = nil
}
}
if err == nil {
s.spendTimeCatalogState.laborDay, err = s.currentLaborDayCatalog(ctx)
}
s.spendTimeCatalogLoadErr = err
if err != nil {
return nil, true, err
}
s.logPacketEvent("dnf-spend-time-catalog-loaded",
"event_id", currentSpendTimeEventID,
"reward_count", len(s.spendTimeCatalogState.rewardItemIDs),
"total_stage_seconds", s.spendTimeCatalogState.totalStageSeconds,
"source", dnfonlineevent.AttendanceEventPVFPath)
return s.spendTimeCatalogState, true, nil
}
*/
func (s *Service) currentSpendTimeOwner() (*dnfonlineevent.Owner, error) {
repositories, ok := s.repositoryGroup()
if !ok {
return nil, dnfonlineevent.ErrOwnerUnavailable
}
return dnfonlineevent.NewOwner(repositories)
}
// Publish the activity directory once per actor generation, followed by the
// active owner's state (561 for native 228), then start the durable clock.
func (s *Service) sendCurrentSpendTimeInitialStateOnce(
session *gameSession,
source string,
) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
characterID := session.selectedCharacterID
characterGeneration := session.characterGeneration
session.spendTime.mu.Lock()
alreadyInitialized := session.spendTime.characterID == characterID &&
session.spendTime.initializedCharacterGeneration == characterGeneration &&
session.spendTime.catalog != nil && !session.spendTime.anchor.IsZero()
session.spendTime.mu.Unlock()
if alreadyInitialized {
// The descriptor is a one-shot activity-table replacement. Replaying
// op108 from the scene-ready tail resets the native owner and makes the
// same online-time entries render a second time. The first town route
// already sends op108 followed by op1206; later callbacks only refresh
// their authoritative state through the normal progress path.
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
defer cancel()
catalog, available, err := s.currentSpendTimeRuntimeCatalogIfAvailable(ctx)
if err != nil {
return fmt.Errorf("load current spend-time catalog: %w", err)
}
if !available || catalog == nil {
s.logGameEvent(session, "game-spend-time-initialization-deferred",
"source", source,
"char_id", characterID,
"reason", "runtime_pvf_archive_not_loaded")
return nil
}
owner, err := s.currentSpendTimeOwner()
if err != nil {
return fmt.Errorf("create current spend-time owner: %w", err)
}
now := s.gameplayNow()
accountID := s.accountIDForSession(session)
characterIDText := strconv.Itoa(int(characterID))
session.spendTime.mu.Lock()
defer session.spendTime.mu.Unlock()
if session.spendTime.characterID == characterID &&
session.spendTime.initializedCharacterGeneration == characterGeneration &&
session.spendTime.catalog != nil && !session.spendTime.anchor.IsZero() {
return nil
}
if session.spendTime.characterID != 0 && session.spendTime.characterID != characterID {
return fmt.Errorf("current spend-time clock still owns character %d", session.spendTime.characterID)
}
snapshot, err := owner.Status(ctx, dnfonlineevent.StatusCommand{
AccountID: accountID,
CharacterID: characterIDText,
Definition: catalog.definition,
ObservedAt: now,
})
if err != nil {
return fmt.Errorf("load current spend-time status: %w", err)
}
snapshot, rewardItems, claimErr := s.claimCurrentSpendTimeUnlockedStages(
ctx,
owner,
accountID,
characterIDText,
catalog,
snapshot,
now,
)
if claimErr != nil {
s.logGameEvent(session, "game-spend-time-initial-auto-award-deferred",
"source", source,
"char_id", characterID,
"online_seconds", snapshot.OnlineSeconds,
"error", claimErr)
}
completedStages := currentSpendTimeClaimedStages(catalog.definition, snapshot)
if err := s.settleCurrentLaborDay(ctx, session, catalog.laborDay, time.Time{}, time.Time{}, true); err != nil {
return err
}
// op108 is not merely process-global metadata: it creates the two floating
// activity controls in the selected actor's town HUD. Rebuild it once for
// every new actor generation. Keep the legacy registry only for a retry in
// the same generation, where a descriptor may already have reached the EXE
// before a later packet write failed.
sceneDescriptorRequired := !session.spendTime.eventInfoSent ||
session.spendTime.eventInfoDescriptorGeneration != characterGeneration
registrySendDescriptor, descriptorReady := false, true
if !sceneDescriptorRequired {
registrySendDescriptor, descriptorReady = s.beginCurrentSpendTimeEventInfo(session)
if !descriptorReady {
return fmt.Errorf("current spend-time descriptor retry is being sent by another session for client pid=%d", session.clientPID)
}
}
forceDescriptorReplay := session.spendTime.eventInfoReplayAfterTeamReturn
sendDescriptor := sceneDescriptorRequired || registrySendDescriptor || forceDescriptorReplay
descriptorSent, sendErr := s.sendCurrentSpendTimeProtocolState(
session,
catalog,
snapshot.OnlineSeconds,
completedStages,
sendDescriptor,
)
if registrySendDescriptor || descriptorSent {
// Commit immediately after a successful op108 write, even when this
// scene-generation send bypassed the legacy registry. A later op1206
// progress or joust snapshot failure must not let a retry rediscover an
// unsent process-level descriptor and duplicate it in this same scene.
s.finishCurrentSpendTimeEventInfo(session, descriptorSent)
}
if descriptorSent {
session.spendTime.eventInfoSent = true
session.spendTime.eventInfoDescriptorGeneration = characterGeneration
}
if forceDescriptorReplay && descriptorSent {
// The full op108 write is the commit boundary. A later op1206 failure is
// retried progress-only because replaying the base catalog twice after the
// team-return repair would repeat the same client-side owner reset.
session.spendTime.eventInfoReplayAfterTeamReturn = false
s.logGameEvent(session, "game-spend-time-descriptor-replayed-after-team-return",
"source", source,
"char_id", characterID,
"client_pid", session.clientPID,
"reason", "team_channel_return_rebuilds_client_activity_table")
}
if sendErr != nil {
return sendErr
}
if !sendDescriptor {
s.logGameEvent(session, "game-spend-time-descriptor-skipped",
"source", source,
"char_id", characterID,
"client_pid", session.clientPID,
"already_sent", session.spendTime.eventInfoSent,
"reason", "descriptor_already_written_for_current_actor_generation_progress_only")
}
if err := s.sendCurrentSpendTimeRewardItemUpdates(session, rewardItems, source+"_initial_auto_award"); err != nil {
return err
}
// 参考实现这里调 cera_shop_buycount.go 的 sendCurrentCeraShopBuyCountInitialState。
// 本树把同一件事实现成了 sendCurrentCeraShopBuyCountGiftProjection(同一个
// 0x040B opcode、同一个 u32 实购买次数体),只是计数落在角色 Stats 而不是账号
// 元数据上,所以这里直接复用本树那一个,不引入第二份"已购买次数"下发。
if err := s.sendCurrentCeraShopBuyCountGiftProjection(ctx, session, "spend_time_initial_state"); err != nil {
return err
}
if err := s.sendCurrentMileageInitialState(ctx, session); err != nil {
return err
}
session.spendTime.characterID = characterID
session.spendTime.characterGeneration = characterGeneration
session.spendTime.initializedCharacterGeneration = characterGeneration
session.spendTime.accountID = accountID
// Keep the exact observation start. onlineevent.Owner conservatively
// ceil(from)/floor(to); truncating here would manufacture up to one second.
session.spendTime.anchor = now
session.spendTime.generation++
session.spendTime.onlineSeconds = snapshot.OnlineSeconds
session.spendTime.completedStages = completedStages
session.spendTime.catalog = catalog
session.spendTime.laborDayActive = catalog.laborDay != nil && catalog.laborDay.Policy.ActiveAt(now)
if err := s.armCurrentSpendTimeTickLocked(session); err != nil {
session.spendTime.characterID = 0
session.spendTime.characterGeneration = 0
session.spendTime.initializedCharacterGeneration = 0
session.spendTime.accountID = ""
session.spendTime.anchor = time.Time{}
session.spendTime.timerName = ""
session.spendTime.onlineSeconds = 0
session.spendTime.completedStages = 0
session.spendTime.catalog = nil
return err
}
s.logGameEvent(session, "game-spend-time-initial-state-sent",
"source", source,
"char_id", characterID,
"event_id", currentSpendTimeEventID,
"online_seconds", snapshot.OnlineSeconds,
"completed_stages", completedStages,
"reward_item_updates", len(rewardItems),
"sequence", "class0_op108_descriptor_then_class0_op1206_progress_then_class0_op1206_luke_schedule_then_class0_op1206_fiendwar_schedule_then_class0_op1303_raid_availability_then_atomic_reward_op14_before_typed_op24")
return nil
}
func (s *Service) sendCurrentSpendTimeProtocolState(
session *gameSession,
catalog *currentSpendTimeRuntimeCatalog,
onlineSeconds uint64,
completedStages uint32,
includeFirstProcessDescriptor bool,
) (bool, error) {
if s == nil || session == nil || catalog == nil {
return false, nil
}
var eventInfoTransport []byte
if includeFirstProcessDescriptor {
eventInfoBody, err := buildCurrentOnlineEventInfoBodyAt(
catalog.rewardItemIDs,
catalog.sumRewardItemIDs,
catalog.totalStageSeconds,
catalog.nativePCRoom != nil,
catalog.laborDay, s.gameplayNow(),
)
if err != nil {
return false, err
}
eventInfoTransport, err = zlibCompress(eventInfoBody)
if err != nil {
return false, fmt.Errorf("compress current spend-time op108: %w", err)
}
}
progressBody, err := buildCurrentSpendTimeProgressBody(onlineSeconds, completedStages)
if err != nil {
return false, err
}
descriptorSent := false
if includeFirstProcessDescriptor {
if err := s.sendCurrentProtectedClass0Packet(
session,
currentSpendTimeEventInfoMsgID,
eventInfoTransport,
currentSpendTimeEventInfoCodec,
"current_op108_spend_time_catalog",
); err != nil {
return false, err
}
descriptorSent = true
// Replacing the native event table invalidates its attendance snapshot.
session.monthlyAttendance = currentMonthlyAttendanceProjection{}
session.weeklyAttendance = currentWeeklyAttendanceProjection{}
}
// Install the directory before the selected online owner's state. The
// original four-plus-four panel is 228/561; 2347/1206 is a different HUD.
if catalog.nativePCRoom != nil {
if err := s.sendCurrentPCRoomTimePoint(session, catalog, 0, -1); err != nil {
return descriptorSent, err
}
} else if err := s.sendGameUpperRawClass(session, currentSpendTimeProgressMsgID, progressBody, 0); err != nil {
return descriptorSent, err
}
// Events 181/282/2449 own the Anton, Luke and Fiend War cards in the native
// ESC/team-raid window. Their authoritative channels may already be
// connected, but the cards remain hidden until the matching narrow schedule
// records are applied after the final op108 catalog.
//
// 本树刻意比参考实现多推一条:参考版只发 282/2449 两档,而 181 的记录里
// raw[0]/raw[1] 是账号/角色**本周已用**次数——客户端把这两项渲染成
// "账号周入场次数 / 角色周入场次数",且显示的是 (上限 - 已用)。少了 181
// 这条记录,已经进过本的玩家会看到"上限/上限",而 op1303 的入场门禁却按
// 同一个计数拒绝他。sendCurrentRaidWeeklySchedules 一次把三档都发出去,
// 计数直接取自 raid_admission_counter 的费用账本。
if err := s.sendCurrentRaidWeeklySchedules(session, "spend_time_after_op108"); err != nil {
return descriptorSent, err
}
// The current client consumes the activity collection and weekly-state
// projection before it reads the per-raid daily availability table. Keep
// this immediately after all op1206 records so Anton, Luke and Fiend War
// observe the final post-op356 event table before evaluating availability.
if err := s.sendCurrentRaidDailyAvailability(session, "spend_time_after_op1206"); err != nil {
return descriptorSent, err
}
// 骑士马战(joust)活动已取消:登录快照不再推送 op1240 活动状态与
// op1241 骑手名单;客户端因此不会把 owner 609 的竞猜界面置为可用。
// 结算/历史实现保留为死代码,便于日后按需恢复。
if err := s.sendCurrentMonthlyAttendance(session, false); err != nil {
return descriptorSent, err
}
if err := s.sendCurrentWeeklyAttendance(session, false, false); err != nil {
return descriptorSent, err
}
if err := s.sendCurrentReturneeState(session, false); err != nil {
return descriptorSent, err
}
// 全职LV预约得好礼(等级预约,活动 222)已按运营要求关闭:
// 不再下发 op623 的等级预约状态包,客户端不显示该活动。
// 结算实现(level_aiming.go / onlineevent.LevelAiming*)保留为死代码,
// 便于日后按需恢复。
return descriptorSent, nil
}
func currentSpendTimeReachedStages(definition dnfonlineevent.Definition, onlineSeconds uint64) uint32 {
var completed uint32
for _, stage := range definition.Stages {
if onlineSeconds < stage.RequiredSeconds {
break
}
completed++
}
return completed
}
func currentSpendTimeClaimedStages(definition dnfonlineevent.Definition, snapshot dnfonlineevent.Snapshot) uint32 {
claimed := make(map[string]struct{}, len(snapshot.ClaimedStageIDs))
for _, stageID := range snapshot.ClaimedStageIDs {
claimed[stageID] = struct{}{}
}
var completed uint32
for _, stage := range definition.Stages {
if _, ok := claimed[stage.ID]; !ok {
break
}
completed++
}
return completed
}
func (s *Service) claimCurrentSpendTimeUnlockedStages(
ctx context.Context,
owner *dnfonlineevent.Owner,
accountID string,
characterID string,
catalog *currentSpendTimeRuntimeCatalog,
snapshot dnfonlineevent.Snapshot,
claimedAt time.Time,
) (dnfonlineevent.Snapshot, []dnfonlineevent.CommittedItem, error) {
accountID = strings.TrimSpace(accountID)
characterID = strings.TrimSpace(characterID)
if s == nil || owner == nil || accountID == "" || characterID == "" || catalog == nil {
return snapshot, nil, dnfonlineevent.ErrOwnerUnavailable
}
if catalog.nativePCRoom != nil {
// Preserve the existing durable observation clock; native 228 owns
// manual mail rewards and cumulative completions from this point on.
_, err := s.settleCurrentPCRoomTimePoint(ctx, accountID, characterID, catalog, claimedAt, 0, -1)
return snapshot, nil, err
}
claimed := make(map[string]struct{}, len(snapshot.ClaimedStageIDs))
for _, stageID := range snapshot.ClaimedStageIDs {
claimed[stageID] = struct{}{}
}
itemCatalog, err := s.currentPVFItemCatalog()
if err != nil {
return snapshot, nil, err
}
items := make([]dnfonlineevent.CommittedItem, 0, currentSpendTimeStageCount)
for _, stage := range catalog.definition.Stages {
if snapshot.OnlineSeconds < stage.RequiredSeconds {
break
}
if _, alreadyClaimed := claimed[stage.ID]; alreadyClaimed {
continue
}
claim, claimErr := owner.Claim(ctx, dnfonlineevent.ClaimCommand{
AccountID: accountID,
CharacterID: characterID,
Definition: catalog.definition,
StageID: stage.ID,
ClaimedAt: claimedAt,
Allocate: currentSpendTimeItemAllocator(itemCatalog, claimedAt, stage.ID),
})
if claimErr != nil {
return snapshot, items, claimErr
}
snapshot = claim.PostSnapshot
claimed[stage.ID] = struct{}{}
items = append(items, currentSpendTimeClaimItemsForCharacter(claim, characterID)...)
}
return snapshot, items, nil
}
func currentSpendTimeClaimItemsForCharacter(
claim dnfonlineevent.ClaimResult,
characterID string,
) []dnfonlineevent.CommittedItem {
if claim.Replayed || claim.CharacterID != characterID {
return nil
}
items := make([]dnfonlineevent.CommittedItem, len(claim.Items))
for index, item := range claim.Items {
items[index] = item
items[index].RawEntry = append([]byte(nil), item.RawEntry...)
}
return items
}
func currentSpendTimeItemAllocator(
catalog *pvfDungeonDropCatalog,
grantedAt time.Time,
stageID string,
) dnfonlineevent.ItemAllocator {
return func(record *dnfrepo.InventoryRecord, reward dnfonlineevent.ItemReward) (dnfonlineevent.CommittedItem, error) {
if catalog == nil || record == nil || reward.ItemID <= 0 || reward.ItemID > math.MaxUint32 ||
reward.Count <= 0 || reward.Count > math.MaxUint32 {
return dnfonlineevent.CommittedItem{}, errDungeonPickupItemInvalid
}
definition, err := catalog.ResolveItem(uint32(reward.ItemID))
if err != nil {
return dnfonlineevent.CommittedItem{}, err
}
definition, err = currentPVFItemDefinitionForGrantAt(definition, grantedAt.UTC())
if err != nil {
return dnfonlineevent.CommittedItem{}, err
}
slot, err := addCurrentDungeonPickupToInventory(record, definition, uint32(reward.Count))
if err != nil {
return dnfonlineevent.CommittedItem{}, err
}
key := currentCeraShopInventorySlotKey(definition.InventoryListType, int16(slot))
stack, found := record.Slots[key]
if !found || stack.ItemID != reward.ItemID || stack.Count < reward.Count {
return dnfonlineevent.CommittedItem{}, errDungeonPickupItemInvalid
}
entry := currentItemListEntryFromStack(definition.InventoryListType, int16(slot), stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
if stack.Extra == nil {
stack.Extra = make(map[string]string, 5)
}
if strings.TrimSpace(stack.Extra["source"]) == "" {
stack.Extra["source"] = "online_event_spend_time"
}
stack.Extra["last_grant_source"] = "online_event_spend_time"
stack.Extra["last_grant_event_id"] = strconv.FormatUint(uint64(currentSpendTimeEventID), 10)
stack.Extra["last_grant_stage_id"] = stageID
record.Slots[key] = stack
return dnfonlineevent.CommittedItem{
SlotKey: key, SlotIndex: slot, ItemID: reward.ItemID,
Delta: reward.Count, PostCount: stack.Count,
RawEntry: append([]byte(nil), entry.data[:]...),
}, nil
}
}
func (s *Service) sendCurrentSpendTimeRewardItemUpdates(
session *gameSession,
items []dnfonlineevent.CommittedItem,
source string,
) error {
if len(items) == 0 {
return nil
}
entries := make([]currentItemListEntry, 0, len(items))
refreshMedals := false
for _, item := range items {
if item.ItemID <= 0 || item.ItemID > math.MaxUint32 || item.PostCount < item.Delta ||
len(item.RawEntry) != currentItemListEntryWireSize ||
binary.LittleEndian.Uint16(item.RawEntry[:2]) != item.SlotIndex ||
binary.LittleEndian.Uint32(item.RawEntry[2:6]) != uint32(item.ItemID) {
return fmt.Errorf("current spend-time committed item has invalid wire receipt: %+v", item)
}
if listType, _, ok := parseSceneInventorySlotKey(item.SlotKey); ok && listType == currentGuildMedalInventoryListType {
refreshMedals = true
continue
}
var entry currentItemListEntry
copy(entry.data[:], item.RawEntry)
entries = append(entries, entry)
}
body := buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, entries)
s.logGameEvent(session, "game-spend-time-reward-item-update-send",
"source", source,
"event_id", currentSpendTimeEventID,
"entry_count", len(entries),
"body_len", len(body),
"body_source", "online_event_atomic_claim_receipts_op14_raw77")
if len(entries) > 0 {
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0); err != nil {
return err
}
}
if refreshMedals {
return s.sendCurrentGuardianGemList38Refresh(session, source)
}
return nil
}
func (s *Service) armCurrentSpendTimeTickLocked(session *gameSession) error {
if s == nil || session == nil || session.spendTime.characterID == 0 || session.spendTime.catalog == nil {
return nil
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return errGameplayTimeQueueUnavailable
}
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.spendTime.characterID)
if err != nil {
return err
}
timerName := fmt.Sprintf("dnf-spend-time:%s:%d", session.connID, characterID)
generation := session.spendTime.generation
session.spendTime.timerName = timerName
return queue.ScheduleAfter(timerName, currentSpendTimeObserveTick, func(time.Time) {
err := s.postGameSessionCharacterEvent(
session,
"spend-time-observation-tick",
characterID,
characterGeneration,
func() error {
s.runCurrentSpendTimeTick(session, generation)
return nil
},
)
if err != nil && !isClosedGameSessionEventError(err) {
s.logPacketEvent("game-session-event-submit-failed",
"conn_id", session.connID,
"source", "spend-time-observation-tick",
"char_id", characterID,
"character_generation", characterGeneration,
"error", err)
}
})
}
func (s *Service) runCurrentSpendTimeTick(session *gameSession, generation uint64) {
if s == nil || session == nil {
return
}
now := s.gameplayNow()
session.spendTime.mu.Lock()
defer session.spendTime.mu.Unlock()
if session.spendTime.characterID == 0 || session.spendTime.catalog == nil ||
session.spendTime.generation != generation {
return
}
settlement, err := s.settleCurrentSpendTimeClockLocked(session, now)
if err != nil {
s.logGameEvent(session, "game-spend-time-observation-deferred",
"char_id", session.spendTime.characterID,
"generation", generation,
"error", err)
} else if session.spendTime.catalog.nativePCRoom != nil {
if settlement.claimErr == nil {
if sendErr := s.sendCurrentPCRoomTimePoint(session, session.spendTime.catalog, 0, -1); sendErr != nil {
s.logGameEvent(session, "game-pcroom-timepoint-refresh-failed", "error", sendErr)
}
}
} else if settlement.changed {
body, buildErr := buildCurrentSpendTimeProgressBody(
session.spendTime.onlineSeconds,
session.spendTime.completedStages,
)
if buildErr != nil {
s.logGameEvent(session, "game-spend-time-progress-build-failed", "error", buildErr)
} else if sendErr := s.sendGameUpperRawClass(session, currentSpendTimeProgressMsgID, body, 0); sendErr != nil {
s.logGameEvent(session, "game-spend-time-progress-send-failed", "error", sendErr)
}
if sendErr := s.sendCurrentSpendTimeRewardItemUpdates(
session,
settlement.rewardItems,
"online_observation_tick",
); sendErr != nil {
s.logGameEvent(session, "game-spend-time-reward-item-update-failed", "error", sendErr)
}
}
if settlement.claimErr != nil {
s.logGameEvent(session, "game-spend-time-auto-award-deferred",
"char_id", session.spendTime.characterID,
"online_seconds", session.spendTime.onlineSeconds,
"error", settlement.claimErr)
}
if err := s.sendCurrentMonthlyAttendance(session, false); err != nil {
s.logGameEvent(session, "game-monthly-attendance-refresh-deferred", "error", err)
}
if err := s.sendCurrentWeeklyAttendance(session, false, false); err != nil {
s.logGameEvent(session, "game-weekly-attendance-refresh-deferred", "error", err)
}
if err := s.sendCurrentReturneeState(session, false); err != nil {
s.logGameEvent(session, "game-returnee-refresh-deferred", "error", err)
}
// 参考实现在这里调 item_lock_snapshot.go 的 refreshCurrentExpiredItemLocks。
// 本树的 itemlock 是**另一套锁模型**(排序锁 + 账号金库锁,`sort_lock.go` /
// `sort_lock_account_cargo.go`),没有参考版那套 72 小时 `UnlockWait` /
// `equipment_lock_unlock_at` / HasExpiredLocks / ReconcileExpired,所以这里
// 刻意不调用任何等价物:在活动 tick 里凭空造一个"过期解锁"语义,会把本树
// 已有的排序锁状态机搅乱。真要那条语义,应当整套移植 itemlock 的 expiry.go
// 并在 game_aligned.go 的登录路径上调用,而不是挂在活动时钟上。
//
// 全职LV预约得好礼(等级预约,活动 222)已关闭,故此处不再下发 op623。
if err := s.armCurrentSpendTimeTickLocked(session); err != nil {
s.logGameEvent(session, "game-spend-time-observation-rearm-deferred",
"char_id", session.spendTime.characterID,
"generation", generation,
"error", err)
}
}
func (s *Service) settleCurrentSpendTimeClockLocked(
session *gameSession,
now time.Time,
) (currentSpendTimeSettlementResult, error) {
if session == nil || session.spendTime.characterID == 0 || session.spendTime.catalog == nil ||
session.spendTime.anchor.IsZero() {
return currentSpendTimeSettlementResult{}, nil
}
intervalTo := now.Truncate(time.Second)
if !session.spendTime.anchor.Before(intervalTo) {
return currentSpendTimeSettlementResult{}, nil
}
owner, err := s.currentSpendTimeOwner()
if err != nil {
return currentSpendTimeSettlementResult{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
defer cancel()
// Labor Day has its own interval union and mail receipts. Settle before
// advancing the shared observation cursor, including logout and 06:00.
if err := s.settleCurrentLaborDay(ctx, session, session.spendTime.catalog.laborDay, session.spendTime.anchor, intervalTo, false); err != nil {
return currentSpendTimeSettlementResult{}, err
}
definition := session.spendTime.catalog.definition
characterID := strconv.Itoa(int(session.spendTime.characterID))
cursor := session.spendTime.anchor
previousCompleted := session.spendTime.completedStages
changed := false
rewardItems := make([]dnfonlineevent.CommittedItem, 0, currentSpendTimeStageCount)
var snapshot dnfonlineevent.Snapshot
var claimErr error
for cursor.Before(intervalTo) {
nextBoundary := currentSpendTimeNextServiceBoundary(definition, cursor)
segmentEnd := intervalTo
endsAtBoundary := false
if !nextBoundary.After(segmentEnd) {
segmentEnd = nextBoundary
endsAtBoundary = true
}
result, err := owner.Observe(ctx, dnfonlineevent.ObserveCommand{
AccountID: session.spendTime.accountID,
CharacterID: characterID,
Definition: definition,
IntervalFrom: cursor,
IntervalTo: segmentEnd,
})
if err != nil {
return currentSpendTimeSettlementResult{}, err
}
changed = changed || result.Changed
claimAt := segmentEnd
if endsAtBoundary {
// Claim the just-finished service day before a following Observe or
// Status resets its daily ledger at exactly 06:00.
claimAt = segmentEnd.Add(-time.Nanosecond)
}
var segmentItems []dnfonlineevent.CommittedItem
snapshot, segmentItems, claimErr = s.claimCurrentSpendTimeUnlockedStages(
ctx,
owner,
session.spendTime.accountID,
characterID,
session.spendTime.catalog,
result.Snapshot,
claimAt,
)
rewardItems = append(rewardItems, segmentItems...)
session.spendTime.onlineSeconds = snapshot.OnlineSeconds
session.spendTime.completedStages = currentSpendTimeClaimedStages(definition, snapshot)
if claimErr != nil {
// Do not cross the reset boundary until the old day's unlocked
// rewards are durably settled. Re-observing the retained interval is
// idempotent because the owner stores an interval union.
if !endsAtBoundary {
session.spendTime.anchor = segmentEnd
}
return currentSpendTimeSettlementResult{
changed: changed || previousCompleted != session.spendTime.completedStages || len(rewardItems) != 0,
rewardItems: rewardItems,
claimErr: claimErr,
}, nil
}
session.spendTime.anchor = segmentEnd
cursor = segmentEnd
if endsAtBoundary && !cursor.Before(intervalTo) {
priorDate := snapshot.CalendarDate
snapshot, err = owner.Status(ctx, dnfonlineevent.StatusCommand{
AccountID: session.spendTime.accountID,
CharacterID: characterID,
Definition: definition,
ObservedAt: segmentEnd,
})
if err != nil {
return currentSpendTimeSettlementResult{}, err
}
changed = changed || snapshot.CalendarDate != priorDate
session.spendTime.onlineSeconds = snapshot.OnlineSeconds
session.spendTime.completedStages = currentSpendTimeClaimedStages(definition, snapshot)
}
}
return currentSpendTimeSettlementResult{
changed: changed || previousCompleted != session.spendTime.completedStages || len(rewardItems) != 0,
rewardItems: rewardItems,
claimErr: claimErr,
}, nil
}
func currentSpendTimeNextServiceBoundary(
definition dnfonlineevent.Definition,
at time.Time,
) time.Time {
location := definition.Calendar
if location == nil {
location = dnfonlineevent.ChinaCalendar
}
boundary := dnfonlineevent.DailyBoundary{Hour: currentSpendTimeResetHour}
if definition.Boundary != nil {
boundary = *definition.Boundary
}
local := at.In(location)
next := time.Date(
local.Year(),
local.Month(),
local.Day(),
boundary.Hour,
boundary.Minute,
boundary.Second,
0,
location,
)
if !at.Before(next) {
next = next.AddDate(0, 0, 1)
}
return next
}
func (s *Service) stopCurrentSpendTimeClock(session *gameSession, source string) {
if s == nil || session == nil {
return
}
session.spendTime.mu.Lock()
if session.spendTime.characterID == 0 || session.spendTime.catalog == nil {
session.spendTime.mu.Unlock()
return
}
settlement, settleErr := s.settleCurrentSpendTimeClockLocked(session, s.gameplayNow())
characterID := session.spendTime.characterID
timerName := session.spendTime.timerName
session.spendTime.generation++
session.spendTime.characterID = 0
session.spendTime.characterGeneration = 0
session.spendTime.initializedCharacterGeneration = 0
session.spendTime.accountID = ""
session.spendTime.anchor = time.Time{}
session.spendTime.timerName = ""
session.spendTime.onlineSeconds = 0
session.spendTime.completedStages = 0
session.spendTime.catalog = nil
session.spendTime.mu.Unlock()
if timerName != "" {
if queue := s.ensureGameplayTimeQueue(); queue != nil {
queue.Cancel(timerName)
}
}
if settleErr != nil {
s.logGameEvent(session, "game-spend-time-stop-settlement-failed",
"char_id", characterID,
"source", source,
"error", settleErr)
}
if settlement.claimErr != nil {
s.logGameEvent(session, "game-spend-time-stop-auto-award-deferred",
"char_id", characterID,
"source", source,
"error", settlement.claimErr)
}
}