merge: master into codex/bark-battle

This commit is contained in:
kdletters
2026-05-19 17:04:32 +08:00
307 changed files with 40711 additions and 26022 deletions
+16 -2
View File
@@ -316,7 +316,7 @@ test('auth gate does not auto-create a guest account when dev guest switch is no
expect(await screen.findByText('应用内容')).toBeTruthy();
});
test('auth gate keeps password entry available when login options are empty', async () => {
test('auth gate keeps sms and password entries available when login options are empty', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
@@ -336,12 +336,19 @@ test('auth gate keeps password entry available when login options are empty', as
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy();
expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy();
expect(within(dialog).getByLabelText('验证码')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
expect(within(dialog).queryByText('读取登录方式失败')).toBeNull();
});
test('auth gate falls back to password entry when login options request fails', async () => {
test('auth gate keeps sms and password entries available when login options request fails', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockRejectedValue(
@@ -357,6 +364,13 @@ test('auth gate falls back to password entry when login options request fails',
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy();
expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy();
expect(within(dialog).getByLabelText('验证码')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
});
+10 -8
View File
@@ -61,7 +61,7 @@ type AuthStatus =
| 'ready'
| 'error';
const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password'];
const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password'];
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
@@ -76,11 +76,13 @@ function normalizeAvailableLoginMethods(
): AuthLoginMethod[] {
const normalizedMethods = Array.from(new Set(methods ?? []));
// 密码登录由 Rust auth entry 固定承载,不依赖短信或微信环境开关。
// 当 login-options 联调失败或配置返回空数组时,仍要保留账号入口,避免登录弹窗失去可操作方式。
return normalizedMethods.length > 0
? normalizedMethods
: FALLBACK_LOGIN_METHODS;
// 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。
return Array.from(
new Set<AuthLoginMethod>([
...REQUIRED_LOGIN_METHODS,
...normalizedMethods,
]),
);
}
type AuthHydrateSessionResult =
@@ -367,9 +369,9 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
setAvailableLoginMethods(FALLBACK_LOGIN_METHODS);
setAvailableLoginMethods(REQUIRED_LOGIN_METHODS);
setUser(null);
// 中文注释:登录方式接口失败时按产品约定保留密码登录入口;
// 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口;
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
setError(callbackResult?.error ?? '');
setStatus('unauthenticated');
+2 -2
View File
@@ -80,8 +80,8 @@ export function LoginScreen({
const [legalConsentChecked, setLegalConsentChecked] = useState(false);
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const passwordLoginEnabled = true;
const phoneLoginEnabled = true;
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
@@ -221,6 +221,81 @@ test('creation hub marks generating and newly completed drafts', () => {
expect(html).toContain('creation-work-card__spinner');
});
test('creation hub does not mask completed puzzle drafts while a later level image is generating', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle-work-session-1',
profileId: 'puzzle-profile-session-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图草稿',
workDescription: '已经有可查看的首关结果。',
levelName: '潮雾拼图',
summary: '已经有可查看的首关结果。',
themeTags: ['潮雾'],
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
publicationStatus: 'draft',
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
publishedAt: null,
publishReady: false,
sourceSessionId: 'puzzle-session-1',
generationStatus: 'generating',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '潮雾拼图',
pictureDescription: '潮雾港口。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/generated-puzzle-assets/session/cover.png',
assetId: 'asset-1',
prompt: '潮雾港口',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
},
{
levelId: 'puzzle-level-2',
levelName: '灯塔',
pictureDescription: '灯塔新关卡。',
pictureReference: null,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
},
],
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).not.toContain('生成中...');
expect(html).not.toContain('creation-work-card__spinner');
expect(html).toContain('继续创作《潮雾拼图草稿》');
});
test('creation hub published work uses unified list card layout', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
@@ -86,6 +86,58 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
});
test('buildCreationWorkShelfItems restores persisted generation state for puzzle and match3d drafts', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:generating',
profileId: 'puzzle-profile-generating',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-generating',
authorDisplayName: '测试作者',
levelName: '生成中拼图',
summary: '退出产品后仍应显示生成中。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
},
],
match3dItems: [
{
workId: 'match3d:generating',
profileId: 'match3d-profile-generating',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-generating',
gameName: '生成中抓鹅',
themeText: '糖果厨房',
summary: '退出产品后仍应显示生成中。',
tags: [],
coverImageSrc: null,
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generationStatus: 'generating',
},
],
});
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(
true,
);
expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe(
true,
);
});
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
const onOpenBabyObjectMatchDetail = vi.fn();
const onDeleteBabyObjectMatch = vi.fn();
@@ -238,13 +238,19 @@ export function buildCreationWorkShelfItems(params: {
]
.map((item) => {
const state = getItemState?.(item);
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
return state
? {
...item,
isGenerating: state.isGenerating,
isGenerating: Boolean(state.isGenerating || persistedIsGenerating),
hasUnreadUpdate: state.hasUnreadUpdate,
}
: item;
: persistedIsGenerating
? {
...item,
isGenerating: true,
}
: item;
})
.sort(
(left, right) =>
@@ -635,7 +641,7 @@ function isCreationTypeReferenceCoverImageSrc(value?: string | null) {
);
}
function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
export function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (
directCoverImageSrc &&
@@ -645,33 +651,44 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
}
for (const level of item.levels ?? []) {
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
if (
levelCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
) {
return levelCoverImageSrc;
const levelImageSrc = resolvePuzzleLevelCoverImageSrc(level);
if (levelImageSrc) {
return levelImageSrc;
}
}
const selectedCandidateImageSrc =
level.selectedCandidateId && level.candidates.length > 0
? normalizeCoverImageSrc(
level.candidates.find(
(candidate) => candidate.candidateId === level.selectedCandidateId,
)?.imageSrc,
return null;
}
export function resolvePuzzleLevelCoverImageSrc(
level: NonNullable<PuzzleWorkSummary['levels']>[number],
) {
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
if (
levelCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
) {
return levelCoverImageSrc;
}
const selectedCandidateImageSrc =
level.selectedCandidateId && level.candidates.length > 0
? normalizeCoverImageSrc(
level.candidates.find(
(candidate) => candidate.candidateId === level.selectedCandidateId,
)?.imageSrc,
)
: null;
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
: null;
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
if (
candidateImageSrc &&
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
) {
return candidateImageSrc;
}
if (
candidateImageSrc &&
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
) {
return candidateImageSrc;
}
return null;
@@ -793,6 +810,31 @@ function buildPuzzleWorkShelfActions(
};
}
function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
switch (item.source.kind) {
case 'match3d':
return item.source.item.generationStatus === 'generating';
case 'puzzle':
return isPersistedPuzzleDraftGenerating(item.source.item);
default:
return false;
}
}
export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) {
if (item.generationStatus !== 'generating') {
return false;
}
const hasUsableCover = Boolean(resolvePuzzleWorkCoverImageSrc(item));
const hasReadyLevel = (item.levels ?? []).some((level) =>
Boolean(resolvePuzzleLevelCoverImageSrc(level)),
);
// 中文注释:作品架“生成中”只表示初始草稿还没有可查看结果;结果页追加关卡或重绘局部图片不能锁住整张草稿卡。
return !hasUsableCover && !hasReadyLevel;
}
function buildRpgWorkShelfActions(
item: CustomWorldWorkSummary,
adapter: RpgWorkShelfAdapter,
@@ -1359,7 +1359,7 @@ describe('Match3DResultView', () => {
'img[src="/match3d-background-references/pot-fused-reference.png"]',
);
expect(containerImage).toBeTruthy();
expect(containerImage?.className).toContain('w-[min(99vw,34rem)]');
expect(containerImage?.className).toContain('w-[min(108vw,38rem)]');
expect(containerImage?.className).toContain('-translate-x-1/2');
expect(
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
@@ -52,6 +52,19 @@ import {
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
import { resolveGeometryAsset } from './match3dVisualAssets';
const runtimeAudioFeedback = vi.hoisted(() => ({
playRuntimeMergeSound: vi.fn(),
}));
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../services/runtimeAudioFeedback')>();
return {
...actual,
playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound,
};
});
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
return {
@@ -82,6 +95,7 @@ afterEach(() => {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__;
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
vi.restoreAllMocks();
});
@@ -519,6 +533,318 @@ test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () =>
).toBe('scale(0.58)');
});
test('点击物品乐观插入到物品栏同类后面并后移后续物品', async () => {
const baseRun = startLocalMatch3DRun(3);
const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3);
expect(appleBoard && pearTray && appleTray).toBeTruthy();
const run: Match3DRunSnapshot = {
...baseRun,
items: [
{
...appleBoard!,
itemInstanceId: 'apple-3',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
clickable: true,
state: 'InBoard',
x: 0.5,
y: 0.5,
layer: 10,
traySlotIndex: null,
},
{
...pearTray!,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
clickable: false,
state: 'InTray',
traySlotIndex: 0,
},
{
...appleTray!,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
clickable: false,
state: 'InTray',
traySlotIndex: 1,
},
{
...baseRun.items[3]!,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
clickable: false,
state: 'InTray',
traySlotIndex: 2,
},
...baseRun.items.slice(4).map((item) => ({
...item,
clickable: false,
state: 'InBoard' as const,
})),
],
traySlots: baseRun.traySlots.map((slot) => {
if (slot.slotIndex === 0) {
return {
slotIndex: 0,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 1) {
return {
slotIndex: 1,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 2) {
return {
slotIndex: 2,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
};
}
return { slotIndex: slot.slotIndex };
}),
};
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
status: 'Accepted' as const,
acceptedItemInstanceId: payload.itemInstanceId,
clearedItemInstanceIds: [],
run: {
...run,
snapshotVersion: run.snapshotVersion + 1,
},
}));
const onOptimisticRunChange = vi.fn();
render(
<Match3DRuntimeShell
run={run}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
const board = screen.getByTestId('match3d-board');
mockMatch3DBoardRect();
mockMatch3DPointerCapture(board);
Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', {
configurable: true,
value: () => ({
bottom: 530,
height: 56,
left: 220,
right: 276,
top: 474,
width: 56,
x: 220,
y: 474,
toJSON: () => ({}),
}),
});
const point = toMatch3DBoardClientPoint(run.items[0]!);
fireMatch3DBoardPointer(board, 'pointerdown', point, 14);
fireMatch3DBoardPointer(board, 'pointerup', point, 14);
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled());
const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as
| Match3DRunSnapshot
| undefined;
expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
'apple-1',
'apple-2',
'apple-3',
'pear-1',
null,
null,
null,
]);
expect(
optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3')
?.traySlotIndex,
).toBe(2);
});
test('三消确认后物品栏播放合成动画并隐藏权威快照中已清除的槽位', async () => {
const baseRun = startLocalMatch3DRun(1);
const [first, second, third, fourth] = baseRun.items.slice(0, 4);
expect(first && second && third).toBeTruthy();
const run: Match3DRunSnapshot = {
...baseRun,
totalItemCount: 4,
items: [
{
...first!,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
state: 'InTray',
clickable: false,
traySlotIndex: 0,
},
{
...second!,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
state: 'InTray',
clickable: false,
traySlotIndex: 1,
},
{
...third!,
itemInstanceId: 'apple-3',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
state: 'InBoard',
clickable: true,
x: 0.5,
y: 0.5,
layer: 10,
traySlotIndex: null,
},
{
...(fourth ?? third!),
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
state: 'InTray',
clickable: false,
traySlotIndex: 2,
},
],
traySlots: baseRun.traySlots.map((slot) => {
if (slot.slotIndex === 0) {
return {
slotIndex: 0,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 1) {
return {
slotIndex: 1,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 2) {
return {
slotIndex: 2,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
};
}
return { slotIndex: slot.slotIndex };
}),
};
const acceptedRun: Match3DRunSnapshot = {
...run,
snapshotVersion: run.snapshotVersion + 1,
clearedItemCount: 3,
items: run.items.map((item) =>
item.itemTypeId === 'apple'
? {
...item,
state: 'Cleared' as const,
clickable: false,
traySlotIndex: null,
}
: { ...item, traySlotIndex: 0 },
),
traySlots: run.traySlots.map((slot) =>
slot.slotIndex === 0
? {
slotIndex: 0,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
}
: { slotIndex: slot.slotIndex },
),
};
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
status: 'Accepted' as const,
acceptedItemInstanceId: payload.itemInstanceId,
clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'],
run: acceptedRun,
}));
const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
rerender(
<Match3DRuntimeShell
run={nextRun}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
});
const { rerender } = render(
<Match3DRuntimeShell
run={run}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
const board = screen.getByTestId('match3d-board');
mockMatch3DBoardRect();
mockMatch3DPointerCapture(board);
screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => {
Object.defineProperty(slot, 'getBoundingClientRect', {
configurable: true,
value: () => ({
bottom: 530,
height: 56,
left: 52 + index * 58,
right: 108 + index * 58,
top: 474,
width: 56,
x: 52 + index * 58,
y: 474,
toJSON: () => ({}),
}),
});
});
const point = toMatch3DBoardClientPoint(run.items[2]!);
fireMatch3DBoardPointer(board, 'pointerdown', point, 15);
fireMatch3DBoardPointer(board, 'pointerup', point, 15);
await waitFor(() =>
expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(),
);
expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3);
expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy();
expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull();
expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1);
const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as
| Match3DRunSnapshot
| undefined;
expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
'pear-1',
null,
null,
null,
null,
null,
null,
]);
});
test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => {
const run = startLocalMatch3DRun(1);
const clickableItem = run.items.find((item) => item.clickable)!;
@@ -1025,9 +1351,10 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
const containerImage = screen.getByTestId(
'match3d-container-image',
) as HTMLImageElement;
expect(containerImage.className).toContain('w-[min(99vw,34rem)]');
expect(containerImage.className).toContain('w-[min(116vw,42rem)]');
expect(containerImage.className).toContain('h-auto');
expect(containerImage.className).toContain('left-1/2');
expect(containerImage.className).toContain('top-[54%]');
expect(containerImage.className).toContain('-translate-x-1/2');
expect(screen.getByTestId('match3d-board').className).toContain(
'bg-transparent',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
import { expect, test } from 'vitest';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import {
findMatch3DHitItem,
isPointInsideMatch3DItemAlphaHotspot,
resolveMatch3DItemSizeScale,
type Match3DResolvedImageSourceEntry,
} from './match3dHotspot';
import { resolveRenderableItemFrame } from './match3dRuntimePresentation';
function buildMatch3DHotspotRun(): Match3DRunSnapshot {
return {
runId: 'hotspot-run',
profileId: 'hotspot-profile',
status: 'Running',
snapshotVersion: 1,
startedAtMs: 1,
durationLimitMs: 600_000,
remainingMs: 600_000,
clearCount: 1,
totalItemCount: 1,
clearedItemCount: 0,
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
items: [
{
clickable: true,
itemInstanceId: 'alpha-hotspot-item',
itemTypeId: 'match3d-type-01',
visualKey: 'block-red-2x2',
x: 0.5,
y: 0.5,
radius: 0.1,
layer: 1,
state: 'InBoard',
},
],
};
}
test('透明像素不作为抓大鹅物品点击热区', () => {
const run = buildMatch3DHotspotRun();
const item = run.items[0]!;
const mask = {
width: 4,
height: 4,
alpha: new Uint8ClampedArray([
0, 0, 0, 0,
0, 255, 255, 0,
0, 255, 255, 0,
0, 0, 0, 0,
]),
};
const imageSourceEntriesByType = new Map<string, Match3DResolvedImageSourceEntry[]>([
[
'match3d-type-01',
[
{
source: '/generated-match3d-assets/item-01.png',
resolvedSource: 'https://oss.example.com/item-01.png',
},
],
],
]);
const alphaHitMasks = new Map([
['/generated-match3d-assets/item-01.png', mask],
]);
const itemSizeByType = new Map([['match3d-type-01', '大' as const]]);
const frame = resolveRenderableItemFrame(item);
expect(
findMatch3DHitItem(run, frame.x - frame.radius * 0.6, 0.5, {
alphaHitMasks,
imageSourceEntriesByType,
itemSizeByType,
}),
).toBeUndefined();
expect(
findMatch3DHitItem(run, 0.5, 0.5, {
alphaHitMasks,
imageSourceEntriesByType,
itemSizeByType,
})?.itemInstanceId,
).toBe('alpha-hotspot-item');
});
test('小尺寸物品只在缩放后的非透明主体内命中', () => {
const item = buildMatch3DHotspotRun().items[0]!;
const mask = {
width: 2,
height: 2,
alpha: new Uint8ClampedArray([255, 255, 255, 255]),
};
expect(
isPointInsideMatch3DItemAlphaHotspot({
item,
pointX: 0.5,
pointY: 0.5,
mask,
itemSize: '小',
}),
).toBe(true);
expect(
isPointInsideMatch3DItemAlphaHotspot({
item,
pointX: 0.38,
pointY: 0.5,
mask,
itemSize: '小',
}),
).toBe(false);
});
test('抓大鹅生成物品大和中也会做一定程度缩小', () => {
expect(resolveMatch3DItemSizeScale('大')).toBeLessThan(1);
expect(resolveMatch3DItemSizeScale('中')).toBeLessThan(0.78);
expect(resolveMatch3DItemSizeScale('大')).toBeGreaterThan(
resolveMatch3DItemSizeScale('中'),
);
expect(resolveMatch3DItemSizeScale('中')).toBeGreaterThan(
resolveMatch3DItemSizeScale('小'),
);
});
@@ -0,0 +1,194 @@
import type {
Match3DItemSnapshot,
Match3DRunSnapshot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
isItemState,
resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
export type Match3DGeneratedItemRelativeSize = '大' | '中' | '小';
export type Match3DAlphaHitMask = {
width: number;
height: number;
alpha: Uint8ClampedArray;
};
export type Match3DResolvedImageSourceEntry = {
source: string;
resolvedSource: string;
};
const MATCH3D_HIT_ALPHA_THRESHOLD = 8;
function isPointInsideCircle(
pointX: number,
pointY: number,
item: Match3DItemSnapshot,
) {
const frame = resolveRenderableItemFrame(item);
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
}
function clampMatch3DHitPixelIndex(value: number, size: number) {
return Math.min(size - 1, Math.max(0, Math.floor(value * size)));
}
export function resolveMatch3DItemSizeScale(
itemSize: Match3DGeneratedItemRelativeSize | undefined,
) {
if (itemSize === '小') {
return 0.58;
}
if (itemSize === '中') {
return 0.68;
}
return 0.88;
}
function isPointInsideAlphaHitMask(
localX: number,
localY: number,
mask: Match3DAlphaHitMask,
itemSize: Match3DGeneratedItemRelativeSize,
) {
if (
mask.width <= 0 ||
mask.height <= 0 ||
mask.alpha.length < mask.width * mask.height ||
localX < 0 ||
localX > 1 ||
localY < 0 ||
localY > 1
) {
return false;
}
const aspectRatio = mask.width / mask.height;
const containWidth = aspectRatio >= 1 ? 1 : aspectRatio;
const containHeight = aspectRatio >= 1 ? 1 / aspectRatio : 1;
const imageScale = resolveMatch3DItemSizeScale(itemSize);
const renderedWidth = containWidth * imageScale;
const renderedHeight = containHeight * imageScale;
const imageLeft = (1 - renderedWidth) / 2;
const imageTop = (1 - renderedHeight) / 2;
if (
localX < imageLeft ||
localX > imageLeft + renderedWidth ||
localY < imageTop ||
localY > imageTop + renderedHeight
) {
return false;
}
const imageX = (localX - imageLeft) / renderedWidth;
const imageY = (localY - imageTop) / renderedHeight;
const pixelX = clampMatch3DHitPixelIndex(imageX, mask.width);
const pixelY = clampMatch3DHitPixelIndex(imageY, mask.height);
return (
(mask.alpha[pixelY * mask.width + pixelX] ?? 0) >
MATCH3D_HIT_ALPHA_THRESHOLD
);
}
export function isPointInsideMatch3DItemAlphaHotspot({
item,
pointX,
pointY,
mask,
itemSize,
}: {
item: Match3DItemSnapshot;
pointX: number;
pointY: number;
mask: Match3DAlphaHitMask;
itemSize: Match3DGeneratedItemRelativeSize;
}) {
const frame = resolveRenderableItemFrame(item);
const diameter = frame.radius * 2;
if (diameter <= 0) {
return false;
}
return isPointInsideAlphaHitMask(
(pointX - (frame.x - frame.radius)) / diameter,
(pointY - (frame.y - frame.radius)) / diameter,
mask,
itemSize,
);
}
export function hashMatch3DString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}
export function resolveMatch3DImageSourceEntryForItem(
item: Match3DItemSnapshot,
imageSourceEntriesByType: ReadonlyMap<
string,
readonly Match3DResolvedImageSourceEntry[]
>,
) {
const sources = imageSourceEntriesByType.get(item.itemTypeId);
if (!sources || sources.length <= 0) {
return null;
}
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? null;
}
export function findMatch3DHitItem(
run: Match3DRunSnapshot,
pointX: number,
pointY: number,
options: {
imageSourceEntriesByType?: ReadonlyMap<
string,
readonly Match3DResolvedImageSourceEntry[]
>;
alphaHitMasks?: ReadonlyMap<string, Match3DAlphaHitMask>;
failedAlphaHitMaskSources?: ReadonlySet<string>;
itemSizeByType?: ReadonlyMap<string, Match3DGeneratedItemRelativeSize>;
} = {},
) {
return run.items
.filter((item) => {
if (
!isItemState(item.state, 'in_board') ||
!item.clickable ||
!isPointInsideCircle(pointX, pointY, item)
) {
return false;
}
const imageSourceEntry = resolveMatch3DImageSourceEntryForItem(
item,
options.imageSourceEntriesByType ?? new Map(),
);
const mask = imageSourceEntry
? options.alphaHitMasks?.get(imageSourceEntry.source)
: null;
if (!mask) {
return (
!imageSourceEntry ||
!isGeneratedLegacyPath(imageSourceEntry.source) ||
options.failedAlphaHitMaskSources?.has(imageSourceEntry.source) ===
true
);
}
return isPointInsideMatch3DItemAlphaHotspot({
item,
pointX,
pointY,
mask,
itemSize: options.itemSizeByType?.get(item.itemTypeId) ?? '大',
});
})
.sort((left, right) => right.layer - left.layer)[0];
}
@@ -27,7 +27,7 @@ export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS =
'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16';
export const MATCH3D_RUNTIME_STAGE_CLASS =
'relative mt-3 flex min-h-0 flex-1 items-center justify-center';
'relative mt-5 flex min-h-0 flex-1 items-center justify-center';
export const MATCH3D_RUNTIME_BOARD_BASE_CLASS =
'relative aspect-square max-w-full';
@@ -41,7 +41,7 @@ export const MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS =
'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]';
export const MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS =
'pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(99vw,34rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
'pointer-events-none absolute left-1/2 top-[54%] z-0 h-auto w-[min(116vw,42rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
export const MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS =
'pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
import { Loader2, Sparkles } from 'lucide-react';
export type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
type PuzzleOnboardingViewProps = {
prompt: string;
phase: PuzzleOnboardingPhase;
error: string | null;
copy: string;
onPromptChange: (value: string) => void;
onSubmit: () => void;
onSkip: () => void;
};
export function PuzzleOnboardingView({
prompt,
phase,
error,
copy,
onPromptChange,
onSubmit,
onSkip,
}: PuzzleOnboardingViewProps) {
const isGenerating = phase === 'generating';
const isGenerated = phase === 'generated';
const canSubmit = Boolean(prompt.trim()) && !isGenerating && !isGenerated;
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_30%_15%,rgba(251,191,36,0.22),transparent_30%),linear-gradient(135deg,#0f172a,#111827_46%,#1e1b4b)] px-4 py-8 text-white">
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.045)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:38px_38px] opacity-30" />
<button
type="button"
disabled={isGenerating}
onClick={onSkip}
className="absolute right-4 top-4 z-10 inline-flex min-h-10 items-center justify-center rounded-full border border-white/14 bg-black/24 px-4 text-sm font-black text-white/86 shadow-[0_12px_28px_rgba(0,0,0,0.22)] backdrop-blur transition hover:border-amber-200/45 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-45 sm:right-6 sm:top-6"
>
跳过
</button>
<section className="relative flex w-full max-w-[34rem] flex-col items-center gap-5 text-center">
<div className="grid h-14 w-14 place-items-center rounded-[1.2rem] border border-amber-200/32 bg-amber-200/14 text-amber-100 shadow-[0_18px_48px_rgba(251,191,36,0.18)]">
{isGenerating ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<Sparkles className="h-6 w-6" />
)}
</div>
<h1 className="text-[2rem] font-black leading-tight sm:text-[2.85rem]">
{copy}
</h1>
<form
className="flex w-full flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<textarea
value={prompt}
disabled={isGenerating || isGenerated}
onChange={(event) => onPromptChange(event.target.value)}
placeholder="把你的梦讲给我听吧"
rows={4}
className="min-h-32 w-full resize-none rounded-[1.25rem] border border-white/14 bg-black/28 px-4 py-4 text-base font-semibold leading-7 text-white shadow-[0_18px_50px_rgba(0,0,0,0.24)] outline-none backdrop-blur placeholder:text-white/42 focus:border-amber-200/70 focus:ring-2 focus:ring-amber-200/20 disabled:opacity-70"
/>
<button
type="submit"
disabled={!canSubmit}
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
生成
</>
) : (
'生成'
)}
</button>
</form>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
{error}
</div>
) : null}
</section>
</div>
);
}
type PuzzleOnboardingLoginOverlayProps = {
isSaving: boolean;
error: string | null;
copy: string;
onLogin: () => void;
};
export function PuzzleOnboardingLoginOverlay({
isSaving,
error,
copy,
onLogin,
}: PuzzleOnboardingLoginOverlayProps) {
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-950/72 px-4 py-6 text-white backdrop-blur-md">
<section className="flex w-full max-w-[24rem] flex-col items-center gap-5 rounded-[1.35rem] border border-white/14 bg-slate-950/94 px-5 py-6 text-center shadow-[0_28px_90px_rgba(0,0,0,0.5)]">
<div className="grid h-12 w-12 place-items-center rounded-[1rem] bg-amber-200 text-slate-950">
{isSaving ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Sparkles className="h-5 w-5" />
)}
</div>
<h2 className="text-2xl font-black leading-tight">{copy}</h2>
<button
type="button"
disabled={isSaving}
onClick={onLogin}
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
注册账号 / 登录
</>
) : (
'注册账号 / 登录'
)}
</button>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
{error}
</div>
) : null}
</section>
</div>
);
}
@@ -535,6 +535,60 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
});
});
test('puzzle workspace submits history image when AI redraw is off', async () => {
const onCreateFromForm = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
{
assetObjectId: 'asset-history-1',
assetKind: 'puzzle_cover_image',
imageSrc: '/generated-puzzle-assets/history/image.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: 'puzzle-session-1',
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
},
]);
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '选择历史图片' }));
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
fireEvent.click(
await within(picker).findByRole('button', { name: /image\.png/u }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
const aiRedrawSwitch = screen.getByRole('switch', { name: 'AI重绘' });
fireEvent.click(aiRedrawSwitch);
expect(screen.queryByLabelText('画面AI重绘要求(提示词)')).toBeNull();
expect(screen.queryByText('消耗2泥点')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '历史素材 · image.png',
pictureDescription: '历史素材 · image.png',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
referenceImageSrcs: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
});
});
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
@@ -317,6 +317,7 @@ describe('PuzzleResultView', () => {
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
shouldAutoNameLevel: false,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
@@ -466,6 +467,7 @@ describe('PuzzleResultView', () => {
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
shouldAutoNameLevel: true,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
@@ -485,6 +487,42 @@ describe('PuzzleResultView', () => {
]);
});
test('requests automatic level naming when generating an unnamed level image', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
shouldAutoNameLevel: true,
}),
);
});
test('keeps generation progress visible after closing and reopening level dialog', () => {
const onExecuteAction = vi.fn();
@@ -567,6 +605,180 @@ describe('PuzzleResultView', () => {
).toHaveProperty('disabled', true);
});
test('keeps level controls enabled while regenerating the UI background', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
isBusy={false}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '雨夜猫街竖屏拼图UI背景' },
});
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
promptText: '雨夜猫街竖屏拼图UI背景',
}),
);
expect(
screen.getByRole('button', { name: /生成中/u }),
).toHaveProperty('disabled', true);
openPuzzleLevelsTab();
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
expect(addLevelButton).toHaveProperty('disabled', false);
fireEvent.click(addLevelButton);
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
});
test('restores UI background generate button when background generation fails', () => {
const onExecuteAction = vi.fn();
const { rerender } = render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
isBusy={false}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '雨夜猫街竖屏拼图UI背景' },
});
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
'button',
{ name: '确定' },
),
);
expect(screen.getByRole('button', { name: /生成中/u })).toHaveProperty(
'disabled',
true,
);
rerender(
<PuzzleResultView
session={createSession()}
error="UI背景生成失败"
onBack={() => {}}
onExecuteAction={onExecuteAction}
isBusy={false}
/>,
);
const generateButton = screen.getByRole('button', { name: /生成UI背景/u });
expect(generateButton).toHaveProperty('disabled', false);
expect(screen.queryByRole('button', { name: /生成中/u })).toBeNull();
});
test('keeps the current level dialog open when another level generation completes', () => {
const base = createSession();
const firstLevel = base.draft!.levels![0]!;
const generatingSecondLevel = {
...firstLevel,
levelId: 'puzzle-level-2',
levelName: '第二关',
pictureDescription: '第二关画面正在生成。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating' as const,
};
const localThirdLevel = {
...firstLevel,
levelId: 'puzzle-level-3',
levelName: '第三关',
pictureDescription: '第三关初稿。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle' as const,
};
const completedSecondLevel = {
...generatingSecondLevel,
candidates: [
{
candidateId: 'candidate-level-2',
imageSrc: '/puzzle/level-2.png',
assetId: 'asset-level-2',
prompt: '第二关画面',
actualPrompt: null,
sourceType: 'generated' as const,
selected: true,
},
],
selectedCandidateId: 'candidate-level-2',
coverImageSrc: '/puzzle/level-2.png',
coverAssetId: 'asset-level-2',
generationStatus: 'ready' as const,
};
const { rerender } = render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [firstLevel, generatingSecondLevel, localThirdLevel],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('第三关'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '正在编辑第三关的信息。' },
});
rerender(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [firstLevel, completedSecondLevel],
},
updatedAt: '2026-05-14T10:00:00.000Z',
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
const currentDialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(currentDialog).getByLabelText('关卡名称')).toHaveProperty(
'value',
'第三关',
);
expect(within(currentDialog).getByLabelText('画面描述')).toHaveProperty(
'value',
'正在编辑第三关的信息。',
);
expect(within(currentDialog).queryByDisplayValue('第二关')).toBeNull();
});
test('publishes with work info and serialized levels', () => {
const onExecuteAction = vi.fn();
@@ -1053,6 +1265,7 @@ describe('PuzzleResultView', () => {
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
shouldAutoNameLevel: false,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
@@ -24,9 +24,9 @@ import type {
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
@@ -93,6 +93,11 @@ type PuzzleLevelGenerationRuntime = {
estimateSeconds: number;
};
type PuzzleUiBackgroundGenerationState = {
levelId: string;
prompt: string;
} | null;
function resolvePuzzleLevelGenerationProgress(
level: PuzzleDraftLevel,
runtime: PuzzleLevelGenerationRuntime | null,
@@ -1409,16 +1414,22 @@ function PuzzleUiAssetsTab({
editState,
imageRefreshKey,
isBusy,
uiBackgroundGeneration,
onChange,
onGenerate,
}: {
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
onChange: (nextState: DraftEditState) => void;
onGenerate: (prompt: string) => void;
}) {
const firstLevel = editState.levels[0] ?? null;
const isGeneratingUiBackground = Boolean(
firstLevel &&
uiBackgroundGeneration?.levelId === firstLevel.levelId,
);
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
editState,
@@ -1490,21 +1501,30 @@ function PuzzleUiAssetsTab({
</button>
<button
type="button"
disabled={!firstLevel || !normalizedPrompt || isBusy}
disabled={
!firstLevel ||
!normalizedPrompt ||
isBusy ||
isGeneratingUiBackground
}
onClick={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
setIsCostConfirmOpen(true);
}}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy ? (
{isBusy || isGeneratingUiBackground ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
{hasGeneratedUiBackground ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
{isGeneratingUiBackground
? '生成中'
: hasGeneratedUiBackground
? '重新生成'
: '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
</button>
</div>
</div>
@@ -1547,7 +1567,12 @@ function PuzzleUiAssetsTab({
</button>
<button
type="button"
disabled={!firstLevel || !normalizedPrompt || isBusy}
disabled={
!firstLevel ||
!normalizedPrompt ||
isBusy ||
isGeneratingUiBackground
}
onClick={() => {
if (!firstLevel || !normalizedPrompt) {
return;
@@ -1559,7 +1584,7 @@ function PuzzleUiAssetsTab({
setIsCostConfirmOpen(false);
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
>
确定
</button>
@@ -1696,6 +1721,7 @@ function PuzzleAssetConfigTab({
editState,
imageRefreshKey,
isBusy,
uiBackgroundGeneration,
onAssetConfigTabChange,
onChange,
onGenerateUiBackground,
@@ -1704,6 +1730,7 @@ function PuzzleAssetConfigTab({
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
onChange: (nextState: DraftEditState) => void;
onGenerateUiBackground: (prompt: string) => void;
@@ -1719,6 +1746,7 @@ function PuzzleAssetConfigTab({
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
uiBackgroundGeneration={uiBackgroundGeneration}
onChange={onChange}
onGenerate={onGenerateUiBackground}
/>
@@ -1829,35 +1857,51 @@ export function PuzzleResultView({
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
null,
);
const [uiBackgroundGeneration, setUiBackgroundGeneration] =
useState<PuzzleUiBackgroundGenerationState>(null);
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
Record<string, PuzzleLevelGenerationRuntime>
>({});
const [generationNowMs, setGenerationNowMs] = useState(() => Date.now());
const latestEditStateRef = useRef<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
const savedEditStateRef = useRef<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
useEffect(() => {
latestEditStateRef.current = editState;
}, [editState]);
useEffect(() => {
if (error) {
setUiBackgroundGeneration(null);
}
}, [error]);
useEffect(() => {
if (!draft) {
setEditState(null);
latestEditStateRef.current = null;
setActiveLevelId(null);
setUiBackgroundGeneration(null);
setAutoSaveState('idle');
setAutoSaveError(null);
setTagGenerationError(null);
return;
}
const nextState = createDraftEditState(draft);
setEditState((currentState) => {
const mergedState = mergeDraftEditStateWithIncomingState(
currentState,
nextState,
);
savedEditStateRef.current = nextState;
return mergedState;
});
const mergedState = mergeDraftEditStateWithIncomingState(
latestEditStateRef.current,
nextState,
);
latestEditStateRef.current = mergedState;
savedEditStateRef.current = nextState;
setEditState(mergedState);
setGenerationRuntimeByLevelId((current) => {
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
nextState.levels.forEach((level) => {
mergedState.levels.forEach((level) => {
if (level.generationStatus === 'generating') {
nextRuntimes[level.levelId] =
current[level.levelId] ?? {
@@ -1870,13 +1914,26 @@ export function PuzzleResultView({
});
setActiveLevelId((currentLevelId) =>
currentLevelId &&
nextState.levels.some((level) => level.levelId === currentLevelId)
mergedState.levels.some((level) => level.levelId === currentLevelId)
? currentLevelId
: null,
);
setAutoSaveState('idle');
setAutoSaveError(null);
setTagGenerationError(null);
setUiBackgroundGeneration((current) => {
if (
current &&
mergedState.levels.some(
(level) =>
level.levelId === current.levelId &&
resolvePuzzleUiBackgroundSource(level),
)
) {
return null;
}
return current;
});
}, [draft]);
const syncedDraft = useMemo(() => {
@@ -2156,6 +2213,7 @@ export function PuzzleResultView({
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
uiBackgroundGeneration={uiBackgroundGeneration}
onAssetConfigTabChange={setActiveAssetConfigTab}
onChange={setEditState}
onGenerateUiBackground={(prompt) => {
@@ -2163,6 +2221,10 @@ export function PuzzleResultView({
if (!firstLevel) {
return;
}
setUiBackgroundGeneration({
levelId: firstLevel.levelId,
prompt,
});
onExecuteAction({
action: 'generate_puzzle_ui_background',
levelId: firstLevel.levelId,
@@ -2249,6 +2311,7 @@ export function PuzzleResultView({
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
candidateCount: 1,
shouldAutoNameLevel: !nextLevel.levelName.trim(),
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
summary: editState.workDescription.trim(),
File diff suppressed because it is too large Load Diff
@@ -387,16 +387,24 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
fallbackSrc,
alt,
className,
...rest
}: {
src?: string | null;
fallbackSrc?: string | null;
alt?: string;
className?: string;
}) =>
src ? (
<img src={src} alt={alt ?? ''} className={className} {...rest} />
<img
src={src}
data-fallback-src={fallbackSrc ?? undefined}
alt={alt ?? ''}
className={className}
{...rest}
/>
) : null,
}));
@@ -2901,6 +2909,36 @@ test('mobile discover recommend feed only rotates the card closest to screen cen
);
});
test('mobile discover recommend feed renders cover fallback for legacy browsers', async () => {
renderStatefulLoggedOutHomeView({
latestEntries: [
{
...puzzlePublicEntry,
coverImageSrc:
'/generated-puzzle-assets/puzzle-session-1/cover/image.png',
},
],
});
const discoverPanel = document.getElementById('platform-tab-panel-category');
if (!discoverPanel) {
throw new Error('缺少发现面板');
}
const card = within(discoverPanel).getByRole('button', { name: /奇幻拼图/u });
const cover = card.querySelector('.platform-public-work-card__cover');
const image = within(card).getByRole('img');
expect(cover).toBeTruthy();
expect(cover?.className).toContain('platform-public-work-card__cover');
expect(image.getAttribute('src')).toBe(
'/generated-puzzle-assets/puzzle-session-1/cover/image.png',
);
expect(image.getAttribute('data-fallback-src')).toBe(
'/creation-type-references/puzzle.webp',
);
});
test('mobile today channel only shows newly published works from today', async () => {
const user = userEvent.setup();
const now = new Date();
+10 -2
View File
@@ -31,6 +31,7 @@ import {
UserRound,
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
import {
type ComponentType,
type CSSProperties,
@@ -42,7 +43,6 @@ import {
useRef,
useState,
} from 'react';
import QRCode from 'qrcode';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
@@ -58,13 +58,13 @@ import type {
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
WechatNativePayment,
ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileTaskItem,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
WechatMiniProgramPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
@@ -137,6 +137,7 @@ import {
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldFallbackCoverImage,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
@@ -392,11 +393,13 @@ function usePlatformDesktopLayout() {
function ResolvedAssetBackdrop({
src,
fallbackSrc,
alt,
className,
ariaHidden = false,
}: {
src?: string | null;
fallbackSrc?: string | null;
alt: string;
className: string;
ariaHidden?: boolean;
@@ -404,6 +407,7 @@ function ResolvedAssetBackdrop({
return (
<ResolvedAssetImage
src={src}
fallbackSrc={fallbackSrc}
alt={alt}
aria-hidden={ariaHidden}
className={className}
@@ -522,6 +526,7 @@ function WorldCard({
variant?: 'standard' | 'immersive';
}) {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
const fallbackAssetCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const coverSlides = useMemo(() => {
if (!enableCoverCarousel) {
return fallbackCoverImage
@@ -606,6 +611,7 @@ function WorldCard({
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
fallbackSrc={fallbackAssetCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
@@ -692,6 +698,7 @@ function RecommendCoverOnlyCard({
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
@@ -708,6 +715,7 @@ function RecommendCoverOnlyCard({
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
fallbackSrc={fallbackCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
@@ -13,7 +13,9 @@ import {
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldFallbackCoverImage,
} from './rpgEntryWorldPresentation';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
@@ -46,6 +48,32 @@ test('platform work display text limits names and tags by character count', () =
).toEqual(['超长机关', '星桥']);
});
test('platform public cards use play type reference images as cover fallback', () => {
const puzzleCard: PlatformPuzzleGalleryCard = {
sourceType: 'puzzle',
workId: 'puzzle-work-1',
profileId: 'puzzle-profile-1',
publicWorkCode: 'PZ-PUZZLE1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: '机关拼图',
subtitle: '拼图关卡',
summaryText: '公开作品',
coverImageSrc: '/generated-puzzle-assets/session/cover/image.png',
themeTags: ['拼图'],
playCount: 1,
remixCount: 0,
likeCount: 0,
visibility: 'published',
publishedAt: '2026-05-18T00:00:00.000Z',
updatedAt: '2026-05-18T00:00:00.000Z',
};
expect(resolvePlatformWorldFallbackCoverImage(puzzleCard)).toBe(
'/creation-type-references/puzzle.webp',
);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',
@@ -446,6 +446,36 @@ export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
return '';
}
export function resolvePlatformWorldFallbackCoverImage(
entry: PlatformWorldCardLike,
) {
if (isPuzzleGalleryEntry(entry)) {
return '/creation-type-references/puzzle.webp';
}
if (isMatch3DGalleryEntry(entry)) {
return '/creation-type-references/match3d.webp';
}
if (isSquareHoleGalleryEntry(entry)) {
return '/creation-type-references/square-hole.webp';
}
if (isVisualNovelGalleryEntry(entry)) {
return '/creation-type-references/visual-novel.webp';
}
if (isBigFishGalleryEntry(entry)) {
return '/creation-type-references/big-fish.webp';
}
if (isEdutainmentGalleryEntry(entry)) {
return '/creation-type-references/creative-agent.webp';
}
return '/creation-type-references/rpg.webp';
}
export function resolvePlatformWorldCoverSlides(
entry: PlatformWorldCardLike,
): PlatformPuzzleCoverSlide[] {
+127 -1
View File
@@ -281,8 +281,124 @@ body {
will-change: transform, opacity;
}
@keyframes match3d-tray-token-shift {
0% {
transform: translate3d(
var(--match3d-tray-shift-x, 0px),
var(--match3d-tray-shift-y, 0px),
0
);
}
100% {
transform: translate3d(0, 0, 0);
}
}
.match3d-tray-token-shift {
animation: match3d-tray-token-shift 0.24s cubic-bezier(0.2, 0.8, 0.2, 1)
both;
will-change: transform;
}
@keyframes match3d-tray-token-clear {
0% {
opacity: 1;
transform: translate3d(-50%, -50%, 0) scale(1);
}
62% {
opacity: 1;
transform:
translate3d(
calc(-50% + var(--match3d-tray-clear-dx, 0px)),
calc(-50% + var(--match3d-tray-clear-dy, 0px)),
0
)
scale(0.84);
}
100% {
opacity: 0;
transform:
translate3d(
calc(-50% + var(--match3d-tray-clear-dx, 0px)),
calc(-50% + var(--match3d-tray-clear-dy, 0px)),
0
)
scale(0.38);
filter: blur(1px);
}
}
.match3d-tray-token-clear {
transform: translate3d(-50%, -50%, 0);
animation: match3d-tray-token-clear 0.46s cubic-bezier(0.2, 0.72, 0.18, 1)
both;
transform-origin: center;
will-change: transform, opacity;
}
@keyframes match3d-tray-clear-flash {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.2);
}
42% {
opacity: 0.95;
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.45);
}
}
.match3d-tray-clear-flash {
width: 4rem;
height: 4rem;
border-radius: 9999px;
background:
radial-gradient(circle, rgba(255, 255, 255, 0.95) 0 10%, transparent 12%),
radial-gradient(circle, rgba(255, 236, 157, 0.48) 0 42%, transparent 64%);
box-shadow:
0 0 22px rgba(255, 255, 255, 0.62),
0 0 52px rgba(251, 191, 36, 0.34);
mix-blend-mode: screen;
animation: match3d-tray-clear-flash 0.46s ease-out both;
transform: translate(-50%, -50%);
}
@keyframes match3d-merge-feedback-pulse {
0% {
opacity: 0;
transform: scale(0.48);
}
42% {
opacity: 0.8;
}
100% {
opacity: 0;
transform: scale(1.28);
}
}
.match3d-merge-feedback-pulse {
animation: match3d-merge-feedback-pulse 0.52s ease-out both;
background:
radial-gradient(circle, rgba(255, 255, 255, 0.56) 0 18%, transparent 20%),
radial-gradient(circle, rgba(255, 255, 255, 0.28) 0 45%, transparent 68%);
}
@media (prefers-reduced-motion: reduce) {
.match3d-token-fly-to-tray {
.match3d-token-fly-to-tray,
.match3d-tray-token-shift,
.match3d-tray-token-clear,
.match3d-tray-clear-flash,
.match3d-merge-feedback-pulse {
animation-duration: 1ms;
}
}
@@ -1110,6 +1226,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background: color-mix(in srgb, var(--platform-panel-fill-soft) 86%, #000 14%);
}
.platform-public-work-card__cover::before {
content: '';
display: block;
padding-top: 56.25%;
}
.platform-public-work-card__body {
background: color-mix(in srgb, var(--platform-subpanel-fill) 92%, #000 8%);
}
@@ -4786,6 +4908,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
min-height: min(58vh, 28rem);
}
.platform-public-work-card--immersive .platform-public-work-card__cover::before {
padding-top: 122%;
}
.platform-public-work-card--immersive .platform-public-work-card__body {
min-height: 8rem;
padding: 0.9rem 0.95rem 1rem;
@@ -45,6 +45,7 @@ test('creation agent action requests are not auto-retried by default', async ()
'执行失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 0 }),
timeoutMs: 1_000_000,
}),
);
});
@@ -41,6 +41,7 @@ const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
const DEFAULT_CREATION_AGENT_ACTION_RETRY: ApiRetryOptions = {
maxRetries: 0,
};
const DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS = 1_000_000;
function buildJsonPostInit(payload: unknown): RequestInit {
return {
@@ -182,7 +183,8 @@ export function createCreationAgentClient<
messages.executeAction,
{
retry: executeActionRetry,
timeoutMs: executeActionTimeoutMs,
timeoutMs:
executeActionTimeoutMs ?? DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS,
},
);

Some files were not shown because too many files have changed in this diff Show More