From 83eef0dffa636859422aa5870ce35b767c42dd7c Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 16 Jun 2026 10:03:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DBangDream=20Tsugu?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E8=B5=84=E6=BA=90=E9=A2=84=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/bangdream-render-smoke-matrix.ps1 | 257 +++++++ .../qqbot/core/contract/qqbot.types.ts | 1 + .../registry/qqbot-plugin-registry.service.ts | 1 + .../src/application/render-assets.ts | 19 + .../src/domain/card/card-art.renderer.ts | 64 +- .../src/domain/card/card-prefix.renderer.ts | 19 +- .../src/domain/card/card-rarity.renderer.ts | 28 +- .../domain/card/card-skill-text.renderer.ts | 33 +- .../domain/player/player-detail.renderer.ts | 25 +- .../src/domain/song/song-list.renderer.ts | 2 +- .../qqbot/plugins/bangdream/src/index.ts | 8 +- .../bangdream/src/theme/canvas-background.ts | 74 +- .../bangdream/src/theme/canvas-output.ts | 32 +- .../bangdream/src/theme/title.renderer.ts | 23 +- .../plugin-lifecycle-runtime.spec.ts | 2 +- .../plugin-registry-timeout.spec.ts | 2 +- .../plugins/plugin-registry-compat.spec.ts | 18 + .../bangdream-operation-branch-matrix.spec.ts | 630 ++++++++++++++++++ .../bangdream-package-entry.spec.ts | 10 + .../song/song-search-rendering.spec.ts | 53 ++ .../bangdream/theme/canvas-background.spec.ts | 43 ++ .../theme/theme-asset-preload.spec.ts | 77 +++ 22 files changed, 1317 insertions(+), 104 deletions(-) create mode 100644 scripts/bangdream-render-smoke-matrix.ps1 create mode 100644 src/modules/qqbot/plugins/bangdream/src/application/render-assets.ts create mode 100644 test/qqbot/plugins/bangdream/application/bangdream-operation-branch-matrix.spec.ts create mode 100644 test/qqbot/plugins/bangdream/theme/canvas-background.spec.ts create mode 100644 test/qqbot/plugins/bangdream/theme/theme-asset-preload.spec.ts 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 { + 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 { + await preloadBangDreamCardArtAssets(); trainingStatus = card.ableToTraining(trainingStatus); const spec = BANGDREAM_CARD_ART_SPEC.illustration; const cardIllustrationImage = diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-prefix.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-prefix.renderer.ts index df68b7c..66b64e2 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-prefix.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-prefix.renderer.ts @@ -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列表。 diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-rarity.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-rarity.renderer.ts index 1770ce8..2aade61 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-rarity.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-rarity.renderer.ts @@ -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 | 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 { + await preloadBangDreamCardRarityAssets(); const content: Array = []; let star: Image; if (shouldUseTrainedRarityStar(rarity, trainingStatus)) { diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-skill-text.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-skill-text.renderer.ts index 668dab1..5ee2fc8 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-skill-text.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-skill-text.renderer.ts @@ -11,19 +11,27 @@ import { } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-skill-text.layout'; const skillIcon: Partial> = {}; -/** - * 在图片布局层中加载图片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 | 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 { + await preloadBangDreamCardSkillTextAssets(); const content = createSkillTextFragments({ effectTypes: skill.getEffectTypes(), scoreUpMaxValue: skill.getScoreUpMaxValue(), diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-detail.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-detail.renderer.ts index 002f795..3a1b632 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-detail.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-detail.renderer.ts @@ -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 | 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> { + await preloadBangDreamPlayerAssets(); const result = []; let player = playerRepository.create(playerId, mainServer); //不使用缓存查询 diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/song/song-list.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/song/song-list.renderer.ts index eb2d9fd..f71b5e0 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/song/song-list.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/song/song-list.renderer.ts @@ -40,7 +40,7 @@ export async function drawSongInList( ): Promise { 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, }); diff --git a/src/modules/qqbot/plugins/bangdream/src/index.ts b/src/modules/qqbot/plugins/bangdream/src/index.ts index e3b2efe..eeede24 100644 --- a/src/modules/qqbot/plugins/bangdream/src/index.ts +++ b/src/modules/qqbot/plugins/bangdream/src/index.ts @@ -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, diff --git a/src/modules/qqbot/plugins/bangdream/src/theme/canvas-background.ts b/src/modules/qqbot/plugins/bangdream/src/theme/canvas-background.ts index cf65e82..166e58c 100644 --- a/src/modules/qqbot/plugins/bangdream/src/theme/canvas-background.ts +++ b/src/modules/qqbot/plugins/bangdream/src/theme/canvas-background.ts @@ -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 | 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 { + await preloadBangDreamBackgroundAssets(); //将图片铺满画面,并且增加20亮度 const backgroundBuffer = await spreadBackgroundImage( image, diff --git a/src/modules/qqbot/plugins/bangdream/src/theme/canvas-output.ts b/src/modules/qqbot/plugins/bangdream/src/theme/canvas-output.ts index 2d58212..77a2d86 100644 --- a/src/modules/qqbot/plugins/bangdream/src/theme/canvas-output.ts +++ b/src/modules/qqbot/plugins/bangdream/src/theme/canvas-output.ts @@ -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 | 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 { + 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, }), diff --git a/src/modules/qqbot/plugins/bangdream/src/theme/title.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/theme/title.renderer.ts index 5a9e38e..79bc653 100644 --- a/src/modules/qqbot/plugins/bangdream/src/theme/title.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/theme/title.renderer.ts @@ -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 | 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( diff --git a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts index e465130..b9499b2 100644 --- a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts @@ -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', {}, {}), diff --git a/test/modules/qqbot/plugin-platform/plugin-registry-timeout.spec.ts b/test/modules/qqbot/plugin-platform/plugin-registry-timeout.spec.ts index b592cc9..606e248 100644 --- a/test/modules/qqbot/plugin-platform/plugin-registry-timeout.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-registry-timeout.spec.ts @@ -21,7 +21,7 @@ describe('QQBot plugin registry operation timeout', () => { }, ], } as any); - commandRegistry.onModuleInit(); + await commandRegistry.onModuleInit(); await expect( Promise.race([ diff --git a/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts b/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts index 2bac09e..95df8f6 100644 --- a/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts +++ b/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts @@ -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']); diff --git a/test/qqbot/plugins/bangdream/application/bangdream-operation-branch-matrix.spec.ts b/test/qqbot/plugins/bangdream/application/bangdream-operation-branch-matrix.spec.ts new file mode 100644 index 0000000..73c0d39 --- /dev/null +++ b/test/qqbot/plugins/bangdream/application/bangdream-operation-branch-matrix.spec.ts @@ -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; + +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 = { + 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) => Promise>, + ) => 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 = { + 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, + ) => { + 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; +} diff --git a/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts b/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts index 8e4c205..1b65b0b 100644 --- a/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts +++ b/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts @@ -9,6 +9,7 @@ const mockContext = { refreshDictionaryCache: jest.fn(), }; const mockConfigureBangDreamRuntimeIo = jest.fn(); +const mockPreloadBangDreamRenderAssets = jest.fn(); const mockOperationModules = new Map(); 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', diff --git a/test/qqbot/plugins/bangdream/song/song-search-rendering.spec.ts b/test/qqbot/plugins/bangdream/song/song-search-rendering.spec.ts index de58109..47d5a56 100644 --- a/test/qqbot/plugins/bangdream/song/song-search-rendering.spec.ts +++ b/test/qqbot/plugins/bangdream/song/song-search-rendering.spec.ts @@ -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]); + }); }); diff --git a/test/qqbot/plugins/bangdream/theme/canvas-background.spec.ts b/test/qqbot/plugins/bangdream/theme/canvas-background.spec.ts new file mode 100644 index 0000000..de98b2c --- /dev/null +++ b/test/qqbot/plugins/bangdream/theme/canvas-background.spec.ts @@ -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, + ); + }); +}); diff --git a/test/qqbot/plugins/bangdream/theme/theme-asset-preload.spec.ts b/test/qqbot/plugins/bangdream/theme/theme-asset-preload.spec.ts new file mode 100644 index 0000000..22d5536 --- /dev/null +++ b/test/qqbot/plugins/bangdream/theme/theme-asset-preload.spec.ts @@ -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 { + 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([]); + }); +});