fix: 修复BangDream Tsugu渲染资源预加载

This commit is contained in:
sunlei 2026-06-16 10:03:51 +08:00
parent 0127628a1e
commit 83eef0dffa
22 changed files with 1317 additions and 104 deletions

View File

@ -0,0 +1,257 @@
param(
[string]$OutDir = ".kt-workspace/bangdream-smoke/matrix",
[int]$TimeoutSeconds = 240,
[switch]$SkipExternalPlayer
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$ResolvedOutDir = if ([System.IO.Path]::IsPathRooted($OutDir)) {
$OutDir
} else {
Join-Path $ProjectRoot $OutDir
}
New-Item -ItemType Directory -Force -Path $ResolvedOutDir | Out-Null
$StdoutLog = Join-Path $ResolvedOutDir "matrix.out.log"
$StderrLog = Join-Path $ResolvedOutDir "matrix.err.log"
$SummaryFile = Join-Path $ResolvedOutDir "matrix-summary.json"
Remove-Item -LiteralPath $StdoutLog,$StderrLog,$SummaryFile -Force -ErrorAction SilentlyContinue
$Payload = @{
outDir = $ResolvedOutDir
skipExternalPlayer = [bool]$SkipExternalPlayer
}
$PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5
$PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson))
$NodeCode = @"
const fs = require("fs");
const path = require("path");
const XLSX = require("xlsx");
const { loadImage } = require("skia-canvas");
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
const { createPlugin } = require("./src/modules/qqbot/plugins/bangdream/src");
const cases = [
{ name: "song-search-detail", operationKey: "bangdream.song.search", text: "136", expectedImageCount: 1 },
{ name: "song-search-list", operationKey: "bangdream.song.search", text: "FIRE BIRD", expectedImageCount: 1 },
{ name: "song-chart", operationKey: "bangdream.song.chart", text: "136 expert", expectedImageCount: 1 },
{ name: "song-random", operationKey: "bangdream.song.random", text: "FIRE BIRD", expectedImageCount: 1 },
{ name: "song-meta", operationKey: "bangdream.song.meta", text: "cn", expectedImageCount: 1 },
{ name: "card-search-detail", operationKey: "bangdream.card.search", text: "472", expectedImageCount: 1 },
{ name: "card-search-list", operationKey: "bangdream.card.search", text: "香澄", expectedImageCount: 1 },
{ name: "card-illustration", operationKey: "bangdream.card.illustration", text: "472", expectedImageCountMin: 1 },
{ name: "character-search-detail", operationKey: "bangdream.character.search", text: "1", expectedImageCount: 1 },
{ name: "character-search-list", operationKey: "bangdream.character.search", text: "香澄", expectedImageCount: 1 },
{ name: "event-search-detail", operationKey: "bangdream.event.search", text: "50", expectedImageCount: 1 },
{ name: "event-search-list", operationKey: "bangdream.event.search", text: "summer", expectedImageCount: 1 },
{ name: "event-stage", operationKey: "bangdream.event.stage", text: "310 cn -m", expectedImageCount: 5 },
{ name: "player-search", operationKey: "bangdream.player.search", text: "26591455 jp", expectedImageCount: 1, external: true },
{ name: "gacha-search", operationKey: "bangdream.gacha.search", text: "259", expectedImageCount: 1 },
{ name: "gacha-simulate", operationKey: "bangdream.gacha.simulate", text: "10 259", expectedImageCount: 1 },
{ name: "cutoff-detail", operationKey: "bangdream.cutoff.detail", text: "ycx 1000 50 cn", expectedImageCount: 1 },
{ name: "cutoff-detail-top10", operationKey: "bangdream.cutoff.detail", text: "ycx 10 100 cn", expectedImageCount: 1 },
{ name: "cutoff-all", operationKey: "bangdream.cutoff.all", text: "50 cn", expectedImageCount: 1 },
{ name: "cutoff-recent", operationKey: "bangdream.cutoff.recent", text: "1000 50 cn", expectedImageCount: 1 },
];
function createHttpError(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
error.response = { status: statusCode };
return error;
}
async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeoutMs = options.timeoutMs || 30000;
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
headers: options.headers,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function readExcelRows(filePath) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
}
async function requestBuffer(url, options) {
const response = await fetchWithTimeout(url, options);
const body = Buffer.from(await response.arrayBuffer());
if (!response.ok) {
throw createHttpError("BangDream resource request failed: " + response.status, response.status);
}
return { body, statusCode: response.status };
}
async function requestJson(url, options) {
const response = await fetchWithTimeout(url, options);
const text = await response.text();
if (!response.ok) {
throw createHttpError("BangDream JSON request failed: " + response.status, response.status);
}
return { body: JSON.parse(text), statusCode: response.status };
}
function safeName(name) {
return name.replace(/[^a-zA-Z0-9._-]+/g, "-");
}
async function writeCaseImages(testCase, result) {
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
if (matches.length === 0) throw new Error(testCase.name + ": no CQ image payload");
if (testCase.expectedImageCount && matches.length !== testCase.expectedImageCount) {
throw new Error(testCase.name + ": expected imageCount=" + testCase.expectedImageCount + ", got " + matches.length);
}
if (testCase.expectedImageCountMin && matches.length < testCase.expectedImageCountMin) {
throw new Error(testCase.name + ": expected imageCount>=" + testCase.expectedImageCountMin + ", got " + matches.length);
}
const files = [];
for (let index = 0; index < matches.length; index++) {
const output = path.join(payload.outDir, safeName(testCase.name) + (index === 0 ? "" : "-" + (index + 1)) + ".jpg");
const buffer = Buffer.from(matches[index][1], "base64");
fs.writeFileSync(output, buffer);
const image = await loadImage(output);
files.push({
bytes: buffer.length,
height: image.height,
out: output,
width: image.width,
});
}
return files;
}
(async () => {
fs.mkdirSync(payload.outDir, { recursive: true });
const manifest = readJsonFile(path.join(process.cwd(), "src/modules/qqbot/plugins/bangdream/plugin.json"));
const operations = manifest.operations.map((operation) => ({
handlerName: operation.handlerName,
key: operation.key,
}));
const plugin = createPlugin({
io: {
getConfig: (key) => process.env[key],
readAssetFile: async (filePath) => fs.promises.readFile(filePath),
readExcelRows: async (filePath) => readExcelRows(filePath),
readJsonFile: async (filePath) => readJsonFile(filePath),
readJsonFileSync: (filePath) => readJsonFile(filePath),
requestArrayBuffer: requestBuffer,
requestJson,
sleep: async (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
writeJsonFile: async (filePath, data) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data));
},
},
normalizeError: (error) => String(error?.message || error || "BangDream command failed"),
operations,
});
await plugin.activate();
const summaries = [];
const executedKeys = new Set();
for (const testCase of cases) {
if (testCase.external && payload.skipExternalPlayer) {
summaries.push({
name: testCase.name,
operationKey: testCase.operationKey,
skipped: true,
reason: "external-player",
});
continue;
}
const startedAt = Date.now();
const result = await plugin.executeOperation(testCase.operationKey, {
compress: true,
displayedServerList: "",
text: testCase.text,
});
const files = await writeCaseImages(testCase, result);
executedKeys.add(testCase.operationKey);
summaries.push({
durationMs: Date.now() - startedAt,
files,
imageCount: result.imageCount,
name: testCase.name,
operationKey: testCase.operationKey,
query: result.query,
});
process.stdout.write("[matrix] " + testCase.name + " imageCount=" + result.imageCount + " first=" + files[0].width + "x" + files[0].height + "\n");
}
const manifestKeys = manifest.operations.map((operation) => operation.key).sort();
const missingKeys = manifestKeys.filter((key) => !cases.some((item) => item.operationKey === key));
if (missingKeys.length > 0) {
throw new Error("Smoke matrix missing operation keys: " + missingKeys.join(", "));
}
const summary = {
caseCount: cases.length,
executedOperationKeys: [...executedKeys].sort(),
manifestOperationKeys: manifestKeys,
outDir: payload.outDir,
skipped: summaries.filter((item) => item.skipped),
summaries,
};
const summaryFile = path.join(payload.outDir, "matrix-summary.json");
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
process.stdout.write(JSON.stringify({
caseCount: summary.caseCount,
executedOperationKeyCount: summary.executedOperationKeys.length,
skippedCount: summary.skipped.length,
summaryFile,
}, null, 2) + "\n");
})().catch((error) => {
process.stderr.write(String(error?.stack || error) + "\n");
process.exit(1);
});
"@
$NodeCodeBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($NodeCode))
$EvalCode = "eval(Buffer.from('$NodeCodeBase64','base64').toString('utf8'))"
$env:TS_NODE_TRANSPILE_ONLY = "true"
$Process = Start-Process `
-FilePath "node" `
-ArgumentList @("-r", "ts-node/register", "-r", "tsconfig-paths/register", "-e", $EvalCode) `
-WorkingDirectory $ProjectRoot `
-RedirectStandardOutput $StdoutLog `
-RedirectStandardError $StderrLog `
-PassThru `
-WindowStyle Hidden
$Deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while (-not $Process.HasExited) {
if ((Get-Date) -ge $Deadline) {
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
Write-Output "BangDream smoke matrix timed out and process $($Process.Id) was killed."
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
if (Test-Path $StderrLog) { Get-Content $StderrLog }
exit 124
}
Start-Sleep -Milliseconds 500
$Process.Refresh()
}
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
if (Test-Path $StderrLog) { Get-Content $StderrLog }
if (-not (Test-Path $SummaryFile)) {
Write-Output "BangDream smoke matrix did not produce a summary file."
exit 1
}
exit $Process.ExitCode

View File

@ -167,6 +167,7 @@ export type QqbotPluginOperation<Input = any, Output = any> = {
};
export type QqbotIntegrationPlugin = {
activate?: () => Promise<unknown> | unknown;
description?: string;
healthCheck?: () => Promise<QqbotPluginHealth>;
key: string;

View File

@ -35,6 +35,7 @@ export class QqbotPluginRegistryService implements OnModuleInit {
async onModuleInit() {
for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) {
await plugin.activate?.();
this.register(plugin);
}
await this.hydrateInactivePluginKeys();

View File

@ -0,0 +1,19 @@
import { preloadBangDreamCardArtAssets } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-art.renderer';
import { preloadBangDreamCardRarityAssets } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-rarity.renderer';
import { preloadBangDreamCardSkillTextAssets } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-skill-text.renderer';
import { preloadBangDreamPlayerAssets } from '@/modules/qqbot/plugins/bangdream/src/domain/player/player-detail.renderer';
import { preloadBangDreamBackgroundAssets } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-background';
import { preloadBangDreamOutputAssets } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-output';
import { preloadBangDreamTitleAssets } from '@/modules/qqbot/plugins/bangdream/src/theme/title.renderer';
export async function preloadBangDreamRenderAssets() {
await Promise.all([
preloadBangDreamBackgroundAssets(),
preloadBangDreamCardArtAssets(),
preloadBangDreamCardRarityAssets(),
preloadBangDreamCardSkillTextAssets(),
preloadBangDreamOutputAssets(),
preloadBangDreamPlayerAssets(),
preloadBangDreamTitleAssets(),
]);
}

View File

@ -17,34 +17,46 @@ import { cardArtResourceRepository } from '@/modules/qqbot/plugins/bangdream/src
const cardTypeIconList: { [type: string]: Image } = {};
const starList: { [type: string]: Image } = {};
let limitBreakIcon: Image;
let cardArtAssetsPreload: Promise<void> | undefined;
/**
* Once
*/
async function loadImageOnce() {
cardTypeIconList.limited = await loadImageFromPath(
getBangDreamAssetPath('cardLimited'),
);
cardTypeIconList.dreamfes = await loadImageFromPath(
getBangDreamAssetPath('cardDreamfes'),
);
cardTypeIconList.kirafes = await loadImageFromPath(
getBangDreamAssetPath('cardKirafes'),
);
cardTypeIconList.birthday = await loadImageFromPath(
getBangDreamAssetPath('cardBirthday'),
);
starList.normal = await loadImageFromPath(getBangDreamAssetPath('cardStar'));
starList.trained = await loadImageFromPath(
getBangDreamAssetPath('cardStarTrained'),
);
limitBreakIcon = await loadImageFromPath(
getBangDreamAssetPath('cardLimitBreakRank'),
);
export async function preloadBangDreamCardArtAssets() {
if (!cardArtAssetsPreload) {
cardArtAssetsPreload = Promise.all([
loadImageFromPath(getBangDreamAssetPath('cardLimited')),
loadImageFromPath(getBangDreamAssetPath('cardDreamfes')),
loadImageFromPath(getBangDreamAssetPath('cardKirafes')),
loadImageFromPath(getBangDreamAssetPath('cardBirthday')),
loadImageFromPath(getBangDreamAssetPath('cardStar')),
loadImageFromPath(getBangDreamAssetPath('cardStarTrained')),
loadImageFromPath(getBangDreamAssetPath('cardLimitBreakRank')),
])
.then(
([
limited,
dreamfes,
kirafes,
birthday,
normalStar,
trainedStar,
limitBreak,
]) => {
cardTypeIconList.limited = limited;
cardTypeIconList.dreamfes = dreamfes;
cardTypeIconList.kirafes = kirafes;
cardTypeIconList.birthday = birthday;
starList.normal = normalStar;
starList.trained = trainedStar;
limitBreakIcon = limitBreak;
},
)
.catch((error) => {
cardArtAssetsPreload = undefined;
throw error;
});
}
await cardArtAssetsPreload;
}
loadImageOnce();
//根据稀有度与属性,获得图标框
/**
* Frame
@ -113,6 +125,7 @@ export async function drawCardIcon({
cardTypeVisible = true,
skillLevel,
}: DrawCardIconOptions): Promise<Canvas> {
await preloadBangDreamCardArtAssets();
trainingStatus = card.ableToTraining(trainingStatus);
illustrationTrainingStatus ??= trainingStatus;
const spec = BANGDREAM_CARD_ART_SPEC.icon;
@ -248,6 +261,7 @@ export async function drawCardIllustration({
trainingStatus,
isList = false,
}: DrawCardIllustrationOptions): Promise<Canvas> {
await preloadBangDreamCardArtAssets();
trainingStatus = card.ableToTraining(trainingStatus);
const spec = BANGDREAM_CARD_ART_SPEC.illustration;
const cardIllustrationImage =

View File

@ -14,19 +14,12 @@ import {
getCardPrefixBandLogoLayout,
} from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-prefix.layout';
let prefixBG: Canvas;
/**
* Once
*/
async function loadImageOnce() {
prefixBG = drawRoundedRect({
width: BANGDREAM_CARD_PREFIX_SPEC.canvas.width,
height: BANGDREAM_CARD_PREFIX_SPEC.canvas.height,
color: BANGDREAM_CARD_PREFIX_SPEC.background.color,
radius: [...BANGDREAM_CARD_PREFIX_SPEC.background.radius],
});
}
loadImageOnce();
const prefixBG: Canvas = drawRoundedRect({
width: BANGDREAM_CARD_PREFIX_SPEC.canvas.width,
height: BANGDREAM_CARD_PREFIX_SPEC.canvas.height,
color: BANGDREAM_CARD_PREFIX_SPEC.background.color,
radius: [...BANGDREAM_CARD_PREFIX_SPEC.background.radius],
});
/**
* PrefixIn列表

View File

@ -15,16 +15,25 @@ interface RarityInListOptions {
}
export const starList: { [type: string]: Image } = {};
/**
* Once
*/
async function loadImageOnce() {
starList.normal = await loadImageFromPath(getBangDreamAssetPath('cardStar'));
starList.trained = await loadImageFromPath(
getBangDreamAssetPath('cardStarTrained'),
);
let rarityAssetsPreload: Promise<void> | undefined;
export async function preloadBangDreamCardRarityAssets() {
if (!rarityAssetsPreload) {
rarityAssetsPreload = Promise.all([
loadImageFromPath(getBangDreamAssetPath('cardStar')),
loadImageFromPath(getBangDreamAssetPath('cardStarTrained')),
])
.then(([normal, trained]) => {
starList.normal = normal;
starList.trained = trained;
})
.catch((error) => {
rarityAssetsPreload = undefined;
throw error;
});
}
await rarityAssetsPreload;
}
loadImageOnce();
/**
* RarityIn列表
@ -38,6 +47,7 @@ export async function drawRarityInList({
trainingStatus = true,
text,
}: RarityInListOptions): Promise<Canvas> {
await preloadBangDreamCardRarityAssets();
const content: Array<string | Image | Canvas> = [];
let star: Image;
if (shouldUseTrainedRarityStar(rarity, trainingStatus)) {

View File

@ -11,19 +11,27 @@ import {
} from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-skill-text.layout';
const skillIcon: Partial<Record<BangDreamSkillIconKey, Image>> = {};
/**
* Once
*/
async function loadImageOnce() {
skillIcon.life = await loadImageFromPath(getBangDreamAssetPath('skillLife'));
skillIcon.judge = await loadImageFromPath(
getBangDreamAssetPath('skillJudge'),
);
skillIcon.damage = await loadImageFromPath(
getBangDreamAssetPath('skillDamage'),
);
let skillTextAssetsPreload: Promise<void> | undefined;
export async function preloadBangDreamCardSkillTextAssets() {
if (!skillTextAssetsPreload) {
skillTextAssetsPreload = Promise.all([
loadImageFromPath(getBangDreamAssetPath('skillLife')),
loadImageFromPath(getBangDreamAssetPath('skillJudge')),
loadImageFromPath(getBangDreamAssetPath('skillDamage')),
])
.then(([life, judge, damage]) => {
skillIcon.life = life;
skillIcon.judge = judge;
skillIcon.damage = damage;
})
.catch((error) => {
skillTextAssetsPreload = undefined;
throw error;
});
}
await skillTextAssetsPreload;
}
loadImageOnce();
//卡牌Icon右下角的技能描述图标
/**
@ -33,6 +41,7 @@ loadImageOnce();
* @returns
*/
export async function drawCardIconSkill(skill: Skill): Promise<Canvas> {
await preloadBangDreamCardSkillTextAssets();
const content = createSkillTextFragments({
effectTypes: skill.getEffectTypes(),
scoreUpMaxValue: skill.getScoreUpMaxValue(),

View File

@ -25,15 +25,23 @@ import { loadImageFromPath } from '@/modules/qqbot/plugins/bangdream/src/theme/c
import { playerRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/player/player.repository';
let BGDefaultImage: Image;
/**
* QQBot Once
*/
async function loadImageOnce() {
BGDefaultImage = await loadImageFromPath(
path.join(assetsRootPath, '/BG/common.png'),
);
let playerAssetsPreload: Promise<void> | undefined;
export async function preloadBangDreamPlayerAssets() {
if (!playerAssetsPreload) {
playerAssetsPreload = loadImageFromPath(
path.join(assetsRootPath, '/BG/common.png'),
)
.then((image) => {
BGDefaultImage = image;
})
.catch((error) => {
playerAssetsPreload = undefined;
throw error;
});
}
await playerAssetsPreload;
}
loadImageOnce();
/**
* QQBot
@ -50,6 +58,7 @@ export async function drawPlayerDetail(
useEasyBG: boolean,
compress: boolean,
): Promise<Array<Buffer | string>> {
await preloadBangDreamPlayerAssets();
const result = [];
let player = playerRepository.create(playerId, mainServer);
//不使用缓存查询

View File

@ -40,7 +40,7 @@ export async function drawSongInList(
): Promise<Canvas> {
const server = getServerByPriority(song.publishedAt, displayedServerList);
const songImage = resizeImage({
image: await song.getSongJacketImage(),
image: await song.getSongJacketImage(displayedServerList),
widthMax: BANGDREAM_SONG_LIST_SPEC.item.jacketSourceWidthMax,
heightMax: BANGDREAM_SONG_LIST_SPEC.item.jacketSourceHeightMax,
});

View File

@ -22,6 +22,7 @@ import type {
BangDreamOperationKey,
} from './domain/common/bangdream.types';
import { waitForBangDreamCatalogReady } from './application/catalog/bangdream-catalog-cache';
import { preloadBangDreamRenderAssets } from './application/render-assets';
type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & {
description?: string;
@ -77,7 +78,12 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) {
});
return {
activate: () => context.refreshDictionaryCache(),
activate: async () => {
await Promise.all([
context.refreshDictionaryCache(),
preloadBangDreamRenderAssets(),
]);
},
description: options.description,
dispose: async () => undefined,
executeOperation,

View File

@ -1,7 +1,12 @@
import { createBlurredTrianglePattern } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-background-triangle';
import { scatterImages } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-background-scatter';
import { drawTextOnCanvas } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-background-text';
import { loadImage, Image, Canvas } from 'skia-canvas';
import {
loadImage,
Image,
Canvas,
CanvasRenderingContext2D,
} from 'skia-canvas';
import { loadImageFromPath } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image';
import { getBangDreamAssetPath } from '@/modules/qqbot/plugins/bangdream/src/theme/asset-manifest';
import { BANGDREAM_RENDER_THEME } from '@/modules/qqbot/plugins/bangdream/src/theme/render-theme';
@ -13,6 +18,17 @@ interface BackgroundOptions {
height: number;
}
interface TextureLike {
height: number;
width: number;
}
interface TextureTileOptions {
ratio: number;
x: number;
y: number;
}
// 将图片等比例缩放并重复铺满整个画布,并且增加亮度
/**
* spreadBackground图片
@ -114,17 +130,27 @@ function getScaledDimensions(
const star: Image[] = [];
let defaultBGTexture: Image;
/**
* Once
*/
async function loadImageOnce() {
star.push(await loadImageFromPath(getBangDreamAssetPath('backgroundStar1')));
star.push(await loadImageFromPath(getBangDreamAssetPath('backgroundStar2')));
defaultBGTexture = await loadImageFromPath(
getBangDreamAssetPath('backgroundObjectBig'),
);
let backgroundAssetsPreload: Promise<void> | undefined;
export async function preloadBangDreamBackgroundAssets() {
if (!backgroundAssetsPreload) {
backgroundAssetsPreload = Promise.all([
loadImageFromPath(getBangDreamAssetPath('backgroundStar1')),
loadImageFromPath(getBangDreamAssetPath('backgroundStar2')),
loadImageFromPath(getBangDreamAssetPath('backgroundObjectBig')),
])
.then(([star1, star2, texture]) => {
star.length = 0;
star.push(star1, star2);
defaultBGTexture = texture;
})
.catch((error) => {
backgroundAssetsPreload = undefined;
throw error;
});
}
await backgroundAssetsPreload;
}
loadImageOnce();
/**
* Background
@ -132,6 +158,7 @@ loadImageOnce();
* @param options1 - options1参数
*/
export async function createEasyBackground({ width, height }) {
await preloadBangDreamBackgroundAssets();
const bgColor = BANGDREAM_RENDER_THEME.color.backgroundEasy;
const canvas: Canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d');
@ -144,13 +171,11 @@ export async function createEasyBackground({ width, height }) {
while (y < height) {
x = 0 - Math.random() * defaultBGTexture.width * ratio;
while (x < width) {
ctx.drawImage(
defaultBGTexture,
drawScaledTextureTile(ctx, defaultBGTexture, {
ratio,
x,
y,
defaultBGTexture.width * ratio,
defaultBGTexture.height * ratio,
);
});
x += defaultBGTexture.width * ratio;
}
y += defaultBGTexture.height * ratio;
@ -158,6 +183,22 @@ export async function createEasyBackground({ width, height }) {
return canvas;
}
/**
* 使 Tsugu skia-canvas
* scaled drawImage Nest native
*/
export function drawScaledTextureTile(
ctx: CanvasRenderingContext2D,
texture: TextureLike,
{ ratio, x, y }: TextureTileOptions,
) {
ctx.save();
ctx.translate(x, y);
ctx.scale(ratio, ratio);
ctx.drawImage(texture as Image | Canvas, 0, 0);
ctx.restore();
}
/**
* 使
*
@ -197,6 +238,7 @@ export async function createBackground({
width,
height,
}: BackgroundOptions): Promise<Canvas> {
await preloadBangDreamBackgroundAssets();
//将图片铺满画面并且增加20亮度
const backgroundBuffer = await spreadBackgroundImage(
image,

View File

@ -8,15 +8,23 @@ import { loadImageFromPath } from '@/modules/qqbot/plugins/bangdream/src/theme/c
import { getBangDreamAssetPath } from '@/modules/qqbot/plugins/bangdream/src/theme/asset-manifest';
let BGDefaultImage: Image;
/**
* Once
*/
async function loadImageOnce() {
BGDefaultImage = await loadImageFromPath(
getBangDreamAssetPath('backgroundLive'),
);
let outputAssetsPreload: Promise<void> | undefined;
export async function preloadBangDreamOutputAssets() {
if (!outputAssetsPreload) {
outputAssetsPreload = loadImageFromPath(
getBangDreamAssetPath('backgroundLive'),
)
.then((image) => {
BGDefaultImage = image;
})
.catch((error) => {
outputAssetsPreload = undefined;
throw error;
});
}
await outputAssetsPreload;
}
loadImageOnce();
export interface OutputFinalOptions {
startWithSpace?: boolean;
@ -43,8 +51,10 @@ export const outputFinalCanv = async function ({
useEasyBG = true,
useImageBG = false,
text = 'BanG Dream!',
BGimage = BGDefaultImage,
BGimage,
}: OutputFinalOptions): Promise<Canvas> {
await preloadBangDreamOutputAssets();
const backgroundImage = BGimage ?? BGDefaultImage;
let allH = 30;
if (startWithSpace) {
allH += 50;
@ -72,7 +82,7 @@ export const outputFinalCanv = async function ({
} else if (useImageBG) {
ctx.drawImage(
await createImageBackground({
image: BGimage,
image: backgroundImage,
width: maxW,
height: allH,
}),
@ -83,7 +93,7 @@ export const outputFinalCanv = async function ({
ctx.drawImage(
await createBackground({
text,
image: BGimage,
image: backgroundImage,
width: maxW,
height: allH,
}),

View File

@ -9,13 +9,21 @@ import {
} from '@/modules/qqbot/plugins/bangdream/src/theme/title.layout';
let titleImage: Image;
/**
* Once
*/
async function loadImageOnce() {
titleImage = await loadImageFromPath(getBangDreamAssetPath('title'));
let titleImagePreload: Promise<void> | undefined;
export async function preloadBangDreamTitleAssets() {
if (!titleImagePreload) {
titleImagePreload = loadImageFromPath(getBangDreamAssetPath('title'))
.then((image) => {
titleImage = image;
})
.catch((error) => {
titleImagePreload = undefined;
throw error;
});
}
await titleImagePreload;
}
loadImageOnce();
/**
*
@ -25,6 +33,9 @@ loadImageOnce();
* @returns
*/
export function drawTitle(title1: string, title2: string): Canvas {
if (!titleImage) {
throw new Error('BangDream 标题资源未初始化');
}
const canvas = new Canvas(titleImage.width, titleImage.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(

View File

@ -280,7 +280,7 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
const commandRegistry = new QqbotPluginRegistryService({
loadCommandPlugins: () => [commandPlugin],
} as any);
commandRegistry.onModuleInit();
await commandRegistry.onModuleInit();
await expect(
commandRegistry.execute('demo-plugin', 'demo.echo', {}, {}),

View File

@ -21,7 +21,7 @@ describe('QQBot plugin registry operation timeout', () => {
},
],
} as any);
commandRegistry.onModuleInit();
await commandRegistry.onModuleInit();
await expect(
Promise.race([

View File

@ -19,6 +19,24 @@ const createPlugin = (
});
describe('QQBot plugin registry platform key compatibility', () => {
it('activates builtin command plugins before they are registered', async () => {
const activate = jest.fn(async () => undefined);
const plugin = {
...createPlugin('demo-plugin', []),
activate,
};
const registry = new QqbotPluginRegistryService({
loadCommandPlugins: () => [plugin],
} as any);
await registry.onModuleInit();
expect(activate).toHaveBeenCalledTimes(1);
expect(registry.listPlugins().map((item) => item.key)).toEqual([
'demo-plugin',
]);
});
it('uses platform plugin keys as primary keys while resolving legacy command keys', async () => {
const bangdream = createPlugin('bangdream', ['bangDream']);
const ff14Plugin = createPlugin('ff14-market', ['ff14Market']);

View File

@ -0,0 +1,630 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type {
BangDreamCommandInput,
BangDreamOperationHandlerName,
BangDreamOperationKey,
} from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types';
const mockImageBuffer = (name: string) => Buffer.from(`image:${name}`);
const mockFuzzySearch = jest.fn(() => ({ song: [136] }));
const mockSongCtor = jest.fn().mockImplementation((songId: number) => ({
songId,
}));
const mockDrawSongDetail = jest.fn(async () => [
mockImageBuffer('song-detail'),
]);
const mockDrawSongList = jest.fn(async () => [mockImageBuffer('song-list')]);
const mockDrawSongChart = jest.fn(async () => [
mockImageBuffer('song-chart'),
]);
const mockDrawSongRandom = jest.fn(async () => [
mockImageBuffer('song-random'),
]);
const mockDrawSongMetaList = jest.fn(async () => [
mockImageBuffer('song-meta'),
]);
const mockDrawCardDetail = jest.fn(async () => [
mockImageBuffer('card-detail'),
]);
const mockDrawCardList = jest.fn(async () => [mockImageBuffer('card-list')]);
const mockCardGetTrainingStatusList = jest.fn(() => [false, true]);
const mockCardGetCardIllustrationImageBuffer = jest.fn(
async (trainingStatus: boolean) =>
mockImageBuffer(`card-illustration-${trainingStatus}`),
);
const mockCardCtor = jest.fn().mockImplementation((cardId: number) => ({
cardId,
getCardIllustrationImageBuffer: mockCardGetCardIllustrationImageBuffer,
getTrainingStatusList: mockCardGetTrainingStatusList,
isExist: true,
}));
const mockDrawCharacterDetail = jest.fn(async () => [
mockImageBuffer('character-detail'),
]);
const mockDrawCharacterList = jest.fn(async () => [
mockImageBuffer('character-list'),
]);
const mockGetPresentEvent = jest.fn(() => ({ eventId: 50 }));
const mockDrawEventDetail = jest.fn(async () => [
mockImageBuffer('event-detail'),
]);
const mockDrawEventList = jest.fn(async () => [mockImageBuffer('event-list')]);
const mockDrawEventStage = jest.fn(async () =>
Array.from({ length: 5 }, (_, index) =>
mockImageBuffer(`event-stage-${index}`),
),
);
const mockDrawPlayerDetail = jest.fn(async () => [
mockImageBuffer('player-detail'),
]);
const mockDrawGachaDetail = jest.fn(async () => [
mockImageBuffer('gacha-detail'),
]);
const mockDrawRandomGacha = jest.fn(async () => [
mockImageBuffer('gacha-simulate'),
]);
const mockGachaCtor = jest.fn().mockImplementation((gachaId: number) => ({
gachaId,
type: 'normal',
}));
const mockGetPresentGachaList = jest.fn(async () => [
{
gachaId: 300,
type: 'normal',
},
]);
const mockDrawCutoffDetail = jest.fn(async () => [
mockImageBuffer('cutoff-detail'),
]);
const mockDrawCutoffEventTop = jest.fn(async () => [
mockImageBuffer('cutoff-event-top'),
]);
const mockDrawCutoffAll = jest.fn(async () => [
mockImageBuffer('cutoff-all'),
]);
const mockDrawCutoffListOfRecentEvent = jest.fn(async () => [
mockImageBuffer('cutoff-recent'),
]);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search',
() => ({
fuzzySearch: mockFuzzySearch,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song.model',
() => ({
Song: mockSongCtor,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-detail.renderer',
() => ({
drawSongDetail: mockDrawSongDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-search.renderer',
() => ({
drawSongList: mockDrawSongList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-chart.renderer',
() => ({
drawSongChart: mockDrawSongChart,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-random.renderer',
() => ({
drawSongRandom: mockDrawSongRandom,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-meta.renderer',
() => ({
drawSongMetaList: mockDrawSongMetaList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/card/card-detail.renderer',
() => ({
drawCardDetail: mockDrawCardDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/card/card-search.renderer',
() => ({
drawCardList: mockDrawCardList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/card/card.model',
() => ({
Card: mockCardCtor,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/character/character-detail.renderer',
() => ({
drawCharacterDetail: mockDrawCharacterDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/character/character-search.renderer',
() => ({
drawCharacterList: mockDrawCharacterList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/event/event.model',
() => ({
getPresentEvent: mockGetPresentEvent,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/event/event-detail.renderer',
() => ({
drawEventDetail: mockDrawEventDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/event/event-search.renderer',
() => ({
drawEventList: mockDrawEventList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/event/event-stage.renderer',
() => ({
drawEventStage: mockDrawEventStage,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/player/player-detail.renderer',
() => ({
drawPlayerDetail: mockDrawPlayerDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-detail.renderer',
() => ({
drawGachaDetail: mockDrawGachaDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-simulate.renderer',
() => ({
drawRandomGacha: mockDrawRandomGacha,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model',
() => ({
Gacha: mockGachaCtor,
getPresentGachaList: mockGetPresentGachaList,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/policy/gacha.policy',
() => ({
BANGDREAM_GACHA_DEFAULT_SPIN_COUNT: 10,
isBirthdayGachaType: jest.fn(() => false),
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer',
() => ({
drawCutoffDetail: mockDrawCutoffDetail,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-event-top.renderer',
() => ({
drawCutoffEventTop: mockDrawCutoffEventTop,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-all.renderer',
() => ({
drawCutoffAll: mockDrawCutoffAll,
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-recent.renderer',
() => ({
drawCutoffListOfRecentEvent: mockDrawCutoffListOfRecentEvent,
}),
);
type ManifestOperation = {
handlerName: BangDreamOperationHandlerName;
key: BangDreamOperationKey;
};
type BranchCase = {
assertBranch: () => void;
expectedImageCount: number;
expectedQuery: string;
input: BangDreamCommandInput;
key: BangDreamOperationKey;
name: string;
};
const branchCases: BranchCase[] = [
{
assertBranch: () => expect(mockDrawSongDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '136',
input: { text: '136' },
key: 'bangdream.song.search',
name: 'song.search numeric detail',
},
{
assertBranch: () => expect(mockDrawSongList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '夏祭',
input: { text: '夏祭' },
key: 'bangdream.song.search',
name: 'song.search fuzzy list',
},
{
assertBranch: () => expect(mockDrawSongChart).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '136 3',
input: { text: '136 expert' },
key: 'bangdream.song.chart',
name: 'song.chart explicit difficulty',
},
{
assertBranch: () => expect(mockDrawSongRandom).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '随机曲',
input: { text: '' },
key: 'bangdream.song.random',
name: 'song.random empty query',
},
{
assertBranch: () => expect(mockDrawSongMetaList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: 'cn',
input: { text: 'cn' },
key: 'bangdream.song.meta',
name: 'song.meta server',
},
{
assertBranch: () => expect(mockDrawCardDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1001',
input: { text: '1001' },
key: 'bangdream.card.search',
name: 'card.search numeric detail',
},
{
assertBranch: () => expect(mockDrawCardList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '香澄',
input: { text: '香澄' },
key: 'bangdream.card.search',
name: 'card.search fuzzy list',
},
{
assertBranch: () => {
expect(mockCardCtor).toHaveBeenCalledWith(1001);
expect(mockCardGetCardIllustrationImageBuffer).toHaveBeenCalledTimes(2);
},
expectedImageCount: 2,
expectedQuery: '1001',
input: { text: '1001' },
key: 'bangdream.card.illustration',
name: 'card.illustration trained variants',
},
{
assertBranch: () =>
expect(mockDrawCharacterDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1',
input: { text: '1' },
key: 'bangdream.character.search',
name: 'character.search numeric detail',
},
{
assertBranch: () => expect(mockDrawCharacterList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '香澄',
input: { text: '香澄' },
key: 'bangdream.character.search',
name: 'character.search fuzzy list',
},
{
assertBranch: () => expect(mockDrawEventDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '50',
input: { text: '50' },
key: 'bangdream.event.search',
name: 'event.search numeric detail',
},
{
assertBranch: () => expect(mockDrawEventList).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: 'summer',
input: { text: 'summer' },
key: 'bangdream.event.search',
name: 'event.search fuzzy list',
},
{
assertBranch: () => expect(mockDrawEventStage).toHaveBeenCalledWith(
50,
3,
true,
true,
),
expectedImageCount: 5,
expectedQuery: '50',
input: { text: '50 -m cn' },
key: 'bangdream.event.stage',
name: 'event.stage meta split output',
},
{
assertBranch: () => expect(mockDrawPlayerDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '123456',
input: { text: '123456 cn' },
key: 'bangdream.player.search',
name: 'player.search server detail',
},
{
assertBranch: () => expect(mockDrawGachaDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '300',
input: { text: '300' },
key: 'bangdream.gacha.search',
name: 'gacha.search detail',
},
{
assertBranch: () => {
expect(mockGachaCtor).toHaveBeenCalledWith(300);
expect(mockDrawRandomGacha).toHaveBeenCalledTimes(1);
},
expectedImageCount: 1,
expectedQuery: '10 300',
input: { text: '10 300' },
key: 'bangdream.gacha.simulate',
name: 'gacha.simulate explicit gacha',
},
{
assertBranch: () => {
expect(mockGetPresentGachaList).toHaveBeenCalledWith(3);
expect(mockDrawRandomGacha).toHaveBeenCalledTimes(1);
},
expectedImageCount: 1,
expectedQuery: '10',
input: { text: '10 cn' },
key: 'bangdream.gacha.simulate',
name: 'gacha.simulate present gacha fallback',
},
{
assertBranch: () => expect(mockDrawCutoffDetail).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1000 50',
input: { text: 'ycx 1000 cn', eventId: 50 },
key: 'bangdream.cutoff.detail',
name: 'cutoff.detail tier detail',
},
{
assertBranch: () => expect(mockDrawCutoffEventTop).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '10 50',
input: { eventId: 50, mainServer: 'cn', tier: 10 },
key: 'bangdream.cutoff.detail',
name: 'cutoff.detail top10 branch',
},
{
assertBranch: () => expect(mockDrawCutoffAll).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '50',
input: { eventId: 50, text: 'cn' },
key: 'bangdream.cutoff.all',
name: 'cutoff.all event',
},
{
assertBranch: () =>
expect(mockDrawCutoffListOfRecentEvent).toHaveBeenCalledTimes(1),
expectedImageCount: 1,
expectedQuery: '1000 50',
input: { text: '1000 50 cn' },
key: 'bangdream.cutoff.recent',
name: 'cutoff.recent tier',
},
];
const manifestOperations: ManifestOperation[] = JSON.parse(
readFileSync(
join(process.cwd(), 'src/modules/qqbot/plugins/bangdream/plugin.json'),
'utf8',
),
).operations;
let operationsByKey: Map<BangDreamOperationKey, any>;
describe('BangDream operation branch matrix', () => {
beforeAll(async () => {
const { getBangDreamOperationsByHandlerName } = await import(
'@/modules/qqbot/plugins/bangdream/src/operations'
);
const operationsByHandlerName = getBangDreamOperationsByHandlerName();
operationsByKey = new Map(
manifestOperations.map((operation) => [
operation.key,
operationsByHandlerName.get(operation.handlerName),
]),
);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('covers every manifest command operation in the branch matrix', () => {
expect([...new Set(branchCases.map((item) => item.key))].sort()).toEqual(
manifestOperations.map((operation) => operation.key).sort(),
);
});
it.each(branchCases)(
'executes $name and compares output contract',
async (branchCase) => {
const operation = operationsByKey.get(branchCase.key);
expect(operation).toBeDefined();
const output = await operation!.execute(
branchCase.input,
createCommandContext(),
);
expect(output).toMatchObject({
imageCount: branchCase.expectedImageCount,
operationKey: branchCase.key,
query: branchCase.expectedQuery,
source: 'BangDream 内置插件',
});
expect(output.replyText.match(/\[CQ:image,file=base64:\/\//g)).toHaveLength(
branchCase.expectedImageCount,
);
branchCase.assertBranch();
},
);
});
function createCommandContext() {
const pickText = (input: BangDreamCommandInput) =>
`${input.query || input.text || input.raw || ''}`.trim();
const optionalNumber = (value: unknown) => {
if (value === undefined || value === null || value === '') return undefined;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : undefined;
};
const getTokens = (input: BangDreamCommandInput) => {
if (Array.isArray(input.args)) {
return input.args.map((item) => `${item}`.trim()).filter(Boolean);
}
return pickText(input).split(/\s+/).filter(Boolean);
};
const normalizeServer = (value: unknown) => {
if (value === undefined || value === null || value === '') return undefined;
const raw = `${value}`.trim().toLowerCase();
const mapped: Record<string, number> = {
cn: 3,
en: 1,
jp: 0,
kr: 4,
tw: 2,
国服: 3,
日服: 0,
};
const numeric = optionalNumber(raw);
if (numeric !== undefined && numeric >= 0 && numeric <= 4) {
return numeric;
}
return mapped[raw];
};
const pickMainServer = (input: BangDreamCommandInput, tokens: string[]) => {
const explicit =
input.mainServer ??
input.serverName ??
input.server ??
tokens.find((item) => normalizeServer(item) !== undefined);
return normalizeServer(explicit) ?? 3;
};
return {
drawFuzzyResult: async (
_query: string,
render: (matches: Record<string, unknown>) => Promise<Array<Buffer>>,
) => render({ result: [1] }),
firstNumber: (tokens: string[]) =>
tokens.map((item) => optionalNumber(item)).find((item) => item !== undefined),
firstToken: (input: BangDreamCommandInput) => getTokens(input)[0],
getRenderOptions: (
input: BangDreamCommandInput,
defaults: { useEasyBG?: boolean } = {},
) => ({
compress: input.compress === undefined ? true : input.compress !== false,
displayedServerList: [3, 0],
mainServer: pickMainServer(input, getTokens(input)),
useEasyBG:
input.useEasyBG === undefined
? defaults.useEasyBG ?? false
: input.useEasyBG !== false,
}),
getTokens,
isInteger: (value: string) => /^(0|[1-9]\d*)$/.test(value),
normalizeBoolean: (value: unknown, fallback: boolean) => {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
return ['1', 'true', 'yes', 'y', '-m'].includes(
`${value}`.trim().toLowerCase(),
);
},
optionalNumber,
pickDifficulty: (value: unknown) => {
const numeric = optionalNumber(value);
if (numeric !== undefined) return numeric;
const mapped: Record<string, number> = {
easy: 0,
expert: 3,
hard: 2,
normal: 1,
special: 4,
};
return mapped[`${value || ''}`.trim().toLowerCase()];
},
pickMainServer,
pickText,
requireNumber: (explicit: unknown, fallback: unknown, message: string) => {
const value = optionalNumber(explicit) ?? optionalNumber(fallback);
if (value === undefined) throw new Error(message);
return value;
},
requireText: (input: BangDreamCommandInput, message: string) => {
const value = pickText(input);
if (!value) throw new Error(message);
return value;
},
secondNumber: (tokens: string[]) =>
tokens
.map((item) => optionalNumber(item))
.filter((item) => item !== undefined)[1],
toImageReply: (
operationKey: BangDreamOperationKey,
query: string,
list: Array<Buffer | string>,
) => {
const images = list.filter((item): item is Buffer => Buffer.isBuffer(item));
if (images.length === 0) {
throw new Error(
list.find((item): item is string => typeof item === 'string') ||
'BangDream 未返回图片',
);
}
return {
imageCount: images.length,
operationKey,
query,
replyText: images
.map((item) => `[CQ:image,file=base64://${item.toString('base64')}]`)
.join('\n'),
source: 'BangDream 内置插件',
};
},
} as any;
}

