Merge remote-tracking branch 'origin/master' into codex/fix-draft-result-back-target

This commit is contained in:
kdletters
2026-05-26 16:45:57 +08:00
47 changed files with 2678 additions and 79 deletions

View File

@@ -705,7 +705,10 @@ function isRecommendRuntimeReadyForEntry(
return Boolean(state.match3dRun);
}
if (expectedKind === 'puzzle') {
return Boolean(state.puzzleRun);
return (
state.puzzleRun?.entryProfileId === entry.profileId ||
state.puzzleRun?.currentLevel?.profileId === entry.profileId
);
}
if (expectedKind === 'square-hole') {
return Boolean(state.squareHoleRun);
@@ -3149,6 +3152,7 @@ export function PlatformEntryFlowShellImpl({
useState<PuzzleRuntimeAuthMode>('default');
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
const puzzleStartInFlightKeyRef = useRef<string | null>(null);
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const puzzleRunRef = useRef<PuzzleRunSnapshot | null>(null);
const errorSetterRefNoop = useMemo(
@@ -9105,10 +9109,13 @@ export function PlatformEntryFlowShellImpl({
levelId?: string | null,
options: { embedded?: boolean; authMode?: PuzzleRuntimeAuthMode } = {},
) => {
if (isPuzzleBusy) {
const normalizedLevelId = levelId?.trim() ?? '';
const startKey = `${profileId}:${normalizedLevelId}`;
if (isPuzzleBusy || puzzleStartInFlightKeyRef.current === startKey) {
return false;
}
puzzleStartInFlightKeyRef.current = startKey;
setIsPuzzleBusy(true);
setPuzzleError(null);
@@ -9117,7 +9124,7 @@ export function PlatformEntryFlowShellImpl({
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
const startRunPayload = {
profileId: item.profileId,
levelId: levelId ?? null,
levelId: normalizedLevelId || null,
};
const canUseRuntimeGuestAuth =
options.embedded || options.authMode === 'isolated';
@@ -9171,6 +9178,9 @@ export function PlatformEntryFlowShellImpl({
}
return false;
} finally {
if (puzzleStartInFlightKeyRef.current === startKey) {
puzzleStartInFlightKeyRef.current = null;
}
setIsPuzzleBusy(false);
}
},
@@ -9949,7 +9959,13 @@ export function PlatformEntryFlowShellImpl({
return;
}
const submitKey = `${puzzleRun.runId}:${currentLevel.profileId}:${currentLevel.gridSize}:${currentLevel.elapsedMs}`;
const submitKey = [
puzzleRun.runId,
currentLevel.profileId,
currentLevel.levelId ?? currentLevel.levelIndex,
currentLevel.gridSize,
currentLevel.elapsedMs,
].join(':');
if (submittedPuzzleLeaderboardKeysRef.current.has(submitKey)) {
return;
}
@@ -9987,7 +10003,6 @@ export function PlatformEntryFlowShellImpl({
void platformBootstrap.refreshSaveArchives();
})
.catch((error) => {
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
setPuzzleError(
resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'),
);
@@ -10034,6 +10049,10 @@ 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
@@ -10073,10 +10092,35 @@ export function PlatformEntryFlowShellImpl({
puzzleRuntimeAuthMode === 'isolated'
? await advancePuzzleNextLevel(
puzzleRun.runId,
{},
preferSimilarWork ? { preferSimilarWork: true } : {},
runtimeGuestOptions,
)
: await advancePuzzleNextLevel(puzzleRun.runId);
: 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),
);
}
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
@@ -10088,8 +10132,12 @@ export function PlatformEntryFlowShellImpl({
[
isPuzzleBusy,
isPuzzleLeaderboardBusy,
activeRecommendRuntimeKind,
puzzleRun,
puzzleRuntimeReturnStage,
puzzleRuntimeAuthMode,
setActiveRecommendEntryKey,
setPuzzleGalleryEntries,
resolvePuzzleErrorMessage,
selectedPuzzleDetail,
setIsPuzzleBusy,
@@ -12489,6 +12537,10 @@ export function PlatformEntryFlowShellImpl({
recommendRuntimeStartRequestRef.current = startRequestId;
const isCurrentStartRequest = () =>
recommendRuntimeStartRequestRef.current === startRequestId;
setActiveRecommendEntryKey(entryKey);
setActiveRecommendRuntimeKind(runtimeKind);
setActiveRecommendRuntimeError(null);
setIsStartingRecommendEntry(true);
if (entryKey !== activeRecommendEntryKey) {
await saveAndExitRecommendPuzzleRuntime();
if (!isCurrentStartRequest()) {
@@ -12641,14 +12693,16 @@ export function PlatformEntryFlowShellImpl({
],
);
const selectAdjacentRecommendRuntimeEntry = useCallback(
(direction: 1 | -1) => {
(direction: 1 | -1, baseEntryKey?: string | null) => {
if (recommendRuntimeEntries.length === 0) {
return;
}
const normalizedBaseEntryKey =
baseEntryKey?.trim() || activeRecommendEntryKey;
const activeIndex = recommendRuntimeEntries.findIndex(
(entry) =>
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
getPlatformPublicGalleryEntryKey(entry) === normalizedBaseEntryKey,
);
const baseIndex = activeIndex >= 0 ? activeIndex : 0;
const nextIndex =
@@ -12659,7 +12713,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (
getPlatformPublicGalleryEntryKey(nextEntry) === activeRecommendEntryKey
getPlatformPublicGalleryEntryKey(nextEntry) === normalizedBaseEntryKey
) {
return;
}
@@ -14437,18 +14491,19 @@ export function PlatformEntryFlowShellImpl({
isStartingRecommendEntry={
isStartingRecommendEntry ||
isBigFishBusy ||
isPuzzleBusy ||
(isPuzzleBusy &&
!(activeRecommendRuntimeKind === 'puzzle' && puzzleRun)) ||
isMatch3DBusy ||
isSquareHoleBusy ||
isVisualNovelBusy ||
isWoodenFishBusy
}
recommendRuntimeError={activeRecommendRuntimeError}
onSelectNextRecommendEntry={() =>
selectAdjacentRecommendRuntimeEntry(1)
onSelectNextRecommendEntry={(activeEntryKey) =>
selectAdjacentRecommendRuntimeEntry(1, activeEntryKey)
}
onSelectPreviousRecommendEntry={() =>
selectAdjacentRecommendRuntimeEntry(-1)
onSelectPreviousRecommendEntry={(activeEntryKey) =>
selectAdjacentRecommendRuntimeEntry(-1, activeEntryKey)
}
onLikeRecommendEntry={(entry) => {
likePublicWork(entry);

View File

@@ -882,6 +882,7 @@ test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关
expect(nextSprite).toBeTruthy();
expect(nextSprite?.style.backgroundSize).toBe('320% 480%');
expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%');
expect(screen.getByRole('button', { name: '下一关' }).textContent).toBe('');
expect(
screen
.getByRole('button', { name: '提示' })
@@ -971,6 +972,11 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
nextLevelProfileId: 'profile-1',
nextLevelId: 'puzzle-level-2',
recommendedNextWorks: [],
currentLevel: {
...clearedRun.currentLevel!,
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
},
};
renderPuzzleRuntime(
@@ -986,7 +992,9 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
act(() => {
vi.advanceTimersByTime(1_400);
});
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
act(() => {
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
});
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
const nextButton = screen.getByRole('button', { name: //u });
@@ -1002,6 +1010,53 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useRealTimers();
});
test('推荐页关闭通关弹窗后保留底部下一关入口且不叠加下一关素材图', async () => {
vi.useFakeTimers();
const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: null,
nextLevelMode: 'sameWork',
nextLevelProfileId: 'profile-1',
nextLevelId: 'puzzle-level-2',
recommendedNextWorks: [],
currentLevel: {
...clearedRun.currentLevel!,
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithoutRecommendedNextProfile}
embedded
hideBackButton
hideExitControls
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
act(() => {
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
});
await act(async () => {});
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
const nextButton = screen.getByRole('button', { name: //u });
expect(nextButton).toBeTruthy();
expect(
nextButton.querySelector('[data-puzzle-ui-sprite="next"]'),
).toBeTruthy();
expect(nextButton.textContent?.trim()).toBe('');
vi.useRealTimers();
});
test('当前作品没有下一关时展示三个相似作品并可选择进入', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();

View File

@@ -1933,6 +1933,7 @@ export function PuzzleRuntimeShell({
<button
type="button"
disabled={isBusy}
aria-label={hasSimilarWorkChoices ? '换个作品' : '下一关'}
onClick={() => {
if (hasSimilarWorkChoices) {
setDismissedClearKey(null);
@@ -1944,9 +1945,8 @@ export function PuzzleRuntimeShell({
levelId: run.nextLevelId ?? null,
});
}}
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center justify-center rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
>
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="next"

View File

@@ -6367,6 +6367,260 @@ test('home recommendation keeps logged-in puzzle start on default auth instead o
);
});
test('logged out home recommendation next starts the next puzzle work', async () => {
const user = userEvent.setup();
const firstWork = {
workId: 'puzzle-work-public-next-1',
profileId: 'puzzle-profile-public-next-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-public-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-public-next-2',
profileId: 'puzzle-profile-public-next-2',
ownerUserId: 'user-3',
sourceSessionId: 'puzzle-session-public-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;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [firstWork, secondWork],
});
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
item: profileId === secondWork.profileId ? secondWork : firstWork,
}));
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: (action) => action(),
})}
/>,
);
const recommendNavButton = document.querySelector<HTMLButtonElement>(
'.platform-bottom-nav [aria-label="推荐"]',
);
expect(recommendNavButton).toBeTruthy();
await user.click(recommendNavButton!);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: firstWork.profileId,
levelId: null,
},
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
}),
);
});
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
await user.click(await screen.findByRole('button', { name: '下一个' }));
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: secondWork.profileId,
levelId: null,
},
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
}),
);
});
});
test('home recommendation puzzle next level switches to similar work detail', async () => {
const user = userEvent.setup();
const entryWork = {
workId: 'puzzle-work-public-guest-1',
profileId: 'puzzle-profile-public-guest-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-public-guest-1',
authorDisplayName: '拼图作者',
levelName: '雨夜猫塔',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
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',
},
],
} satisfies PuzzleWorkSummary;
const similarWork = {
...entryWork,
workId: 'puzzle-work-similar-guest-1',
profileId: 'puzzle-profile-similar-guest-1',
levelName: '风塔试炼',
summary: '另一套奇幻机关拼图。',
levels: [
{
levelId: 'similar-level-1',
levelName: '风塔试炼',
pictureDescription: '相似作品首关。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'ready',
},
],
} satisfies PuzzleWorkSummary;
const clearedRun = buildClearedPuzzleRun({
runId: 'run-puzzle-profile-public-guest-1',
entryProfileId: entryWork.profileId,
profileId: entryWork.profileId,
levelName: entryWork.levelName,
levelIndex: 1,
elapsedMs: 18_000,
});
const clearedRunWithSameWorkNext: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: entryWork.profileId,
nextLevelMode: 'sameWork',
nextLevelProfileId: entryWork.profileId,
nextLevelId: 'puzzle-level-2',
recommendedNextWorks: [],
};
const startedRun = buildMockPuzzleRun(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(),
},
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [entryWork],
});
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
item: profileId === similarWork.profileId ? similarWork : entryWork,
}));
vi.mocked(startPuzzleRun).mockResolvedValue({
run: {
...startedRun,
currentLevel: {
...startedRun.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 />);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: entryWork.profileId,
levelId: null,
},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
await waitFor(() => {
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
});
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: '下一关' }));
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedRun.runId,
{ preferSimilarWork: true },
);
});
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);
});
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-1',

