Enforce Genarrative play-type SOP and update docs

Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
This commit is contained in:
2026-05-20 12:12:00 +08:00
parent f370539a6f
commit 3931442249
123 changed files with 15514 additions and 3419 deletions

View File

@@ -0,0 +1,468 @@
import {
ArrowLeft,
Loader2,
Play,
RefreshCcw,
Send,
Shuffle,
} from 'lucide-react';
import { type CSSProperties, useMemo, useState } from 'react';
import type {
JumpHopDraftResponse,
JumpHopPath,
JumpHopPlatform,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type JumpHopResultViewProps = {
profile:
| (JumpHopDraftResponse & {
characterImageSrc?: string | null;
tileAtlasImageSrc?: string | null;
pathPreviewImageSrc?: string | null;
})
| (JumpHopWorkProfileResponse & {
characterImageSrc?: string | null;
tileAtlasImageSrc?: string | null;
pathPreviewImageSrc?: string | null;
});
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onEdit: () => void;
onStartTestRun: () => void;
onPublish: () => void;
onRegenerateCharacter: () => void;
onRegenerateTiles: () => void;
};
function isJumpHopWorkProfile(
profile: JumpHopResultViewProps['profile'],
): profile is JumpHopWorkProfileResponse {
return 'summary' in profile;
}
type MiniMapPlatform = {
platform: JumpHopPlatform;
index: number;
x: number;
y: number;
width: number;
height: number;
isStart: boolean;
isFinish: boolean;
};
const difficultyToneByValue: Record<
JumpHopPath['difficulty'],
{ accent: string; soft: string; label: string }
> = {
advanced: {
accent: '#f97316',
soft: 'rgba(249, 115, 22, 0.16)',
label: '进阶',
},
challenge: {
accent: '#e11d48',
soft: 'rgba(225, 29, 72, 0.16)',
label: '挑战',
},
easy: {
accent: '#14b8a6',
soft: 'rgba(20, 184, 166, 0.16)',
label: '轻松',
},
standard: {
accent: '#2563eb',
soft: 'rgba(37, 99, 235, 0.16)',
label: '标准',
},
};
const tileToneByType: Record<string, string> = {
accent: '#c4b5fd',
bonus: '#fde68a',
finish: '#86efac',
normal: '#e0f2fe',
start: '#bae6fd',
target: '#fecdd3',
};
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function normalizePathPlatforms(path: JumpHopPath | null | undefined) {
const platforms = path?.platforms ?? [];
if (platforms.length === 0) {
return [];
}
const coordinatePlatforms = platforms.filter(
(platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y),
);
const shouldUseCoordinates = coordinatePlatforms.length >= 2;
const xValues = shouldUseCoordinates
? coordinatePlatforms.map((platform) => platform.x)
: [];
const yValues = shouldUseCoordinates
? coordinatePlatforms.map((platform) => platform.y)
: [];
const minX = Math.min(...xValues);
const maxX = Math.max(...xValues);
const minY = Math.min(...yValues);
const maxY = Math.max(...yValues);
const xRange = Math.max(maxX - minX, 1);
const yRange = Math.max(maxY - minY, 1);
const denominator = Math.max(platforms.length - 1, 1);
return platforms.map((platform, index): MiniMapPlatform => {
const sequenceRatio = index / denominator;
const hasCoordinates =
shouldUseCoordinates &&
isFiniteNumber(platform.x) &&
isFiniteNumber(platform.y);
const x = hasCoordinates
? 12 + ((platform.x - minX) / xRange) * 76
: 12 + sequenceRatio * 76;
const y = hasCoordinates
? 14 + ((platform.y - minY) / yRange) * 72
: 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18;
return {
platform,
index,
x,
y,
width: Math.min(Math.max(platform.width || 54, 42), 82),
height: Math.min(Math.max(platform.height || 42, 34), 68),
isStart: index === 0 || platform.tileType === 'start',
isFinish:
index === path?.finishIndex ||
platform.tileType === 'finish' ||
platform.tileType === 'target',
};
});
}
function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) {
const platforms = useMemo(() => normalizePathPlatforms(path), [path]);
const tone =
difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard;
const pathPoints = platforms
.map((platform) => `${platform.x},${platform.y}`)
.join(' ');
if (platforms.length === 0) {
return null;
}
return (
<div
className="relative aspect-[1/1] w-full overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]"
style={
{
'--jump-hop-path-accent': tone.accent,
'--jump-hop-path-soft': tone.soft,
} as CSSProperties
}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.92),transparent_28%),radial-gradient(circle_at_75%_78%,rgba(125,211,252,0.24),transparent_32%)]" />
<svg
viewBox="0 0 100 100"
className="absolute inset-0 h-full w-full"
aria-hidden="true"
>
<polyline
points={pathPoints}
fill="none"
stroke="var(--jump-hop-path-soft)"
strokeWidth="11"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points={pathPoints}
fill="none"
stroke="var(--jump-hop-path-accent)"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="4 4"
/>
</svg>
{platforms.map((item) => {
const tileTone =
tileToneByType[item.platform.tileType] ?? tileToneByType.normal;
const scoreBoost =
isFiniteNumber(item.platform.scoreValue) &&
item.platform.scoreValue > 1;
const style = {
left: `${item.x}%`,
top: `${item.y}%`,
width: `${item.width}%`,
height: `${item.height}%`,
background: tileTone,
borderColor: item.isFinish ? tone.accent : 'rgba(255,255,255,0.92)',
zIndex: 10 + item.index,
} as CSSProperties;
return (
<div
key={
item.platform.platformId ||
`${item.index}-${item.platform.tileType}`
}
className="absolute grid max-h-9 max-w-11 min-h-6 min-w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-[0.72rem] border-2 shadow-[0_8px_18px_rgba(15,23,42,0.13)]"
style={style}
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{
background:
item.isStart || item.isFinish ? tone.accent : '#ffffff',
boxShadow: scoreBoost ? `0 0 0 4px ${tone.soft}` : undefined,
}}
/>
{item.isStart || item.isFinish ? (
<span className="absolute -top-2.5 rounded-full bg-slate-950/78 px-1.5 py-0.5 text-[0.58rem] font-black leading-none text-white">
{item.isStart ? '起' : '终'}
</span>
) : null}
</div>
);
})}
<div className="absolute left-2 top-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-strong)] shadow-sm">
{tone.label}
</div>
<div className="absolute bottom-2 right-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-base)] shadow-sm">
{platforms.length}
</div>
</div>
);
}
export function JumpHopResultView({
profile,
isBusy = false,
error = null,
onBack,
onEdit,
onStartTestRun,
onPublish,
onRegenerateCharacter,
onRegenerateTiles,
}: JumpHopResultViewProps) {
const [isPublishing, setIsPublishing] = useState(false);
const isWorkProfile = isJumpHopWorkProfile(profile);
const draft = isWorkProfile ? profile.draft : profile;
const safeDraft = draft as JumpHopDraftResponse & {
characterAsset: NonNullable<JumpHopDraftResponse['characterAsset']>;
tileAtlasAsset: NonNullable<JumpHopDraftResponse['tileAtlasAsset']>;
path: NonNullable<JumpHopDraftResponse['path']>;
};
const path = isWorkProfile ? profile.path : safeDraft.path;
const characterAsset = isWorkProfile
? profile.characterAsset
: safeDraft.characterAsset;
const tileAtlasAsset = isWorkProfile
? profile.tileAtlasAsset
: safeDraft.tileAtlasAsset;
const titleSource = isWorkProfile
? profile.summary.workTitle
: profile.workTitle;
const summarySource = isWorkProfile
? profile.summary.workDescription
: profile.workDescription;
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
const pathPlatforms = normalizePathPlatforms(path);
const canRenderPathMiniMap = pathPlatforms.length > 0;
const hasAssets = Boolean(
profile.characterImageSrc?.trim() ||
profile.tileAtlasImageSrc?.trim() ||
profile.pathPreviewImageSrc?.trim() ||
characterAsset?.imageSrc?.trim() ||
tileAtlasAsset?.imageSrc?.trim() ||
canRenderPathMiniMap,
);
const handlePublish = async () => {
setIsPublishing(true);
try {
await Promise.resolve(onPublish());
} finally {
setIsPublishing(false);
}
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex gap-2">
<button
type="button"
onClick={onRegenerateCharacter}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<RefreshCcw className="h-4 w-4" />
</button>
<button
type="button"
onClick={onRegenerateTiles}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<Shuffle className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
{title}
</div>
{summary ? (
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{summary}
</div>
) : null}
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{profile.characterImageSrc || characterAsset?.imageSrc ? (
<ResolvedAssetImage
src={
('characterImageSrc' in profile
? profile.characterImageSrc
: null) ??
characterAsset?.imageSrc ??
''
}
alt="角色图"
className="aspect-[1/1] w-full object-cover"
/>
) : (
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{profile.tileAtlasImageSrc || tileAtlasAsset?.imageSrc ? (
<ResolvedAssetImage
src={
('tileAtlasImageSrc' in profile
? profile.tileAtlasImageSrc
: null) ??
tileAtlasAsset?.imageSrc ??
''
}
alt="地块图"
className="aspect-[1/1] w-full object-cover"
/>
) : (
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{path && canRenderPathMiniMap ? (
<JumpHopPathMiniMap path={path} />
) : 'pathPreviewImageSrc' in profile &&
profile.pathPreviewImageSrc ? (
<ResolvedAssetImage
src={profile.pathPreviewImageSrc}
alt="路径预览"
className="aspect-[1/1] w-full object-cover"
/>
) : path ? (
<div className="grid aspect-[1/1] place-items-center px-3 text-center">
<div>
<div className="text-3xl font-black text-[var(--platform-text-strong)]">
{path.platforms.length}
</div>
<div className="mt-1 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
{path.difficulty}
</div>
</div>
</div>
) : (
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
{!hasAssets ? (
<div className="platform-banner platform-banner--neutral mt-3 rounded-2xl text-sm leading-6">
</div>
) : null}
</section>
<section className="platform-subpanel flex flex-col rounded-[1.25rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
<div className="mt-auto grid gap-2 pt-3">
<button
type="button"
onClick={onEdit}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={onStartTestRun}
disabled={isBusy}
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
>
<Play className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
void handlePublish();
}}
disabled={isBusy || isPublishing}
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</section>
</div>
</div>
);
}
export default JumpHopResultView;