refactor: 收口 BangDream 抽卡策略

This commit is contained in:
sunlei 2026-06-06 22:50:51 +08:00
parent 0da6d21d26
commit 46f6a2465c
7 changed files with 240 additions and 88 deletions

View File

@ -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 7Facade 和 hook 接入

View File

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

View File

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

View File

@ -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<Array<Buffer | string>> {
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({

View File

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

View File

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

View File

@ -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,