diff --git a/scripts/bangdream-render-smoke-matrix.ps1 b/scripts/bangdream-render-smoke-matrix.ps1
new file mode 100644
index 0000000..05e3257
--- /dev/null
+++ b/scripts/bangdream-render-smoke-matrix.ps1
@@ -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
diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts
index 48be41f..3972e16 100644
--- a/src/modules/qqbot/core/contract/qqbot.types.ts
+++ b/src/modules/qqbot/core/contract/qqbot.types.ts
@@ -167,6 +167,7 @@ export type QqbotPluginOperation = {
};
export type QqbotIntegrationPlugin = {
+ activate?: () => Promise | unknown;
description?: string;
healthCheck?: () => Promise;
key: string;
diff --git a/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts b/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts
index 3756175..19adbf0 100644
--- a/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts
+++ b/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts
@@ -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();
diff --git a/src/modules/qqbot/plugins/bangdream/src/application/render-assets.ts b/src/modules/qqbot/plugins/bangdream/src/application/render-assets.ts
new file mode 100644
index 0000000..ee8b9cb
--- /dev/null
+++ b/src/modules/qqbot/plugins/bangdream/src/application/render-assets.ts
@@ -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(),
+ ]);
+}
diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-art.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-art.renderer.ts
index a7d8003..19c111c 100644
--- a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-art.renderer.ts
+++ b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-art.renderer.ts
@@ -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 | 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