refactor: 收口 BangDream 卡牌资源仓储
This commit is contained in:
parent
4493174ad3
commit
742dae2349
@ -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/<file>.spec.ts ...`,路径用正斜杠。
|
||||
|
||||
### Phase 4:搜索 specification 和 matcher
|
||||
|
||||
|
||||
@ -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<number | null>;
|
||||
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<Record<string, any>> {
|
||||
return await this.provider.getJson<Record<string, any>>(
|
||||
`/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<Buffer> {
|
||||
return await this.provider.getAsset(
|
||||
this.getImagePath(source, imageType, trainingStatus),
|
||||
imageType === 'icon' ? undefined : { memoryCache: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const cardResourceRepository = new CardResourceRepository();
|
||||
@ -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<string, any>
|
||||
>(
|
||||
`/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<Image> {
|
||||
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<Image> {
|
||||
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<Buffer> {
|
||||
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<Image> {
|
||||
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);
|
||||
}
|
||||
/**
|
||||
|
||||
@ -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<BangDreamDataProvider> {
|
||||
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> = {},
|
||||
): 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user