From 47a48fb311ba065d77c861d995cabaf60e29796c Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 6 Jun 2026 21:24:54 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20BangDream=20?= =?UTF-8?q?=E6=A1=A3=E7=BA=BF=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ot-bangdream-tsugu-global-refactor-plan.md | 8 + .../tsugu/command-renderers/cutoff-all.ts | 13 +- .../tsugu/models/cn-event-estimate-policy.ts | 269 ++++++++++++++++++ .../bangDream/tsugu/models/cutoff-policy.ts | 212 ++++++++++++++ .../plugins/bangDream/tsugu/models/cutoff.ts | 114 +++----- .../plugins/bangDream/tsugu/models/event.ts | 64 ++--- .../bangDream/tsugu/models/server-policy.ts | 80 ++++++ .../tsugu/render-blocks/list-time.ts | 154 ++-------- .../plugins/bangDream/tsugu/policy.spec.ts | 160 +++++++++++ 9 files changed, 817 insertions(+), 257 deletions(-) create mode 100644 src/qqbot/plugins/bangDream/tsugu/models/cn-event-estimate-policy.ts create mode 100644 src/qqbot/plugins/bangDream/tsugu/models/cutoff-policy.ts create mode 100644 src/qqbot/plugins/bangDream/tsugu/models/server-policy.ts create mode 100644 test/qqbot/plugins/bangDream/tsugu/policy.spec.ts diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index da88aa4..45273ec 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -364,6 +364,14 @@ export interface TsuguHook { - `models/cutoff.ts` 的请求、预测、展示规则分层。 - policy 均有纯函数测试。 +当前进度: + +- 已新增 `models/server-policy.ts`,收口服务器 UTC 偏移、服务器时区 Date 转换、时间戳规范化和档线日增 checkpoint 服务器规则;`render-blocks/list-time.ts` 保留兼容入口但不再维护时区 switch。 +- 已新增 `models/cn-event-estimate-policy.ts`,把国服预估起始活动、跳过活动和无邦日规则从 `list-time.ts` 移出,提供 `calculateCnEventEstimateStartAt` 纯函数测试入口;`list-time.ts` 和 `event.ts` 改走 policy,不再让 Event 排序依赖渲染层预估实现。 +- 已新增 `models/cutoff-policy.ts`,收口档位列表、档位支持判断、国服缺失活动时间预估、档线活动状态、预测窗口、日增天数和最近同类型活动选择规则;`models/cutoff.ts` 和 `cutoff-all.ts` 已接入。 +- 已新增 `test/qqbot/plugins/bangDream/tsugu/policy.spec.ts`,覆盖服务器时区、国服预估纯计算、档位判断、状态/预测窗口、日增 checkpoint、活动天数和最近活动选择;policy 单测 mock `main-data-store`,避免纯测试启动主数据定时器。 +- 已生成 `phase6-policy-event-50.jpg`、`phase6-policy-cutoff-detail-100-50-cn.jpg`、`phase6-policy-cutoff-recent-100-50-cn.jpg`,验证查活动、单档线和历史档线图片输出非空。 + ### Phase 7:Facade 和 hook 接入 目标:Nest 边界稳定,Tsugu 内部可观测。 diff --git a/src/qqbot/plugins/bangDream/tsugu/command-renderers/cutoff-all.ts b/src/qqbot/plugins/bangDream/tsugu/command-renderers/cutoff-all.ts index 610cbf3..fc72d11 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/cutoff-all.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/cutoff-all.ts @@ -1,5 +1,8 @@ import { Event } from '@/qqbot/plugins/bangDream/tsugu/models/event'; -import { drawList, line } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame'; +import { + drawList, + line, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame'; import { drawDataBlock } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/data-block'; import { Image, Canvas } from 'skia-canvas'; import { formatTime } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-time'; @@ -8,13 +11,11 @@ import { drawTitle } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/title'; import { outputEasyImages } from '@/qqbot/plugins/bangDream/tsugu/canvas/output'; import { Cutoff } from '@/qqbot/plugins/bangDream/tsugu/models/cutoff'; import { drawCutoffChart } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart'; -import { - serverNameFullList, - tierListOfServer, -} from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; +import { serverNameFullList } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { drawEventDataBlock } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/detail-blocks'; import { statusName } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { BangDreamEventStatus } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; +import { getCutoffTierList } from '@/qqbot/plugins/bangDream/tsugu/models/cutoff-policy'; /** * 在QQBot 图片视图层中绘制档线全部。 @@ -43,7 +44,7 @@ export async function drawCutoffAll( const list: Array = []; //初始化档线列表 - const tierList = tierListOfServer[Server[mainServer]]; + const tierList = getCutoffTierList(mainServer); const cutoffList: Array = []; for (const i in tierList) { const tempCutoff = new Cutoff(eventId, mainServer, tierList[i]); diff --git a/src/qqbot/plugins/bangDream/tsugu/models/cn-event-estimate-policy.ts b/src/qqbot/plugins/bangDream/tsugu/models/cn-event-estimate-policy.ts new file mode 100644 index 0000000..c064801 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/cn-event-estimate-policy.ts @@ -0,0 +1,269 @@ +import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; +import { stringToNumberArray } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; +import { BangDreamServerId as Server } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-protocol'; +import { + BANGDREAM_CN_BLOCKED_EVENT_IDS, + BANGDREAM_CN_ESTIMATE_START_EVENT_ID, + BANGDREAM_DEFAULT_NO_BANG_DAYS, +} from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const ACTIVE_EVENT_LOOKBACK_MS = DAY_MS; + +export interface BangDreamEventSchedule { + endAt: Array; + startAt: Array; +} + +export interface BangDreamEventTimeLike extends BangDreamEventSchedule { + eventId: number; +} + +export interface CnEventEstimatePolicyOptions { + blockedEventIds?: readonly number[]; + defaultNoBangDays?: number; + estimateStartEventId?: number; +} + +export interface CnEventEstimateCalculationContext { + currentEvent: BangDreamEventTimeLike; + eventId: number; + getSchedule: (eventId: number) => BangDreamEventSchedule | null; + options?: CnEventEstimatePolicyOptions; + presentJpEventId: number; +} + +/** + * 读取活动时间表,避免国服预估逻辑依赖 Event class。 + * + * @param eventId - 活动 ID。 + * @returns 活动时间表。 + */ +export function getBangDreamEventSchedule( + eventId: number, +): BangDreamEventSchedule | null { + const eventData = bangDreamMainDataRepository.getEntity>( + 'events', + eventId, + ); + if (!eventData) return null; + return { + endAt: stringToNumberArray(eventData.endAt), + startAt: stringToNumberArray(eventData.startAt), + }; +} + +/** + * 获取某服务器当前或最近结束的活动 ID。 + * + * @param server - 目标服务器。 + * @param time - 查询时间,默认当前时间。 + * @returns 活动 ID。 + */ +export function getPresentBangDreamEventId( + server: number, + time = Date.now(), +): number | null { + const eventIds = bangDreamMainDataRepository.getNumericIds('events'); + const activeEventIds: number[] = []; + let latestEndedEventId: number | null = null; + let latestEndedAt = 0; + + for (const eventId of eventIds) { + const schedule = getBangDreamEventSchedule(eventId); + const startAt = schedule?.startAt[server]; + const endAt = schedule?.endAt[server]; + if (startAt == null || endAt == null) continue; + + if (startAt - ACTIVE_EVENT_LOOKBACK_MS <= time && endAt >= time) { + activeEventIds.push(eventId); + continue; + } + if (endAt <= time && endAt > latestEndedAt) { + latestEndedAt = endAt; + latestEndedEventId = eventId; + } + } + + if (activeEventIds.length > 0) { + return activeEventIds[activeEventIds.length - 1]; + } + return latestEndedEventId; +} + +/** + * 计算活动跨越的自然日数量,首尾日都计入。 + * + * @param startTs - 开始时间。 + * @param endTs - 结束时间。 + * @returns 自然日数量。 + */ +export function getBangDreamOccupiedDays( + startTs: number, + endTs: number, +): number { + const start = new Date(startTs); + const end = new Date(endTs); + const startDay = new Date( + start.getFullYear(), + start.getMonth(), + start.getDate(), + ); + const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate()); + return Math.floor((endDay.getTime() - startDay.getTime()) / DAY_MS) + 1; +} + +/** + * 纯函数计算国服预估开放时间。 + * + * @param context - 预估所需活动上下文。 + * @returns 预估开始时间。 + */ +export function calculateCnEventEstimateStartAt({ + currentEvent, + eventId, + getSchedule, + options, + presentJpEventId, +}: CnEventEstimateCalculationContext): number | null { + const policyOptions = { + blockedEventIds: BANGDREAM_CN_BLOCKED_EVENT_IDS, + defaultNoBangDays: BANGDREAM_DEFAULT_NO_BANG_DAYS, + estimateStartEventId: BANGDREAM_CN_ESTIMATE_START_EVENT_ID, + ...options, + }; + const currentStartCn = currentEvent.startAt[Server.cn]; + const currentEndCn = currentEvent.endAt[Server.cn]; + const currentStartJp = currentEvent.startAt[Server.jp]; + const currentEndJp = currentEvent.endAt[Server.jp]; + if ( + currentStartCn == null || + currentEndCn == null || + currentStartJp == null || + currentEndJp == null + ) { + return null; + } + + const eventLenOffset = + getBangDreamOccupiedDays(currentStartCn, currentEndCn) - + getBangDreamOccupiedDays(currentStartJp, currentEndJp); + const currentEventOffset = calculateCurrentEventOffset({ + currentEvent, + eventLenOffset, + getSchedule, + policyOptions, + presentJpEventId, + }); + if (currentEventOffset == null) return null; + + let probableTimeOffset = currentStartCn; + for (let i = policyOptions.estimateStartEventId; i < presentJpEventId; i++) { + const schedule = getSchedule(i); + if (!schedule) continue; + if (!schedule.startAt[Server.cn]) { + const nextSchedule = getSchedule(i + 1); + if (!nextSchedule) continue; + probableTimeOffset += + (getBangDreamOccupiedDays( + schedule.startAt[Server.jp], + nextSchedule.startAt[Server.jp], + ) - + 1) * + DAY_MS; + } + if (i + 1 === eventId) return probableTimeOffset + currentEventOffset; + if (policyOptions.blockedEventIds.includes(i + 1)) continue; + } + + const presentJpSchedule = getSchedule(presentJpEventId); + if ( + !presentJpSchedule || + presentJpSchedule.startAt[Server.jp] == null || + presentJpSchedule.endAt[Server.jp] == null + ) { + return null; + } + const presentJpEventLength = + (getBangDreamOccupiedDays( + presentJpSchedule.startAt[Server.jp], + presentJpSchedule.endAt[Server.jp], + ) + + policyOptions.defaultNoBangDays) * + DAY_MS; + return probableTimeOffset + currentEventOffset + presentJpEventLength; +} + +/** + * 按当前主数据计算国服预估开放时间。 + * + * @param eventId - 目标活动 ID。 + * @param currentEvent - 国服当前活动。 + * @returns 预估开始时间。 + */ +export function estimateCnEventStartAt( + eventId: number, + currentEvent: BangDreamEventTimeLike | null, +): number | null { + if (!currentEvent) return null; + const presentJpEventId = getPresentBangDreamEventId(Server.jp); + if (presentJpEventId == null) return null; + return calculateCnEventEstimateStartAt({ + currentEvent, + eventId, + getSchedule: getBangDreamEventSchedule, + presentJpEventId, + }); +} + +function calculateCurrentEventOffset({ + currentEvent, + eventLenOffset, + getSchedule, + policyOptions, + presentJpEventId, +}: { + currentEvent: BangDreamEventTimeLike; + eventLenOffset: number; + getSchedule: (eventId: number) => BangDreamEventSchedule | null; + policyOptions: Required; + presentJpEventId: number; +}) { + const currentEventId = currentEvent.eventId; + if (currentEventId < presentJpEventId) { + const nextSchedule = getSchedule(currentEventId + 1); + if (!nextSchedule || nextSchedule.startAt[Server.jp] == null) return null; + return ( + (getBangDreamOccupiedDays( + currentEvent.startAt[Server.jp], + nextSchedule.startAt[Server.jp], + ) - + 1 + + eventLenOffset) * + DAY_MS + ); + } + if (currentEventId === presentJpEventId) { + const previousSchedule = getSchedule(currentEventId - 1); + if (!previousSchedule || previousSchedule.endAt[Server.jp] == null) { + return null; + } + return ( + (getBangDreamOccupiedDays( + previousSchedule.endAt[Server.jp], + currentEvent.endAt[Server.jp], + ) - + 1 + + eventLenOffset) * + DAY_MS + ); + } + return ( + (getBangDreamOccupiedDays( + currentEvent.startAt[Server.cn], + currentEvent.endAt[Server.cn], + ) + + policyOptions.defaultNoBangDays) * + DAY_MS + ); +} diff --git a/src/qqbot/plugins/bangDream/tsugu/models/cutoff-policy.ts b/src/qqbot/plugins/bangDream/tsugu/models/cutoff-policy.ts new file mode 100644 index 0000000..e311c6a --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/cutoff-policy.ts @@ -0,0 +1,212 @@ +import { + BangDreamEventStatus, + BangDreamServerId as Server, +} from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-protocol'; +import { + estimateCnEventStartAt, + type BangDreamEventTimeLike, +} from '@/qqbot/plugins/bangDream/tsugu/models/cn-event-estimate-policy'; +import { + getBangDreamDateByServerTimezone, + getBangDreamServerUtcOffset, + isBangDreamDailyCheckpoint, + normalizeBangDreamTimestamp, +} from '@/qqbot/plugins/bangDream/tsugu/models/server-policy'; +import { BANGDREAM_TIER_LIST_BY_SERVER } from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options'; + +const FIRST_DAY_END_HOUR = 4; +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface CutoffEventScheduleInput { + currentEvent?: BangDreamEventTimeLike | null; + endAt: Array; + eventId: number; + server: number; + startAt: Array; +} + +export interface CutoffRecentEventCandidate { + eventId: number; + eventType: string; + startAt: Array; +} + +export interface CutoffRecentEventSelectionOptions { + candidates: CutoffRecentEventCandidate[]; + count: number; + event: CutoffRecentEventCandidate; + sameType?: boolean; + server: number; +} + +/** + * 获取指定服务器支持的档位列表。 + * + * @param server - 目标服务器。 + * @returns 档位列表。 + */ +export function getCutoffTierList(server: number): readonly number[] { + return BANGDREAM_TIER_LIST_BY_SERVER[Server[server]] ?? []; +} + +/** + * 判断服务器是否支持指定档位。 + * + * @param server - 目标服务器。 + * @param tier - 目标档位。 + * @returns 是否支持。 + */ +export function isCutoffTierSupported(server: number, tier: number): boolean { + return getCutoffTierList(server).includes(tier); +} + +/** + * 解析档线活动时间,国服缺失时使用预估开始时间。 + * + * @param input - 活动时间输入。 + * @returns 档线可用的开始与结束时间。 + */ +export function resolveCutoffEventSchedule({ + currentEvent, + endAt, + eventId, + server, + startAt, +}: CutoffEventScheduleInput): { endAt: number | null; startAt: number | null } { + const directStartAt = startAt[server]; + const directEndAt = endAt[server]; + if (directStartAt || server !== Server.cn) { + return { endAt: directEndAt, startAt: directStartAt }; + } + + const estimatedStartAt = estimateCnEventStartAt(eventId, currentEvent); + const jpStartAt = startAt[Server.jp]; + const jpEndAt = endAt[Server.jp]; + if (estimatedStartAt == null || jpStartAt == null || jpEndAt == null) { + return { endAt: directEndAt, startAt: directStartAt }; + } + return { + endAt: estimatedStartAt + (jpEndAt - jpStartAt), + startAt: estimatedStartAt, + }; +} + +/** + * 计算活动档线状态。 + * + * @param startAt - 开始时间。 + * @param endAt - 结束时间。 + * @param now - 当前时间。 + * @returns 活动状态。 + */ +export function getCutoffEventStatus( + startAt: number | null, + endAt: number | null, + now = Date.now(), +): BangDreamEventStatus { + if (startAt == null || endAt == null) return BangDreamEventStatus.ended; + if (now < startAt) return BangDreamEventStatus.notStart; + if (now > endAt) return BangDreamEventStatus.ended; + return BangDreamEventStatus.inProgress; +} + +/** + * 获取预测窗口,统一使用档线对象已解析的时间。 + * + * @param startAt - 开始时间。 + * @param endAt - 结束时间。 + * @returns 秒级预测窗口。 + */ +export function getCutoffPredictionWindow(startAt: number, endAt: number) { + return { + endTs: Math.floor(endAt / 1000), + startTs: Math.floor(startAt / 1000), + }; +} + +/** + * 计算时间点位于活动第几天。 + * + * @param server - 目标服务器。 + * @param eventStartAt - 活动开始时间。 + * @param time - 目标时间。 + * @returns 活动天数索引。 + */ +export function getCutoffDayIndex( + server: number, + eventStartAt: number, + time: number | string, +) { + if (!eventStartAt) return 0; + const offsetMs = getBangDreamServerUtcOffset(server) * 60 * 60 * 1000; + const eventStartAtTime = normalizeBangDreamTimestamp(eventStartAt); + const timestamp = normalizeBangDreamTimestamp(time); + const serverStartTime = eventStartAtTime + offsetMs; + const startDate = new Date(serverStartTime); + const firstDayEndServerTime = + serverStartTime + + (DAY_MS + + FIRST_DAY_END_HOUR * 60 * 60 * 1000 - + startDate.getUTCHours() * 60 * 60 * 1000 - + startDate.getUTCMinutes() * 60 * 1000 - + startDate.getUTCSeconds() * 1000 - + startDate.getUTCMilliseconds()); + const firstDayEndTime = firstDayEndServerTime - offsetMs; + if (timestamp < firstDayEndTime) return 0; + return Math.ceil((timestamp - firstDayEndTime) / DAY_MS); +} + +/** + * 判断 Date 是否是档线日增 checkpoint。 + * + * @param server - 目标服务器。 + * @param date - 已按服务器时区转换后的 Date。 + * @returns 是否为 checkpoint。 + */ +export function isCutoffDailyCheckpoint(server: number, date: Date): boolean { + return isBangDreamDailyCheckpoint(server, date); +} + +/** + * 将时间戳转换为服务器时区 Date。 + * + * @param time - 秒级或毫秒级时间戳。 + * @param server - 目标服务器。 + * @returns 服务器时区 Date。 + */ +export function getCutoffDateByServerTimezone( + time: number | string, + server: number, +): Date { + return getBangDreamDateByServerTimezone(time, server); +} + +/** + * 选择用于档线对比的最近活动 ID。 + * + * @param options - 活动候选和过滤条件。 + * @returns 最近活动 ID 列表。 + */ +export function selectRecentCutoffEventIds({ + candidates, + count, + event, + sameType = false, + server, +}: CutoffRecentEventSelectionOptions): number[] { + const eventStartAt = event.startAt[server]; + if (eventStartAt == null) return []; + + const matched = [...candidates] + .filter((candidate) => candidate.startAt[server] != null) + .sort((a, b) => b.startAt[server] - a.startAt[server]) + .filter((candidate) => { + if (sameType && candidate.eventType !== event.eventType) return false; + return candidate.startAt[server] <= eventStartAt; + }) + .sort((a, b) => a.startAt[server] - b.startAt[server]); + + return matched + .slice(Math.max(matched.length - count, 0)) + .map((item) => item.eventId); +} diff --git a/src/qqbot/plugins/bangDream/tsugu/models/cutoff.ts b/src/qqbot/plugins/bangDream/tsugu/models/cutoff.ts index 4186dd1..d3a62ca 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/cutoff.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/cutoff.ts @@ -6,7 +6,6 @@ import { preferHhwxSource, reportDataSourceProblem, clearDataSourceProblem, - tierListOfServer, } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { @@ -16,11 +15,14 @@ import { import { predict } from '@/qqbot/plugins/bangDream/tsugu/calculations/cutoff-predictor'; import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; import { - normalizeTimestamp, - getDateByServerTimezone, - getServerUtcOffset, - getProbableTimeDifference, -} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-time'; + getCutoffDateByServerTimezone, + getCutoffDayIndex, + getCutoffEventStatus, + getCutoffPredictionWindow, + isCutoffDailyCheckpoint, + isCutoffTierSupported, + resolveCutoffEventSchedule, +} from '@/qqbot/plugins/bangDream/tsugu/models/cutoff-policy'; import { BangDreamEventStatus } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; export class Cutoff { @@ -58,7 +60,7 @@ export class Cutoff { this.eventId = eventId; this.server = server; //如果该档线不在该服的档线列表中,直接返回 - if (!tierListOfServer[Server[server]].includes(tier)) { + if (!isCutoffTierSupported(server, tier)) { this.isExist = false; return; } @@ -67,29 +69,17 @@ export class Cutoff { // this.startAt = event.startAt[server] // this.endAt = event.endAt[server] // 当该活动在服务器上尚未存在时,使用预测的时间去推断startAt以及endAt,以解决部分情况下卡死及档线状态不对的问题 - this.startAt = - event.startAt[server] || server != Server.cn - ? event.startAt[server] - : getProbableTimeDifference(this.eventId, getPresentEvent(this.server)); - this.endAt = - event.endAt[server] || server != Server.cn - ? event.endAt[server] - : getProbableTimeDifference( - this.eventId, - getPresentEvent(this.server), - ) + - (event.endAt[Server.jp] - event.startAt[Server.jp]); - const tempEvent = new Event(this.eventId); + const schedule = resolveCutoffEventSchedule({ + currentEvent: getPresentEvent(this.server), + endAt: event.endAt, + eventId: this.eventId, + server, + startAt: event.startAt, + }); + this.startAt = schedule.startAt; + this.endAt = schedule.endAt; this.currentGetDataTime = new Date().getTime(); - //状态 - const time = new Date().getTime(); - if (time < tempEvent.startAt[this.server]) { - this.status = BangDreamEventStatus.notStart; - } else if (time > tempEvent.endAt[this.server]) { - this.status = BangDreamEventStatus.ended; - } else { - this.status = BangDreamEventStatus.inProgress; - } + this.status = getCutoffEventStatus(this.startAt, this.endAt); } /** * 在 Cutoff 模型中获取最终 Tracker 数据源。 @@ -245,9 +235,10 @@ export class Cutoff { this.latestCutoff = this.cutoffs[this.cutoffs.length - 1]; } //rate - const rateDataList = bangDreamMainDataRepository.getValue< - Array<{ server: number; type: string; tier: number; rate: number }> - >('rates'); + const rateDataList = + bangDreamMainDataRepository.getValue< + Array<{ server: number; type: string; tier: number; rate: number }> + >('rates'); const rateData = rateDataList.find((element) => { return ( element.server == this.server && @@ -294,11 +285,7 @@ export class Cutoff { * @returns 计算后的数值。 */ getPredictionWindow(): { startTs: number; endTs: number } { - const event = new Event(this.eventId); - return { - startTs: Math.floor(event.startAt[this.server] / 1000), - endTs: Math.floor(event.endAt[this.server] / 1000), - }; + return getCutoffPredictionWindow(this.startAt, this.endAt); } /** * 在 Cutoff 模型中获取档线列表InSeconds。 @@ -342,36 +329,7 @@ export class Cutoff { * @param ts - ts参数。 */ getDaysOfEvent(ts: number) { - if (!this.startAt) return 0; - const offsetMs = getServerUtcOffset(this.server) * 60 * 60 * 1000; - const eventStartAtTime = normalizeTimestamp(this.startAt); - const timestamp = normalizeTimestamp(ts); - - const serverStartTime = eventStartAtTime + offsetMs; - - const startDate = new Date(serverStartTime); - - const hour = startDate.getUTCHours(); - const minute = startDate.getUTCMinutes(); - const second = startDate.getUTCSeconds(); - const millisecond = startDate.getUTCMilliseconds(); - - const firstDayEndServerTime = - serverStartTime + - (86400000 + - 4 * 60 * 60 * 1000 - - hour * 60 * 60 * 1000 - - minute * 60 * 1000 - - second * 1000 - - millisecond); - - const firstDayEndTime = firstDayEndServerTime - offsetMs; - - if (timestamp < firstDayEndTime) { - return 0; - } else { - return Math.ceil((timestamp - firstDayEndTime) / 86400000); - } + return getCutoffDayIndex(this.server, this.startAt, ts); } /** * 在 Cutoff 模型中判断日增Checkpoint。 @@ -380,13 +338,7 @@ export class Cutoff { * @returns 判断结果。 */ isDailyCheckpoint(date: Date): boolean { - return ( - (this.server == Server.cn || - this.server == Server.tw || - this.server == Server.jp) && - date.getUTCHours() === 3 && - date.getUTCMinutes() === 45 - ); + return isCutoffDailyCheckpoint(this.server, date); } /** * 在 Cutoff 模型中获取日增CheckpointSeries。 @@ -397,8 +349,8 @@ export class Cutoff { const score: number[] = []; const time: number[] = []; for (const c of this.cutoffs) { - const timestamp = normalizeTimestamp(c.time); - const date = getDateByServerTimezone(timestamp, this.server); + const timestamp = c.time; + const date = getCutoffDateByServerTimezone(timestamp, this.server); if (this.isDailyCheckpoint(date)) { score.push(c.ep); time.push(timestamp); @@ -609,11 +561,11 @@ export class Cutoff { let lastCutoffTime = this.cutoffs[this.cutoffs.length - 1].time; // HHWX数据源会在快要结活的时候改为每15分钟抓取一次,因此需要主动规避 let usePrevPoint = false; - const UTCMin = getDateByServerTimezone( + const UTCMin = getCutoffDateByServerTimezone( lastCutoffTime, this.server, ).getUTCMinutes(); - const UTCHour = getDateByServerTimezone( + const UTCHour = getCutoffDateByServerTimezone( lastCutoffTime, this.server, ).getUTCHours(); @@ -630,13 +582,13 @@ export class Cutoff { const time: number[] = []; const scoreCur: number[] = []; const timeCur: number[] = []; - const dateNow = getDateByServerTimezone(lastCutoffTime, this.server); + const dateNow = getCutoffDateByServerTimezone(lastCutoffTime, this.server); const lastestUtcHour = dateNow.getUTCHours(); const lastestUtcMinutes = dateNow.getUTCMinutes(); for (const c of this.cutoffs) { let allowPushFlag = false; - const timestamp = normalizeTimestamp(c.time); + const timestamp = c.time; const d = this.getDaysOfEvent(timestamp); if (d < curEventDays - 2) { continue; @@ -644,7 +596,7 @@ export class Cutoff { if (d > curEventDays - 2) { allowPushFlag = true; } - const date = getDateByServerTimezone(timestamp, this.server); + const date = getCutoffDateByServerTimezone(timestamp, this.server); if ( (this.server == Server.cn || this.server == Server.tw || diff --git a/src/qqbot/plugins/bangDream/tsugu/models/event.ts b/src/qqbot/plugins/bangDream/tsugu/models/event.ts index 75f98b3..3defd1b 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/event.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/event.ts @@ -3,13 +3,15 @@ import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute'; import { Character } from '@/qqbot/plugins/bangDream/tsugu/models/character'; -import { - globalDefaultServer, -} from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; +import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { stringToNumberArray } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; -import { getProbableTimeDifference } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-time'; import { BANGDREAM_EVENT_TYPE_NAME } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; import { eventDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/event-data-repository'; +import { estimateCnEventStartAt } from '@/qqbot/plugins/bangDream/tsugu/models/cn-event-estimate-policy'; +import { + selectRecentCutoffEventIds, + type CutoffRecentEventCandidate, +} from '@/qqbot/plugins/bangDream/tsugu/models/cutoff-policy'; const typeName: Record = BANGDREAM_EVENT_TYPE_NAME; @@ -93,10 +95,9 @@ export class Event { */ constructor(eventId: number) { this.eventId = eventId; - const eventData = bangDreamMainDataRepository.getEntity>( - 'events', - eventId, - ); + const eventData = bangDreamMainDataRepository.getEntity< + Record + >('events', eventId); if (eventData == undefined) { this.isExist = false; return; @@ -387,17 +388,17 @@ export function sortEventList( let prvEvent = null; let nxtEvent = null; if (a.startAt[server] == null) { - prvEvent = getProbableTimeDifference(a.eventId, presentEventCN); + prvEvent = estimateCnEventStartAt(a.eventId, presentEventCN); } else { prvEvent = a.startAt[server]; } if (b.startAt[server] == null) { - nxtEvent = getProbableTimeDifference(b.eventId, presentEventCN); + nxtEvent = estimateCnEventStartAt(b.eventId, presentEventCN); } else { nxtEvent = b.startAt[server]; } if (prvEvent != null || nxtEvent != null) { - return prvEvent - nxtEvent; + return (prvEvent ?? 0) - (nxtEvent ?? 0); } } continue; @@ -425,31 +426,18 @@ export function getRecentEventListByEventAndServer( sameType: boolean = false, ) { const eventIdList = bangDreamMainDataRepository.getNumericIds('events'); - //对活动列表进行排序,从新到旧 - eventIdList.sort((a, b) => { - const eventA = new Event(a); - const eventB = new Event(b); - if (eventA.startAt[server] == null || eventB.startAt[server] == null) { - return 0; - } - return eventB.startAt[server] - eventA.startAt[server]; - }); - const tempEventList: Array = []; - for (let i = 0; i < eventIdList.length; i++) { - const tempEvent = new Event(eventIdList[i]); - if (tempEvent.startAt[server] != null) { - if (sameType && tempEvent.eventType != event.eventType) { - continue; - } - if (tempEvent.startAt[server] > event.startAt[server]) { - continue; - } - tempEventList.push(tempEvent); - } - } - sortEventList(tempEventList, [server]); - return tempEventList.slice( - tempEventList.length - count, - tempEventList.length, - ); + const candidates: CutoffRecentEventCandidate[] = eventIdList + .map((eventId) => new Event(eventId)) + .map((candidate) => ({ + eventId: candidate.eventId, + eventType: candidate.eventType, + startAt: candidate.startAt, + })); + return selectRecentCutoffEventIds({ + candidates, + count, + event, + sameType, + server, + }).map((eventId) => new Event(eventId)); } diff --git a/src/qqbot/plugins/bangDream/tsugu/models/server-policy.ts b/src/qqbot/plugins/bangDream/tsugu/models/server-policy.ts new file mode 100644 index 0000000..0eb37bc --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/server-policy.ts @@ -0,0 +1,80 @@ +import { BangDreamServerId as Server } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-protocol'; + +const SERVER_UTC_OFFSET_BY_SERVER: Record = { + [Server.cn]: 8, + [Server.en]: 0, + [Server.jp]: 9, + [Server.kr]: 9, + [Server.tw]: 8, +}; + +const DAILY_CHECKPOINT_SERVERS = new Set([ + Server.cn, + Server.jp, + Server.tw, +]); + +/** + * 规范化上游时间戳为毫秒。 + * + * @param time - 秒级或毫秒级时间戳。 + * @returns 毫秒级时间戳。 + */ +export function normalizeBangDreamTimestamp(time: number | string): number { + const timestamp = Number(time); + return timestamp < 1e12 ? timestamp * 1000 : timestamp; +} + +/** + * 获取服务器对应的 UTC 偏移小时数。 + * + * @param server - 目标服务器。 + * @returns UTC 偏移小时数。 + */ +export function getBangDreamServerUtcOffset(server: number): number { + return SERVER_UTC_OFFSET_BY_SERVER[server] ?? 0; +} + +/** + * 将时间戳转换为目标服务器时区下的 Date。 + * + * @param time - 秒级或毫秒级时间戳。 + * @param server - 目标服务器。 + * @returns 服务器时区 Date。 + */ +export function getBangDreamDateByServerTimezone( + time: number | string, + server: number, +): Date { + const timestamp = normalizeBangDreamTimestamp(time); + const offset = getBangDreamServerUtcOffset(server); + return new Date(timestamp + offset * 60 * 60 * 1000); +} + +/** + * 判断服务器是否存在每日档线 checkpoint。 + * + * @param server - 目标服务器。 + * @returns 是否启用每日 checkpoint。 + */ +export function hasBangDreamDailyCheckpoint(server: number): boolean { + return DAILY_CHECKPOINT_SERVERS.has(server); +} + +/** + * 判断时间是否命中档线日增 checkpoint。 + * + * @param server - 目标服务器。 + * @param date - 已按服务器时区转换后的 Date。 + * @returns 是否命中 checkpoint。 + */ +export function isBangDreamDailyCheckpoint( + server: number, + date: Date, +): boolean { + return ( + hasBangDreamDailyCheckpoint(server) && + date.getUTCHours() === 3 && + date.getUTCMinutes() === 45 + ); +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time.ts index 0cbe74a..3975a6d 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time.ts @@ -6,13 +6,15 @@ import { } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { drawListByServerList } from './list-frame'; import { Canvas } from 'skia-canvas'; -import { Event } from '@/qqbot/plugins/bangDream/tsugu/models/event'; -import mainAPI from '@/qqbot/plugins/bangDream/tsugu/models/main-data-store'; import { - BANGDREAM_CN_BLOCKED_EVENT_IDS, - BANGDREAM_CN_ESTIMATE_START_EVENT_ID, - BANGDREAM_DEFAULT_NO_BANG_DAYS, -} from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; + estimateCnEventStartAt, + type BangDreamEventTimeLike, +} from '@/qqbot/plugins/bangDream/tsugu/models/cn-event-estimate-policy'; +import { + getBangDreamDateByServerTimezone, + getBangDreamServerUtcOffset, + normalizeBangDreamTimestamp, +} from '@/qqbot/plugins/bangDream/tsugu/models/server-policy'; interface TimeInListOptions { key?: string; @@ -35,13 +37,16 @@ export async function drawTimeInList( for (let i = 0; i < content.length; i++) { const element = content[i]; if (element == null) { - if (i == 3 && estimateCNTime) { + if (i == 3 && estimateCNTime && eventId != null) { const currentEvent = getPresentEvent(getServerByName('cn')); - const currentEventId = currentEvent.eventId; - if (eventId > currentEventId) { + const currentEventId = currentEvent?.eventId; + const estimatedStartAt = getProbableTimeDifference( + eventId, + currentEvent, + ); + if (currentEventId != null && eventId > currentEventId) { formattedTimeList.push( - formatTime(getProbableTimeDifference(eventId, currentEvent)) + - ' (预计开放时间)', + formatTime(estimatedStartAt) + ' (预计开放时间)', ); } } @@ -68,91 +73,9 @@ export async function drawTimeInList( */ export function getProbableTimeDifference( eventId: number, - currentEvent: Event, -): number { - const eventsData = mainAPI['events']; - const presentEventJP = getPresentEvent(Server.jp).eventId; // 取得日服最新一期的活动 - let currentEventWithNoBanGDaysTotalOffset = 0; - const currentEventId = currentEvent.eventId; - // 计算当前活动日服与国服的实际持续天数偏移 - const eventLenOffset = - occupiedDays( - new Event(currentEventId).startAt[Server.cn], - new Event(currentEventId).endAt[Server.cn], - ) - - occupiedDays( - new Event(currentEventId).startAt[Server.jp], - new Event(currentEventId).endAt[Server.jp], - ); - // 计算当前正在进行的活动含无邦日当天一共有多少天 - if (currentEventId < presentEventJP) { - currentEventWithNoBanGDaysTotalOffset = - (occupiedDays( - new Event(currentEventId).startAt[Server.jp], - new Event(currentEventId + 1).startAt[Server.jp], - ) - - 1 + - eventLenOffset) * - 24 * - 3600 * - 1000; - } else if (currentEventId == presentEventJP) { - // 如果当前活动与日服并行,则计算无邦日就从上一个endAt到这一个endAt - currentEventWithNoBanGDaysTotalOffset = - (occupiedDays( - new Event(currentEventId - 1).endAt[Server.jp], - new Event(currentEventId).endAt[Server.jp], - ) - - 1 + - eventLenOffset) * - 24 * - 3600 * - 1000; - } else { - // 预防国服可能出现与台服一样存在自有活动的情形,如台服5001。 - currentEventWithNoBanGDaysTotalOffset = - (occupiedDays( - new Event(currentEventId).startAt[Server.cn], - new Event(currentEventId).endAt[Server.cn], - ) + - BANGDREAM_DEFAULT_NO_BANG_DAYS) * - 24 * - 3600 * - 1000; - } - let probableTimeOffset = currentEvent.startAt[Server.cn]; // 等于正在举办活动的StartAt - for (let i = BANGDREAM_CN_ESTIMATE_START_EVENT_ID; i < presentEventJP; i++) { - if (!eventsData[i.toString()]['startAt'][Server.cn]) { - // 对于国服来说当前活动未举办 - // 计算活动相对于日服(含无邦日)的时长。 - probableTimeOffset += - (occupiedDays( - new Event(i).startAt[Server.jp], - new Event(i + 1).startAt[Server.jp], - ) - - 1) * - 24 * - 3600 * - 1000; - } - if (i + 1 == eventId) - return probableTimeOffset + currentEventWithNoBanGDaysTotalOffset; //如果下一个循环是要获取的时间 - if (BANGDREAM_CN_BLOCKED_EVENT_IDS.includes(i + 1)) continue; // 对于国服而言不会举办的活动,跳过 - } - const presentEventJPLen = - (occupiedDays( - new Event(presentEventJP).startAt[Server.jp], - new Event(presentEventJP).endAt[Server.jp], - ) + - BANGDREAM_DEFAULT_NO_BANG_DAYS) * - 24 * - 3600 * - 1000; - return ( - probableTimeOffset + - currentEventWithNoBanGDaysTotalOffset + - presentEventJPLen - ); // 日服321,预测322+ + currentEvent: BangDreamEventTimeLike | null, +): number | null { + return estimateCnEventStartAt(eventId, currentEvent); } /** @@ -311,24 +234,6 @@ export function formatSeconds(value: number) { * @param endTs - endTs参数。 * @returns 计算后的数值。 */ -function occupiedDays(startTs: number, endTs: number): number { - const start = new Date(startTs); - const end = new Date(endTs); - - // 取年月日,忽略时分秒 - const startDay = new Date( - start.getFullYear(), - start.getMonth(), - start.getDate(), - ); - const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate()); - - const msPerDay = 1000 * 60 * 60 * 24; - - // 计算跨越的天数,再加1包含第一天 - return Math.floor((endDay.getTime() - startDay.getTime()) / msPerDay) + 1; -} - /** * 在图片布局层中规范化Timestamp。 * @@ -336,8 +241,7 @@ function occupiedDays(startTs: number, endTs: number): number { * @returns 计算后的数值。 */ export function normalizeTimestamp(time: number | string): number { - const t = Number(time); - return t < 1e12 ? t * 1000 : t; + return normalizeBangDreamTimestamp(time); } /** @@ -347,19 +251,7 @@ export function normalizeTimestamp(time: number | string): number { * @returns 计算后的数值。 */ export function getServerUtcOffset(server: Server): number { - switch (server) { - case Server.cn: - case Server.tw: - return 8; - - case Server.jp: - case Server.kr: - return 9; - - case Server.en: - default: - return 0; - } + return getBangDreamServerUtcOffset(server); } /** @@ -373,7 +265,5 @@ export function getDateByServerTimezone( time: number | string, server: Server, ): Date { - const timestamp = normalizeTimestamp(time); - const offset = getServerUtcOffset(server); - return new Date(timestamp + offset * 60 * 60 * 1000); + return getBangDreamDateByServerTimezone(time, server); } diff --git a/test/qqbot/plugins/bangDream/tsugu/policy.spec.ts b/test/qqbot/plugins/bangDream/tsugu/policy.spec.ts new file mode 100644 index 0000000..1c607cc --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/policy.spec.ts @@ -0,0 +1,160 @@ +jest.mock('@/qqbot/plugins/bangDream/tsugu/models/main-data-store', () => ({ + __esModule: true, + default: {}, +})); + +import { + calculateCnEventEstimateStartAt, + getBangDreamOccupiedDays, + type BangDreamEventSchedule, +} from '@/qqbot/plugins/bangDream/tsugu/models/cn-event-estimate-policy'; +import { + getCutoffDateByServerTimezone, + getCutoffDayIndex, + getCutoffEventStatus, + getCutoffPredictionWindow, + getCutoffTierList, + isCutoffDailyCheckpoint, + isCutoffTierSupported, + selectRecentCutoffEventIds, +} from '@/qqbot/plugins/bangDream/tsugu/models/cutoff-policy'; +import { + getBangDreamServerUtcOffset, + normalizeBangDreamTimestamp, +} from '@/qqbot/plugins/bangDream/tsugu/models/server-policy'; +import { + BangDreamEventStatus, + BangDreamServerId as Server, +} from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-protocol'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('BangDream server policy', () => { + it('normalizes timestamps and applies server UTC offsets', () => { + expect(normalizeBangDreamTimestamp(1_700_000_000)).toBe(1_700_000_000_000); + expect(normalizeBangDreamTimestamp(1_700_000_000_000)).toBe( + 1_700_000_000_000, + ); + expect(getBangDreamServerUtcOffset(Server.cn)).toBe(8); + expect(getBangDreamServerUtcOffset(Server.jp)).toBe(9); + expect(getBangDreamServerUtcOffset(Server.en)).toBe(0); + }); +}); + +describe('BangDream CN event estimate policy', () => { + it('keeps occupied day calculation inclusive', () => { + expect( + getBangDreamOccupiedDays(Date.UTC(2026, 0, 1), Date.UTC(2026, 0, 1)), + ).toBe(1); + expect( + getBangDreamOccupiedDays(Date.UTC(2026, 0, 1), Date.UTC(2026, 0, 3)), + ).toBe(3); + }); + + it('calculates the next missing CN event start from pure schedules', () => { + const schedules: Record = { + 100: { + endAt: [Date.UTC(2026, 0, 5), null, null, Date.UTC(2026, 0, 5), null], + startAt: [Date.UTC(2026, 0, 1), null, null, Date.UTC(2026, 0, 1), null], + }, + 101: { + endAt: [Date.UTC(2026, 0, 10), null, null, null, null], + startAt: [Date.UTC(2026, 0, 6), null, null, null, null], + }, + }; + + const result = calculateCnEventEstimateStartAt({ + currentEvent: { eventId: 100, ...schedules[100] }, + eventId: 101, + getSchedule: (eventId) => schedules[eventId] || null, + options: { + defaultNoBangDays: 1, + estimateStartEventId: 100, + }, + presentJpEventId: 101, + }); + + expect(result).toBe(Date.UTC(2026, 0, 6)); + }); +}); + +describe('BangDream cutoff policy', () => { + it('checks tier support by server', () => { + expect(getCutoffTierList(Server.cn)).toContain(1500); + expect(isCutoffTierSupported(Server.cn, 1500)).toBe(true); + expect(isCutoffTierSupported(Server.jp, 1500)).toBe(false); + }); + + it('resolves event status and prediction windows', () => { + const startAt = Date.UTC(2026, 0, 1); + const endAt = Date.UTC(2026, 0, 5); + + expect(getCutoffEventStatus(startAt, endAt, startAt - 1)).toBe( + BangDreamEventStatus.notStart, + ); + expect(getCutoffEventStatus(startAt, endAt, startAt + 1)).toBe( + BangDreamEventStatus.inProgress, + ); + expect(getCutoffEventStatus(startAt, endAt, endAt + 1)).toBe( + BangDreamEventStatus.ended, + ); + expect(getCutoffPredictionWindow(startAt, endAt)).toEqual({ + endTs: Math.floor(endAt / 1000), + startTs: Math.floor(startAt / 1000), + }); + }); + + it('calculates daily checkpoints and event day index', () => { + const checkpoint = getCutoffDateByServerTimezone( + Date.UTC(2026, 0, 1, 18, 45), + Server.jp, + ); + expect(isCutoffDailyCheckpoint(Server.jp, checkpoint)).toBe(true); + expect(isCutoffDailyCheckpoint(Server.en, checkpoint)).toBe(false); + + const eventStartAt = Date.UTC(2026, 0, 1, 12); + expect(getCutoffDayIndex(Server.jp, eventStartAt, eventStartAt + 1)).toBe( + 0, + ); + expect( + getCutoffDayIndex(Server.jp, eventStartAt, eventStartAt + DAY_MS), + ).toBe(1); + }); + + it('selects recent comparable events by type and server start time', () => { + const candidates = [ + eventCandidate(1, 'story', [Date.UTC(2026, 0, 1)]), + eventCandidate(2, 'festival', [Date.UTC(2026, 0, 5)]), + eventCandidate(3, 'story', [Date.UTC(2026, 0, 10)]), + eventCandidate(4, 'story', [Date.UTC(2026, 0, 15)]), + ]; + + expect( + selectRecentCutoffEventIds({ + candidates, + count: 2, + event: candidates[3], + sameType: true, + server: Server.jp, + }), + ).toEqual([3, 4]); + }); +}); + +function eventCandidate( + eventId: number, + eventType: string, + jpStartAt: number[], +) { + return { + eventId, + eventType, + startAt: [ + jpStartAt[0], + null, + null, + jpStartAt[Server.cn] ?? jpStartAt[0], + null, + ], + }; +}