This commit is contained in:
2026-04-30 17:49:07 +08:00
parent 805d6f8cae
commit 9d684cb7b3
615 changed files with 15368 additions and 6172 deletions
@@ -88,13 +88,34 @@ vi.mock('./rpg-runtime-shell', () => ({
session,
chrome,
}: {
session: { gameState: { currentScenePreset?: { name?: string } | null } };
session: {
gameState: {
currentScenePreset?: { id?: string; name?: string } | null;
playerCharacter?: { name?: string } | null;
runtimeSessionId?: string | null;
runtimeMode?: string;
runtimePersistenceDisabled?: boolean;
};
currentStory?: { text?: string } | null;
};
chrome?: { hidePlayerLevelBadge?: boolean };
}) => (
<div>
<div>幕预览运行时</div>
{chrome?.hidePlayerLevelBadge ? <div>隐藏等级徽标</div> : null}
<div>{session.gameState.currentScenePreset?.name ?? '未进入场景'}</div>
<div>{session.gameState.currentScenePreset?.id ?? '未进入场景ID'}</div>
<div>
{session.gameState.playerCharacter ? '已选择预览角色' : '未选择角色'}
</div>
<div>{session.gameState.runtimeSessionId ?? '未设置预览会话'}</div>
<div>{session.gameState.runtimeMode ?? '未设置运行模式'}</div>
<div>
{session.gameState.runtimePersistenceDisabled
? '预览禁用持久化'
: '预览允许持久化'}
</div>
<div>{session.currentStory?.text ?? '未生成当前故事'}</div>
</div>
),
}));
@@ -102,6 +123,30 @@ vi.mock('./rpg-runtime-shell', () => ({
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
resolveCharacterRoleAssetWorkflow: vi.fn(({ role }) =>
Promise.resolve({
ok: true,
cache: null,
workflow: {
role,
defaultPromptBundle: {
visualPromptText: '',
animationPromptText: '',
scenePromptText: '',
},
visualPromptText: '',
animationPromptText: '',
animationPromptTextByKey: {},
visualDrafts: [],
selectedVisualDraftId: '',
selectedAnimation: 'idle',
},
}),
),
putCharacterRoleAssetWorkflow: vi.fn().mockResolvedValue({
ok: true,
cache: null,
}),
generateCharacterVisualCandidates: vi.fn(),
publishCharacterVisualAsset: vi.fn(),
generateCharacterAnimationDraft: vi.fn(),
@@ -1312,6 +1357,13 @@ test('场景幕预览会打开当前幕运行时面板', async () => {
expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0);
expect(screen.getByText('隐藏等级徽标')).toBeTruthy();
expect(screen.getByText('已选择预览角色')).toBeTruthy();
expect(screen.getByText('runtime-scene-act-preview')).toBeTruthy();
expect(screen.getByText('landmark-1')).toBeTruthy();
expect(screen.getByText('play')).toBeTruthy();
expect(screen.getByText('预览禁用持久化')).toBeTruthy();
expect(screen.getByText(/顾潮音已经在沉钟栈桥等你/u)).toBeTruthy();
expect(screen.queryByText('正在载入这一幕的游戏流程...')).toBeNull();
await user.click(screen.getByRole('button', { name: '结束预览' }));
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
@@ -104,9 +104,9 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.queryByText('角色 3')).toBeNull();
expect(screen.queryByText('地点 4')).toBeNull();
expect(
screen.getByRole('button', { name: /角色扮演.*剧情演绎/u }),
).toBeTruthy();
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: /拼图.*创意礼物/u })).toBeTruthy();
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
@@ -43,7 +43,7 @@ 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).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).not.toContain('大鱼吃小鱼');
@@ -208,8 +208,11 @@ function mapPuzzleWorkToShelfItem(
id: item.workId,
kind: 'puzzle',
status,
title: item.levelName,
summary: item.summary,
title: item.workTitle?.trim() || item.levelName.trim() || '未命名拼图',
summary:
item.workDescription?.trim() ||
item.summary.trim() ||
(status === 'draft' ? '未填写作品描述' : ''),
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
@@ -29,13 +29,14 @@ function createPuzzleEntry(): PlatformPublicGalleryCard {
};
}
test('PlatformWorkDetailView renders compact stats and recent update time', () => {
test('PlatformWorkDetailView renders compact stats and date time', () => {
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
@@ -43,12 +44,53 @@ test('PlatformWorkDetailView renders compact stats and recent update time', () =
expect(screen.getByText('改造')).toBeTruthy();
expect(screen.getByText('游玩')).toBeTruthy();
expect(screen.getByText('点赞')).toBeTruthy();
expect(screen.getByText('最近更新')).toBeTruthy();
expect(screen.getAllByText('点赞').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('日期')).toBeTruthy();
expect(screen.queryByText('改造次数')).toBeNull();
expect(screen.queryByText('游玩次数')).toBeNull();
expect(screen.queryByText('上线日期')).toBeNull();
expect(screen.queryByText('最近更新')).toBeNull();
expect(screen.getByText('2026-04-25')).toBeTruthy();
expect(screen.getAllByText('次')).toHaveLength(2);
expect(screen.getByText('赞')).toBeTruthy();
expect(screen.getByRole('button', { name: '点赞 4赞' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品改造' })).toBeTruthy();
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
});
test('PlatformWorkDetailView prefers resolved public user display name', () => {
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
authorDisplayName="新的作者昵称"
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
expect(screen.getByText('新的作者昵称')).toBeTruthy();
expect(screen.queryByText('137****6613')).toBeNull();
});
test('PlatformWorkDetailView calls like handler', () => {
const onLike = vi.fn();
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={onLike}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '点赞 4赞' }));
expect(onLike).toHaveBeenCalledTimes(1);
});
@@ -27,9 +27,11 @@ import {
export interface PlatformWorkDetailViewProps {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
authorDisplayName?: string | null;
isBusy: boolean;
error: string | null;
onBack: () => void;
onLike: () => void;
onStart: () => void;
onRemix: () => void;
}
@@ -59,15 +61,19 @@ function getAuthorAvatarLabel(authorDisplayName: string) {
export function PlatformWorkDetailView({
entry,
authorAvatarUrl,
authorDisplayName,
isBusy,
error,
onBack,
onLike,
onStart,
onRemix,
}: PlatformWorkDetailViewProps) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const resolvedAuthorDisplayName =
authorDisplayName?.trim() || entry.authorDisplayName;
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
@@ -85,13 +91,6 @@ export function PlatformWorkDetailView({
);
const stats = resolvePlatformWorldStats(entry);
const statItems = [
{
label: '改造',
value: formatCompactCount(stats.remixCount),
unit: '次',
icon: GitFork,
tone: 'remix',
},
{
label: '游玩',
value: formatCompactCount(stats.playCount),
@@ -99,6 +98,13 @@ export function PlatformWorkDetailView({
icon: Gamepad2,
tone: 'play',
},
{
label: '改造',
value: formatCompactCount(stats.remixCount),
unit: '次',
icon: GitFork,
tone: 'remix',
},
{
label: '点赞',
value: formatCompactCount(stats.likeCount),
@@ -107,7 +113,7 @@ export function PlatformWorkDetailView({
tone: 'like',
},
{
label: '最近更新',
label: '日期',
value: formatPlatformWorldTime(stats.updatedAt ?? stats.publishedAt),
icon: Clock3,
tone: 'time',
@@ -199,9 +205,7 @@ export function PlatformWorkDetailView({
)}
</div>
<div className="min-w-0 flex-1">
<div className="platform-work-detail__name">
{displayName}
</div>
<div className="platform-work-detail__name">{displayName}</div>
<div className="platform-work-detail__author">
<span className="platform-work-detail__author-avatar">
{normalizedAuthorAvatarUrl ? (
@@ -213,23 +217,25 @@ export function PlatformWorkDetailView({
/>
) : (
<span className="platform-work-detail__author-avatar-label">
{getAuthorAvatarLabel(entry.authorDisplayName)}
{getAuthorAvatarLabel(resolvedAuthorDisplayName)}
</span>
)}
</span>
<span className="platform-work-detail__author-name">
{entry.authorDisplayName}
{resolvedAuthorDisplayName}
</span>
</div>
</div>
<button
type="button"
className="platform-work-detail__remix"
onClick={onRemix}
className="platform-work-detail__like"
onClick={onLike}
disabled={isBusy}
aria-label={`点赞 ${formatCompactCount(stats.likeCount)}赞`}
title="点赞"
>
<GitFork className="h-5 w-5" />
作品改造
<Heart className="h-5 w-5 fill-current" />
点赞
</button>
</div>
@@ -300,6 +306,15 @@ export function PlatformWorkDetailView({
</div>
<div className="platform-work-detail__bottom">
<button
type="button"
className="platform-work-detail__remix"
onClick={onRemix}
disabled={isBusy}
>
<GitFork className="h-5 w-5" />
作品改造
</button>
<button
type="button"
className="platform-work-detail__start"
@@ -37,9 +37,9 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
{
id: 'rpg',
title: '角色扮演',
subtitle: '剧情演绎,冒险成长',
badge: '可创建',
locked: false,
subtitle: '敬请期待',
badge: '敬请期待',
locked: true,
},
{
id: 'big-fish',
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
@@ -66,7 +66,11 @@ beforeEach(() => {
}
});
test('puzzle workspace submits the two-field form instead of agent chat', () => {
afterEach(() => {
vi.useRealTimers();
});
test('puzzle workspace submits the work form instead of agent chat', () => {
const onCreateFromForm = vi.fn();
render(
@@ -79,9 +83,12 @@ test('puzzle workspace submits the two-field form instead of agent chat', () =>
/>,
);
fireEvent.change(screen.getByLabelText('拼图标题'), {
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(screen.getByLabelText('作品描述'), {
target: { value: '一套雨夜猫街主题拼图。' },
});
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
@@ -89,6 +96,8 @@ test('puzzle workspace submits the two-field form instead of agent chat', () =>
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '暖灯猫街',
workTitle: '暖灯猫街',
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
});
@@ -98,6 +107,7 @@ test('puzzle workspace submits the two-field form instead of agent chat', () =>
test('puzzle workspace falls back to compile action for restored sessions', () => {
const onExecuteAction = vi.fn();
const onCreateFromForm = vi.fn();
render(
<PuzzleAgentWorkspace
@@ -105,15 +115,98 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
expect(onCreateFromForm).not.toHaveBeenCalled();
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'compile_puzzle_draft',
promptText: '潮雾中的灯塔与断桥',
workTitle: '雾港遗迹拼图',
workDescription: '雾港遗迹拼图',
pictureDescription: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
candidateCount: 1,
});
});
test('puzzle workspace restores form draft fields and autosaves edits', () => {
vi.useFakeTimers();
const onAutoSaveForm = vi.fn();
const formDraftSession: PuzzleAgentSessionSnapshot = {
...baseSession,
seedText:
'作品名称:旧街拼图\n作品描述:旧街雨夜的拼图草稿。\n画面描述:旧街灯牌下的猫。',
draft: {
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
levelName: '旧街灯牌',
summary: '旧街雨夜的拼图草稿。',
themeTags: ['旧街', '雨夜', '猫'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: baseSession.anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '旧街灯牌',
pictureDescription: '旧街灯牌下的猫。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
},
],
formDraft: {
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
pictureDescription: '旧街灯牌下的猫。',
},
},
};
render(
<PuzzleAgentWorkspace
session={formDraftSession}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onAutoSaveForm={onAutoSaveForm}
/>,
);
expect((screen.getByLabelText('作品名称') as HTMLInputElement).value).toBe(
'旧街拼图',
);
expect((screen.getByLabelText('作品描述') as HTMLTextAreaElement).value).toBe(
'旧街雨夜的拼图草稿。',
);
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
'旧街灯牌下的猫。',
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '旧街灯牌下的猫和发光雨伞。' },
});
act(() => {
vi.advanceTimersByTime(700);
});
expect(onAutoSaveForm).toHaveBeenCalledWith({
seedText: '旧街拼图',
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
});
});
@@ -1,5 +1,5 @@
import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
import { type ChangeEvent, useEffect, useState } from 'react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
@@ -7,6 +7,7 @@ import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
type PuzzleAgentWorkspaceProps = {
session: PuzzleAgentSessionSnapshot | null;
@@ -16,39 +17,48 @@ type PuzzleAgentWorkspaceProps = {
onSubmitMessage: (payload: SendPuzzleAgentMessageRequest) => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
onAutoSaveForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
};
type PuzzleFormState = {
title: string;
workTitle: string;
workDescription: string;
pictureDescription: string;
referenceImageSrc: string;
referenceImageLabel: string;
};
const EMPTY_FORM_STATE: PuzzleFormState = {
title: '',
workTitle: '',
workDescription: '',
pictureDescription: '',
referenceImageSrc: '',
referenceImageLabel: '',
};
function readPuzzleReferenceImageAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
}
function resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
): PuzzleFormState {
const formDraft = session?.draft?.formDraft;
if (formDraft) {
return {
workTitle: formDraft.workTitle ?? '',
workDescription: formDraft.workDescription ?? '',
pictureDescription: formDraft.pictureDescription ?? '',
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择参考图'
: '',
};
}
if (initialFormPayload) {
return {
title: initialFormPayload.seedText ?? '',
workTitle:
initialFormPayload.workTitle ?? initialFormPayload.seedText ?? '',
workDescription: initialFormPayload.workDescription ?? '',
pictureDescription: initialFormPayload.pictureDescription ?? '',
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload.referenceImageSrc
@@ -62,11 +72,17 @@ function resolveInitialFormState(
}
return {
title:
workTitle:
session.draft?.workTitle ||
session.draft?.levelName ||
session.seedText ||
session.anchorPack.themePromise.value ||
session.messages.find((message) => message.role === 'user')?.text ||
'',
workDescription:
session.draft?.workDescription ||
session.anchorPack.themePromise.value ||
'',
pictureDescription:
session.draft?.summary || session.anchorPack.visualSubject.value || '',
referenceImageSrc: '',
@@ -85,6 +101,7 @@ export function PuzzleAgentWorkspace({
onBack,
onExecuteAction,
onCreateFromForm,
onAutoSaveForm,
initialFormPayload = null,
}: PuzzleAgentWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
@@ -93,15 +110,95 @@ export function PuzzleAgentWorkspace({
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const previousSessionIdRef = useRef<string | null>(session?.sessionId ?? null);
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (
currentSessionId &&
previousSessionIdRef.current === null &&
appliedInitialFormKeyRef.current === JSON.stringify(initialFormPayload ?? null)
) {
previousSessionIdRef.current = currentSessionId;
return;
}
previousSessionIdRef.current = currentSessionId;
const nextInitialFormKey =
currentSessionId ?? JSON.stringify(initialFormPayload ?? null);
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
return;
}
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
}, [initialFormPayload, session]);
}, [initialFormPayload, session?.sessionId]);
const title = formState.title.trim();
const workTitle = formState.workTitle.trim();
const workDescription = formState.workDescription.trim();
const pictureDescription = formState.pictureDescription.trim();
const canSubmit = Boolean(title && pictureDescription) && !isBusy;
const canSubmit =
Boolean(workTitle && workDescription && pictureDescription) && !isBusy;
const autosavePayload = useMemo(
() => ({
seedText: workTitle,
workTitle,
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
}),
[
formState.referenceImageSrc,
pictureDescription,
workDescription,
workTitle,
],
);
const autosaveSignature = JSON.stringify([
autosavePayload.workTitle,
autosavePayload.workDescription,
autosavePayload.pictureDescription,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
const autosaveSessionIdRef = useRef(session?.sessionId ?? null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (autosaveSessionIdRef.current === currentSessionId) {
return;
}
autosaveSessionIdRef.current = currentSessionId;
lastAutosaveSignatureRef.current = autosaveSignature;
}, [autosaveSignature, session?.sessionId]);
useEffect(() => {
if (
!session ||
session.stage !== 'collecting_anchors' ||
!session.draft?.formDraft ||
!onAutoSaveForm ||
lastAutosaveSignatureRef.current === autosaveSignature
) {
return;
}
const timer = window.setTimeout(() => {
lastAutosaveSignatureRef.current = autosaveSignature;
onAutoSaveForm(autosavePayload);
}, 700);
return () => window.clearTimeout(timer);
}, [
autosavePayload,
autosaveSignature,
onAutoSaveForm,
session?.draft?.formDraft,
session?.stage,
session?.sessionId,
]);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
@@ -135,12 +232,14 @@ export function PuzzleAgentWorkspace({
}
const payload = {
seedText: title,
seedText: workTitle,
workTitle,
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
};
if (onCreateFromForm) {
if (!session && onCreateFromForm) {
onCreateFromForm(payload);
return;
}
@@ -148,6 +247,9 @@ export function PuzzleAgentWorkspace({
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: pictureDescription,
workTitle,
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
candidateCount: 1,
});
@@ -174,19 +276,38 @@ export function PuzzleAgentWorkspace({
<div className="space-y-5">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
拼图标题
作品名称
</span>
<input
value={formState.title}
value={formState.workTitle}
disabled={isBusy}
onChange={(event) =>
setFormState((current) => ({
...current,
title: event.target.value,
workTitle: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="拼图标题"
aria-label="作品名称"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
作品描述
</span>
<textarea
value={formState.workDescription}
disabled={isBusy}
rows={4}
onChange={(event) =>
setFormState((current) => ({
...current,
workDescription: event.target.value,
}))
}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="作品描述"
/>
</label>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -294,6 +294,38 @@ test('合并块按实际拼块外轮廓描边', () => {
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
});
test('基础单块使用圆角裁剪图片', () => {
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
coverImageSrc: '/puzzle.png',
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const basePiece = container.querySelector(
'[data-piece-id="piece-0"]',
) as HTMLElement | null;
expect(basePiece?.className).toContain('overflow-hidden');
expect(basePiece?.className).toContain('rounded-[0.85rem]');
});
test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockResolvedValue(clearedRun);
@@ -1171,7 +1171,7 @@ export function PuzzleRuntimeShell({
pieceElementRefMap.current.delete(piece.pieceId);
}}
data-piece-id={piece?.pieceId ?? undefined}
className={`relative flex h-full items-center justify-center border-2 border-white/22 text-sm font-black transition ${
className={`relative flex h-full items-center justify-center overflow-hidden rounded-[0.85rem] border-2 border-white/22 text-sm font-black transition ${
occupied
? isSelected
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
@@ -14,14 +14,23 @@ import { createPortal } from 'react-dom';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
buildCustomWorldRuntimeCharacters,
createCharacterSkillCooldowns,
getCharacterMaxHp,
getCharacterMaxMana,
ROLE_TEMPLATE_CHARACTERS,
setRuntimeCharacterOverrides,
} from '../../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../../data/customWorldVisuals';
import { buildInitialEquipmentLoadout } from '../../data/equipmentEffects';
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews';
import { buildEncounterFromSceneNpc } from '../../data/scenePresets';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient';
@@ -75,6 +84,8 @@ import {
type SceneChapterBlueprint,
type SceneNpc,
type ScenePresetInfo,
type StoryMoment,
type StoryOption,
WorldType,
} from '../../types';
import {
@@ -1946,6 +1957,93 @@ function buildSceneActPreviewScenePreset(params: {
};
}
const SCENE_ACT_PREVIEW_SESSION_ID = 'runtime-scene-act-preview';
function buildSceneActPreviewNpcOption(params: {
functionId: string;
actionText: string;
npcId: string;
action: 'chat' | 'fight' | 'leave';
}): StoryOption {
return {
functionId: params.functionId,
actionText: params.actionText,
text: params.actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: params.npcId,
action: params.action,
},
};
}
function buildSceneActPreviewOpeningStory(params: {
sceneName: string;
encounter: NonNullable<ReturnType<typeof buildEncounterFromSceneNpc>>;
}): StoryMoment {
const npcId = params.encounter.id ?? params.encounter.npcName;
const openingText = `${params.encounter.npcName}已经在${params.sceneName || '当前场景'}等你。`;
return {
text: openingText,
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: params.encounter.npcName,
text: openingText,
},
],
streaming: false,
npcChatState: {
npcId,
npcName: params.encounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: 'npc_initiated',
sceneActId: null,
turnLimit: null,
remainingTurns: null,
limitReason: null,
forceExitAfterTurn: false,
terminationMode: null,
terminationReason: null,
isHostileChat: false,
pendingQuestOffer: null,
combatContext: null,
},
options: [
buildSceneActPreviewNpcOption({
functionId: 'npc_chat',
actionText: '先听他说完',
npcId,
action: 'chat',
}),
buildSceneActPreviewNpcOption({
functionId: 'npc_fight',
actionText: '与他对战',
npcId,
action: 'fight',
}),
buildSceneActPreviewNpcOption({
functionId: 'npc_leave',
actionText: '暂时离开',
npcId,
action: 'leave',
}),
],
};
}
function SceneActPreviewRuntime({
profile,
landmark,
@@ -2045,8 +2143,10 @@ function SceneActPreviewRuntime({
useNpcInteractionFlow(gameState);
const isPreviewReady =
gameState.currentScene === 'Story' &&
Boolean(gameState.playerCharacter) &&
gameState.currentScenePreset?.id === landmark.id;
gameState.runtimeSessionId === SCENE_ACT_PREVIEW_SESSION_ID &&
gameState.playerCharacter?.id === previewCharacter?.id &&
gameState.currentScenePreset?.id === previewScenePreset?.id &&
Boolean(storyFlow.currentStory);
useEffect(() => {
if (
@@ -2064,28 +2164,64 @@ function SceneActPreviewRuntime({
storyFlow.resetStoryState();
setBottomTab('adventure');
setIsMapOpen(false);
handleCustomWorldSelect(profile);
handleCharacterSelect(previewCharacter);
// 中文注释:幕预览只需要同步静态资料层,不能调用正式选世界入口;
// 后者会排队写入“已选世界但未选角”的中间态,把本地预览 GameState 覆盖回加载中。
setRuntimeCustomWorldProfile(profile);
setRuntimeCharacterOverrides(buildCustomWorldRuntimeCharacters(profile));
const previewCharacterMaxHp = getCharacterMaxHp(
previewCharacter,
WorldType.CUSTOM,
profile,
);
const previewCharacterMaxMana = getCharacterMaxMana(previewCharacter);
const previewEquipment = buildInitialEquipmentLoadout(
previewCharacter,
profile,
);
setGameState((current) => ({
...current,
worldType: WorldType.CUSTOM,
customWorldProfile: profile,
playerCharacter: previewCharacter,
runtimeSessionId: SCENE_ACT_PREVIEW_SESSION_ID,
runtimeActionVersion: 1,
// 中文注释:幕预览也统一复用正式 play 运行链,
// 只通过禁持久化控制“不写正式存档”。
runtimeMode: 'play',
runtimePersistenceDisabled: true,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
currentScenePreset: previewScenePreset,
currentEncounter: previewEncounter,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
playerHp: previewCharacterMaxHp,
playerMaxHp: previewCharacterMaxHp,
playerMana: previewCharacterMaxMana,
playerMaxMana: previewCharacterMaxMana,
playerSkillCooldowns: createCharacterSkillCooldowns(previewCharacter),
playerCurrency: 0,
playerInventory: [],
playerEquipment: previewEquipment,
npcStates: {},
quests: [],
roster: [],
companions: [],
storyHistory: [],
chapterState: null,
campaignState: null,
activeScenarioPackId: profile.scenarioPackId ?? null,
activeCampaignPackId: profile.campaignPackId ?? null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
characterChats: {},
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -2102,11 +2238,14 @@ function SceneActPreviewRuntime({
currentSceneActState: previewActRuntimeState,
},
}));
storyFlow.hydrateStoryState(
buildSceneActPreviewOpeningStory({
sceneName: previewScenePreset.name,
encounter: previewEncounter,
}),
);
}, [
act,
handleCharacterSelect,
handleCustomWorldSelect,
landmark.id,
previewActRuntimeState,
previewCharacter,
previewEncounter,
File diff suppressed because it is too large Load Diff
@@ -133,6 +133,8 @@ const puzzlePublicEntry = {
summaryText: '一张用于公开分享的拼图作品。',
coverImageSrc: null,
themeTags: ['奇幻'],
playCount: 20,
remixCount: 5,
likeCount: 12,
visibility: 'published',
publishedAt: '1777110165.990127Z',
@@ -553,22 +555,40 @@ test('public gallery cards hide work code until detail is opened', async () => {
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});
test('mobile public work cards render cover, content and like count', () => {
test('mobile public work cards render cover, author, kind and cover stats', () => {
const { container } = renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
});
const card = screen.getByRole('button', {
name: /奇幻拼图,12点赞/u,
name: /奇幻拼图,拼图,20游玩,5改造,12点赞/u,
});
expect(
card.querySelector('.platform-public-work-card__cover.aspect-video'),
).toBeTruthy();
expect(
card.querySelector('.platform-public-work-card__cover-stats'),
).toBeTruthy();
expect(
card.querySelectorAll('.platform-public-work-card__cover-stat'),
).toHaveLength(3);
expect(
card.querySelector('.platform-public-work-card__kind')?.textContent,
).toBe('拼图');
expect(
card.querySelector('.platform-public-work-card__author-avatar')
?.textContent,
).toBe('拼');
expect(screen.getByText('奇幻拼图')).toBeTruthy();
expect(screen.getByText('拼图玩家')).toBeTruthy();
expect(screen.getByText('一张用于公开分享的拼图作品。')).toBeTruthy();
expect(screen.getByText('奇幻')).toBeTruthy();
expect(screen.getByText('20')).toBeTruthy();
expect(screen.getByText('5')).toBeTruthy();
expect(screen.getByText('12')).toBeTruthy();
expect(screen.getByText('点赞')).toBeTruthy();
expect(card.querySelector('.platform-pill--warm')?.textContent).not.toBe(
'推荐',
);
expect(
container.querySelector('.platform-mobile-home-channel--active')
?.textContent,
+70 -40
View File
@@ -9,6 +9,7 @@ import {
Clock3,
Coins,
Copy,
Gamepad2,
Heart,
House,
LogIn,
@@ -362,22 +363,40 @@ function SaveArchivePreview({
function WorldCard({
entry,
badge,
metaLabel,
onClick,
className,
}: {
entry: PlatformWorldCardLike;
badge: string;
metaLabel: string;
entry: PlatformPublicGalleryCard;
onClick: () => void;
className?: string;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 3);
const playCount = getPlatformWorldPlayCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
const cardLabel = `${entry.worldName},${formatCompactCount(likeCount)}点赞`;
const typeLabel = describePublicGalleryCardKind(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const cardLabel = `${entry.worldName},${typeLabel},${formatCompactCount(playCount)}游玩,${formatCompactCount(remixCount)}改造,${formatCompactCount(likeCount)}点赞`;
const coverStats = [
{
label: '游玩',
value: playCount,
icon: Gamepad2,
},
{
label: '改造',
value: remixCount,
icon: Pencil,
},
{
label: '点赞',
value: likeCount,
icon: Heart,
},
];
return (
<button
@@ -397,13 +416,16 @@ function WorldCard({
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.02),rgba(0,0,0,0.18))]" />
<div className="absolute left-3 top-3 flex min-w-0 max-w-[calc(100%-1.5rem)] flex-wrap gap-1.5">
<span className="platform-pill platform-pill--warm max-w-[9rem] truncate px-2.5">
{badge}
</span>
<span className="platform-pill platform-pill--neutral max-w-[9rem] truncate px-2.5">
{metaLabel}
</span>
<div className="platform-public-work-card__cover-stats">
{coverStats.map(({ label, value, icon: Icon }) => (
<span key={label} className="platform-public-work-card__cover-stat">
<Icon
className={`h-3.5 w-3.5 ${label === '点赞' ? 'fill-current' : ''}`}
aria-hidden="true"
/>
<span>{formatCompactCount(value)}</span>
</span>
))}
</div>
</div>
@@ -413,21 +435,21 @@ function WorldCard({
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
{displayName}
</div>
{entry.subtitle ? (
<div className="mt-0.5 line-clamp-1 break-words text-[11px] font-medium text-[var(--platform-text-soft)]">
{entry.subtitle}
</div>
) : null}
</div>
<div className="platform-public-work-card__likes shrink-0 text-right">
<div className="flex items-center justify-end gap-1 text-xs font-black text-[var(--platform-warm-text)]">
<Heart className="h-3.5 w-3.5 fill-current" />
<span>{formatCompactCount(likeCount)}</span>
</div>
<div className="mt-0.5 text-[10px] font-semibold text-[var(--platform-text-soft)]">
点赞
<div className="mt-1 flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="platform-public-work-card__author-avatar"
>
{authorAvatarLabel}
</span>
<span className="line-clamp-1 break-words text-[11px] font-semibold text-[var(--platform-text-soft)]">
{authorName}
</span>
</div>
</div>
<span className="platform-public-work-card__kind shrink-0">
{typeLabel}
</span>
</div>
<div className="line-clamp-2 break-words text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)]">
@@ -960,6 +982,10 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
return formatPlatformWorkDisplayTag(kind);
}
function getPublicAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
}
function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(entry.likeCount ?? 0));
}
@@ -1404,9 +1430,16 @@ function ProfileNicknameModal({
}) {
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
<div
role="dialog"
aria-modal="true"
aria-labelledby="profile-nickname-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-black">修改昵称</div>
<div id="profile-nickname-title" className="text-base font-black">
修改昵称
</div>
<button
type="button"
aria-label="关闭昵称修改"
@@ -1529,9 +1562,16 @@ function ProfileAvatarCropModal({
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
<div
role="dialog"
aria-modal="true"
aria-labelledby="profile-avatar-crop-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-black">裁剪头像</div>
<div id="profile-avatar-crop-title" className="text-base font-black">
裁剪头像
</div>
<button
type="button"
aria-label="关闭头像裁剪"
@@ -2726,12 +2766,6 @@ export function RpgEntryHomeView({
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:mobile-feed:${mobileHomeChannel}`}
entry={entry}
badge={
mobileHomeChannel === 'recommend'
? '推荐'
: describePublicGalleryCardKind(entry)
}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
/>
@@ -3244,8 +3278,6 @@ export function RpgEntryHomeView({
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
entry={entry}
badge="推荐"
metaLabel={describePublicGalleryCardKind(entry)}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full min-w-0"
/>
@@ -3375,8 +3407,6 @@ export function RpgEntryHomeView({
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
entry={entry}
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full min-w-0"
/>
@@ -96,9 +96,9 @@ export function mapPuzzleWorkToPlatformGalleryCard(
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: work.authorDisplayName,
worldName: work.levelName,
worldName: work.workTitle || work.levelName,
subtitle: '拼图关卡',
summaryText: work.summary,
summaryText: work.workDescription || work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: work.themeTags,
playCount: work.playCount ?? 0,
@@ -120,7 +120,7 @@ export function mapBigFishWorkToPlatformGalleryCard(
profileId: work.sourceSessionId,
publicWorkCode: buildBigFishPublicWorkCode(work.sourceSessionId),
ownerUserId: work.ownerUserId,
authorDisplayName: '大鱼陶泥主',
authorDisplayName: work.authorDisplayName,
worldName: work.title,
subtitle: work.subtitle || '大鱼吃小鱼',
summaryText: work.summary,
@@ -315,6 +315,20 @@ describe('createStoryChoiceActions', () => {
treasureHints: [],
npcs: [],
},
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: 'scene-bridge',
chapterId: 'chapter-bridge',
currentActId: 'act-2',
currentActIndex: 1,
completedActIds: ['act-1'],
visitedActIds: ['act-1', 'act-2'],
},
},
},
};
const setCurrentStory = vi.fn();
@@ -369,11 +383,13 @@ describe('createStoryChoiceActions', () => {
currentScenePreset: expect.objectContaining({
id: 'scene-bridge',
}),
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
currentActId: 'act-2',
}),
}),
}),
);
expect(setGameState.mock.calls[0]?.[0]).not.toHaveProperty(
'storyEngineMemory',
);
expect(setCurrentStory).toHaveBeenCalledWith({
...currentStory,
options: deferredOptions,
@@ -198,6 +198,9 @@ export function createStoryChoiceActions({
currentScenePreset:
currentStory.deferredRuntimeState.currentScenePreset ??
gameState.currentScenePreset,
storyEngineMemory:
currentStory.deferredRuntimeState.storyEngineMemory ??
gameState.storyEngineMemory,
});
}
setCurrentStory({
@@ -175,7 +175,7 @@ function createState(overrides: Partial<GameState> = {}): GameState {
function createSceneActProfile(
primaryNpcId = 'npc-rival',
actCount = 1,
actCount = 2,
): NonNullable<GameState['customWorldProfile']> {
const acts = Array.from({ length: actCount }, (_, index) => ({
id: `scene-bridge-act-${index + 1}`,
@@ -847,6 +847,79 @@ describe('npcEncounterActions', () => {
]);
});
it('streams a model-driven npc-initiated opening after first contact was resolved', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '又见面了。桥口的风比刚才更乱,我先把你漏掉的那句话补上。',
suggestions: ['你先说我漏掉了什么'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: 8,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
}),
currentStory: {
text: '断桥客重新抬眼,像是准备先开口。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '继续和断桥客搭话')).toBe(true);
await flushAsyncWork();
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ id: 'npc-rival' }),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'',
expect.objectContaining({
affinity: 8,
chattedCount: 1,
}),
expect.objectContaining({
npcInitiatesConversation: true,
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '又见面了。桥口的风比刚才更乱,我先把你漏掉的那句话补上。',
},
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '你先说我漏掉了什么',
}),
]);
});
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 1,
@@ -960,6 +1033,7 @@ describe('npcEncounterActions', () => {
it('sends a closing chat turn after exiting npc chat and keeps the dialogue panel until continue', async () => {
const gameState = createState({
customWorldProfile: createSceneActProfile(),
storyHistory: [
{
text: '你先试探了对方的态度。',
@@ -1026,11 +1100,17 @@ describe('npcEncounterActions', () => {
forceExitAfterTurn: true,
functionOptions: expect.arrayContaining([
expect.objectContaining({ functionId: 'npc_help' }),
expect.objectContaining({ functionId: 'npc_fight' }),
]),
}),
}),
);
const exitChatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(exitChatDirective.functionOptions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ functionId: 'npc_fight' }),
]),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
@@ -1051,11 +1131,35 @@ describe('npcEncounterActions', () => {
functionId: 'story_continue_adventure',
}),
]);
expect(
lastStory.deferredRuntimeState?.storyEngineMemory?.currentSceneActState,
).toEqual(
expect.objectContaining({
currentActId: 'scene-bridge-act-2',
completedActIds: expect.arrayContaining(['scene-bridge-act-1']),
}),
);
expect(lastStory.deferredOptions).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
interaction: expect.objectContaining({
npcId: 'npc-rival',
action: 'chat',
}),
}),
expect.objectContaining({
functionId: 'npc_chat',
interaction: expect.objectContaining({
npcId: 'npc-rival',
action: 'chat',
}),
}),
]);
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
});
it('feeds current story non-chat function options into npc chat context', async () => {
it('feeds allowed positive-affinity function options into npc chat context', async () => {
const gameState = createState({
storyHistory: [
{
@@ -1149,14 +1253,17 @@ describe('npcEncounterActions', () => {
functionId: 'npc_help',
actionText: '借你的人脉把线索铺开',
}),
expect.objectContaining({
functionId: 'npc_fight',
actionText: '现在就把这笔旧账打清',
}),
]),
}),
}),
);
const chatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(chatDirective.functionOptions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ functionId: 'npc_fight' }),
]),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.options).toEqual(
@@ -1235,6 +1342,163 @@ describe('npcEncounterActions', () => {
);
});
it('keeps negative-affinity chat function context empty so only chat choices appear mid-dialogue', async () => {
const gameState = createState({
customWorldProfile: createSceneActProfile(),
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
});
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '关系暂未变化',
npcReply: '你只剩这一句话的机会。',
suggestions: ['我只问最后一句'],
functionSuggestions: [
{
functionId: 'npc_help',
actionText: '让你帮我一次',
},
],
});
const actions = createNpcEncounterActions({
gameState,
currentStory: {
text: '断桥客没有放下戒备。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '断桥客', text: '别再靠近。' },
],
options: [
createOption('npc_chat', '先问清最后一句', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 1,
customInputPlaceholder: '输入你想对 TA 说的话',
},
},
getAvailableOptionsForState: vi.fn(() => [
createOption('npc_chat', '先问清最后一句', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
]),
});
await expect(
actions.handleNpcChatTurn(createEncounter(), '我只问最后一句。'),
).resolves.toBe(true);
const chatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(chatDirective.functionOptions).toEqual([]);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '我只问最后一句',
}),
]);
});
it('sends hostile termination mode for any negative-affinity npc chat turn', async () => {
const gameState = createState({
customWorldProfile: null,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
});
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '关系暂未变化',
npcReply: '话到这里就够了。',
suggestions: [],
chatDirective: {
forceExit: true,
terminationReason: 'hostile_breakoff',
},
});
const actions = createNpcEncounterActions({
gameState,
currentStory: {
text: '断桥客没有放下戒备。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '断桥客', text: '别再靠近。' },
],
options: [
createOption('npc_chat', '先问清最后一句', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 1,
customInputPlaceholder: '输入你想对 TA 说的话',
},
},
});
await expect(
actions.handleNpcChatTurn(createEncounter(), '我只问最后一句。'),
).resolves.toBe(true);
const chatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(chatDirective).toMatchObject({
turnLimit: null,
remainingTurns: null,
limitReason: 'negative_affinity',
terminationMode: 'hostile_model',
isHostileChat: true,
functionOptions: [],
});
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect.objectContaining({ functionId: 'npc_fight' }),
expect.objectContaining({ functionId: 'battle_escape_breakout' }),
expect.objectContaining({ functionId: 'battle_escape_breakout' }),
expect.objectContaining({ functionId: 'battle_escape_breakout' }),
]);
});
it('lets the current act primary npc enter limited chat even with negative affinity', () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
@@ -1287,7 +1551,7 @@ describe('npcEncounterActions', () => {
).toBe(false);
});
it('streams npc-initiated opening when negative affinity chat starts from interaction options', async () => {
it('streams npc-initiated opening when resolved negative affinity chat starts from interaction options', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
@@ -1308,7 +1572,7 @@ describe('npcEncounterActions', () => {
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
firstMeaningfulContactResolved: true,
},
},
}),
@@ -1375,6 +1639,86 @@ describe('npcEncounterActions', () => {
]);
});
it('lets the model terminate a hostile npc-initiated opening immediately', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '离桥口远一点。再往前,我就不听你解释了。',
suggestions: [],
functionSuggestions: [],
chatDirective: {
forceExit: true,
closingMode: 'foreshadow_close',
terminationReason: 'hostile_breakoff',
},
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
customWorldProfile: createSceneActProfile(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
},
},
}),
currentStory: {
text: '断桥客仍挡在桥口。',
options: [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
},
});
expect(
actions.handleNpcInteraction(
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
),
).toBe(true);
await flushAsyncWork();
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '离桥口远一点。再往前,我就不听你解释了。',
},
]);
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_fight',
actionText: '战斗',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
]);
});
it('lets player exit hostile chat and offers fight plus scene escape routes instead of continuing adventure', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
@@ -17,14 +17,18 @@ import {
applyQuestProgressFromSpar,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import { getScenePresetById } from '../../data/scenePresets';
import { resolveFunctionOption } from '../../data/stateFunctions';
import { streamNpcChatTurn } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
advanceSceneActRuntimeState,
getSceneConnectionDirectionText,
resolveSceneActProgression,
resolveLimitedPrimaryNpcChatState,
} from '../../services/customWorldSceneActRuntime';
import { normalizeStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
Character,
Encounter,
@@ -389,6 +393,9 @@ export function createStoryNpcEncounterActions({
}),
params.encounter,
playerCharacter,
{
sourceState: params.nextState,
},
);
setCurrentStory(
@@ -744,12 +751,32 @@ export function createStoryNpcEncounterActions({
const buildNpcChatFunctionOptionCatalog = (
encounter: Encounter,
playerCharacter: Character,
) =>
buildPostNpcChatOptionCatalog(encounter, playerCharacter)
sourceState: GameState = gameState,
) => {
const npcState = getResolvedNpcState(sourceState, encounter);
if (npcState.affinity < 0) {
return [];
}
const allowedFunctionIds = new Set([
'npc_help',
'npc_trade',
'npc_gift',
'npc_quest_accept',
'npc_quest_turn_in',
'npc_recruit',
'npc_chat_quest_offer_view',
'npc_chat_quest_offer_replace',
'npc_chat_quest_offer_abandon',
]);
return buildPostNpcChatOptionCatalog(encounter, playerCharacter)
.filter((option) => option.functionId !== 'battle_escape_breakout')
.filter((option) => !isNpcChatOptionForEncounter(option, encounter))
.filter((option) => option.interaction?.kind === 'npc')
.filter((option) => allowedFunctionIds.has(option.functionId))
.map(cloneNpcChatFunctionOption);
};
const toNpcChatDirectiveWithFunctionOptions = (
directive: NpcChatDirective,
@@ -757,11 +784,15 @@ export function createStoryNpcEncounterActions({
playerCharacter: Character,
options?: {
forcePlayerExit?: boolean;
sourceState?: GameState;
},
): NpcChatDirective => {
const sourceState = options?.sourceState ?? gameState;
const npcState = getResolvedNpcState(sourceState, encounter);
const functionOptions = buildNpcChatFunctionOptionCatalog(
encounter,
playerCharacter,
sourceState,
).map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
@@ -769,12 +800,22 @@ export function createStoryNpcEncounterActions({
action:
option.interaction?.kind === 'npc' ? option.interaction.action : null,
}));
const isHostileChat =
// 中文注释:只要当前 NPC 仍是负好感,本轮聊天就必须交给模型判断是否主动中止,不能只依赖场景幕 directive。
const shouldForceHostileModelChat =
npcState.affinity < 0 ||
directive?.limitReason === 'negative_affinity' ||
directive?.isHostileChat === true ||
directive?.terminationMode === 'hostile_model';
const isHostileChat =
shouldForceHostileModelChat || encounter.hostile === true;
return {
...(directive ?? {}),
turnLimit: directive?.turnLimit ?? null,
remainingTurns: directive?.remainingTurns ?? null,
limitReason:
directive?.limitReason ??
(npcState.affinity < 0 ? 'negative_affinity' : null),
terminationMode: isHostileChat ? 'hostile_model' : 'none',
isHostileChat,
terminationReason: options?.forcePlayerExit
@@ -988,14 +1029,57 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
playerCharacter: Character,
) => {
const travelOptions = buildSceneConnectionTravelOptions(gameState);
const sceneActProgression = resolveSceneActProgression({
profile: gameState.customWorldProfile,
sceneId: gameState.currentScenePreset?.id ?? null,
storyEngineMemory: gameState.storyEngineMemory,
});
const nextSceneActState = sceneActProgression
? advanceSceneActRuntimeState({
progress: sceneActProgression,
})
: null;
if (nextSceneActState) {
const nextStoryEngineMemory = {
...normalizeStoryEngineMemoryState(gameState.storyEngineMemory),
currentSceneActState: nextSceneActState,
};
const nextState: GameState = {
...ensureSceneEncounterPreview({
...gameState,
storyEngineMemory: nextStoryEngineMemory,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
}),
};
const nextOptions =
getAvailableOptionsForState(nextState, playerCharacter) ??
buildSceneConnectionTravelOptions(nextState);
const nextActEntryOptions = nextOptions.filter(
(option) =>
option.functionId === 'npc_preview_talk' ||
option.functionId === 'npc_chat',
);
return {
deferredRuntimeState: {
currentScenePreset: gameState.currentScenePreset,
storyEngineMemory: nextStoryEngineMemory,
},
options:
nextActEntryOptions.length > 0 ? nextActEntryOptions : nextOptions,
};
}
return {
deferredRuntimeState: null,
options:
travelOptions.length > 0
? travelOptions
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
options: buildSceneConnectionTravelOptions(gameState),
};
};
@@ -1045,23 +1129,6 @@ export function createStoryNpcEncounterActions({
)
: [];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
affinity: number,
) => {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (
character: Character,
actionText = '逃跑',
@@ -1178,31 +1245,6 @@ export function createStoryNpcEncounterActions({
},
});
const buildHostileNpcStoryMoment = (
encounter: Encounter,
character: Character,
affinity: number,
): StoryMoment => {
const declarationText = buildHostileNpcDeclarationText(encounter, affinity);
return {
text: declarationText,
options: [
buildHostileNpcFightOption(encounter),
...buildHostileNpcEscapeOptions(character),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: declarationText,
},
],
streaming: false,
};
};
const shouldUseHostileNpcChatClosureOptions = (
directive: NpcChatDirective,
affinity: number,
@@ -1369,17 +1411,62 @@ export function createStoryNpcEncounterActions({
throw new Error('NPC 主动开场结果为空');
}
const resolvedOpeningDirective = {
sceneActId: resolvedChatDirective?.sceneActId ?? null,
turnLimit:
chatTurn.chatDirective?.turnLimit ??
resolvedChatDirective?.turnLimit ??
null,
remainingTurns:
chatTurn.chatDirective?.remainingTurns ??
resolvedChatDirective?.remainingTurns ??
null,
limitReason: resolvedChatDirective?.limitReason ?? null,
terminationMode: resolvedChatDirective?.terminationMode ?? null,
terminationReason:
chatTurn.chatDirective?.terminationReason ??
resolvedChatDirective?.terminationReason ??
null,
isHostileChat: resolvedChatDirective?.isHostileChat ?? false,
closingMode:
chatTurn.chatDirective?.closingMode ??
resolvedChatDirective?.closingMode ??
'free',
forceExitAfterTurn:
chatTurn.chatDirective?.forceExit ??
resolvedChatDirective?.forceExitAfterTurn ??
false,
} satisfies NonNullable<NpcChatDirective>;
const openingDialogue = [
...existingDialogue,
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
];
if (resolvedOpeningDirective.forceExitAfterTurn) {
setCurrentStory({
text: openingDialogue.map((turn) => turn.text).join('\n'),
options: buildNpcChatClosureOptions(
encounter,
playerCharacter,
resolvedOpeningDirective,
npcState.affinity,
),
displayMode: 'dialogue',
dialogue: openingDialogue,
streaming: false,
deferredOptions: undefined,
deferredRuntimeState: undefined,
});
return true;
}
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
],
dialogue: openingDialogue,
options: buildNpcChatMixedTurnOptions(
encounter,
playerCharacter,
@@ -1390,7 +1477,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: 0,
chatDirective: resolvedChatDirective,
chatDirective: resolvedOpeningDirective,
openingSource: 'npc_initiated',
}),
);
@@ -1731,6 +1818,15 @@ export function createStoryNpcEncounterActions({
);
const nextState = {
...gameState,
...(progressionResult.deferredRuntimeState?.storyEngineMemory
? {
storyEngineMemory:
progressionResult.deferredRuntimeState.storyEngineMemory,
}
: {}),
currentScenePreset:
progressionResult.deferredRuntimeState?.currentScenePreset ??
gameState.currentScenePreset,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
@@ -1738,8 +1834,6 @@ export function createStoryNpcEncounterActions({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentScenePreset: gameState.currentScenePreset,
storyEngineMemory: gameState.storyEngineMemory,
};
setGameState(nextState);
@@ -1812,36 +1906,14 @@ export function createStoryNpcEncounterActions({
},
} satisfies StoryOption);
if (
!currentStory?.npcChatState &&
!npcState.firstMeaningfulContactResolved
) {
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
}
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
setCurrentStory(
buildHostileNpcStoryMoment(
encounter,
playerCharacter,
npcState.affinity,
),
);
return true;
}
return enterNpcChat(
// 中文注释:每次从 NPC 入口新开聊天,都必须由模型生成 NPC 首句;首遇标记只用于关系结算,不再决定谁先开口。
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
};
const resolveServerNpcStoryAction = async (params: {
@@ -2092,17 +2164,14 @@ export function createStoryNpcEncounterActions({
nextTurnCount: 0,
});
if (!npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
resolvedOption,
[],
limitedChatDirective,
);
return true;
}
return enterNpcChat(encounter, resolvedOption);
// 中文注释:不在已有聊天里时,点击聊天入口也重新走 NPC 模型首句,避免回到玩家先选话题的旧分支。
void startNpcInitiatedOpening(
encounter,
resolvedOption,
[],
limitedChatDirective,
);
return true;
}
case 'quest_accept': {
void resolveServerNpcStoryAction({
+93 -7
View File
@@ -883,8 +883,63 @@ body {
background: color-mix(in srgb, var(--platform-subpanel-fill) 92%, #000 8%);
}
.platform-public-work-card__likes {
min-width: 3.2rem;
.platform-public-work-card__cover-stats {
position: absolute;
right: 0.65rem;
bottom: 0.55rem;
display: flex;
max-width: calc(100% - 1.3rem);
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.35rem;
}
.platform-public-work-card__cover-stat {
display: inline-flex;
min-height: 1.55rem;
align-items: center;
gap: 0.22rem;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 9999px;
background: rgba(0, 0, 0, 0.36);
color: rgba(255, 255, 255, 0.92);
padding: 0.16rem 0.48rem;
font-size: 0.68rem;
font-weight: 800;
line-height: 1;
backdrop-filter: blur(10px);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.18);
}
.platform-public-work-card__kind {
max-width: 4.5rem;
border: 1px solid var(--platform-cool-border);
border-radius: 9999px;
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
padding: 0.28rem 0.6rem;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.platform-public-work-card__author-avatar {
display: inline-flex;
width: 1.15rem;
height: 1.15rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-subpanel-border);
border-radius: 9999px;
background: var(--platform-profile-avatar-fill);
color: var(--platform-button-primary-text);
font-size: 0.62rem;
font-weight: 900;
line-height: 1;
}
.platform-ranking-panel {
@@ -3852,21 +3907,35 @@ button {
white-space: nowrap;
}
.platform-work-detail__remix {
.platform-work-detail__like {
display: inline-flex;
min-width: 5.2rem;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
border: 1px solid var(--platform-button-primary-border);
border: 1px solid
color-mix(
in srgb,
var(--platform-work-like-accent, #ff6b6b) 24%,
transparent
);
border-radius: 1rem;
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
background: color-mix(
in srgb,
var(--platform-work-like-accent, #ff6b6b) 10%,
var(--platform-panel-fill) 90%
);
color: var(--platform-work-like-accent, #ff6b6b);
padding: 0.6rem 0.75rem;
font-size: 0.8125rem;
font-weight: 900;
box-shadow: var(--platform-profile-action-shadow);
box-shadow: 0 0.55rem 1.2rem
color-mix(
in srgb,
var(--platform-work-like-accent, #ff6b6b) 10%,
transparent
);
}
.platform-work-detail__remix:disabled,
@@ -4051,12 +4120,15 @@ button {
bottom: 0;
z-index: 5;
display: grid;
grid-template-columns: minmax(7.2rem, 0.44fr) minmax(0, 1fr);
gap: 0.7rem;
padding: 0.9rem 1rem calc(env(safe-area-inset-bottom, 0px) + 0.9rem);
border-top: 1px solid var(--platform-line-soft);
background: var(--platform-panel-fill);
backdrop-filter: blur(18px);
}
.platform-work-detail__remix,
.platform-work-detail__start {
display: inline-flex;
min-height: 3.4rem;
@@ -4072,6 +4144,20 @@ button {
box-shadow: var(--platform-profile-action-shadow);
}
.platform-work-detail__remix {
border: 1px solid var(--platform-neutral-border);
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
font-size: 0.875rem;
}
.platform-work-detail__start {
border: 1px solid var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
font-size: 1rem;
}
@media (min-width: 768px) {
.platform-work-detail {
border-radius: 1.2rem;
+38
View File
@@ -457,4 +457,42 @@ describe('apiClient', () => {
},
});
});
it('uses api error details.message as ApiClientError message', async () => {
setStoredAccessToken('details-message-token', { emit: false });
fetchMock.mockResolvedValueOnce(
createResponseMock({
status: 400,
body: JSON.stringify({
ok: false,
data: null,
error: {
code: 'BAD_REQUEST',
message: '请求参数不合法',
details: {
provider: 'dashscope',
message: '拼图图片生成失败:请求参数不合法',
},
},
meta: {},
}),
headers: {
'Content-Type': 'application/json',
},
}),
);
await expect(
requestJson('/api/runtime/puzzle/agent/sessions/test/actions', {
method: 'POST',
}, '执行拼图操作失败。'),
).rejects.toMatchObject({
message: '拼图图片生成失败:请求参数不合法',
status: 400,
code: 'BAD_REQUEST',
details: {
provider: 'dashscope',
},
});
});
});

Some files were not shown because too many files have changed in this diff Show More