From 1deb2dc8b6b72aa8134c24f955d5a48bd96024cc Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 16 Jun 2026 07:31:19 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=99=8D=E4=BD=8EBangDream=E6=AD=8C?= =?UTF-8?q?=E6=9B=B2=E8=AF=A6=E6=83=85=E6=B8=B2=E6=9F=93=E5=B3=B0=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bangdream/src/domain/song/song.model.ts | 107 +++++++++++++ .../src/theme/detail-block.renderer.ts | 32 ++-- .../bangdream/song/song-meta-summary.spec.ts | 150 ++++++++++++++++++ 3 files changed, 270 insertions(+), 19 deletions(-) create mode 100644 test/qqbot/plugins/bangdream/song/song-meta-summary.spec.ts diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/song/song.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/song/song.model.ts index 90a4d24..ebce1a0 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/song/song.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/song/song.model.ts @@ -78,6 +78,7 @@ export class Song { //meta数据 hasMeta = false; + private readonly songJacketImageCache = new Map>(); meta: { [difficultyId: number]: { @@ -206,6 +207,18 @@ export class Song { */ async getSongJacketImage( displayedServerList: Server[] = [Server.jp, Server.cn], + ): Promise { + const cacheKey = displayedServerList.join(','); + let jacketImage = this.songJacketImageCache.get(cacheKey); + if (!jacketImage) { + jacketImage = this.loadSongJacketImage(displayedServerList); + this.songJacketImageCache.set(cacheKey, jacketImage); + } + return await jacketImage; + } + + private async loadSongJacketImage( + displayedServerList: Server[], ): Promise { const jacketImageBuffer = await songResourceRepository.getJacketImageBuffer( this, @@ -357,6 +370,16 @@ export interface SongInRank { meta: number; rank: number; } + +export interface SongMetaRankSummary { + entries: Array<{ + difficulty: number; + meta: number; + rank: number; + }>; + maxMeta: number; +} + /** * 在BangDream 领域模型层中获取MetaRanking。 * @@ -404,3 +427,87 @@ export function getMetaRanking( } return songRankList; } + +/** + * 在BangDream 领域模型层中获取指定歌曲的Meta排名摘要。 + * + * @param targetSong - 目标歌曲。 + * @param withFever - withFever参数。 + * @param mainServer - 主数据服务器参数。 + * @returns 目标歌曲的排名条目与全局最大Meta。 + */ +export function getSongMetaRankSummary( + targetSong: Song, + withFever: boolean, + mainServer: Server, +): SongMetaRankSummary { + const songIdList = bangdreamCatalogRepository.getNumericIds('meta'); + const rowMetas: number[] = []; + const targetEntries: Array<{ + difficulty: number; + meta: number; + order: number; + }> = []; + let maxMeta = 0; + + for (let i = 0; i < songIdList.length; i++) { + const songId = songIdList[i]; + const song = songId === targetSong.songId ? targetSong : new Song(songId); + if (!isSongMetaRankCandidate(song, mainServer)) { + continue; + } + for (const j in song.difficulty) { + const difficulty = parseInt(j); + const meta = song.calcMeta(withFever, difficulty); + const order = rowMetas.length; + rowMetas.push(meta); + if (meta > maxMeta) { + maxMeta = meta; + } + if (song.songId === targetSong.songId) { + targetEntries.push({ + difficulty, + meta, + order, + }); + } + } + } + + targetEntries.sort((a, b) => { + const metaDiff = b.meta - a.meta; + return metaDiff === 0 ? a.order - b.order : metaDiff; + }); + + return { + entries: targetEntries.map((entry) => ({ + difficulty: entry.difficulty, + meta: entry.meta, + rank: countStableMetaRank(rowMetas, entry.meta, entry.order), + })), + maxMeta, + }; +} + +function isSongMetaRankCandidate(song: Song, mainServer: Server): boolean { + return ( + song.publishedAt[mainServer] != null && + Object.keys(song.notes).length > 0 && + song.hasMeta + ); +} + +function countStableMetaRank( + rowMetas: number[], + targetMeta: number, + targetOrder: number, +) { + let rank = 0; + for (let i = 0; i < rowMetas.length; i++) { + const meta = rowMetas[i]; + if (meta > targetMeta || (meta === targetMeta && i < targetOrder)) { + rank++; + } + } + return rank; +} diff --git a/src/modules/qqbot/plugins/bangdream/src/theme/detail-block.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/theme/detail-block.renderer.ts index 5e6fb03..5d8476d 100644 --- a/src/modules/qqbot/plugins/bangdream/src/theme/detail-block.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/theme/detail-block.renderer.ts @@ -12,7 +12,8 @@ import { import { Song, getMetaRanking, - SongInRank, + getSongMetaRankSummary, + type SongMetaRankSummary, } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song.model'; import { drawDottedLine } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-dotted-line'; import { @@ -240,19 +241,9 @@ export async function drawSongMetaListDataBlock( topLeftText?: string, displayedServerList: Server[] = globalDefaultServer, ) { - const metaRanking = {}; + const metaRanking: Partial> = {}; for (const server of displayedServerList) { - metaRanking[server] = {}; - metaRanking[server].data = getMetaRanking(withFever, server); - metaRanking[server].maxMeta = metaRanking[server].data[0].meta; - } - const songMetaRanking = {}; - for (const server of displayedServerList) { - songMetaRanking[server] = {}; - const tempMetaRanking = metaRanking[server].data; - songMetaRanking[server].data = tempMetaRanking.filter( - (value: SongInRank) => value.songId == song.songId, - ); + metaRanking[server] = getSongMetaRankSummary(song, withFever, server); } const list: Array = []; @@ -260,14 +251,17 @@ export async function drawSongMetaListDataBlock( const difficultyId = parseInt(difficulty); let text = ''; for (const server of displayedServerList) { - const tempSongMetaRanking = songMetaRanking[server].data; - for (let j = 0; j < tempSongMetaRanking.length; j++) { - if (tempSongMetaRanking[j].difficulty == difficultyId) { + const summary = metaRanking[server]; + if (!summary) { + continue; + } + for (let j = 0; j < summary.entries.length; j++) { + if (summary.entries[j].difficulty == difficultyId) { const percent = getRelativeMetaPercent( - tempSongMetaRanking[j].meta, - metaRanking[server].maxMeta, + summary.entries[j].meta, + summary.maxMeta, ); - text += `${serverNameFullList[server]}: ${percent}% #${tempSongMetaRanking[j].rank + 1} `; + text += `${serverNameFullList[server]}: ${percent}% #${summary.entries[j].rank + 1} `; } } } diff --git a/test/qqbot/plugins/bangdream/song/song-meta-summary.spec.ts b/test/qqbot/plugins/bangdream/song/song-meta-summary.spec.ts new file mode 100644 index 0000000..30d2b01 --- /dev/null +++ b/test/qqbot/plugins/bangdream/song/song-meta-summary.spec.ts @@ -0,0 +1,150 @@ +describe('BangDream song meta rank summary', () => { + it('matches full meta ranking entries for a single song', async () => { + jest.resetModules(); + jest.doMock( + '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache', + () => ({ + __esModule: true, + default: createCatalogFixture(), + }), + ); + + const { Server } = await import( + '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model' + ); + const songModule = await import( + '@/modules/qqbot/plugins/bangdream/src/domain/song/song.model' + ); + const { Song, getMetaRanking } = songModule; + const getSongMetaRankSummary = ( + songModule as typeof songModule & { + getSongMetaRankSummary?: ( + song: InstanceType, + withFever: boolean, + server: typeof Server.cn, + ) => { + entries: Array<{ difficulty: number; meta: number; rank: number }>; + maxMeta: number; + }; + } + ).getSongMetaRankSummary; + + const song = new Song(243); + const fullRanking = getMetaRanking(true, Server.cn); + expect(typeof getSongMetaRankSummary).toBe('function'); + const summary = getSongMetaRankSummary(song, true, Server.cn); + + expect(summary.maxMeta).toBe(fullRanking[0].meta); + expect(summary.entries).toEqual( + fullRanking + .filter((entry) => entry.songId === 243) + .map(({ difficulty, meta, rank }) => ({ difficulty, meta, rank })), + ); + }); + + it('caches jacket image decoding within a song instance', async () => { + const getJacketImageBuffer = jest + .fn, [unknown]>() + .mockResolvedValue(Buffer.from('fake-png')); + const loadImage = jest.fn(async () => ({ + height: 1, + width: 1, + })); + + jest.resetModules(); + jest.doMock( + '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache', + () => ({ + __esModule: true, + default: createCatalogFixture(), + }), + ); + jest.doMock( + '@/modules/qqbot/plugins/bangdream/src/domain/song/song-resource.repository', + () => ({ + songResourceRepository: { + getChart: jest.fn(), + getDetail: jest.fn(), + getJacketImageBuffer, + getJacketImagePath: jest.fn(), + getSongRip: jest.fn(), + resolveJacketImageUrl: jest.fn(), + }, + }), + ); + jest.doMock('skia-canvas', () => ({ + loadImage, + })); + + const { Song } = await import( + '@/modules/qqbot/plugins/bangdream/src/domain/song/song.model' + ); + + const song = new Song(243); + const first = await song.getSongJacketImage(); + const second = await song.getSongJacketImage(); + + expect(first).toBe(second); + expect(getJacketImageBuffer).toHaveBeenCalledTimes(1); + expect(loadImage).toHaveBeenCalledTimes(1); + }); +}); + +function createCatalogFixture() { + return { + meta: { + 100: createMeta({ 1: 90, 2: 60 }), + 243: createMeta({ 1: 50, 2: 70 }), + 300: createMeta({ 1: 100, 2: 100 }), + 400: createMeta({ 1: 70, 2: 70 }), + }, + songs: { + 100: createSong({ id: 100, publishedCn: true }), + 243: createSong({ id: 243, publishedCn: true }), + 300: createSong({ id: 300, publishedCn: false }), + 400: createSong({ id: 400, publishedCn: true }), + }, + }; +} + +function createSong({ + id, + publishedCn, +}: { + id: number; + publishedCn: boolean; +}) { + return { + bandId: 1, + bpm: { + 1: [{ bpm: 120, end: 1, start: 0 }], + 2: [{ bpm: 140, end: 1, start: 0 }], + }, + closedAt: [], + difficulty: { + 1: { playLevel: 20 }, + 2: { playLevel: 25 }, + }, + jacketImage: [`song-${id}`], + length: 120, + musicTitle: [`Song ${id}`, null, null, `歌曲 ${id}`, null], + nickname: null, + notes: { + 1: 500, + 2: 700, + }, + publishedAt: [null, null, null, publishedCn ? 1 : null, null], + tag: 'normal', + }; +} + +function createMeta(values: Record) { + return Object.fromEntries( + Object.entries(values).map(([difficulty, value]) => [ + difficulty, + { + 7: [0, value, 0, value], + }, + ]), + ); +}