diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 7015439..d4f4408 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -14,7 +14,7 @@ ## 当前事实 - Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu` -- TS 文件:初始基线 92;当前 `tsugu` 源码 133 +- TS 文件:初始基线 92;当前 `tsugu` 源码 134 - 函数节点:481,其中稳定函数 410,匿名/内联回调 71 - 源码 JSDoc:稳定函数 410/410 已覆盖 - 变量声明:1896 @@ -300,6 +300,7 @@ export interface TsuguHook { - 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。 - 已新增 `models/song-resource-repository.ts`,把 `Song` 的歌曲详情、谱面、封面路径、封面完整 URL 和封面缺失时服务器回退下载收口到不依赖 `Song` 类的资源 repository,避免 `song.ts` 继续直接拼 Bestdori API/asset 路径;`song-resource-repository.spec.ts` 覆盖 detail/chart provider 调用、13/40 旧封面批次、273 强制 CN 和封面 fallback 行为,本地 `/查曲 136`、`/查谱面 136 expert` 图片 smoke 输出非空。 - 已新增 `models/card-resource-repository.ts`,把 `Card` 的详情请求、资源批次目录、图标/插画/trim 图片路径和重图缓存策略收口到 provider-backed repository;`card-resource-repository.spec.ts` 覆盖 `/api/cards` 缓存参数、`9999` 后资源批次、CN 优先资源路径和非 icon 图片 `memoryCache=false`,本地 `/查卡 472` 图片 smoke 输出非空。 +- 已新增 `models/gacha-resource-repository.ts`,把 `Gacha` 的详情请求、首页横幅、screen 背景、`bg1` fallback 和 Logo 资源路径收口到 provider-backed repository;`gacha-resource-repository.spec.ts` 覆盖 `/api/gacha` 缓存参数、CN 优先 screen 路径、横幅缺失回退 Logo 和背景 fallback,本地 `/查卡池 259`、`/抽卡模拟 10 259` 图片 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/gacha-resource-repository.ts b/src/qqbot/plugins/bangDream/tsugu/models/gacha-resource-repository.ts new file mode 100644 index 0000000..c4c75a1 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/gacha-resource-repository.ts @@ -0,0 +1,141 @@ +import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { + getServerByPriority, + Server, +} from '@/qqbot/plugins/bangDream/tsugu/models/server'; +import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; + +export interface GachaResourceSource { + bannerAssetBundleName?: string; + gachaId: number; + publishedAt: Array; + resourceName: string; +} + +export type GachaScreenImageType = 'background' | 'backgroundFallback' | 'logo'; + +/** 将服务器枚举值转换为 Bestdori 资源路径中的服务器编码。 */ +function toServerCode(server: Server | undefined): string { + return server == null ? 'undefined' : Server[server]; +} + +export class GachaResourceRepository { + constructor( + private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + ) {} + + /** + * 获取卡池远端详情。 + * + * @param gachaId - 卡池 ID。 + * @param update - 是否绕过缓存。 + */ + async getDetail( + gachaId: number, + update: boolean = true, + ): Promise> { + return await this.provider.getJson>( + `/api/gacha/${gachaId}.json`, + { cacheTime: update ? 0 : 1 / 0 }, + ); + } + + /** + * 获取卡池横幅资源路径。 + * + * @param source - 卡池资源来源字段。 + */ + getBannerImagePath(source: GachaResourceSource): string | null { + if (!source.bannerAssetBundleName) return null; + return `/assets/jp/homebanner_rip/${source.bannerAssetBundleName}.png`; + } + + /** + * 获取卡池 screen 资源路径。 + * + * @param source - 卡池资源来源字段。 + * @param imageType - screen 图片类型。 + * @param displayedServerList - 可展示服务器优先级。 + */ + getScreenImagePath( + source: GachaResourceSource, + imageType: GachaScreenImageType, + displayedServerList: Server[] = globalDefaultServer, + ): string { + const serverCode = toServerCode( + getServerByPriority(source.publishedAt, displayedServerList), + ); + const fileName = + imageType === 'background' + ? 'bg' + : imageType === 'backgroundFallback' + ? 'bg1' + : 'logo'; + return `/assets/${serverCode}/gacha/screen/${source.resourceName}_rip/${fileName}.png`; + } + + /** + * 下载卡池横幅资源,缺失时回退到 Logo。 + * + * @param source - 卡池资源来源字段。 + * @param displayedServerList - 可展示服务器优先级。 + */ + async getBannerImageBuffer( + source: GachaResourceSource, + displayedServerList: Server[] = globalDefaultServer, + ): Promise { + const bannerPath = this.getBannerImagePath(source); + if (!bannerPath) { + return await this.getLogoImageBuffer(source, displayedServerList); + } + try { + return await this.provider.getAsset(bannerPath, { ignoreError: false }); + } catch { + return await this.getLogoImageBuffer(source, displayedServerList); + } + } + + /** + * 下载卡池背景资源,`bg.png` 缺失时回退到 `bg1.png`。 + * + * @param source - 卡池资源来源字段。 + * @param displayedServerList - 可展示服务器优先级。 + */ + async getBackgroundImageBuffer( + source: GachaResourceSource, + displayedServerList: Server[] = globalDefaultServer, + ): Promise { + try { + return await this.provider.getAsset( + this.getScreenImagePath(source, 'background', displayedServerList), + { ignoreError: false }, + ); + } catch { + return await this.provider.getAsset( + this.getScreenImagePath( + source, + 'backgroundFallback', + displayedServerList, + ), + ); + } + } + + /** + * 下载卡池 Logo 资源。 + * + * @param source - 卡池资源来源字段。 + * @param displayedServerList - 可展示服务器优先级。 + */ + async getLogoImageBuffer( + source: GachaResourceSource, + displayedServerList: Server[] = globalDefaultServer, + ): Promise { + return await this.provider.getAsset( + this.getScreenImagePath(source, 'logo', displayedServerList), + ); + } +} + +export const gachaResourceRepository = new GachaResourceRepository(); diff --git a/src/qqbot/plugins/bangDream/tsugu/models/gacha.ts b/src/qqbot/plugins/bangDream/tsugu/models/gacha.ts index a0b4eae..8021af2 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/gacha.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/gacha.ts @@ -1,4 +1,3 @@ -import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { Image, loadImage } from 'skia-canvas'; import { @@ -7,14 +6,13 @@ import { serverList, } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { getPresentEvent } from '@/qqbot/plugins/bangDream/tsugu/models/event'; -import { - globalDefaultServer, -} from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; +import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { BANGDREAM_GACHA_TYPE_NAME } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; import { isFreeGachaType, isPermanentJapaneseGachaPeriod, } from '@/qqbot/plugins/bangDream/tsugu/models/gacha-policy'; +import { gachaResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/gacha-resource-repository'; const gachaDataCache = {}; @@ -81,10 +79,9 @@ export class Gacha { */ constructor(gachaId: number) { this.gachaId = gachaId; - const gachaData = bangDreamMainDataRepository.getEntity>( - 'gacha', - gachaId, - ); + const gachaData = bangDreamMainDataRepository.getEntity< + Record + >('gacha', gachaId); if (gachaData == undefined) { this.isExist = false; return; @@ -147,14 +144,7 @@ export class Gacha { * @param update - update参数,未传入时使用默认值。 */ async getData(update: boolean = true) { - const time = update ? 0 : 1 / 0; - const gachaData = await bangDreamBestdoriProvider.getJson< - Record - >( - `/api/gacha/${this.gachaId}.json`, - { cacheTime: time }, - ); - return gachaData; + return await gachaResourceRepository.getDetail(this.gachaId, update); } /** * 在 Gacha 模型中获取横幅图片。 @@ -162,16 +152,9 @@ export class Gacha { * @returns 异步处理结果。 */ async getBannerImage(): Promise { - try { - if (this.bannerAssetBundleName == undefined) return this.getGachaLogo(); - const BannerImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/jp/homebanner_rip/${this.bannerAssetBundleName}.png`, - { ignoreError: false }, - ); - return await loadImage(BannerImageBuffer); - } catch { - return this.getGachaLogo(); - } + const bannerImageBuffer = + await gachaResourceRepository.getBannerImageBuffer(this); + return await loadImage(bannerImageBuffer); } /** * 在 Gacha 模型中获取卡池背景图片。 @@ -183,19 +166,12 @@ export class Gacha { displayedServerList: Server[] = globalDefaultServer, ): Promise { if (!displayedServerList) displayedServerList = globalDefaultServer; - const server = getServerByPriority(this.publishedAt); - let BGImageBuffer: Buffer; - try { - BGImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[server]}/gacha/screen/${this.resourceName}_rip/bg.png`, - { ignoreError: false }, + const backgroundImageBuffer = + await gachaResourceRepository.getBackgroundImageBuffer( + this, + displayedServerList, ); - } catch { - BGImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[server]}/gacha/screen/${this.resourceName}_rip/bg1.png`, - ); - } - return await loadImage(BGImageBuffer); + return await loadImage(backgroundImageBuffer); } /** * 在 Gacha 模型中获取卡池Logo。 @@ -207,11 +183,11 @@ export class Gacha { displayedServerList: Server[] = globalDefaultServer, ): Promise { if (!displayedServerList) displayedServerList = globalDefaultServer; - const server = getServerByPriority(this.publishedAt); - const LogoImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[server]}/gacha/screen/${this.resourceName}_rip/logo.png`, + const logoImageBuffer = await gachaResourceRepository.getLogoImageBuffer( + this, + displayedServerList, ); - return await loadImage(LogoImageBuffer); + return await loadImage(logoImageBuffer); } /** * 在 Gacha 模型中获取活动ID。 diff --git a/test/qqbot/plugins/bangDream/tsugu/gacha-resource-repository.spec.ts b/test/qqbot/plugins/bangDream/tsugu/gacha-resource-repository.spec.ts new file mode 100644 index 0000000..244405e --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/gacha-resource-repository.spec.ts @@ -0,0 +1,140 @@ +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { + GachaResourceRepository, + type GachaResourceSource, +} from '@/qqbot/plugins/bangDream/tsugu/models/gacha-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}`), + }; +} + +function createGachaSource( + overrides: Partial = {}, +): GachaResourceSource { + return { + bannerAssetBundleName: 'banner_gacha0259', + gachaId: 259, + publishedAt: [100, null, null, 200, null], + resourceName: 'gacha0259', + ...overrides, + }; +} + +describe('BangDream gacha resource repository', () => { + it('routes gacha detail requests through the provider with explicit cache policy', async () => { + const provider = createProviderMock(); + provider.getJson.mockResolvedValue({ ok: true }); + const repository = new GachaResourceRepository(provider); + + await expect(repository.getDetail(259, true)).resolves.toEqual({ + ok: true, + }); + await expect(repository.getDetail(259, false)).resolves.toEqual({ + ok: true, + }); + + expect(provider.getJson).toHaveBeenNthCalledWith(1, '/api/gacha/259.json', { + cacheTime: 0, + }); + expect(provider.getJson).toHaveBeenNthCalledWith(2, '/api/gacha/259.json', { + cacheTime: Infinity, + }); + }); + + it('builds banner, background, fallback background, and logo paths', () => { + const repository = new GachaResourceRepository(createProviderMock()); + const source = createGachaSource(); + const serverPriority = [Server.cn, Server.jp]; + + expect(repository.getBannerImagePath(source)).toBe( + '/assets/jp/homebanner_rip/banner_gacha0259.png', + ); + expect( + repository.getScreenImagePath(source, 'background', serverPriority), + ).toBe('/assets/cn/gacha/screen/gacha0259_rip/bg.png'); + expect( + repository.getScreenImagePath( + source, + 'backgroundFallback', + serverPriority, + ), + ).toBe('/assets/cn/gacha/screen/gacha0259_rip/bg1.png'); + expect(repository.getScreenImagePath(source, 'logo', serverPriority)).toBe( + '/assets/cn/gacha/screen/gacha0259_rip/logo.png', + ); + }); + + it('falls back to logo when the banner is missing or unavailable', async () => { + const provider = createProviderMock(); + const logoBuffer = Buffer.from('logo'); + provider.getAsset.mockResolvedValue(logoBuffer); + const repository = new GachaResourceRepository(provider); + + await expect( + repository.getBannerImageBuffer( + createGachaSource({ bannerAssetBundleName: undefined }), + [Server.cn, Server.jp], + ), + ).resolves.toBe(logoBuffer); + + expect(provider.getAsset).toHaveBeenNthCalledWith( + 1, + '/assets/cn/gacha/screen/gacha0259_rip/logo.png', + ); + + provider.getAsset.mockReset(); + provider.getAsset + .mockRejectedValueOnce(new Error('banner missing')) + .mockResolvedValueOnce(logoBuffer); + + await expect( + repository.getBannerImageBuffer(createGachaSource(), [ + Server.cn, + Server.jp, + ]), + ).resolves.toBe(logoBuffer); + + expect(provider.getAsset).toHaveBeenNthCalledWith( + 1, + '/assets/jp/homebanner_rip/banner_gacha0259.png', + { ignoreError: false }, + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 2, + '/assets/cn/gacha/screen/gacha0259_rip/logo.png', + ); + }); + + it('falls back from bg.png to bg1.png for gacha backgrounds', async () => { + const provider = createProviderMock(); + const fallbackBuffer = Buffer.from('bg1'); + provider.getAsset + .mockRejectedValueOnce(new Error('background missing')) + .mockResolvedValueOnce(fallbackBuffer); + const repository = new GachaResourceRepository(provider); + + await expect( + repository.getBackgroundImageBuffer(createGachaSource(), [ + Server.cn, + Server.jp, + ]), + ).resolves.toBe(fallbackBuffer); + + expect(provider.getAsset).toHaveBeenNthCalledWith( + 1, + '/assets/cn/gacha/screen/gacha0259_rip/bg.png', + { ignoreError: false }, + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 2, + '/assets/cn/gacha/screen/gacha0259_rip/bg1.png', + ); + }); +});