fix: 修复BangDream Tsugu渲染资源预加载
This commit is contained in:
parent
0127628a1e
commit
83eef0dffa
257
scripts/bangdream-render-smoke-matrix.ps1
Normal file
257
scripts/bangdream-render-smoke-matrix.ps1
Normal 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
|
||||||
@ -167,6 +167,7 @@ export type QqbotPluginOperation<Input = any, Output = any> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type QqbotIntegrationPlugin = {
|
export type QqbotIntegrationPlugin = {
|
||||||
|
activate?: () => Promise<unknown> | unknown;
|
||||||
description?: string;
|
description?: string;
|
||||||
healthCheck?: () => Promise<QqbotPluginHealth>;
|
healthCheck?: () => Promise<QqbotPluginHealth>;
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export class QqbotPluginRegistryService implements OnModuleInit {
|
|||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) {
|
for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) {
|
||||||
|
await plugin.activate?.();
|
||||||
this.register(plugin);
|
this.register(plugin);
|
||||||
}
|
}
|
||||||
await this.hydrateInactivePluginKeys();
|
await this.hydrateInactivePluginKeys();
|
||||||
|
|||||||
@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@ -17,34 +17,46 @@ import { cardArtResourceRepository } from '@/modules/qqbot/plugins/bangdream/src
|
|||||||
const cardTypeIconList: { [type: string]: Image } = {};
|
const cardTypeIconList: { [type: string]: Image } = {};
|
||||||
const starList: { [type: string]: Image } = {};
|
const starList: { [type: string]: Image } = {};
|
||||||
let limitBreakIcon: Image;
|
let limitBreakIcon: Image;
|
||||||
|
let cardArtAssetsPreload: Promise<void> | undefined;
|
||||||
|
|
||||||
/**
|
export async function preloadBangDreamCardArtAssets() {
|
||||||
* 在图片布局层中加载图片Once。
|
if (!cardArtAssetsPreload) {
|
||||||
*/
|
cardArtAssetsPreload = Promise.all([
|
||||||
async function loadImageOnce() {
|
loadImageFromPath(getBangDreamAssetPath('cardLimited')),
|
||||||
cardTypeIconList.limited = await loadImageFromPath(
|
loadImageFromPath(getBangDreamAssetPath('cardDreamfes')),
|
||||||
getBangDreamAssetPath('cardLimited'),
|
loadImageFromPath(getBangDreamAssetPath('cardKirafes')),
|
||||||
);
|
loadImageFromPath(getBangDreamAssetPath('cardBirthday')),
|
||||||
cardTypeIconList.dreamfes = await loadImageFromPath(
|
loadImageFromPath(getBangDreamAssetPath('cardStar')),
|
||||||
getBangDreamAssetPath('cardDreamfes'),
|
loadImageFromPath(getBangDreamAssetPath('cardStarTrained')),
|
||||||
);
|
loadImageFromPath(getBangDreamAssetPath('cardLimitBreakRank')),
|
||||||
cardTypeIconList.kirafes = await loadImageFromPath(
|
])
|
||||||
getBangDreamAssetPath('cardKirafes'),
|
.then(
|
||||||
);
|
([
|
||||||
cardTypeIconList.birthday = await loadImageFromPath(
|
limited,
|
||||||
getBangDreamAssetPath('cardBirthday'),
|
dreamfes,
|
||||||
);
|
kirafes,
|
||||||
starList.normal = await loadImageFromPath(getBangDreamAssetPath('cardStar'));
|
birthday,
|
||||||
starList.trained = await loadImageFromPath(
|
normalStar,
|
||||||
getBangDreamAssetPath('cardStarTrained'),
|
trainedStar,
|
||||||
);
|
limitBreak,
|
||||||
limitBreakIcon = await loadImageFromPath(
|
]) => {
|
||||||
getBangDreamAssetPath('cardLimitBreakRank'),
|
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。
|
* 在图片布局层中获取卡牌图标Frame。
|
||||||
@ -113,6 +125,7 @@ export async function drawCardIcon({
|
|||||||
cardTypeVisible = true,
|
cardTypeVisible = true,
|
||||||
skillLevel,
|
skillLevel,
|
||||||
}: DrawCardIconOptions): Promise<Canvas> {
|
}: DrawCardIconOptions): Promise<Canvas> {
|
||||||
|
await preloadBangDreamCardArtAssets();
|
||||||
trainingStatus = card.ableToTraining(trainingStatus);
|
trainingStatus = card.ableToTraining(trainingStatus);
|
||||||
illustrationTrainingStatus ??= trainingStatus;
|
illustrationTrainingStatus ??= trainingStatus;
|
||||||
const spec = BANGDREAM_CARD_ART_SPEC.icon;
|
const spec = BANGDREAM_CARD_ART_SPEC.icon;
|
||||||
@ -248,6 +261,7 @@ export async function drawCardIllustration({
|
|||||||
trainingStatus,
|
trainingStatus,
|
||||||
isList = false,
|
isList = false,
|
||||||
}: DrawCardIllustrationOptions): Promise<Canvas> {
|
}: DrawCardIllustrationOptions): Promise<Canvas> {
|
||||||
|
await preloadBangDreamCardArtAssets();
|
||||||
trainingStatus = card.ableToTraining(trainingStatus);
|
trainingStatus = card.ableToTraining(trainingStatus);
|
||||||
const spec = BANGDREAM_CARD_ART_SPEC.illustration;
|
const spec = BANGDREAM_CARD_ART_SPEC.illustration;
|
||||||
const cardIllustrationImage =
|
const cardIllustrationImage =
|
||||||
|
|||||||
@ -14,19 +14,12 @@ import {
|
|||||||
getCardPrefixBandLogoLayout,
|
getCardPrefixBandLogoLayout,
|
||||||
} from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-prefix.layout';
|
} from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-prefix.layout';
|
||||||
|
|
||||||
let prefixBG: Canvas;
|
const prefixBG: Canvas = drawRoundedRect({
|
||||||
/**
|
width: BANGDREAM_CARD_PREFIX_SPEC.canvas.width,
|
||||||
* 在图片布局层中加载图片Once。
|
height: BANGDREAM_CARD_PREFIX_SPEC.canvas.height,
|
||||||
*/
|
color: BANGDREAM_CARD_PREFIX_SPEC.background.color,
|
||||||
async function loadImageOnce() {
|
radius: [...BANGDREAM_CARD_PREFIX_SPEC.background.radius],
|
||||||
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();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在图片布局层中绘制卡牌PrefixIn列表。
|
* 在图片布局层中绘制卡牌PrefixIn列表。
|
||||||
|
|||||||
@ -15,16 +15,25 @@ interface RarityInListOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const starList: { [type: string]: Image } = {};
|
export const starList: { [type: string]: Image } = {};
|
||||||
/**
|
let rarityAssetsPreload: Promise<void> | undefined;
|
||||||
* 在图片布局层中加载图片Once。
|
|
||||||
*/
|
export async function preloadBangDreamCardRarityAssets() {
|
||||||
async function loadImageOnce() {
|
if (!rarityAssetsPreload) {
|
||||||
starList.normal = await loadImageFromPath(getBangDreamAssetPath('cardStar'));
|
rarityAssetsPreload = Promise.all([
|
||||||
starList.trained = await loadImageFromPath(
|
loadImageFromPath(getBangDreamAssetPath('cardStar')),
|
||||||
getBangDreamAssetPath('cardStarTrained'),
|
loadImageFromPath(getBangDreamAssetPath('cardStarTrained')),
|
||||||
);
|
])
|
||||||
|
.then(([normal, trained]) => {
|
||||||
|
starList.normal = normal;
|
||||||
|
starList.trained = trained;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
rarityAssetsPreload = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await rarityAssetsPreload;
|
||||||
}
|
}
|
||||||
loadImageOnce();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在图片布局层中绘制RarityIn列表。
|
* 在图片布局层中绘制RarityIn列表。
|
||||||
@ -38,6 +47,7 @@ export async function drawRarityInList({
|
|||||||
trainingStatus = true,
|
trainingStatus = true,
|
||||||
text,
|
text,
|
||||||
}: RarityInListOptions): Promise<Canvas> {
|
}: RarityInListOptions): Promise<Canvas> {
|
||||||
|
await preloadBangDreamCardRarityAssets();
|
||||||
const content: Array<string | Image | Canvas> = [];
|
const content: Array<string | Image | Canvas> = [];
|
||||||
let star: Image;
|
let star: Image;
|
||||||
if (shouldUseTrainedRarityStar(rarity, trainingStatus)) {
|
if (shouldUseTrainedRarityStar(rarity, trainingStatus)) {
|
||||||
|
|||||||
@ -11,19 +11,27 @@ import {
|
|||||||
} from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-skill-text.layout';
|
} from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-skill-text.layout';
|
||||||
|
|
||||||
const skillIcon: Partial<Record<BangDreamSkillIconKey, Image>> = {};
|
const skillIcon: Partial<Record<BangDreamSkillIconKey, Image>> = {};
|
||||||
/**
|
let skillTextAssetsPreload: Promise<void> | undefined;
|
||||||
* 在图片布局层中加载图片Once。
|
|
||||||
*/
|
export async function preloadBangDreamCardSkillTextAssets() {
|
||||||
async function loadImageOnce() {
|
if (!skillTextAssetsPreload) {
|
||||||
skillIcon.life = await loadImageFromPath(getBangDreamAssetPath('skillLife'));
|
skillTextAssetsPreload = Promise.all([
|
||||||
skillIcon.judge = await loadImageFromPath(
|
loadImageFromPath(getBangDreamAssetPath('skillLife')),
|
||||||
getBangDreamAssetPath('skillJudge'),
|
loadImageFromPath(getBangDreamAssetPath('skillJudge')),
|
||||||
);
|
loadImageFromPath(getBangDreamAssetPath('skillDamage')),
|
||||||
skillIcon.damage = await 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右下角的技能描述图标
|
//卡牌Icon右下角的技能描述图标
|
||||||
/**
|
/**
|
||||||
@ -33,6 +41,7 @@ loadImageOnce();
|
|||||||
* @returns 异步处理结果。
|
* @returns 异步处理结果。
|
||||||
*/
|
*/
|
||||||
export async function drawCardIconSkill(skill: Skill): Promise<Canvas> {
|
export async function drawCardIconSkill(skill: Skill): Promise<Canvas> {
|
||||||
|
await preloadBangDreamCardSkillTextAssets();
|
||||||
const content = createSkillTextFragments({
|
const content = createSkillTextFragments({
|
||||||
effectTypes: skill.getEffectTypes(),
|
effectTypes: skill.getEffectTypes(),
|
||||||
scoreUpMaxValue: skill.getScoreUpMaxValue(),
|
scoreUpMaxValue: skill.getScoreUpMaxValue(),
|
||||||
|
|||||||
@ -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';
|
import { playerRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/player/player.repository';
|
||||||
|
|
||||||
let BGDefaultImage: Image;
|
let BGDefaultImage: Image;
|
||||||
/**
|
let playerAssetsPreload: Promise<void> | undefined;
|
||||||
* 在QQBot 图片视图层中加载图片Once。
|
|
||||||
*/
|
export async function preloadBangDreamPlayerAssets() {
|
||||||
async function loadImageOnce() {
|
if (!playerAssetsPreload) {
|
||||||
BGDefaultImage = await loadImageFromPath(
|
playerAssetsPreload = loadImageFromPath(
|
||||||
path.join(assetsRootPath, '/BG/common.png'),
|
path.join(assetsRootPath, '/BG/common.png'),
|
||||||
);
|
)
|
||||||
|
.then((image) => {
|
||||||
|
BGDefaultImage = image;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
playerAssetsPreload = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await playerAssetsPreload;
|
||||||
}
|
}
|
||||||
loadImageOnce();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在QQBot 图片视图层中绘制玩家详情。
|
* 在QQBot 图片视图层中绘制玩家详情。
|
||||||
@ -50,6 +58,7 @@ export async function drawPlayerDetail(
|
|||||||
useEasyBG: boolean,
|
useEasyBG: boolean,
|
||||||
compress: boolean,
|
compress: boolean,
|
||||||
): Promise<Array<Buffer | string>> {
|
): Promise<Array<Buffer | string>> {
|
||||||
|
await preloadBangDreamPlayerAssets();
|
||||||
const result = [];
|
const result = [];
|
||||||
let player = playerRepository.create(playerId, mainServer);
|
let player = playerRepository.create(playerId, mainServer);
|
||||||
//不使用缓存查询
|
//不使用缓存查询
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export async function drawSongInList(
|
|||||||
): Promise<Canvas> {
|
): Promise<Canvas> {
|
||||||
const server = getServerByPriority(song.publishedAt, displayedServerList);
|
const server = getServerByPriority(song.publishedAt, displayedServerList);
|
||||||
const songImage = resizeImage({
|
const songImage = resizeImage({
|
||||||
image: await song.getSongJacketImage(),
|
image: await song.getSongJacketImage(displayedServerList),
|
||||||
widthMax: BANGDREAM_SONG_LIST_SPEC.item.jacketSourceWidthMax,
|
widthMax: BANGDREAM_SONG_LIST_SPEC.item.jacketSourceWidthMax,
|
||||||
heightMax: BANGDREAM_SONG_LIST_SPEC.item.jacketSourceHeightMax,
|
heightMax: BANGDREAM_SONG_LIST_SPEC.item.jacketSourceHeightMax,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import type {
|
|||||||
BangDreamOperationKey,
|
BangDreamOperationKey,
|
||||||
} from './domain/common/bangdream.types';
|
} from './domain/common/bangdream.types';
|
||||||
import { waitForBangDreamCatalogReady } from './application/catalog/bangdream-catalog-cache';
|
import { waitForBangDreamCatalogReady } from './application/catalog/bangdream-catalog-cache';
|
||||||
|
import { preloadBangDreamRenderAssets } from './application/render-assets';
|
||||||
|
|
||||||
type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & {
|
type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & {
|
||||||
description?: string;
|
description?: string;
|
||||||
@ -77,7 +78,12 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activate: () => context.refreshDictionaryCache(),
|
activate: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
context.refreshDictionaryCache(),
|
||||||
|
preloadBangDreamRenderAssets(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
description: options.description,
|
description: options.description,
|
||||||
dispose: async () => undefined,
|
dispose: async () => undefined,
|
||||||
executeOperation,
|
executeOperation,
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { createBlurredTrianglePattern } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-background-triangle';
|
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 { scatterImages } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-background-scatter';
|
||||||
import { drawTextOnCanvas } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-background-text';
|
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 { loadImageFromPath } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image';
|
||||||
import { getBangDreamAssetPath } from '@/modules/qqbot/plugins/bangdream/src/theme/asset-manifest';
|
import { getBangDreamAssetPath } from '@/modules/qqbot/plugins/bangdream/src/theme/asset-manifest';
|
||||||
import { BANGDREAM_RENDER_THEME } from '@/modules/qqbot/plugins/bangdream/src/theme/render-theme';
|
import { BANGDREAM_RENDER_THEME } from '@/modules/qqbot/plugins/bangdream/src/theme/render-theme';
|
||||||
@ -13,6 +18,17 @@ interface BackgroundOptions {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TextureLike {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextureTileOptions {
|
||||||
|
ratio: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 将图片等比例缩放并重复铺满整个画布,并且增加亮度
|
// 将图片等比例缩放并重复铺满整个画布,并且增加亮度
|
||||||
/**
|
/**
|
||||||
* 在底层绘图工具层中处理spreadBackground图片。
|
* 在底层绘图工具层中处理spreadBackground图片。
|
||||||
@ -114,17 +130,27 @@ function getScaledDimensions(
|
|||||||
const star: Image[] = [];
|
const star: Image[] = [];
|
||||||
|
|
||||||
let defaultBGTexture: Image;
|
let defaultBGTexture: Image;
|
||||||
/**
|
let backgroundAssetsPreload: Promise<void> | undefined;
|
||||||
* 在底层绘图工具层中加载图片Once。
|
|
||||||
*/
|
export async function preloadBangDreamBackgroundAssets() {
|
||||||
async function loadImageOnce() {
|
if (!backgroundAssetsPreload) {
|
||||||
star.push(await loadImageFromPath(getBangDreamAssetPath('backgroundStar1')));
|
backgroundAssetsPreload = Promise.all([
|
||||||
star.push(await loadImageFromPath(getBangDreamAssetPath('backgroundStar2')));
|
loadImageFromPath(getBangDreamAssetPath('backgroundStar1')),
|
||||||
defaultBGTexture = await loadImageFromPath(
|
loadImageFromPath(getBangDreamAssetPath('backgroundStar2')),
|
||||||
getBangDreamAssetPath('backgroundObjectBig'),
|
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。
|
* 在底层绘图工具层中创建简易Background。
|
||||||
@ -132,6 +158,7 @@ loadImageOnce();
|
|||||||
* @param options1 - options1参数。
|
* @param options1 - options1参数。
|
||||||
*/
|
*/
|
||||||
export async function createEasyBackground({ width, height }) {
|
export async function createEasyBackground({ width, height }) {
|
||||||
|
await preloadBangDreamBackgroundAssets();
|
||||||
const bgColor = BANGDREAM_RENDER_THEME.color.backgroundEasy;
|
const bgColor = BANGDREAM_RENDER_THEME.color.backgroundEasy;
|
||||||
const canvas: Canvas = new Canvas(width, height);
|
const canvas: Canvas = new Canvas(width, height);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@ -144,13 +171,11 @@ export async function createEasyBackground({ width, height }) {
|
|||||||
while (y < height) {
|
while (y < height) {
|
||||||
x = 0 - Math.random() * defaultBGTexture.width * ratio;
|
x = 0 - Math.random() * defaultBGTexture.width * ratio;
|
||||||
while (x < width) {
|
while (x < width) {
|
||||||
ctx.drawImage(
|
drawScaledTextureTile(ctx, defaultBGTexture, {
|
||||||
defaultBGTexture,
|
ratio,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
defaultBGTexture.width * ratio,
|
});
|
||||||
defaultBGTexture.height * ratio,
|
|
||||||
);
|
|
||||||
x += defaultBGTexture.width * ratio;
|
x += defaultBGTexture.width * ratio;
|
||||||
}
|
}
|
||||||
y += defaultBGTexture.height * ratio;
|
y += defaultBGTexture.height * ratio;
|
||||||
@ -158,6 +183,22 @@ export async function createEasyBackground({ width, height }) {
|
|||||||
return canvas;
|
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,
|
width,
|
||||||
height,
|
height,
|
||||||
}: BackgroundOptions): Promise<Canvas> {
|
}: BackgroundOptions): Promise<Canvas> {
|
||||||
|
await preloadBangDreamBackgroundAssets();
|
||||||
//将图片铺满画面,并且增加20亮度
|
//将图片铺满画面,并且增加20亮度
|
||||||
const backgroundBuffer = await spreadBackgroundImage(
|
const backgroundBuffer = await spreadBackgroundImage(
|
||||||
image,
|
image,
|
||||||
|
|||||||
@ -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';
|
import { getBangDreamAssetPath } from '@/modules/qqbot/plugins/bangdream/src/theme/asset-manifest';
|
||||||
|
|
||||||
let BGDefaultImage: Image;
|
let BGDefaultImage: Image;
|
||||||
/**
|
let outputAssetsPreload: Promise<void> | undefined;
|
||||||
* 在底层绘图工具层中加载图片Once。
|
|
||||||
*/
|
export async function preloadBangDreamOutputAssets() {
|
||||||
async function loadImageOnce() {
|
if (!outputAssetsPreload) {
|
||||||
BGDefaultImage = await loadImageFromPath(
|
outputAssetsPreload = loadImageFromPath(
|
||||||
getBangDreamAssetPath('backgroundLive'),
|
getBangDreamAssetPath('backgroundLive'),
|
||||||
);
|
)
|
||||||
|
.then((image) => {
|
||||||
|
BGDefaultImage = image;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
outputAssetsPreload = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await outputAssetsPreload;
|
||||||
}
|
}
|
||||||
loadImageOnce();
|
|
||||||
|
|
||||||
export interface OutputFinalOptions {
|
export interface OutputFinalOptions {
|
||||||
startWithSpace?: boolean;
|
startWithSpace?: boolean;
|
||||||
@ -43,8 +51,10 @@ export const outputFinalCanv = async function ({
|
|||||||
useEasyBG = true,
|
useEasyBG = true,
|
||||||
useImageBG = false,
|
useImageBG = false,
|
||||||
text = 'BanG Dream!',
|
text = 'BanG Dream!',
|
||||||
BGimage = BGDefaultImage,
|
BGimage,
|
||||||
}: OutputFinalOptions): Promise<Canvas> {
|
}: OutputFinalOptions): Promise<Canvas> {
|
||||||
|
await preloadBangDreamOutputAssets();
|
||||||
|
const backgroundImage = BGimage ?? BGDefaultImage;
|
||||||
let allH = 30;
|
let allH = 30;
|
||||||
if (startWithSpace) {
|
if (startWithSpace) {
|
||||||
allH += 50;
|
allH += 50;
|
||||||
@ -72,7 +82,7 @@ export const outputFinalCanv = async function ({
|
|||||||
} else if (useImageBG) {
|
} else if (useImageBG) {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
await createImageBackground({
|
await createImageBackground({
|
||||||
image: BGimage,
|
image: backgroundImage,
|
||||||
width: maxW,
|
width: maxW,
|
||||||
height: allH,
|
height: allH,
|
||||||
}),
|
}),
|
||||||
@ -83,7 +93,7 @@ export const outputFinalCanv = async function ({
|
|||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
await createBackground({
|
await createBackground({
|
||||||
text,
|
text,
|
||||||
image: BGimage,
|
image: backgroundImage,
|
||||||
width: maxW,
|
width: maxW,
|
||||||
height: allH,
|
height: allH,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -9,13 +9,21 @@ import {
|
|||||||
} from '@/modules/qqbot/plugins/bangdream/src/theme/title.layout';
|
} from '@/modules/qqbot/plugins/bangdream/src/theme/title.layout';
|
||||||
|
|
||||||
let titleImage: Image;
|
let titleImage: Image;
|
||||||
/**
|
let titleImagePreload: Promise<void> | undefined;
|
||||||
* 在图片布局层中加载图片Once。
|
|
||||||
*/
|
export async function preloadBangDreamTitleAssets() {
|
||||||
async function loadImageOnce() {
|
if (!titleImagePreload) {
|
||||||
titleImage = await loadImageFromPath(getBangDreamAssetPath('title'));
|
titleImagePreload = loadImageFromPath(getBangDreamAssetPath('title'))
|
||||||
|
.then((image) => {
|
||||||
|
titleImage = image;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
titleImagePreload = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await titleImagePreload;
|
||||||
}
|
}
|
||||||
loadImageOnce();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在图片布局层中绘制标题。
|
* 在图片布局层中绘制标题。
|
||||||
@ -25,6 +33,9 @@ loadImageOnce();
|
|||||||
* @returns 渲染或资源结果。
|
* @returns 渲染或资源结果。
|
||||||
*/
|
*/
|
||||||
export function drawTitle(title1: string, title2: string): Canvas {
|
export function drawTitle(title1: string, title2: string): Canvas {
|
||||||
|
if (!titleImage) {
|
||||||
|
throw new Error('BangDream 标题资源未初始化');
|
||||||
|
}
|
||||||
const canvas = new Canvas(titleImage.width, titleImage.height);
|
const canvas = new Canvas(titleImage.width, titleImage.height);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
|
|||||||
@ -280,7 +280,7 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
const commandRegistry = new QqbotPluginRegistryService({
|
const commandRegistry = new QqbotPluginRegistryService({
|
||||||
loadCommandPlugins: () => [commandPlugin],
|
loadCommandPlugins: () => [commandPlugin],
|
||||||
} as any);
|
} as any);
|
||||||
commandRegistry.onModuleInit();
|
await commandRegistry.onModuleInit();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
commandRegistry.execute('demo-plugin', 'demo.echo', {}, {}),
|
commandRegistry.execute('demo-plugin', 'demo.echo', {}, {}),
|
||||||
|
|||||||
@ -21,7 +21,7 @@ describe('QQBot plugin registry operation timeout', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as any);
|
} as any);
|
||||||
commandRegistry.onModuleInit();
|
await commandRegistry.onModuleInit();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
Promise.race([
|
Promise.race([
|
||||||
|
|||||||
@ -19,6 +19,24 @@ const createPlugin = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('QQBot plugin registry platform key compatibility', () => {
|
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 () => {
|
it('uses platform plugin keys as primary keys while resolving legacy command keys', async () => {
|
||||||
const bangdream = createPlugin('bangdream', ['bangDream']);
|
const bangdream = createPlugin('bangdream', ['bangDream']);
|
||||||
const ff14Plugin = createPlugin('ff14-market', ['ff14Market']);
|
const ff14Plugin = createPlugin('ff14-market', ['ff14Market']);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ const mockContext = {
|
|||||||
refreshDictionaryCache: jest.fn(),
|
refreshDictionaryCache: jest.fn(),
|
||||||
};
|
};
|
||||||
const mockConfigureBangDreamRuntimeIo = jest.fn();
|
const mockConfigureBangDreamRuntimeIo = jest.fn();
|
||||||
|
const mockPreloadBangDreamRenderAssets = jest.fn();
|
||||||
const mockOperationModules = new Map<string, jest.Mock>();
|
const mockOperationModules = new Map<string, jest.Mock>();
|
||||||
const mockManifestOperations = [
|
const mockManifestOperations = [
|
||||||
['bangdream.song.search', 'searchSong'],
|
['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', () => ({
|
jest.mock('@/modules/qqbot/plugins/bangdream/src/operations', () => ({
|
||||||
getBangDreamOperationsByHandlerName: () =>
|
getBangDreamOperationsByHandlerName: () =>
|
||||||
new Map(
|
new Map(
|
||||||
@ -85,6 +93,7 @@ describe('BangDream package entry', () => {
|
|||||||
mockOperationModules.clear();
|
mockOperationModules.clear();
|
||||||
mockContext.refreshDictionaryCache.mockResolvedValue(undefined);
|
mockContext.refreshDictionaryCache.mockResolvedValue(undefined);
|
||||||
mockContext.checkHealth.mockResolvedValue(true);
|
mockContext.checkHealth.mockResolvedValue(true);
|
||||||
|
mockPreloadBangDreamRenderAssets.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('binds every manifest operation directly to the package operation modules', async () => {
|
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(mockContext.refreshDictionaryCache).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockPreloadBangDreamRenderAssets).toHaveBeenCalledTimes(1);
|
||||||
expect(waitForBangDreamCatalogReady).toHaveBeenCalledWith([
|
expect(waitForBangDreamCatalogReady).toHaveBeenCalledWith([
|
||||||
'songs',
|
'songs',
|
||||||
'meta',
|
'meta',
|
||||||
|
|||||||
@ -36,4 +36,57 @@ describe('BangDream song search rendering', () => {
|
|||||||
expect(order).toEqual([1, 2, 3]);
|
expect(order).toEqual([1, 2, 3]);
|
||||||
expect(images.map((image) => image.width)).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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
43
test/qqbot/plugins/bangdream/theme/canvas-background.spec.ts
Normal file
43
test/qqbot/plugins/bangdream/theme/canvas-background.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user