refactor: 收口 BangDream 抽卡模拟规格

This commit is contained in:
sunlei 2026-06-07 00:17:24 +08:00
parent 3969d3e4e2
commit f8d964c6ee
6 changed files with 348 additions and 50 deletions

View File

@ -360,6 +360,9 @@ export interface TsuguHook {
- 已新增 `list-frame-spec.spec.ts`,覆盖列表正文宽度、标签/tips 偏移、合并列宽、居中图片换行和左侧竖线尺寸;本地生成 `list-frame-spec-song-136.jpg`、`list-frame-spec-event-50.jpg`、`list-frame-spec-character-1.jpg`,验证列表框架规格收口后查曲/查活动/查角色图片输出正常。
- 已新增 `canvas/text-spec.ts`收口文本默认字号、行高比例、baseline、空画布尺寸、混排间距和内联图片缩放计算`canvas/text.ts` 改为消费 spec原有文本换行和混排拆分逻辑保持不变。
- 已新增 `text-spec.spec.ts`,覆盖行高/间距比例、baseline、内联图片缩放和空/单行/多行画布尺寸;本地生成 `text-spec-song-136.jpg`、`text-spec-event-50.jpg`、`text-spec-character-1.jpg`,验证文本规格收口后查曲/查活动/查角色图片输出正常。
- 已新增 `render-blocks/gacha-simulate-spec.ts`,收口抽卡模拟网格宽度、单抽/汇总混排尺寸、重复卡叠层、数量右对齐和卡池横幅布局;`gacha-simulate.ts` 改为消费 spec抽卡概率和卡池选择逻辑保持不变。
- 已新增 `gacha-simulate-spec.spec.ts`,覆盖 10 抽网格、汇总模式、重复卡叠层、计数字样和横幅尺寸;本地生成 `gacha-simulate-spec-10-259.jpg``gacha-simulate-spec-50-259-after-cache-fix.jpg`,验证抽卡模拟两种布局模式图片输出正常。
- 抽卡模拟 50 抽 smoke 暴露资源下载短时失败会把 URL 写入无过期错误缓存,导致后续重试直接输出 `asset error`;已改为只有 HTTP 404 进入错误缓存,超时/网络抖动不污染 URL并新增 `file-cache-client.spec.ts` 覆盖瞬时失败后可再次下载、404 缓存缺失资源两种路径。
### Phase 6策略 policy 和时间/档线规则

View File

@ -20,8 +20,16 @@ import {
pickGachaCardIdByWeight,
pickGachaRarityByRate,
} from '@/qqbot/plugins/bangDream/tsugu/models/gacha-policy';
import {
BANGDREAM_GACHA_SIMULATE_SPEC,
createGachaBannerCanvasSize,
createGachaSimulateWrapOptions,
getGachaBannerImageMaxWidth,
getGachaCountTextPosition,
getGachaDuplicateIconRect,
getGachaDuplicateLayerCount,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-simulate-spec';
const maxWidth = 230 * 5;
/**
* QQBot Random卡池
*
@ -36,7 +44,9 @@ export async function drawRandomGacha(
compress: boolean,
): Promise<Array<Buffer | string>> {
if (isGachaSpinCountTooLarge(times)) {
return [`错误: 抽卡次数过多, 请不要超过${BANGDREAM_GACHA_MAX_SPIN_COUNT}`];
return [
`错误: 抽卡次数过多, 请不要超过${BANGDREAM_GACHA_MAX_SPIN_COUNT}`,
];
}
if (!gacha.isExist) {
return ['错误: 该卡池不存在'];
@ -52,11 +62,8 @@ export async function drawRandomGacha(
cardImageList.push(await drawGachaCard(getGachaRandomCard(gacha, i)));
}
gachaImage = drawTextWithImages({
textSize: 230,
lineHeight: 230,
content: cardImageList,
maxWidth: maxWidth,
spacing: 0,
...createGachaSimulateWrapOptions('single'),
});
} else {
const gachaList: { [cardId: number]: number } = {};
@ -98,11 +105,8 @@ export async function drawRandomGacha(
cardImageList.push(...cardImageResults);
gachaImage = drawTextWithImages({
textSize: 115,
lineHeight: 115,
content: cardImageList,
maxWidth: maxWidth,
spacing: 0,
...createGachaSimulateWrapOptions('summary'),
});
}
@ -134,37 +138,54 @@ async function drawGachaCard(card: Card, numberOfCard: number = 1) {
cardIdVisible: true,
});
if (numberOfCard > 1) {
const canvas = new Canvas(230, 230);
const canvas = new Canvas(
BANGDREAM_GACHA_SIMULATE_SPEC.card.canvas.width,
BANGDREAM_GACHA_SIMULATE_SPEC.card.canvas.height,
);
const ctx = canvas.getContext('2d');
const maxTimes = Math.min(6, numberOfCard - 1);
const layerCount = getGachaDuplicateLayerCount(numberOfCard);
const cardIconWithoutId = await drawCardIcon({
card: card,
trainingStatus: false,
cardTypeVisible: false,
cardIdVisible: false,
});
for (let i = 1; i <= maxTimes; i++) {
ctx.drawImage(
cardIconWithoutId,
35 - (maxTimes - i + 1) * 4,
20 - (maxTimes - i + 1) * 4,
180,
180,
);
for (let i = 0; i < layerCount; i++) {
const rect = getGachaDuplicateIconRect(i, layerCount);
ctx.drawImage(cardIconWithoutId, rect.x, rect.y, rect.width, rect.height);
}
ctx.drawImage(cardIconWithId, 35, 20, 180, 210);
const iconWithCount = BANGDREAM_GACHA_SIMULATE_SPEC.card.iconWithCount;
ctx.drawImage(
cardIconWithId,
iconWithCount.x,
iconWithCount.y,
iconWithCount.width,
iconWithCount.height,
);
const countTextSpec = BANGDREAM_GACHA_SIMULATE_SPEC.card.countText;
const numberText = drawText({
text: `x${numberOfCard}`,
textSize: 30,
maxWidth: 80,
color: '#A7A7A7',
textSize: countTextSpec.textSize,
maxWidth: countTextSpec.maxWidth,
color: countTextSpec.color,
});
ctx.drawImage(numberText, 215 - numberText.width, 195);
const countPosition = getGachaCountTextPosition(numberText.width);
ctx.drawImage(numberText, countPosition.x, countPosition.y);
return canvas;
} else {
const canvas = new Canvas(230, 230);
const canvas = new Canvas(
BANGDREAM_GACHA_SIMULATE_SPEC.card.canvas.width,
BANGDREAM_GACHA_SIMULATE_SPEC.card.canvas.height,
);
const ctx = canvas.getContext('2d');
ctx.drawImage(cardIconWithId, 35, 20, 180, 200);
const iconSingle = BANGDREAM_GACHA_SIMULATE_SPEC.card.iconSingle;
ctx.drawImage(
cardIconWithId,
iconSingle.x,
iconSingle.y,
iconSingle.width,
iconSingle.height,
);
return canvas;
}
}
@ -195,21 +216,6 @@ function getGachaRandomCard(gacha: Gacha, times: number) {
return card;
}
/*
const okButton = drawRoundedRectWithText({
text: 'ok',
textColor: '#FFFFFF',
textSize: 60,
color: '#FE3B73',
textAlign: 'center',
width: 280,
height: 80,
radius: 20,
strokeColor: '#FFFFFF',
strokeWidth: 5,
})
*/
//画下方的卡池Banner与抽卡按钮
/**
* QQBot
@ -219,11 +225,15 @@ const okButton = drawRoundedRectWithText({
async function drawGachaBanner(gacha: Gacha) {
const gachaBannerImage = resizeImage({
image: await drawGachaDataBlock(gacha),
widthMax: maxWidth / 2,
widthMax: getGachaBannerImageMaxWidth(),
});
const canvas = new Canvas(maxWidth + 200, gachaBannerImage.height);
const canvasSize = createGachaBannerCanvasSize(gachaBannerImage.height);
const canvas = new Canvas(canvasSize.width, canvasSize.height);
const ctx = canvas.getContext('2d');
//ctx.drawImage(okButton, 1010, 0)
ctx.drawImage(gachaBannerImage, 50, 0);
ctx.drawImage(
gachaBannerImage,
BANGDREAM_GACHA_SIMULATE_SPEC.banner.imageX,
BANGDREAM_GACHA_SIMULATE_SPEC.banner.imageY,
);
return canvas;
}

View File

@ -6,7 +6,8 @@ import {
normalizeBangDreamPositiveInteger,
} from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options';
const errUrl: string[] = [];
const errorUrlCache: { [url: string]: number } = {};
const ERROR_URL_CACHE_EXPIRY_MS = 12 * 60 * 60 * 1000;
const DEFAULT_REQUEST_TIMEOUT_MS = 8000;
function getRequestTimeoutMs(): number {
@ -35,8 +36,8 @@ export async function download(
ensureDirectoryExists(directory);
}
try {
if (errUrl.includes(url)) {
throw new Error('downloadFile: errUrl.includes(url)');
if (isErrorUrlCacheActive(url)) {
throw new Error('downloadFile: errorUrlCache includes url');
}
let eTag: string | undefined;
const cacheFilePath = path.join(directory || '', `${fileName || ''}`);
@ -83,7 +84,9 @@ export async function download(
}
return fileBuffer;
} catch (e) {
errUrl.push(url);
if (getErrorResponseStatus(e) === 404) {
errorUrlCache[url] = Date.now();
}
if (url.includes('.png')) {
throw e;
} else {
@ -94,6 +97,26 @@ export async function download(
}
}
function isErrorUrlCacheActive(url: string): boolean {
const cachedAt = errorUrlCache[url];
if (cachedAt == null) {
return false;
}
if (Date.now() - cachedAt >= ERROR_URL_CACHE_EXPIRY_MS) {
delete errorUrlCache[url];
return false;
}
return true;
}
function getErrorResponseStatus(error: unknown): number | undefined {
if (typeof error !== 'object' || error == null || !('response' in error)) {
return undefined;
}
const response = (error as { response?: { status?: number } }).response;
return response?.status;
}
/**
* DirectoryExists
*

View File

@ -0,0 +1,149 @@
export interface GachaSimulateRect {
height: number;
width: number;
x: number;
y: number;
}
export interface GachaSimulatePoint {
x: number;
y: number;
}
export const BANGDREAM_GACHA_SIMULATE_SPEC = {
banner: {
extraWidth: 200,
imageX: 50,
imageY: 0,
maxWidthRatio: 1 / 2,
},
card: {
canvas: { height: 230, width: 230 },
countText: {
color: '#A7A7A7',
maxWidth: 80,
rightX: 215,
textSize: 30,
y: 195,
},
duplicateIcon: {
height: 180,
maxLayerCount: 6,
offsetStep: 4,
width: 180,
x: 35,
y: 20,
},
iconSingle: { height: 200, width: 180, x: 35, y: 20 },
iconWithCount: { height: 210, width: 180, x: 35, y: 20 },
},
grid: {
columns: 5,
single: { lineHeight: 230, textSize: 230 },
spacing: 0,
summary: { lineHeight: 115, textSize: 115 },
},
} as const;
/**
*
*/
export function getGachaSimulateGridMaxWidth(): number {
return (
BANGDREAM_GACHA_SIMULATE_SPEC.card.canvas.width *
BANGDREAM_GACHA_SIMULATE_SPEC.grid.columns
);
}
/**
*
*
* @param mode -
*/
export function createGachaSimulateWrapOptions(mode: 'single' | 'summary'): {
lineHeight: number;
maxWidth: number;
spacing: number;
textSize: number;
} {
const sizeSpec = BANGDREAM_GACHA_SIMULATE_SPEC.grid[mode];
return {
lineHeight: sizeSpec.lineHeight,
maxWidth: getGachaSimulateGridMaxWidth(),
spacing: BANGDREAM_GACHA_SIMULATE_SPEC.grid.spacing,
textSize: sizeSpec.textSize,
};
}
/**
*
*
* @param numberOfCard -
*/
export function getGachaDuplicateLayerCount(numberOfCard: number): number {
return Math.min(
BANGDREAM_GACHA_SIMULATE_SPEC.card.duplicateIcon.maxLayerCount,
Math.max(0, numberOfCard - 1),
);
}
/**
*
*
* @param layerIndex - 0
* @param layerCount -
*/
export function getGachaDuplicateIconRect(
layerIndex: number,
layerCount: number,
): GachaSimulateRect {
const duplicateSpec = BANGDREAM_GACHA_SIMULATE_SPEC.card.duplicateIcon;
const offset = (layerCount - layerIndex) * duplicateSpec.offsetStep;
return {
height: duplicateSpec.height,
width: duplicateSpec.width,
x: duplicateSpec.x - offset,
y: duplicateSpec.y - offset,
};
}
/**
*
*
* @param textWidth -
*/
export function getGachaCountTextPosition(
textWidth: number,
): GachaSimulatePoint {
return {
x: BANGDREAM_GACHA_SIMULATE_SPEC.card.countText.rightX - textWidth,
y: BANGDREAM_GACHA_SIMULATE_SPEC.card.countText.y,
};
}
/**
*
*/
export function getGachaBannerImageMaxWidth(): number {
return (
getGachaSimulateGridMaxWidth() *
BANGDREAM_GACHA_SIMULATE_SPEC.banner.maxWidthRatio
);
}
/**
*
*
* @param imageHeight -
*/
export function createGachaBannerCanvasSize(imageHeight: number): {
height: number;
width: number;
} {
return {
height: imageHeight,
width:
getGachaSimulateGridMaxWidth() +
BANGDREAM_GACHA_SIMULATE_SPEC.banner.extraWidth,
};
}

View File

@ -0,0 +1,53 @@
describe('BangDream file cache client', () => {
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
it('does not poison png url cache after transient network failures', async () => {
const imageBuffer = Buffer.from('png-data');
const get = jest
.fn()
.mockRejectedValueOnce(new Error('timeout of 8000ms exceeded'))
.mockResolvedValueOnce({ data: imageBuffer, headers: {} });
jest.doMock('axios', () => ({
__esModule: true,
default: { get },
}));
const { download } =
await import('@/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client');
await expect(download('https://example.com/card.png')).rejects.toThrow(
'timeout of 8000ms exceeded',
);
await expect(download('https://example.com/card.png')).resolves.toEqual(
imageBuffer,
);
expect(get).toHaveBeenCalledTimes(2);
});
it('keeps missing png urls in the error cache', async () => {
const notFoundError = Object.assign(new Error('not found'), {
response: { status: 404 },
});
const get = jest.fn().mockRejectedValueOnce(notFoundError);
jest.doMock('axios', () => ({
__esModule: true,
default: { get },
}));
const { download } =
await import('@/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client');
await expect(download('https://example.com/missing.png')).rejects.toThrow(
'not found',
);
await expect(download('https://example.com/missing.png')).rejects.toThrow(
'errorUrlCache includes url',
);
expect(get).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,60 @@
import {
BANGDREAM_GACHA_SIMULATE_SPEC,
createGachaBannerCanvasSize,
createGachaSimulateWrapOptions,
getGachaBannerImageMaxWidth,
getGachaCountTextPosition,
getGachaDuplicateIconRect,
getGachaDuplicateLayerCount,
getGachaSimulateGridMaxWidth,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-simulate-spec';
describe('BangDream gacha simulate spec', () => {
it('keeps grid widths and wrap modes stable', () => {
expect(getGachaSimulateGridMaxWidth()).toBe(1150);
expect(createGachaSimulateWrapOptions('single')).toEqual({
lineHeight: 230,
maxWidth: 1150,
spacing: 0,
textSize: 230,
});
expect(createGachaSimulateWrapOptions('summary')).toEqual({
lineHeight: 115,
maxWidth: 1150,
spacing: 0,
textSize: 115,
});
});
it('caps duplicate card shadow layers', () => {
expect(getGachaDuplicateLayerCount(1)).toBe(0);
expect(getGachaDuplicateLayerCount(2)).toBe(1);
expect(getGachaDuplicateLayerCount(99)).toBe(
BANGDREAM_GACHA_SIMULATE_SPEC.card.duplicateIcon.maxLayerCount,
);
});
it('positions duplicate card layers from back to front', () => {
expect(getGachaDuplicateIconRect(0, 6)).toEqual({
height: 180,
width: 180,
x: 11,
y: -4,
});
expect(getGachaDuplicateIconRect(5, 6)).toEqual({
height: 180,
width: 180,
x: 31,
y: 16,
});
});
it('keeps count text and banner layout stable', () => {
expect(getGachaCountTextPosition(42)).toEqual({ x: 173, y: 195 });
expect(getGachaBannerImageMaxWidth()).toBe(575);
expect(createGachaBannerCanvasSize(320)).toEqual({
height: 320,
width: 1350,
});
});
});