diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index cd05024..7015439 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` 源码 132 +- TS 文件:初始基线 92;当前 `tsugu` 源码 133 - 函数节点:481,其中稳定函数 410,匿名/内联回调 71 - 源码 JSDoc:稳定函数 410/410 已覆盖 - 变量声明:1896 @@ -299,6 +299,8 @@ export interface TsuguHook { - 已新增 `cache-client-policy.spec.ts`,覆盖缓存客户端 retry/status/not-found 策略;`file-cache-client.spec.ts` 补 SVG 缺失资源 404 缓存,避免 SVG 素材缺失仍反复请求。 - 本地图片烟测已生成查歌 `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 输出非空。 +- 固化: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/card-resource-repository.ts b/src/qqbot/plugins/bangDream/tsugu/models/card-resource-repository.ts new file mode 100644 index 0000000..86ca7a8 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/card-resource-repository.ts @@ -0,0 +1,113 @@ +import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { formatNumber } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; +import { + getServerByPriority, + Server, +} from '@/qqbot/plugins/bangDream/tsugu/models/server'; +import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; + +export interface CardResourceSource { + cardId: number; + releasedAt: Array; + resourceSetName: string; +} + +export type CardImageType = 'icon' | 'illustration' | 'trim'; + +/** + * 获取卡牌资源训练状态后缀。 + * + * @param trainingStatus - 是否特训。 + */ +function toTrainingSuffix(trainingStatus: boolean): string { + return trainingStatus ? '_after_training' : '_normal'; +} + +/** + * 将服务器枚举值转换为 Bestdori 资源路径中的服务器编码。 + * + * @param server - 服务器枚举值。 + */ +function toServerCode(server: Server | undefined): string { + return server == null ? 'undefined' : Server[server]; +} + +export class CardResourceRepository { + constructor( + private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + ) {} + + /** + * 获取卡牌远端详情。 + * + * @param cardId - 卡牌 ID。 + * @param update - 是否绕过缓存。 + */ + async getDetail( + cardId: number, + update: boolean = true, + ): Promise> { + return await this.provider.getJson>( + `/api/cards/${cardId}.json`, + { cacheTime: update ? 0 : 1 / 0 }, + ); + } + + /** + * 获取卡牌资源批次目录。 + * + * @param cardId - 卡牌 ID。 + */ + getRip(cardId: number): string { + if (cardId >= 9999) return '200_rip'; + const cardResourceSetId = Math.floor(cardId / 50); + return `${formatNumber(cardResourceSetId, 3)}_rip`; + } + + /** + * 获取卡牌图片资源路径。 + * + * @param source - 卡牌资源来源字段。 + * @param imageType - 图片类型。 + * @param trainingStatus - 是否使用特训后资源。 + * @param displayedServerList - 可展示服务器优先级。 + */ + getImagePath( + source: CardResourceSource, + imageType: CardImageType, + trainingStatus: boolean, + displayedServerList: Server[] = globalDefaultServer, + ): string { + const serverCode = toServerCode( + getServerByPriority(source.releasedAt, displayedServerList), + ); + const trainingSuffix = toTrainingSuffix(trainingStatus); + if (imageType === 'icon') { + return `/assets/${serverCode}/thumb/chara/card00${this.getRip(source.cardId)}/${source.resourceSetName}${trainingSuffix}.png`; + } + const fileName = + imageType === 'trim' ? `trim${trainingSuffix}` : `card${trainingSuffix}`; + return `/assets/${serverCode}/characters/resourceset/${source.resourceSetName}_rip/${fileName}.png`; + } + + /** + * 下载卡牌图片资源。 + * + * @param source - 卡牌资源来源字段。 + * @param imageType - 图片类型。 + * @param trainingStatus - 是否使用特训后资源。 + */ + async getImageBuffer( + source: CardResourceSource, + imageType: CardImageType, + trainingStatus: boolean, + ): Promise { + return await this.provider.getAsset( + this.getImagePath(source, imageType, trainingStatus), + imageType === 'icon' ? undefined : { memoryCache: false }, + ); + } +} + +export const cardResourceRepository = new CardResourceRepository(); diff --git a/src/qqbot/plugins/bangDream/tsugu/models/card.ts b/src/qqbot/plugins/bangDream/tsugu/models/card.ts index 35ca588..cb07080 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/card.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/card.ts @@ -8,14 +8,11 @@ import { import { Gacha } from '@/qqbot/plugins/bangDream/tsugu/models/gacha'; import { Event } from '@/qqbot/plugins/bangDream/tsugu/models/event'; import { Image, loadImage } from 'skia-canvas'; -import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; -import { - stringToNumberArray, - formatNumber, -} from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; +import { stringToNumberArray } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; import { BANGDREAM_CARD_TYPE_NAME } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; +import { cardResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/card-resource-repository'; export interface Stat { //综合力 @@ -177,14 +174,7 @@ export class Card { * @param update - update参数,未传入时使用默认值。 */ async getData(update: boolean = true) { - const time = update ? 0 : 1 / 0; - const cardData = await bangDreamBestdoriProvider.getJson< - Record - >( - `/api/cards/${this.cardId}.json`, - { cacheTime: time }, - ); - return cardData; + return await cardResourceRepository.getDetail(this.cardId, update); } /** @@ -366,15 +356,7 @@ export class Card { * @returns 格式化后的文本。 */ getRip(): string { - let cardResourceSetId: string; - if (this.cardId < 9999) { - //确定目录位置,50为一组 - const cardResourceSetIdNumber: number = Math.floor(this.cardId / 50); - cardResourceSetId = formatNumber(cardResourceSetIdNumber, 3); - } else { - cardResourceSetId = '200'; - } - return cardResourceSetId + '_rip'; + return cardResourceRepository.getRip(this.cardId); } /** * 在 Card 模型中获取卡牌图标图片。 @@ -384,10 +366,10 @@ export class Card { */ async getCardIconImage(trainingStatus: boolean): Promise { trainingStatus = this.ableToTraining(trainingStatus); - const trainingString = trainingStatus ? '_after_training' : '_normal'; - const tempServer = this.getFirstReleasedServer(); - const cardIconImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[tempServer]}/thumb/chara/card00${this.getRip()}/${this.resourceSetName}${trainingString}.png`, + const cardIconImageBuffer = await cardResourceRepository.getImageBuffer( + this, + 'icon', + trainingStatus, ); return await loadImage(cardIconImageBuffer); } @@ -399,12 +381,12 @@ export class Card { */ async getCardIllustrationImage(trainingStatus: boolean): Promise { trainingStatus = this.ableToTraining(trainingStatus); - const trainingString = trainingStatus ? '_after_training' : '_normal'; - const tempServer = this.getFirstReleasedServer(); - const cardIllustrationImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[tempServer]}/characters/resourceset/${this.resourceSetName}_rip/card${trainingString}.png`, - { memoryCache: false }, - ); + const cardIllustrationImageBuffer = + await cardResourceRepository.getImageBuffer( + this, + 'illustration', + trainingStatus, + ); return await loadImage(cardIllustrationImageBuffer); } /** @@ -417,13 +399,11 @@ export class Card { trainingStatus: boolean, ): Promise { trainingStatus = this.ableToTraining(trainingStatus); - const trainingString = trainingStatus ? '_after_training' : '_normal'; - const tempServer = this.getFirstReleasedServer(); - const cardIllustration = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[tempServer]}/characters/resourceset/${this.resourceSetName}_rip/card${trainingString}.png`, - { memoryCache: false }, + return await cardResourceRepository.getImageBuffer( + this, + 'illustration', + trainingStatus, ); - return cardIllustration; } /** * 在 Card 模型中获取卡牌Trim图片。 @@ -433,12 +413,8 @@ export class Card { */ async getCardTrimImage(trainingStatus: boolean): Promise { trainingStatus = this.ableToTraining(trainingStatus); - const trainingString = trainingStatus ? '_after_training' : '_normal'; - const tempServer = this.getFirstReleasedServer(); - const cardIllustrationImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[tempServer]}/characters/resourceset/${this.resourceSetName}_rip/trim${trainingString}.png`, - { memoryCache: false }, - ); + const cardIllustrationImageBuffer = + await cardResourceRepository.getImageBuffer(this, 'trim', trainingStatus); return await loadImage(cardIllustrationImageBuffer); } /** diff --git a/test/qqbot/plugins/bangDream/tsugu/card-resource-repository.spec.ts b/test/qqbot/plugins/bangDream/tsugu/card-resource-repository.spec.ts new file mode 100644 index 0000000..745330e --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/card-resource-repository.spec.ts @@ -0,0 +1,102 @@ +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { + CardResourceRepository, + type CardResourceSource, +} from '@/qqbot/plugins/bangDream/tsugu/models/card-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 createCardSource( + overrides: Partial = {}, +): CardResourceSource { + return { + cardId: 472, + releasedAt: [100, null, null, 200, null], + resourceSetName: 'res0472', + ...overrides, + }; +} + +describe('BangDream card resource repository', () => { + it('routes card detail requests through the provider with explicit cache policy', async () => { + const provider = createProviderMock(); + provider.getJson.mockResolvedValue({ ok: true }); + const repository = new CardResourceRepository(provider); + + await expect(repository.getDetail(472, true)).resolves.toEqual({ + ok: true, + }); + await expect(repository.getDetail(472, false)).resolves.toEqual({ + ok: true, + }); + + expect(provider.getJson).toHaveBeenNthCalledWith(1, '/api/cards/472.json', { + cacheTime: 0, + }); + expect(provider.getJson).toHaveBeenNthCalledWith(2, '/api/cards/472.json', { + cacheTime: Infinity, + }); + }); + + it('builds card resource rip directories from card ids', () => { + const repository = new CardResourceRepository(createProviderMock()); + + expect(repository.getRip(472)).toBe('009_rip'); + expect(repository.getRip(9999)).toBe('200_rip'); + }); + + it('builds icon, illustration, and trim asset paths from server priority', () => { + const repository = new CardResourceRepository(createProviderMock()); + const source = createCardSource(); + const serverPriority = [Server.cn, Server.jp]; + + expect(repository.getImagePath(source, 'icon', false, serverPriority)).toBe( + '/assets/cn/thumb/chara/card00009_rip/res0472_normal.png', + ); + expect( + repository.getImagePath(source, 'illustration', true, serverPriority), + ).toBe( + '/assets/cn/characters/resourceset/res0472_rip/card_after_training.png', + ); + expect(repository.getImagePath(source, 'trim', false, serverPriority)).toBe( + '/assets/cn/characters/resourceset/res0472_rip/trim_normal.png', + ); + }); + + it('keeps heavy card artwork outside provider memory cache', async () => { + const provider = createProviderMock(); + const assetBuffer = Buffer.from('asset'); + provider.getAsset.mockResolvedValue(assetBuffer); + const repository = new CardResourceRepository(provider); + const source = createCardSource(); + const expectedIconPath = + '/assets/cn/thumb/chara/card00009_rip/res0472_normal.png'; + const expectedIllustrationPath = + '/assets/cn/characters/resourceset/res0472_rip/card_after_training.png'; + + await expect( + repository.getImageBuffer(source, 'icon', false), + ).resolves.toBe(assetBuffer); + await repository.getImageBuffer(source, 'illustration', true); + + expect(provider.getAsset).toHaveBeenNthCalledWith( + 1, + expectedIconPath, + undefined, + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 2, + expectedIllustrationPath, + { memoryCache: false }, + ); + }); +});