View File

@ -9,6 +9,7 @@ const mockContext = {
refreshDictionaryCache: jest.fn(),
};
const mockConfigureBangDreamRuntimeIo = jest.fn();
const mockPreloadBangDreamRenderAssets = jest.fn();
const mockOperationModules = new Map<string, jest.Mock>();
const mockManifestOperations = [
['bangdream.song.search', 'searchSong'],
@ -42,6 +43,13 @@ jest.mock(
}),
);
jest.mock(
'@/modules/qqbot/plugins/bangdream/src/application/render-assets',
() => ({
preloadBangDreamRenderAssets: mockPreloadBangDreamRenderAssets,
}),
);
jest.mock('@/modules/qqbot/plugins/bangdream/src/operations', () => ({
getBangDreamOperationsByHandlerName: () =>
new Map(
@ -85,6 +93,7 @@ describe('BangDream package entry', () => {
mockOperationModules.clear();
mockContext.refreshDictionaryCache.mockResolvedValue(undefined);
mockContext.checkHealth.mockResolvedValue(true);
mockPreloadBangDreamRenderAssets.mockResolvedValue(undefined);
});
it('binds every manifest operation directly to the package operation modules', async () => {
@ -120,6 +129,7 @@ describe('BangDream package entry', () => {
});
expect(mockContext.refreshDictionaryCache).toHaveBeenCalledTimes(1);
expect(mockPreloadBangDreamRenderAssets).toHaveBeenCalledTimes(1);
expect(waitForBangDreamCatalogReady).toHaveBeenCalledWith([
'songs',
'meta',

View File

@ -36,4 +36,57 @@ describe('BangDream song search rendering', () => {
expect(order).toEqual([1, 2, 3]);
expect(images.map((image) => image.width)).toEqual([1, 2, 3]);
});
it('keeps Tsugu song list style by loading jackets with the requested server priority', async () => {
const { Canvas } = await import('skia-canvas');
const { drawSongInList } = await import(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-list.renderer'
);
const requestedServers = [Server.cn, Server.jp];
const getSongJacketImage = jest.fn(async () => new Canvas(64, 64));
const song = {
bandId: 1,
difficulty: {
0: { playLevel: 9 },
},
getSongJacketImage,
musicTitle: ['FIRE BIRD', 'FIRE BIRD', 'FIRE BIRD', 'FIRE BIRD'],
publishedAt: [1, 1, 1, 1, 1],
songId: 187,
} as unknown as Song;
await drawSongInList(song, undefined, 'Roselia', requestedServers);
expect(getSongJacketImage).toHaveBeenCalledWith(requestedServers);
});
it('does not replace fuzzy search list jackets with lightweight placeholders', async () => {
const { renderSongListItemsSequentially } = await import(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-search.renderer'
);
const renderOptions: unknown[] = [];
const songs = [187, 243].map(
(songId) =>
({
songId,
}) as Song,
);
const renderer = (async (
_song,
_difficulty,
_text,
_displayedServerList,
options,
) => {
renderOptions.push(options);
return {
height: 1,
width: 1,
} as any;
}) as any;
await renderSongListItemsSequentially(songs, [Server.cn], renderer);
expect(renderOptions).toEqual([undefined, undefined]);
});
});

View File

@ -0,0 +1,43 @@
import { drawScaledTextureTile } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-background';
describe('BangDream easy background rendering', () => {
it('keeps Tsugu texture scale while avoiding scaled drawImage overload', () => {
const calls: string[] = [];
const context = {
drawImage: jest.fn((_image, x, y, width, height) => {
calls.push(
['drawImage', x, y, width ?? 'none', height ?? 'none'].join(':'),
);
}),
restore: jest.fn(() => calls.push('restore')),
save: jest.fn(() => calls.push('save')),
scale: jest.fn((x, y) => calls.push(`scale:${x}:${y}`)),
translate: jest.fn((x, y) => calls.push(`translate:${x}:${y}`)),
};
const texture = {
height: 1002,
width: 1334,
};
drawScaledTextureTile(context as any, texture as any, {
ratio: 1.334,
x: -12,
y: 34,
});
expect(calls).toEqual([
'save',
'translate:-12:34',
'scale:1.334:1.334',
'drawImage:0:0:none:none',
'restore',
]);
expect(context.drawImage).not.toHaveBeenCalledWith(
texture,
-12,
34,
texture.width * 1.334,
texture.height * 1.334,
);
});
});

View File

@ -0,0 +1,77 @@
import { readFile, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import * as XLSX from 'xlsx';
async function collectTypeScriptFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) return collectTypeScriptFiles(fullPath);
if (!entry.isFile() || !entry.name.endsWith('.ts')) return [];
const fileStat = await stat(fullPath);
return fileStat.isFile() ? [fullPath] : [];
}),
);
return files.flat();
}
describe('BangDream theme asset preload', () => {
it('loads title assets after runtime IO is configured during plugin activation', async () => {
jest.resetModules();
const { createPlugin } = await import(
'@/modules/qqbot/plugins/bangdream/src'
);
const manifest = JSON.parse(
await readFile(
join(
process.cwd(),
'src/modules/qqbot/plugins/bangdream/plugin.json',
),
'utf8',
),
);
const plugin = createPlugin({
io: {
readAssetFile: async (filePath) => readFile(filePath),
readExcelRows: async (filePath) => {
const workbook = XLSX.readFile(filePath);
return XLSX.utils.sheet_to_json(
workbook.Sheets[workbook.SheetNames[0]],
);
},
},
operations: manifest.operations.map((operation) => ({
handlerName: operation.handlerName as any,
key: operation.key as any,
})),
});
await plugin.activate();
const { drawTitle } = await import(
'@/modules/qqbot/plugins/bangdream/src/theme/title.renderer'
);
const title = drawTitle('查询', '歌曲列表');
expect(title.width).toBe(587);
expect(title.height).toBe(110);
});
it('does not load local render assets at module import time', async () => {
const srcRoot = join(
process.cwd(),
'src/modules/qqbot/plugins/bangdream/src',
);
const files = await collectTypeScriptFiles(srcRoot);
const eagerLoaders: string[] = [];
for (const file of files) {
const source = await readFile(file, 'utf8');
if (source.includes('loadImageOnce();')) {
eagerLoaders.push(file.replace(process.cwd(), '').replace(/^[\\/]/, ''));
}
}
expect(eagerLoaders).toEqual([]);
});
});