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

970 lines
36 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}