refactor: 收口 BangDream 档线策略

This commit is contained in:
sunlei 2026-06-06 21:24:54 +08:00
parent 25e39ae7ca
commit 47a48fb311
9 changed files with 817 additions and 257 deletions

View File

@ -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 7Facade 和 hook 接入
目标Nest 边界稳定Tsugu 内部可观测。

View File

@ -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<Image | Canvas> = [];
//初始化档线列表
const tierList = tierListOfServer[Server[mainServer]];
const tierList = getCutoffTierList(mainServer);
const cutoffList: Array<Cutoff> = [];
for (const i in tierList) {
const tempCutoff = new Cutoff(eventId, mainServer, tierList[i]);

View File

@ -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<number | null>;
startAt: Array<number | null>;
}
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<Record<string, any>>(
'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<CnEventEstimatePolicyOptions>;
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
);
}

View File

@ -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<number | null>;
eventId: number;
server: number;
startAt: Array<number | null>;
}
export interface CutoffRecentEventCandidate {
eventId: number;
eventType: string;
startAt: Array<number | null>;
}
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);
}

View File

@ -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,7 +235,8 @@ export class Cutoff {
this.latestCutoff = this.cutoffs[this.cutoffs.length - 1];
}
//rate
const rateDataList = bangDreamMainDataRepository.getValue<
const rateDataList =
bangDreamMainDataRepository.getValue<
Array<{ server: number; type: string; tier: number; rate: number }>
>('rates');
const rateData = rateDataList.find((element) => {
@ -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 ||

View File

@ -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<string, string> = BANGDREAM_EVENT_TYPE_NAME;
@ -93,10 +95,9 @@ export class Event {
*/
constructor(eventId: number) {
this.eventId = eventId;
const eventData = bangDreamMainDataRepository.getEntity<Record<string, any>>(
'events',
eventId,
);
const eventData = bangDreamMainDataRepository.getEntity<
Record<string, any>
>('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<Event> = [];
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));
}

View File

@ -0,0 +1,80 @@
import { BangDreamServerId as Server } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-protocol';
const SERVER_UTC_OFFSET_BY_SERVER: Record<number, number> = {
[Server.cn]: 8,
[Server.en]: 0,
[Server.jp]: 9,
[Server.kr]: 9,
[Server.tw]: 8,
};
const DAILY_CHECKPOINT_SERVERS = new Set<number>([
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
);
}

View File

@ -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);
}

View File

@ -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<number, BangDreamEventSchedule> = {
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,
],
};
}