完善抓大鹅创作入口与运行态表现

This commit is contained in:
2026-05-01 22:07:55 +08:00
parent 8c03ec95c6
commit 9a3db67e13
25 changed files with 1320 additions and 183 deletions

View File

@@ -245,6 +245,7 @@ test('auth gate keeps password entry available when login options are empty', as
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
expect(within(dialog).queryByText('读取登录方式失败')).toBeNull();
});
test('auth gate falls back to password entry when login options request fails', async () => {

View File

@@ -263,6 +263,7 @@ export function AuthGate({ children }: AuthGateProps) {
let isActive = true;
const hydrate = async () => {
const callbackResult = consumeAuthCallbackResult();
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
if (!isActive) {
@@ -291,16 +292,13 @@ export function AuthGate({ children }: AuthGateProps) {
setAvailableLoginMethods(FALLBACK_LOGIN_METHODS);
setUser(null);
setError(
optionsError instanceof Error
? optionsError.message
: '读取登录方式失败,请稍后再试。',
);
// 中文注释:登录方式接口失败时按产品约定保留密码登录入口;
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
setError(callbackResult?.error ?? '');
setStatus('unauthenticated');
}
};
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
setShowLoginModal(true);

View File

@@ -113,9 +113,9 @@ test('creation hub reflects updated draft title summary and counts after rerende
).toBeTruthy();
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
expect((match3dButton as HTMLButtonElement).disabled).toBe(true);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(
within(match3dButton).getAllByText('敬请期待').length,
within(match3dButton).getAllByText('经典消除玩法').length,
).toBeGreaterThan(0);
expect(puzzleButton).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();

View File

@@ -61,8 +61,119 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
expect(clickableItem).toBeTruthy();
const { onClickItem, onOptimisticRunChange } = renderRuntime(run);
fireEvent.click(screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`));
fireEvent.click(
screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`),
);
expect(onOptimisticRunChange).toHaveBeenCalled();
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
});
test('后端形状视觉键不会被统一兜底成红色苹字', () => {
const run = startLocalMatch3DRun(2);
run.items = run.items.slice(0, 2).map((item, index) => ({
...item,
itemInstanceId: `shape-${index}`,
itemTypeId: `shape-type-${index}`,
visualKey: index === 0 ? 'red_circle' : 'yellow_triangle',
x: 0.42 + index * 0.16,
y: 0.5,
layer: index,
clickable: true,
}));
renderRuntime(run);
expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy();
expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy();
expect(screen.queryAllByText('苹')).toHaveLength(0);
});
test('水果题材视觉键也渲染为无文字纯色几何体', () => {
const run = startLocalMatch3DRun(3);
run.items = run.items.slice(0, 3).map((item, index) => ({
...item,
itemInstanceId: `fruit-${index}`,
itemTypeId: `fruit-type-${index}`,
visualKey:
index === 0
? 'watermelon-green'
: index === 1
? 'apple-red'
: 'grape-purple',
x: 0.35 + index * 0.15,
y: 0.5,
radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07,
layer: index,
clickable: true,
}));
renderRuntime(run);
expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy();
expect(
screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'),
).toBe('heart');
expect(
screen
.getByTestId('match3d-visual-grape-purple')
.getAttribute('data-shape'),
).toBe('star');
expect(screen.queryByText('苹果')).toBeNull();
expect(screen.queryByText('苹')).toBeNull();
});
test('运行态支持梯形和平行四边形等差异化几何造型', () => {
const run = startLocalMatch3DRun(3);
run.items = run.items.slice(0, 3).map((item, index) => ({
...item,
itemInstanceId: `geometry-${index}`,
itemTypeId: `geometry-type-${index}`,
visualKey:
index === 0
? 'peach-pink'
: index === 1
? 'banana-yellow'
: 'orange_hexagon',
x: 0.35 + index * 0.15,
y: 0.5,
layer: index,
clickable: true,
}));
renderRuntime(run);
expect(
screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'),
).toBe('trapezoid');
expect(
screen
.getByTestId('match3d-visual-banana-yellow')
.getAttribute('data-shape'),
).toBe('parallelogram');
expect(
screen
.getByTestId('match3d-visual-orange_hexagon')
.getAttribute('data-shape'),
).toBe('hexagon');
});
test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => {
const run = startLocalMatch3DRun(1);
const item = run.items[0]!;
run.items = [
{
...item,
itemInstanceId: 'legacy-outside',
visualKey: 'apple-red',
x: -0.4,
y: 0.5,
radius: 0.1,
clickable: true,
},
];
renderRuntime(run);
const token = screen.getByTestId(
'match3d-item-legacy-outside',
) as HTMLElement;
expect(parseFloat(token.style.left)).toBeGreaterThanOrEqual(0);
expect(parseFloat(token.style.left)).toBeLessThanOrEqual(100);
});

