Files
hanfuit 0ced464168 启动器新增游戏设置页(频道开关+活动开关),窗口标题改名
- 服务端新增活动开关运行时配置与 GM 接口(GET/POST /dnf/gm/activity-switches),
  持久化到 Settings,接入月签到/周签到/PC房↔累计在线的实时下发
- 服务端新增频道开关 GM 接口(channel-switches),目录生成时过滤禁用频道
- channelcatalog/channelinfo 支持按禁用频道 ID 过滤目录与客户端脚本
- 启动器新增「游戏设置」页面:频道开关 Tab + 活动开关 Tab
- native.py/server.py 新增对应转发 API
- 窗口标题/页面 title/侧边栏「90轮回」统一改为「DNF90 灵狐小竹整合单机版」
2026-11-04 20:08:50 +08:00

1069 lines
42 KiB
Go
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
// allowInstantStagesForProbe 是**临时验证开关**:为 true 时把四个阶段的在线
// 时长门槛压到 1/2/3/4 秒,用来确认"达标 → 领取 → 入账/发放"整条链路是否通畅。
// 真实运营门槛是 PVF 里的 [process time seconds] 1800/1800/3600/3600
// (累积 1800/3600/7200/10800 秒)。
//
// 2026-10-12:该开关已用于定位并验证"累计在线领取"的修复(见
// claimCurrentSpendTimeUnlockedStages 里被移除的 nativePCRoom 提前 return)。
// 验证完成,恢复真实门槛。
allowInstantStagesForProbe = false
)
// 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
// closedActivityProjectionGeneration 记录"活动目录已关闭"分支里那批
// 与泡点无关的下发(团本日程 / 月签到 / 周签到 / 回归状态)已经为哪一代角色
// 发过。catalog==nil 时不会设置 catalog 字段,alreadyInitialized 恒为假,
// 而这个函数在场景尾巴、初次进城、重连等多处被调用——没有这个标记,
// 那批下发会重复推送。
closedActivityProjectionGeneration uint64
}
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]
// 注意:item_id=0 不能在这里被容忍。
// 虽然客户端把 0 当作"空奖励位",但本树有两条硬性校验会挡住:
// 1) Definition.Validate(): stage.Gold==0 && len(stage.Items)==0 → "empty/negative reward"
// 2) buildCurrentOnlineEventInfoBodyAt(): len(rewardItemIDs)==0 → "reward count=0"
// 所以"清零奖励同时保留目录"结构上不可行;曾按此方向改过 PVF 并导致
// 活动目录解析失败、选完频道即断线(已实测)。这里保持严格校验。
if duration <= 0 || reward.ItemID <= 0 || uint64(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,
)
}
stageItems := []dnfonlineevent.ItemReward{{
ItemID: reward.ItemID,
Count: reward.Count,
}}
rewardItemIDs = append(rewardItemIDs, uint32(reward.ItemID))
cumulative += uint64(duration)
if cumulative > math.MaxUint32 {
return nil, fmt.Errorf("current spend-time cumulative seconds=%d overflows u32", cumulative)
}
// 仅用于**验证奖励发放链路**的临时开关:把阶段门槛压到 1/2/3/4 秒,
// 使四个阶段几乎立刻可领。Definition.Validate 要求 RequiredSeconds 为正
// 且严格递增,所以用 index+1 而不是 0。
// 验证完成后把 allowInstantStagesForProbe 置回 false 即恢复真实门槛。
requiredSeconds := cumulative
if allowInstantStagesForProbe {
requiredSeconds = uint64(index + 1)
}
definition.Stages = append(definition.Stages, dnfonlineevent.Stage{
ID: fmt.Sprintf("stage-%d", index+1),
RequiredSeconds: requiredSeconds,
Items: stageItems,
})
}
for _, reward := range snapshot.RewardItemsForSum {
if reward.ItemID <= 0 || uint64(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.
//
// 说明:本部署关闭「累计在线得好礼」(event 2347)的做法是**只摘掉它的目录行**,
// 而不是关掉整个活动目录。
//
// 教训(实测两次):把本函数改成直接返回 "活动目录不可用" 虽然能隐藏该 HUD,但
// 客户端的团本日期校验依赖活动目录本身——目录没了,即使团本日程照发,点击超时空
// 仍报「使用日期不准确」。所以目录必须保留,只在 buildCurrentOnlineEventInfoBodyAt
// 里移除 2347 的基础目录行与 active 行。
func (s *Service) currentSpendTimeRuntimeCatalogIfAvailable(ctx context.Context) (*currentSpendTimeRuntimeCatalog, bool, error) {
if s == nil {
return nil, false, nil
}
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")
// 活动目录已关闭。判断这批"与泡点无关的下发"是否已经为当前角色代发过。
session.spendTime.mu.Lock()
alreadyProjected := session.spendTime.characterID == characterID &&
session.spendTime.closedActivityProjectionGeneration == characterGeneration
session.spendTime.mu.Unlock()
if alreadyProjected {
return nil
}
// 关键:团本日程与"泡点"活动目录**无关**,但它们的下发点原本挂在
// sendCurrentSpendTimeProtocolState 里(该函数在 catalog==nil 时开头即返回)。
// 本部署关闭了泡点活动目录,若在此直接 return,三条团本日程
// (181 安徒恩 / 282 卢克 / 2449 超时空)与每日可用性表就一条都不发,
// 客户端因而读不到超时空的日期窗口,点击时报「使用日期不准确」。
// 所以这里先独立推送团本相关投影,再返回。
if err := s.sendCurrentRaidEntryProjections(session, source); err != nil {
return err
}
// 同理:月签到、周签到、回归状态的下发点也只挂在同一个活动状态函数里。
// 本次只关闭「累计在线得好礼」与「全职LV预约得好礼」两个活动,这三项
// 必须继续下发,否则会连带丢失。
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)
}
session.spendTime.mu.Lock()
session.spendTime.characterID = characterID
session.spendTime.closedActivityProjectionGeneration = characterGeneration
session.spendTime.mu.Unlock()
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
}
// sendCurrentRaidEntryProjections 只推送"团本入场"相关的两个投影:
//
// op1206 三条团本周日程(181 安徒恩 / 282 卢克 / 2449 超时空)
// op1303 三行每日可用性表
//
// 它被刻意从"泡点活动目录"里**解耦**出来。原本这两个投影写在
// sendCurrentSpendTimeProtocolState 内部,而该函数在 catalog==nil 时会于开头
// 直接返回——于是本部署一旦关闭泡点活动目录,团本日程就跟着一起消失,客户端
// 读不到超时空的日期窗口,点击超时空时报「使用日期不准确」。
//
// 注意顺序:客户端先消费活动集合与周状态投影,再读每日可用性表,所以这里
// 保持"先 op1206、后 op1303",与原来的相对顺序一致。
func (s *Service) sendCurrentRaidEntryProjections(session *gameSession, source string) error {
if s == nil || session == nil {
return nil
}
if err := s.sendCurrentRaidWeeklySchedules(session, source+"_raid_weekly_schedules"); err != nil {
return err
}
// 客户端在读完周日程后才会评估每日可用性,顺序不能颠倒。
return s.sendCurrentRaidDailyAvailability(session, source+"_raid_daily_availability")
}
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
// 活动开关:pcroom_timepoint 开关决定走「PC 房时间点(228)」还是「累计在线得好礼(2347)」。
// 开关关闭时强制走 2347 分支(即使 etc/pcroomtimepoint.etc 存在)。
usePCRoom := catalog.nativePCRoom != nil && s.ActivitySwitchEnabled(context.Background(), gmActivityPCRoomKey)
if includeFirstProcessDescriptor {
eventInfoBody, err := buildCurrentOnlineEventInfoBodyAt(
catalog.rewardItemIDs,
catalog.sumRewardItemIDs,
catalog.totalStageSeconds,
usePCRoom,
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 usePCRoom {
if err := s.sendCurrentPCRoomTimePoint(session, catalog, 0, -1); err != nil {
return descriptorSent, err
}
} else {
// 累计在线得好礼(2347) 的第一份进度包必须照常推送(见 tick 路径说明)。
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
}
// 骑士马战(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
}
// 【修复 2026-10-12】这里原本是:
//
// if catalog.nativePCRoom != nil {
// _, err := s.settleCurrentPCRoomTimePoint(...)
// return snapshot, nil, err // ← 直接返回,跳过下面的 op1206 领取
// }
//
// 那会让「累计在线得好礼」(event 2347) 的领取路径**被整条跳过**:只要
// etc/pcroomtimepoint.etc 存在(nativePCRoom != nil,本部署就是),四个阶段
// 就永远发不出去,客户端表现为"活动在、点领取没反应"。参考端能领取,说明它
// 同时走两条路径。
//
// op1206 协议侧(buildCurrentOnlineEventInfoBodyAt / sendCurrentSpendTimeProtocolState)
// 本来就同时写 228 与 2347 两套数据,可见原先那个 return 与协议自相矛盾。
// 现在改为:先照常领取 2347 的阶段,最后再顺带结算 228 的泡点(见函数末尾)。
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)...)
}
// 228(泡点/PC Room)的结算放在 2347 阶段领取**之后**,两条路径都要走。
if catalog.nativePCRoom != nil {
if _, pcroomErr := s.settleCurrentPCRoomTimePoint(
ctx, accountID, characterID, catalog, claimedAt, 0, -1,
); pcroomErr != nil {
// 泡点结算失败不能吞掉已经发出去的 2347 阶段奖励,所以只记日志。
s.logGameEvent(nil, "game-spend-time-pcroom-settle-deferred",
"char_id", characterID, "error", pcroomErr)
}
}
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 {
// 累计在线得好礼(2347) 的进度包必须照常推送:客户端靠它拿到
// 在线秒数与已完成阶段数,缺了它「领取」按钮就没有数据可领。
// (此前为"关闭活动"加的门已移除——实测关不掉 HUD,却会让奖励领不了。)
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)
}
}