@@ -47,6 +47,11 @@ describe('PublishShareModal', () => {
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
|
||||
expect(dialog.parentElement?.className).toContain('!items-center');
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||||
expect(dialog.getAttribute('style')).toBeNull();
|
||||
expect(within(dialog).getByText(/邀请你来玩《暖灯猫街》/u)).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Check, Copy, MessageCircle, Music2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
buildPublishShareText,
|
||||
type PublishShareModalPayload,
|
||||
@@ -44,6 +45,7 @@ export function PublishShareModal({
|
||||
payload,
|
||||
onClose,
|
||||
}: PublishShareModalProps) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
@@ -89,7 +91,8 @@ export function PublishShareModal({
|
||||
title="分享给朋友"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
panelClassName="platform-remap-surface"
|
||||
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
|
||||
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
||||
footer={
|
||||
|
||||
@@ -132,6 +132,8 @@ import {
|
||||
remixPuzzleGalleryWork,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advancePuzzleNextLevel,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
@@ -141,6 +143,7 @@ import {
|
||||
extendLocalPuzzleTime,
|
||||
isLocalPuzzleRun,
|
||||
refreshLocalPuzzleTimer,
|
||||
resolvePuzzleRestartLevelId,
|
||||
restartLocalPuzzleLevel,
|
||||
setLocalPuzzlePaused,
|
||||
startLocalPuzzleRun,
|
||||
@@ -876,31 +879,20 @@ function mergePuzzleServiceRuntimeState(
|
||||
}
|
||||
|
||||
const serviceLevel = serviceRun.currentLevel;
|
||||
if (
|
||||
currentRun.currentLevel.status === 'cleared' &&
|
||||
serviceLevel.status !== 'cleared'
|
||||
) {
|
||||
return {
|
||||
...currentRun,
|
||||
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
|
||||
nextLevelMode: serviceRun.nextLevelMode,
|
||||
nextLevelProfileId: serviceRun.nextLevelProfileId,
|
||||
nextLevelId: serviceRun.nextLevelId,
|
||||
recommendedNextWorks: serviceRun.recommendedNextWorks,
|
||||
leaderboardEntries:
|
||||
currentRun.currentLevel.leaderboardEntries.length > 0
|
||||
? currentRun.currentLevel.leaderboardEntries
|
||||
: currentRun.leaderboardEntries,
|
||||
};
|
||||
}
|
||||
|
||||
const leaderboardEntries =
|
||||
serviceLevel.leaderboardEntries.length > 0
|
||||
? serviceLevel.leaderboardEntries
|
||||
: serviceRun.leaderboardEntries;
|
||||
|
||||
// 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。
|
||||
return {
|
||||
...currentRun,
|
||||
runId: serviceRun.runId,
|
||||
entryProfileId: serviceRun.entryProfileId,
|
||||
clearedLevelCount: Math.max(
|
||||
currentRun.clearedLevelCount,
|
||||
serviceRun.clearedLevelCount,
|
||||
),
|
||||
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
|
||||
nextLevelMode: serviceRun.nextLevelMode,
|
||||
nextLevelProfileId: serviceRun.nextLevelProfileId,
|
||||
@@ -909,18 +901,10 @@ function mergePuzzleServiceRuntimeState(
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...currentRun.currentLevel,
|
||||
status: serviceLevel.status,
|
||||
startedAtMs: serviceLevel.startedAtMs,
|
||||
clearedAtMs: serviceLevel.clearedAtMs,
|
||||
elapsedMs: serviceLevel.elapsedMs,
|
||||
timeLimitMs: serviceLevel.timeLimitMs,
|
||||
remainingMs: serviceLevel.remainingMs,
|
||||
pausedAccumulatedMs: serviceLevel.pausedAccumulatedMs,
|
||||
pauseStartedAtMs: serviceLevel.pauseStartedAtMs,
|
||||
freezeAccumulatedMs: serviceLevel.freezeAccumulatedMs,
|
||||
freezeStartedAtMs: serviceLevel.freezeStartedAtMs,
|
||||
freezeUntilMs: serviceLevel.freezeUntilMs,
|
||||
leaderboardEntries,
|
||||
leaderboardEntries:
|
||||
leaderboardEntries.length > 0
|
||||
? leaderboardEntries
|
||||
: currentRun.currentLevel.leaderboardEntries,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2181,8 +2165,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const item = detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
|
||||
const run = startLocalPuzzleRun(item, levelId ?? null);
|
||||
const item =
|
||||
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
|
||||
const { run } = await startPuzzleRun({
|
||||
profileId: item.profileId,
|
||||
levelId: levelId ?? null,
|
||||
});
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeReturnStage(returnStage);
|
||||
@@ -2411,14 +2399,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
try {
|
||||
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
|
||||
setPuzzleRun(
|
||||
swapLocalPuzzlePieces(
|
||||
puzzleRun,
|
||||
payload,
|
||||
isLocalPuzzleRun(puzzleRun) ? selectedPuzzleDetail : null,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, selectedPuzzleDetail],
|
||||
);
|
||||
|
||||
const dragPuzzlePiece = useCallback(
|
||||
@@ -2430,14 +2424,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
try {
|
||||
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
|
||||
setPuzzleRun(
|
||||
dragLocalPuzzlePiece(
|
||||
puzzleRun,
|
||||
payload,
|
||||
isLocalPuzzleRun(puzzleRun) ? selectedPuzzleDetail : null,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, selectedPuzzleDetail],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2515,18 +2515,46 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const restartPuzzleCurrentLevel = useCallback(async () => {
|
||||
const currentLevel = puzzleRun?.currentLevel ?? null;
|
||||
if (!puzzleRun || !currentLevel || isPuzzleBusy) {
|
||||
const currentRun = puzzleRunRef.current ?? puzzleRun;
|
||||
const currentLevel = currentRun?.currentLevel ?? null;
|
||||
if (!currentRun || !currentLevel || isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleError(null);
|
||||
const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun);
|
||||
puzzleRunRef.current = nextRun;
|
||||
setPuzzleRun(nextRun);
|
||||
setIsPuzzleBusy(true);
|
||||
try {
|
||||
if (isLocalPuzzleRun(currentRun)) {
|
||||
const nextRun = restartLocalPuzzleLevel(currentRun);
|
||||
puzzleRunRef.current = nextRun;
|
||||
setPuzzleRun(nextRun);
|
||||
return;
|
||||
}
|
||||
|
||||
const detailItem =
|
||||
selectedPuzzleDetail?.profileId === currentLevel.profileId
|
||||
? selectedPuzzleDetail
|
||||
: await getPuzzleGalleryDetail(currentLevel.profileId).then(
|
||||
(response) => response.item,
|
||||
);
|
||||
const { run } = await startPuzzleRun({
|
||||
profileId: currentLevel.profileId,
|
||||
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
|
||||
});
|
||||
setSelectedPuzzleDetail(detailItem);
|
||||
puzzleRunRef.current = run;
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '重新开始拼图关卡失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
}, [
|
||||
isPuzzleBusy,
|
||||
puzzleRun,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
]);
|
||||
|
||||
@@ -2565,14 +2593,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
gameState.currentLevelId.trim()
|
||||
? gameState.currentLevelId
|
||||
: null;
|
||||
const item = selectedPuzzleDetail?.profileId === profileId
|
||||
? selectedPuzzleDetail
|
||||
: await getPuzzleGalleryDetail(profileId).then((response) => response.item);
|
||||
const nextRun = startLocalPuzzleRun(item, levelId);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(nextRun);
|
||||
setPuzzleRuntimeReturnStage('platform');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
const item =
|
||||
selectedPuzzleDetail?.profileId === profileId
|
||||
? selectedPuzzleDetail
|
||||
: await getPuzzleGalleryDetail(profileId).then(
|
||||
(response) => response.item,
|
||||
);
|
||||
await startPuzzleRunFromProfile(
|
||||
item.profileId,
|
||||
'platform',
|
||||
item,
|
||||
false,
|
||||
levelId,
|
||||
);
|
||||
} catch (error) {
|
||||
platformBootstrap.setSaveError(
|
||||
resolvePuzzleErrorMessage(error, '恢复拼图存档失败。'),
|
||||
@@ -2587,7 +2620,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectedPuzzleDetail,
|
||||
resolvePuzzleErrorMessage,
|
||||
setPuzzleError,
|
||||
setSelectionStage,
|
||||
startPuzzleRunFromProfile,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2651,7 +2684,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
const advancePuzzleLevel = useCallback(
|
||||
async (target?: { profileId?: string; levelId?: string | null }) => {
|
||||
async (_target?: { profileId?: string; levelId?: string | null }) => {
|
||||
if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) {
|
||||
return;
|
||||
}
|
||||
@@ -2665,12 +2698,43 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const nextRun = advanceLocalPuzzleLevel(
|
||||
puzzleRun,
|
||||
selectedPuzzleDetail,
|
||||
target,
|
||||
);
|
||||
setPuzzleRun(nextRun);
|
||||
if (isLocalPuzzleRun(puzzleRun)) {
|
||||
const nextRun = advanceLocalPuzzleLevel(
|
||||
puzzleRun,
|
||||
selectedPuzzleDetail,
|
||||
_target,
|
||||
);
|
||||
setPuzzleRun(nextRun);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetProfileId = _target?.profileId?.trim() ?? '';
|
||||
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
|
||||
const itemPromise =
|
||||
selectedPuzzleDetail?.profileId === targetProfileId
|
||||
? Promise.resolve(selectedPuzzleDetail)
|
||||
: getPuzzleGalleryDetail(targetProfileId).then(
|
||||
(response) => response.item,
|
||||
);
|
||||
const [{ run }, item] = await Promise.all([
|
||||
advancePuzzleNextLevel(puzzleRun.runId, {
|
||||
targetProfileId,
|
||||
}),
|
||||
itemPromise,
|
||||
]);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath(
|
||||
'puzzle-runtime',
|
||||
buildPuzzlePublicWorkCode(item.profileId),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||
} finally {
|
||||
@@ -3563,7 +3627,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) {
|
||||
const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
|
||||
const work =
|
||||
selectedPuzzleDetail?.profileId === selectedPublicWorkDetail.profileId
|
||||
? selectedPuzzleDetail
|
||||
: mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
|
||||
if (!work) {
|
||||
setPublicWorkDetailError(
|
||||
'当前拼图作品信息不完整,暂时无法进入玩法。',
|
||||
@@ -3628,10 +3695,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
isPublicWorkDetailBusy,
|
||||
runProtectedAction,
|
||||
selectedDetailEntry,
|
||||
selectedPuzzleDetail,
|
||||
selectedPublicWorkDetail,
|
||||
startBigFishRunFromWork,
|
||||
startMatch3DRunFromProfile,
|
||||
startPuzzleRunFromProfile,
|
||||
startMatch3DRunFromProfile,
|
||||
]);
|
||||
|
||||
const remixPublicWork = useCallback(
|
||||
|
||||
@@ -132,7 +132,7 @@ function syncDraftFromEditState(
|
||||
workTitle: editState.workTitle.trim() || draft.workTitle,
|
||||
workDescription: editState.workDescription.trim(),
|
||||
levelName: primaryLevel.levelName,
|
||||
summary: primaryLevel.pictureDescription,
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
candidates: primaryLevel.candidates,
|
||||
selectedCandidateId: primaryLevel.selectedCandidateId,
|
||||
@@ -1378,7 +1378,7 @@ export function PuzzleResultView({
|
||||
workTitle: normalizedState.workTitle,
|
||||
workDescription: normalizedState.workDescription,
|
||||
levelName: firstLevel.levelName,
|
||||
summary: firstLevel.pictureDescription,
|
||||
summary: normalizedState.workDescription,
|
||||
themeTags: normalizedState.themeTags,
|
||||
coverImageSrc: resolveLevelFormalImageSrc(firstLevel) || null,
|
||||
coverAssetId: firstLevel.coverAssetId ?? null,
|
||||
@@ -1531,7 +1531,7 @@ export function PuzzleResultView({
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
levelName: firstLevel.levelName.trim(),
|
||||
summary: firstLevel.pictureDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(editState.levels),
|
||||
});
|
||||
@@ -1555,7 +1555,7 @@ export function PuzzleResultView({
|
||||
candidateCount: 1,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: activeLevel.pictureDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(editState.levels),
|
||||
});
|
||||
|
||||
@@ -1743,26 +1743,30 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
{isExitRemodelPromptOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/72 px-4 py-6 backdrop-blur-sm"
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/76 px-4 py-6 backdrop-blur-md"
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-exit-remodel-title"
|
||||
className="flex w-full max-w-[22rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
|
||||
className="relative flex w-full max-w-[21rem] flex-col overflow-hidden rounded-[1.35rem] border border-amber-200/24 bg-[linear-gradient(180deg,rgba(30,41,59,0.98),rgba(2,6,23,0.98))] shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="px-5 pt-6 text-center">
|
||||
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-amber-200/70 to-transparent" />
|
||||
<header className="flex flex-col items-center px-6 pt-7 text-center">
|
||||
<div className="mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-amber-200/28 bg-amber-200/12 shadow-[0_16px_42px_rgba(251,191,36,0.18)]">
|
||||
<Sparkles className="h-7 w-7 text-amber-200" />
|
||||
</div>
|
||||
<h2
|
||||
id="puzzle-exit-remodel-title"
|
||||
className="text-2xl font-black leading-tight text-white"
|
||||
className="text-[1.75rem] font-black leading-[1.08] text-white"
|
||||
>
|
||||
体验不佳?
|
||||
<br />
|
||||
试试改造功能!
|
||||
</h2>
|
||||
</header>
|
||||
<footer className="grid gap-3 px-5 py-5">
|
||||
<footer className="grid gap-3 px-5 pb-5 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
@@ -1770,7 +1774,7 @@ export function PuzzleRuntimeShell({
|
||||
setIsExitRemodelPromptOpen(false);
|
||||
void onRemodelWork?.(exitPromptProfileId);
|
||||
}}
|
||||
className="rounded-full bg-amber-200 px-5 py-3 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="min-h-[3.25rem] rounded-2xl bg-amber-200 px-5 text-sm font-black text-slate-950 shadow-[0_14px_34px_rgba(251,191,36,0.24)] transition hover:bg-amber-100 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
作品改造
|
||||
</button>
|
||||
@@ -1780,7 +1784,7 @@ export function PuzzleRuntimeShell({
|
||||
setIsExitRemodelPromptOpen(false);
|
||||
onBack();
|
||||
}}
|
||||
className="rounded-full border border-white/14 bg-black/24 px-5 py-3 text-sm font-black text-white transition hover:bg-white/10"
|
||||
className="min-h-[3rem] rounded-2xl border border-white/14 bg-white/8 px-5 text-sm font-bold text-white/92 transition hover:bg-white/12 active:translate-y-px"
|
||||
>
|
||||
保存并退出
|
||||
</button>
|
||||
|
||||
@@ -1782,6 +1782,21 @@ beforeEach(() => {
|
||||
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
|
||||
new Error('未启用拼图 remix'),
|
||||
);
|
||||
vi.mocked(startPuzzleRun).mockImplementation(async (payload) => {
|
||||
const run = buildMockPuzzleRun(payload.profileId, '后端拼图关卡');
|
||||
return {
|
||||
run: {
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
levelId: payload.levelId ?? run.currentLevel.levelId,
|
||||
startedAtMs: Date.now(),
|
||||
}
|
||||
: run.currentLevel,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mocked(advancePuzzleNextLevel).mockImplementation(async (runId) => ({
|
||||
run: buildMockPuzzleRun(`${runId}-next-profile`, '后端推荐下一关'),
|
||||
}));
|
||||
@@ -2496,12 +2511,6 @@ test('published puzzle works appear on home and mobile game category channel', a
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: buildMockPuzzleRun(
|
||||
publishedPuzzleWork.profileId,
|
||||
publishedPuzzleWork.levelName,
|
||||
),
|
||||
});
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
@@ -2587,12 +2596,6 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: buildMockPuzzleRun(
|
||||
publishedPuzzleWork.profileId,
|
||||
publishedPuzzleWork.levelName,
|
||||
),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
@@ -2959,63 +2962,24 @@ test('published puzzle work card restores its source session for editing', async
|
||||
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('formal puzzle next level uses backend run and leaderboard keeps frontend level snapshot', async () => {
|
||||
test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstLevelLeaderboardEntries = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '测试玩家',
|
||||
elapsedMs: 12_000,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
];
|
||||
const firstLevel = buildClearedPuzzleRun({
|
||||
const clearedFirstLevel = buildClearedPuzzleRun({
|
||||
runId: 'run-puzzle-profile-public-1',
|
||||
entryProfileId: 'puzzle-profile-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelName: '雨夜猫塔',
|
||||
levelIndex: 1,
|
||||
elapsedMs: 12_000,
|
||||
recommendedNextProfileId: 'puzzle-profile-public-2',
|
||||
leaderboardEntries: firstLevelLeaderboardEntries,
|
||||
elapsedMs: 18_000,
|
||||
});
|
||||
const secondLevelBase = buildMockPuzzleRun(
|
||||
'puzzle-profile-public-2',
|
||||
'星桥机关',
|
||||
);
|
||||
const secondLevel: PuzzleRunSnapshot = {
|
||||
...secondLevelBase,
|
||||
runId: firstLevel.runId,
|
||||
entryProfileId: firstLevel.entryProfileId,
|
||||
currentLevelIndex: 2,
|
||||
playedProfileIds: [
|
||||
'puzzle-profile-public-1',
|
||||
'puzzle-profile-public-2',
|
||||
],
|
||||
currentLevel: {
|
||||
...secondLevelBase.currentLevel!,
|
||||
runId: firstLevel.runId,
|
||||
levelIndex: 2,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
const clearedFirstLevelWithNext = {
|
||||
...clearedFirstLevel,
|
||||
recommendedNextProfileId: 'puzzle-profile-public-1',
|
||||
nextLevelMode: 'sameWork' as const,
|
||||
nextLevelProfileId: 'puzzle-profile-public-1',
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
};
|
||||
const clearedSecondLevel = buildClearedPuzzleRun({
|
||||
runId: firstLevel.runId,
|
||||
entryProfileId: firstLevel.entryProfileId,
|
||||
profileId: 'puzzle-profile-public-2',
|
||||
levelName: '星桥机关',
|
||||
levelIndex: 2,
|
||||
elapsedMs: 18_000,
|
||||
});
|
||||
const serviceLeaderboardRun = buildClearedPuzzleRun({
|
||||
runId: firstLevel.runId,
|
||||
entryProfileId: firstLevel.entryProfileId,
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelName: '雨夜猫塔',
|
||||
levelIndex: 1,
|
||||
elapsedMs: 18_000,
|
||||
recommendedNextProfileId: 'puzzle-profile-public-2',
|
||||
});
|
||||
const leaderboardEntries = [
|
||||
{
|
||||
rank: 1,
|
||||
@@ -3024,27 +2988,49 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({ run: firstLevel });
|
||||
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({ run: secondLevel });
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: {
|
||||
...serviceLeaderboardRun,
|
||||
const backendLeaderboardRun = {
|
||||
...clearedFirstLevelWithNext,
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...clearedFirstLevelWithNext.currentLevel!,
|
||||
leaderboardEntries,
|
||||
},
|
||||
};
|
||||
const backendSecondLevel = {
|
||||
...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关'),
|
||||
runId: clearedFirstLevel.runId,
|
||||
entryProfileId: clearedFirstLevel.entryProfileId,
|
||||
currentLevelIndex: 2,
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关')
|
||||
.currentLevel!,
|
||||
runId: clearedFirstLevel.runId,
|
||||
levelIndex: 2,
|
||||
levelId: 'puzzle-level-2',
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
const backendStartedRun = buildMockPuzzleRun(
|
||||
'puzzle-profile-public-1',
|
||||
'雨夜猫塔',
|
||||
);
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: {
|
||||
...backendStartedRun,
|
||||
currentLevel: {
|
||||
...serviceLeaderboardRun.currentLevel!,
|
||||
leaderboardEntries,
|
||||
...backendStartedRun.currentLevel!,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(dragPuzzlePieceOrGroup).mockResolvedValue({
|
||||
run: clearedSecondLevel,
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: backendLeaderboardRun,
|
||||
});
|
||||
vi.mocked(swapPuzzlePieces).mockResolvedValue({
|
||||
run: clearedSecondLevel,
|
||||
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({
|
||||
run: backendSecondLevel,
|
||||
});
|
||||
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedSecondLevel);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedSecondLevel);
|
||||
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedFirstLevel);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedFirstLevel);
|
||||
|
||||
const puzzleWork: PuzzleWorkSummary = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
@@ -3063,6 +3049,28 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
playCount: 8,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫塔',
|
||||
pictureDescription: '雨夜猫塔首关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
{
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '星桥机关第二关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
@@ -3071,7 +3079,6 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: puzzleWork,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
@@ -3082,39 +3089,216 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith({
|
||||
levelId: null,
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
});
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '下一关' }, { timeout: 3000 }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(firstLevel.runId);
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith({
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelId: null,
|
||||
});
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledTimes(1);
|
||||
expect((await screen.findAllByText('星桥机关')).length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
|
||||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitPuzzleLeaderboard).toHaveBeenCalledWith(firstLevel.runId, {
|
||||
profileId: 'puzzle-profile-public-2',
|
||||
gridSize: 3,
|
||||
elapsedMs: 18_000,
|
||||
nickname: '测试玩家',
|
||||
});
|
||||
expect(swapLocalPuzzlePieces).toHaveBeenCalled();
|
||||
});
|
||||
expect(swapPuzzlePieces).not.toHaveBeenCalled();
|
||||
expect(dragPuzzlePieceOrGroup).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(submitPuzzleLeaderboard).toHaveBeenCalledWith(
|
||||
clearedFirstLevel.runId,
|
||||
{
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
gridSize: 3,
|
||||
elapsedMs: 18_000,
|
||||
nickname: '测试玩家',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '通关完成' }, { timeout: 3000 }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
const dialog = await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: '通关完成' },
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.getByText('测试玩家')).toBeTruthy();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||||
clearedFirstLevel.runId,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
(await screen.findAllByText('星桥机关', undefined, {
|
||||
timeout: 3000,
|
||||
})).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('formal puzzle similar work keeps current run level progression', async () => {
|
||||
const user = userEvent.setup();
|
||||
const clearedThirdLevel = buildClearedPuzzleRun({
|
||||
runId: 'run-puzzle-profile-public-1',
|
||||
entryProfileId: 'puzzle-profile-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelName: '雨夜猫塔',
|
||||
levelIndex: 3,
|
||||
elapsedMs: 18_000,
|
||||
recommendedNextProfileId: 'puzzle-profile-similar-2',
|
||||
});
|
||||
const clearedThirdLevelWithCandidates: PuzzleRunSnapshot = {
|
||||
...clearedThirdLevel,
|
||||
nextLevelMode: 'similarWorks',
|
||||
nextLevelProfileId: 'puzzle-profile-similar-1',
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [
|
||||
{
|
||||
profileId: 'puzzle-profile-similar-1',
|
||||
levelName: '雾海遗迹',
|
||||
authorDisplayName: '星桥旅人',
|
||||
themeTags: ['奇幻', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
similarityScore: 0.91,
|
||||
},
|
||||
{
|
||||
profileId: 'puzzle-profile-similar-2',
|
||||
levelName: '风塔试炼',
|
||||
authorDisplayName: '晨风',
|
||||
themeTags: ['奇幻', '机关'],
|
||||
coverImageSrc: null,
|
||||
similarityScore: 0.84,
|
||||
},
|
||||
],
|
||||
};
|
||||
const similarFourthLevel = {
|
||||
...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼'),
|
||||
runId: clearedThirdLevel.runId,
|
||||
entryProfileId: clearedThirdLevel.entryProfileId,
|
||||
currentLevelIndex: 4,
|
||||
currentGridSize: 5 as const,
|
||||
playedProfileIds: [
|
||||
'puzzle-profile-public-1',
|
||||
'puzzle-profile-similar-2',
|
||||
],
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼')
|
||||
.currentLevel!,
|
||||
runId: clearedThirdLevel.runId,
|
||||
levelIndex: 4,
|
||||
levelId: 'similar-level-1',
|
||||
gridSize: 5 as const,
|
||||
timeLimitMs: 210_000,
|
||||
remainingMs: 210_000,
|
||||
startedAtMs: Date.now(),
|
||||
board: {
|
||||
rows: 5,
|
||||
cols: 5,
|
||||
selectedPieceId: null,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [],
|
||||
pieces: Array.from({ length: 25 }, (_, index) => ({
|
||||
pieceId: `piece-${index}`,
|
||||
correctRow: Math.floor(index / 5),
|
||||
correctCol: index % 5,
|
||||
currentRow: Math.floor(index / 5),
|
||||
currentCol: index % 5,
|
||||
mergedGroupId: null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
const backendStartedRun = buildMockPuzzleRun(
|
||||
'puzzle-profile-public-1',
|
||||
'雨夜猫塔',
|
||||
);
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: {
|
||||
...backendStartedRun,
|
||||
currentLevel: {
|
||||
...backendStartedRun.currentLevel!,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: clearedThirdLevelWithCandidates,
|
||||
});
|
||||
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({
|
||||
run: similarFourthLevel,
|
||||
});
|
||||
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedThirdLevel);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedThirdLevel);
|
||||
|
||||
const entryWork: PuzzleWorkSummary = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||||
playCount: 8,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
const similarWork: PuzzleWorkSummary = {
|
||||
...entryWork,
|
||||
workId: 'puzzle-work-similar-2',
|
||||
profileId: 'puzzle-profile-similar-2',
|
||||
levelName: '风塔试炼',
|
||||
summary: '另一套奇幻机关拼图。',
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [entryWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === similarWork.profileId ? similarWork : entryWork,
|
||||
}));
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockClear();
|
||||
|
||||
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
|
||||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
||||
|
||||
const dialog = await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: '通关完成' },
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(within(dialog).getByRole('button', { name: /风塔试炼/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||||
clearedThirdLevel.runId,
|
||||
{ targetProfileId: 'puzzle-profile-similar-2' },
|
||||
);
|
||||
});
|
||||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('第 4 关')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll('[data-piece-id]').length).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
test('first puzzle runtime back click can open remix result page', async () => {
|
||||
@@ -3177,9 +3361,6 @@ test('first puzzle runtime back click can open remix result page', async () => {
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: puzzleWork,
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: buildMockPuzzleRun(puzzleWork.profileId, puzzleWork.levelName),
|
||||
});
|
||||
vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({
|
||||
session: remixSession,
|
||||
});
|
||||
@@ -4795,7 +4976,7 @@ test('creation hub published work experience button enters world directly', asyn
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creation hub published work card no longer exposes direct delete action', async () => {
|
||||
test('creation hub published work card keeps delete action guarded by detail flow', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const publishedWork = {
|
||||
@@ -4867,6 +5048,6 @@ test('creation hub published work card no longer exposes direct delete action',
|
||||
await openCreationHub(user);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
||||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
24
src/config/viteProxyConfig.test.ts
Normal file
24
src/config/viteProxyConfig.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import viteConfig from '../../vite.config';
|
||||
|
||||
describe('vite dev api proxy', () => {
|
||||
it('forwards the profile main route to the Rust API server', async () => {
|
||||
const resolvedConfig =
|
||||
typeof viteConfig === 'function'
|
||||
? await viteConfig({ command: 'serve', mode: 'test' })
|
||||
: viteConfig;
|
||||
|
||||
// 中文注释:`/api/profile/*` 是“我的”和“存档”页面的主链路由;
|
||||
// 本地 Vite 若漏配代理,会把请求回退到 index.html,前端再按 JSON 解析就会报 `Unexpected token '<'`。
|
||||
expect(resolvedConfig.server?.proxy).toEqual(
|
||||
expect.objectContaining({
|
||||
'/api/profile': expect.objectContaining({
|
||||
target: expect.any(String),
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
|
||||
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
|
||||
@@ -53,10 +54,6 @@ function resolvePuzzleLevelConfig(levelIndex: number): PuzzleLevelConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||
return resolvePuzzleLevelConfig(clearedLevelCount + 1).gridSize;
|
||||
}
|
||||
|
||||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64;
|
||||
|
||||
function buildLocalPuzzleRunId(profileId: string) {
|
||||
@@ -724,20 +721,88 @@ function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
|
||||
}
|
||||
|
||||
// 本地兜底只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
|
||||
function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
function resolveWorkLevelIndexById(
|
||||
levels: PuzzleDraftLevel[] | undefined,
|
||||
levelId: string | null | undefined,
|
||||
) {
|
||||
if (!levelId) {
|
||||
return -1;
|
||||
}
|
||||
return levels?.findIndex((level) => level.levelId === levelId) ?? -1;
|
||||
}
|
||||
|
||||
function resolveWorkLevelById(
|
||||
levels: PuzzleDraftLevel[] | undefined,
|
||||
levelId: string | null | undefined,
|
||||
) {
|
||||
const levelIndex = resolveWorkLevelIndexById(levels, levelId);
|
||||
return levelIndex >= 0 ? (levels?.[levelIndex] ?? null) : null;
|
||||
}
|
||||
|
||||
function resolveNextSameWorkLevel(
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
currentLevel: PuzzleRuntimeLevelSnapshot,
|
||||
) {
|
||||
const levels = work?.levels;
|
||||
if (!levels?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentLevelIndexById = resolveWorkLevelIndexById(
|
||||
levels,
|
||||
currentLevel.levelId,
|
||||
);
|
||||
const nextLevelIndex =
|
||||
currentLevelIndexById >= 0
|
||||
? currentLevelIndexById + 1
|
||||
: currentLevel.levelIndex;
|
||||
return levels[nextLevelIndex] ?? null;
|
||||
}
|
||||
|
||||
function applyLocalNextLevelHandoff(
|
||||
run: PuzzleRunSnapshot,
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
currentLevel: PuzzleRuntimeLevelSnapshot,
|
||||
) {
|
||||
const nextLevel = resolveNextSameWorkLevel(work, currentLevel);
|
||||
return {
|
||||
...run,
|
||||
nextLevelMode: nextLevel ? ('sameWork' as const) : ('none' as const),
|
||||
nextLevelProfileId: nextLevel ? currentLevel.profileId : null,
|
||||
nextLevelId: nextLevel?.levelId ?? null,
|
||||
recommendedNextProfileId: nextLevel ? currentLevel.profileId : null,
|
||||
recommendedNextWorks: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackLocalLevel(
|
||||
run: PuzzleRunSnapshot,
|
||||
work?: PuzzleWorkSummary | null,
|
||||
target?: { profileId?: string; levelId?: string | null },
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'cleared') {
|
||||
return run;
|
||||
}
|
||||
|
||||
const nextLevelIndex = run.currentLevelIndex + 1;
|
||||
const gridSize = resolvePuzzleGridSize(run.clearedLevelCount);
|
||||
const gridSize = resolvePuzzleLevelConfig(nextLevelIndex).gridSize;
|
||||
const nextProfileId =
|
||||
run.recommendedNextProfileId ??
|
||||
target?.profileId?.trim() ||
|
||||
run.nextLevelProfileId ||
|
||||
run.recommendedNextProfileId ||
|
||||
buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
|
||||
const nextLevel =
|
||||
resolveWorkLevelById(work?.levels, target?.levelId ?? run.nextLevelId) ??
|
||||
resolveNextSameWorkLevel(work, currentLevel);
|
||||
const startedAtMs = Date.now();
|
||||
const nextLevelName =
|
||||
nextLevel?.levelName ??
|
||||
buildLocalLevelName(currentLevel.levelName, nextLevelIndex);
|
||||
const nextCoverImageSrc =
|
||||
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
|
||||
|
||||
return {
|
||||
const nextRun: PuzzleRunSnapshot = {
|
||||
...run,
|
||||
currentLevelIndex: nextLevelIndex,
|
||||
currentGridSize: gridSize,
|
||||
@@ -749,10 +814,10 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
...currentLevel,
|
||||
runId: run.runId,
|
||||
levelIndex: nextLevelIndex,
|
||||
levelId: null,
|
||||
levelId: nextLevel?.levelId ?? null,
|
||||
gridSize,
|
||||
profileId: nextProfileId,
|
||||
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
|
||||
levelName: nextLevelName,
|
||||
board: buildInitialBoard(
|
||||
gridSize,
|
||||
run.runId,
|
||||
@@ -763,28 +828,32 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
coverImageSrc: nextCoverImageSrc,
|
||||
...buildLevelTimerFields(nextLevelIndex),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'none',
|
||||
nextLevelProfileId: null,
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
|
||||
if (!nextRun.currentLevel) {
|
||||
return nextRun;
|
||||
}
|
||||
return applyLocalNextLevelHandoff(nextRun, work, nextRun.currentLevel);
|
||||
}
|
||||
|
||||
export function startLocalPuzzleRun(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleGridSize(0);
|
||||
const gridSize = resolvePuzzleLevelConfig(1).gridSize;
|
||||
const runId = buildLocalPuzzleRunId(item.profileId);
|
||||
const startedAtMs = Date.now();
|
||||
const firstLevel = item.levels?.[0] ?? null;
|
||||
const requestedLevelIndex = resolveWorkLevelIndexById(item.levels, levelId);
|
||||
const currentLevelIndex = requestedLevelIndex >= 0 ? requestedLevelIndex : 0;
|
||||
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
|
||||
const firstLevelName = firstLevel?.levelName || item.levelName;
|
||||
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
|
||||
const secondLevel = item.levels?.[1] ?? null;
|
||||
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
|
||||
return {
|
||||
runId,
|
||||
entryProfileId: item.profileId,
|
||||
@@ -811,10 +880,10 @@ export function startLocalPuzzleRun(
|
||||
...buildLevelTimerFields(1),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: secondLevel ? 'sameWork' : 'none',
|
||||
nextLevelProfileId: secondLevel ? item.profileId : null,
|
||||
nextLevelId: secondLevel?.levelId ?? null,
|
||||
recommendedNextProfileId: nextSameWorkLevel ? item.profileId : null,
|
||||
nextLevelMode: nextSameWorkLevel ? 'sameWork' : 'none',
|
||||
nextLevelProfileId: nextSameWorkLevel ? item.profileId : null,
|
||||
nextLevelId: nextSameWorkLevel?.levelId ?? null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
@@ -823,6 +892,7 @@ export function startLocalPuzzleRun(
|
||||
export function swapLocalPuzzlePieces(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
work?: PuzzleWorkSummary | null,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
@@ -843,10 +913,13 @@ export function swapLocalPuzzlePieces(
|
||||
second.currentRow = firstPosition.row;
|
||||
second.currentCol = firstPosition.col;
|
||||
|
||||
return applyNextBoard(
|
||||
const nextRun = applyNextBoard(
|
||||
timedRun,
|
||||
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
||||
);
|
||||
return nextRun.currentLevel?.status === 'cleared'
|
||||
? syncLocalPuzzleRunHandoff(nextRun, work)
|
||||
: nextRun;
|
||||
}
|
||||
|
||||
function dragSinglePiece(
|
||||
@@ -968,6 +1041,7 @@ function dragGroup(
|
||||
export function dragLocalPuzzlePiece(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
work?: PuzzleWorkSummary | null,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
@@ -1003,16 +1077,32 @@ export function dragLocalPuzzlePiece(
|
||||
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
|
||||
}
|
||||
|
||||
return applyNextBoard(
|
||||
const nextRun = applyNextBoard(
|
||||
timedRun,
|
||||
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
||||
);
|
||||
return nextRun.currentLevel?.status === 'cleared'
|
||||
? syncLocalPuzzleRunHandoff(nextRun, work)
|
||||
: nextRun;
|
||||
}
|
||||
|
||||
export function advanceLocalPuzzleLevel(
|
||||
run: PuzzleRunSnapshot,
|
||||
work?: PuzzleWorkSummary | null,
|
||||
target?: { profileId?: string; levelId?: string | null },
|
||||
): PuzzleRunSnapshot {
|
||||
return buildFallbackLocalLevel(run);
|
||||
return buildFallbackLocalLevel(run, work, target);
|
||||
}
|
||||
|
||||
export function syncLocalPuzzleRunHandoff(
|
||||
run: PuzzleRunSnapshot,
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel) {
|
||||
return run;
|
||||
}
|
||||
return applyLocalNextLevelHandoff(run, work, currentLevel);
|
||||
}
|
||||
|
||||
export function restartLocalPuzzleLevel(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
AdvancePuzzleNextLevelRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
@@ -101,11 +102,21 @@ export async function dragPuzzlePieceOrGroup(
|
||||
/**
|
||||
* 进入推荐出的下一关。
|
||||
*/
|
||||
export async function advancePuzzleNextLevel(runId: string) {
|
||||
export async function advancePuzzleNextLevel(
|
||||
runId: string,
|
||||
payload: AdvancePuzzleNextLevelRequest = {},
|
||||
) {
|
||||
const targetProfileId = payload.targetProfileId?.trim() ?? '';
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
|
||||
{
|
||||
method: 'POST',
|
||||
...(targetProfileId
|
||||
? {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targetProfileId }),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
'进入下一关失败',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user