refactor: 收口 BangDream 抽卡策略
This commit is contained in:
parent
0da6d21d26
commit
46f6a2465c
@ -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 接入
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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({
|
||||
|
||||
143
src/qqbot/plugins/bangDream/tsugu/models/gacha-policy.ts
Normal file
143
src/qqbot/plugins/bangDream/tsugu/models/gacha-policy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user