1
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
streamBigFishCreationMessage,
|
||||
} from '../../services/big-fish-creation';
|
||||
import {
|
||||
likeBigFishGalleryWork,
|
||||
listBigFishGallery,
|
||||
remixBigFishGalleryWork,
|
||||
} from '../../services/big-fish-gallery';
|
||||
@@ -94,11 +95,13 @@ import {
|
||||
} from '../../services/puzzle-agent';
|
||||
import {
|
||||
getPuzzleGalleryDetail,
|
||||
likePuzzleGalleryWork,
|
||||
listPuzzleGallery,
|
||||
remixPuzzleGalleryWork,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
advancePuzzleNextLevel,
|
||||
getPuzzleRun,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
@@ -121,6 +124,7 @@ import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreati
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetailByCode,
|
||||
likeRpgEntryWorldGallery,
|
||||
recordRpgEntryWorldGalleryPlay,
|
||||
remixRpgEntryWorldGallery,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
@@ -202,6 +206,13 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
function isSamePlatformPublicGalleryEntry(
|
||||
left: PlatformPublicGalleryCard,
|
||||
right: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return getPlatformPublicGalleryEntryKey(left) === getPlatformPublicGalleryEntryKey(right);
|
||||
}
|
||||
|
||||
function mergePlatformPublicGalleryEntries(
|
||||
rpgEntries: CustomWorldGalleryCard[],
|
||||
puzzleEntries: PlatformPublicGalleryCard[],
|
||||
@@ -281,6 +292,7 @@ function mapPublicWorkDetailToBigFishWork(
|
||||
workId: entry.workId,
|
||||
sourceSessionId: entry.profileId,
|
||||
ownerUserId: entry.ownerUserId,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
title: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summary: entry.summaryText,
|
||||
@@ -299,6 +311,20 @@ function mapPublicWorkDetailToBigFishWork(
|
||||
};
|
||||
}
|
||||
|
||||
function mergePuzzleWorkSummary(
|
||||
current: PuzzleWorkSummary,
|
||||
updated: PuzzleWorkSummary,
|
||||
): PuzzleWorkSummary {
|
||||
return current.profileId === updated.profileId ? updated : current;
|
||||
}
|
||||
|
||||
function mergeBigFishWorkSummary(
|
||||
current: BigFishWorkSummary,
|
||||
updated: BigFishWorkSummary,
|
||||
): BigFishWorkSummary {
|
||||
return current.sourceSessionId === updated.sourceSessionId ? updated : current;
|
||||
}
|
||||
|
||||
async function resolvePublicWorkAuthorSummary(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): Promise<PublicUserSummary | null> {
|
||||
@@ -457,15 +483,85 @@ function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
|
||||
function buildPuzzleCompileActionFromFormPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null,
|
||||
): PuzzleAgentActionRequest {
|
||||
const workTitle = payload?.workTitle?.trim() || payload?.seedText?.trim();
|
||||
const workDescription = payload?.workDescription?.trim();
|
||||
const pictureDescription = payload?.pictureDescription?.trim();
|
||||
|
||||
return {
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText:
|
||||
payload?.pictureDescription?.trim() || payload?.seedText?.trim(),
|
||||
promptText: pictureDescription || workTitle,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
...(pictureDescription ? { pictureDescription } : {}),
|
||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||
candidateCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleFormPayloadFromSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): CreatePuzzleAgentSessionRequest {
|
||||
const formDraft = session.draft?.formDraft;
|
||||
const workTitle =
|
||||
formDraft?.workTitle?.trim() ||
|
||||
session.draft?.workTitle?.trim() ||
|
||||
session.draft?.levelName?.trim() ||
|
||||
session.anchorPack.themePromise.value.trim() ||
|
||||
session.seedText?.trim() ||
|
||||
'';
|
||||
const workDescription =
|
||||
formDraft?.workDescription?.trim() ||
|
||||
session.draft?.workDescription?.trim() ||
|
||||
session.draft?.summary?.trim() ||
|
||||
'';
|
||||
const pictureDescription =
|
||||
formDraft?.pictureDescription?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
|
||||
session.anchorPack.visualSubject.value.trim() ||
|
||||
'';
|
||||
|
||||
return {
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleFormPayloadFromAction(
|
||||
payload: PuzzleAgentActionRequest,
|
||||
): CreatePuzzleAgentSessionRequest | null {
|
||||
if (
|
||||
payload.action !== 'compile_puzzle_draft' &&
|
||||
payload.action !== 'save_puzzle_form_draft'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workTitle = payload.workTitle?.trim() ?? '';
|
||||
const workDescription = payload.workDescription?.trim() ?? '';
|
||||
const pictureDescription =
|
||||
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
|
||||
|
||||
return {
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.referenceImageSrc ?? null)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function isPuzzleFormOnlyDraft(session: PuzzleAgentSessionSnapshot | null) {
|
||||
return Boolean(
|
||||
session?.stage === 'collecting_anchors' && session.draft?.formDraft,
|
||||
);
|
||||
}
|
||||
|
||||
const CustomWorldGenerationView = lazy(async () => {
|
||||
const module = await import('../CustomWorldGenerationView');
|
||||
return {
|
||||
@@ -1125,6 +1221,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
onActionComplete: async ({ payload, response, setSession }) => {
|
||||
setPuzzleOperation(response.operation);
|
||||
setSession(response.session);
|
||||
const formPayload = buildPuzzleFormPayloadFromAction(payload);
|
||||
if (formPayload) {
|
||||
setPuzzleFormDraftPayload(formPayload);
|
||||
}
|
||||
|
||||
if (payload.action === 'publish_puzzle_work') {
|
||||
await Promise.allSettled([
|
||||
@@ -1167,6 +1267,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
},
|
||||
beforeExecuteAction: ({ payload }) => {
|
||||
const formPayload = buildPuzzleFormPayloadFromAction(payload);
|
||||
if (formPayload) {
|
||||
setPuzzleFormDraftPayload(formPayload);
|
||||
}
|
||||
|
||||
if (payload.action !== 'compile_puzzle_draft') {
|
||||
return;
|
||||
}
|
||||
@@ -1224,19 +1329,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleGenerationState(null);
|
||||
setPuzzleFormDraftPayload(null);
|
||||
puzzleFlow.setSession(null);
|
||||
puzzleFlow.setError(null);
|
||||
puzzleFlow.setStreamingReplyText('');
|
||||
puzzleFlow.setIsStreamingReply(false);
|
||||
enterCreateTab();
|
||||
setShowCreationTypeModal(false);
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
}, [enterCreateTab, puzzleFlow, setSelectionStage]);
|
||||
const nextSession = await puzzleFlow.openWorkspace({});
|
||||
if (nextSession) {
|
||||
void refreshPuzzleShelf();
|
||||
}
|
||||
}, [puzzleFlow, refreshPuzzleShelf]);
|
||||
|
||||
const createPuzzleDraftFromForm = useCallback(
|
||||
async (payload: CreatePuzzleAgentSessionRequest) => {
|
||||
setPuzzleFormDraftPayload(payload);
|
||||
const nextSession = await puzzleFlow.openWorkspace(payload);
|
||||
const nextSession = puzzleFlow.session ?? (await puzzleFlow.openWorkspace(payload));
|
||||
if (!nextSession) {
|
||||
return;
|
||||
}
|
||||
@@ -1249,6 +1351,36 @@ export function PlatformEntryFlowShellImpl({
|
||||
[puzzleFlow],
|
||||
);
|
||||
|
||||
const savePuzzleFormDraft = useCallback(
|
||||
async (payload: CreatePuzzleAgentSessionRequest) => {
|
||||
const session = puzzleFlow.session;
|
||||
if (!session || session.stage !== 'collecting_anchors') {
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleFormDraftPayload(payload);
|
||||
|
||||
try {
|
||||
const response = await executePuzzleAgentAction(session.sessionId, {
|
||||
action: 'save_puzzle_form_draft',
|
||||
promptText: payload.pictureDescription ?? null,
|
||||
workTitle: payload.workTitle ?? payload.seedText ?? '',
|
||||
workDescription: payload.workDescription ?? '',
|
||||
pictureDescription: payload.pictureDescription ?? '',
|
||||
});
|
||||
setPuzzleOperation(response.operation);
|
||||
puzzleFlow.setSession(response.session);
|
||||
setPuzzleError(null);
|
||||
void refreshPuzzleShelf();
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '保存拼图表单草稿失败。'),
|
||||
);
|
||||
}
|
||||
},
|
||||
[puzzleFlow, refreshPuzzleShelf, resolvePuzzleErrorMessage, setPuzzleError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (platformBootstrap.canReadProtectedData) {
|
||||
hadReadableProtectedDataRef.current = true;
|
||||
@@ -1318,7 +1450,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const handleCreationHubCreateType = useCallback(
|
||||
(type: PlatformCreationTypeId) => {
|
||||
if (type === 'airp' || type === 'visual-novel') {
|
||||
if (type === 'rpg' || type === 'airp' || type === 'visual-novel') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1326,13 +1458,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'rpg') {
|
||||
runProtectedAction(() => {
|
||||
void sessionController.openRpgAgentWorkspace();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'big-fish') {
|
||||
runProtectedAction(() => {
|
||||
void openBigFishAgentWorkspace();
|
||||
@@ -1351,7 +1476,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
openPuzzleAgentWorkspace,
|
||||
prepareCreationLaunch,
|
||||
runProtectedAction,
|
||||
sessionController,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1459,6 +1583,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
returnStage: PuzzleRuntimeReturnStage = 'work-detail',
|
||||
detailItem?: PuzzleWorkSummary,
|
||||
mirrorErrorToPublicDetail = false,
|
||||
levelId?: string | null,
|
||||
) => {
|
||||
if (isPuzzleBusy) {
|
||||
return;
|
||||
@@ -1470,7 +1595,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const item =
|
||||
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
|
||||
const { run } = await startPuzzleRun({ profileId: item.profileId });
|
||||
const { run } = await startPuzzleRun({
|
||||
profileId: item.profileId,
|
||||
levelId: levelId ?? null,
|
||||
});
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeReturnStage(returnStage);
|
||||
@@ -1513,6 +1641,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
ownerUserId: authUi?.user?.id ?? 'current-user',
|
||||
sourceSessionId: puzzleSession?.sessionId ?? null,
|
||||
authorDisplayName: authUi?.user?.displayName ?? '玩家',
|
||||
workTitle: draft.workTitle || draft.levelName,
|
||||
workDescription: draft.workDescription || draft.summary,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
@@ -1787,7 +1917,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
const advancePuzzleLevel = useCallback(async () => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1801,13 +1931,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const { run } = await advanceLocalPuzzleNextLevel({
|
||||
run: puzzleRun,
|
||||
sourceSessionId:
|
||||
selectedPuzzleDetail?.sourceSessionId ??
|
||||
puzzleSession?.sessionId ??
|
||||
null,
|
||||
});
|
||||
const { run } = isLocalPuzzleRun(puzzleRun)
|
||||
? await advanceLocalPuzzleNextLevel({
|
||||
run: puzzleRun,
|
||||
sourceSessionId:
|
||||
selectedPuzzleDetail?.sourceSessionId ??
|
||||
puzzleSession?.sessionId ??
|
||||
null,
|
||||
})
|
||||
: await advancePuzzleNextLevel(puzzleRun.runId);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||
@@ -1817,6 +1949,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}, [
|
||||
isPuzzleBusy,
|
||||
isPuzzleLeaderboardBusy,
|
||||
puzzleRun,
|
||||
puzzleSession,
|
||||
resolvePuzzleErrorMessage,
|
||||
@@ -2022,14 +2155,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
const displayName =
|
||||
work.workTitle?.trim() || work.levelName.trim() || '未命名拼图';
|
||||
const confirmed = window.confirm(
|
||||
`确认删除作品《${work.levelName}》吗?删除后会从你的作品列表和公开广场中移除。`,
|
||||
`确认删除作品《${displayName}》吗?删除后会从你的作品列表和公开广场中移除。`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
setPuzzleFormDraftPayload(null);
|
||||
setPuzzleError(null);
|
||||
|
||||
void deletePuzzleWork(work.profileId)
|
||||
@@ -2095,6 +2231,127 @@ export function PlatformEntryFlowShellImpl({
|
||||
[setSelectionStage],
|
||||
);
|
||||
|
||||
const syncUpdatedPublicWorkDetail = useCallback(
|
||||
(updatedEntry: PlatformPublicGalleryCard) => {
|
||||
setSelectedPublicWorkDetail((current) =>
|
||||
current && isSamePlatformPublicGalleryEntry(current, updatedEntry)
|
||||
? updatedEntry
|
||||
: current,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const likePublicWork = useCallback(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
if (isPublicWorkDetailBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
setIsPublicWorkDetailBusy(true);
|
||||
setPublicWorkDetailError(null);
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
void likeBigFishGalleryWork(entry.profileId)
|
||||
.then((response) => {
|
||||
const updatedWork = response.items.find(
|
||||
(item) => item.sourceSessionId === entry.profileId,
|
||||
);
|
||||
if (!updatedWork) {
|
||||
return;
|
||||
}
|
||||
setBigFishGalleryEntries((current) =>
|
||||
current.map((item) => mergeBigFishWorkSummary(item, updatedWork)),
|
||||
);
|
||||
setBigFishWorks((current) =>
|
||||
current.map((item) => mergeBigFishWorkSummary(item, updatedWork)),
|
||||
);
|
||||
syncUpdatedPublicWorkDetail(
|
||||
mapBigFishWorkToPublicWorkDetail(updatedWork),
|
||||
);
|
||||
setBigFishRuntimeWork((current) =>
|
||||
current ? mergeBigFishWorkSummary(current, updatedWork) : current,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setPublicWorkDetailError(
|
||||
resolveBigFishErrorMessage(
|
||||
error,
|
||||
'点赞大鱼吃小鱼作品失败。',
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPublicWorkDetailBusy(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
void likePuzzleGalleryWork(entry.profileId)
|
||||
.then((response) => {
|
||||
const updatedWork = response.item;
|
||||
setPuzzleGalleryEntries((current) =>
|
||||
current.map((item) => mergePuzzleWorkSummary(item, updatedWork)),
|
||||
);
|
||||
setPuzzleWorks((current) =>
|
||||
current.map((item) => mergePuzzleWorkSummary(item, updatedWork)),
|
||||
);
|
||||
setSelectedPuzzleDetail((current) =>
|
||||
current ? mergePuzzleWorkSummary(current, updatedWork) : current,
|
||||
);
|
||||
syncUpdatedPublicWorkDetail(
|
||||
mapPuzzleWorkToPublicWorkDetail(updatedWork),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setPublicWorkDetailError(
|
||||
resolvePuzzleErrorMessage(error, '点赞拼图作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPublicWorkDetailBusy(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void likeRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
|
||||
.then((updatedEntry) => {
|
||||
setSelectedDetailEntry((current) =>
|
||||
current?.profileId === updatedEntry.profileId ? updatedEntry : current,
|
||||
);
|
||||
platformBootstrap.setPublishedGalleryEntries((current) =>
|
||||
current.map((item) =>
|
||||
item.profileId === updatedEntry.profileId
|
||||
? mapRpgGalleryCardToPublicWorkDetail(updatedEntry)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
syncUpdatedPublicWorkDetail(
|
||||
mapRpgGalleryCardToPublicWorkDetail(updatedEntry),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setPublicWorkDetailError(
|
||||
resolveRpgCreationErrorMessage(error, '点赞 RPG 作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPublicWorkDetailBusy(false);
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
isPublicWorkDetailBusy,
|
||||
platformBootstrap,
|
||||
resolveBigFishErrorMessage,
|
||||
resolvePuzzleErrorMessage,
|
||||
runProtectedAction,
|
||||
syncUpdatedPublicWorkDetail,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const detailEntry =
|
||||
selectionStage === 'work-detail'
|
||||
@@ -2244,9 +2501,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
if (!restoredSession) {
|
||||
await refreshPuzzleShelf().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPuzzleFormOnlyDraft(restoredSession)) {
|
||||
setPuzzleFormDraftPayload(
|
||||
buildPuzzleFormPayloadFromSession(restoredSession),
|
||||
);
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
} else {
|
||||
setPuzzleFormDraftPayload(null);
|
||||
}
|
||||
},
|
||||
[openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError],
|
||||
[
|
||||
openPuzzleDetail,
|
||||
puzzleFlow,
|
||||
refreshPuzzleShelf,
|
||||
setPuzzleError,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const startBigFishRunFromWork = useCallback(
|
||||
@@ -2649,6 +2922,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
workId: `big-fish:${sessionId}`,
|
||||
sourceSessionId: sessionId,
|
||||
ownerUserId: work.ownerUserId ?? '',
|
||||
authorDisplayName: work.worldSubtitle || '玩家',
|
||||
title: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summary: work.worldSubtitle,
|
||||
@@ -2977,6 +3251,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformWorkDetailView
|
||||
entry={selectedPublicWorkDetail}
|
||||
authorAvatarUrl={selectedPublicWorkAuthor?.avatarUrl ?? null}
|
||||
authorDisplayName={selectedPublicWorkAuthor?.displayName ?? null}
|
||||
isBusy={isPublicWorkDetailBusy || isPuzzleBusy || isBigFishBusy}
|
||||
error={publicWorkDetailError}
|
||||
onBack={() => {
|
||||
@@ -2984,6 +3259,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
clearSelectedPublicWorkAuthor();
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onLike={() => {
|
||||
likePublicWork(selectedPublicWorkDetail);
|
||||
}}
|
||||
onStart={startSelectedPublicWork}
|
||||
onRemix={remixSelectedPublicWork}
|
||||
/>
|
||||
@@ -3008,6 +3286,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformWorkDetailView
|
||||
entry={mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry)}
|
||||
authorAvatarUrl={selectedPublicWorkAuthor?.avatarUrl ?? null}
|
||||
authorDisplayName={selectedPublicWorkAuthor?.displayName ?? null}
|
||||
isBusy={detailNavigation.isMutatingDetail}
|
||||
error={detailNavigation.detailError}
|
||||
onBack={() => {
|
||||
@@ -3015,6 +3294,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
clearSelectedPublicWorkAuthor();
|
||||
entryNavigation.backToPlatformHome();
|
||||
}}
|
||||
onLike={() => {
|
||||
likePublicWork(
|
||||
mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry),
|
||||
);
|
||||
}}
|
||||
onStart={handleStartSelectedWorld}
|
||||
onRemix={() => {
|
||||
remixPublicWork(
|
||||
@@ -3290,6 +3574,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
onCreateFromForm={(payload) => {
|
||||
void createPuzzleDraftFromForm(payload);
|
||||
}}
|
||||
onAutoSaveForm={(payload) => {
|
||||
void savePuzzleFormDraft(payload);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -3312,6 +3599,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
anchorEntries={buildPuzzleGenerationAnchorEntries(
|
||||
puzzleSession,
|
||||
puzzleFormDraftPayload,
|
||||
)}
|
||||
progress={buildMiniGameDraftGenerationProgress(
|
||||
puzzleGenerationState,
|
||||
@@ -3344,7 +3632,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'puzzle-result' && puzzleSession?.draft && (
|
||||
{selectionStage === 'puzzle-result' &&
|
||||
puzzleSession?.draft &&
|
||||
!isPuzzleFormOnlyDraft(puzzleSession) && (
|
||||
<motion.div
|
||||
key="puzzle-result"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -3717,9 +4007,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setShowCreationTypeModal(false);
|
||||
}}
|
||||
onSelectRpg={() => {
|
||||
runProtectedAction(() => {
|
||||
void sessionController.openRpgAgentWorkspace();
|
||||
});
|
||||
// RPG 创作入口当前为敬请期待;保留回调防御,避免旧入口绕过锁定态。
|
||||
}}
|
||||
onSelectBigFish={() => {
|
||||
runProtectedAction(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -45,96 +45,79 @@ afterEach(() => {
|
||||
function createSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜猫咪',
|
||||
status: 'confirmed' as const,
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '屋檐下的猫',
|
||||
status: 'confirmed' as const,
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '温暖',
|
||||
status: 'confirmed' as const,
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '雨滴与灯牌',
|
||||
status: 'confirmed' as const,
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '猫咪、雨夜',
|
||||
status: 'confirmed' as const,
|
||||
},
|
||||
};
|
||||
const level = {
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/candidate-1.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫咪',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated' as const,
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/candidate-1.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
const baseSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 2,
|
||||
progressPercent: 88,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜猫咪',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '屋檐下的猫',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '温暖',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '雨滴与灯牌',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '猫咪、雨夜',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
anchorPack,
|
||||
draft: {
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜'],
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: level.levelName,
|
||||
summary: level.pictureDescription,
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜猫咪',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '屋檐下的猫',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '温暖',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '雨滴与灯牌',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '猫咪、雨夜',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/candidate-1.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫咪',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/candidate-1.png',
|
||||
coverAssetId: 'asset-1',
|
||||
anchorPack,
|
||||
candidates: level.candidates,
|
||||
selectedCandidateId: level.selectedCandidateId,
|
||||
coverImageSrc: level.coverImageSrc,
|
||||
coverAssetId: level.coverAssetId,
|
||||
generationStatus: 'ready',
|
||||
levels: [level],
|
||||
metadata: null,
|
||||
},
|
||||
messages: [],
|
||||
@@ -160,40 +143,7 @@ function createSession(
|
||||
}
|
||||
|
||||
describe('PuzzleResultView', () => {
|
||||
test('auto saves renamed title to the puzzle work profile', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
levelName: '暖灯猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses one ordered list without tabs or persistent publish validation', () => {
|
||||
test('renders level list and work info tabs', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
@@ -203,117 +153,22 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '基本信息' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '拼图图片' })).toBeNull();
|
||||
const html = document.body.textContent ?? '';
|
||||
expect(html.indexOf('关卡名称')).toBeLessThan(html.indexOf('画面预览'));
|
||||
expect(html.indexOf('画面预览')).toBeLessThan(html.indexOf('画面描述'));
|
||||
expect(html.indexOf('画面描述')).toBeLessThan(html.indexOf('重新生成画面'));
|
||||
expect(html.indexOf('重新生成画面')).toBeLessThan(html.indexOf('题材标签'));
|
||||
expect(screen.queryByText('作者预览')).toBeNull();
|
||||
expect(screen.queryByText('发布校验')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /作品测试/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /发布/u })).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
|
||||
expect(screen.getByText('雨夜猫街')).toBeTruthy();
|
||||
|
||||
test('edits theme tags with chips instead of a persistent tag input', () => {
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
|
||||
expect(screen.getByLabelText('作品名称')).toHaveProperty(
|
||||
'value',
|
||||
'暖灯猫街作品',
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('新题材标签')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
|
||||
expect(screen.queryByText('猫咪')).toBeNull();
|
||||
expect(screen.getByText('雨夜')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新增题材标签'));
|
||||
fireEvent.change(screen.getByLabelText('新题材标签'), {
|
||||
target: { value: '暖灯' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }));
|
||||
|
||||
expect(screen.getByText('暖灯')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('新题材标签')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows blockers only after clicking publish and blocks publish action', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
resultPreview: {
|
||||
draft: createSession().draft!,
|
||||
publishReady: false,
|
||||
blockers: [
|
||||
{
|
||||
id: 'missing-cover',
|
||||
code: 'missing-cover',
|
||||
message: '请先选择正式图',
|
||||
},
|
||||
],
|
||||
qualityFindings: [],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('请先选择正式图')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
expect(within(dialog).getByText('请先选择正式图')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '发布到广场' }));
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starts work test from the current editable draft', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText('新增题材标签'));
|
||||
fireEvent.change(screen.getByLabelText('新题材标签'), {
|
||||
target: { value: '暖灯' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /作品测试/u }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelName: '暖灯猫街',
|
||||
summary: '一只猫在雨夜灯牌下回头。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
}),
|
||||
expect(screen.getByLabelText('作品描述')).toHaveProperty(
|
||||
'value',
|
||||
'一套雨夜猫街主题拼图。',
|
||||
);
|
||||
});
|
||||
|
||||
test('auto saves edited picture description to the puzzle work profile', async () => {
|
||||
test('auto saves work info and levels through one payload', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
@@ -328,8 +183,9 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
|
||||
fireEvent.change(screen.getByLabelText('作品名称'), {
|
||||
target: { value: '暖灯猫街合集' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -339,83 +195,68 @@ describe('PuzzleResultView', () => {
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
summary: '一只猫在雨夜灯牌下回头。',
|
||||
workTitle: '暖灯猫街合集',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levels: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('requires at least three theme tags before publish can pass', () => {
|
||||
test('opens an independent level detail dialog for generation and test play', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
expect(
|
||||
within(dialog).getByText('正式标签数量必须在 3 到 6 之间。'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '发布到广场',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('publishes with the edited picture description', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
},
|
||||
resultPreview: {
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
},
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
|
||||
'button',
|
||||
{ name: '发布到广场' },
|
||||
),
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'publish_puzzle_work',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '一只猫在雨夜灯牌下回头。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: undefined,
|
||||
candidateCount: 1,
|
||||
});
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /体验该关/u }));
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelName: '暖灯猫街',
|
||||
summary: '一只猫在雨夜灯牌下回头。',
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '暖灯猫街',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('auto saves added and removed theme tags', async () => {
|
||||
test('adds and deletes levels from the list', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
@@ -430,11 +271,10 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新增题材标签'));
|
||||
fireEvent.change(screen.getByLabelText('新题材标签'), {
|
||||
target: { value: '暖灯' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -443,11 +283,16 @@ describe('PuzzleResultView', () => {
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levels: expect.arrayContaining([
|
||||
expect.objectContaining({ levelId: 'puzzle-level-1' }),
|
||||
expect.objectContaining({ levelName: '第2关' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
|
||||
fireEvent.click(screen.getByLabelText('删除关卡 第2关'));
|
||||
expect(screen.queryByText('第2关')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
@@ -455,12 +300,16 @@ describe('PuzzleResultView', () => {
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
themeTags: ['雨夜', '暖灯'],
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('generates one image from the picture description and replaces current image', () => {
|
||||
test('publishes with work info and serialized levels', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -471,24 +320,34 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('画面描述')).toBeTruthy();
|
||||
expect(screen.queryByText(/候选图/u)).toBeNull();
|
||||
expect(screen.queryByText(/请生成一张适合正方形拼图关卡/u)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
|
||||
'button',
|
||||
{ name: '发布到广场' },
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_puzzle_images',
|
||||
promptText: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: undefined,
|
||||
candidateCount: 1,
|
||||
});
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'publish_puzzle_work',
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
}),
|
||||
);
|
||||
const payload = onExecuteAction.mock.calls[0]![0];
|
||||
expect(JSON.parse(payload.levelsJson)).toEqual([
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('selects a history puzzle asset as reference image for the next generation', async () => {
|
||||
test('selects a history puzzle asset as reference image for the selected level', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||
{
|
||||
@@ -512,13 +371,14 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: '选择历史拼图素材',
|
||||
});
|
||||
fireEvent.click(
|
||||
await within(dialog).findByRole('button', { name: /账号 user-1/u }),
|
||||
await within(picker).findByRole('button', { name: /账号 user-1/u }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -528,102 +388,12 @@ describe('PuzzleResultView', () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenLastCalledWith({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '屋檐下的猫与暖灯街角。',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
candidateCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('refreshes the current formal image when session cover image changes', async () => {
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'),
|
||||
).toBe('/puzzle/candidate-1.png');
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-2',
|
||||
imageSrc: '/puzzle/candidate-2.png',
|
||||
assetId: 'asset-2',
|
||||
prompt: '新图',
|
||||
actualPrompt: '新图',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-2',
|
||||
coverImageSrc: '/puzzle/candidate-2.png',
|
||||
coverAssetId: 'asset-2',
|
||||
},
|
||||
updatedAt: '2026-04-27T11:11:11.000Z',
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'),
|
||||
).toBe('/puzzle/candidate-2.png');
|
||||
});
|
||||
});
|
||||
|
||||
test('prefers the selected latest candidate image when coverImageSrc lags behind', async () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/candidate-1.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '旧图',
|
||||
actualPrompt: '旧图',
|
||||
sourceType: 'generated',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
candidateId: 'candidate-2',
|
||||
imageSrc: '/puzzle/candidate-2.png',
|
||||
assetId: 'asset-2',
|
||||
prompt: '新图',
|
||||
actualPrompt: '新图',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-2',
|
||||
coverImageSrc: '/puzzle/candidate-1.png',
|
||||
coverAssetId: 'asset-1',
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'),
|
||||
).toBe('/puzzle/candidate-2.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
100
src/index.css
100
src/index.css
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock('../apiClient', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { listBigFishGallery } from './bigFishGalleryClient';
|
||||
import { likeBigFishGalleryWork, listBigFishGallery } from './bigFishGalleryClient';
|
||||
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
@@ -42,3 +42,15 @@ test('listBigFishGallery keeps non-gallery-read errors visible', async () => {
|
||||
|
||||
await expect(listBigFishGallery()).rejects.toBe(error);
|
||||
});
|
||||
|
||||
test('likeBigFishGalleryWork posts to authenticated like route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await likeBigFishGalleryWork('big-fish-session-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/big-fish/gallery/big-fish-session-1/like',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'点赞大鱼吃小鱼作品失败',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,7 +50,21 @@ export async function remixBigFishGalleryWork(sessionId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞公开大鱼吃小鱼作品,后端按当前登录用户做幂等计数。
|
||||
*/
|
||||
export async function likeBigFishGalleryWork(sessionId: string) {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
`${BIG_FISH_GALLERY_API_BASE}/${encodeURIComponent(sessionId)}/like`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'点赞大鱼吃小鱼作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishGalleryClient = {
|
||||
like: likeBigFishGalleryWork,
|
||||
list: listBigFishGallery,
|
||||
remix: remixBigFishGalleryWork,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
bigFishGalleryClient,
|
||||
likeBigFishGalleryWork,
|
||||
listBigFishGallery,
|
||||
remixBigFishGalleryWork,
|
||||
} from './bigFishGalleryClient';
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose only title and picture description', () => {
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 1,
|
||||
@@ -110,13 +110,24 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
}, {
|
||||
seedText: '表单作品名',
|
||||
workTitle: '暖灯猫街',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'puzzle-title',
|
||||
label: '拼图标题',
|
||||
value: '雨夜猫街',
|
||||
label: '作品名称',
|
||||
value: '暖灯猫街',
|
||||
},
|
||||
{
|
||||
id: 'work-description',
|
||||
label: '作品描述',
|
||||
value: '一套雨夜猫街主题拼图。',
|
||||
},
|
||||
{
|
||||
id: 'picture-description',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldGenerationStep,
|
||||
@@ -228,6 +231,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
|
||||
export function buildPuzzleGenerationAnchorEntries(
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined,
|
||||
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
if (!session) {
|
||||
return [];
|
||||
@@ -236,13 +240,28 @@ export function buildPuzzleGenerationAnchorEntries(
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
{
|
||||
key: 'puzzle-title',
|
||||
label: '拼图标题',
|
||||
value: session.draft?.levelName || session.anchorPack.themePromise.value,
|
||||
label: '作品名称',
|
||||
value:
|
||||
formPayload?.workTitle?.trim() ||
|
||||
formPayload?.seedText?.trim() ||
|
||||
session.draft?.workTitle ||
|
||||
session.anchorPack.themePromise.value,
|
||||
},
|
||||
{
|
||||
key: 'work-description',
|
||||
label: '作品描述',
|
||||
value:
|
||||
formPayload?.workDescription?.trim() ||
|
||||
session.draft?.workDescription ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'picture-description',
|
||||
label: '画面描述',
|
||||
value: session.draft?.summary || session.anchorPack.visualSubject.value,
|
||||
value:
|
||||
formPayload?.pictureDescription?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription ||
|
||||
session.anchorPack.visualSubject.value,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
getPuzzleGalleryDetail,
|
||||
likePuzzleGalleryWork,
|
||||
listPuzzleGallery,
|
||||
puzzleGalleryClient,
|
||||
remixPuzzleGalleryWork,
|
||||
|
||||
@@ -8,7 +8,11 @@ vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
import { getPuzzleGalleryDetail, listPuzzleGallery } from './puzzleGalleryClient';
|
||||
import {
|
||||
getPuzzleGalleryDetail,
|
||||
likePuzzleGalleryWork,
|
||||
listPuzzleGallery,
|
||||
} from './puzzleGalleryClient';
|
||||
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
@@ -50,3 +54,20 @@ test('getPuzzleGalleryDetail reads public detail without auth refresh coupling',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('likePuzzleGalleryWork posts to authenticated like route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
item: {
|
||||
profileId: 'puzzle-profile-1',
|
||||
likeCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await likePuzzleGalleryWork('puzzle-profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/gallery/puzzle-profile-1/like',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'点赞拼图作品失败',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,19 @@ export async function getPuzzleGalleryDetail(profileId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞公开拼图作品,后端按当前登录用户做幂等计数。
|
||||
*/
|
||||
export async function likePuzzleGalleryWork(profileId: string) {
|
||||
return requestJson<{ item: PuzzleWorkSummary }>(
|
||||
`${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}/like`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'点赞拼图作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将公开拼图作品复制为当前用户的草稿。
|
||||
*/
|
||||
@@ -65,6 +78,7 @@ export async function remixPuzzleGalleryWork(profileId: string) {
|
||||
|
||||
export const puzzleGalleryClient = {
|
||||
getDetail: getPuzzleGalleryDetail,
|
||||
like: likePuzzleGalleryWork,
|
||||
list: listPuzzleGallery,
|
||||
remix: remixPuzzleGalleryWork,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
PuzzleWorkDetailResponse,
|
||||
PuzzleWorkMutationResponse,
|
||||
@@ -52,16 +53,19 @@ export async function getPuzzleWorkDetail(profileId: string) {
|
||||
|
||||
/**
|
||||
* 更新已发布或草稿态拼图作品的轻量字段。
|
||||
* 只覆盖结果页约定的标题、摘要、标签与正式图。
|
||||
* 只覆盖结果页约定的作品信息、首关摘要、标签、正式图与关卡列表。
|
||||
*/
|
||||
export async function updatePuzzleWork(
|
||||
profileId: string,
|
||||
payload: {
|
||||
workTitle?: string;
|
||||
workDescription?: string;
|
||||
levelName: string;
|
||||
summary: string;
|
||||
themeTags: string[];
|
||||
coverImageSrc?: string | null;
|
||||
coverAssetId?: string | null;
|
||||
levels: PuzzleDraftLevel[];
|
||||
},
|
||||
) {
|
||||
return requestJson<PuzzleWorkMutationResponse>(
|
||||
|
||||
117
src/services/puzzleReferenceImage.test.ts
Normal file
117
src/services/puzzleReferenceImage.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH,
|
||||
readPuzzleReferenceImageAsDataUrl,
|
||||
} from './puzzleReferenceImage';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function stubFileReader(dataUrl: string) {
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
error: Error | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result = dataUrl;
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
}
|
||||
|
||||
function stubImage(width = 4096, height = 3072) {
|
||||
class MockImage {
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
naturalWidth = width;
|
||||
naturalHeight = height;
|
||||
width = width;
|
||||
height = height;
|
||||
|
||||
set src(_value: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
|
||||
}
|
||||
|
||||
function stubCanvas(dataUrls: string[]) {
|
||||
const drawImage = vi.fn();
|
||||
const toDataURL = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
() => dataUrls.shift() ?? 'data:image/jpeg;base64,small',
|
||||
);
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
|
||||
if (tagName !== 'canvas') {
|
||||
return originalCreateElement(tagName);
|
||||
}
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({
|
||||
drawImage,
|
||||
fillRect: vi.fn(),
|
||||
fillStyle: '',
|
||||
imageSmoothingEnabled: false,
|
||||
imageSmoothingQuality: 'low',
|
||||
}),
|
||||
toDataURL,
|
||||
} as unknown as HTMLCanvasElement;
|
||||
});
|
||||
return { drawImage, toDataURL };
|
||||
}
|
||||
|
||||
describe('readPuzzleReferenceImageAsDataUrl', () => {
|
||||
test('compresses large puzzle reference images before JSON upload', async () => {
|
||||
stubFileReader(`data:image/png;base64,${'A'.repeat(3 * 1024 * 1024)}`);
|
||||
stubImage();
|
||||
const { drawImage, toDataURL } = stubCanvas([
|
||||
`data:image/jpeg;base64,${'B'.repeat(1200)}`,
|
||||
`data:image/jpeg;base64,${'C'.repeat(1000)}`,
|
||||
`data:image/jpeg;base64,${'D'.repeat(1400)}`,
|
||||
]);
|
||||
|
||||
const file = new File(['x'.repeat(2 * 1024 * 1024)], 'reference.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
|
||||
|
||||
expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`);
|
||||
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1536, 1152);
|
||||
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84);
|
||||
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76);
|
||||
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68);
|
||||
});
|
||||
|
||||
test('rejects reference images that still exceed the upload budget', async () => {
|
||||
stubFileReader(
|
||||
`data:image/png;base64,${'A'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 1)}`,
|
||||
);
|
||||
stubImage();
|
||||
stubCanvas([
|
||||
`data:image/jpeg;base64,${'B'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 1)}`,
|
||||
`data:image/jpeg;base64,${'C'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 2)}`,
|
||||
`data:image/jpeg;base64,${'D'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 3)}`,
|
||||
]);
|
||||
|
||||
const file = new File(['x'.repeat(2 * 1024 * 1024)], 'reference.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow(
|
||||
'参考图过大,请换一张尺寸更小的图片。',
|
||||
);
|
||||
});
|
||||
});
|
||||
117
src/services/puzzleReferenceImage.ts
Normal file
117
src/services/puzzleReferenceImage.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1536;
|
||||
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
|
||||
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
|
||||
|
||||
type PuzzleReferenceImageSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('参考图读取失败,请重试。'));
|
||||
return;
|
||||
}
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureReferenceImageWithinLimit(dataUrl: string) {
|
||||
if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
|
||||
throw new Error('参考图过大,请换一张尺寸更小的图片。');
|
||||
}
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
function loadReferenceImage(dataUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('参考图读取失败,请重试。'));
|
||||
image.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCompressedImageSize(
|
||||
image: HTMLImageElement,
|
||||
): PuzzleReferenceImageSize {
|
||||
const sourceWidth = image.naturalWidth || image.width;
|
||||
const sourceHeight = image.naturalHeight || image.height;
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
throw new Error('参考图读取失败,请重试。');
|
||||
}
|
||||
|
||||
const scale = Math.min(
|
||||
1,
|
||||
PUZZLE_REFERENCE_IMAGE_MAX_EDGE / Math.max(sourceWidth, sourceHeight),
|
||||
);
|
||||
return {
|
||||
width: Math.max(1, Math.round(sourceWidth * scale)),
|
||||
height: Math.max(1, Math.round(sourceHeight * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldCompressReferenceImage(file: File, dataUrl: string) {
|
||||
return (
|
||||
file.size > PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES ||
|
||||
dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
async function compressReferenceImageDataUrl(file: File, dataUrl: string) {
|
||||
if (
|
||||
typeof document === 'undefined' ||
|
||||
typeof Image === 'undefined' ||
|
||||
!shouldCompressReferenceImage(file, dataUrl)
|
||||
) {
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
const image = await loadReferenceImage(dataUrl);
|
||||
const size = resolveCompressedImageSize(image);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size.width;
|
||||
canvas.height = size.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
// 中文注释:参考图只作为生成提示,不需要保留手机原图体积;压到单边 1536 内给 JSON body 留余量。
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(0, 0, size.width, size.height);
|
||||
context.drawImage(image, 0, 0, size.width, size.height);
|
||||
|
||||
const candidates = [0.84, 0.76, 0.68].map((quality) =>
|
||||
canvas.toDataURL('image/jpeg', quality),
|
||||
);
|
||||
return candidates.reduce((best, current) =>
|
||||
current.length < best.length ? current : best,
|
||||
);
|
||||
}
|
||||
|
||||
export async function readPuzzleReferenceImageAsDataUrl(file: File) {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
try {
|
||||
const compressedDataUrl = await compressReferenceImageDataUrl(
|
||||
file,
|
||||
dataUrl,
|
||||
);
|
||||
return ensureReferenceImageWithinLimit(
|
||||
compressedDataUrl.length < dataUrl.length ? compressedDataUrl : dataUrl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (dataUrl.length <= PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
|
||||
return dataUrl;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
getRpgEntryWorldLibraryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
likeRpgEntryWorldGallery,
|
||||
publishRpgEntryWorldProfile,
|
||||
recordRpgEntryWorldGalleryPlay,
|
||||
remixRpgEntryWorldGallery,
|
||||
|
||||
@@ -152,6 +152,26 @@ describe('rpgEntry public custom world gallery routes', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('likes public gallery detail through the authenticated mutation route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
likeCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const { likeRpgEntryWorldGallery } = await import('./rpgEntryLibraryClient');
|
||||
await likeRpgEntryWorldGallery('user-1', 'profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery/user-1/profile-1/like',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'点赞作品失败',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rpgEntry save archive routes', () => {
|
||||
|
||||
@@ -115,6 +115,23 @@ export async function recordRpgEntryWorldGalleryPlay(
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function likeRpgEntryWorldGallery(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/like`,
|
||||
{ method: 'POST' },
|
||||
'点赞作品失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldLibraryDetail(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
@@ -218,6 +235,7 @@ export const rpgEntryLibraryClient = {
|
||||
getWorldLibraryDetail: getRpgEntryWorldLibraryDetail,
|
||||
remixWorldGallery: remixRpgEntryWorldGallery,
|
||||
recordWorldGalleryPlay: recordRpgEntryWorldGalleryPlay,
|
||||
likeWorldGallery: likeRpgEntryWorldGallery,
|
||||
upsertWorldProfile: upsertRpgEntryWorldProfile,
|
||||
deleteWorldProfile: deleteRpgEntryWorldProfile,
|
||||
publishWorldProfile: publishRpgEntryWorldProfile,
|
||||
|
||||
Reference in New Issue
Block a user