From 46f6a2465cd6d50c5e1bd85470c668a0770b7631 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 6 Jun 2026 22:50:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20BangDream=20?= =?UTF-8?q?=E6=8A=BD=E5=8D=A1=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 | 3 + .../qqbot-bangdream-renderer.service.ts | 13 +- .../tsugu/command-renderers/event-detail.ts | 3 +- .../tsugu/command-renderers/gacha-simulate.ts | 101 +++---------- .../bangDream/tsugu/models/gacha-policy.ts | 143 ++++++++++++++++++ .../plugins/bangDream/tsugu/models/gacha.ts | 8 +- .../plugins/bangDream/tsugu/policy.spec.ts | 57 +++++++ 7 files changed, 240 insertions(+), 88 deletions(-) create mode 100644 src/qqbot/plugins/bangDream/tsugu/models/gacha-policy.ts diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 41e78dd..cbb8a6f 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -375,6 +375,9 @@ export interface TsuguHook { - 已新增 `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`,验证查活动、单档线和历史档线图片输出非空。 +- 已新增 `models/gacha-policy.ts`,收口抽卡默认次数、上限、十连保底、稀有度概率、卡牌权重、生日/免费/日服常驻卡池过滤规则;`gacha-simulate.ts`、`gacha.ts`、`event-detail.ts` 和 renderer 当前卡池选择已接入。 +- `policy.spec.ts` 已补抽卡策略纯函数测试,覆盖卡池类型分类、抽卡次数上限、十连保底和权重抽取;本地生成 `gacha-policy-simulate-10-259.jpg` 和 `gacha-policy-event-50.jpg`,验证抽卡模拟与活动详情图片输出非空。 +- smoke 脚本和远程调试流程继续按已固化规则执行:图片落盘后清理本次 Node 进程并返回成功;远程命令测试必须按 `operationKey` 查询 `commandId`、传完整命令文本、外层设置 timeout,并在成功/失败分支显式退出。 ### Phase 7:Facade 和 hook 接入 diff --git a/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts b/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts index 83fe43a..0cee085 100644 --- a/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts +++ b/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts @@ -2,9 +2,12 @@ import { Injectable, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DictService } from '@/admin/dict/dict.service'; import { Card } from '../tsugu/models/card'; -import { BangDreamGachaType } from '../tsugu/models/bangdream-protocol'; import { getPresentEvent } from '../tsugu/models/event'; import { Gacha, getPresentGachaList } from '../tsugu/models/gacha'; +import { + BANGDREAM_GACHA_DEFAULT_SPIN_COUNT, + isBirthdayGachaType, +} from '../tsugu/models/gacha-policy'; import { Server } from '../tsugu/models/server'; import { Song } from '../tsugu/models/song'; import mainAPI, { waitForMainDataReady } from '../tsugu/models/main-data-store'; @@ -344,7 +347,9 @@ export class QqbotBangDreamRendererService { const tokens = this.getTokens(input); const mainServer = this.pickMainServer(input, tokens); const times = - this.optionalNumber(input.times) ?? this.firstNumber(tokens) ?? 10; + this.optionalNumber(input.times) ?? + this.firstNumber(tokens) ?? + BANGDREAM_GACHA_DEFAULT_SPIN_COUNT; const gachaId = this.optionalNumber(input.gachaId) ?? this.secondNumber(tokens); const options = this.getRenderOptions({ ...input, mainServer }); @@ -360,9 +365,7 @@ export class QqbotBangDreamRendererService { private async pickPresentGacha(mainServer: Server) { const gachaList = await getPresentGachaList(mainServer); - const gacha = gachaList.find( - (item) => item.type !== BangDreamGachaType.birthday, - ); + const gacha = gachaList.find((item) => !isBirthdayGachaType(item.type)); if (!gacha) throw new Error('错误: 该服务器没有正在进行的卡池'); return gacha; } diff --git a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-detail.ts b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-detail.ts index 319285e..fec497b 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-detail.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-detail.ts @@ -30,6 +30,7 @@ import { globalDefaultServer, serverNameFullList, } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; +import { isBirthdayGachaType } from '@/qqbot/plugins/bangDream/tsugu/models/gacha-policy'; import { drawSongInList, drawSongListInList, @@ -500,7 +501,7 @@ export async function getEventGachaAndCardList( const gachaCardIdList: number[] = []; for (let i = 0; i < gachaList.length; i++) { const tempGacha = gachaList[i]; - if (tempGacha.type == 'birthday') { + if (isBirthdayGachaType(tempGacha.type)) { continue; } await tempGacha.initFull(!useCache); diff --git a/src/qqbot/plugins/bangDream/tsugu/command-renderers/gacha-simulate.ts b/src/qqbot/plugins/bangDream/tsugu/command-renderers/gacha-simulate.ts index ca97c8c..f4b30c7 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/gacha-simulate.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/gacha-simulate.ts @@ -12,6 +12,14 @@ import { getServerByPriority } from '@/qqbot/plugins/bangDream/tsugu/models/serv import { drawDataBlock } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/data-block'; import { resizeImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { drawGachaDataBlock } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/detail-blocks'; +import { + applyGachaGuaranteedRarity, + BANGDREAM_GACHA_DEFAULT_SPIN_COUNT, + BANGDREAM_GACHA_MAX_SPIN_COUNT, + isGachaSpinCountTooLarge, + pickGachaCardIdByWeight, + pickGachaRarityByRate, +} from '@/qqbot/plugins/bangDream/tsugu/models/gacha-policy'; const maxWidth = 230 * 5; /** @@ -24,11 +32,11 @@ const maxWidth = 230 * 5; */ export async function drawRandomGacha( gacha: Gacha, - times: number = 10, + times: number = BANGDREAM_GACHA_DEFAULT_SPIN_COUNT, compress: boolean, ): Promise> { - if (times > 10000) { - return ['错误: 抽卡次数过多, 请不要超过10000次']; + if (isGachaSpinCountTooLarge(times)) { + return [`错误: 抽卡次数过多, 请不要超过${BANGDREAM_GACHA_MAX_SPIN_COUNT}次`]; } if (!gacha.isExist) { return ['错误: 该卡池不存在']; @@ -173,86 +181,19 @@ function getGachaRandomCard(gacha: Gacha, times: number) { const gachaDetails = gacha.details[server]; const gachaRates = gacha.rates[server]; //计算稀有度 - let cardRarity = parseInt(getRandomRarity(gachaRates)); - if (times % 10 == 9 && cardRarity < 3) { - //第十发保底 - cardRarity = 3; - } + const cardRarity = applyGachaGuaranteedRarity( + times, + parseInt(`${pickGachaRarityByRate(gachaRates)}`), + ); const rarityTotalWeight = gachaRates[cardRarity].weightTotal; - const cardId = getCardByWeight(cardRarity, rarityTotalWeight, gachaDetails); - const card = new Card(parseInt(cardId)); + const cardId = pickGachaCardIdByWeight( + cardRarity, + rarityTotalWeight, + gachaDetails, + ); + const card = new Card(parseInt(`${cardId}`)); return card; } -/** - * 在QQBot 图片视图层中处理random数字。 - * - * @param max - max参数。 - */ -function randomNumber(max: number) { - return Math.random() * max; -} -//根据权重随机抽取一张卡牌 -/** - * 在QQBot 图片视图层中获取卡牌ByWeight。 - * - * @param Rarity - Rarity参数。 - * @param totalWeight - totalWeight参数。 - * @param cardWeightList - 卡牌Weight列表参数。 - */ -function getCardByWeight( - Rarity: number, - totalWeight: number, - cardWeightList: { [cardId: string]: { rarityIndex: number; weight: number } }, -) { - const randomNum = randomNumber(totalWeight); - let currentWeight = 0; - for (const key in cardWeightList) { - if (cardWeightList.hasOwnProperty(key)) { - if (cardWeightList[key].rarityIndex !== Rarity) { - continue; - } - const card = cardWeightList[key]; - currentWeight += card.weight; - if (randomNum < currentWeight) { - return key; - } - } - } -} - -//根据权重随机抽取稀有度 -/** - * 在QQBot 图片视图层中获取RandomRarity。 - * - * @param rarities - rarities参数。 - * @returns 格式化后的文本。 - */ -function getRandomRarity(rarities: { - [rarity: string]: { rate: number; weightTotal: number }; -}): string | null { - let totalRate = 0; - // 计算所有几率的总和 - for (const key in rarities) { - if (rarities.hasOwnProperty(key)) { - totalRate += rarities[key].rate; - } - } - - // 生成一个 0 到总几率的随机数 - const randomNum = randomNumber(totalRate); - - let currentRate = 0; - // 根据随机数落在的几率范围,返回对应的 rarity - for (const key in rarities) { - if (rarities.hasOwnProperty(key)) { - const rarity = rarities[key]; - currentRate += rarity.rate; - if (randomNum < currentRate) { - return key; - } - } - } -} /* const okButton = drawRoundedRectWithText({ diff --git a/src/qqbot/plugins/bangDream/tsugu/models/gacha-policy.ts b/src/qqbot/plugins/bangDream/tsugu/models/gacha-policy.ts new file mode 100644 index 0000000..1ed1762 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/gacha-policy.ts @@ -0,0 +1,143 @@ +import { BangDreamGachaType } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-protocol'; + +export const BANGDREAM_GACHA_DEFAULT_SPIN_COUNT = 10; +export const BANGDREAM_GACHA_MAX_SPIN_COUNT = 10000; +export const BANGDREAM_GACHA_GUARANTEED_RARITY = 3; + +export type BangDreamRandomSource = () => number; + +export type GachaRarityRates = Record< + string, + { + rate: number; + weightTotal: number; + } +>; + +export type GachaCardWeightList = Record< + string, + { + rarityIndex: number; + weight: number; + } +>; + +/** + * 判断卡池类型是否为生日卡池。 + * + * @param type - 卡池类型。 + * @returns 判断结果。 + */ +export function isBirthdayGachaType(type: string): boolean { + return type === BangDreamGachaType.birthday; +} + +/** + * 判断卡池类型是否为免费卡池。 + * + * @param type - 卡池类型。 + * @returns 判断结果。 + */ +export function isFreeGachaType(type: string): boolean { + return type === BangDreamGachaType.free; +} + +/** + * 判断日服卡池是否为常驻期。 + * + * @param gachaPeriod - 卡池期间文案。 + * @returns 判断结果。 + */ +export function isPermanentJapaneseGachaPeriod( + gachaPeriod?: string | null, +): boolean { + return gachaPeriod === '期限なし'; +} + +/** + * 判断抽卡次数是否超过上限。 + * + * @param times - 抽卡次数。 + * @returns 判断结果。 + */ +export function isGachaSpinCountTooLarge(times: number): boolean { + return times > BANGDREAM_GACHA_MAX_SPIN_COUNT; +} + +/** + * 应用十连保底稀有度规则。 + * + * @param drawIndex - 从 0 开始的抽卡序号。 + * @param rarity - 原始稀有度。 + * @returns 应用保底后的稀有度。 + */ +export function applyGachaGuaranteedRarity( + drawIndex: number, + rarity: number, +): number { + if (drawIndex % BANGDREAM_GACHA_DEFAULT_SPIN_COUNT === 9) { + return Math.max(rarity, BANGDREAM_GACHA_GUARANTEED_RARITY); + } + return rarity; +} + +/** + * 按卡池概率抽取稀有度。 + * + * @param rarities - 稀有度概率表。 + * @param random - 随机源。 + * @returns 稀有度 key。 + */ +export function pickGachaRarityByRate( + rarities: GachaRarityRates, + random: BangDreamRandomSource = Math.random, +): string | null { + const totalRate = Object.values(rarities).reduce( + (sum, rarity) => sum + rarity.rate, + 0, + ); + const randomValue = random() * totalRate; + let currentRate = 0; + for (const key in rarities) { + if (!Object.prototype.hasOwnProperty.call(rarities, key)) { + continue; + } + currentRate += rarities[key].rate; + if (randomValue < currentRate) { + return key; + } + } + return null; +} + +/** + * 按卡牌权重抽取卡牌 ID。 + * + * @param rarity - 目标稀有度。 + * @param totalWeight - 目标稀有度总权重。 + * @param cardWeightList - 卡牌权重表。 + * @param random - 随机源。 + * @returns 卡牌 ID。 + */ +export function pickGachaCardIdByWeight( + rarity: number, + totalWeight: number, + cardWeightList: GachaCardWeightList, + random: BangDreamRandomSource = Math.random, +): string | undefined { + const randomValue = random() * totalWeight; + let currentWeight = 0; + for (const cardId in cardWeightList) { + if (!Object.prototype.hasOwnProperty.call(cardWeightList, cardId)) { + continue; + } + const card = cardWeightList[cardId]; + if (card.rarityIndex !== rarity) { + continue; + } + currentWeight += card.weight; + if (randomValue < currentWeight) { + return cardId; + } + } +} diff --git a/src/qqbot/plugins/bangDream/tsugu/models/gacha.ts b/src/qqbot/plugins/bangDream/tsugu/models/gacha.ts index 15c0a0f..a0b4eae 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/gacha.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/gacha.ts @@ -11,6 +11,10 @@ import { globalDefaultServer, } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { BANGDREAM_GACHA_TYPE_NAME } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; +import { + isFreeGachaType, + isPermanentJapaneseGachaPeriod, +} from '@/qqbot/plugins/bangDream/tsugu/models/gacha-policy'; const gachaDataCache = {}; @@ -278,12 +282,12 @@ export async function getPresentGachaList( continue; } if (gacha.publishedAt[server] <= end && gacha.closedAt[server] >= start) { - if (gacha.type == 'free') { + if (isFreeGachaType(gacha.type)) { continue; } if (gacha.gachaName[Server.jp] != null) { await gacha.initFull(false); - if (gacha.gachaPeriod[Server.jp] == '期限なし') { + if (isPermanentJapaneseGachaPeriod(gacha.gachaPeriod[Server.jp])) { continue; } } diff --git a/test/qqbot/plugins/bangDream/tsugu/policy.spec.ts b/test/qqbot/plugins/bangDream/tsugu/policy.spec.ts index 1c607cc..9488a85 100644 --- a/test/qqbot/plugins/bangDream/tsugu/policy.spec.ts +++ b/test/qqbot/plugins/bangDream/tsugu/policy.spec.ts @@ -22,8 +22,19 @@ import { getBangDreamServerUtcOffset, normalizeBangDreamTimestamp, } from '@/qqbot/plugins/bangDream/tsugu/models/server-policy'; +import { + applyGachaGuaranteedRarity, + BANGDREAM_GACHA_MAX_SPIN_COUNT, + isBirthdayGachaType, + isFreeGachaType, + isGachaSpinCountTooLarge, + isPermanentJapaneseGachaPeriod, + pickGachaCardIdByWeight, + pickGachaRarityByRate, +} from '@/qqbot/plugins/bangDream/tsugu/models/gacha-policy'; import { BangDreamEventStatus, + BangDreamGachaType, BangDreamServerId as Server, } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-protocol'; @@ -141,6 +152,52 @@ describe('BangDream cutoff policy', () => { }); }); +describe('BangDream gacha policy', () => { + it('classifies gacha types and spin count limits', () => { + expect(isBirthdayGachaType(BangDreamGachaType.birthday)).toBe(true); + expect(isBirthdayGachaType(BangDreamGachaType.limited)).toBe(false); + expect(isFreeGachaType(BangDreamGachaType.free)).toBe(true); + expect(isPermanentJapaneseGachaPeriod('期限なし')).toBe(true); + expect(isGachaSpinCountTooLarge(BANGDREAM_GACHA_MAX_SPIN_COUNT)).toBe( + false, + ); + expect(isGachaSpinCountTooLarge(BANGDREAM_GACHA_MAX_SPIN_COUNT + 1)).toBe( + true, + ); + }); + + it('applies ten-pull guaranteed rarity without changing other pulls', () => { + expect(applyGachaGuaranteedRarity(8, 2)).toBe(2); + expect(applyGachaGuaranteedRarity(9, 2)).toBe(3); + expect(applyGachaGuaranteedRarity(9, 4)).toBe(4); + }); + + it('picks rarity by rate and cards by rarity weight', () => { + expect( + pickGachaRarityByRate( + { + 2: { rate: 97, weightTotal: 100 }, + 3: { rate: 3, weightTotal: 50 }, + }, + () => 0.98, + ), + ).toBe('3'); + + expect( + pickGachaCardIdByWeight( + 3, + 50, + { + 100: { rarityIndex: 2, weight: 100 }, + 200: { rarityIndex: 3, weight: 10 }, + 201: { rarityIndex: 3, weight: 40 }, + }, + () => 0.5, + ), + ).toBe('201'); + }); +}); + function eventCandidate( eventId: number, eventType: string,