diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 4ae205d..bf8873d 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -310,6 +310,7 @@ export interface TsuguHook { - 已新增 `models/costume-resource-repository.ts`,把 `Costume` 的服装详情请求和 Live2D 缩略图素材路径从 `bestdoriUrl + callAPIAndCacheResponse/downloadFile` 收口到 provider-backed repository;`costume-resource-repository.spec.ts` 覆盖详情路径、`sdchara.png` 路径和非内存缓存下载调用,本地 `/查卡 472` 图片 smoke 保持演出缩略图正常输出。 - 已新增 `models/item-resource-repository.ts`,把 `Item` 的道具缩略图路径从 `bestdoriUrl + downloadFileCache` 收口到 provider-backed repository;`item-resource-repository.spec.ts` 覆盖 material、star 和 common item 三类路径,本地 `/查卡池 259` 图片 smoke 输出非空。 - 已新增 `models/cutoff-event-top-repository.ts` 和 `render-blocks/player-ranking-resource-repository.ts`,把前十榜 eventtop 数据请求与排名徽章素材路径从 `bestdoriUrl + callAPIAndCacheResponse/downloadFileCache` 收口到 provider-backed repository;相关 spec 覆盖 eventtop query path、排名徽章路径和 provider 调用,本地 `/ycx 10 100 cn` 图片 smoke 输出非空。 +- 已新增 `models/degree-resource-repository.ts`,把 `Degree` 的称号缩略图、称号框、称号图标、动态称号脚本和纹理素材路径从 `bestdoriUrl + downloadFile/downloadFileCache` 收口到 provider-backed repository;`degree-resource-repository.spec.ts` 覆盖旧/新缩略图 fallback、动态称号旧纹理白名单和 provider 调用,本地 `/ycx 10 100 cn` 图片 smoke 输出非空。 - 固化:Windows 下不要用反斜杠测试路径直接调用 Jest pattern,容易出现 `Pattern ... - 0 matches`;指定文件测试统一使用 `pnpm exec jest --runInBand --runTestsByPath test/qqbot/plugins/bangDream/tsugu/.spec.ts ...`,路径用正斜杠。 ### Phase 4:搜索 specification 和 matcher diff --git a/src/qqbot/plugins/bangDream/tsugu/models/degree-resource-repository.ts b/src/qqbot/plugins/bangDream/tsugu/models/degree-resource-repository.ts new file mode 100644 index 0000000..709a64d --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/degree-resource-repository.ts @@ -0,0 +1,154 @@ +import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; + +const LEGACY_ANIMATED_TEXTURE_NAMES = new Set([ + 'ani_degree_bilibili_day1', + 'ani_degree_bilibili_092701', + 'ani_degree_bilibili_collabo', + 'ani_degree_bilibili_6years', +]); + +export class DegreeResourceRepository { + constructor( + private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + ) {} + + /** + * 获取称号缩略图资源路径。 + * + * @param baseImageName - 称号基础图片名。 + * @param server - 目标服务器。 + */ + getThumbnailPath(baseImageName: string, server: Server): string { + return `/assets/${Server[server]}/thumb/degree_rip/${baseImageName}.png`; + } + + /** + * 获取新版称号缩略图兜底资源路径。 + * + * @param baseImageName - 称号基础图片名。 + * @param server - 目标服务器。 + */ + getFallbackThumbnailPath(baseImageName: string, server: Server): string { + return `/assets/${Server[server]}/thumb/degree_rip/assets-star-forassetbundle-startapp-thumbnail-degree-${baseImageName}.png`; + } + + /** + * 获取称号框资源路径。 + * + * @param frameName - 称号框资源名。 + * @param server - 目标服务器。 + */ + getFramePath(frameName: string, server: Server): string { + return `/assets/${Server[server]}/thumb/degree_rip/${frameName}.png`; + } + + /** + * 获取称号图标资源路径。 + * + * @param iconName - 称号图标资源名。 + * @param server - 目标服务器。 + */ + getIconPath(iconName: string, server: Server): string { + return `/assets/${Server[server]}/thumb/degree_rip/${iconName}.png`; + } + + /** + * 获取动态称号脚本资源路径。 + * + * @param baseImageName - 称号基础图片名。 + * @param server - 目标服务器。 + */ + getAnimatedScriptPath(baseImageName: string, server: Server): string { + return `/assets/${Server[server]}/${baseImageName}_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-${baseImageName}-${baseImageName}.asset`; + } + + /** + * 获取动态称号纹理资源路径。 + * + * @param baseImageName - 称号基础图片名。 + * @param server - 目标服务器。 + */ + getAnimatedTexturePath(baseImageName: string, server: Server): string { + const fileName = LEGACY_ANIMATED_TEXTURE_NAMES.has(baseImageName) + ? `${baseImageName}.png` + : `assets-star-forassetbundle-startapp-thumbnail-animedegree-${baseImageName}-${baseImageName}.png`; + return `/assets/${Server[server]}/${baseImageName}_rip/${fileName}`; + } + + /** + * 下载称号缩略图资源,缺失时回退到新版统一路径。 + * + * @param baseImageName - 称号基础图片名。 + * @param server - 目标服务器。 + */ + async getThumbnailBuffer( + baseImageName: string, + server: Server, + ): Promise { + try { + return await this.provider.getAsset( + this.getThumbnailPath(baseImageName, server), + { ignoreError: false, memoryCache: false }, + ); + } catch { + return await this.provider.getAsset( + this.getFallbackThumbnailPath(baseImageName, server), + { memoryCache: false }, + ); + } + } + + /** + * 下载称号框资源。 + * + * @param frameName - 称号框资源名。 + * @param server - 目标服务器。 + */ + async getFrameBuffer(frameName: string, server: Server): Promise { + return await this.provider.getAsset(this.getFramePath(frameName, server)); + } + + /** + * 下载称号图标资源。 + * + * @param iconName - 称号图标资源名。 + * @param server - 目标服务器。 + */ + async getIconBuffer(iconName: string, server: Server): Promise { + return await this.provider.getAsset(this.getIconPath(iconName, server)); + } + + /** + * 下载动态称号脚本资源。 + * + * @param baseImageName - 称号基础图片名。 + * @param server - 目标服务器。 + */ + async getAnimatedScriptBuffer( + baseImageName: string, + server: Server, + ): Promise { + return await this.provider.getAsset( + this.getAnimatedScriptPath(baseImageName, server), + ); + } + + /** + * 下载动态称号纹理资源。 + * + * @param baseImageName - 称号基础图片名。 + * @param server - 目标服务器。 + */ + async getAnimatedTextureBuffer( + baseImageName: string, + server: Server, + ): Promise { + return await this.provider.getAsset( + this.getAnimatedTexturePath(baseImageName, server), + ); + } +} + +export const degreeResourceRepository = new DegreeResourceRepository(); diff --git a/src/qqbot/plugins/bangDream/tsugu/models/degree.ts b/src/qqbot/plugins/bangDream/tsugu/models/degree.ts index 60324fb..d394db0 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/degree.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/degree.ts @@ -1,10 +1,8 @@ import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; -import { downloadFileCache } from '@/qqbot/plugins/bangDream/tsugu/data-clients/asset-cache-client'; -import { downloadFile } from '@/qqbot/plugins/bangDream/tsugu/data-clients/asset-cache-client'; import { Canvas, Image, loadImage } from 'skia-canvas'; import mainAPI from '@/qqbot/plugins/bangDream/tsugu/models/main-data-store'; -import { bestdoriUrl } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { readJSONFromBuffer } from './model-utils'; +import { degreeResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/degree-resource-repository'; export class Degree { degreeId: number; @@ -52,20 +50,11 @@ export class Degree { return degreeImageBuffer; } catch {} } - // 资源路径修复 - try { - const degreeImageBuffer = await downloadFile( - `${bestdoriUrl}/assets/${Server[server]}/thumb/degree_rip/${this.baseImageName[server]}.png`, - false, - ); - return loadImage(degreeImageBuffer); - } catch { - const degreeImageBuffer = await downloadFile( - `${bestdoriUrl}/assets/${Server[server]}/thumb/degree_rip/assets-star-forassetbundle-startapp-thumbnail-degree-${this.baseImageName[server]}.png`, - true, - ); - return loadImage(degreeImageBuffer); - } + const degreeImageBuffer = await degreeResourceRepository.getThumbnailBuffer( + this.baseImageName[server], + server, + ); + return loadImage(degreeImageBuffer); } /** * 在 Degree 模型中获取称号Frame。 @@ -80,8 +69,9 @@ export class Degree { return new Canvas(1, 1); } - const degreeFrameBuffer = await downloadFileCache( - `${bestdoriUrl}/assets/${Server[server]}/thumb/degree_rip/${frameName}.png`, + const degreeFrameBuffer = await degreeResourceRepository.getFrameBuffer( + frameName, + server, ); return loadImage(degreeFrameBuffer); } @@ -97,8 +87,9 @@ export class Degree { //这个为空底图 return new Canvas(1, 1); } - const degreeIconBuffer = await downloadFileCache( - `${bestdoriUrl}/assets/${Server[server]}/thumb/degree_rip/${iconName}.png`, + const degreeIconBuffer = await degreeResourceRepository.getIconBuffer( + iconName, + server, ); return loadImage(degreeIconBuffer); } @@ -132,11 +123,12 @@ export async function getFrameFromAnimatedDegreeAsset( server: Server, frame?: number, ): Promise { - // script - // example https://bestdori.com/assets/cn/ani_degree_bilibili_day1_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-ani_degree_bilibili_day1-ani_degree_bilibili_day1.asset - const scriptUrl = `${bestdoriUrl}/assets/${Server[server]}/${baseImageName}_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-${baseImageName}-${baseImageName}.asset`; - const srciptBuffer = await downloadFileCache(scriptUrl); - const script = await readJSONFromBuffer(srciptBuffer); + const scriptBuffer = + await degreeResourceRepository.getAnimatedScriptBuffer( + baseImageName, + server, + ); + const script = await readJSONFromBuffer(scriptBuffer); const frames: Array = script['Base']['mSprites'] as Array; const framecount = frames.length; if (!frame) { @@ -144,27 +136,9 @@ export async function getFrameFromAnimatedDegreeAsset( frame = Math.floor(Math.random() * framecount); } - // texture - // example https://bestdori.com/assets/cn/ani_degree_bilibili_day1_rip/ani_degree_bilibili_day1.png - // example https://bestdori.com/assets/cn/ani_degree_election_5th_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-ani_degree_election_5th-ani_degree_election_5th.png - const textureUrlOld = `${bestdoriUrl}/assets/${Server[server]}/${baseImageName}_rip/${baseImageName}.png`; - const textureUrlNew = `${bestdoriUrl}/assets/${Server[server]}/${baseImageName}_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-${baseImageName}-${baseImageName}.png`; - // 后期使用了统一的资源路径 - const useTextureUrlOldAssetWhitelist = [ - 'ani_degree_bilibili_day1', - 'ani_degree_bilibili_092701', - 'ani_degree_bilibili_collabo', - 'ani_degree_bilibili_6years', - ]; - let useTextureUrlOld = false; - for (const l of useTextureUrlOldAssetWhitelist) { - if (baseImageName == l) { - useTextureUrlOld = true; - break; - } - } - const textureBuffer = await downloadFileCache( - useTextureUrlOld ? textureUrlOld : textureUrlNew, + const textureBuffer = await degreeResourceRepository.getAnimatedTextureBuffer( + baseImageName, + server, ); const texture = await loadImage(textureBuffer); diff --git a/test/qqbot/plugins/bangDream/tsugu/degree-resource-repository.spec.ts b/test/qqbot/plugins/bangDream/tsugu/degree-resource-repository.spec.ts new file mode 100644 index 0000000..e164f18 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/degree-resource-repository.spec.ts @@ -0,0 +1,118 @@ +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { DegreeResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/degree-resource-repository'; +import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; + +function createProviderMock(): jest.Mocked { + return { + getAsset: jest.fn(), + getJson: jest.fn(), + getTracker: jest.fn(), + name: 'MockBestdori', + resolveUrl: jest.fn((pathOrUrl) => `https://bestdori.example${pathOrUrl}`), + }; +} + +describe('BangDream degree resource repository', () => { + it('builds degree thumbnail, frame, icon, and animated asset paths', () => { + const repository = new DegreeResourceRepository(createProviderMock()); + + expect(repository.getThumbnailPath('degree_001', Server.cn)).toBe( + '/assets/cn/thumb/degree_rip/degree_001.png', + ); + expect(repository.getFallbackThumbnailPath('degree_001', Server.cn)).toBe( + '/assets/cn/thumb/degree_rip/assets-star-forassetbundle-startapp-thumbnail-degree-degree_001.png', + ); + expect(repository.getFramePath('normal_1', Server.cn)).toBe( + '/assets/cn/thumb/degree_rip/normal_1.png', + ); + expect(repository.getIconPath('event_1', Server.cn)).toBe( + '/assets/cn/thumb/degree_rip/event_1.png', + ); + expect( + repository.getAnimatedScriptPath('ani_degree_election_5th', Server.cn), + ).toBe( + '/assets/cn/ani_degree_election_5th_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-ani_degree_election_5th-ani_degree_election_5th.asset', + ); + }); + + it('keeps legacy animated degree texture paths for known old assets', () => { + const repository = new DegreeResourceRepository(createProviderMock()); + + expect( + repository.getAnimatedTexturePath('ani_degree_bilibili_day1', Server.cn), + ).toBe('/assets/cn/ani_degree_bilibili_day1_rip/ani_degree_bilibili_day1.png'); + expect( + repository.getAnimatedTexturePath('ani_degree_election_5th', Server.cn), + ).toBe( + '/assets/cn/ani_degree_election_5th_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-ani_degree_election_5th-ani_degree_election_5th.png', + ); + }); + + it('falls back to the unified thumbnail path when the legacy thumbnail is missing', async () => { + const provider = createProviderMock(); + const fallbackBuffer = Buffer.from('fallback-thumbnail'); + provider.getAsset + .mockRejectedValueOnce(new Error('thumbnail missing')) + .mockResolvedValueOnce(fallbackBuffer); + const repository = new DegreeResourceRepository(provider); + + await expect( + repository.getThumbnailBuffer('degree_001', Server.cn), + ).resolves.toBe(fallbackBuffer); + + expect(provider.getAsset).toHaveBeenNthCalledWith( + 1, + '/assets/cn/thumb/degree_rip/degree_001.png', + { ignoreError: false, memoryCache: false }, + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 2, + '/assets/cn/thumb/degree_rip/assets-star-forassetbundle-startapp-thumbnail-degree-degree_001.png', + { memoryCache: false }, + ); + }); + + it('downloads degree frame, icon, script, and texture assets through the provider', async () => { + const provider = createProviderMock(); + const frameBuffer = Buffer.from('frame'); + const iconBuffer = Buffer.from('icon'); + const scriptBuffer = Buffer.from('script'); + const textureBuffer = Buffer.from('texture'); + provider.getAsset + .mockResolvedValueOnce(frameBuffer) + .mockResolvedValueOnce(iconBuffer) + .mockResolvedValueOnce(scriptBuffer) + .mockResolvedValueOnce(textureBuffer); + const repository = new DegreeResourceRepository(provider); + + await expect(repository.getFrameBuffer('normal_1', Server.cn)).resolves.toBe( + frameBuffer, + ); + await expect(repository.getIconBuffer('event_1', Server.cn)).resolves.toBe( + iconBuffer, + ); + await expect( + repository.getAnimatedScriptBuffer('ani_degree_election_5th', Server.cn), + ).resolves.toBe(scriptBuffer); + await expect( + repository.getAnimatedTextureBuffer('ani_degree_election_5th', Server.cn), + ).resolves.toBe(textureBuffer); + + expect(provider.getAsset).toHaveBeenNthCalledWith( + 1, + '/assets/cn/thumb/degree_rip/normal_1.png', + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 2, + '/assets/cn/thumb/degree_rip/event_1.png', + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 3, + '/assets/cn/ani_degree_election_5th_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-ani_degree_election_5th-ani_degree_election_5th.asset', + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 4, + '/assets/cn/ani_degree_election_5th_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-ani_degree_election_5th-ani_degree_election_5th.png', + ); + }); +});