View File

@@ -195,8 +195,8 @@ export interface RpgEntryHomeViewProps {
activeRecommendEntryKey?: string | null;
isStartingRecommendEntry?: boolean;
recommendRuntimeError?: string | null;
onSelectNextRecommendEntry?: () => void;
onSelectPreviousRecommendEntry?: () => void;
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onOpenLibraryDetail: (
@@ -5214,6 +5214,9 @@ export function RpgEntryHomeView({
const [recommendShareState, setRecommendShareState] = useState<
'idle' | 'copied' | 'failed'
>('idle');
const activeRecommendEntryKeyForSelection = activeRecommendEntry
? buildPublicGalleryCardKey(activeRecommendEntry)
: null;
const recommendShareResetTimerRef = useRef<number | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
@@ -5236,15 +5239,16 @@ export function RpgEntryHomeView({
);
window.setTimeout(() => {
if (direction === 1) {
onSelectNextRecommendEntry?.();
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
} else {
onSelectPreviousRecommendEntry?.();
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
}
setRecommendDragOffsetY(0);
setRecommendDragCommitDirection(null);
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
},
[
activeRecommendEntryKeyForSelection,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
recommendDragCommitDirection,
@@ -5344,9 +5348,10 @@ export function RpgEntryHomeView({
return;
}
onSelectNextRecommendEntry?.();
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
}, [
activeRecommendEntry,
activeRecommendEntryKeyForSelection,
commitRecommendDrag,
isAuthenticated,
onSelectNextRecommendEntry,

View File

@@ -30,6 +30,9 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360,
retryUnsafeMethods: true,
};
const PUZZLE_RUNTIME_LEADERBOARD_RETRY: ApiRetryOptions = {
maxRetries: 0,
};
type PuzzleRuntimeRequestOptions = RuntimeGuestRequestOptions;
/**
@@ -125,16 +128,22 @@ export async function advancePuzzleNextLevel(
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
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 requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
{
method: 'POST',
...(targetProfileId
...(hasRequestPayload
? {
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify({ targetProfileId }),
body: JSON.stringify(requestPayload),
}
: {
headers: buildRuntimeGuestHeaders(options),
@@ -156,20 +165,20 @@ export async function submitPuzzleLeaderboard(
payload: SubmitPuzzleLeaderboardRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload),
},
'提交拼图排行榜失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY,
...requestOptions,
},
);
}

View File

@@ -17,7 +17,11 @@ import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient';
import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient';
import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient';
import { startPuzzleRun } from './puzzle-runtime/puzzleRuntimeClient';
import {
advancePuzzleNextLevel,
startPuzzleRun,
submitPuzzleLeaderboard,
} from './puzzle-runtime/puzzleRuntimeClient';
import { startSquareHoleRun } from './square-hole-runtime/squareHoleRuntimeClient';
import { startVisualNovelRun } from './visual-novel-runtime/visualNovelRuntimeClient';
@@ -87,6 +91,21 @@ describe('recommended runtime guest launch clients', () => {
),
expectedUrl: '/api/runtime/puzzle/runs',
},
{
name: 'puzzle leaderboard',
start: () =>
submitPuzzleLeaderboard(
'run-puzzle-1',
{
profileId: 'puzzle-profile-1',
gridSize: 3,
elapsedMs: 18_000,
nickname: '玩家',
},
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/leaderboard',
},
])(
'$name start request uses the runtime guest bearer token without touching login auth',
async ({ start, expectedUrl }) => {
@@ -110,4 +129,63 @@ describe('recommended runtime guest launch clients', () => {
);
},
);
it('puzzle next level can carry preferSimilarWork through the runtime guest request', async () => {
await advancePuzzleNextLevel(
'run-puzzle-1',
{ preferSimilarWork: true },
{ runtimeGuestToken: 'runtime-guest-token' },
);
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
expect(url).toBe('/api/runtime/puzzle/runs/run-puzzle-1/next-level');
expect(init).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer runtime-guest-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify({ preferSimilarWork: true }),
}),
);
expect(options).toEqual(
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
it('puzzle leaderboard submission does not retry unsafe writes', async () => {
await submitPuzzleLeaderboard(
'run-puzzle-1',
{
profileId: 'puzzle-profile-1',
gridSize: 3,
elapsedMs: 18_000,
nickname: '玩家',
},
{ runtimeGuestToken: 'runtime-guest-token' },
);
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
expect(url).toBe('/api/runtime/puzzle/runs/run-puzzle-1/leaderboard');
expect(init).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer runtime-guest-token',
'Content-Type': 'application/json',
}),
}),
);
expect(options).toEqual(
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 0,
}),
}),
);
});
});