From 4d0cd425e1f656d3874fa7b8ce37251d16ec6e7e Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 10 Sep 2026 14:54:06 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20AGC=20=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=BA=93=E8=81=9A=E5=90=88=E6=B4=BE=E7=94=9F=E7=BA=AF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 gameCreationAppAssetTagLibrary:从 manifest assets[].tags 派生去重、稳定排序、含使用计数的标签库 - 标签归一化沿用写入路径 normalizeGameCreationAppAssetTags,同素材重复标签只计一次 - 新增 assetTagsMatchSelection / gameCreationAppAssetMatchesTags 标签筛选判据(AND、空选择不过滤) - 新增 5 条纯模块测试覆盖去重计数、顺序稳定、空标签与筛选判据 --- .../gameCreationAppAssetTagLibrary.test.ts | 65 ++++++++++++++++ .../gameCreationAppAssetTagLibrary.ts | 78 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts create mode 100644 packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts diff --git a/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts new file mode 100644 index 000000000..f2bd3b2be --- /dev/null +++ b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + assetTagsMatchSelection, + buildGameCreationAppAssetTagLibrary, + gameCreationAppAssetMatchesTags, +} from './gameCreationAppAssetTagLibrary'; + +describe('AI 游戏创作 App 标签库派生', () => { + it('dedupes tags and counts how many assets use each tag', () => { + const library = buildGameCreationAppAssetTagLibrary([ + { id: 'asset-a', tags: ['像素风', '主角'] }, + { id: 'asset-b', tags: ['像素风'] }, + { id: 'asset-c', tags: [] }, + { id: 'asset-d', tags: undefined }, + ]); + + expect(library).toEqual([ + { tag: '像素风', assetCount: 2, assetIds: ['asset-a', 'asset-b'] }, + { tag: '主角', assetCount: 1, assetIds: ['asset-a'] }, + ]); + }); + + it('keeps the order stable across input permutations', () => { + const first = buildGameCreationAppAssetTagLibrary([ + { id: 'asset-a', tags: ['b', 'a'] }, + { id: 'asset-b', tags: ['b', 'c'] }, + ]); + const second = buildGameCreationAppAssetTagLibrary([ + { id: 'asset-b', tags: ['c', 'b'] }, + { id: 'asset-a', tags: ['a', 'b'] }, + ]); + + expect(second).toEqual(first); + expect(first.map((entry) => entry.tag)).toEqual(['b', 'a', 'c']); + }); + + it('counts a repeated tag on one asset once and ignores blank tags', () => { + const library = buildGameCreationAppAssetTagLibrary([ + { id: 'asset-a', tags: ['像素风', '像素风', ' ', ''] }, + ]); + + expect(library).toEqual([ + { tag: '像素风', assetCount: 1, assetIds: ['asset-a'] }, + ]); + }); + + it('returns an empty library without registered assets', () => { + expect(buildGameCreationAppAssetTagLibrary([])).toEqual([]); + }); + + it('requires every selected tag to match and treats an empty selection as no filter', () => { + const tags = ['像素风', '主角']; + + expect(assetTagsMatchSelection(tags, [])).toBe(true); + expect(assetTagsMatchSelection(tags, ['像素风'])).toBe(true); + expect(assetTagsMatchSelection(tags, ['像素风', '主角'])).toBe(true); + expect(assetTagsMatchSelection(tags, ['像素风', '场景'])).toBe(false); + expect(assetTagsMatchSelection(undefined, ['像素风'])).toBe(false); + expect(gameCreationAppAssetMatchesTags({ tags }, ['主角'])).toBe(true); + expect(gameCreationAppAssetMatchesTags({ tags: undefined }, ['主角'])).toBe( + false, + ); + }); +}); diff --git a/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts new file mode 100644 index 000000000..0ebcfdb71 --- /dev/null +++ b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts @@ -0,0 +1,78 @@ +import { + type GameCreationAppAssetManifestEntry, + gameCreationAppAssetTags, + normalizeGameCreationAppAssetTags, +} from './gameCreationApp'; + +/** + * 标签库里的一项。标签库是从 manifest `assets[].tags` 派生出来的数据, + * 不新增任何持久化字段:manifest 一改,标签库重算即可。 + */ +export interface GameCreationAppAssetTagLibraryEntry { + tag: string; + /** 使用该标签的已登记素材数量。 */ + assetCount: number; + /** 使用该标签的素材 id,按 id 稳定升序,与 manifest 中的写入顺序无关。 */ + assetIds: string[]; +} + +type AssetTagSource = Pick; + +/** + * 派生标签库:按标签去重、统计使用次数,并给出稳定总序。 + * + * 排序口径:使用次数降序 → 标签 `zh-CN` 本地化升序。 + * 两条规则都能在任意输入顺序下复现同一结果,因此标签筛选 UI 不会因为 + * manifest 里素材顺序变化而抖动。 + */ +export function buildGameCreationAppAssetTagLibrary( + assets: readonly AssetTagSource[], +): GameCreationAppAssetTagLibraryEntry[] { + const byTag = new Map(); + for (const asset of assets) { + // 归一化口径沿用写入路径:去空白、丢空串、同素材内去重。 + for (const tag of normalizeGameCreationAppAssetTags( + gameCreationAppAssetTags(asset), + )) { + const assetIds = byTag.get(tag); + if (!assetIds) { + byTag.set(tag, [asset.id]); + continue; + } + // 同一条素材重复写同一个标签只算一次。 + if (!assetIds.includes(asset.id)) { + assetIds.push(asset.id); + } + } + } + return Array.from(byTag, ([tag, assetIds]) => ({ + tag, + assetCount: assetIds.length, + assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)), + })).sort( + (left, right) => + right.assetCount - left.assetCount || + left.tag.localeCompare(right.tag, 'zh-CN'), + ); +} + +/** + * 标签筛选判据:已选标签必须全部命中(AND),空选择视为不过滤。 + * 与 `category` 筛选共存时由调用方叠加,本函数只看标签。 + */ +export function assetTagsMatchSelection( + tags: readonly string[] | undefined, + selectedTags: readonly string[], +): boolean { + if (selectedTags.length === 0) return true; + const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? [])); + return selectedTags.every((tag) => normalized.has(tag)); +} + +/** manifest 资产条目上的标签筛选,口径与 `assetTagsMatchSelection` 一致。 */ +export function gameCreationAppAssetMatchesTags( + asset: Pick, + selectedTags: readonly string[], +): boolean { + return assetTagsMatchSelection(gameCreationAppAssetTags(asset), selectedTags); +}