Merge branch 'master' into codex/frontend-error-dialogs

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	server-rs/crates/api-server/src/generated_asset_sheets.rs
This commit is contained in:
kdletters
2026-05-26 22:10:41 +08:00
175 changed files with 4760 additions and 465 deletions
@@ -174,7 +174,7 @@ test('baby object result blocks placeholder assets and exposes regeneration', as
);
expect(
screen.getByText('当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。'),
screen.getByText('当前作品仍是占位资源,请重新生成素材后再试玩或发布。'),
).toBeTruthy();
expect(
(screen.getByRole('button', { name: '试玩' }) as HTMLButtonElement)
@@ -158,7 +158,7 @@ export function BabyObjectMatchResultView({
{!hasGeneratedAssets ? (
<div className="platform-banner mt-3 rounded-2xl text-sm leading-6">
image-2
</div>
) : null}
</div>
@@ -1,13 +1,6 @@
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useEffect } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
@@ -235,35 +228,58 @@ test('顶部 HUD 对齐拼图样式展示关卡名和倒计时', () => {
expect(screen.getByText('第 1 关')).toBeTruthy();
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
expect(screen.getByText('10:00')).toBeTruthy();
expect(screen.getByRole('button', { name: '打开抓大鹅设置' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '重新开始' })).toBeNull();
expect(screen.getByTestId('match3d-runtime-level-logo')).toBeTruthy();
expect(
screen.getByText('水果抓大鹅').closest('.puzzle-runtime-level-title-card'),
).toBeTruthy();
const timerCard = screen.getByText('10:00').closest('.puzzle-runtime-timer-card');
expect(timerCard).toBeTruthy();
expect(timerCard?.className).toContain('puzzle-runtime-timer');
expect(screen.queryByRole('button', { name: '打开抓大鹅设置' })).toBeNull();
expect(screen.getByRole('button', { name: '返回' })).toBeTruthy();
expect(screen.queryByTestId('match3d-ui-sprite-settings')).toBeNull();
});
test('抓大鹅右上角设置面板内置重新开始', () => {
const run = startLocalMatch3DRun(4);
const onRestart = vi.fn();
test('抓大鹅运行态不再渲染设置入口', () => {
render(
<Match3DRuntimeShell
run={run}
levelName="水果抓大鹅"
run={startLocalMatch3DRun(4)}
onBack={vi.fn()}
onRestart={onRestart}
onRestart={vi.fn()}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开抓大鹅设置' }));
const dialog = screen.getByRole('dialog', { name: '抓大鹅设置' });
expect(within(dialog).getByText('水果抓大鹅')).toBeTruthy();
expect(within(dialog).getByText('已清除 0/12')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '重新开始' }));
expect(onRestart).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('button', { name: '打开抓大鹅设置' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '抓大鹅设置' })).toBeNull();
});
test('抓大鹅顶部和底部保留交互边界但不显示旧半透底', () => {
render(
<Match3DRuntimeShell
run={startLocalMatch3DRun(4)}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
expect(screen.getByTestId('match3d-board').className).toContain(
'bg-transparent',
);
expect(screen.getAllByTestId('match3d-tray-slot')[0]!.className).toContain(
'bg-transparent',
);
expect(
screen.getByRole('button', { name: '移出' }).className,
).toContain('bg-transparent');
expect(screen.getByRole('button', { name: '返回' }).className).toContain(
'bg-transparent',
);
});
test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => {
const run: Match3DRunSnapshot = {
...startLocalMatch3DRun(4),
@@ -1548,9 +1564,7 @@ test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () =
expect(
screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'),
).toBe('data:image/png;base64,返回');
expect(
screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'),
).toBe('data:image/png;base64,设置');
expect(screen.queryByTestId('match3d-ui-sprite-settings')).toBeNull();
expect(
screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'),
).toBe('data:image/png;base64,移出');
@@ -1,9 +1,7 @@
import {
ArrowLeft,
CheckCircle2,
Clock3,
RotateCcw,
Settings,
Clock,
XCircle,
} from 'lucide-react';
import {
@@ -17,6 +15,7 @@ import {
useState,
} from 'react';
import match3DRuntimeLevelLogo from '../../../media/logo.png';
import type {
Match3DClickItemRequest,
Match3DClickItemResult,
@@ -71,19 +70,9 @@ import {
} from './match3dRuntimePresentation';
import {
MATCH3D_RUNTIME_BOARD_BASE_CLASS,
MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS,
MATCH3D_RUNTIME_BOARD_WIDTH,
MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS,
MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS,
MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS,
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
MATCH3D_RUNTIME_HEADER_CARD_CLASS,
MATCH3D_RUNTIME_LEVEL_BADGE_CLASS,
MATCH3D_RUNTIME_STAGE_CLASS,
MATCH3D_RUNTIME_TIMER_CLASS,
MATCH3D_RUNTIME_TIMER_URGENT_CLASS,
} from './match3dRuntimeUiStyles';
import { Match3DVisualIcon, resolveVisualSeed } from './match3dVisualAssets';
@@ -769,7 +758,7 @@ function Match3DTrayToken({
}) {
if (!slot.visualKey) {
return (
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
<span className="h-full w-full rounded-none border border-dashed border-white/18 bg-transparent" />
);
}
const visualSeed = resolveVisualSeed(slot.visualKey);
@@ -1030,7 +1019,6 @@ export function Match3DRuntimeShell({
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] =
useState('');
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const runtimeGeneratedItemAssets = useMemo(
@@ -1366,10 +1354,6 @@ export function Match3DRuntimeShell({
useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] =
useState('');
const [isContainerImageLoaded, setIsContainerImageLoaded] = useState(false);
const hasRenderedContainerAsset = Boolean(
resolvedContainerImageSrc && isContainerImageLoaded,
);
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
@@ -1489,7 +1473,6 @@ export function Match3DRuntimeShell({
let cancelled = false;
const controller = new AbortController();
setResolvedContainerImageSrc('');
setIsContainerImageLoaded(false);
if (!isGeneratedLegacyPath(containerAssetSrc)) {
setResolvedContainerImageSrc(containerAssetSrc);
return undefined;
@@ -1501,7 +1484,6 @@ export function Match3DRuntimeShell({
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedContainerImageSrc(resolvedSrc);
setIsContainerImageLoaded(false);
}
})
.catch(() => {
@@ -1511,7 +1493,6 @@ export function Match3DRuntimeShell({
? ''
: MATCH3D_CONTAINER_REFERENCE_SRC,
);
setIsContainerImageLoaded(false);
}
});
@@ -1937,9 +1918,8 @@ export function Match3DRuntimeShell({
const timerClassName =
timeLeftMs <= levelAudioConfig.countdownWarningThresholdMs &&
isRunState(run.status, 'running')
? MATCH3D_RUNTIME_TIMER_URGENT_CLASS
: MATCH3D_RUNTIME_TIMER_CLASS;
const canRestartRun = Boolean(run?.runId) && !isBusy;
? 'puzzle-runtime-timer--urgent'
: 'puzzle-runtime-timer';
return (
<main
@@ -1978,7 +1958,7 @@ export function Match3DRuntimeShell({
) : (
<button
type="button"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
className="flex h-10 w-10 items-center justify-center rounded-full border border-transparent bg-transparent text-white shadow-none transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 sm:h-11 sm:w-11"
onClick={onBack}
aria-label="返回"
>
@@ -1992,43 +1972,38 @@ export function Match3DRuntimeShell({
) : null}
</button>
)}
<div className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
<div className="flex max-w-full items-center justify-center gap-1.5">
<span className={MATCH3D_RUNTIME_LEVEL_BADGE_CLASS}> 1 </span>
<span className="min-w-0 truncate text-sm font-black sm:text-base">
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
<div className="puzzle-runtime-level-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:px-4 sm:pr-5">
<span aria-hidden="true" className="puzzle-runtime-level-logo">
<img
src={match3DRuntimeLevelLogo}
alt=""
data-testid="match3d-runtime-level-logo"
className="puzzle-runtime-level-logo__image"
draggable={false}
/>
</span>
<span className="puzzle-runtime-level-badge shrink-0 text-[0.92rem] font-black sm:text-base">
1
</span>
<span className="min-w-0 truncate text-[0.92rem] font-black sm:text-base">
{displayLevelName}
</span>
</div>
<div className={timerClassName}>
<Clock3 className="h-4 w-4 sm:h-5 sm:w-5" />
<div
className={`puzzle-runtime-timer-card -mt-px inline-flex items-center gap-1.5 px-3.5 py-1.5 font-mono text-lg font-black leading-none sm:text-xl ${timerClassName}`}
>
<Clock className="h-4 w-4 sm:h-5 sm:w-5" />
{formatTimer(timeLeftMs)}
</div>
</div>
<button
type="button"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={() => setIsSettingsPanelOpen(true)}
aria-label="打开抓大鹅设置"
>
<Match3DSpriteImage
region={uiSpritesheetRegionByLabel.get('设置')}
testId="match3d-ui-sprite-settings"
className="h-7 w-7 object-contain"
/>
{!uiSpritesheetRegionByLabel.get('设置') ? (
<Settings size={18} />
) : null}
</button>
<div aria-hidden="true" />
</header>
<section className={MATCH3D_RUNTIME_STAGE_CLASS}>
<div
ref={stageRef}
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} ${
hasRenderedContainerAsset
? MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS
: MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS
}`}
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} overflow-hidden rounded-[50%] border border-white/14 bg-transparent shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]`}
style={{
width: MATCH3D_RUNTIME_BOARD_WIDTH,
}}
@@ -2043,22 +2018,11 @@ export function Match3DRuntimeShell({
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className={`${MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS} ${
isContainerImageLoaded ? 'opacity-100' : 'opacity-0'
}`}
className={`${MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS} opacity-0`}
data-testid="match3d-container-image"
onLoad={() => setIsContainerImageLoaded(true)}
onError={() => {
setIsContainerImageLoaded(false);
setResolvedContainerImageSrc((currentSrc) =>
currentSrc && currentSrc !== MATCH3D_CONTAINER_REFERENCE_SRC
? MATCH3D_CONTAINER_REFERENCE_SRC
: '',
);
}}
/>
) : (
<div className={MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS} />
<div className="pointer-events-none absolute inset-0 z-0 rounded-full border border-white/10 bg-transparent" />
)}
{run.items.map((item) =>
hasPendingMatch3DGeneratedImageForItem(
@@ -2091,7 +2055,7 @@ export function Match3DRuntimeShell({
</div>
</section>
<section className={MATCH3D_RUNTIME_GLASS_TRAY_CLASS}>
<section className="mt-3 w-full min-w-0">
<div
className="relative grid grid-cols-7 gap-1.5"
data-testid="match3d-tray"
@@ -2107,7 +2071,7 @@ export function Match3DRuntimeShell({
return (
<div
key={slot.slotIndex}
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
className="relative z-0 h-14 min-w-0 rounded-none border border-transparent bg-transparent p-0 sm:h-16"
data-testid="match3d-tray-slot"
data-slot-index={slot.slotIndex}
ref={(element) => {
@@ -2170,7 +2134,7 @@ export function Match3DRuntimeShell({
<button
key={label}
type="button"
className="flex min-h-12 items-center justify-center overflow-hidden rounded-[1rem] border border-white/58 bg-white/50 px-2 py-2 text-sm font-black text-slate-800 shadow-[0_10px_24px_rgba(15,23,42,0.14)] backdrop-blur-md"
className="flex min-h-12 items-center justify-center overflow-hidden rounded-none border border-transparent bg-transparent px-1 py-2 text-sm font-black text-white shadow-none transition hover:bg-white/10"
aria-label={label}
>
<Match3DSpriteImage
@@ -2219,84 +2183,6 @@ export function Match3DRuntimeShell({
onBack={onBack}
onRestart={onRestart}
/>
{isSettingsPanelOpen ? (
<div
className="absolute inset-0 z-[85] flex items-center justify-center bg-slate-950/42 px-4 py-6 backdrop-blur-sm"
onClick={() => setIsSettingsPanelOpen(false)}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="match3d-settings-title"
className="w-full max-w-[20.5rem] overflow-hidden rounded-[1.35rem] border border-white/18 bg-white/95 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
onClick={(event) => event.stopPropagation()}
>
<header className="border-b border-slate-200 px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2
id="match3d-settings-title"
className="text-base font-black"
>
</h2>
</div>
<button
type="button"
aria-label="关闭设置"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:bg-slate-50 hover:text-slate-900"
onClick={() => setIsSettingsPanelOpen(false)}
>
<XCircle size={18} />
</button>
</div>
</header>
<div className="space-y-3 px-5 py-4">
<div className="rounded-[1rem] border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">
</div>
<div className="mt-2 text-sm font-black text-slate-900">
{displayLevelName}
</div>
<div className="mt-1 text-xs text-slate-500">
{run.clearedItemCount}/{run.totalItemCount}
</div>
</div>
<div className="rounded-[1rem] border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">
</div>
<div className="mt-2 font-mono text-xl font-black text-slate-900">
{formatTimer(timeLeftMs)}
</div>
</div>
</div>
<footer className="grid gap-3 border-t border-slate-200 px-5 py-4">
<button
type="button"
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-slate-950 px-4 py-3 text-sm font-black text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-45"
disabled={!canRestartRun}
onClick={() => {
setIsSettingsPanelOpen(false);
onRestart();
}}
>
<RotateCcw size={16} />
</button>
<button
type="button"
className="inline-flex min-h-12 items-center justify-center rounded-[1rem] border border-slate-200 bg-white px-4 py-3 text-sm font-bold text-slate-700 transition hover:bg-slate-50"
onClick={() => setIsSettingsPanelOpen(false)}
>
</button>
</footer>
</section>
</div>
) : null}
</main>
);
}
File diff suppressed because it is too large Load Diff
@@ -411,7 +411,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
});
});
test('puzzle workspace switches the image model from the description box', () => {
test('puzzle workspace switches image mode without exposing model names', () => {
const onCreateFromForm = vi.fn();
render(
@@ -427,9 +427,9 @@ test('puzzle workspace switches the image model from the description box', () =>
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
fireEvent.click(screen.getByRole('button', { name: '图片生成模式' }));
expect(screen.queryByText(/gpt|nanobanana|gemini/u)).toBeNull();
fireEvent.click(screen.getByRole('menuitemradio', { name: '创意模式' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCost();
@@ -45,8 +45,8 @@ export function PuzzleImageModelPicker({
className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="图片模型"
title="图片模型"
aria-label="图片生成模式"
title="图片生成模式"
>
<span className="truncate">
{getPuzzleImageModelLabel(normalizedValue)}
@@ -9,8 +9,8 @@ export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{
id: PuzzleImageModelId;
label: string;
}> = [
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' },
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' },
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: '标准模式' },
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: '创意模式' },
];
export function normalizePuzzleImageModel(
@@ -25,6 +25,6 @@ export function normalizePuzzleImageModel(
export function getPuzzleImageModelLabel(model: PuzzleImageModelId) {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ??
'gpt-image-2'
'标准模式'
);
}
@@ -1305,7 +1305,7 @@ describe('PuzzleResultView', () => {
expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull();
});
test('passes the selected image model when regenerating a level image', () => {
test('passes the selected image mode without exposing model names', () => {
const onExecuteAction = vi.fn();
render(
@@ -1319,9 +1319,12 @@ describe('PuzzleResultView', () => {
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' }));
fireEvent.click(
within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }),
within(dialog).getByRole('button', { name: '图片生成模式' }),
);
expect(within(dialog).queryByText(/gpt|nanobanana|gemini/u)).toBeNull();
fireEvent.click(
within(dialog).getByRole('menuitemradio', { name: '标准模式' }),
);
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
@@ -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();
@@ -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"
File diff suppressed because it is too large Load Diff
+43 -10
View File
@@ -76,6 +76,7 @@ import type {
WechatMiniProgramPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { AuthUser } from '../../services/authService';
@@ -192,8 +193,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: (
@@ -4068,6 +4069,7 @@ export function RpgEntryHomeView({
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
const hasManualCategoryTagSelectionRef = useRef(false);
const pendingPublicAuthorKeysRef = useRef<Set<string>>(new Set());
const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
Record<string, PublicUserSummary | null>
@@ -4290,16 +4292,33 @@ export function RpgEntryHomeView({
useEffect(() => {
if (categoryGroups.length === 0) {
setSelectedCategoryTag(null);
hasManualCategoryTagSelectionRef.current = false;
return;
}
const firstCategoryGroup = categoryGroups[0];
const firstCategoryGroup =
categoryGroups.find((group) =>
group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)),
) ?? categoryGroups[0];
const selectedCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
if (
firstCategoryGroup &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
(!selectedCategoryGroup ||
(!hasManualCategoryTagSelectionRef.current &&
selectedCategoryGroup.entries.every((entry) =>
isMatch3DDemoProfileId(entry.profileId),
) &&
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
}
if (
selectedCategoryTag &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
) {
hasManualCategoryTagSelectionRef.current = false;
}
}, [categoryGroups, selectedCategoryTag]);
useEffect(() => {
@@ -5226,6 +5245,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<{
@@ -5248,15 +5270,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,
@@ -5356,9 +5379,10 @@ export function RpgEntryHomeView({
return;
}
onSelectNextRecommendEntry?.();
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
}, [
activeRecommendEntry,
activeRecommendEntryKeyForSelection,
commitRecommendDrag,
isAuthenticated,
onSelectNextRecommendEntry,
@@ -5643,7 +5667,10 @@ export function RpgEntryHomeView({
<button
key={group.tag}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
@@ -5841,7 +5868,10 @@ export function RpgEntryHomeView({
<button
key={`${group.tag}:desktop-discover-category`}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
@@ -6610,7 +6640,10 @@ export function RpgEntryHomeView({
<button
key={`${group.tag}:desktop-category`}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
@@ -10,6 +10,7 @@ import {
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry,
isMatch3DGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
@@ -21,6 +22,7 @@ import {
resolvePlatformPublicWorkCode,
resolvePlatformWorldFallbackCoverImage,
} from './rpgEntryWorldPresentation';
import { buildMatch3DDemoGalleryCard } from '../../data/match3dDemoGalleryCard';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25');
@@ -78,6 +80,24 @@ test('platform public cards use play type reference images as cover fallback', (
);
});
test('builds local Match3D demo gallery card with generated runtime assets intact', () => {
const card = buildMatch3DDemoGalleryCard();
expect(isMatch3DGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('M3-20260525');
expect(resolvePlatformPublicWorkCode(card)).toBe('M3-20260525');
expect(card.coverImageSrc).toBe(
'/match3d-demo/undersea-candy-market/level-scene.png',
);
expect(card.generatedBackgroundAsset?.uiSpritesheetImageSrc).toBe(
'/match3d-demo/undersea-candy-market/ui-spritesheet.png',
);
expect(card.generatedBackgroundAsset?.containerImageSrc).toBeNull();
expect(card.generatedItemAssets?.[0]?.imageViews?.[0]?.imageSrc).toBe(
'/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png',
);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',
+141
View File
@@ -0,0 +1,141 @@
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
} from '../../packages/shared/src/contracts/match3dWorks';
import { buildMatch3DPublicWorkCode } from '../services/publicWorkCode';
import type { PlatformMatch3DGalleryCard } from '../components/rpg-entry/rpgEntryWorldPresentation';
export const MATCH3D_DEMO_PROFILE_ID = 'match3d-demo-20260525';
export const MATCH3D_DEMO_WORK_ID = 'match3d-demo-undersea-candy-market';
export const MATCH3D_DEMO_PUBLIC_WORK_CODE =
buildMatch3DPublicWorkCode(MATCH3D_DEMO_PROFILE_ID);
const MATCH3D_DEMO_ASSET_BASE = '/match3d-demo/undersea-candy-market';
const MATCH3D_DEMO_PUBLISHED_AT = '2026-05-25T12:04:17.000+08:00';
const MATCH3D_DEMO_ITEM_NAMES = [
'海星糖',
'贝壳糖',
'珊瑚软糖',
'珍珠泡泡糖',
'海马棒棒糖',
'鱼尾果冻',
'水母棉花糖',
'螺旋饼干',
'海螺巧克力',
'贝珠马卡龙',
'珊瑚杯糕',
'星砂软糖',
'小鱼糖块',
'海草曲奇',
'泡泡杯',
'蓝莓珊瑚糖',
'迷你糖罐',
'珍珠饼',
'海浪甜甜圈',
'贝壳蛋糕',
] as const;
export const MATCH3D_DEMO_BACKGROUND_ASSET: Match3DGeneratedBackgroundAsset = {
prompt: '海底糖果集市抓大鹅关卡背景',
levelScenePrompt: '海底糖果集市完整关卡画面',
levelSceneImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/level-scene.png`,
imageSrc: `${MATCH3D_DEMO_ASSET_BASE}/background.png`,
uiSpritesheetPrompt: '海底糖果集市 UI 透明 spritesheet',
uiSpritesheetImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/ui-spritesheet.png`,
itemSpritesheetPrompt: '海底糖果集市物品 10x10 透明 spritesheet',
itemSpritesheetImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/item-spritesheet.png`,
containerImageSrc: null,
status: 'image_ready',
};
export function buildMatch3DDemoGeneratedItemAssets() {
return MATCH3D_DEMO_ITEM_NAMES.map<Match3DGeneratedItemAsset>(
(itemName, itemIndex) => {
const itemNumber = itemIndex + 1;
const paddedItemNumber = String(itemNumber).padStart(2, '0');
return {
itemId: `match3d-item-${itemNumber}`,
itemName,
itemSize:
itemIndex < 4 ? '大' : itemIndex < 14 ? '中' : '小',
imageViews: Array.from({ length: 5 }, (_, viewIndex) => {
const viewNumber = viewIndex + 1;
return {
viewId: `view-${String(viewNumber).padStart(2, '0')}`,
viewIndex: viewNumber,
imageSrc: `${MATCH3D_DEMO_ASSET_BASE}/item-slices/item-${paddedItemNumber}/view-${String(viewNumber).padStart(2, '0')}.png`,
};
}),
backgroundAsset:
itemIndex === 0 ? MATCH3D_DEMO_BACKGROUND_ASSET : null,
status: 'image_ready',
};
},
);
}
export function buildMatch3DDemoWorkProfile(): Match3DWorkProfile {
return {
workId: MATCH3D_DEMO_WORK_ID,
profileId: MATCH3D_DEMO_PROFILE_ID,
ownerUserId: 'official-match3d-demo',
sourceSessionId: 'match3d-demo-session-20260525',
gameName: '海底糖果集市',
themeText: '海底糖果集市',
summary: '在海底糖果集市里把同款甜点抓成三消。',
tags: ['抓大鹅', '海底糖果', '官方示例'],
coverImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/level-scene.png`,
referenceImageSrc: null,
clearCount: 21,
difficulty: 8,
publicationStatus: 'published',
playCount: 0,
updatedAt: MATCH3D_DEMO_PUBLISHED_AT,
publishedAt: MATCH3D_DEMO_PUBLISHED_AT,
publishReady: true,
generationStatus: 'ready',
backgroundPrompt: MATCH3D_DEMO_BACKGROUND_ASSET.prompt,
backgroundImageSrc: MATCH3D_DEMO_BACKGROUND_ASSET.imageSrc,
backgroundImageObjectKey: null,
generatedBackgroundAsset: MATCH3D_DEMO_BACKGROUND_ASSET,
generatedItemAssets: buildMatch3DDemoGeneratedItemAssets(),
};
}
export function buildMatch3DDemoGalleryCard(): PlatformMatch3DGalleryCard {
const profile = buildMatch3DDemoWorkProfile();
return {
sourceType: 'match3d',
workId: profile.workId,
profileId: profile.profileId,
sourceSessionId: profile.sourceSessionId,
publicWorkCode: MATCH3D_DEMO_PUBLIC_WORK_CODE,
ownerUserId: profile.ownerUserId,
authorDisplayName: '官方示例',
worldName: profile.gameName,
subtitle: '抓大鹅 · 资源管线示例',
summaryText: profile.summary,
coverImageSrc: profile.coverImageSrc ?? null,
themeTags: profile.tags,
playCount: profile.playCount,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: profile.publishedAt ?? null,
updatedAt: profile.updatedAt,
backgroundPrompt: profile.backgroundPrompt ?? null,
backgroundImageSrc: profile.backgroundImageSrc ?? null,
backgroundImageObjectKey: profile.backgroundImageObjectKey ?? null,
generatedBackgroundAsset: profile.generatedBackgroundAsset ?? null,
generatedItemAssets: profile.generatedItemAssets ?? [],
};
}
export const MATCH3D_DEMO_WORK_PROFILE = buildMatch3DDemoWorkProfile();
export const MATCH3D_DEMO_GALLERY_CARD = buildMatch3DDemoGalleryCard();
export function isMatch3DDemoProfileId(profileId: string | null | undefined) {
return profileId?.trim() === MATCH3D_DEMO_PROFILE_ID;
}
@@ -279,7 +279,7 @@ async function generateBabyObjectMatchAssets(
const assets = normalizeGeneratedAssets(response.assets, itemNames);
const visualPackage = normalizeGeneratedVisualPackage(response.visualPackage);
if (!assets || !visualPackage) {
throw new Error('宝贝识物 image-2 资源生成结果不完整,请重试。');
throw new Error('宝贝识物素材生成结果不完整,请重试。');
}
return { assets, visualPackage };
@@ -447,7 +447,10 @@ function settleMatchedTrayItems(
};
}
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
export function startLocalMatch3DRun(
clearCount = 12,
profileId = 'local-match3d-profile',
): Match3DRunSnapshot {
const normalizedClearCount =
normalizeLocalMatch3DRuntimeClearCount(clearCount);
const selectedSeeds = selectVisualSeeds(normalizedClearCount);
@@ -467,7 +470,7 @@ export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
const nowMs = Date.now();
return {
runId: `local-match3d-run-${nowMs}`,
profileId: 'local-match3d-profile',
profileId,
status: 'Running',
snapshotVersion: 1,
startedAtMs: nowMs,
@@ -95,7 +95,7 @@ test('local Match3D runtime adapter exposes the same runtime seam as the server
const started = await adapter.startRun('ignored-local-profile');
const clickableItem = started.run.items.find((item) => item.clickable);
expect(started.run.profileId).toBe('local-match3d-profile');
expect(started.run.profileId).toBe('ignored-local-profile');
expect(clickableItem).toBeTruthy();
const clickResult = await adapter.clickItem(started.run.runId, {
@@ -117,6 +117,15 @@ test('local Match3D runtime adapter exposes the same runtime seam as the server
expect(stopped.run.status).toBe('Stopped');
});
test('local Match3D runtime adapter keeps the requested profile id on restart', async () => {
const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 });
const started = await adapter.startRun('match3d-demo-20260525');
const restarted = await adapter.restartRun(started.run.runId);
expect(started.run.profileId).toBe('match3d-demo-20260525');
expect(restarted.run.profileId).toBe('match3d-demo-20260525');
});
test('local Match3D runtime adapter keeps authority run local to the adapter', async () => {
const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) });
const first = await adapter.getRun('unused-run-id');
@@ -36,6 +36,7 @@ export type Match3DRuntimeAdapter = {
export type LocalMatch3DRuntimeAdapterOptions = {
clearCount?: number;
profileId?: string;
initialRun?: Match3DRunResponse['run'];
};
@@ -74,11 +75,16 @@ export function createServerMatch3DRuntimeAdapter(
export function createLocalMatch3DRuntimeAdapter(
options: LocalMatch3DRuntimeAdapterOptions = {},
): Match3DRuntimeAdapter {
let authorityRun = options.initialRun ?? startLocalMatch3DRun(options.clearCount);
let authorityRun =
options.initialRun ??
startLocalMatch3DRun(options.clearCount, options.profileId);
return {
async startRun() {
authorityRun = startLocalMatch3DRun(options.clearCount);
async startRun(profileId) {
authorityRun = startLocalMatch3DRun(
options.clearCount,
profileId || options.profileId,
);
return { run: authorityRun };
},
async getRun() {
@@ -91,7 +97,10 @@ export function createLocalMatch3DRuntimeAdapter(
return result;
},
async restartRun() {
authorityRun = startLocalMatch3DRun(options.clearCount);
authorityRun = startLocalMatch3DRun(
options.clearCount,
authorityRun.profileId || options.profileId,
);
return { run: authorityRun };
},
async stopRun() {
@@ -36,6 +36,42 @@ describe('match3dSpritesheetParser', () => {
]);
});
test('同一行图标高度错位时仍按行内横向顺序映射标签', () => {
const width = 36;
const height = 24;
const alpha = new Uint8ClampedArray(width * height);
const paint = (x0: number, y0: number, x1: number, y1: number) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = 255;
}
}
};
paint(2, 1, 5, 4);
paint(12, 1, 15, 4);
paint(22, 1, 25, 4);
paint(2, 15, 5, 20);
paint(12, 13, 15, 18);
paint(22, 10, 25, 15);
const regions = detectMatch3DSpritesheetRegions({
alpha,
width,
height,
labels: ['返回', '设置', '方格', '移出', '凑齐', '打乱'],
});
expect(regions.map((region) => `${region.label}:${region.x}`)).toEqual([
'返回:2',
'设置:12',
'方格:22',
'移出:2',
'凑齐:12',
'打乱:22',
]);
});
test('忽略小噪点,只返回可用矩形素材', () => {
const width = 8;
const height = 8;
+62 -2
View File
@@ -79,8 +79,7 @@ export function detectMatch3DSpritesheetRegions({
}
}
return components
.sort((left, right) => left.y - right.y || left.x - right.x)
return sortMatch3DSpritesheetComponentsByRows(components)
.map((component, index) => ({
label: labels[index] ?? `素材${index + 1}`,
x: component.x,
@@ -90,6 +89,67 @@ export function detectMatch3DSpritesheetRegions({
}));
}
function sortMatch3DSpritesheetComponentsByRows(
components: Match3DDetectedComponent[],
) {
const rows: Array<{
top: number;
bottom: number;
components: Match3DDetectedComponent[];
}> = [];
[...components]
.sort(
(left, right) =>
resolveMatch3DSpritesheetComponentCenterY(left) -
resolveMatch3DSpritesheetComponentCenterY(right) ||
left.x - right.x,
)
.forEach((component) => {
const row = rows.find((entry) =>
isMatch3DSpritesheetComponentInRow(component, entry),
);
if (!row) {
rows.push({
top: component.y,
bottom: component.y + component.height - 1,
components: [component],
});
return;
}
row.top = Math.min(row.top, component.y);
row.bottom = Math.max(row.bottom, component.y + component.height - 1);
row.components.push(component);
});
return rows
.sort((left, right) => left.top - right.top)
.flatMap((row) =>
row.components.sort((left, right) => left.x - right.x || left.y - right.y),
);
}
function resolveMatch3DSpritesheetComponentCenterY(
component: Match3DDetectedComponent,
) {
return component.y + component.height / 2;
}
function isMatch3DSpritesheetComponentInRow(
component: Match3DDetectedComponent,
row: { top: number; bottom: number },
) {
const bottom = component.y + component.height - 1;
if (component.y <= row.bottom && bottom >= row.top) {
return true;
}
const gap =
component.y > row.bottom ? component.y - row.bottom : row.top - bottom;
return gap <= Math.max(2, component.height * 0.25);
}
export function buildMatch3DItemSpritesheetViewRegions<
Region extends Match3DSpritesheetRegion,
>(
@@ -37,7 +37,7 @@ describe('miniGameDraftGenerationProgress', () => {
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
);
expect(progress?.steps[2]?.detail).toBe(
'调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
'生成 1:1 拼图首图,预计 4 分钟。',
);
expect(progress?.estimatedRemainingMs).toBe(446_500);
expect(progress?.overallProgress).toBe(0);
@@ -167,7 +167,7 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
steps.push({
id: 'puzzle-cover-image',
label: '生成拼图首图',
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
detail: '生成 1:1 拼图首图,预计 4 分钟。',
durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS,
});
}
@@ -177,15 +177,15 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
id: 'puzzle-level-scene',
label: '生成关卡画面',
detail: shouldSkipPuzzleCoverGeneration(state)
? '直接使用上传图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。'
: '使用拼图首图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。',
? '直接使用上传图作为参考,生成 9:16 完整关卡画面,预计 90 秒。'
: '使用拼图首图作为参考,生成 9:16 完整关卡画面,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
id: 'puzzle-ui-assets',
label: '生成UI与背景',
detail:
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景;两次 gpt-image-2 并发,预计 90 秒。',
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
@@ -305,7 +305,7 @@ const MATCH3D_STEPS = [
{
id: 'match3d-level-scene',
label: '生成关卡整图',
detail: '调用 gpt-image-2 生成 9:16 完整抓大鹅关卡画面。',
detail: '生成 9:16 完整抓大鹅关卡画面。',
weight: 28,
},
{
@@ -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,
},
);
}
@@ -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,
}),
}),
);
});
});