fix: polish bark battle creation flow

This commit is contained in:
kdletters
2026-05-22 05:00:07 +08:00
parent 01da85a577
commit bf82f04b64
73 changed files with 9362 additions and 2663 deletions

View File

@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
@@ -226,6 +227,42 @@ const babyObjectMatchDraftItem: BabyObjectMatchDraft = {
publishedAt: null,
};
const barkBattleDraftItem: BarkBattleWorkSummary = {
workId: 'bark-battle-work-draft-visible',
draftId: 'bark-battle-draft-visible',
ownerUserId: 'user-1',
authorDisplayName: '声浪作者',
title: '竖屏声浪草稿',
summary: '生成完成后也必须留在我的草稿里。',
themeDescription: '霓虹竖屏擂台',
playerImageDescription: '红围巾选手',
opponentImageDescription: '蓝头带对手',
onomatopoeia: ['炸场', '破阵'],
playerCharacterImageSrc: '/bark/player.png',
opponentCharacterImageSrc: '/bark/opponent.png',
uiBackgroundImageSrc: '/bark/background.png',
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-21T10:00:00.000Z',
publishedAt: null,
};
const barkBattlePublishedItem: BarkBattleWorkSummary = {
...barkBattleDraftItem,
workId: 'bark-battle-work-published-visible',
draftId: 'bark-battle-draft-published-visible',
title: '竖屏声浪已发布',
summary: '发布完成后必须留在已发布作品里。',
authorDisplayName: '发布作者',
status: 'published',
playCount: 9,
updatedAt: '2026-05-21T10:10:00.000Z',
publishedAt: '2026-05-21T10:10:00.000Z',
};
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
@@ -592,6 +629,47 @@ test('creation hub shows delete action for baby object match drafts', async () =
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
});
test('creation hub works-only tab filters bark battle draft and published works', async () => {
const user = userEvent.setup();
const onOpenBarkBattleDetail = vi.fn();
render(
<CustomWorldCreationHub
mode="works-only"
items={[]}
barkBattleItems={[barkBattleDraftItem, barkBattlePublishedItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenBarkBattleDetail={onOpenBarkBattleDetail}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy();
expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy();
expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy();
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '草稿 1' }));
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.queryByText('竖屏声浪已发布')).toBeNull();
await user.click(screen.getByRole('button', { name: '已发布 1' }));
expect(screen.queryByText('竖屏声浪草稿')).toBeNull();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
await user.click(
screen.getByRole('button', { name: //u }),
);
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem);
});
test('creation hub published work delete action is revealed without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
@@ -68,6 +69,9 @@ type CustomWorldCreationHubProps = {
babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
barkBattleItems?: BarkBattleWorkSummary[];
onOpenBarkBattleDetail?: ((item: BarkBattleWorkSummary) => void) | null;
onDeleteBarkBattle?: ((item: BarkBattleWorkSummary) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
@@ -173,6 +177,9 @@ export function CustomWorldCreationHub({
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null,
barkBattleItems = [],
onOpenBarkBattleDetail = null,
onDeleteBarkBattle = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
@@ -196,6 +203,7 @@ export function CustomWorldCreationHub({
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
@@ -204,6 +212,7 @@ export function CustomWorldCreationHub({
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
@@ -219,6 +228,8 @@ export function CustomWorldCreationHub({
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenBarkBattleDetail: onOpenBarkBattleDetail ?? undefined,
onDeleteBarkBattle: onDeleteBarkBattle ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
@@ -227,6 +238,7 @@ export function CustomWorldCreationHub({
bigFishItems,
isSquareHoleCreationVisible,
babyObjectMatchItems,
barkBattleItems,
items,
match3dItems,
onDeleteBigFish,
@@ -235,12 +247,14 @@ export function CustomWorldCreationHub({
onDeletePublished,
onDeletePuzzle,
onDeleteBabyObjectMatch,
onDeleteBarkBattle,
onDeleteVisualNovel,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
onOpenMatch3DDetail,
onOpenBabyObjectMatchDetail,
onOpenBarkBattleDetail,
onOpenPuzzleDetail,
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
@@ -284,6 +298,9 @@ export function CustomWorldCreationHub({
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'bark-battle':
onOpenBarkBattleDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;

View File

@@ -61,6 +61,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
'square-hole': '/creation-type-references/square-hole.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'bark-battle': '/creation-type-references/bark-battle.webp',
'visual-novel': '/creation-type-references/visual-novel.webp',
};
@@ -727,6 +728,8 @@ export function CustomWorldWorkCard({
{item.summary}
</div>
<div className="creation-work-card__author">{item.authorDisplayName}</div>
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (

View File

@@ -1,10 +1,16 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
hasBarkBattleRequiredImages,
isPersistedBarkBattleDraftGenerating,
type CreationWorkShelfItem,
} from './creationWorkShelf';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
const items = buildCreationWorkShelfItems({
@@ -50,6 +56,253 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
expect(items[1]?.publicWorkCode).toBeNull();
});
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '汪汪测试杯',
summary: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-14T10:01:00.000Z',
publishedAt: null,
},
{
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '汪汪测试杯',
summary: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-14T10:02:00.000Z',
publishedAt: '2026-05-14T10:02:00.000Z',
},
],
});
expect(items).toHaveLength(1);
expect(items[0]?.kind).toBe('bark-battle');
expect(items[0]?.status).toBe('published');
expect(items[0]?.publicWorkCode).toBe('BB-TLEWORK1');
expect(items[0]?.authorDisplayName).toBe('测试玩家');
});
test('buildCreationWorkShelfItems keeps separate bark battle draft and published works visible', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'BB-DRAFT001',
draftId: 'bark-battle-draft-visible',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '草稿声浪赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/draft-player.png',
opponentCharacterImageSrc: '/draft-opponent.png',
uiBackgroundImageSrc: '/draft-background.png',
difficultyPreset: 'easy',
status: 'draft',
generationStatus: 'ready',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
},
{
workId: 'BB-PUB00001',
draftId: 'bark-battle-draft-published',
ownerUserId: 'user-1',
authorDisplayName: '发布作者',
title: '已发布声浪赛',
summary: '',
themeDescription: '霓虹声浪挑战',
playerImageDescription: '柴犬选手',
opponentImageDescription: '机器人对手',
playerCharacterImageSrc: '/published-player.png',
opponentCharacterImageSrc: '/published-opponent.png',
uiBackgroundImageSrc: '/published-background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 3,
updatedAt: '2026-05-21T00:00:00.000Z',
publishedAt: '2026-05-21T00:00:00.000Z',
},
],
});
expect(items).toHaveLength(2);
expect(items.find((item) => item.status === 'draft')?.id).toBe('BB-DRAFT001');
expect(items.find((item) => item.status === 'published')?.id).toBe(
'BB-PUB00001',
);
expect(items.find((item) => item.status === 'published')?.publicWorkCode).toBe(
'BB-PUB00001',
);
});
test('buildCreationWorkShelfItems gives bark battle draft cover from character or reference fallback', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'BB-COVER001',
draftId: 'bark-battle-draft-cover',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '角色封面声浪赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/draft-player-cover.png',
opponentCharacterImageSrc: '/draft-opponent-cover.png',
uiBackgroundImageSrc: null,
difficultyPreset: 'easy',
status: 'draft',
generationStatus: 'partial_failed',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
},
{
workId: 'BB-COVER002',
draftId: 'bark-battle-draft-cover-fallback',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '默认封面声浪赛',
summary: '',
themeDescription: '夜市声浪挑战',
playerImageDescription: '柴犬选手',
opponentImageDescription: '机器人对手',
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'pending_assets',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-19T00:00:00.000Z',
publishedAt: null,
},
],
});
expect(items.find((item) => item.id === 'BB-COVER001')?.coverImageSrc).toBe(
'/draft-player-cover.png',
);
expect(items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs).toEqual([
'/draft-player-cover.png',
'/draft-opponent-cover.png',
]);
expect(items.find((item) => item.id === 'BB-COVER002')?.coverImageSrc).toBe(
'/creation-type-references/bark-battle.webp',
);
});
test('buildCreationWorkShelfItems keeps bark battle draft author display name', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'bark-battle-work-draft-author',
draftId: 'bark-battle-draft-author',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '草稿声浪赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/player.png',
opponentCharacterImageSrc: '/opponent.png',
uiBackgroundImageSrc: '/background.png',
difficultyPreset: 'easy',
status: 'draft',
generationStatus: 'ready',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
},
],
});
expect(items[0]?.kind).toBe('bark-battle');
expect(items[0]?.status).toBe('draft');
expect(items[0]?.authorDisplayName).toBe('草稿作者');
});
test('buildCreationWorkShelfItems falls back unknown authors to player label', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d-work-author-fallback',
profileId: 'match3d-profile-author-fallback',
ownerUserId: 'user-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '把水果从透明罐里抓出来。',
tags: [],
coverImageSrc: null,
clearCount: 0,
difficulty: 1,
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
publishReady: true,
},
],
});
expect(items[0]?.kind).toBe('match3d');
expect(items[0]?.authorDisplayName).toBe('玩家');
});
test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => {
const onOpenPuzzleDetail = vi.fn();
const onDeletePuzzle = vi.fn();
@@ -672,6 +925,159 @@ test('buildCreationWorkShelfItems uses match3d transparent container reference a
);
});
test('buildCreationWorkShelfItems maps bark battle works with scene role cover and BB code', () => {
const onOpenBarkBattleDetail = vi.fn();
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'bark-battle-work-12345678',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '公园声浪赛',
summary: '柯基和哈士奇比拼声浪。',
themeDescription: '傍晚公园擂台',
playerImageDescription: '红围巾柯基',
opponentImageDescription: '蓝头带哈士奇',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 6,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
},
],
onOpenBarkBattleDetail,
});
const item = items[0];
item?.actions.open();
expect(item?.kind).toBe('bark-battle');
expect(item?.publicWorkCode).toBe('BB-12345678');
expect(item?.sharePath).toContain('/works/detail?work=BB-12345678');
expect(item?.coverImageSrc).toBe('/generated-bark-battle/background.png');
expect(item?.coverRenderMode).toBe('scene_with_roles');
expect(item?.coverCharacterImageSrcs).toEqual([
'/generated-bark-battle/player.png',
'/generated-bark-battle/opponent.png',
]);
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(
expect.objectContaining({ workId: 'bark-battle-work-12345678' }),
);
});
test('bark battle draft generating state follows pending assets or missing three images', () => {
const draft = {
workId: 'bark-battle-work-draft',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '草稿声浪赛',
summary: '',
themeDescription: '草地',
playerImageDescription: '柯基',
opponentImageDescription: '哈士奇',
playerCharacterImageSrc: '/player.png',
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: '/background.png',
difficultyPreset: 'easy' as const,
status: 'draft' as const,
generationStatus: 'pending_assets',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
};
expect(hasBarkBattleRequiredImages(draft)).toBe(false);
expect(isPersistedBarkBattleDraftGenerating(draft)).toBe(true);
expect(
isPersistedBarkBattleDraftGenerating({
...draft,
opponentCharacterImageSrc: '/opponent.png',
generationStatus: 'ready',
}),
).toBe(false);
});
test('CustomWorldWorkCard renders author for draft and published works', () => {
const buildItem = (
status: CreationWorkShelfItem['status'],
authorDisplayName: string,
): CreationWorkShelfItem => ({
id: `card-${status}`,
kind: 'bark-battle',
status,
authorDisplayName,
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
summary: '一场轻快的汪汪声浪对决。',
updatedAt: '2026-05-20T00:00:00.000Z',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode: null,
sharePath: null,
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
canDelete: false,
canShare: false,
badges: [
{ id: 'status', label: status === 'draft' ? '草稿' : '已发布', tone: 'neutral' },
{ id: 'type', label: '汪汪', tone: 'neutral' },
],
metrics: [],
actions: { open: () => {} },
source: {
kind: 'bark-battle',
item: {
workId: `bark-battle-${status}`,
draftId: `draft-${status}`,
ownerUserId: 'user-1',
authorDisplayName,
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
summary: '一场轻快的汪汪声浪对决。',
themeDescription: '公园舞台',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status,
generationStatus: 'ready',
publishReady: status === 'published',
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: status === 'published' ? '2026-05-20T00:00:00.000Z' : null,
},
},
});
const draftHtml = renderToStaticMarkup(
createElement(CustomWorldWorkCard, {
item: buildItem('draft', '草稿作者'),
onOpen: () => {},
}),
);
const publishedHtml = renderToStaticMarkup(
createElement(CustomWorldWorkCard, {
item: buildItem('published', '发布作者'),
onOpen: () => {},
}),
);
expect(draftHtml).toContain('作者:草稿作者');
expect(publishedHtml).toContain('作者:发布作者');
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,

View File

@@ -1,3 +1,4 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -9,6 +10,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBabyObjectMatchPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
@@ -19,6 +21,9 @@ import type { CustomWorldProfile } from '../../types';
const MATCH3D_CONTAINER_REFERENCE_COVER_SRC =
'/match3d-background-references/pot-fused-reference.png';
const BARK_BATTLE_REFERENCE_COVER_SRC =
'/creation-type-references/bark-battle.webp';
const DEFAULT_CREATION_WORK_AUTHOR = '玩家';
export type CreationWorkShelfKind =
| 'rpg'
@@ -27,6 +32,7 @@ export type CreationWorkShelfKind =
| 'square-hole'
| 'puzzle'
| 'baby-object-match'
| 'bark-battle'
| 'visual-novel';
export type CreationWorkShelfStatus = 'draft' | 'published';
@@ -84,6 +90,10 @@ export type CreationWorkShelfSource =
kind: 'visual-novel';
item: VisualNovelWorkSummary;
}
| {
kind: 'bark-battle';
item: BarkBattleWorkSummary;
}
| {
kind: 'baby-object-match';
item: BabyObjectMatchDraft;
@@ -103,6 +113,7 @@ export type CreationWorkShelfItem = {
hasUnreadUpdate?: boolean;
title: string;
summary: string;
authorDisplayName: string;
updatedAt: string;
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
@@ -127,6 +138,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems?: SquareHoleWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[];
barkBattleItems?: BarkBattleWorkSummary[];
visualNovelItems?: VisualNovelWorkSummary[];
canDeleteRpg?: boolean;
canDeleteBigFish?: boolean;
@@ -134,6 +146,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteBarkBattle?: boolean;
canDeleteVisualNovel?: boolean;
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
onEnterRpgPublished?: (profileId: string) => void;
@@ -149,6 +162,8 @@ export function buildCreationWorkShelfItems(params: {
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
onDeleteBabyObjectMatch?: (item: BabyObjectMatchDraft) => void;
onOpenBarkBattleDetail?: (item: BarkBattleWorkSummary) => void;
onDeleteBarkBattle?: (item: BarkBattleWorkSummary) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
@@ -163,6 +178,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems = [],
puzzleItems,
babyObjectMatchItems = [],
barkBattleItems = [],
visualNovelItems = [],
canDeleteRpg = false,
canDeleteBigFish = false,
@@ -170,6 +186,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteBarkBattle = false,
canDeleteVisualNovel = false,
onOpenRpgDraft,
onEnterRpgPublished,
@@ -185,6 +202,8 @@ export function buildCreationWorkShelfItems(params: {
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenBarkBattleDetail,
onDeleteBarkBattle,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
@@ -229,6 +248,12 @@ export function buildCreationWorkShelfItems(params: {
onDelete: onDeleteBabyObjectMatch,
}),
),
...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
onOpen: onOpenBarkBattleDetail,
onDelete: onDeleteBarkBattle,
}),
),
...visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail,
@@ -259,6 +284,28 @@ export function buildCreationWorkShelfItems(params: {
);
}
function mergeBarkBattleShelfSourceItems(
items: readonly BarkBattleWorkSummary[],
): BarkBattleWorkSummary[] {
const byWorkId = new Map<string, BarkBattleWorkSummary>();
for (const item of items) {
const current = byWorkId.get(item.workId);
if (!current) {
byWorkId.set(item.workId, item);
continue;
}
if (current.status !== 'published' && item.status === 'published') {
byWorkId.set(item.workId, { ...current, ...item });
continue;
}
if (current.status === item.status) {
byWorkId.set(item.workId, { ...current, ...item });
}
}
return Array.from(byWorkId.values());
}
type RpgWorkShelfAdapter = {
onOpenDraft?: (item: CustomWorldWorkSummary) => void;
onEnterPublished?: (profileId: string) => void;
@@ -303,6 +350,7 @@ function mapRpgWorkToShelfItem(
status: item.status,
title: item.title,
summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item, libraryEntry),
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: item.coverRenderMode ?? 'image',
@@ -342,6 +390,7 @@ function mapBigFishWorkToShelfItem(
status: item.status,
title: item.title,
summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
@@ -386,6 +435,7 @@ function mapMatch3DWorkToShelfItem(
status,
title: item.gameName,
summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
@@ -434,6 +484,7 @@ function mapPuzzleWorkToShelfItem(
item.workDescription?.trim() ||
item.summary.trim() ||
(status === 'draft' ? '未填写作品描述' : ''),
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
@@ -500,6 +551,7 @@ function mapBabyObjectMatchDraftToShelfItem(
summary:
item.workDescription.trim() ||
`${item.itemNames[0]}${item.itemNames[1]}识物分类`,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
@@ -549,6 +601,7 @@ function mapVisualNovelWorkToShelfItem(
status,
title,
summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
@@ -578,6 +631,72 @@ function mapVisualNovelWorkToShelfItem(
};
}
function mapBarkBattleWorkToShelfItem(
item: BarkBattleWorkSummary,
canDelete: boolean,
adapter: WorkShelfAdapter<BarkBattleWorkSummary>,
): CreationWorkShelfItem {
const status = item.status;
const publicWorkCode =
status === 'published' ? buildBarkBattlePublicWorkCode(item.workId) : null;
const playerCharacterImageSrc = normalizeCoverImageSrc(
item.playerCharacterImageSrc,
);
const opponentCharacterImageSrc = normalizeCoverImageSrc(
item.opponentCharacterImageSrc,
);
const coverImageSrc =
normalizeCoverImageSrc(item.uiBackgroundImageSrc) ??
playerCharacterImageSrc ??
opponentCharacterImageSrc ??
BARK_BATTLE_REFERENCE_COVER_SRC;
const coverCharacterImageSrcs = [
playerCharacterImageSrc,
opponentCharacterImageSrc,
].filter((imageSrc): imageSrc is string => Boolean(imageSrc));
const canRenderSceneWithRoles =
Boolean(normalizeCoverImageSrc(item.uiBackgroundImageSrc)) &&
coverCharacterImageSrcs.length >= 2;
return {
id: item.workId,
kind: 'bark-battle',
status,
title: item.title.trim() || '汪汪声浪大作战',
summary:
item.summary.trim() ||
item.themeDescription.trim() ||
(status === 'draft' ? '未填写作品描述' : ''),
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: canRenderSceneWithRoles ? 'scene_with_roles' : 'image',
coverCharacterImageSrcs,
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '汪汪', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: 0,
likeCount: 0,
})
: [],
actions: buildWorkShelfActions(item, adapter),
source: { kind: 'bark-battle', item },
};
}
function mapSquareHoleWorkToShelfItem(
item: SquareHoleWorkSummary,
canDelete: boolean,
@@ -596,6 +715,7 @@ function mapSquareHoleWorkToShelfItem(
status,
title: item.gameName,
summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
@@ -625,6 +745,26 @@ function mapSquareHoleWorkToShelfItem(
};
}
function resolveAuthorDisplayName(
...sources: Array<unknown>
) {
for (const source of sources) {
const authorDisplayName =
source &&
typeof source === 'object' &&
'authorDisplayName' in source &&
typeof source.authorDisplayName === 'string'
? source.authorDisplayName.trim()
: '';
if (authorDisplayName) {
return authorDisplayName;
}
}
return DEFAULT_CREATION_WORK_AUTHOR;
}
function normalizeCoverImageSrc(value?: string | null) {
return value?.trim() || null;
}
@@ -816,11 +956,34 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
return item.source.item.generationStatus === 'generating';
case 'puzzle':
return isPersistedPuzzleDraftGenerating(item.source.item);
case 'bark-battle':
return isPersistedBarkBattleDraftGenerating(item.source.item);
default:
return false;
}
}
export function isPersistedBarkBattleDraftGenerating(
item: BarkBattleWorkSummary,
) {
if (item.status === 'published') {
return false;
}
return (
item.generationStatus === 'pending_assets' ||
!hasBarkBattleRequiredImages(item)
);
}
export function hasBarkBattleRequiredImages(item: BarkBattleWorkSummary) {
return Boolean(
normalizeCoverImageSrc(item.playerCharacterImageSrc) &&
normalizeCoverImageSrc(item.opponentCharacterImageSrc) &&
normalizeCoverImageSrc(item.uiBackgroundImageSrc),
);
}
export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) {
if (item.generationStatus !== 'generating') {
return false;