refactor: 收口 BangDream 抽卡模拟规格
This commit is contained in:
parent
3969d3e4e2
commit
f8d964c6ee
@ -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 和时间/档线规则
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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。
|
||||
*
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
53
test/qqbot/plugins/bangDream/tsugu/file-cache-client.spec.ts
Normal file
53
test/qqbot/plugins/bangDream/tsugu/file-cache-client.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user