diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index cea8ccf..ce5459b 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -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 和时间/档线规则 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 f4b30c7..1df2e2c 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/gacha-simulate.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/gacha-simulate.ts @@ -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> { 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; } diff --git a/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts b/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts index 7978f21..d98ce0d 100644 --- a/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts +++ b/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts @@ -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。 * diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-simulate-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-simulate-spec.ts new file mode 100644 index 0000000..9a16f70 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-simulate-spec.ts @@ -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, + }; +} diff --git a/test/qqbot/plugins/bangDream/tsugu/file-cache-client.spec.ts b/test/qqbot/plugins/bangDream/tsugu/file-cache-client.spec.ts new file mode 100644 index 0000000..aab4c44 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/file-cache-client.spec.ts @@ -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); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/gacha-simulate-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/gacha-simulate-spec.spec.ts new file mode 100644 index 0000000..6852e88 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/gacha-simulate-spec.spec.ts @@ -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, + }); + }); +});