Merge remote-tracking branch 'origin/codex/tiaoyitiao' into codex/tiaoyitiao

# Conflicts:
#	docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md
#	server-rs/crates/spacetime-client/src/jump_hop.rs
#	server-rs/crates/spacetime-client/src/mapper/runtime.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	src/components/jump-hop-result/JumpHopResultView.test.tsx
#	src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/components/unified-creation/workspaces/JumpHopCreationWorkspace.tsx
#	src/components/unified-creation/workspaces/JumpHopWorkspace.test.tsx
This commit is contained in:
2026-06-04 11:34:31 +08:00
52 changed files with 6752 additions and 2346 deletions

View File

@@ -111,53 +111,11 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
resultStage: 'jump-hop-result',
fields: [
{
id: 'workTitle',
id: 'themeText',
kind: 'text',
label: '作品标题',
label: '题',
required: true,
},
{
id: 'workDescription',
kind: 'text',
label: '作品简介',
required: true,
},
{
id: 'themeTags',
kind: 'text',
label: '主题标签',
required: true,
},
{
id: 'difficulty',
kind: 'select',
label: '难度',
required: true,
},
{
id: 'stylePreset',
kind: 'select',
label: '风格',
required: true,
},
{
id: 'characterPrompt',
kind: 'text',
label: '角色提示词',
required: true,
},
{
id: 'tilePrompt',
kind: 'text',
label: '地块提示词',
required: true,
},
{
id: 'endMoodPrompt',
kind: 'text',
label: '终点氛围',
required: false,
},
],
},
'wooden-fish': {

View File

@@ -33,7 +33,7 @@ function createSessionResponse(): JumpHopSessionResponse {
};
}
test('jump hop workspace submits structured payload after required fields are filled', async () => {
test('jump hop workspace submits theme payload after required field is filled', async () => {
const user = userEvent.setup();
const onSubmitted = vi.fn();
const sessionResponse = createSessionResponse();
@@ -46,14 +46,11 @@ test('jump hop workspace submits structured payload after required fields are fi
const submitButton = screen.getByRole('button', { name: '生成' });
expect(submitButton).toHaveProperty('disabled', true);
await user.type(screen.getByLabelText('作品标题'), '云朵跳台');
await user.type(screen.getByLabelText('作品简介'), '在云端一路跳到星星。');
await user.type(screen.getByLabelText('主题标签'), '云朵 星星');
await user.selectOptions(screen.getByLabelText('难度'), 'standard');
await user.selectOptions(screen.getByLabelText('风格'), 'paper-toy');
await user.type(screen.getByLabelText('角色提示词'), '一只纸片小兔');
await user.type(screen.getByLabelText('地块提示词'), '柔软云朵平台');
await user.type(screen.getByLabelText('终点氛围'), '星光门');
expect(screen.getByLabelText('题')).toBeTruthy();
expect(screen.queryByLabelText('作品标题')).toBeNull();
expect(screen.queryByLabelText('角色提示词')).toBeNull();
await user.type(screen.getByLabelText('主题'), '云朵跳台');
expect(submitButton).toHaveProperty('disabled', false);
await user.click(submitButton);
@@ -61,21 +58,22 @@ test('jump hop workspace submits structured payload after required fields are fi
await waitFor(() => {
expect(mockCreateSession).toHaveBeenCalledWith({
templateId: 'jump-hop',
workTitle: '云朵跳台',
workDescription: '在云端一路跳到星星。',
themeTags: ['云朵', '星星'],
themeText: '云朵跳台',
workTitle: '云朵跳台跳一跳',
workDescription: '云朵跳台主题的俯视角平台跳跃作品',
themeTags: ['云朵跳台', '跳一跳', '休闲'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '一只纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: '云朵跳台主题的俯视角清爽游戏化立体感平台素材',
endMoodPrompt: null,
});
});
expect(onSubmitted).toHaveBeenCalledWith(
sessionResponse,
expect.objectContaining({
templateId: 'jump-hop',
workTitle: '云朵跳台',
themeText: '云朵跳台',
}),
);
});

View File

@@ -2,9 +2,7 @@ import { ArrowLeft, Loader2, Send } from 'lucide-react';
import { useMemo, useState } from 'react';
import type {
JumpHopDifficulty,
JumpHopSessionResponse,
JumpHopStylePreset,
JumpHopWorkspaceCreateRequest,
} from '../../../../packages/shared/src/contracts/jumpHop';
import { jumpHopClient } from '../../../services/jump-hop/jumpHopClient';
@@ -22,27 +20,31 @@ type JumpHopCreationWorkspaceProps = {
};
type JumpHopWorkspaceFormState = {
workTitle: string;
workDescription: string;
themeTags: string;
difficulty: JumpHopDifficulty;
stylePreset: JumpHopStylePreset;
characterPrompt: string;
tilePrompt: string;
endMoodPrompt: string;
themeText: string;
};
const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
workTitle: '',
workDescription: '',
themeTags: '',
difficulty: 'easy',
stylePreset: 'minimal-blocks',
characterPrompt: '',
tilePrompt: '',
endMoodPrompt: '',
themeText: '',
};
function buildJumpHopWorkspacePayload(
formState: JumpHopWorkspaceFormState,
): JumpHopWorkspaceCreateRequest {
const themeText = formState.themeText.trim();
return {
templateId: 'jump-hop',
themeText,
workTitle: `${themeText}跳一跳`,
workDescription: `${themeText}主题的俯视角平台跳跃作品`,
themeTags: [themeText, '跳一跳', '休闲'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: `${themeText}主题的俯视角清爽游戏化立体感平台素材`,
endMoodPrompt: null,
};
}
export function JumpHopCreationWorkspace({
isBusy = false,
error = null,
@@ -56,14 +58,7 @@ export function JumpHopCreationWorkspace({
const [isSubmitting, setIsSubmitting] = useState(false);
const canSubmit = useMemo(
() =>
Boolean(
formState.workTitle.trim() &&
formState.workDescription.trim() &&
formState.themeTags.trim() &&
formState.characterPrompt.trim() &&
formState.tilePrompt.trim(),
),
() => Boolean(formState.themeText.trim()),
[formState],
);
@@ -77,20 +72,7 @@ export function JumpHopCreationWorkspace({
setLocalError(null);
try {
const payload: JumpHopWorkspaceCreateRequest = {
templateId: 'jump-hop',
workTitle: formState.workTitle.trim(),
workDescription: formState.workDescription.trim(),
themeTags: formState.themeTags
.split(/[,、\s]+/)
.map((item) => item.trim())
.filter(Boolean),
difficulty: formState.difficulty,
stylePreset: formState.stylePreset,
characterPrompt: formState.characterPrompt.trim(),
tilePrompt: formState.tilePrompt.trim(),
endMoodPrompt: formState.endMoodPrompt.trim() || null,
};
const payload = buildJumpHopWorkspacePayload(formState);
const response = await jumpHopClient.createSession(payload);
onSubmitted(response, payload);
} catch (caughtError) {
@@ -124,143 +106,22 @@ export function JumpHopCreationWorkspace({
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3">
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.workTitle}
value={formState.themeText}
onChange={(event) =>
setFormState((current) => ({
...current,
workTitle: event.target.value,
themeText: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.workDescription}
onChange={(event) =>
setFormState((current) => ({
...current,
workDescription: event.target.value,
}))
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.themeTags}
onChange={(event) =>
setFormState((current) => ({
...current,
themeTags: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<select
value={formState.difficulty}
onChange={(event) =>
setFormState((current) => ({
...current,
difficulty: event.target.value as JumpHopDifficulty,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
>
<option value="easy">easy</option>
<option value="standard">standard</option>
<option value="advanced">advanced</option>
<option value="challenge">challenge</option>
</select>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<select
value={formState.stylePreset}
onChange={(event) =>
setFormState((current) => ({
...current,
stylePreset: event.target.value as JumpHopStylePreset,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
>
<option value="minimal-blocks">minimal-blocks</option>
<option value="paper-toy">paper-toy</option>
<option value="neon-glass">neon-glass</option>
<option value="forest-stone">forest-stone</option>
<option value="future-metal">future-metal</option>
<option value="custom">custom</option>
</select>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.characterPrompt}
onChange={(event) =>
setFormState((current) => ({
...current,
characterPrompt: event.target.value,
}))
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.tilePrompt}
onChange={(event) =>
setFormState((current) => ({
...current,
tilePrompt: event.target.value,
}))
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.endMoodPrompt}
onChange={(event) =>
setFormState((current) => ({
...current,
endMoodPrompt: event.target.value,
}))
}
rows={2}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
</div>
{localError || error ? (