View File

@@ -41,6 +41,174 @@ type Match3DFeedbackEvent = {
kind: 'cleared' | 'rejected';
itemIds: string[];
};
type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number];
type Match3DGeometryShape =
| 'circle'
| 'triangle'
| 'diamond'
| 'square'
| 'star'
| 'hexagon'
| 'capsule'
| 'heart'
| 'trapezoid'
| 'parallelogram';
type Match3DGeometryAsset = {
shape: Match3DGeometryShape;
fill: string;
stroke: string;
};
const MATCH3D_RENDER_CENTER = 0.5;
const MATCH3D_RENDER_RADIUS = 0.5;
const MATCH3D_RENDER_SAFE_MARGIN = 0.035;
const MATCH3D_GEOMETRY_ASSETS: Record<string, Match3DGeometryAsset> = {
'watermelon-green': {
shape: 'circle',
fill: '#16a34a',
stroke: '#14532d',
},
'apple-red': {
shape: 'heart',
fill: '#ef4444',
stroke: '#991b1b',
},
'banana-yellow': {
shape: 'parallelogram',
fill: '#facc15',
stroke: '#a16207',
},
'grape-purple': {
shape: 'star',
fill: '#8b5cf6',
stroke: '#5b21b6',
},
'melon-green': {
shape: 'hexagon',
fill: '#84cc16',
stroke: '#3f6212',
},
'berry-blue': {
shape: 'diamond',
fill: '#2563eb',
stroke: '#1e3a8a',
},
'peach-pink': {
shape: 'trapezoid',
fill: '#fb7185',
stroke: '#be123c',
},
'plum-indigo': {
shape: 'capsule',
fill: '#4f46e5',
stroke: '#312e81',
},
'lime-lime': {
shape: 'square',
fill: '#65a30d',
stroke: '#365314',
},
'orange-orange': {
shape: 'triangle',
fill: '#f97316',
stroke: '#9a3412',
},
'pear-cyan': {
shape: 'parallelogram',
fill: '#06b6d4',
stroke: '#155e75',
},
red_circle: {
shape: 'circle',
fill: '#ef4444',
stroke: '#991b1b',
},
yellow_triangle: {
shape: 'triangle',
fill: '#facc15',
stroke: '#a16207',
},
purple_diamond: {
shape: 'diamond',
fill: '#7c3aed',
stroke: '#4c1d95',
},
green_square: {
shape: 'square',
fill: '#16a34a',
stroke: '#14532d',
},
blue_star: {
shape: 'star',
fill: '#0ea5e9',
stroke: '#075985',
},
orange_hexagon: {
shape: 'hexagon',
fill: '#f97316',
stroke: '#9a3412',
},
cyan_capsule: {
shape: 'capsule',
fill: '#06b6d4',
stroke: '#155e75',
},
pink_heart: {
shape: 'heart',
fill: '#ec4899',
stroke: '#9d174d',
},
lime_leaf: {
shape: 'trapezoid',
fill: '#84cc16',
stroke: '#3f6212',
},
white_moon: {
shape: 'parallelogram',
fill: '#e2e8f0',
stroke: '#64748b',
},
};
const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [
{ shape: 'circle', fill: '#f43f5e', stroke: '#9f1239' },
{ shape: 'triangle', fill: '#f59e0b', stroke: '#92400e' },
{ shape: 'diamond', fill: '#8b5cf6', stroke: '#5b21b6' },
{ shape: 'star', fill: '#10b981', stroke: '#065f46' },
{ shape: 'trapezoid', fill: '#0ea5e9', stroke: '#075985' },
{ shape: 'parallelogram', fill: '#14b8a6', stroke: '#115e59' },
];
const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
{
itemTypeId: 'unknown-rose',
visualKey: 'unknown-rose',
colorClassName: 'from-rose-400 to-red-600',
label: '一',
},
{
itemTypeId: 'unknown-amber',
visualKey: 'unknown-amber',
colorClassName: 'from-yellow-300 to-amber-500',
label: '二',
},
{
itemTypeId: 'unknown-violet',
visualKey: 'unknown-violet',
colorClassName: 'from-violet-400 to-purple-700',
label: '三',
},
{
itemTypeId: 'unknown-emerald',
visualKey: 'unknown-emerald',
colorClassName: 'from-emerald-300 to-green-600',
label: '四',
},
{
itemTypeId: 'unknown-sky',
visualKey: 'unknown-sky',
colorClassName: 'from-sky-300 to-blue-600',
label: '五',
},
];
function formatTimer(value: number) {
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
@@ -49,7 +217,11 @@ function formatTimer(value: number) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function formatElapsed(startedAtMs: number, remainingMs: number, durationLimitMs: number) {
function formatElapsed(
startedAtMs: number,
remainingMs: number,
durationLimitMs: number,
) {
const elapsedMs = Math.max(0, durationLimitMs - remainingMs);
const totalSeconds = Math.floor(elapsedMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
@@ -57,11 +229,128 @@ function formatElapsed(startedAtMs: number, remainingMs: number, durationLimitMs
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function hashVisualKey(visualKey: string) {
let hash = 0;
for (const char of visualKey) {
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
}
return hash;
}
function resolveVisualSeed(visualKey: string) {
return (
MATCH3D_VISUAL_SEEDS.find((seed) => seed.visualKey === visualKey) ??
MATCH3D_VISUAL_SEEDS[0]!
const knownSeed = MATCH3D_VISUAL_SEEDS.find(
(seed) => seed.visualKey === visualKey,
);
if (knownSeed) {
return knownSeed;
}
return MATCH3D_UNKNOWN_VISUAL_SEEDS[
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_VISUAL_SEEDS.length
]!;
}
function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset {
return (
MATCH3D_GEOMETRY_ASSETS[visualKey] ??
MATCH3D_UNKNOWN_GEOMETRY_ASSETS[
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_GEOMETRY_ASSETS.length
]!
);
}
function renderGeometryShape(asset: Match3DGeometryAsset) {
const shapeProps = {
fill: asset.fill,
stroke: asset.stroke,
strokeWidth: 6,
strokeLinejoin: 'round' as const,
};
switch (asset.shape) {
case 'circle':
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
case 'triangle':
return <path d="M50 12 L89 84 H11Z" {...shapeProps} />;
case 'diamond':
return <path d="M50 9 L91 50 L50 91 L9 50Z" {...shapeProps} />;
case 'square':
return <rect x="16" y="16" width="68" height="68" rx="8" {...shapeProps} />;
case 'star':
return (
<path
d="M50 8 L61 36 L91 38 L68 58 L76 88 L50 72 L24 88 L32 58 L9 38 L39 36Z"
{...shapeProps}
/>
);
case 'hexagon':
return <path d="M28 12 H72 L94 50 L72 88 H28 L6 50Z" {...shapeProps} />;
case 'capsule':
return <rect x="10" y="28" width="80" height="44" rx="22" {...shapeProps} />;
case 'heart':
return (
<path
d="M50 86 C25 66 13 52 17 34 C20 18 40 16 50 31 C60 16 80 18 83 34 C87 52 75 66 50 86Z"
{...shapeProps}
/>
);
case 'trapezoid':
return <path d="M27 18 H73 L90 82 H10Z" {...shapeProps} />;
case 'parallelogram':
return <path d="M34 16 H88 L66 84 H12Z" {...shapeProps} />;
default:
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
}
}
function Match3DVisualIcon({
visualKey,
className = '',
}: {
visualKey: string;
className?: string;
}) {
const asset = resolveGeometryAsset(visualKey);
return (
<svg
className={`pointer-events-none h-full w-full drop-shadow-[0_5px_7px_rgba(15,23,42,0.36)] ${className}`}
viewBox="0 0 100 100"
aria-hidden
focusable={false}
data-testid={`match3d-visual-${visualKey}`}
data-shape={asset.shape}
>
{renderGeometryShape(asset)}
</svg>
);
}
function resolveRenderableItemFrame(item: Match3DItemSnapshot) {
const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN;
const radius = Math.min(
Math.max(Number.isFinite(item.radius) ? item.radius : 0.06, 0.035),
maxRadius,
);
const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER;
const rawY = Number.isFinite(item.y) ? item.y : MATCH3D_RENDER_CENTER;
const dx = rawX - MATCH3D_RENDER_CENTER;
const dy = rawY - MATCH3D_RENDER_CENTER;
const distance = Math.hypot(dx, dy);
const maxDistance = Math.max(
0,
MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN - radius,
);
if (distance <= maxDistance || distance <= 0) {
return { x: rawX, y: rawY, radius };
}
const ratio = maxDistance / distance;
return {
x: MATCH3D_RENDER_CENTER + dx * ratio,
y: MATCH3D_RENDER_CENTER + dy * ratio,
radius,
};
}
function buildClientEventId(itemInstanceId: string) {
@@ -81,9 +370,11 @@ function isItemState(
state: Match3DItemSnapshot['state'],
expected: 'in_board' | 'in_tray' | 'cleared' | 'flying',
) {
return String(state)
.replace(/([a-z])([A-Z])/gu, '$1_$2')
.toLowerCase() === expected;
return (
String(state)
.replace(/([a-z])([A-Z])/gu, '$1_$2')
.toLowerCase() === expected
);
}
function isPointInsideCircle(
@@ -91,14 +382,11 @@ function isPointInsideCircle(
pointY: number,
item: Match3DItemSnapshot,
) {
return Math.hypot(pointX - item.x, pointY - item.y) <= item.radius;
const frame = resolveRenderableItemFrame(item);
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
}
function findHitItem(
run: Match3DRunSnapshot,
pointX: number,
pointY: number,
) {
function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) {
return run.items
.filter(
(item) =>
@@ -151,51 +439,57 @@ function Match3DToken({
onClick: (item: Match3DItemSnapshot) => void;
}) {
const visualSeed = resolveVisualSeed(item.visualKey);
const size = `${item.radius * 200}%`;
const itemStateClass =
isItemState(item.state, 'flying')
? 'scale-75 opacity-0'
: item.clickable
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
: 'opacity-48';
const frame = resolveRenderableItemFrame(item);
const size = `${frame.radius * 200}%`;
const itemStateClass = isItemState(item.state, 'flying')
? 'scale-75 opacity-0'
: item.clickable
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
: 'opacity-48';
if (!isItemState(item.state, 'in_board') && !isItemState(item.state, 'flying')) {
if (
!isItemState(item.state, 'in_board') &&
!isItemState(item.state, 'flying')
) {
return null;
}
return (
<button
type="button"
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-sm font-black text-white shadow-[0_10px_18px_rgba(15,23,42,0.32)] transition-all duration-300 [text-shadow:0_1px_2px_rgba(15,23,42,0.65)] ${itemStateClass}`}
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center bg-transparent p-0 transition-all duration-300 ${itemStateClass}`}
style={{
left: `${item.x * 100}%`,
top: `${item.y * 100}%`,
left: `${frame.x * 100}%`,
top: `${frame.y * 100}%`,
width: size,
height: size,
zIndex: item.layer,
zIndex: item.layer + 10,
}}
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
data-testid={`match3d-item-${item.itemInstanceId}`}
disabled={disabled || !item.clickable || !isItemState(item.state, 'in_board')}
disabled={
disabled || !item.clickable || !isItemState(item.state, 'in_board')
}
onClick={() => onClick(item)}
>
<span className="relative z-10">{visualSeed.label}</span>
<span className="absolute inset-[16%] rounded-full bg-white/24" />
<span className="absolute left-[18%] top-[14%] h-[18%] w-[28%] rounded-full bg-white/42 blur-[1px]" />
<Match3DVisualIcon visualKey={item.visualKey} className="relative z-10" />
</button>
);
}
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) {
if (!slot.visualKey) {
return <span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />;
return (
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
);
}
const visualSeed = resolveVisualSeed(slot.visualKey);
return (
<span
className={`flex h-full w-full items-center justify-center rounded-xl border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-xs font-black text-white shadow-[0_8px_16px_rgba(15,23,42,0.24)] [text-shadow:0_1px_2px_rgba(15,23,42,0.62)]`}
className="flex h-full w-full items-center justify-center p-1"
aria-label={visualSeed.label}
>
{visualSeed.label}
<Match3DVisualIcon visualKey={slot.visualKey} />
</span>
);
}
@@ -228,14 +522,18 @@ function Match3DSettlement({
<div className="mb-4 flex items-center gap-3">
<span
className={`flex h-11 w-11 items-center justify-center rounded-full ${
won ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'
won
? 'bg-emerald-100 text-emerald-700'
: 'bg-rose-100 text-rose-700'
}`}
>
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
</span>
<div>
<h2 className="text-xl font-black">{title}</h2>
<p className="text-sm font-semibold text-slate-500">{description}</p>
<p className="text-sm font-semibold text-slate-500">
{description}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -271,9 +569,8 @@ export function Match3DRuntimeShell({
}: Match3DRuntimeShellProps) {
const stageRef = useRef<HTMLDivElement | null>(null);
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
const [feedbackEvent, setFeedbackEvent] = useState<Match3DFeedbackEvent | null>(
null,
);
const [feedbackEvent, setFeedbackEvent] =
useState<Match3DFeedbackEvent | null>(null);
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
useEffect(() => {
@@ -371,7 +668,7 @@ export function Match3DRuntimeShell({
if (!run) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
{isBusy ? '载入中' : error ?? '暂无运行态'}
{isBusy ? '载入中' : (error ?? '暂无运行态')}
</div>
);
}
@@ -422,7 +719,7 @@ export function Match3DRuntimeShell({
onPointerDown={handleBoardPointerDown}
data-testid="match3d-board"
>
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
{run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}

View File

@@ -1081,12 +1081,13 @@ export function PlatformEntryFlowShellImpl({
const galleryResponse = await listMatch3DGallery();
setMatch3DGalleryEntries(galleryResponse.items);
return galleryResponse.items;
} catch (error) {
} catch {
// 中文注释:公开广场是首页展示数据,失败时只降级为空列表;
// 不写入创作错误态,避免挡住抓大鹅共创入口。
setMatch3DGalleryEntries([]);
setMatch3DError(resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。'));
return [];
}
}, [resolveMatch3DErrorMessage]);
}, []);
const refreshPuzzleShelf = useCallback(async () => {
setIsPuzzleLoadingLibrary(true);
@@ -1211,6 +1212,7 @@ export function PlatformEntryFlowShellImpl({
isBigFishCreationVisible
? refreshBigFishGallery()
: Promise.resolve([] as BigFishWorkSummary[]),
refreshMatch3DGallery(),
refreshPuzzleGallery(),
]);
return latestSession;
@@ -1812,7 +1814,6 @@ export function PlatformEntryFlowShellImpl({
(type: PlatformCreationTypeId) => {
if (
type === 'rpg' ||
type === 'match3d' ||
type === 'airp' ||
type === 'visual-novel'
) {
@@ -1830,6 +1831,13 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (type === 'match3d') {
runProtectedAction(() => {
void openMatch3DAgentWorkspace();
});
return;
}
if (type === 'puzzle') {
runProtectedAction(() => {
void openPuzzleAgentWorkspace();
@@ -1838,6 +1846,7 @@ export function PlatformEntryFlowShellImpl({
},
[
openBigFishAgentWorkspace,
openMatch3DAgentWorkspace,
openPuzzleAgentWorkspace,
prepareCreationLaunch,
runProtectedAction,
@@ -5067,7 +5076,9 @@ export function PlatformEntryFlowShellImpl({
});
}}
onSelectMatch3D={() => {
// 抓大鹅创作入口当前为敬请期待;保留回调防御,避免旧入口绕过锁定态。
runProtectedAction(() => {
void openMatch3DAgentWorkspace();
});
}}
onSelectPuzzle={() => {
runProtectedAction(() => {

View File

@@ -68,9 +68,9 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
{
id: 'match3d',
title: '抓大鹅',
subtitle: '敬请期待',
badge: '敬请期待',
locked: true,
subtitle: '经典消除玩法',
badge: '可创建',
locked: false,
},
{
id: 'airp',

View File

@@ -10,6 +10,9 @@ import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
Match3DAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
@@ -124,17 +127,6 @@ async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
expect(await screen.findByText('角色扮演')).toBeTruthy();
}
async function expectRpgCreationLocked(
user: ReturnType<typeof userEvent.setup>,
) {
await openCreationHub(user);
const rpgButton = screen.getByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
await user.click(rpgButton);
expect(createRpgCreationSession).not.toHaveBeenCalled();
}
async function openExistingRpgDraft(
user: ReturnType<typeof userEvent.setup>,
actionName: string | RegExp = /(?:|)/u,
@@ -433,6 +425,21 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
),
}));
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
}) => (
<div className="match3d-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
</div>
),
}));
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
Match3DRuntimeShell: ({
run,
@@ -670,6 +677,59 @@ function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
};
}
function buildMockMatch3DAgentSession(
overrides: Partial<Match3DAgentSessionSnapshot> = {},
): Match3DAgentSessionSnapshot {
const sessionId = overrides.sessionId ?? 'match3d-agent-session-1';
return {
sessionId,
currentTurn: 0,
progressPercent: 20,
stage: 'collecting',
anchorPack: {
theme: {
key: 'theme',
label: '题材主题',
value: '水果消除',
status: 'confirmed',
},
clearCount: {
key: 'clearCount',
label: '需要消除次数',
value: '4',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '5',
status: 'confirmed',
},
},
config: {
themeText: '水果消除',
referenceImageSrc: null,
clearCount: 4,
difficulty: 5,
},
draft: null,
messages: [
{
id: 'match3d-message-1',
role: 'assistant',
kind: 'chat',
text: '我们先确定抓大鹅题材、消除次数和难度。',
createdAt: '2026-05-01T10:00:00.000Z',
},
],
lastAssistantReply: '我们先确定抓大鹅题材、消除次数和难度。',
publishedProfileId: null,
updatedAt: '2026-05-01T10:00:00.000Z',
...overrides,
};
}
function buildMockRpgGalleryDetail(
entry: CustomWorldGalleryCard,
): CustomWorldLibraryEntry {
@@ -2500,6 +2560,38 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
expect(screen.queryByText(//u)).toBeNull();
});
test('match3d creation card opens workspace even when public galleries fail', async () => {
const user = userEvent.setup();
const match3dSession = buildMockMatch3DAgentSession();
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
new Error('读取作品广场失败'),
);
vi.mocked(listMatch3DGallery).mockRejectedValueOnce(
new Error('读取抓大鹅广场失败'),
);
vi.mocked(match3dCreationClient.createSession).mockResolvedValueOnce({
session: match3dSession,
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
const button = screen.getByRole('button', {
name: /.*/u,
});
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
await user.click(button);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledWith({});
});
expect(await screen.findByText('抓大鹅工作区match3d-agent-session-1')).toBeTruthy();
});
test('puzzle draft card restores the bound agent session and opens the result view', async () => {
const user = userEvent.setup();

View File

@@ -270,6 +270,8 @@ export function useRpgEntryBootstrap(
if (galleryEntriesResult.status === 'fulfilled') {
setPublishedGalleryEntries(galleryEntriesResult.value);
} else {
// 中文注释:公开广场只影响首页展示,失败时降级为空列表;
// 私有作品库和创作作品列表的受保护失败才需要阻塞提示。
setPublishedGalleryEntries([]);
}
@@ -277,17 +279,14 @@ export function useRpgEntryBootstrap(
(canReadProtectedData &&
libraryEntriesResult.status === 'rejected') ||
(canReadProtectedData &&
workEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected'
workEntriesResult.status === 'rejected')
) {
const platformFailure =
libraryEntriesResult.status === 'rejected'
? libraryEntriesResult.reason
: workEntriesResult.status === 'rejected'
? workEntriesResult.reason
: galleryEntriesResult.status === 'rejected'
? galleryEntriesResult.reason
: null;
: null;
setPlatformError(
resolveRpgEntryErrorMessage(platformFailure, '读取平台数据失败。'),
);