Merge remote-tracking branch 'origin/master' into hermes/hermes-996d586b
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-05-08 14:42:15 +08:00
29 changed files with 1909 additions and 124 deletions
@@ -87,14 +87,16 @@ const baseDraftItem: CustomWorldWorkSummary = {
canEnterWorld: false,
};
test('creation hub reflects updated draft title summary and counts after rerender', () => {
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
const { rerender } = render(
<CustomWorldCreationHub
items={[baseDraftItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onCreateType={onCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
@@ -105,14 +107,21 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.queryByText('角色 3')).toBeNull();
expect(screen.queryByText('地点 4')).toBeNull();
const puzzleButton = screen.getByRole('button', { name: /.*/u });
const match3dButton = screen.getByRole('button', {
name: /.*/u,
});
const squareHoleButton = screen.getByRole('button', { name: //u });
expect((squareHoleButton as HTMLButtonElement).disabled).toBe(false);
expect(puzzleButton).toBeTruthy();
expect(match3dButton).toBeTruthy();
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByText('反直觉形状分拣')).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
await user.click(match3dButton);
expect(onCreateType).toHaveBeenCalledWith('match3d');
rerender(
<CustomWorldCreationHub
@@ -44,9 +44,10 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).toContain('抓大鹅');
expect(html).toContain('经典消除玩法');
expect(html).not.toContain('角色扮演');
expect(html).not.toContain('大鱼吃小鱼');
expect(html).not.toContain('抓大鹅');
});
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,13 @@ import {
createMatch3DThreeGeometry,
measureMatch3DItemPreviewDimension,
resolveMatch3DColliderBounds,
resolveMatch3DBoardDepthPlan,
resolveMatch3DBoundaryRadius,
resolveMatch3DPhysicsStabilityPlan,
resolveMatch3DSpawnTimingPlan,
resolveMatch3DStackTargetY,
resolveMatch3DSpawnDelay,
resolveMatch3DSpawnY,
resolveMatch3DTrayPreviewRotation,
resolveMatch3DTrayPreviewReferenceDimension,
resolveMatch3DTrayPreviewScale,
@@ -447,6 +454,143 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
).toBeCloseTo(cylinderBounds.height);
});
test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () => {
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
const earlyBottomY = resolveMatch3DStackTargetY(300, 300, 0);
const lateBottomY = resolveMatch3DStackTargetY(300, 60, 0);
expect(largeDepthPlan.initialDepth).toBeGreaterThan(
smallDepthPlan.initialDepth,
);
expect(largeDepthPlan.layerCapacity).toBeLessThan(
smallDepthPlan.layerCapacity,
);
expect(largeDepthPlan.layerCount).toBeGreaterThan(
smallDepthPlan.layerCount,
);
expect(largeDepthPlan.surfaceY).toBeGreaterThan(largeDepthPlan.baseY);
expect(lateBottomY).toBeGreaterThan(earlyBottomY);
expect(lateBottomY).toBeLessThanOrEqual(largeDepthPlan.surfaceY);
});
test('高数量 3D 局面使用更稳定的物理参数', () => {
const smallPlan = resolveMatch3DPhysicsStabilityPlan(30);
const largePlan = resolveMatch3DPhysicsStabilityPlan(300);
expect(largePlan.contactFriction).toBeGreaterThan(
smallPlan.contactFriction,
);
expect(largePlan.contactRestitution).toBeLessThan(
smallPlan.contactRestitution,
);
expect(largePlan.linearDamping).toBeGreaterThan(smallPlan.linearDamping);
expect(largePlan.angularDamping).toBeGreaterThan(smallPlan.angularDamping);
expect(largePlan.solverIterations).toBeGreaterThan(
smallPlan.solverIterations,
);
expect(largePlan.maxHorizontalSpeed).toBeLessThan(
smallPlan.maxHorizontalSpeed,
);
});
test('3D 真实边界半径比视觉半径更保守,避免长条贴边穿出锅壁', () => {
const longBrick = resolveGeometryAsset('block-black-1x8');
const radius = 1;
const boundaryRadius = resolveMatch3DBoundaryRadius(longBrick, radius);
const visualRadius = Math.hypot(
resolveMatch3DColliderBounds(longBrick, radius).width / 2,
resolveMatch3DColliderBounds(longBrick, radius).depth / 2,
);
expect(boundaryRadius).toBeCloseTo(visualRadius);
expect(boundaryRadius).toBeGreaterThan(2.4);
});
test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
const fastTimingPlan = resolveMatch3DSpawnTimingPlan(29);
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
const smallTimingPlan = resolveMatch3DSpawnTimingPlan(30);
const largeTimingPlan = resolveMatch3DSpawnTimingPlan(300);
const bottomDelay = resolveMatch3DSpawnDelay(0, largeDepthPlan.layerCapacity);
const middleDelay = resolveMatch3DSpawnDelay(30, largeDepthPlan.layerCapacity);
const topDelay = resolveMatch3DSpawnDelay(120, largeDepthPlan.layerCapacity);
const dynamicCapacityDelay = resolveMatch3DSpawnDelay(
120,
largeDepthPlan.layerCapacity,
);
const defaultCapacityDelay = resolveMatch3DSpawnDelay(
120,
smallDepthPlan.layerCapacity,
);
expect(bottomDelay).toBe(0);
expect(middleDelay).toBeGreaterThan(bottomDelay);
expect(topDelay).toBeGreaterThan(middleDelay);
expect(dynamicCapacityDelay).toBeGreaterThan(defaultCapacityDelay);
expect(smallTimingPlan.frameSpawnLimit).toBeLessThan(
fastTimingPlan.frameSpawnLimit,
);
expect(smallTimingPlan.burstSize).toBeLessThan(fastTimingPlan.burstSize);
expect(smallTimingPlan.layerDelayMs).toBeGreaterThan(
fastTimingPlan.layerDelayMs,
);
expect(
resolveMatch3DSpawnDelay(29, smallDepthPlan.layerCapacity, smallTimingPlan),
).toBeGreaterThan(450);
expect(largeTimingPlan.initialDelayMs).toBeGreaterThan(
smallTimingPlan.initialDelayMs,
);
expect(largeTimingPlan.frameSpawnLimit).toBeLessThan(
smallTimingPlan.frameSpawnLimit,
);
expect(largeTimingPlan.burstSize).toBeLessThanOrEqual(6);
expect(largeTimingPlan.layerDelayMs).toBeGreaterThanOrEqual(
smallTimingPlan.layerDelayMs,
);
expect(
resolveMatch3DSpawnDelay(299, largeDepthPlan.layerCapacity, largeTimingPlan),
).toBeGreaterThan(5000);
});
test('3D 新物体生成高度会避让同位置已有堆叠', () => {
const plannedSpawnY = 2;
const raisedSpawnY = resolveMatch3DSpawnY(
plannedSpawnY,
0.8,
0.7,
{ x: 0.1, z: 0.1 },
[
{
boundaryRadius: 0.7,
colliderHeight: 0.9,
x: 0.18,
y: 2.4,
z: 0.15,
},
],
);
const unchangedSpawnY = resolveMatch3DSpawnY(
plannedSpawnY,
0.8,
0.7,
{ x: 0.1, z: 0.1 },
[
{
boundaryRadius: 0.7,
colliderHeight: 0.9,
x: 3,
y: 4,
z: 3,
},
],
);
expect(raisedSpawnY).toBeGreaterThan(plannedSpawnY);
expect(unchangedSpawnY).toBe(plannedSpawnY);
});
test('积木视觉键不会被统一兜底成红色苹字', () => {
const run = startLocalMatch3DRun(2);
run.items = run.items.slice(0, 2).map((item, index) => ({
@@ -1,4 +1,4 @@
import { Loader2 } from 'lucide-react';
import { Loader2, Sparkles } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import {
lazy,
@@ -166,6 +166,10 @@ import {
submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import {
generatePuzzleOnboardingWork,
savePuzzleOnboardingWork,
} from '../../services/puzzle-onboarding';
import {
claimPuzzleWorkPointIncentive,
deletePuzzleWork,
@@ -251,6 +255,13 @@ type PuzzleRuntimeReturnStage =
| 'work-detail'
| 'platform';
type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
type PuzzleOnboardingDraft = {
promptText: string;
item: PuzzleWorkSummary;
};
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
type SquareHoleRuntimeReturnStage =
@@ -605,6 +616,157 @@ function mergePuzzleWorkSummary(
return current.profileId === updated.profileId ? updated : current;
}
const PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY =
'genarrative.puzzle-onboarding.first-visit.v1';
const PUZZLE_ONBOARDING_COPY = '待定待定待定';
const PUZZLE_ONBOARDING_CLEAR_COPY = '只差一步,就可以永久保留你的梦';
const PUZZLE_ONBOARDING_GENERATED_DELAY_MS = 700;
function hasSeenPuzzleOnboarding() {
if (typeof window === 'undefined') {
return true;
}
try {
return (
window.localStorage.getItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY) ===
'1'
);
} catch {
return false;
}
}
function markPuzzleOnboardingSeen() {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY, '1');
} catch {
// 中文注释:localStorage 不可写时只降级为本次会话展示,不影响主流程。
}
}
function PuzzleOnboardingView({
prompt,
phase,
error,
onPromptChange,
onSubmit,
}: {
prompt: string;
phase: PuzzleOnboardingPhase;
error: string | null;
onPromptChange: (value: string) => void;
onSubmit: () => void;
}) {
const isGenerating = phase === 'generating';
const isGenerated = phase === 'generated';
const canSubmit = Boolean(prompt.trim()) && !isGenerating && !isGenerated;
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_30%_15%,rgba(251,191,36,0.22),transparent_30%),linear-gradient(135deg,#0f172a,#111827_46%,#1e1b4b)] px-4 py-8 text-white">
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.045)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:38px_38px] opacity-30" />
<section className="relative flex w-full max-w-[34rem] flex-col items-center gap-5 text-center">
<div className="grid h-14 w-14 place-items-center rounded-[1.2rem] border border-amber-200/32 bg-amber-200/14 text-amber-100 shadow-[0_18px_48px_rgba(251,191,36,0.18)]">
{isGenerating ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<Sparkles className="h-6 w-6" />
)}
</div>
<h1 className="text-[2rem] font-black leading-tight sm:text-[2.85rem]">
{PUZZLE_ONBOARDING_COPY}
</h1>
<form
className="flex w-full flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<textarea
value={prompt}
disabled={isGenerating || isGenerated}
onChange={(event) => onPromptChange(event.target.value)}
placeholder="把你的梦讲给我听吧"
rows={4}
className="min-h-32 w-full resize-none rounded-[1.25rem] border border-white/14 bg-black/28 px-4 py-4 text-base font-semibold leading-7 text-white shadow-[0_18px_50px_rgba(0,0,0,0.24)] outline-none backdrop-blur placeholder:text-white/42 focus:border-amber-200/70 focus:ring-2 focus:ring-amber-200/20 disabled:opacity-70"
/>
<button
type="submit"
disabled={!canSubmit}
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'生成'
)}
</button>
</form>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
{error}
</div>
) : null}
</section>
</div>
);
}
function PuzzleOnboardingLoginOverlay({
isSaving,
error,
onLogin,
}: {
isSaving: boolean;
error: string | null;
onLogin: () => void;
}) {
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-950/72 px-4 py-6 text-white backdrop-blur-md">
<section className="flex w-full max-w-[24rem] flex-col items-center gap-5 rounded-[1.35rem] border border-white/14 bg-slate-950/94 px-5 py-6 text-center shadow-[0_28px_90px_rgba(0,0,0,0.5)]">
<div className="grid h-12 w-12 place-items-center rounded-[1rem] bg-amber-200 text-slate-950">
{isSaving ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Sparkles className="h-5 w-5" />
)}
</div>
<h2 className="text-2xl font-black leading-tight">
{PUZZLE_ONBOARDING_CLEAR_COPY}
</h2>
<button
type="button"
disabled={isSaving}
onClick={onLogin}
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
/
</>
) : (
'注册账号 / 登录'
)}
</button>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
{error}
</div>
) : null}
</section>
</div>
);
}
function mergeBigFishWorkSummary(
current: BigFishWorkSummary,
updated: BigFishWorkSummary,
@@ -1124,10 +1286,24 @@ export function PlatformEntryFlowShellImpl({
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const puzzleRunRef = useRef<PuzzleRunSnapshot | null>(null);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [puzzleShelfError, setPuzzleShelfError] = useState<string | null>(null);
const [puzzleCreationError, setPuzzleCreationError] = useState<string | null>(
null,
);
const [puzzleGenerationState, setPuzzleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
useState<CreatePuzzleAgentSessionRequest | null>(null);
const [puzzleOnboardingPrompt, setPuzzleOnboardingPrompt] = useState('');
const [puzzleOnboardingPhase, setPuzzleOnboardingPhase] =
useState<PuzzleOnboardingPhase>('input');
const [puzzleOnboardingDraft, setPuzzleOnboardingDraft] =
useState<PuzzleOnboardingDraft | null>(null);
const [puzzleOnboardingError, setPuzzleOnboardingError] = useState<
string | null
>(null);
const [isPuzzleOnboardingSaving, setIsPuzzleOnboardingSaving] =
useState(false);
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
useState(false);
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
@@ -1295,9 +1471,9 @@ export function PlatformEntryFlowShellImpl({
try {
const worksResponse = await listPuzzleWorks();
setPuzzleWorks(worksResponse.items);
setPuzzleError(null);
setPuzzleShelfError(null);
} catch (error) {
setPuzzleError(
setPuzzleShelfError(
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
);
} finally {
@@ -1609,6 +1785,106 @@ export function PlatformEntryFlowShellImpl({
[authUi],
);
const savePuzzleOnboardingDraft = useCallback(async () => {
if (!puzzleOnboardingDraft || isPuzzleOnboardingSaving) {
return;
}
setIsPuzzleOnboardingSaving(true);
setPuzzleOnboardingError(null);
try {
const response = await savePuzzleOnboardingWork({
promptText: puzzleOnboardingDraft.promptText,
item: puzzleOnboardingDraft.item,
});
setPuzzleWorks((current) => [response.item, ...current]);
setSelectedPuzzleDetail(null);
setPuzzleRun(null);
setPuzzleOnboardingDraft(null);
setPuzzleOnboardingPrompt('');
setPuzzleOnboardingPhase('input');
platformBootstrap.setPlatformTab('home');
setSelectionStage('platform');
void refreshPuzzleShelf();
} catch (error) {
setPuzzleOnboardingError(
resolvePuzzleErrorMessage(error, '保存新手引导拼图失败。'),
);
} finally {
setIsPuzzleOnboardingSaving(false);
}
}, [
isPuzzleOnboardingSaving,
platformBootstrap,
puzzleOnboardingDraft,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
setSelectionStage,
]);
const requestPuzzleOnboardingLogin = useCallback(() => {
if (isPuzzleOnboardingSaving) {
return;
}
authUi?.openLoginModal(() => {
void savePuzzleOnboardingDraft();
});
}, [authUi, isPuzzleOnboardingSaving, savePuzzleOnboardingDraft]);
useEffect(() => {
if (
!authUi ||
authUi?.user ||
selectionStage !== 'platform' ||
hasSeenPuzzleOnboarding()
) {
return;
}
setPuzzleOnboardingPhase('input');
setPuzzleOnboardingError(null);
setSelectionStage('puzzle-onboarding');
}, [authUi, authUi?.user, selectionStage, setSelectionStage]);
const submitPuzzleOnboardingPrompt = useCallback(async () => {
const promptText = puzzleOnboardingPrompt.trim();
if (!promptText || puzzleOnboardingPhase === 'generating') {
return;
}
setPuzzleOnboardingPhase('generating');
setPuzzleOnboardingError(null);
try {
const response = await generatePuzzleOnboardingWork({ promptText });
const item: PuzzleWorkSummary = {
...response.item,
levels:
response.item.levels && response.item.levels.length > 0
? response.item.levels
: [response.level],
};
setPuzzleOnboardingDraft({ promptText, item });
setSelectedPuzzleDetail(item);
setPuzzleOnboardingPhase('generated');
markPuzzleOnboardingSeen();
window.setTimeout(() => {
setPuzzleRun(startLocalPuzzleRun(item));
setPuzzleRuntimeReturnStage('platform');
setSelectionStage('puzzle-runtime');
}, PUZZLE_ONBOARDING_GENERATED_DELAY_MS);
} catch (error) {
setPuzzleOnboardingPhase('input');
setPuzzleOnboardingError(
resolvePuzzleErrorMessage(error, '生成新手引导拼图失败。'),
);
}
}, [
puzzleOnboardingPhase,
puzzleOnboardingPrompt,
resolvePuzzleErrorMessage,
setSelectionStage,
]);
const requestDeleteCreationWork = useCallback(
(confirmation: DeleteCreationWorkConfirmation) => {
if (deletingCreationWorkId) {
@@ -1986,8 +2262,14 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab,
setSelectionStage,
onSessionOpened: () => {
sessionController.setCreationTypeError(null);
setPuzzleCreationError(null);
setShowCreationTypeModal(false);
},
onOpenError: ({ errorMessage }) => {
sessionController.setCreationTypeError(errorMessage);
setPuzzleCreationError(errorMessage);
},
onActionComplete: async ({ payload, response, setSession }) => {
setPuzzleOperation(response.operation);
setSession(response.session);
@@ -2167,6 +2449,8 @@ export function PlatformEntryFlowShellImpl({
setPuzzleOperation(null);
setPuzzleGenerationState(null);
setPuzzleFormDraftPayload(null);
sessionController.setCreationTypeError(null);
setPuzzleCreationError(null);
const nextSession = await puzzleFlow.openWorkspace({});
if (nextSession) {
void refreshPuzzleShelf();
@@ -2274,6 +2558,8 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(null);
setPuzzleGenerationState(null);
setIsPuzzleNextLevelGenerating(false);
setPuzzleShelfError(null);
setPuzzleCreationError(null);
setPuzzleError(null);
setDeletingCreationWorkId(null);
setClaimingPuzzlePointIncentiveProfileId(null);
@@ -4970,6 +5256,7 @@ export function PlatformEntryFlowShellImpl({
bigFishError ??
match3dError ??
squareHoleError ??
puzzleShelfError ??
puzzleError)
}
onRetry={() => {
@@ -4977,6 +5264,8 @@ export function PlatformEntryFlowShellImpl({
setBigFishError(null);
setMatch3DError(null);
setSquareHoleError(null);
setPuzzleShelfError(null);
setPuzzleCreationError(null);
setPuzzleError(null);
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
platformBootstrap.setPlatformError(
@@ -4995,6 +5284,7 @@ export function PlatformEntryFlowShellImpl({
bigFishError ??
match3dError ??
squareHoleError ??
puzzleCreationError ??
puzzleError
}
createBusy={
@@ -5926,6 +6216,26 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'puzzle-onboarding' && (
<motion.div
key="puzzle-onboarding"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100]"
>
<PuzzleOnboardingView
prompt={puzzleOnboardingPrompt}
phase={puzzleOnboardingPhase}
error={puzzleOnboardingError}
onPromptChange={setPuzzleOnboardingPrompt}
onSubmit={() => {
void submitPuzzleOnboardingPrompt();
}}
/>
</motion.div>
)}
{selectionStage === 'puzzle-generating' && (
<motion.div
key="puzzle-generating"
@@ -6064,6 +6374,7 @@ export function PlatformEntryFlowShellImpl({
isPuzzleLeaderboardBusy
}
error={puzzleError}
hideBackButton={Boolean(puzzleOnboardingDraft)}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
@@ -6100,6 +6411,14 @@ export function PlatformEntryFlowShellImpl({
</div>
</div>
) : null}
{puzzleOnboardingDraft &&
puzzleRun?.currentLevel?.status === 'cleared' ? (
<PuzzleOnboardingLoginOverlay
isSaving={isPuzzleOnboardingSaving}
error={puzzleOnboardingError}
onLogin={requestPuzzleOnboardingLogin}
/>
) : null}
</motion.div>
)}
@@ -6345,6 +6664,7 @@ export function PlatformEntryFlowShellImpl({
bigFishError ??
match3dError ??
squareHoleError ??
puzzleCreationError ??
puzzleError ??
sessionController.creationTypeError
}
@@ -11,8 +11,12 @@ test('platform creation types are derived from new work entry config', () => {
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
(item) => item.id === 'puzzle',
);
const match3dConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
(item) => item.id === 'match3d',
);
expect(puzzleConfig).toBeTruthy();
expect(match3dConfig).toBeTruthy();
expect(PLATFORM_CREATION_TYPES).toContainEqual(
expect.objectContaining({
id: 'puzzle',
@@ -23,6 +27,16 @@ test('platform creation types are derived from new work entry config', () => {
hidden: false,
}),
);
expect(PLATFORM_CREATION_TYPES).toContainEqual(
expect.objectContaining({
id: 'match3d',
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: match3dConfig?.badge,
locked: false,
hidden: false,
}),
);
});
test('new work entry config controls visibility and open order', () => {
@@ -30,13 +44,14 @@ test('new work entry config controls visibility and open order', () => {
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
expect(isPlatformCreationTypeVisible('match3d')).toBe(false);
expect(isPlatformCreationTypeVisible('match3d')).toBe(true);
expect(visibleIds).not.toContain('rpg');
expect(visibleIds).not.toContain('big-fish');
expect(visibleIds).not.toContain('match3d');
expect(visibleIds).toContain('match3d');
expect(visibleIds[0]).toBe('puzzle');
expect(visibleIds).toEqual([
'puzzle',
'match3d',
'square-hole',
'airp',
'visual-novel',
@@ -31,6 +31,7 @@ export type SelectionStage =
| 'square-hole-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-onboarding'
| 'puzzle-result'
| 'puzzle-gallery-detail'
| 'puzzle-runtime'
@@ -75,6 +75,10 @@ type PlatformCreationAgentFlowControllerOptions<
enterCreateTab: () => void;
setSelectionStage: (stage: SelectionStage) => void;
onSessionOpened?: () => void;
onOpenError?: (params: {
error: unknown;
errorMessage: string;
}) => void;
onActionComplete?: (params: {
payload: TActionPayload;
response: TActionResponse;
@@ -173,9 +177,15 @@ export function usePlatformCreationAgentFlowController<
options.setSelectionStage(options.workspaceStage);
return nextSession;
} catch (caughtError) {
setError(
options.resolveErrorMessage(caughtError, options.errorMessages.open),
const errorMessage = options.resolveErrorMessage(
caughtError,
options.errorMessages.open,
);
setError(errorMessage);
options.onOpenError?.({
error: caughtError,
errorMessage,
});
return null;
} finally {
setIsBusy(false);
@@ -40,6 +40,7 @@ type PuzzleRuntimeShellProps = {
run: PuzzleRunSnapshot | null;
isBusy?: boolean;
error?: string | null;
hideBackButton?: boolean;
onBack: () => void;
onRemodelWork?: (profileId: string) => void | Promise<void>;
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
@@ -306,6 +307,7 @@ export function PuzzleRuntimeShell({
run,
isBusy = false,
error = null,
hideBackButton = false,
onBack,
onRemodelWork,
onSwapPieces,
@@ -1095,7 +1097,10 @@ export function PuzzleRuntimeShell({
type="button"
onClick={handleBackRequest}
aria-label="返回上一页"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
disabled={hideBackButton}
className={`h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur ${
hideBackButton ? 'invisible pointer-events-none' : 'inline-flex'
}`}
>
<ArrowLeft className="h-4 w-4" />
</button>
@@ -1732,7 +1737,9 @@ export function PuzzleRuntimeShell({
setIsSettingsPanelOpen(false);
onBack();
}}
className="rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100"
className={`rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100 ${
hideBackButton ? 'hidden' : ''
}`}
>
</button>
@@ -1143,6 +1143,10 @@ beforeEach(() => {
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
window.localStorage.clear();
window.localStorage.setItem(
'genarrative.puzzle-onboarding.first-visit.v1',
'1',
);
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 0,
totalPlayTimeMs: 0,
@@ -1869,22 +1873,25 @@ beforeEach(() => {
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
});
test('create hub hides RPG and Match3D while keeping AIRP and visual novel locked', async () => {
test('create hub hides RPG while keeping Match3D open and future templates locked', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
const match3dButton = screen.getByRole('button', {
name: /.*/u,
});
const airpButton = screen.getByRole('button', { name: /AIRP/u });
const visualNovelButton = screen.getByRole('button', {
name: //u,
});
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
@@ -2841,7 +2848,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
expect(screen.queryByText(//u)).toBeNull();
});
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
test('visible match3d creation card opens workspace even when public galleries fail', async () => {
const user = userEvent.setup();
const match3dSession = buildMockMatch3DAgentSession();
@@ -2860,10 +2867,14 @@ test('hidden match3d creation card stays closed even when public galleries fail'
await openCreationHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
expect(
screen.queryByRole('button', { name: /.*/u }),
).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
await user.click(
screen.getByRole('button', { name: /.*/u }),
);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
});
expect(await screen.findByText('抓大鹅工作区:match3d-agent-session-1')).toBeTruthy();
});
test('puzzle draft result back button returns to creation hub', async () => {
+1 -1
View File
@@ -43,7 +43,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: '可创建',
visible: false,
visible: true,
open: true,
},
{
+5
View File
@@ -0,0 +1,5 @@
export {
generatePuzzleOnboardingWork,
puzzleOnboardingClient,
savePuzzleOnboardingWork,
} from './puzzleOnboardingClient';
@@ -0,0 +1,62 @@
import type {
PuzzleOnboardingGenerateRequest,
PuzzleOnboardingGenerateResponse,
PuzzleOnboardingSaveRequest,
} from '../../../packages/shared/src/contracts/puzzleOnboarding';
import type { PuzzleWorkMutationResponse } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const PUZZLE_ONBOARDING_API_BASE = '/api/runtime/puzzle/onboarding';
const PUZZLE_ONBOARDING_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 未登录首次访问生成的临时 1 关拼图,不写入用户作品库。
*/
export async function generatePuzzleOnboardingWork(
payload: PuzzleOnboardingGenerateRequest,
) {
return requestJson<PuzzleOnboardingGenerateResponse>(
`${PUZZLE_ONBOARDING_API_BASE}/generate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成新手引导拼图失败',
{
retry: PUZZLE_ONBOARDING_WRITE_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}
/**
* 登录后把临时拼图保存成当前用户的草稿作品。
*/
export async function savePuzzleOnboardingWork(
payload: PuzzleOnboardingSaveRequest,
) {
return requestJson<PuzzleWorkMutationResponse>(
`${PUZZLE_ONBOARDING_API_BASE}/save`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'保存新手引导拼图失败',
{
retry: PUZZLE_ONBOARDING_WRITE_RETRY,
},
);
}
export const puzzleOnboardingClient = {
generate: generatePuzzleOnboardingWork,
save: savePuzzleOnboardingWork,
};