@@ -316,6 +316,17 @@ function buildAgentResultPublishGateView(
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
|
||||
const normalizedSessionId = sessionId?.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
const stableSuffix = normalizedSessionId.startsWith('puzzle-session-')
|
||||
? normalizedSessionId.slice('puzzle-session-'.length)
|
||||
: normalizedSessionId;
|
||||
return `puzzle-profile-${stableSuffix}`;
|
||||
}
|
||||
|
||||
const CustomWorldGenerationView = lazy(async () => {
|
||||
const module = await import('../CustomWorldGenerationView');
|
||||
return {
|
||||
@@ -2450,6 +2461,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
>
|
||||
<PuzzleResultView
|
||||
session={puzzleSession}
|
||||
profileId={
|
||||
puzzleSession.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(puzzleSession.sessionId)
|
||||
}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import * as puzzleWorksService from '../../services/puzzle-works';
|
||||
import { PuzzleResultView } from './PuzzleResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
@@ -31,6 +33,15 @@ vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works', () => ({
|
||||
updatePuzzleWork: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
@@ -149,6 +160,39 @@ 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 two tabs without author preview or persistent publish validation', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
@@ -168,9 +212,13 @@ describe('PuzzleResultView', () => {
|
||||
});
|
||||
|
||||
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={() => {}}
|
||||
/>,
|
||||
@@ -256,6 +304,78 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('requires at least three theme tags before publish can pass', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
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('auto saves added and removed theme tags', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新增题材标签'));
|
||||
fireEvent.change(screen.getByLabelText('新题材标签'), {
|
||||
target: { value: '暖灯' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
themeTags: ['雨夜', '暖灯'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('generates one image from the picture description and replaces current image', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createPortal } from 'react-dom';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import {
|
||||
puzzleAssetClient,
|
||||
type PuzzleHistoryAsset,
|
||||
@@ -24,6 +25,7 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleResultViewProps = {
|
||||
session: PuzzleAgentSessionSnapshot;
|
||||
profileId?: string | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -32,6 +34,7 @@ type PuzzleResultViewProps = {
|
||||
};
|
||||
|
||||
type PuzzleResultTab = 'basic' | 'images';
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
type DraftEditState = {
|
||||
levelName: string;
|
||||
@@ -39,6 +42,10 @@ type DraftEditState = {
|
||||
themeTags: string[];
|
||||
};
|
||||
|
||||
const PUZZLE_MIN_THEME_TAG_COUNT = 3;
|
||||
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
|
||||
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
|
||||
function normalizeThemeTagInput(value: string) {
|
||||
return [
|
||||
...new Set(
|
||||
@@ -84,7 +91,16 @@ function publishBlockedReason(session: PuzzleAgentSessionSnapshot) {
|
||||
if (!session.resultPreview) {
|
||||
return ['等待结果页草稿完成后再发布。'];
|
||||
}
|
||||
return session.resultPreview.blockers.map((entry) => entry.message);
|
||||
return session.resultPreview.blockers
|
||||
.filter(
|
||||
(entry) =>
|
||||
![
|
||||
'MISSING_LEVEL_NAME',
|
||||
'INVALID_TAG_COUNT',
|
||||
'MISSING_COVER_IMAGE',
|
||||
].includes(entry.code),
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function buildPublishReady(
|
||||
@@ -96,7 +112,10 @@ function buildPublishReady(
|
||||
const blockers = [
|
||||
...publishBlockedReason(session),
|
||||
...(editState.levelName.trim() ? [] : ['关卡名不能为空。']),
|
||||
...(editState.themeTags.length > 0 ? [] : ['至少需要 1 个题材标签。']),
|
||||
...(editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
|
||||
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT
|
||||
? []
|
||||
: [`正式标签数量必须在 ${PUZZLE_MIN_THEME_TAG_COUNT} 到 ${PUZZLE_MAX_THEME_TAG_COUNT} 之间。`]),
|
||||
...(formalImageSrc ? [] : ['请先选择一张正式拼图图片。']),
|
||||
];
|
||||
|
||||
@@ -105,7 +124,8 @@ function buildPublishReady(
|
||||
publishReady:
|
||||
Boolean(session.resultPreview?.publishReady) &&
|
||||
Boolean(editState.levelName.trim()) &&
|
||||
editState.themeTags.length > 0 &&
|
||||
editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
|
||||
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT &&
|
||||
Boolean(formalImageSrc),
|
||||
};
|
||||
}
|
||||
@@ -130,12 +150,29 @@ function resolvePuzzleFormalImageSrc(draft: PuzzleResultDraft) {
|
||||
}
|
||||
|
||||
function PuzzleResultHeader({
|
||||
autoSaveState,
|
||||
isBusy,
|
||||
onBack,
|
||||
}: {
|
||||
autoSaveState: PuzzleAutoSaveState;
|
||||
isBusy: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const autoSaveBadge =
|
||||
autoSaveState === 'saving' ? (
|
||||
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
保存中
|
||||
</div>
|
||||
) : autoSaveState === 'saved' ? (
|
||||
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已自动保存
|
||||
</div>
|
||||
) : autoSaveState === 'error' ? (
|
||||
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
|
||||
保存失败
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
@@ -149,6 +186,7 @@ function PuzzleResultHeader({
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
{autoSaveBadge}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -871,6 +909,7 @@ function PuzzleResultActionBar({
|
||||
*/
|
||||
export function PuzzleResultView({
|
||||
session,
|
||||
profileId = null,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
@@ -884,15 +923,77 @@ export function PuzzleResultView({
|
||||
const [editState, setEditState] = useState<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
const [autoSaveState, setAutoSaveState] = useState<PuzzleAutoSaveState>('idle');
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
return;
|
||||
}
|
||||
setEditState(createDraftEditState(draft));
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || !editState || !profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLevelName = editState.levelName.trim();
|
||||
const normalizedSummary = editState.summary.trim();
|
||||
const normalizedTags = normalizeThemeTagInput(editState.themeTags.join(','));
|
||||
const draftLevelName = draft.levelName.trim();
|
||||
const draftSummary = draft.summary.trim();
|
||||
const draftTags = normalizeThemeTagInput(draft.themeTags.join(','));
|
||||
const levelNameChanged = normalizedLevelName !== draftLevelName;
|
||||
const summaryChanged = normalizedSummary !== draftSummary;
|
||||
const tagsChanged =
|
||||
normalizedTags.length !== draftTags.length ||
|
||||
normalizedTags.some((tag, index) => tag !== draftTags[index]);
|
||||
|
||||
if (!levelNameChanged && !summaryChanged && !tagsChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoSaveState('saving');
|
||||
setAutoSaveError(null);
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void updatePuzzleWork(profileId, {
|
||||
levelName: normalizedLevelName,
|
||||
summary: normalizedSummary,
|
||||
themeTags: normalizedTags,
|
||||
coverImageSrc: formalImageSrc || null,
|
||||
coverAssetId: draft.coverAssetId ?? null,
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAutoSaveState('saved');
|
||||
})
|
||||
.catch((saveError) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAutoSaveState('error');
|
||||
setAutoSaveError(
|
||||
saveError instanceof Error ? saveError.message : '自动保存失败。',
|
||||
);
|
||||
});
|
||||
}, PUZZLE_AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [draft, editState, formalImageSrc, profileId]);
|
||||
|
||||
const publishState = useMemo(() => {
|
||||
if (!draft || !editState) {
|
||||
return {
|
||||
@@ -915,7 +1016,11 @@ export function PuzzleResultView({
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
|
||||
<PuzzleResultHeader isBusy={isBusy} onBack={onBack} />
|
||||
<PuzzleResultHeader
|
||||
autoSaveState={autoSaveState}
|
||||
isBusy={isBusy}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
<PuzzleResultTabs
|
||||
activeTab={activeTab}
|
||||
@@ -953,6 +1058,11 @@ export function PuzzleResultView({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && autoSaveError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{autoSaveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PuzzleResultActionBar
|
||||
draft={draft}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeAll, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type QuestLogEntry,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { RpgAdventurePanel } from './RpgAdventurePanel';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function createOption(functionId: string, actionText: string): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingQuest(): QuestLogEntry {
|
||||
return {
|
||||
id: 'quest-liu-1',
|
||||
issuerNpcId: 'npc-liu',
|
||||
issuerNpcName: '柳无声',
|
||||
sceneId: 'scene-bamboo',
|
||||
title: '竹林密信',
|
||||
description: '替柳无声查清竹林中的密信来源。',
|
||||
summary: '去竹林查清密信来源。',
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
reward: {
|
||||
affinityBonus: 5,
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '完成后可获得报酬。',
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingQuestStory(quest: QuestLogEntry): StoryMoment {
|
||||
const viewOption = createOption('npc_chat_quest_offer_view', '查看任务');
|
||||
viewOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'view',
|
||||
};
|
||||
const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务');
|
||||
replaceOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'replace',
|
||||
};
|
||||
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
|
||||
abandonOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'abandon',
|
||||
};
|
||||
|
||||
return {
|
||||
text: '柳无声把真正的委托说了出来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
|
||||
],
|
||||
options: [viewOption, replaceOption, abandonOption],
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: {
|
||||
quest,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAcceptedQuestStory(quest: QuestLogEntry): StoryMoment {
|
||||
return {
|
||||
text: '柳无声把接下来的线索正式交给了你。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
|
||||
{ speaker: 'player', text: '这件事我愿意接下,你把关键要点交给我。' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '先去竹林查清密信来源。' },
|
||||
],
|
||||
options: [
|
||||
createOption('npc_chat', '这件事里你最担心哪一步'),
|
||||
createOption('npc_chat', '我回来时你最想先知道什么'),
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function QuestOfferHarness() {
|
||||
const pendingQuest = createPendingQuest();
|
||||
const [currentStory, setCurrentStory] = useState<StoryMoment>(
|
||||
createPendingQuestStory(pendingQuest),
|
||||
);
|
||||
const [quests, setQuests] = useState<QuestLogEntry[]>([]);
|
||||
const acceptPendingOffer = vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
setQuests([pendingQuest]);
|
||||
setCurrentStory(createAcceptedQuestStory(pendingQuest));
|
||||
});
|
||||
return pendingQuest.id;
|
||||
});
|
||||
|
||||
return (
|
||||
<RpgAdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
displayedOptions={currentStory.options}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onSubmitNpcChatInput={() => true}
|
||||
onExitNpcChat={() => true}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
worldType={WorldType.WUXIA}
|
||||
quests={quests}
|
||||
questUi={{
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
}}
|
||||
goalPulse={null}
|
||||
onDismissGoalPulse={() => undefined}
|
||||
battleRewardUi={{
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '竹林古道',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
}}
|
||||
musicVolume={0.6}
|
||||
onMusicVolumeChange={() => undefined}
|
||||
onSaveAndExit={() => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (!HTMLElement.prototype.scrollTo) {
|
||||
HTMLElement.prototype.scrollTo = () => undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test('quest offer accept button reuses the shared accepted-quest follow-up chain', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<QuestOfferHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /查看任务/u }));
|
||||
await user.click(await screen.findByRole('button', { name: '领取任务' }));
|
||||
|
||||
expect(await screen.findByText('任务进度:0/1')).toBeTruthy();
|
||||
expect(screen.getAllByText('竹林密信').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('待领取')).toBeNull();
|
||||
expect(screen.getByText('这件事里你最担心哪一步')).toBeTruthy();
|
||||
});
|
||||
@@ -1724,6 +1724,9 @@ export function RpgAdventurePanel({
|
||||
onAcceptPendingNpcQuestOffer={() => {
|
||||
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
|
||||
if (!acceptedQuestId) return null;
|
||||
// 中文注释:待领取任务详情弹层走的是异步服务端接取链路,
|
||||
// 这里先记录 questId,等 quest 真正进入日志后再由 effect 统一收口面板状态。
|
||||
setPendingAcceptedQuestId(acceptedQuestId);
|
||||
setSelectedQuestId(null);
|
||||
return acceptedQuestId;
|
||||
}}
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('sceneEncounterPreviews', () => {
|
||||
expect(resolved.currentEncounter).toBeNull();
|
||||
expect(resolved.currentBattleNpcId).toBe('npc-trader');
|
||||
expect(resolved.currentNpcBattleMode).toBe('fight');
|
||||
expect(resolved.sparReturnEncounter).toEqual(state.currentEncounter);
|
||||
expect(resolved.sceneHostileNpcs).toHaveLength(1);
|
||||
expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin');
|
||||
});
|
||||
|
||||
@@ -240,7 +240,9 @@ function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
|
||||
currentBattleNpcId: battleNpcId,
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
// 中文注释:NPC 开战后要保留战前原始遭遇,供战斗收尾时恢复和平态站位。
|
||||
// 这里复用现有 sparReturnEncounter 存槽,避免战后误把 battle encounter 的临时坐标带回场景。
|
||||
sparReturnEncounter: encounter,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
|
||||
@@ -132,67 +132,16 @@ function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption {
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackStory(text = 'fallback'): StoryMoment {
|
||||
function createFallbackStory(
|
||||
text = 'fallback',
|
||||
options: StoryOption[] = [],
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function createCustomWorldProfileForSceneAct(sceneId: string) {
|
||||
return {
|
||||
id: 'custom-world-test',
|
||||
name: '场景幕重置测试',
|
||||
summary: '用于验证战败后回到首幕。',
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: `${sceneId}-chapter`,
|
||||
sceneId,
|
||||
title: '测试章节',
|
||||
summary: '测试章节摘要',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: `${sceneId}-act-1`,
|
||||
sceneId,
|
||||
title: '第一幕',
|
||||
summary: '开场第一幕',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/act-1.png',
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: null,
|
||||
oppositeNpcId: null,
|
||||
eventDescription: '第一幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '完成第一幕目标',
|
||||
transitionHook: '第一幕过渡',
|
||||
},
|
||||
{
|
||||
id: `${sceneId}-act-2`,
|
||||
sceneId,
|
||||
title: '第二幕',
|
||||
summary: '推进第二幕',
|
||||
stageCoverage: ['expansion'],
|
||||
backgroundImageSrc: '/act-2.png',
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: null,
|
||||
oppositeNpcId: null,
|
||||
eventDescription: '第二幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '完成第二幕目标',
|
||||
transitionHook: '第二幕过渡',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as NonNullable<GameState['customWorldProfile']>;
|
||||
}
|
||||
|
||||
const neverNpcEncounter = (
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter => false;
|
||||
@@ -692,10 +641,8 @@ describe('createStoryChoiceActions', () => {
|
||||
it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
|
||||
const customWorldProfile = createCustomWorldProfileForSceneAct(firstScene.id);
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
customWorldProfile,
|
||||
currentScenePreset: firstScene,
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
@@ -735,7 +682,6 @@ describe('createStoryChoiceActions', () => {
|
||||
}));
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
@@ -763,7 +709,7 @@ describe('createStoryChoiceActions', () => {
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory('fallback')),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
@@ -809,22 +755,24 @@ describe('createStoryChoiceActions', () => {
|
||||
id: firstScene.id,
|
||||
}),
|
||||
playerHp: 100,
|
||||
playerMana: 20,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: null,
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
sceneId: firstScene.id,
|
||||
currentActId: `${firstScene.id}-act-1`,
|
||||
currentActIndex: 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const revivedState = setGameState.mock.calls[1]?.[0] as GameState;
|
||||
expect(revivedState.currentBattleNpcId).toBeNull();
|
||||
expect(revivedState.currentNpcBattleMode).toBeNull();
|
||||
expect(revivedState.currentNpcBattleOutcome).toBeNull();
|
||||
expect(
|
||||
revivedState.currentEncounter !== null || revivedState.sceneHostileNpcs.length > 0,
|
||||
).toBe(true);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('settles escape locally without ai continuation', async () => {
|
||||
|
||||
@@ -721,6 +721,59 @@ describe('npcEncounterActions', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('restores the pre-battle encounter after fight_victory instead of using the battle encounter position', () => {
|
||||
const preBattleEncounter = {
|
||||
...createEncounter(),
|
||||
xMeters: 12,
|
||||
context: '断桥外侧',
|
||||
};
|
||||
const battleEncounter = {
|
||||
...createEncounter(),
|
||||
xMeters: 3.2,
|
||||
context: '战斗中心位',
|
||||
};
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
currentEncounter: battleEncounter,
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'npc-rival',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: 'fight_victory',
|
||||
sparReturnEncounter: preBattleEncounter,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-rival',
|
||||
name: '断桥客',
|
||||
action: '逼近',
|
||||
description: '拦路旧敌',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 0,
|
||||
maxHp: 12,
|
||||
renderKind: 'npc',
|
||||
encounter: battleEncounter,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = actions.finalizeNpcBattleResult(
|
||||
actions.gameState,
|
||||
actions.gameState.playerCharacter!,
|
||||
'fight',
|
||||
'fight_victory',
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.nextState.currentEncounter).toEqual(preBattleEncounter);
|
||||
expect(result?.nextState.currentEncounter?.xMeters).toBe(12);
|
||||
expect(result?.nextState.sparReturnEncounter).toBeNull();
|
||||
});
|
||||
|
||||
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
|
||||
const encounter = createEncounter();
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
|
||||
327
src/hooks/rpg-runtime-story/postBattleFlow.test.ts
Normal file
327
src/hooks/rpg-runtime-story/postBattleFlow.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { ensureSceneEncounterPreviewMock } = vi.hoisted(() => ({
|
||||
ensureSceneEncounterPreviewMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../data/sceneEncounterPreviews', () => ({
|
||||
ensureSceneEncounterPreview: ensureSceneEncounterPreviewMock,
|
||||
}));
|
||||
|
||||
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { AnimationState, type GameState, WorldType } from '../../types';
|
||||
import { buildRevivedFirstSceneState } from './postBattleFlow';
|
||||
|
||||
function createBackstoryReveal(label: string) {
|
||||
return {
|
||||
publicSummary: `${label}的公开背景`,
|
||||
chapters: [
|
||||
{
|
||||
id: `${label}-surface`,
|
||||
title: '表层来意',
|
||||
affinityRequired: 15,
|
||||
teaser: `${label}先收着话。`,
|
||||
content: `${label}把真正目的藏在后面。`,
|
||||
contextSnippet: `${label}表面上仍在试探。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-scar`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 30,
|
||||
teaser: `${label}提到旧事会迟疑。`,
|
||||
content: `${label}背后压着旧伤。`,
|
||||
contextSnippet: `${label}仍被旧事牵制。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-hidden`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 60,
|
||||
teaser: `${label}真正执念并不在表面。`,
|
||||
content: `${label}真正想守住的是另一条暗线。`,
|
||||
contextSnippet: `${label}另有没说出口的理由。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-final`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: 90,
|
||||
teaser: `${label}手里还扣着底牌。`,
|
||||
content: `${label}掌握能改写局势的最后证据。`,
|
||||
contextSnippet: `${label}最后底牌还没翻出。`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryRole(id: string, name: string, hostile = false) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: `${name}的头衔`,
|
||||
role: hostile ? '敌对角色' : '同幕角色',
|
||||
description: `${name}的测试描述`,
|
||||
backstory: `${name}的测试背景`,
|
||||
personality: '冷静克制',
|
||||
motivation: hostile ? '阻拦玩家继续向前' : '观察局势变化',
|
||||
combatStyle: hostile ? '正面压制' : '后排支援',
|
||||
initialAffinity: hostile ? -20 : 12,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
backstoryReveal: createBackstoryReveal(name),
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createReviveState(): GameState {
|
||||
const customWorldProfile = {
|
||||
id: 'custom-revive-test',
|
||||
name: '复活回场测试世界',
|
||||
subtitle: '首幕站位恢复',
|
||||
summary: '用于验证复活后第一幕 NPC 会按既有 encounter preview 恢复。',
|
||||
settingText: '围绕开局营地与第一幕对峙角色展开的自定义世界。',
|
||||
tone: '紧张、克制',
|
||||
playerGoal: '复活后重新回到第一幕并面对主交互角色。',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试属性',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '复活回场测试世界',
|
||||
settingSummary: '首幕站位恢复',
|
||||
tone: '紧张、克制',
|
||||
conflictCore: '复活后重新面对主交互角色',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [
|
||||
createStoryRole('npc-front', '正面对手', true),
|
||||
createStoryRole('npc-back-1', '后排甲'),
|
||||
createStoryRole('npc-back-2', '后排乙'),
|
||||
],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
camp: {
|
||||
id: 'custom-scene-camp',
|
||||
name: '开局营地',
|
||||
description: '用于复活回场测试。',
|
||||
visualDescription: '营地火光映着即将重开的第一幕。',
|
||||
imageSrc: '/camp.png',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
narrativeResidues: null,
|
||||
},
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'custom-scene-camp-chapter',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '开局章节',
|
||||
summary: '复活后应回到这里的第一幕。',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'custom-scene-camp-act-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '第一幕',
|
||||
summary: '主交互角色与后排角色一同出现。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/act-1.png',
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '第一幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '重新进入首幕',
|
||||
transitionHook: '首幕回场',
|
||||
},
|
||||
{
|
||||
id: 'custom-scene-camp-act-2',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '第二幕',
|
||||
summary: '这是死亡前已经推进到的幕。',
|
||||
stageCoverage: ['expansion'],
|
||||
backgroundImageSrc: '/act-2.png',
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '第二幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '推进第二幕',
|
||||
transitionHook: '第二幕推进',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as NonNullable<GameState['customWorldProfile']>;
|
||||
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
const firstScene = getScenePresetsByWorld(WorldType.CUSTOM)[0]!;
|
||||
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile,
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
name: '测试主角',
|
||||
title: '旅人',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
},
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.DIE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: firstScene,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 0,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-front': {
|
||||
affinity: -20,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
'npc-back-1': {
|
||||
affinity: 8,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
'npc-back-2': {
|
||||
affinity: 6,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: 'npc-front',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: 'custom-scene-camp',
|
||||
chapterId: 'custom-scene-camp-chapter',
|
||||
currentActId: 'custom-scene-camp-act-2',
|
||||
currentActIndex: 1,
|
||||
completedActIds: ['custom-scene-camp-act-1'],
|
||||
visitedActIds: ['custom-scene-camp-act-1', 'custom-scene-camp-act-2'],
|
||||
},
|
||||
},
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('postBattleFlow', () => {
|
||||
afterEach(() => {
|
||||
ensureSceneEncounterPreviewMock.mockReset();
|
||||
setRuntimeCustomWorldProfile(null);
|
||||
});
|
||||
|
||||
it('rebuilds revived first-scene state through encounter preview restoration', () => {
|
||||
const reviveState = createReviveState();
|
||||
const previewRestoredState = {
|
||||
...reviveState,
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc' as const,
|
||||
characterId: 'npc-front',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手的测试描述',
|
||||
npcAvatar: '正',
|
||||
context: '敌对角色',
|
||||
xMeters: 12,
|
||||
},
|
||||
};
|
||||
ensureSceneEncounterPreviewMock.mockReturnValue(previewRestoredState);
|
||||
|
||||
const revived = buildRevivedFirstSceneState(reviveState);
|
||||
|
||||
expect(ensureSceneEncounterPreviewMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: 'custom-scene-camp',
|
||||
}),
|
||||
currentEncounter: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerHp: 100,
|
||||
playerMana: 20,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: null,
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
currentActId: 'custom-scene-camp-act-1',
|
||||
currentActIndex: 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(revived).toBe(previewRestoredState);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
advanceSceneActRuntimeState,
|
||||
buildInitialSceneActRuntimeState,
|
||||
@@ -169,7 +170,7 @@ export function buildRevivedFirstSceneState(state: GameState): GameState {
|
||||
storyEngineMemory: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
const revivedBaseState = {
|
||||
...state,
|
||||
currentScenePreset: firstScene,
|
||||
currentEncounter: null,
|
||||
@@ -195,19 +196,34 @@ export function buildRevivedFirstSceneState(state: GameState): GameState {
|
||||
...storyEngineMemory,
|
||||
currentSceneActState: firstActState,
|
||||
},
|
||||
};
|
||||
} satisfies GameState;
|
||||
|
||||
// 中文注释:角色复活后要回到“开局进入世界”同一套首幕 encounter preview
|
||||
// 构建链,而不是只清空战斗态。这样第一幕主交互 NPC 与同幕陪衬 NPC
|
||||
// 会按既有槽位一起恢复,避免退化成所有人站成一排。
|
||||
return ensureSceneEncounterPreview(revivedBaseState);
|
||||
}
|
||||
|
||||
export function buildDeathStory(state: GameState): StoryMoment {
|
||||
export function buildDeathStory(
|
||||
state: GameState,
|
||||
deferredOptions?: StoryOption[],
|
||||
): StoryMoment {
|
||||
const firstSceneName =
|
||||
state.worldType
|
||||
? getScenePresetsByWorld(state.worldType)[0]?.name
|
||||
: state.currentScenePreset?.name;
|
||||
|
||||
return {
|
||||
text: firstSceneName
|
||||
? `你在战斗中倒下,随后在${firstSceneName}重新醒来。`
|
||||
: '你在战斗中倒下,随后重新醒来。',
|
||||
options: [buildContinueOption()],
|
||||
// 中文注释:复活后的“继续前进”只负责揭示已经准备好的首场景入口,
|
||||
// 不能再次触发普通剧情推演,否则容易直接被推进到主 NPC 执行态。
|
||||
deferredOptions:
|
||||
deferredOptions && deferredOptions.length > 0
|
||||
? deferredOptions
|
||||
: undefined,
|
||||
streaming: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,6 +301,13 @@ function bridgeServerNpcBattleSnapshot(params: {
|
||||
sceneHostileNpcs: resolvedBattleFormation,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
// 中文注释:服务端兼容链路若未带回战前遭遇,则沿用进入战斗前的原始 encounter,
|
||||
// 让后续 fight_victory / spar_complete 都能恢复到正确站位,而不是战斗中的临时坐标。
|
||||
sparReturnEncounter:
|
||||
snapshotState.sparReturnEncounter ??
|
||||
(previousState.currentEncounter?.kind === 'npc'
|
||||
? previousState.currentEncounter
|
||||
: null),
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
@@ -937,6 +937,9 @@ describe('runtimeStoryCoordinator', () => {
|
||||
yOffset: 62,
|
||||
},
|
||||
]);
|
||||
expect(result.hydratedSnapshot.gameState.sparReturnEncounter).toEqual(
|
||||
gameState.currentEncounter,
|
||||
);
|
||||
});
|
||||
|
||||
it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => {
|
||||
|
||||
@@ -446,8 +446,12 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
],
|
||||
};
|
||||
fallbackState = revivedState;
|
||||
const revivedDeferredOptions =
|
||||
params.buildFallbackStoryForState(revivedState, params.character).options;
|
||||
params.setGameState(revivedState);
|
||||
params.setCurrentStory(buildDeathStory(revivedState));
|
||||
params.setCurrentStory(
|
||||
buildDeathStory(revivedState, revivedDeferredOptions),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -527,7 +527,10 @@ describe('storyChoiceRuntime', () => {
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
buildFallbackStoryForState: () =>
|
||||
createStory('fallback', [
|
||||
createOption('idle_explore_forward'),
|
||||
]),
|
||||
turnVisualMs: 1,
|
||||
});
|
||||
|
||||
@@ -541,6 +544,11 @@ describe('storyChoiceRuntime', () => {
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'story_continue_adventure',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).not.toHaveBeenCalledWith(
|
||||
|
||||
@@ -350,8 +350,12 @@ export async function runServerRuntimeChoiceAction(params: {
|
||||
params.setGameState(deathState);
|
||||
await sleep(PLAYER_REVIVE_DELAY_MS);
|
||||
const revivedState = buildRevivedFirstSceneState(deathState);
|
||||
const revivedDeferredOptions =
|
||||
params.buildFallbackStoryForState(revivedState, params.character).options;
|
||||
params.setGameState(revivedState);
|
||||
params.setCurrentStory(buildDeathStory(revivedState));
|
||||
params.setCurrentStory(
|
||||
buildDeathStory(revivedState, revivedDeferredOptions),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -490,6 +490,7 @@ export function createStoryNpcEncounterActions({
|
||||
(hostileNpc) => hostileNpc.id,
|
||||
);
|
||||
const restoredEncounter =
|
||||
state.sparReturnEncounter ??
|
||||
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
|
||||
activeBattleHostiles[0]?.encounter ??
|
||||
({
|
||||
|
||||
Reference in New Issue
Block a user