合并 master 并修复架构分支回归

合入 master 最新的认证、玩法契约与推荐页改动。

修复拼图草稿生成、推荐页下一关和公开详情访客试玩回归。

修复抓大鹅草稿试玩鉴权与首屏推荐详情测试入口。

补齐相关测试夹具、文档与团队记忆更新。
This commit is contained in:
2026-06-07 21:35:47 +08:00
80 changed files with 2627 additions and 511 deletions

View File

@@ -3,11 +3,11 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
import { AuthGate } from './AuthGate';
import { AuthGate, setAuthGateReloadForTest } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
@@ -107,6 +107,7 @@ beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
window.history.replaceState(null, '', '/');
setAuthGateReloadForTest(vi.fn());
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getStoredAccessToken.mockReturnValue('');
@@ -158,6 +159,10 @@ beforeEach(() => {
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
});
afterEach(() => {
setAuthGateReloadForTest(null);
});
async function acceptLegalConsent(
user: ReturnType<typeof userEvent.setup>,
dialog: HTMLElement,
@@ -382,6 +387,8 @@ test('auth gate keeps sms and password entries available when login options requ
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
@@ -411,6 +418,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
);
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
expect(onAuthenticated).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
@@ -636,6 +644,8 @@ test('registration invite modal can skip when invite code is empty', async () =>
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
@@ -674,10 +684,13 @@ test('auth state refresh keeps mounted platform content and local tab state', as
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
});
expect(screen.getByText('当前Tab创作')).toBeTruthy();
expect(reload).not.toHaveBeenCalled();
});
test('logout withdraws user context before backend request finishes', async () => {
const user = userEvent.setup();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
@@ -703,11 +716,14 @@ test('logout withdraws user context before backend request finishes', async () =
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
await act(async () => {
resolveLogout();
await logoutPromise;
});
expect(reload).toHaveBeenCalledTimes(1);
});
test('auth gate shows sms send feedback in the login modal', async () => {

View File

@@ -65,6 +65,18 @@ type AuthStatus =
const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password'];
let reloadCurrentPageForAuthStateChange = () => {
window.location.reload();
};
export function setAuthGateReloadForTest(handler: (() => void) | null) {
reloadCurrentPageForAuthStateChange =
handler ??
(() => {
window.location.reload();
});
}
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
return (params.get('inviteCode') || params.get('invite_code') || '')
@@ -140,6 +152,8 @@ export function AuthGate({ children }: AuthGateProps) {
const autoOpenedInviteCodeRef = useRef<string | null>(null);
const hasRenderedPlatformContentRef = useRef(false);
const authHydrateVersionRef = useRef(0);
const lastStableAuthPresenceRef = useRef<boolean | null>(null);
const pendingAuthStateReloadRef = useRef(false);
const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current &&
(status === 'checking' || status === 'recovering');
@@ -152,36 +166,64 @@ export function AuthGate({ children }: AuthGateProps) {
hasRenderedPlatformContentRef.current = true;
}
const markAuthStateReloadIfChanged = useCallback(
(
nextUser: AuthUser | null,
options: { reloadOnChange?: boolean } = {},
) => {
const nextHasUser = Boolean(nextUser);
const previousHasUser = lastStableAuthPresenceRef.current;
if (previousHasUser === null) {
lastStableAuthPresenceRef.current = nextHasUser;
return;
}
lastStableAuthPresenceRef.current = nextHasUser;
if (
previousHasUser !== nextHasUser &&
options.reloadOnChange !== false
) {
pendingAuthStateReloadRef.current = true;
}
},
[],
);
const activateReadyUser = useCallback((nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
authHydrateVersionRef.current += 1;
markAuthStateReloadIfChanged(nextUser);
setUser(nextUser);
setStatus('ready');
}, []);
}, [markAuthStateReloadIfChanged]);
const clearLocalAuthenticatedState = useCallback(() => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存
authHydrateVersionRef.current += 1;
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowRegistrationInviteModal(false);
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setRevokingSessionIds([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setPendingInviteCode('');
setRegistrationInviteError('');
setError('');
}, []);
const clearLocalAuthenticatedState = useCallback(
(options: { reloadOnChange?: boolean } = {}) => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
authHydrateVersionRef.current += 1;
markAuthStateReloadIfChanged(null, options);
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowRegistrationInviteModal(false);
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setRevokingSessionIds([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setPendingInviteCode('');
setRegistrationInviteError('');
setError('');
},
[markAuthStateReloadIfChanged],
);
const restoreAuthSession = useCallback(async () => {
const hadLocalAccessToken = Boolean(getStoredAccessToken());
@@ -234,7 +276,7 @@ export function AuthGate({ children }: AuthGateProps) {
}, []);
const logoutCurrentSession = useCallback(async () => {
clearLocalAuthenticatedState();
clearLocalAuthenticatedState({ reloadOnChange: false });
try {
await logoutAuthUser();
} catch (logoutError) {
@@ -243,11 +285,13 @@ export function AuthGate({ children }: AuthGateProps) {
? logoutError.message
: '退出登录失败,请刷新页面确认状态。',
);
} finally {
reloadCurrentPageForAuthStateChange();
}
}, [clearLocalAuthenticatedState]);
const logoutAllSessions = useCallback(async () => {
clearLocalAuthenticatedState();
clearLocalAuthenticatedState({ reloadOnChange: false });
try {
await logoutAllAuthSessions();
} catch (logoutError) {
@@ -256,6 +300,8 @@ export function AuthGate({ children }: AuthGateProps) {
? logoutError.message
: '退出全部设备失败,请刷新页面确认状态。',
);
} finally {
reloadCurrentPageForAuthStateChange();
}
}, [clearLocalAuthenticatedState]);
@@ -386,6 +432,7 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
markAuthStateReloadIfChanged(null);
setUser(null);
setStatus('unauthenticated');
} catch (optionsError) {
@@ -394,6 +441,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
setAvailableLoginMethods(REQUIRED_LOGIN_METHODS);
markAuthStateReloadIfChanged(null);
setUser(null);
// 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口;
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
@@ -413,6 +461,7 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
if (restoredSession.kind === 'guest') {
markAuthStateReloadIfChanged(null);
setAvailableLoginMethods(
normalizeAvailableLoginMethods(
restoredSession.session?.availableLoginMethods,
@@ -423,6 +472,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
const nextSession = restoredSession.session;
markAuthStateReloadIfChanged(nextSession.user);
setUser(nextSession.user);
setAvailableLoginMethods(
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
@@ -470,19 +520,23 @@ export function AuthGate({ children }: AuthGateProps) {
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
window.removeEventListener('hashchange', handleAuthHashChange);
};
}, [restoreAuthSession]);
}, [markAuthStateReloadIfChanged, restoreAuthSession]);
useEffect(() => {
if (!readyUser) {
setShowSettingsModal(false);
return;
} else {
setShowLoginModal(false);
const pendingAction = pendingProtectedActionRef.current;
pendingProtectedActionRef.current = null;
pendingAction?.();
}
setShowLoginModal(false);
const pendingAction = pendingProtectedActionRef.current;
pendingProtectedActionRef.current = null;
pendingAction?.();
if (pendingAuthStateReloadRef.current) {
pendingAuthStateReloadRef.current = false;
reloadCurrentPageForAuthStateChange();
}
}, [readyUser]);
useEffect(() => {

View File

@@ -335,6 +335,16 @@ export function buildCreationWorkShelfItems(params: {
}),
),
},
{
kind: 'puzzle-clear',
buildItems: () =>
puzzleClearItems.map((item) =>
mapPuzzleClearWorkToShelfItem(item, canDeletePuzzleClear, {
onOpen: onOpenPuzzleClearDetail,
onDelete: onDeletePuzzleClear,
}),
),
},
{
kind: 'puzzle',
buildItems: () =>

View File

@@ -28,14 +28,16 @@ test('跳一跳结果页展示排行榜列表', () => {
items: [
{
rank: 1,
playerId: 'player-1',
playerId: 'user-secret-1',
displayName: '陶泥儿玩家',
successfulJumpCount: 12,
durationMs: 40123,
updatedAt: '2026-05-27T00:00:00Z',
},
{
rank: 2,
playerId: 'player-2',
playerId: 'user-secret-2',
displayName: '森林玩家',
successfulJumpCount: 10,
durationMs: 38210,
updatedAt: '2026-05-26T00:00:00Z',
@@ -60,10 +62,12 @@ test('跳一跳结果页展示排行榜列表', () => {
);
expect(screen.getByText('排行榜')).toBeTruthy();
expect(screen.getByText('player-1')).toBeTruthy();
expect(screen.getByText('陶泥儿玩家')).toBeTruthy();
expect(screen.queryByText('user-secret-1')).toBeNull();
expect(screen.getByText('12 跳')).toBeTruthy();
expect(screen.getByText('00:40')).toBeTruthy();
expect(screen.getByText('player-2')).toBeTruthy();
expect(screen.getByText('森林玩家')).toBeTruthy();
expect(screen.queryByText('user-secret-2')).toBeNull();
});
test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
@@ -83,6 +87,24 @@ test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
);
});
test('跳一跳结果页根容器允许移动端向下滚动到操作按钮', () => {
const { container } = render(
<JumpHopResultView
profile={buildProfile()}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain('overflow-y-auto');
expect(root.className).toContain('overscroll-contain');
expect(root.className).toContain('safe-area-inset-bottom');
});
test('跳一跳草稿结果页不请求公开排行榜', () => {
render(
<JumpHopResultView

View File

@@ -231,7 +231,9 @@ function JumpHopResultLeaderboard({
<span className="text-[var(--platform-text-soft)]">
{entry.rank}
</span>
<span className="truncate">{entry.playerId}</span>
<span className="truncate">
{entry.displayName?.trim() || '玩家'}
</span>
<span>{entry.successfulJumpCount} </span>
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
</div>
@@ -300,7 +302,7 @@ export function JumpHopResultView({
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overscroll-contain px-3 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"

View File

@@ -327,7 +327,8 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
items: [
{
rank: 1,
playerId: 'player-1',
playerId: 'user-secret-1',
displayName: '陶泥儿玩家',
successfulJumpCount: 8,
durationMs: 8123,
updatedAt: '2026-05-27T00:00:00Z',
@@ -357,7 +358,8 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy();
const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard');
expect(leaderboard).toBeTruthy();
expect(within(leaderboard).getByText('player-1')).toBeTruthy();
expect(within(leaderboard).getByText('陶泥儿玩家')).toBeTruthy();
expect(within(leaderboard).queryByText('user-secret-1')).toBeNull();
expect(within(leaderboard).getByText('8 跳')).toBeTruthy();
expect(within(leaderboard).getByText('00:08')).toBeTruthy();
});

View File

@@ -548,7 +548,9 @@ function JumpHopLeaderboardPanel({
className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto_auto] items-center gap-2 text-xs font-bold text-slate-700"
>
<span className="text-slate-400">{entry.rank}</span>
<span className="truncate">{entry.playerId}</span>
<span className="truncate">
{entry.displayName?.trim() || '玩家'}
</span>
<span>{entry.successfulJumpCount} </span>
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
</div>

View File

@@ -115,6 +115,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
import {
buildPublicWorkStagePath,
pushAppHistoryPath,
resolvePathForSelectionStage,
} from '../../routing/appPageRoutes';
import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
import {
@@ -371,7 +372,6 @@ import {
isPersistedBarkBattleDraftGenerating,
} from '../custom-world-home/creationWorkShelf';
import {
buildPlatformRecommendFeedEntries,
selectAdjacentPlatformRecommendEntry,
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
import {
@@ -493,7 +493,9 @@ import {
import {
canExposePublicWork,
EDUTAINMENT_HIDDEN_MESSAGE,
filterGeneralPublicWorks,
} from './platformEdutainmentVisibility';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
@@ -2872,10 +2874,10 @@ export function PlatformEntryFlowShellImpl({
publicGalleryFeeds;
const recommendRuntimeEntries = useMemo(
() =>
buildPlatformRecommendFeedEntries(
featuredGalleryEntries,
latestGalleryEntries,
),
buildPlatformRecommendedEntries({
featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries),
latestEntries: filterGeneralPublicWorks(latestGalleryEntries),
}),
[featuredGalleryEntries, latestGalleryEntries],
);
@@ -4440,6 +4442,18 @@ export function PlatformEntryFlowShellImpl({
activePuzzleBackgroundCompileTask?.session ?? puzzleSession;
const puzzleGenerationViewPayload =
activePuzzleBackgroundCompileTask?.payload ?? puzzleFormDraftPayload;
const puzzleGenerationViewStateRef = useRef(puzzleGenerationViewState);
const puzzleGenerationViewPayloadRef = useRef(puzzleGenerationViewPayload);
const setPuzzleSessionRef = useRef(puzzleFlow.setSession);
useEffect(() => {
puzzleGenerationViewStateRef.current = puzzleGenerationViewState;
}, [puzzleGenerationViewState]);
useEffect(() => {
puzzleGenerationViewPayloadRef.current = puzzleGenerationViewPayload;
}, [puzzleGenerationViewPayload]);
useEffect(() => {
setPuzzleSessionRef.current = puzzleFlow.setSession;
}, [puzzleFlow.setSession]);
const puzzleGenerationViewError =
activePuzzleBackgroundCompileTask?.error ?? puzzleError;
const isPuzzleGenerationViewBusy =
@@ -4835,21 +4849,21 @@ export function PlatformEntryFlowShellImpl({
if (hasRecoverableGeneratedPuzzleDraft(latestSession)) {
const payload =
puzzleGenerationViewPayload ??
puzzleGenerationViewPayloadRef.current ??
buildPuzzleFormPayloadFromSession(latestSession);
const generationState =
puzzleGenerationViewState ??
puzzleGenerationViewStateRef.current ??
createPuzzleDraftGenerationStateFromPayload(payload, latestSession);
await recoverCompletedPuzzleDraftGeneration({
sessionId: latestSession.sessionId,
payload,
generationState,
setSession: setPuzzleSession,
setSession: setPuzzleSessionRef.current,
});
return;
}
setPuzzleSession(latestSession);
setPuzzleSessionRef.current(latestSession);
setPuzzleBackgroundCompileTasks((current) => {
const task = current[activePuzzleGenerationSessionId];
if (!task) {
@@ -4892,11 +4906,8 @@ export function PlatformEntryFlowShellImpl({
};
}, [
activePuzzleGenerationSessionId,
puzzleGenerationViewPayload,
puzzleGenerationViewState,
recoverCompletedPuzzleDraftGeneration,
shouldPollPuzzleGenerationSession,
setPuzzleSession,
]);
const match3DGeneratingSessionId =
@@ -5258,27 +5269,48 @@ export function PlatformEntryFlowShellImpl({
);
setPuzzleOperation(response.operation);
const openResult = isViewingPuzzleGeneration(nextSession.sessionId);
const readyGenerationState =
resolveFinishedMiniGameDraftGenerationState(
generationState,
'ready',
{
completedAssetCount: 1,
totalAssetCount: 1,
},
);
const isCompileReady = isPuzzleCompileActionReady(response.session);
const nextGenerationState = isCompileReady
? resolveFinishedMiniGameDraftGenerationState(
generationState,
'ready',
{
completedAssetCount: 1,
totalAssetCount: 1,
},
)
: mergePuzzleSessionProgressIntoGenerationState(
generationState,
response.session,
);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: response.session,
payload,
generationState: readyGenerationState,
generationState: nextGenerationState,
error: null,
},
}));
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
puzzleFlow.setSession(response.session);
setPuzzleGenerationState(readyGenerationState);
setPuzzleGenerationState(nextGenerationState);
}
if (!isCompileReady) {
markDraftGenerating('puzzle', [
response.session.sessionId,
buildPuzzleResultWorkId(response.session.sessionId),
response.session.publishedProfileId,
buildPuzzleResultProfileId(response.session.sessionId),
]);
markPendingDraftGenerating(
'puzzle',
response.session.sessionId,
buildPendingPuzzleDraftMetadata(payload),
);
void refreshPuzzleShelf();
return;
}
const profileId =
@@ -6870,10 +6902,10 @@ export function PlatformEntryFlowShellImpl({
profileId: targetProfileId,
mode: 'play' as const,
};
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runtimeGuestOptions =
options.embedded || workDetail.summary.publishStatus === 'draft'
? await buildRecommendRuntimeAuthOptions(authUi, true)
: {};
const { run } = options.embedded
? await startVisualNovelRun(
targetProfileId,
@@ -8818,7 +8850,11 @@ export function PlatformEntryFlowShellImpl({
profile: Match3DWorkProfile | Match3DWorkSummary,
returnStage: 'match3d-result' | 'work-detail' = 'match3d-result',
mirrorErrorToPublicDetail = false,
options: { embedded?: boolean; itemTypeCountOverride?: number } = {},
options: {
embedded?: boolean;
authMode?: PuzzleRuntimeAuthMode;
itemTypeCountOverride?: number;
} = {},
) => {
if (isMatch3DBusy) {
return false;
@@ -8857,10 +8893,10 @@ export function PlatformEntryFlowShellImpl({
runtimeProfile.generatedBackgroundAsset,
{ expireSeconds: 300 },
);
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runtimeGuestOptions =
options.authMode === 'isolated'
? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS
: await buildRecommendRuntimeAuthOptions(authUi, options.embedded);
const runtimeOptions = {
...runtimeGuestOptions,
...(typeof options.itemTypeCountOverride === 'number'
@@ -9657,10 +9693,6 @@ export function PlatformEntryFlowShellImpl({
? await buildRecommendRuntimeGuestOptions()
: {};
const targetProfileId = _target?.profileId?.trim() ?? '';
const preferSimilarWork =
activeRecommendRuntimeKind === 'puzzle' &&
puzzleRuntimeReturnStage === 'platform' &&
puzzleRun.nextLevelMode === 'sameWork';
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
const itemPromise =
selectedPuzzleDetail?.profileId === targetProfileId
@@ -9700,34 +9732,10 @@ export function PlatformEntryFlowShellImpl({
puzzleRuntimeAuthMode === 'isolated'
? await advancePuzzleNextLevel(
puzzleRun.runId,
preferSimilarWork ? { preferSimilarWork: true } : {},
{},
runtimeGuestOptions,
)
: await advancePuzzleNextLevel(
puzzleRun.runId,
preferSimilarWork ? { preferSimilarWork: true } : {},
);
const nextProfileId = run.currentLevel?.profileId?.trim() ?? '';
if (
nextProfileId &&
selectedPuzzleDetail?.profileId !== nextProfileId
) {
const item = await getPuzzleGalleryDetail(nextProfileId).then(
(response) => response.item,
);
const nextRecommendEntry = mapPuzzleWorkToPlatformGalleryCard(item);
setPuzzleGalleryEntries((current) => {
const nextEntries = current.filter(
(entry) => entry.profileId !== item.profileId,
);
nextEntries.push(item);
return nextEntries;
});
setSelectedPuzzleDetail(item);
setActiveRecommendEntryKey(
getPlatformPublicGalleryEntryKey(nextRecommendEntry),
);
}
: await advancePuzzleNextLevel(puzzleRun.runId, {});
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
@@ -9739,12 +9747,8 @@ export function PlatformEntryFlowShellImpl({
[
isPuzzleBusy,
isPuzzleLeaderboardBusy,
activeRecommendRuntimeKind,
puzzleRun,
puzzleRuntimeReturnStage,
puzzleRuntimeAuthMode,
setActiveRecommendEntryKey,
setPuzzleGalleryEntries,
resolvePuzzleErrorMessage,
selectedPuzzleDetail,
setIsPuzzleBusy,
@@ -10956,12 +10960,18 @@ export function PlatformEntryFlowShellImpl({
}
try {
const detail = await jumpHopClient.getWorkDetail(item.profileId);
const detail = await jumpHopClient.getWorkDetail(item.profileId, {
audience: 'creation',
});
setJumpHopSession(null);
setJumpHopRun(null);
setJumpHopWork(detail.item);
setJumpHopRuntimeReturnStage('jump-hop-result');
enterCreateTab();
pushAppHistoryPath(resolvePathForSelectionStage('jump-hop-result'));
writeCreationUrlState(
buildJumpHopCreationUrlState({ work: detail.item }),
);
setSelectionStage('jump-hop-result');
} catch (error) {
setJumpHopError(
@@ -11572,6 +11582,8 @@ export function PlatformEntryFlowShellImpl({
const started = await startMatch3DRunFromProfile(
normalizedProfile,
'match3d-result',
false,
{ authMode: 'isolated' },
);
if (!started) {
setMatch3DProfile(normalizedProfile);
@@ -12147,7 +12159,11 @@ export function PlatformEntryFlowShellImpl({
let work: JumpHopWorkProfileResponse | null = null;
try {
if (profileId) {
work = (await jumpHopClient.getWorkDetail(profileId)).item;
work = (
await jumpHopClient.getWorkDetail(profileId, {
audience: 'creation',
})
).item;
}
} catch {
work = null;
@@ -12525,6 +12541,13 @@ export function PlatformEntryFlowShellImpl({
setActiveRecommendEntryKey(entryKey);
setActiveRecommendRuntimeKind(runtimeKind);
setActiveRecommendRuntimeError(null);
if (
runtimeKind === 'puzzle' &&
(isPuzzleBusy || puzzleStartInFlightKeyRef.current !== null)
) {
setIsStartingRecommendEntry(false);
return;
}
setIsStartingRecommendEntry(true);
try {
@@ -12659,6 +12682,7 @@ export function PlatformEntryFlowShellImpl({
[
activeRecommendEntryKey,
barkBattleGalleryEntries,
isPuzzleBusy,
saveAndExitRecommendPuzzleRuntime,
selectedPuzzleDetail,
setBarkBattleError,
@@ -12700,6 +12724,23 @@ export function PlatformEntryFlowShellImpl({
selectRecommendRuntimeEntry,
],
);
const resolveRecommendRuntimeEntryKeyByProfileId = useCallback(
(profileId: string | null | undefined) => {
const normalizedProfileId = profileId?.trim();
if (!normalizedProfileId) {
return null;
}
const matchedEntry =
recommendRuntimeEntries.find(
(entry) => entry.profileId === normalizedProfileId,
) ?? null;
return matchedEntry
? getPlatformPublicGalleryEntryKey(matchedEntry)
: null;
},
[recommendRuntimeEntries],
);
const recommendRuntimeContent = useMemo(() => {
if (
@@ -12861,7 +12902,13 @@ export function PlatformEntryFlowShellImpl({
void dragPuzzlePiece(payload);
}}
onAdvanceNextLevel={(target) => {
void advancePuzzleLevel(target);
const targetEntryKey = resolveRecommendRuntimeEntryKeyByProfileId(
target?.profileId,
);
void selectAdjacentRecommendRuntimeEntry(
1,
targetEntryKey ?? activeRecommendEntryKey,
);
}}
onRestartLevel={() => {
void restartPuzzleCurrentLevel();
@@ -13115,9 +13162,10 @@ export function PlatformEntryFlowShellImpl({
squareHoleRun,
submitBigFishInput,
submitVisualNovelRuntimeAction,
advancePuzzleLevel,
dragPuzzlePiece,
resolveRecommendRuntimeEntryKeyByProfileId,
restartPuzzleCurrentLevel,
selectAdjacentRecommendRuntimeEntry,
setSquareHoleError,
swapPuzzlePiecesInRun,
syncPuzzleRuntimeTimeout,
@@ -13166,7 +13214,8 @@ export function PlatformEntryFlowShellImpl({
isLoadingPlatform: platformBootstrap.isLoadingPlatform,
entries: recommendRuntimeEntries,
activeEntryKey: activeRecommendEntryKey,
isStarting: isStartingRecommendEntry,
isStarting: isStartingRecommendEntry || isPuzzleBusy,
hasStartError: Boolean(activeRecommendRuntimeError),
readyState: {
activeKind: activeRecommendRuntimeKind,
hasBabyObjectMatchDraft: Boolean(babyObjectMatchDraft),
@@ -13198,10 +13247,12 @@ export function PlatformEntryFlowShellImpl({
}, [
activeRecommendEntryKey,
activeRecommendRuntimeKind,
activeRecommendRuntimeError,
babyObjectMatchDraft,
bigFishRun,
jumpHopRun,
isStartingRecommendEntry,
isPuzzleBusy,
match3dRun,
platformBootstrap.isLoadingPlatform,
platformBootstrap.platformTab,
@@ -13952,8 +14003,11 @@ export function PlatformEntryFlowShellImpl({
canDeleteBigFish: isBigFishCreationVisible,
canDeleteMatch3D: true,
canDeleteSquareHole: isSquareHoleCreationVisible,
canDeleteJumpHop: isJumpHopCreationVisible,
canDeleteWoodenFish: true,
canDeletePuzzle: true,
canDeleteBabyObjectMatch: isBabyObjectMatchVisible,
canDeleteBarkBattle: true,
canDeleteVisualNovel: true,
onOpenRpgDraft: (item) => {
runProtectedAction(() => {
@@ -13993,12 +14047,16 @@ export function PlatformEntryFlowShellImpl({
});
}
: undefined,
onDeleteJumpHop: isJumpHopCreationVisible
? handleDeleteJumpHopWork
: undefined,
onOpenWoodenFishDetail: (item) => {
runProtectedAction(() => {
markCreationFlowReturnToDraftShelf();
void openWoodenFishDraft(item);
});
},
onDeleteWoodenFish: handleDeleteWoodenFishWork,
onOpenMatch3DDetail: (item) => {
runProtectedAction(() => {
markCreationFlowReturnToDraftShelf();
@@ -14038,6 +14096,7 @@ export function PlatformEntryFlowShellImpl({
openBarkBattleDraft(item);
});
},
onDeleteBarkBattle: handleDeleteBarkBattleWork,
onOpenVisualNovelDetail: (item) => {
runProtectedAction(() => {
markCreationFlowReturnToDraftShelf();
@@ -14057,11 +14116,14 @@ export function PlatformEntryFlowShellImpl({
handleClaimPuzzlePointIncentive,
handleDeleteBabyObjectMatchWork,
handleDeleteBigFishWork,
handleDeleteBarkBattleWork,
handleDeleteJumpHopWork,
handleDeleteMatch3DWork,
handleDeletePublishedWork,
handleDeletePuzzleWork,
handleDeleteSquareHoleWork,
handleDeleteVisualNovelWork,
handleDeleteWoodenFishWork,
isBabyObjectMatchVisible,
isBigFishCreationVisible,
isJumpHopCreationVisible,
@@ -14884,7 +14946,13 @@ export function PlatformEntryFlowShellImpl({
normalizedProfile,
'match3d-result',
false,
options,
{
...options,
authMode:
normalizedProfile.publicationStatus === 'draft'
? 'isolated'
: undefined,
},
);
}}
/>

View File

@@ -24,7 +24,11 @@ import {
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isCustomWorldGalleryEntry,
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
isPuzzleClearGalleryEntry,
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformPublicWorkCode,
@@ -57,9 +61,18 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
return '拼图';
}
if (isPuzzleClearGalleryEntry(entry)) {
return '拼消消';
}
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
return '大鱼吃小鱼';
}
if (isJumpHopGalleryEntry(entry)) {
return '跳一跳';
}
if (isWoodenFishGalleryEntry(entry)) {
return '敲木鱼';
}
if ('sourceType' in entry && entry.sourceType === 'match3d') {
return '抓大鹅';
}
@@ -75,7 +88,11 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if (isEdutainmentGalleryEntry(entry)) {
return entry.templateName;
}
return 'RPG';
if (isCustomWorldGalleryEntry(entry)) {
return 'RPG';
}
throw new Error('未知公开作品类型。');
}
function getAuthorAvatarLabel(authorDisplayName: string) {

View File

@@ -64,7 +64,7 @@ describe('platformCreationLaunchModel', () => {
test('keeps unknown creation type as a prepared noop', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'unknown-template',
type: 'unknown-template' as never,
isBabyObjectMatchVisible: true,
}),
).toEqual({

View File

@@ -1,5 +1,6 @@
import { afterEach, expect, test, vi } from 'vitest';
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
import {
derivePlatformCreationTypes,
groupVisiblePlatformCreationTypes,
@@ -81,7 +82,7 @@ test('database entry config controls visibility open state and display order', (
test('visible platform creation types hide invisible cards and put locked cards last', () => {
const cards = derivePlatformCreationTypes([
{
id: 'hidden',
id: 'airp',
title: '隐藏',
subtitle: '隐藏',
badge: '隐藏',
@@ -95,7 +96,7 @@ test('visible platform creation types hide invisible cards and put locked cards
updatedAtMicros: 1,
},
{
id: 'locked',
id: 'visual-novel',
title: '锁定',
subtitle: '锁定',
badge: '即将开放',
@@ -109,7 +110,7 @@ test('visible platform creation types hide invisible cards and put locked cards
updatedAtMicros: 1,
},
{
id: 'open',
id: 'rpg',
title: '开放',
subtitle: '开放',
badge: '可创建',
@@ -125,13 +126,13 @@ test('visible platform creation types hide invisible cards and put locked cards
]);
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
['open', 'locked'],
['rpg', 'visual-novel'],
);
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
expect(isPlatformCreationTypeVisible(cards, 'airp')).toBe(false);
expect(isPlatformCreationTypeVisible(cards, 'rpg')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'airp')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'visual-novel')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'rpg')).toBe(true);
expect(
cards.every((item) =>
item.imageSrc.startsWith('/creation-type-references/'),
@@ -288,7 +289,7 @@ test('groups visible platform creation types by backend category metadata', () =
updatedAtMicros: 1,
},
{
id: 'hidden',
id: 'airp',
title: '隐藏入口',
subtitle: '隐藏',
badge: '隐藏',
@@ -319,7 +320,7 @@ test('groups visible platform creation types by backend category metadata', () =
test('falls back when backend creation type category metadata is missing', () => {
const cards = derivePlatformCreationTypes([
{
id: 'legacy-entry',
id: 'creative-agent',
title: '历史入口',
subtitle: '旧数据缺少分类字段',
badge: '可创建',
@@ -336,7 +337,7 @@ test('falls back when backend creation type category metadata is missing', () =>
expect(cards[0]).toEqual(
expect.objectContaining({
id: 'legacy-entry',
id: 'creative-agent',
categoryId: 'recommended',
categoryLabel: '热门推荐',
}),
@@ -348,3 +349,24 @@ test('falls back when backend creation type category metadata is missing', () =>
}),
]);
});
test('throws when backend sends an unknown creation type id', () => {
const unknownEntry = {
id: 'unknown-play',
title: '未知玩法',
subtitle: '未知',
badge: '未知',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
} as unknown as CreationEntryTypeConfig;
expect(() => derivePlatformCreationTypes([unknownEntry])).toThrow(
'未知创作类型unknown-play',
);
});

View File

@@ -1,7 +1,11 @@
import {
assertPlatformCreationTypeId,
type PlatformCreationTypeId,
} from '../../../packages/shared/src/contracts/playTypes';
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
export type PlatformCreationTypeId = string;
export type { PlatformCreationTypeId };
export type PlatformCreationTypeCard = {
id: PlatformCreationTypeId;
@@ -117,21 +121,25 @@ export function derivePlatformCreationTypes(
): PlatformCreationTypeCard[] {
const orderedCards = [...creationTypes]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((item) => ({
id: item.id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
imageSrc: item.imageSrc,
locked: !item.open,
categoryId: normalizeCategoryId(item.categoryId),
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
categorySortOrder: item.categorySortOrder,
sortOrder: item.sortOrder,
hidden:
!item.visible ||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
}));
.map((item) => {
const id = assertPlatformCreationTypeId(item.id);
return {
id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
imageSrc: item.imageSrc,
locked: !item.open,
categoryId: normalizeCategoryId(item.categoryId),
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
categorySortOrder: item.categorySortOrder,
sortOrder: item.sortOrder,
hidden:
!item.visible ||
(id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
};
});
return [
...orderedCards.filter((item) => !item.hidden && !item.locked),

View File

@@ -659,6 +659,13 @@ test('platform public gallery flow resolves recommend runtime auto-start gates',
isStarting: true,
}),
).toEqual({ type: 'noop' });
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
activeEntryKey: getPlatformPublicGalleryEntryKey(entry),
hasStartError: true,
}),
).toEqual({ type: 'noop' });
});
test('platform public gallery flow resolves recommend runtime auto-start target', () => {
@@ -695,6 +702,15 @@ test('platform public gallery flow resolves recommend runtime auto-start target'
type: 'start',
entry: activeEntry,
});
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
readyState: { activeKind: null },
}),
).toEqual({
type: 'start',
entry: activeEntry,
});
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,

View File

@@ -165,6 +165,7 @@ export type PlatformRecommendRuntimeAutoStartInput = {
entries: readonly PlatformPublicGalleryCard[];
activeEntryKey: string | null;
isStarting: boolean;
hasStartError?: boolean;
readyState: PlatformRecommendRuntimeReadyState;
};
@@ -496,7 +497,11 @@ export function resolvePlatformRecommendRuntimeAutoStartDecision(
activeEntry !== null &&
isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState);
if ((activeEntry !== null && isActiveRuntimeReady) || input.isStarting) {
if (
(activeEntry !== null && isActiveRuntimeReady) ||
input.isStarting ||
(activeEntry !== null && input.hasStartError)
) {
return { type: 'noop' };
}

View File

@@ -599,7 +599,27 @@ test('platform public work detail flow resolves open strategy', () => {
test('platform public work detail flow maps work summaries to detail entries', () => {
const rpgEntry = buildRpgLibraryEntry();
expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toBe(rpgEntry);
expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toEqual({
ownerUserId: rpgEntry.ownerUserId,
profileId: rpgEntry.profileId,
publicWorkCode: rpgEntry.publicWorkCode,
authorPublicUserCode: rpgEntry.authorPublicUserCode,
visibility: rpgEntry.visibility,
publishedAt: rpgEntry.publishedAt,
updatedAt: rpgEntry.updatedAt,
authorDisplayName: rpgEntry.authorDisplayName,
worldName: rpgEntry.worldName,
subtitle: rpgEntry.subtitle,
summaryText: rpgEntry.summaryText,
coverImageSrc: rpgEntry.coverImageSrc,
themeMode: rpgEntry.themeMode,
playableNpcCount: rpgEntry.playableNpcCount,
landmarkCount: rpgEntry.landmarkCount,
playCount: rpgEntry.playCount ?? 0,
remixCount: rpgEntry.remixCount ?? 0,
likeCount: rpgEntry.likeCount ?? 0,
recentPlayCount7d: rpgEntry.recentPlayCount7d ?? 0,
});
expect(mapPuzzleWorkToPublicWorkDetail(buildPuzzleWork())).toMatchObject({
sourceType: 'puzzle',
workId: 'puzzle-work',
@@ -838,9 +858,20 @@ test('platform public work detail flow resolves like intent', () => {
expect(
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('match3d')),
).toEqual({
type: 'like-rpg-gallery',
ownerUserId: 'user-1',
profileId: 'match3d-profile',
type: 'unsupported',
errorMessage: '作品类型 match3d 暂不支持点赞。',
});
expect(
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('jump-hop')),
).toEqual({
type: 'unsupported',
errorMessage: '作品类型 jump-hop 暂不支持点赞。',
});
expect(
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('wooden-fish')),
).toEqual({
type: 'unsupported',
errorMessage: '作品类型 wooden-fish 暂不支持点赞。',
});
expect(
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('edutainment')),

View File

@@ -297,8 +297,28 @@ export function isRpgPublicWorkDetailEntry(
export function mapRpgGalleryCardToPublicWorkDetail(
entry: PlatformRpgPublicWorkDetailEntry,
): PlatformPublicGalleryCard {
return entry;
): CustomWorldGalleryCard {
return {
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: entry.publicWorkCode,
authorPublicUserCode: entry.authorPublicUserCode,
visibility: entry.visibility,
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
authorDisplayName: entry.authorDisplayName,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
recentPlayCount7d: entry.recentPlayCount7d ?? 0,
};
}
function isRpgPublicWorkLibraryEntry(
@@ -689,6 +709,27 @@ export function resolvePlatformPublicWorkLikeIntent(
};
}
if (isWoodenFishGalleryEntry(entry)) {
return {
type: 'unsupported',
errorMessage: '作品类型 wooden-fish 暂不支持点赞。',
};
}
if (isJumpHopGalleryEntry(entry)) {
return {
type: 'unsupported',
errorMessage: '作品类型 jump-hop 暂不支持点赞。',
};
}
if (isMatch3DGalleryEntry(entry)) {
return {
type: 'unsupported',
errorMessage: '作品类型 match3d 暂不支持点赞。',
};
}
if (isBarkBattleGalleryEntry(entry)) {
return {
type: 'unsupported',

View File

@@ -0,0 +1,178 @@
import { describe, expect, test } from 'vitest';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
const NOW_MS = Date.parse('2026-06-07T12:00:00.000Z');
type PublicCardTestParams = {
id: string;
sourceType?: 'puzzle' | 'match3d' | 'jump-hop';
subtitle?: string;
summaryText?: string;
coverImageSrc?: string | null;
themeTags?: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
publishedAt?: string | null;
updatedAt?: string;
};
function buildPublicCard(
params: PublicCardTestParams,
): PlatformPublicGalleryCard {
const sourceType = params.sourceType ?? 'puzzle';
return {
sourceType,
workId: `${sourceType}-work-${params.id}`,
profileId: `${sourceType}-profile-${params.id}`,
publicWorkCode: `${sourceType.toUpperCase()}-${params.id}`,
ownerUserId: `user-${params.id}`,
authorDisplayName: `${params.id} 作者`,
worldName: `${params.id} 作品`,
subtitle: params.subtitle ?? '公开作品',
summaryText: params.summaryText ?? '公开作品摘要。',
coverImageSrc: params.coverImageSrc ?? `${params.id}.png`,
themeTags: params.themeTags ?? ['推荐'],
playCount: params.playCount ?? 0,
remixCount: params.remixCount ?? 0,
likeCount: params.likeCount ?? 0,
recentPlayCount7d: params.recentPlayCount7d ?? 0,
visibility: 'published',
publishedAt: params.publishedAt ?? '2026-06-01T12:00:00.000Z',
updatedAt:
params.updatedAt ?? params.publishedAt ?? '2026-06-01T12:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
}
describe('buildPlatformRecommendedEntries', () => {
test('combines heat, freshness and featured boost after de-duplicating works', () => {
const coldEntry = buildPublicCard({
id: 'cold',
playCount: 1,
publishedAt: '2026-04-01T12:00:00.000Z',
});
const hotRecentEntry = buildPublicCard({
id: 'hot',
playCount: 8,
likeCount: 4,
recentPlayCount7d: 16,
publishedAt: '2026-06-06T12:00:00.000Z',
});
const curatedEntry = buildPublicCard({
id: 'curated',
playCount: 0,
likeCount: 0,
publishedAt: '2026-05-10T12:00:00.000Z',
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [curatedEntry],
latestEntries: [coldEntry, hotRecentEntry, curatedEntry],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
hotRecentEntry.profileId,
curatedEntry.profileId,
coldEntry.profileId,
]);
});
test('interleaves close-score works from different play types', () => {
const firstPuzzle = buildPublicCard({
id: 'puzzle-a',
sourceType: 'puzzle',
likeCount: 2,
});
const secondPuzzle = buildPublicCard({
id: 'puzzle-b',
sourceType: 'puzzle',
likeCount: 2,
});
const match3d = buildPublicCard({
id: 'match3d-a',
sourceType: 'match3d',
likeCount: 2,
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [],
latestEntries: [firstPuzzle, secondPuzzle, match3d],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
firstPuzzle.profileId,
match3d.profileId,
secondPuzzle.profileId,
]);
});
test('separates same-type candidates while alternatives remain', () => {
const hotPuzzle = buildPublicCard({
id: 'hot-puzzle',
sourceType: 'puzzle',
recentPlayCount7d: 50,
likeCount: 20,
});
const warmPuzzle = buildPublicCard({
id: 'warm-puzzle',
sourceType: 'puzzle',
recentPlayCount7d: 32,
likeCount: 12,
});
const coldMatch3d = buildPublicCard({
id: 'cold-match3d',
sourceType: 'match3d',
publishedAt: '2026-04-01T12:00:00.000Z',
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [],
latestEntries: [hotPuzzle, warmPuzzle, coldMatch3d],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
hotPuzzle.profileId,
coldMatch3d.profileId,
warmPuzzle.profileId,
]);
});
test('falls back to same-type adjacency when no other type remains', () => {
const firstPuzzle = buildPublicCard({
id: 'only-puzzle-a',
sourceType: 'puzzle',
recentPlayCount7d: 8,
});
const secondPuzzle = buildPublicCard({
id: 'only-puzzle-b',
sourceType: 'puzzle',
recentPlayCount7d: 4,
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [],
latestEntries: [firstPuzzle, secondPuzzle],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
firstPuzzle.profileId,
secondPuzzle.profileId,
]);
});
});

View File

@@ -0,0 +1,225 @@
import {
buildPlatformPublicGalleryCardKey,
isEdutainmentGalleryEntry,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkSourceType,
} from '../rpg-entry/rpgEntryWorldPresentation';
const MS_PER_DAY = 86_400_000;
const FEATURED_BONUS = 14;
const MAX_FRESHNESS_SCORE = 12;
export type PlatformRecommendationOptions = {
nowMs?: number;
limit?: number;
};
type RecommendationCandidate = {
entry: PlatformPublicGalleryCard;
key: string;
sourceType: string;
firstSeenIndex: number;
isFeatured: boolean;
timestampMs: number;
score: number;
};
type PlatformRecommendationMetricKey =
| 'playCount'
| 'remixCount'
| 'likeCount'
| 'recentPlayCount7d';
function parseRecommendationTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
if (absoluteTimestamp >= 1_000_000_000_000_000) {
return rawTimestamp / 1000;
}
if (absoluteTimestamp >= 1_000_000_000_000) {
return rawTimestamp;
}
if (absoluteTimestamp >= 1_000_000_000) {
return rawTimestamp * 1000;
}
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) {
return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt);
}
function getRecommendationMetric(
entry: PlatformPublicGalleryCard,
key: PlatformRecommendationMetricKey,
) {
const value = (
entry as Partial<Record<PlatformRecommendationMetricKey, number>>
)[key];
return Math.max(0, Math.round(Number(value ?? 0) || 0));
}
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
if (isEdutainmentGalleryEntry(entry)) {
return `edutainment:${entry.templateId}`;
}
return resolvePlatformPublicWorkSourceType(entry);
}
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
return 'themeTags' in entry && Array.isArray(entry.themeTags)
? entry.themeTags
: [];
}
function scoreRecommendationCandidate(
candidate: Omit<RecommendationCandidate, 'score'>,
nowMs: number,
) {
const entry = candidate.entry;
const ageDays =
candidate.timestampMs > 0
? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY)
: Number.POSITIVE_INFINITY;
const freshnessScore = Number.isFinite(ageDays)
? MAX_FRESHNESS_SCORE / (1 + ageDays / 7)
: 0;
const coverScore = entry.coverImageSrc ? 1.5 : 0;
const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6;
const summaryScore = entry.summaryText.trim() ? 0.8 : 0;
return (
(candidate.isFeatured ? FEATURED_BONUS : 0) +
Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 +
Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 +
Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 +
Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 +
freshnessScore +
coverScore +
tagScore +
summaryScore
);
}
function compareRecommendationCandidates(
left: RecommendationCandidate,
right: RecommendationCandidate,
) {
const scoreDiff = right.score - left.score;
if (scoreDiff !== 0) {
return scoreDiff;
}
const timeDiff = right.timestampMs - left.timestampMs;
if (timeDiff !== 0) {
return timeDiff;
}
if (left.firstSeenIndex !== right.firstSeenIndex) {
return left.firstSeenIndex - right.firstSeenIndex;
}
return left.key.localeCompare(right.key, 'zh-CN');
}
function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) {
const remaining = [...candidates];
const result: RecommendationCandidate[] = [];
while (remaining.length > 0) {
const lastSourceType = result[result.length - 1]?.sourceType ?? null;
let nextIndex = 0;
if (lastSourceType) {
const alternativeIndex = remaining.findIndex(
(candidate) => candidate.sourceType !== lastSourceType,
);
if (alternativeIndex > 0) {
nextIndex = alternativeIndex;
}
}
const [nextCandidate] = remaining.splice(nextIndex, 1);
if (nextCandidate) {
result.push(nextCandidate);
}
}
return result;
}
export function buildPlatformRecommendedEntries(
params: {
featuredEntries: PlatformPublicGalleryCard[];
latestEntries: PlatformPublicGalleryCard[];
},
options: PlatformRecommendationOptions = {},
) {
const candidateMap = new Map<
string,
Omit<RecommendationCandidate, 'score'>
>();
let firstSeenIndex = 0;
const collectEntries = (
entries: PlatformPublicGalleryCard[],
source: 'featured' | 'latest',
) => {
entries.forEach((entry) => {
const key = buildPlatformPublicGalleryCardKey(entry);
const timestampMs = getRecommendationTimestamp(entry);
const existing = candidateMap.get(key);
if (existing) {
existing.isFeatured = existing.isFeatured || source === 'featured';
if (timestampMs >= existing.timestampMs) {
existing.entry = entry;
existing.timestampMs = timestampMs;
}
return;
}
candidateMap.set(key, {
entry,
key,
sourceType: getRecommendationSourceType(entry),
firstSeenIndex,
isFeatured: source === 'featured',
timestampMs,
});
firstSeenIndex += 1;
});
};
collectEntries(params.featuredEntries, 'featured');
collectEntries(params.latestEntries, 'latest');
const nowMs = options.nowMs ?? Date.now();
const rankedCandidates = Array.from(candidateMap.values())
.map((candidate) => ({
...candidate,
score: scoreRecommendationCandidate(candidate, nowMs),
}))
.sort(compareRecommendationCandidates);
const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates);
const limit =
typeof options.limit === 'number' && options.limit > 0
? Math.floor(options.limit)
: diversifiedCandidates.length;
return diversifiedCandidates
.slice(0, limit)
.map((candidate) => candidate.entry);
}

View File

@@ -21,7 +21,24 @@ describe('isPuzzleCompileActionReady', () => {
expect(isPuzzleCompileActionReady(session)).toBe(false);
});
it('treats compile action as ready after the selected cover exists', () => {
it('keeps compile action generating when only the selected cover exists', () => {
const session = {
sessionId: 'puzzle-session-1',
draft: {
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
levels: [
{
generationStatus: 'generating',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
},
],
},
} as PuzzleAgentSessionSnapshot;
expect(isPuzzleCompileActionReady(session)).toBe(false);
});
it('treats compile action as ready after all runtime assets exist', () => {
const session = {
sessionId: 'puzzle-session-1',
draft: {
@@ -30,6 +47,12 @@ describe('isPuzzleCompileActionReady', () => {
{
generationStatus: 'ready',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/session/level-background.png',
},
],
},

View File

@@ -4,6 +4,13 @@ function hasText(value: string | null | undefined) {
return typeof value === 'string' && value.trim().length > 0;
}
function hasAssetReference(
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
) {
return hasText(imageSrc) || hasText(objectKey);
}
export function isPuzzleCompileActionReady(
session: PuzzleAgentSessionSnapshot,
) {
@@ -11,10 +18,19 @@ export function isPuzzleCompileActionReady(
if (!draft) {
return false;
}
if (hasText(draft.coverImageSrc)) {
return true;
}
return (
draft.levels?.some((level) => hasText(level.coverImageSrc)) === true
draft.levels?.some(
(level) =>
(hasText(draft.coverImageSrc) || hasText(level.coverImageSrc)) &&
hasAssetReference(level.levelSceneImageSrc, level.levelSceneImageObjectKey) &&
hasAssetReference(
level.uiSpritesheetImageSrc,
level.uiSpritesheetImageObjectKey,
) &&
hasAssetReference(
level.levelBackgroundImageSrc,
level.levelBackgroundImageObjectKey,
),
) === true
);
}

View File

@@ -13,16 +13,16 @@ import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopWorkDetailResponse,
JumpHopWorkProfileResponse,
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
@@ -42,7 +42,10 @@ import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type {
WoodenFishGalleryCardResponse,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
@@ -71,7 +74,6 @@ import {
submitBigFishInput,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import {
type CreationEntryConfig,
fetchCreationEntryConfig,
@@ -91,6 +93,7 @@ import {
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
@@ -155,6 +158,7 @@ import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient,
getRpgEntryWorldGalleryDetailByCode,
likeRpgEntryWorldGallery,
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
@@ -334,10 +338,6 @@ const ISOLATED_RUNTIME_AUTH_OPTIONS = {
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
const RECOMMEND_RUNTIME_AUTH_OPTIONS = {
...ISOLATED_RUNTIME_AUTH_OPTIONS,
runtimeGuestToken: 'runtime-guest-token',
};
const LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS = ISOLATED_RUNTIME_AUTH_OPTIONS;
function getPlatformTabPanel(tab: string) {
@@ -542,6 +542,7 @@ const rpgEntryLibraryServiceMocks = vi.hoisted(() => ({
getRpgEntryWorldGalleryDetail: vi.fn(),
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
getRpgEntryWorldLibraryDetail: vi.fn(),
likeRpgEntryWorldGallery: vi.fn(),
listRpgEntryWorldGallery: vi.fn(),
listRpgEntryWorldLibrary: vi.fn(),
publishRpgEntryWorldProfile: vi.fn(),
@@ -2005,6 +2006,18 @@ function buildReadyPuzzleDraft(
'/generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
levelSceneImageSrc:
'/generated-puzzle-assets/puzzle-session-recovered/level-scene.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/puzzle-session-recovered/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/puzzle-session-recovered/ui-spritesheet.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/puzzle-session-recovered/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-recovered/level-background.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-recovered/level-background.png',
generationStatus: 'ready',
},
],
@@ -5216,6 +5229,101 @@ test('running puzzle draft opens generation progress from draft tab', async () =
});
});
test('puzzle text-only form stays generating when compile starts background image without cover', async () => {
const user = userEvent.setup();
const initialSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-text-only',
stage: 'collecting_anchors',
progressPercent: 0,
draft: null,
});
const generatingDraft = buildReadyPuzzleDraft({
workTitle: '文字直创拼图',
workDescription: '只输入文字后后台继续生成图片。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
levels: [
{
...buildReadyPuzzleDraft().levels![0]!,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
levelSceneImageSrc: null,
levelSceneImageObjectKey: null,
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey: null,
levelBackgroundImageSrc: null,
levelBackgroundImageObjectKey: null,
generationStatus: 'generating',
},
],
});
const generatingSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-text-only',
stage: 'image_refining',
progressPercent: 88,
draft: generatingDraft,
lastAssistantReply: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。',
resultPreview: {
draft: generatingDraft,
blockers: [
{
id: 'missing-cover-image-puzzle-level-1',
code: 'MISSING_COVER_IMAGE',
message: '正式拼图图片尚未确定',
},
],
qualityFindings: [],
publishReady: false,
},
});
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
session: initialSession,
});
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-text-only',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '首关拼图草稿',
phaseDetail: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。',
progress: 0.88,
},
session: generatingSession,
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: generatingSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(
await screen.findByRole('progressbar', {
name: '拼图图片生成进度',
}),
).toBeTruthy();
await waitFor(() => {
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-text-only',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
});
expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull();
expect(screen.queryByText('请先选择一张正式拼图图片。')).toBeNull();
expect(screen.queryByText('拼图结果页')).toBeNull();
expect(updatePuzzleWork).not.toHaveBeenCalled();
expect(startLocalPuzzleRun).not.toHaveBeenCalled();
});
test('puzzle form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
@@ -6523,6 +6631,7 @@ test('clicking a public work while logged out opens public detail without starti
/>,
);
await openDiscoverHub(user);
const workCards = await screen.findAllByRole('button', {
name: /潮雾列岛/u,
});
@@ -6576,20 +6685,29 @@ test('logged out public detail gates puzzle start and remix before real actions'
/>,
);
await waitFor(() => {
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
await openDiscoverHub(user);
const workCards = screen.getAllByRole('button', { name: /星桥机关/u });
await user.click(workCards[0]!);
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-1',
levelId: null,
},
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
}),
);
});
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(startPuzzleRun).not.toHaveBeenCalled();
requireAuth.mockClear();
await user.click(screen.getByRole('button', { name: '作品改造' }));
expect(requireAuth).toHaveBeenCalledTimes(2);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
});
@@ -7369,6 +7487,75 @@ test('home recommendation share opens publish share modal', async () => {
.toBeTruthy();
});
test('home recommendation wooden fish like does not call RPG gallery like', async () => {
const user = userEvent.setup();
const publishedWoodenFishWork: WoodenFishGalleryCardResponse = {
publicWorkCode: 'WF-3A9EC89B',
workId: 'wooden-fish-work-like-1',
profileId: 'wooden-fish-profile-like-1',
ownerUserId: 'wooden-fish-user-1',
authorDisplayName: '木鱼作者',
workTitle: '莲台木鱼',
workDescription: '推荐页里的敲木鱼作品。',
coverImageSrc: null,
themeTags: ['敲木鱼'],
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
generationStatus: 'ready',
};
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
items: [publishedWoodenFishWork],
hasMore: false,
nextCursor: null,
});
vi.mocked(woodenFishClient.startRun).mockResolvedValue({
run: {
runId: 'wooden-fish-run-like-1',
profileId: publishedWoodenFishWork.profileId,
ownerUserId: publishedWoodenFishWork.ownerUserId,
status: 'playing',
totalTapCount: 0,
wordCounters: [],
startedAtMs: 1,
updatedAtMs: 1,
finishedAtMs: null,
},
});
vi.mocked(likeRpgEntryWorldGallery).mockResolvedValue(
buildMockRpgGalleryDetail({
ownerUserId: 'custom-world-user-1',
profileId: 'custom-world-profile-1',
publicWorkCode: 'CW-00000001',
authorPublicUserCode: 'SY-00000001',
visibility: 'published',
publishedAt: '2026-04-25T09:00:00.000Z',
updatedAt: '2026-04-25T09:00:00.000Z',
authorDisplayName: 'RPG 作者',
worldName: '不应被点赞的 RPG',
subtitle: '错误分流',
summaryText: 'WF 点赞不应进入这里。',
coverImageSrc: null,
themeMode: 'mythic',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 1,
}),
);
render(<TestWrapper withAuth />);
const meta = await screen.findByLabelText('莲台木鱼 作品信息');
await user.click(within(meta).getByRole('button', { name: '点赞 0' }));
expect(likeRpgEntryWorldGallery).not.toHaveBeenCalled();
expect(
await screen.findByText('作品类型 wooden-fish 暂不支持点赞。'),
).toBeTruthy();
});
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
const publishedPuzzleWork = {
workId: 'puzzle-work-public-2',
@@ -7471,12 +7658,6 @@ test('logged out home recommendation next starts the next puzzle work', async ()
/>,
);
const recommendNavButton = document.querySelector<HTMLButtonElement>(
'.platform-bottom-nav [aria-label="推荐"]',
);
expect(recommendNavButton).toBeTruthy();
await user.click(recommendNavButton!);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
@@ -7505,7 +7686,114 @@ test('logged out home recommendation next starts the next puzzle work', async ()
});
});
test('home recommendation puzzle next level switches to similar work detail', async () => {
test('home recommendation keeps cover while switching during a pending puzzle start', async () => {
const user = userEvent.setup();
const firstWork = {
workId: 'puzzle-work-pending-next-1',
profileId: 'puzzle-profile-pending-next-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-pending-next-1',
authorDisplayName: '拼图作者',
levelName: '雨港电路',
summary: '第一张公开拼图仍在启动。',
themeTags: ['雨港', '拼图'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: '2026-04-25T10:00:00.000Z',
playCount: 47,
likeCount: 1,
publishReady: true,
} satisfies PuzzleWorkSummary;
const secondWork = {
...firstWork,
workId: 'puzzle-work-pending-next-2',
profileId: 'puzzle-profile-pending-next-2',
ownerUserId: 'user-3',
sourceSessionId: 'puzzle-session-pending-next-2',
authorDisplayName: '贝壳作者',
levelName: '贝壳潮汐',
summary: '第二张公开拼图。',
themeTags: ['贝壳', '拼图'],
playCount: 1,
likeCount: 0,
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
} satisfies PuzzleWorkSummary;
let resolveFirstRun!: (value: { run: PuzzleRunSnapshot }) => void;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [firstWork, secondWork],
});
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
item: profileId === secondWork.profileId ? secondWork : firstWork,
}));
vi.mocked(startPuzzleRun).mockImplementationOnce(
(async () =>
new Promise((resolve) => {
resolveFirstRun = resolve;
})) as typeof startPuzzleRun,
);
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: (action) => action(),
})}
/>,
);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: firstWork.profileId,
levelId: null,
},
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
}),
);
});
await user.click(await screen.findByRole('button', { name: '下一个' }));
expect(
screen.queryByText('作品暂时无法进入,请稍后再试。'),
).toBeNull();
expect(
await screen.findByLabelText('贝壳潮汐 作品信息', undefined, {
timeout: 3000,
}),
).toBeTruthy();
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
await act(async () => {
resolveFirstRun({
run: buildMockPuzzleRun(firstWork.profileId, '后端拼图关卡'),
});
});
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: secondWork.profileId,
levelId: null,
},
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
}),
);
});
expect(
screen.queryByText('作品暂时无法进入,请稍后再试。'),
).toBeNull();
});
test('home recommendation puzzle next level uses unified recommend switching', async () => {
const user = userEvent.setup();
const entryWork = {
workId: 'puzzle-work-public-guest-1',
@@ -7547,17 +7835,17 @@ test('home recommendation puzzle next level switches to similar work detail', as
},
],
} satisfies PuzzleWorkSummary;
const similarWork = {
const nextRecommendWork = {
...entryWork,
workId: 'puzzle-work-similar-guest-1',
profileId: 'puzzle-profile-similar-guest-1',
workId: 'puzzle-work-public-guest-2',
profileId: 'puzzle-profile-public-guest-2',
levelName: '风塔试炼',
summary: '另一套奇幻机关拼图。',
summary: '另一套推荐拼图。',
levels: [
{
levelId: 'similar-level-1',
levelId: 'next-recommend-level-1',
levelName: '风塔试炼',
pictureDescription: '相似作品首关。',
pictureDescription: '推荐队列下一张拼图。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
@@ -7586,47 +7874,35 @@ test('home recommendation puzzle next level switches to similar work detail', as
entryWork.profileId,
entryWork.levelName,
);
const similarRun = {
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName),
runId: clearedRun.runId,
entryProfileId: entryWork.profileId,
currentLevelIndex: 2,
currentLevel: {
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName)
.currentLevel!,
runId: clearedRun.runId,
levelIndex: 2,
levelId: 'similar-level-1',
startedAtMs: Date.now(),
},
};
const nextRecommendRun = buildMockPuzzleRun(
nextRecommendWork.profileId,
nextRecommendWork.levelName,
);
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [entryWork],
items: [entryWork, nextRecommendWork],
});
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
item: profileId === similarWork.profileId ? similarWork : entryWork,
item: profileId === nextRecommendWork.profileId ? nextRecommendWork : entryWork,
}));
vi.mocked(startPuzzleRun).mockResolvedValue({
run: {
...startedRun,
currentLevel: {
...startedRun.currentLevel!,
startedAtMs: Date.now(),
vi.mocked(startPuzzleRun).mockImplementation(async (payload) => {
const run =
payload.profileId === nextRecommendWork.profileId
? nextRecommendRun
: startedRun;
return {
run: {
...run,
currentLevel: {
...run.currentLevel!,
startedAtMs: Date.now(),
},
},
},
};
});
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
run: clearedRunWithSameWorkNext,
});
let resolveAdvancePuzzleNextLevel!: (value: {
run: PuzzleRunSnapshot;
}) => void;
vi.mocked(advancePuzzleNextLevel).mockReturnValue(
new Promise((resolve) => {
resolveAdvancePuzzleNextLevel = resolve;
}),
);
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedRun);
render(<TestWrapper withAuth />);
@@ -7655,24 +7931,23 @@ test('home recommendation puzzle next level switches to similar work detail', as
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedRun.runId, {
preferSimilarWork: true,
});
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: nextRecommendWork.profileId,
levelId: null,
},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(advancePuzzleNextLevel).not.toHaveBeenCalled();
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
expect(screen.queryByText('加载中...')).toBeNull();
resolveAdvancePuzzleNextLevel({ run: similarRun });
await waitFor(() => {
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(similarWork.profileId);
});
expect(
await screen.findByLabelText('风塔试炼 作品信息', undefined, {
timeout: 3000,
}),
).toBeTruthy();
expect(screen.getAllByText('风塔试炼').length).toBeGreaterThan(0);
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
expect(startPuzzleRun).toHaveBeenCalledTimes(2);
});
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
@@ -8331,10 +8606,84 @@ test('direct jump hop result route restores work detail by profile id', async ()
expect(screen.queryByText('跳一跳草稿未恢复')).toBeNull();
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
'jump-hop-profile-restore-1',
{ audience: 'creation' },
);
expect(jumpHopClient.getSession).not.toHaveBeenCalled();
});
test('completed unpublished jump hop draft opens result page without starting runtime', async () => {
const user = userEvent.setup();
const work = buildMockJumpHopWork({
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-draft-ready-1',
profileId: 'jump-hop-profile-draft-ready-1',
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-draft-ready-1',
themeText: '未发布跳一跳草稿',
workTitle: '未发布跳一跳草稿',
workDescription: '已经生成完成,但还没有发布。',
themeTags: ['草稿'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
});
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({
items: [work.summary],
});
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValueOnce({
item: work,
} satisfies JumpHopWorkDetailResponse);
vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({
...testCreationEntryConfig,
creationTypes: [
...testCreationEntryConfig.creationTypes,
{
id: 'jump-hop',
title: '跳一跳',
subtitle: '主题驱动平台跳跃',
badge: '可创建',
imageSrc: '/creation-type-references/jump-hop.webp',
visible: true,
open: true,
sortOrder: 55,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续创作《未发布跳一跳草稿》/u,
}),
);
expect(await screen.findByText('未发布跳一跳草稿')).toBeTruthy();
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
'jump-hop-profile-draft-ready-1',
{ audience: 'creation' },
);
expect(jumpHopClient.startRun).not.toHaveBeenCalled();
expect(window.location.pathname).toBe('/creation/jump-hop/result');
expect(window.location.search).toContain(
'profileId=jump-hop-profile-draft-ready-1',
);
});
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();

View File

@@ -23,6 +23,7 @@ import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
import type {
SquareHoleHoleOption,
SquareHoleShapeOption,
@@ -55,8 +56,12 @@ export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4;
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
export type PlatformCustomWorldGalleryCard = CustomWorldGalleryCard & {
sourceType?: 'custom-world';
};
export type PlatformWorldCardLike =
| CustomWorldGalleryCard
| PlatformCustomWorldGalleryCard
| CustomWorldLibraryEntry<CustomWorldProfile>
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
@@ -319,7 +324,7 @@ export type PlatformBarkBattleGalleryCard = {
};
export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformCustomWorldGalleryCard
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard
@@ -343,6 +348,14 @@ export function isLibraryWorldEntry(
return 'profile' in entry;
}
export function isCustomWorldGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformCustomWorldGalleryCard {
return !isLibraryWorldEntry(entry) && !('sourceType' in entry)
? true
: 'sourceType' in entry && entry.sourceType === 'custom-world';
}
export function isPuzzleGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformPuzzleGalleryCard {
@@ -403,28 +416,62 @@ export function isBarkBattleGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'bark-battle';
}
export function resolvePlatformPublicWorkSourceType(
entry: PlatformPublicGalleryCard,
): PublicWorkSourceType {
if (isCustomWorldGalleryEntry(entry)) {
return 'custom-world';
}
if (isBigFishGalleryEntry(entry)) {
return 'big-fish';
}
if (isPuzzleGalleryEntry(entry)) {
return 'puzzle';
}
if (isPuzzleClearGalleryEntry(entry)) {
return 'puzzle-clear';
}
if (isJumpHopGalleryEntry(entry)) {
return 'jump-hop';
}
if (isWoodenFishGalleryEntry(entry)) {
return 'wooden-fish';
}
if (isMatch3DGalleryEntry(entry)) {
return 'match3d';
}
if (isSquareHoleGalleryEntry(entry)) {
return 'square-hole';
}
if (isVisualNovelGalleryEntry(entry)) {
return 'visual-novel';
}
if (isBarkBattleGalleryEntry(entry)) {
return 'bark-battle';
}
if (isEdutainmentGalleryEntry(entry)) {
return 'edutainment';
}
throw new Error('未知公开作品类型。');
}
export function buildPlatformPublicGalleryCardKey(
entry: PlatformPublicGalleryCard,
) {
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: isJumpHopGalleryEntry(entry)
? 'jump-hop'
: isWoodenFishGalleryEntry(entry)
? 'wooden-fish'
: isMatch3DGalleryEntry(entry)
? 'match3d'
: isSquareHoleGalleryEntry(entry)
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: isBarkBattleGalleryEntry(entry)
? 'bark-battle'
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
const kind = isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: resolvePlatformPublicWorkSourceType(entry);
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -874,7 +921,11 @@ export function resolvePlatformWorldFallbackCoverImage(
return '/creation-type-references/bark-battle.webp';
}
return '/creation-type-references/rpg.webp';
if (isCustomWorldGalleryEntry(entry) || isLibraryWorldEntry(entry)) {
return '/creation-type-references/rpg.webp';
}
throw new Error('未知公开作品类型。');
}
export function resolvePlatformWorldCoverSlides(

View File

@@ -74,8 +74,7 @@ export function useRpgEntryBootstrap(
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
const [platformTab, setPlatformTabState] =
useState<PlatformHomeTab>('category');
const [platformTab, setPlatformTabState] = useState<PlatformHomeTab>('home');
const [platformError, setPlatformError] = useState<string | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
@@ -351,8 +350,8 @@ export function useRpgEntryBootstrap(
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
// 中文注释:新用户先进入发现页;推荐页可直接进入,真正受保护的动作再单独做登录门禁。
setPlatformTabState(isAuthenticated ? 'home' : 'category');
// 中文注释:新用户先进入推荐页;真正受保护的动作再单独做登录门禁。
setPlatformTabState('home');
}
} finally {
if (isActive) {
@@ -369,7 +368,6 @@ export function useRpgEntryBootstrap(
canReadProtectedData,
getProfileDashboard,
hasInitialAgentSession,
isAuthenticated,
user,
]);

View File

@@ -59,13 +59,15 @@ describe('UnifiedCreationPage', () => {
.getByText('敲木鱼工作台')
.closest('.unified-creation-page__content')
?.className,
).toContain('min-h-max');
).toContain('flex-1');
expect(
screen
.getByText('敲木鱼工作台')
.closest('.unified-creation-page__content')
?.className,
).not.toContain('min-h-0');
).toContain('min-h-0');
expect(root?.className).toContain('overflow-y-auto');
expect(root?.className).toContain('overscroll-contain');
expect(root?.className).toContain('bg-[image:var(--platform-body-fill)]');
});
});

View File

@@ -18,7 +18,7 @@ export function UnifiedCreationPage({
}: UnifiedCreationPageProps) {
return (
<div
className="unified-creation-page platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overflow-x-hidden px-3 pt-2 sm:px-4 sm:pt-3"
className="unified-creation-page platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overflow-x-hidden overscroll-contain bg-[image:var(--platform-body-fill)] px-3 pt-2 sm:px-4 sm:pt-3"
data-play-id={spec.playId}
data-field-kinds={spec.fields.map((field) => field.kind).join(',')}
data-workspace-stage={spec.workspaceStage}
@@ -65,7 +65,7 @@ export function UnifiedCreationPage({
))}
</ul>
</div>
<div className="unified-creation-page__content flex min-h-max flex-col pb-3 sm:pb-4">
<div className="unified-creation-page__content flex min-h-0 flex-1 flex-col pb-3 sm:pb-4">
{children}
</div>
</div>

View File

@@ -16,6 +16,7 @@ describe('unified creation specs', () => {
'jump-hop',
'match3d',
'puzzle',
'puzzle-clear',
'rpg',
'square-hole',
'visual-novel',
@@ -36,41 +37,54 @@ describe('unified creation specs', () => {
test('主要链路都映射到统一创作、生成、结果阶段', () => {
expect(getUnifiedCreationSpec('rpg')).toMatchObject({
title: '文字冒险',
workspaceStage: 'agent-workspace',
generationStage: 'custom-world-generating',
resultStage: 'custom-world-result',
});
expect(getUnifiedCreationSpec('puzzle')).toMatchObject({
title: '拼图',
workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating',
resultStage: 'puzzle-result',
});
expect(getUnifiedCreationSpec('puzzle-clear')).toMatchObject({
workspaceStage: 'puzzle-clear-workspace',
generationStage: 'puzzle-clear-generating',
resultStage: 'puzzle-clear-result',
});
expect(getUnifiedCreationSpec('match3d')).toMatchObject({
title: '抓大鹅',
workspaceStage: 'match3d-agent-workspace',
generationStage: 'match3d-generating',
resultStage: 'match3d-result',
});
expect(getUnifiedCreationSpec('jump-hop')).toMatchObject({
title: '跳一跳',
workspaceStage: 'jump-hop-workspace',
generationStage: 'jump-hop-generating',
resultStage: 'jump-hop-result',
});
expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({
title: '敲木鱼',
workspaceStage: 'wooden-fish-workspace',
generationStage: 'wooden-fish-generating',
resultStage: 'wooden-fish-result',
});
expect(getUnifiedCreationSpec('bark-battle')).toMatchObject({
title: '汪汪声浪',
workspaceStage: 'bark-battle-workspace',
generationStage: 'bark-battle-generating',
resultStage: 'bark-battle-result',
});
expect(getUnifiedCreationSpec('visual-novel')).toMatchObject({
title: '视觉小说',
workspaceStage: 'visual-novel-agent-workspace',
generationStage: 'visual-novel-generating',
resultStage: 'visual-novel-result',
});
expect(getUnifiedCreationSpec('baby-object-match')).toMatchObject({
title: '宝贝识物',
workspaceStage: 'baby-object-match-workspace',
generationStage: 'baby-object-match-generating',
resultStage: 'baby-object-match-result',

View File

@@ -2,11 +2,13 @@ import type {
CreationEntryTypeConfig,
UnifiedCreationSpec,
} from '../../services/creationEntryConfigService';
import type { PlatformCreationTypeId } from '../../../packages/shared/src/contracts/playTypes';
export const UNIFIED_CREATION_PLAY_IDS = [
'rpg',
'big-fish',
'puzzle',
'puzzle-clear',
'match3d',
'jump-hop',
'wooden-fish',
@@ -15,7 +17,7 @@ export const UNIFIED_CREATION_PLAY_IDS = [
'visual-novel',
'baby-object-match',
'creative-agent',
] as const;
] as const satisfies readonly PlatformCreationTypeId[];
export type UnifiedCreationPlayId =
(typeof UNIFIED_CREATION_PLAY_IDS)[number];
@@ -27,7 +29,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
> = {
rpg: {
playId: 'rpg',
title: '想做个什么玩法?',
title: '文字冒险',
workspaceStage: 'agent-workspace',
generationStage: 'custom-world-generating',
resultStage: 'custom-world-result',
@@ -42,7 +44,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
'big-fish': {
playId: 'big-fish',
title: '想做个什么玩法?',
title: '摸鱼',
workspaceStage: 'big-fish-agent-workspace',
generationStage: 'big-fish-generating',
resultStage: 'big-fish-result',
@@ -57,7 +59,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
puzzle: {
playId: 'puzzle',
title: '想做个什么玩法?',
title: '拼图',
workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating',
resultStage: 'puzzle-result',
@@ -82,9 +84,36 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
],
},
'puzzle-clear': {
playId: 'puzzle-clear',
title: '想做个什么玩法?',
workspaceStage: 'puzzle-clear-workspace',
generationStage: 'puzzle-clear-generating',
resultStage: 'puzzle-clear-result',
fields: [
{
id: 'title',
kind: 'text',
label: '作品标题',
required: true,
},
{
id: 'themePrompt',
kind: 'text',
label: '主题',
required: true,
},
{
id: 'backgroundReferenceImage',
kind: 'image',
label: '参考图',
required: false,
},
],
},
match3d: {
playId: 'match3d',
title: '想做个什么玩法?',
title: '抓大鹅',
workspaceStage: 'match3d-agent-workspace',
generationStage: 'match3d-generating',
resultStage: 'match3d-result',
@@ -105,7 +134,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
'jump-hop': {
playId: 'jump-hop',
title: '想做个什么玩法?',
title: '跳一跳',
workspaceStage: 'jump-hop-workspace',
generationStage: 'jump-hop-generating',
resultStage: 'jump-hop-result',
@@ -120,7 +149,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
'wooden-fish': {
playId: 'wooden-fish',
title: '想做个什么玩法?',
title: '敲木鱼',
workspaceStage: 'wooden-fish-workspace',
generationStage: 'wooden-fish-generating',
resultStage: 'wooden-fish-result',
@@ -153,7 +182,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
'square-hole': {
playId: 'square-hole',
title: '想做个什么玩法?',
title: '方洞',
workspaceStage: 'square-hole-agent-workspace',
generationStage: 'square-hole-generating',
resultStage: 'square-hole-result',
@@ -168,7 +197,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
'bark-battle': {
playId: 'bark-battle',
title: '想做个什么玩法?',
title: '汪汪声浪',
workspaceStage: 'bark-battle-workspace',
generationStage: 'bark-battle-generating',
resultStage: 'bark-battle-result',
@@ -213,7 +242,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
'visual-novel': {
playId: 'visual-novel',
title: '想做个什么玩法?',
title: '视觉小说',
workspaceStage: 'visual-novel-agent-workspace',
generationStage: 'visual-novel-generating',
resultStage: 'visual-novel-result',
@@ -234,7 +263,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
'baby-object-match': {
playId: 'baby-object-match',
title: '想做个什么玩法?',
title: '宝贝识物',
workspaceStage: 'baby-object-match-workspace',
generationStage: 'baby-object-match-generating',
resultStage: 'baby-object-match-result',
@@ -255,7 +284,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
'creative-agent': {
playId: 'creative-agent',
title: '想做个什么玩法?',
title: '智能体创作',
workspaceStage: 'creative-agent-workspace',
generationStage: 'puzzle-generating',
resultStage: 'puzzle-result',

View File

@@ -78,7 +78,7 @@ test('match3d workspace submits derived entry form payload instead of agent chat
/>,
);
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
expect(screen.getByText('抓大鹅')).toBeTruthy();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
expect(screen.queryByText('2D素材风格')).toBeNull();
expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull();
@@ -130,7 +130,7 @@ test('match3d workspace can defer visible chrome to the unified creation page',
expect(workspace?.className).not.toContain('h-full');
expect(workspace?.className).not.toContain('overflow-hidden');
expect(workspace?.className).not.toContain('platform-remap-surface');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
expect(screen.queryByRole('heading', { name: '抓大鹅' })).toBeNull();
const themeInput = screen.getByLabelText('想做一个什么题材的抓大鹅?');
expect(themeInput).toBeTruthy();
expect(themeInput.className).not.toContain('h-full');

View File

@@ -115,7 +115,7 @@ export function Match3DCreationWorkspace({
onCreateFromForm,
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
title = '抓大鹅',
unifiedChrome = false,
}: Match3DCreationWorkspaceProps) {
const [formState, setFormState] = useState<Match3DFormState>(() =>

View File

@@ -188,7 +188,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(screen.queryByLabelText('作品名称')).toBeNull();
expect(screen.queryByLabelText('作品描述')).toBeNull();
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
expect(screen.getByText('拼图')).toBeTruthy();
expect(screen.queryByText('try')).toBeNull();
expect(screen.queryByText('Template')).toBeNull();
@@ -238,7 +238,7 @@ test('puzzle workspace can defer visible chrome to the unified creation page', (
expect(workspace?.className).not.toContain('platform-remap-surface');
expect(imagePanel?.className).toContain('flex-none');
expect(imagePanel?.className).not.toContain('flex-1');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
expect(screen.queryByRole('heading', { name: '拼图' })).toBeNull();
expect(screen.getByLabelText('画面描述')).toBeTruthy();
});

View File

@@ -246,7 +246,7 @@ export function PuzzleCreationWorkspace({
onAutoSaveForm,
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
title = '拼图',
unifiedChrome = false,
}: PuzzleCreationWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>

View File

@@ -79,17 +79,19 @@ body {
-webkit-font-smoothing: antialiased;
}
html[data-mobile-keyboard-open='true'],
html[data-mobile-keyboard-open='true'] body,
html[data-mobile-keyboard-open='true'] #root {
background: var(
--platform-keyboard-exposed-fill,
linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)
);
}
.platform-viewport-shell {
height: var(--platform-layout-viewport-height, 100vh);
max-height: var(--platform-layout-viewport-height, 100vh);
min-height: var(--platform-layout-viewport-height, 100vh);
transform: translate3d(
0,
calc(-1 * var(--platform-keyboard-focus-offset, 0px)),
0
);
transform-origin: top center;
transition: transform 180ms ease;
}
@supports (height: 100dvh) {
@@ -220,6 +222,16 @@ body {
}
}
@keyframes platform-recommend-runtime-loading {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(240%);
}
}
@keyframes puzzle-clear-card-clear-pop {
0% {
opacity: 1;
@@ -5281,6 +5293,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.platform-recommend-runtime-viewport {
position: absolute;
inset: 0;
z-index: 1;
isolation: isolate;
min-width: 0;
overflow: hidden;
background: var(--platform-recommend-runtime-fill);
@@ -5298,18 +5312,48 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.platform-recommend-runtime-cover {
position: absolute;
inset: 0;
z-index: 3;
z-index: 30;
isolation: isolate;
overflow: hidden;
background: var(--platform-recommend-runtime-fill);
opacity: 1;
pointer-events: auto;
transition: opacity 420ms ease;
will-change: opacity;
}
.platform-recommend-runtime-loading {
position: absolute;
right: 1rem;
bottom: 0.72rem;
left: 1rem;
z-index: 4;
height: 0.18rem;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.24);
box-shadow: 0 0 18px rgba(255, 255, 255, 0.24);
}
.platform-recommend-runtime-loading::before {
position: absolute;
inset: 0;
width: 42%;
border-radius: inherit;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.38),
rgba(255, 255, 255, 0.92),
rgba(255, 180, 97, 0.86)
);
content: '';
transform: translateX(-110%);
animation: platform-recommend-runtime-loading 1.15s ease-in-out infinite;
}
.platform-recommend-runtime-cover--hidden {
opacity: 0;
pointer-events: none;
transition: opacity 420ms ease;
}
.platform-recommend-swipe-stage {
@@ -5335,6 +5379,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transition: none;
}
.platform-recommend-swipe-rail--resetting {
transition: none;
}
.platform-recommend-swipe-page {
position: absolute;
inset: 0;

View File

@@ -59,6 +59,30 @@ describe('index stylesheet unread dots', () => {
expect(css).toContain('::-webkit-scrollbar-thumb');
});
it('uses the platform fill for root background exposed by mobile keyboard shift', () => {
const css = readIndexCss();
const keyboardRootBlock = getCssBlock(
css,
"html[data-mobile-keyboard-open='true'],\nhtml[data-mobile-keyboard-open='true'] body,\nhtml[data-mobile-keyboard-open='true'] #root",
);
expect(keyboardRootBlock).toContain('--platform-keyboard-exposed-fill');
expect(keyboardRootBlock).toContain('#fffdf9');
expect(keyboardRootBlock).not.toContain('#0a0a0a');
});
it('does not globally transform the platform shell while the mobile keyboard is open', () => {
const css = readIndexCss();
const platformShellBlock = getCssBlock(
css,
'.platform-viewport-shell {\n height',
);
expect(platformShellBlock).toContain('--platform-layout-viewport-height');
expect(platformShellBlock).not.toContain('translate3d');
expect(platformShellBlock).not.toContain('--platform-keyboard-focus-offset');
});
it('uses warm brown tokens for draft unread markers instead of red literals', () => {
const css = readIndexCss();
@@ -129,3 +153,43 @@ describe('index stylesheet creation agent hero contrast', () => {
expect(hintBlock).toContain('rgba(255, 255, 255, 0.72) !important');
});
});
describe('index stylesheet recommend runtime cover', () => {
it('keeps the card cover above embedded runtime and only fades it when ready', () => {
const css = readIndexCss();
const viewportBlock = getCssBlock(
css,
'.platform-recommend-runtime-viewport',
);
expect(viewportBlock).toContain('z-index: 1;');
expect(viewportBlock).toContain('isolation: isolate;');
const coverBlock = getCssBlock(css, '.platform-recommend-runtime-cover');
expect(coverBlock).toContain('z-index: 30;');
expect(coverBlock).toContain('isolation: isolate;');
expect(coverBlock).not.toContain('transition: opacity');
const loadingBlock = getCssBlock(
css,
'.platform-recommend-runtime-loading',
);
expect(loadingBlock).toContain('position: absolute;');
expect(loadingBlock).toContain('z-index: 4;');
const loadingAnimationBlock = getCssBlock(
css,
'.platform-recommend-runtime-loading::before',
);
expect(loadingAnimationBlock).toContain(
'animation: platform-recommend-runtime-loading 1.15s ease-in-out infinite;',
);
expect(css).toContain('@keyframes platform-recommend-runtime-loading');
const hiddenCoverBlock = getCssBlock(
css,
'.platform-recommend-runtime-cover--hidden',
);
expect(hiddenCoverBlock).toContain('transition: opacity 420ms ease;');
});
});

View File

@@ -1,12 +1,59 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
calculateMobileKeyboardFocusShift,
isEditableKeyboardTarget,
resolveMobileKeyboardExposedFill,
resolveMobileKeyboardState,
stabilizeMobileViewportKeyboardFocus,
} from './mobileViewportKeyboardFocus';
const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const originalInnerHeight = window.innerHeight;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalVisualViewport = window.visualViewport;
function defineWindowValue<Key extends keyof Window>(key: Key, value: Window[Key]) {
Object.defineProperty(window, key, {
configurable: true,
value,
});
}
function defineNavigatorValue<Key extends keyof Navigator>(
key: Key,
value: Navigator[Key],
) {
Object.defineProperty(navigator, key, {
configurable: true,
value,
});
}
beforeEach(() => {
document.body.innerHTML = '';
document.documentElement.removeAttribute('data-mobile-keyboard-open');
document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus');
document.documentElement.removeAttribute('style');
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
document.documentElement.removeAttribute('data-mobile-keyboard-open');
document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus');
document.documentElement.removeAttribute('style');
defineWindowValue('matchMedia', originalMatchMedia);
defineWindowValue('requestAnimationFrame', originalRequestAnimationFrame);
defineWindowValue('cancelAnimationFrame', originalCancelAnimationFrame);
defineWindowValue('innerHeight', originalInnerHeight);
defineNavigatorValue('maxTouchPoints', originalMaxTouchPoints);
defineWindowValue('visualViewport', originalVisualViewport);
});
describe('isEditableKeyboardTarget', () => {
it('matches controls that open the mobile keyboard', () => {
const input = document.createElement('input');
@@ -31,47 +78,125 @@ describe('isEditableKeyboardTarget', () => {
});
});
describe('calculateMobileKeyboardFocusShift', () => {
it('moves a bottom input above the visible keyboard area', () => {
expect(
calculateMobileKeyboardFocusShift({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
targetTop: 720,
targetBottom: 770,
currentShift: 0,
margin: 20,
}),
).toBe(290);
describe('resolveMobileKeyboardExposedFill', () => {
it('uses the active platform shell fill for exposed mini-program keyboard space', () => {
document.body.innerHTML = `
<div
class="platform-viewport-shell"
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
></div>
`;
expect(resolveMobileKeyboardExposedFill()).toContain('rgb(255, 253, 249)');
});
it('does not move when the focused input is already visible', () => {
expect(
calculateMobileKeyboardFocusShift({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
targetTop: 250,
targetBottom: 300,
currentShift: 0,
margin: 20,
}),
).toBe(0);
});
it('falls back to the light platform fill before the shell mounts', () => {
document.body.innerHTML = '';
it('caps movement to keyboard inset plus safety margin', () => {
expect(
calculateMobileKeyboardFocusShift({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
targetTop: 790,
targetBottom: 860,
currentShift: 0,
margin: 20,
maxExtraShift: 20,
}),
).toBe(320);
expect(resolveMobileKeyboardExposedFill()).toContain('#fffdf9');
});
});
describe('resolveMobileKeyboardState', () => {
it('detects the keyboard inset without asking the app shell to shift', () => {
expect(
resolveMobileKeyboardState({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
hasEditableTarget: true,
}),
).toEqual({
isOpen: true,
insetBottom: 300,
});
});
it('stays closed when no editable target is focused', () => {
expect(
resolveMobileKeyboardState({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
hasEditableTarget: false,
}),
).toEqual({
isOpen: false,
insetBottom: 0,
});
});
it('accounts for browser visual viewport panning without returning a shell offset', () => {
expect(
resolveMobileKeyboardState({
layoutHeight: 800,
visualTop: 120,
visualHeight: 500,
hasEditableTarget: true,
}),
).toEqual({
isOpen: true,
insetBottom: 180,
});
});
});
describe('stabilizeMobileViewportKeyboardFocus', () => {
it('marks H5 keyboard state without applying a global shell transform offset', () => {
vi.useFakeTimers();
document.body.innerHTML = `
<div
class="platform-viewport-shell"
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
>
<input id="theme-input" />
</div>
`;
defineNavigatorValue('maxTouchPoints', 1);
defineWindowValue(
'matchMedia',
vi.fn().mockReturnValue({ matches: true }) as unknown as Window['matchMedia'],
);
defineWindowValue(
'requestAnimationFrame',
((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as Window['requestAnimationFrame'],
);
defineWindowValue(
'cancelAnimationFrame',
vi.fn() as unknown as Window['cancelAnimationFrame'],
);
defineWindowValue('innerHeight', 800);
defineWindowValue('visualViewport', {
height: 500,
offsetTop: 120,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as VisualViewport);
stabilizeMobileViewportKeyboardFocus();
document.getElementById('theme-input')?.focus();
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
vi.runAllTimers();
expect(document.documentElement.dataset.mobileKeyboardOpen).toBe('true');
expect(
document.documentElement.style.getPropertyValue(
'--platform-keyboard-focus-offset',
),
).toBe('0px');
expect(
document.documentElement.style.getPropertyValue(
'--platform-keyboard-inset-bottom',
),
).toBe('180px');
expect(
document.documentElement.style.getPropertyValue(
'--platform-keyboard-exposed-fill',
),
).toContain('rgb(255, 253, 249)');
});
});

View File

@@ -1,31 +1,43 @@
const MOBILE_POINTER_QUERY = '(pointer: coarse)';
const KEYBOARD_OPEN_THRESHOLD_PX = 96;
const FOCUS_MARGIN_PX = 18;
const MIN_LAYOUT_VIEWPORT_HEIGHT_PX = 320;
const LAYOUT_HEIGHT_VAR = '--platform-layout-viewport-height';
const KEYBOARD_FOCUS_OFFSET_VAR = '--platform-keyboard-focus-offset';
const KEYBOARD_INSET_VAR = '--platform-keyboard-inset-bottom';
const KEYBOARD_EXPOSED_FILL_VAR = '--platform-keyboard-exposed-fill';
const KEYBOARD_EXPOSED_FILL_FALLBACK =
'linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)';
type KeyboardFocusShiftInput = {
layoutHeight: number;
visualTop: number;
visualHeight: number;
targetTop: number;
targetBottom: number;
currentShift: number;
margin?: number;
maxExtraShift?: number;
hasEditableTarget: boolean;
threshold?: number;
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function readVisualViewport() {
return typeof window !== 'undefined' ? window.visualViewport : undefined;
}
export function resolveMobileKeyboardExposedFill() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return KEYBOARD_EXPOSED_FILL_FALLBACK;
}
const platformShell = document.querySelector('.platform-viewport-shell');
if (!(platformShell instanceof HTMLElement)) {
return KEYBOARD_EXPOSED_FILL_FALLBACK;
}
const platformFill = window
.getComputedStyle(platformShell)
.getPropertyValue('--platform-body-fill')
.trim();
return platformFill || KEYBOARD_EXPOSED_FILL_FALLBACK;
}
function readLayoutViewportHeight() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return MIN_LAYOUT_VIEWPORT_HEIGHT_PX;
@@ -110,34 +122,22 @@ export function isEditableKeyboardTarget(
]).has(inputType);
}
export function calculateMobileKeyboardFocusShift({
export function resolveMobileKeyboardState({
layoutHeight,
visualTop,
visualHeight,
targetTop,
targetBottom,
currentShift,
margin = FOCUS_MARGIN_PX,
maxExtraShift = FOCUS_MARGIN_PX,
hasEditableTarget,
threshold = KEYBOARD_OPEN_THRESHOLD_PX,
}: KeyboardFocusShiftInput) {
const visualBottom = visualTop + visualHeight;
const safeTop = visualTop + margin;
const safeBottom = visualBottom - margin;
const unshiftedTargetTop = targetTop + currentShift;
const unshiftedTargetBottom = targetBottom + currentShift;
let nextShift = currentShift;
const insetBottom = Math.max(0, Math.round(layoutHeight - visualBottom));
const isOpen =
hasEditableTarget && layoutHeight - visualHeight > threshold;
if (unshiftedTargetBottom - nextShift > safeBottom) {
nextShift = unshiftedTargetBottom - safeBottom;
}
if (unshiftedTargetTop - nextShift < safeTop) {
nextShift = Math.max(0, unshiftedTargetTop - safeTop);
}
const keyboardInset = Math.max(0, layoutHeight - visualBottom);
const maxShift = keyboardInset + maxExtraShift;
return Math.round(clamp(nextShift, 0, Math.max(0, maxShift)));
return {
isOpen,
insetBottom: isOpen ? insetBottom : 0,
};
}
export function stabilizeMobileViewportKeyboardFocus() {
@@ -154,7 +154,6 @@ export function stabilizeMobileViewportKeyboardFocus() {
const root = document.documentElement;
const visualViewport = readVisualViewport();
let stableLayoutHeight = readLayoutViewportHeight();
let currentShift = 0;
let frameId = 0;
const setLayoutHeight = (nextHeight: number) => {
@@ -167,6 +166,10 @@ export function stabilizeMobileViewportKeyboardFocus() {
const setKeyboardState = (isOpen: boolean, insetBottom = 0) => {
if (isOpen) {
root.style.setProperty(
KEYBOARD_EXPOSED_FILL_VAR,
resolveMobileKeyboardExposedFill(),
);
root.dataset.mobileKeyboardOpen = 'true';
} else {
delete root.dataset.mobileKeyboardOpen;
@@ -178,9 +181,8 @@ export function stabilizeMobileViewportKeyboardFocus() {
);
};
const setFocusShift = (nextShift: number) => {
currentShift = Math.max(0, Math.round(nextShift));
root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, `${currentShift}px`);
const resetFocusShift = () => {
root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, '0px');
};
const readActiveTarget = () =>
@@ -193,15 +195,16 @@ export function stabilizeMobileViewportKeyboardFocus() {
const viewport = readVisualViewport();
const visualTop = viewport?.offsetTop ?? 0;
const visualHeight = viewport?.height ?? window.innerHeight;
const visualBottom = visualTop + visualHeight;
const keyboardInset = Math.max(0, stableLayoutHeight - visualBottom);
const keyboardOpen =
Boolean(activeTarget) &&
stableLayoutHeight - visualHeight > KEYBOARD_OPEN_THRESHOLD_PX;
const keyboardState = resolveMobileKeyboardState({
layoutHeight: stableLayoutHeight,
visualTop,
visualHeight,
hasEditableTarget: Boolean(activeTarget),
});
if (!keyboardOpen || !activeTarget) {
if (!keyboardState.isOpen || !activeTarget) {
setKeyboardState(false);
setFocusShift(0);
resetFocusShift();
if (!activeTarget) {
setLayoutHeight(readLayoutViewportHeight());
@@ -209,19 +212,10 @@ export function stabilizeMobileViewportKeyboardFocus() {
return;
}
// 中文注释:先保持整页布局高度,再只移动画布,让输入框避开键盘
const targetRect = activeTarget.getBoundingClientRect();
const nextShift = calculateMobileKeyboardFocusShift({
layoutHeight: stableLayoutHeight,
visualTop,
visualHeight,
targetTop: targetRect.top,
targetBottom: targetRect.bottom,
currentShift,
});
setKeyboardState(true, keyboardInset);
setFocusShift(nextShift);
// 中文注释:H5 浏览器和小程序 web-view 已会自行处理输入框可见性
// 这里只记录键盘状态、隐藏底部 dock并给可能露出的宿主区域补浅色背景。
setKeyboardState(true, keyboardState.insetBottom);
resetFocusShift();
};
const scheduleSync = () => {
@@ -243,14 +237,14 @@ export function stabilizeMobileViewportKeyboardFocus() {
setLayoutHeight(stableLayoutHeight);
setKeyboardState(false);
setFocusShift(0);
resetFocusShift();
document.addEventListener('focusin', scheduleKeyboardAnimationSync, true);
document.addEventListener('focusout', scheduleKeyboardAnimationSync, true);
window.addEventListener('resize', scheduleKeyboardAnimationSync);
window.addEventListener('orientationchange', () => {
setKeyboardState(false);
setFocusShift(0);
resetFocusShift();
window.setTimeout(() => {
setLayoutHeight(readLayoutViewportHeight());
scheduleSync();

View File

@@ -1,8 +1,9 @@
import type { PlatformCreationTypeId } from '../../packages/shared/src/contracts/playTypes';
import { requestJson } from './apiClient';
/** 后端下发的单个创作类型入口配置,前端只据此展示和分流。 */
export type CreationEntryTypeConfig = {
id: string;
id: PlatformCreationTypeId;
title: string;
subtitle: string;
badge: string;
@@ -27,7 +28,7 @@ export type UnifiedCreationField = {
/** 统一创作工作台契约,把入口类型映射到工作台、生成页和结果页阶段。 */
export type UnifiedCreationSpec = {
playId: string;
playId: PlatformCreationTypeId;
title: string;
workspaceStage: string;
generationStage: string;

View File

@@ -188,9 +188,16 @@ export function executeJumpHopCreationAction(
.then(normalizeJumpHopActionResponse);
}
export async function getJumpHopWorkDetail(profileId: string) {
export async function getJumpHopWorkDetail(
profileId: string,
options: { audience?: 'creation' | 'runtime' } = {},
) {
const base =
options.audience === 'creation'
? JUMP_HOP_WORKS_API_BASE
: `${JUMP_HOP_RUNTIME_API_BASE}/works`;
const response = await requestJson<JumpHopWorkDetailResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
`${base}/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取跳一跳作品详情失败',
);

View File

@@ -30,6 +30,7 @@ const leaderboardResponse: JumpHopLeaderboardResponse = {
{
rank: 1,
playerId: 'player-1',
displayName: '玩家一号',
successfulJumpCount: 10,
durationMs: 3210,
updatedAt: '2026-05-27T00:00:00Z',

View File

@@ -530,6 +530,44 @@ describe('miniGameDraftGenerationProgress', () => {
]);
});
test('jump hop generation anchors hide unused style preset fallback', () => {
const entries = buildJumpHopGenerationAnchorEntries({
sessionId: 'jump-hop-session-style-hidden',
ownerUserId: 'user-1',
status: 'generating',
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-style-hidden',
themeText: '水果',
workTitle: '水果跳一跳',
workDescription: '水果主题跳一跳。',
themeTags: ['水果'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: '',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: null,
generationStatus: 'generating',
},
createdAt: '2026-06-06T10:00:00.000Z',
updatedAt: '2026-06-06T10:00:00.000Z',
});
expect(entries).toEqual([
{
id: 'jump-hop-theme',
label: '主题',
value: '水果',
},
]);
});
test('wooden fish draft generation exposes hit object, background and back button pipeline', () => {
const state = createMiniGameDraftGenerationState('wooden-fish');

View File

@@ -1163,7 +1163,7 @@ export function buildJumpHopGenerationAnchorEntries(
workTitle?: string;
themeText?: string;
characterPrompt?: string;
stylePreset?: string;
tilePrompt?: string;
} | null;
}
| null
@@ -1187,7 +1187,7 @@ export function buildJumpHopGenerationAnchorEntries(
value:
formPayload?.tilePrompt?.trim() ||
config?.tilePrompt?.trim() ||
draft?.stylePreset?.trim() ||
draft?.tilePrompt?.trim() ||
'',
},
];

View File

@@ -93,7 +93,7 @@ export async function dragPuzzlePieceOrGroup(
}
/**
* 进入推荐出的下一关。
* 进入当前 run 的下一关。
*/
export async function advancePuzzleNextLevel(
runId: string,
@@ -101,10 +101,8 @@ export async function advancePuzzleNextLevel(
options: PuzzleRuntimeRequestOptions = {},
) {
const targetProfileId = payload.targetProfileId?.trim() ?? '';
const preferSimilarWork = payload.preferSimilarWork === true;
const requestPayload = {
...(targetProfileId ? { targetProfileId } : {}),
...(preferSimilarWork ? { preferSimilarWork: true } : {}),
};
const hasRequestPayload = Object.keys(requestPayload).length > 0;
return requestRuntimeJson<PuzzleRunResponse>({

View File

@@ -130,10 +130,10 @@ describe('recommended runtime guest launch clients', () => {
},
);
it('puzzle next level can carry preferSimilarWork through the runtime guest request', async () => {
it('puzzle next level keeps the default current-run handoff without a request body', async () => {
await advancePuzzleNextLevel(
'run-puzzle-1',
{ preferSimilarWork: true },
{},
{ runtimeGuestToken: 'runtime-guest-token' },
);
@@ -144,11 +144,10 @@ describe('recommended runtime guest launch clients', () => {
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer runtime-guest-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify({ preferSimilarWork: true }),
}),
);
expect(init.body).toBeUndefined();
expect(options).toEqual(
expect.objectContaining({
skipAuth: true,