refactor: 收口 BangDream 称号资源仓储

This commit is contained in:
sunlei 2026-06-07 03:15:24 +08:00
parent fe8542c687
commit 5b0d2f754d
4 changed files with 294 additions and 47 deletions

View File

@ -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/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/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/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/<file>.spec.ts ...`,路径用正斜杠。 - 固化Windows 下不要用反斜杠测试路径直接调用 Jest pattern容易出现 `Pattern ... - 0 matches`;指定文件测试统一使用 `pnpm exec jest --runInBand --runTestsByPath test/qqbot/plugins/bangDream/tsugu/<file>.spec.ts ...`,路径用正斜杠。
### Phase 4搜索 specification 和 matcher ### Phase 4搜索 specification 和 matcher

View File

@ -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<Buffer> {
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<Buffer> {
return await this.provider.getAsset(this.getFramePath(frameName, server));
}
/**
*
*
* @param iconName -
* @param server -
*/
async getIconBuffer(iconName: string, server: Server): Promise<Buffer> {
return await this.provider.getAsset(this.getIconPath(iconName, server));
}
/**
*
*
* @param baseImageName -
* @param server -
*/
async getAnimatedScriptBuffer(
baseImageName: string,
server: Server,
): Promise<Buffer> {
return await this.provider.getAsset(
this.getAnimatedScriptPath(baseImageName, server),
);
}
/**
*
*
* @param baseImageName -
* @param server -
*/
async getAnimatedTextureBuffer(
baseImageName: string,
server: Server,
): Promise<Buffer> {
return await this.provider.getAsset(
this.getAnimatedTexturePath(baseImageName, server),
);
}
}
export const degreeResourceRepository = new DegreeResourceRepository();

View File

@ -1,10 +1,8 @@
import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; 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 { Canvas, Image, loadImage } from 'skia-canvas';
import mainAPI from '@/qqbot/plugins/bangDream/tsugu/models/main-data-store'; 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 { readJSONFromBuffer } from './model-utils';
import { degreeResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/degree-resource-repository';
export class Degree { export class Degree {
degreeId: number; degreeId: number;
@ -52,20 +50,11 @@ export class Degree {
return degreeImageBuffer; return degreeImageBuffer;
} catch {} } catch {}
} }
// 资源路径修复 const degreeImageBuffer = await degreeResourceRepository.getThumbnailBuffer(
try { this.baseImageName[server],
const degreeImageBuffer = await downloadFile( server,
`${bestdoriUrl}/assets/${Server[server]}/thumb/degree_rip/${this.baseImageName[server]}.png`, );
false, return loadImage(degreeImageBuffer);
);
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);
}
} }
/** /**
* Degree Frame * Degree Frame
@ -80,8 +69,9 @@ export class Degree {
return new Canvas(1, 1); return new Canvas(1, 1);
} }
const degreeFrameBuffer = await downloadFileCache( const degreeFrameBuffer = await degreeResourceRepository.getFrameBuffer(
`${bestdoriUrl}/assets/${Server[server]}/thumb/degree_rip/${frameName}.png`, frameName,
server,
); );
return loadImage(degreeFrameBuffer); return loadImage(degreeFrameBuffer);
} }
@ -97,8 +87,9 @@ export class Degree {
//这个为空底图 //这个为空底图
return new Canvas(1, 1); return new Canvas(1, 1);
} }
const degreeIconBuffer = await downloadFileCache( const degreeIconBuffer = await degreeResourceRepository.getIconBuffer(
`${bestdoriUrl}/assets/${Server[server]}/thumb/degree_rip/${iconName}.png`, iconName,
server,
); );
return loadImage(degreeIconBuffer); return loadImage(degreeIconBuffer);
} }
@ -132,11 +123,12 @@ export async function getFrameFromAnimatedDegreeAsset(
server: Server, server: Server,
frame?: number, frame?: number,
): Promise<Canvas> { ): Promise<Canvas> {
// script const scriptBuffer =
// 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 await degreeResourceRepository.getAnimatedScriptBuffer(
const scriptUrl = `${bestdoriUrl}/assets/${Server[server]}/${baseImageName}_rip/assets-star-forassetbundle-startapp-thumbnail-animedegree-${baseImageName}-${baseImageName}.asset`; baseImageName,
const srciptBuffer = await downloadFileCache(scriptUrl); server,
const script = await readJSONFromBuffer(srciptBuffer); );
const script = await readJSONFromBuffer(scriptBuffer);
const frames: Array<Frame> = script['Base']['mSprites'] as Array<Frame>; const frames: Array<Frame> = script['Base']['mSprites'] as Array<Frame>;
const framecount = frames.length; const framecount = frames.length;
if (!frame) { if (!frame) {
@ -144,27 +136,9 @@ export async function getFrameFromAnimatedDegreeAsset(
frame = Math.floor(Math.random() * framecount); frame = Math.floor(Math.random() * framecount);
} }
// texture const textureBuffer = await degreeResourceRepository.getAnimatedTextureBuffer(
// example https://bestdori.com/assets/cn/ani_degree_bilibili_day1_rip/ani_degree_bilibili_day1.png baseImageName,
// 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 server,
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 texture = await loadImage(textureBuffer); const texture = await loadImage(textureBuffer);

View File

@ -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<BangDreamDataProvider> {
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',
);
});
});