This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions
+27 -21
View File
@@ -926,27 +926,6 @@ export function CustomWorldEntityCatalog({
</div>
</Section>
<Section
title="角色维度"
subtitle={profile.attributeSchema?.schemaName}
>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-2 2xl:grid-cols-3">
{attributeSlots.map((slot) => (
<div
key={slot.slotId}
className="platform-subpanel rounded-xl px-3 py-3"
>
<div className="text-sm font-semibold text-white">
{slot.name}
</div>
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-zinc-400">
{buildAttributeSlotSummary(slot) || slot.definition}
</div>
</div>
))}
</div>
</Section>
<Section
title="世界概述"
actions={
@@ -1000,6 +979,33 @@ export function CustomWorldEntityCatalog({
}
>
<div className="space-y-3">
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-end justify-between gap-2">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
角色维度
</div>
{profile.attributeSchema?.schemaName ? (
<div className="text-xs leading-5 text-zinc-500">
{profile.attributeSchema.schemaName}
</div>
) : null}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6">
{attributeSlots.map((slot) => (
<div
key={slot.slotId}
className="rounded-xl border border-white/10 bg-black/15 px-3 py-3"
>
<div className="text-sm font-semibold text-white">
{slot.name}
</div>
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-zinc-400">
{buildAttributeSlotSummary(slot) || slot.definition}
</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{structuredFoundationEntries.map((entry) => (
<div
+5 -1
View File
@@ -8,15 +8,19 @@ type ResolvedAssetImageProps = Omit<
> & {
src?: string | null;
fallbackSrc?: string | null;
refreshKey?: string | number | null;
};
export function ResolvedAssetImage({
src,
fallbackSrc,
alt,
refreshKey,
...rest
}: ResolvedAssetImageProps) {
const { resolvedUrl } = useResolvedAssetReadUrl(src);
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
refreshKey,
});
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
if (!finalSrc) {
@@ -78,6 +78,7 @@ function renderEntityLayer(effectNpcId: string | null) {
return renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
@@ -185,4 +186,52 @@ describe('GameCanvasEntityLayer', () => {
expect(html).not.toContain('npc-affinity-effect-npc-liu');
expect(html).not.toContain('好感度变化 +3');
});
it('renders scene act back-row encounters alongside the primary encounter', () => {
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[
createEncounter({ id: 'npc-back-1', npcName: '后排甲' }),
createEncounter({ id: 'npc-back-2', npcName: '后排乙' }),
]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={createEncounter({ id: 'npc-primary', npcName: '主角色' })}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).toContain('查看主角色详情');
expect(html).toContain('查看后排甲详情');
expect(html).toContain('查看后排乙详情');
});
});
@@ -51,6 +51,7 @@ type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
interface GameCanvasEntityLayerProps {
companions: CompanionRenderState[];
sceneActAmbientEncounters: Encounter[];
currentScenePreset: ScenePresetInfo | null;
sceneTransitionToken: number;
isSceneTransitionEntering: boolean;
@@ -93,6 +94,13 @@ interface GameCanvasEntityLayerProps {
playerX: number;
}
const SCENE_ACT_BACK_ROW_ANCHOR_X_METERS = RESOLVED_ENTITY_X_METERS + 1.08;
const SCENE_ACT_BACK_ROW_OFFSET_PX = [62, -46] as const;
function addCssPxOffset(value: string, offsetPx: number) {
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
}
function CombatFloatingNumber({
event,
onDone,
@@ -177,6 +185,7 @@ function CombatReactiveSpriteFrame({
export function GameCanvasEntityLayer({
companions,
sceneActAmbientEncounters,
currentScenePreset,
sceneTransitionToken,
isSceneTransitionEntering,
@@ -415,9 +424,16 @@ export function GameCanvasEntityLayer({
</div>
</motion.div>
{sceneCombatants.map(hostileNpc => {
{sceneCombatants.map((hostileNpc, index) => {
const npcEncounter = hostileNpc.encounter;
if (!npcEncounter) return null;
const hostileRenderKey = [
hostileNpc.id,
npcEncounter.id ?? npcEncounter.npcName,
hostileNpc.xMeters,
hostileNpc.yOffset ?? 0,
index,
].join(':');
const config = monsters.find(item => item.id === hostileNpc.id);
const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0};
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
@@ -453,7 +469,7 @@ export function GameCanvasEntityLayer({
return (
<div
key={hostileNpc.id}
key={hostileRenderKey}
className="absolute"
style={{
left: getHostileNpcOuterLeft(hostileNpc),
@@ -628,6 +644,111 @@ export function GameCanvasEntityLayer({
</div>
);
})()}
{!inBattle &&
sceneActAmbientEncounters.map((ambientEncounter, index) => {
const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
if (ambientOffsetPx === undefined) {
return null;
}
const ambientResolvedCharacter =
ambientEncounter.kind !== 'treasure' && ambientEncounter.characterId
? getCharacterById(ambientEncounter.characterId)
: null;
const ambientMonsterConfig =
!ambientResolvedCharacter &&
ambientEncounter.kind === 'npc' &&
ambientEncounter.monsterPresetId
? monsters.find(item => item.id === ambientEncounter.monsterPresetId) ?? null
: null;
const ambientHostileBottomOffsetPx = ambientMonsterConfig
? getHostileNpcSceneBottomOffsetPx(ambientMonsterConfig)
: getSceneNpcVisualBottomOffsetPx(ambientEncounter);
const ambientBottomOffsetPx = ambientResolvedCharacter
? getEncounterCharacterBottomOffsetPx(
stageLiftPx,
ambientEncounter,
ambientResolvedCharacter,
ambientOffsetPx,
)
: stageLiftPx + ambientHostileBottomOffsetPx + ambientOffsetPx;
const ambientFacing = getFacingTowardPlayer(
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
playerX,
);
const ambientBottom = ambientEncounter.characterId
? getEncounterCharacterOpponentBottom(
groundBottom,
stageLiftPx,
ambientEncounter,
getCharacterById(ambientEncounter.characterId),
)
: `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;
return (
<div
key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
className="absolute"
style={{
left: getMonsterWorldLeft(
sideAnchor,
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
cameraAnchorX,
monsterAnchorMeters,
),
bottom: addCssPxOffset(ambientBottom, ambientOffsetPx),
zIndex: getSceneEntityZIndex(ambientBottomOffsetPx),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
<SceneEntityButton
onClick={
ambientEncounter.kind === 'npc'
? () => onEntitySelect?.({kind: 'npc', encounter: ambientEncounter})
: null
}
ariaLabel={
ambientEncounter.kind === 'npc'
? `查看${ambientEncounter.npcName}详情`
: undefined
}
className="relative flex w-28 flex-col items-center"
>
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{ambientResolvedCharacter &&
!ambientEncounter.visual &&
!ambientEncounter.imageSrc?.trim() ? (
<RoleCharacterSprite
state={AnimationState.IDLE}
character={ambientResolvedCharacter}
facing={ambientFacing}
/>
) : ambientMonsterConfig ? (
<HostileNpcAnimator
hostileNpc={ambientMonsterConfig}
animation="idle"
flip={ambientFacing === 'right'}
className="scale-[1.82] origin-bottom"
/>
) : (
<SceneEncounterNpcSprite
encounter={ambientEncounter}
state={AnimationState.IDLE}
facing={ambientFacing}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
/>
)}
</div>
{/* 幕后排角色只是同幕可见实体,不抢占当前交互目标。 */}
{npcAffinityEffect?.npcId ===
(ambientEncounter.id ?? ambientEncounter.npcName) ? (
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
);
})}
</>
);
}
@@ -2,8 +2,12 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime';
import {AnimationState, WorldType} from '../../types';
import {buildEncounterFromSceneNpc} from '../../data/scenePresets';
import {
resolveActiveSceneActBackgroundImage,
resolveActiveSceneActEncounterNpcIds,
} from '../../services/customWorldSceneActRuntime';
import {AnimationState, type Encounter, type SceneNpc, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
import {GameCanvasOverlayLayer} from './GameCanvasOverlayLayer';
@@ -66,6 +70,42 @@ export function GameCanvasRuntime({
const backgroundSrc = activeSceneActBackground
|| currentScenePreset?.imageSrc
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const activeSceneActEncounterNpcIds =
currentScenePreset?.id
? resolveActiveSceneActEncounterNpcIds({
profile: customWorldProfile,
sceneId: currentScenePreset.id,
storyEngineMemory,
})
: [];
const activeSceneActNpcIdSet = new Set(activeSceneActEncounterNpcIds);
const sceneActAmbientEncounters = (currentScenePreset?.npcs ?? [])
.filter((npc: SceneNpc) => {
if (activeSceneActNpcIdSet.size === 0) {
return false;
}
const candidateIds = [npc.id, npc.characterId].filter(
(value): value is string => Boolean(value),
);
const encounterIds = [encounter?.id, encounter?.characterId].filter(
(value): value is string => Boolean(value),
);
return (
candidateIds.some((id) => activeSceneActNpcIdSet.has(id)) &&
!candidateIds.some((id) => encounterIds.includes(id))
);
})
.slice(0, 2)
.map((npc: SceneNpc, index): Encounter => {
const npcEncounter = buildEncounterFromSceneNpc(npc);
return {
...npcEncounter,
xMeters: 3.2 + 1.08,
id: npcEncounter.id ?? `${npc.id}:ambient-${index}`,
};
});
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
const groundBottom = '18%';
const stageLiftPx = 68;
@@ -181,6 +221,7 @@ export function GameCanvasRuntime({
/>
<GameCanvasEntityLayer
companions={companions}
sceneActAmbientEncounters={sceneActAmbientEncounters}
currentScenePreset={currentScenePreset}
sceneTransitionToken={sceneTransitionToken}
isSceneTransitionEntering={isSceneTransitionEntering}
@@ -23,6 +23,7 @@ import type {
PuzzleAgentActionRequest,
PuzzleAgentOperationRecord,
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
@@ -123,6 +124,8 @@ type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
};
type PuzzleRuntimeReturnStage = 'puzzle-result' | 'puzzle-gallery-detail';
type AgentResultBlockerView = {
code?: string;
message: string;
@@ -406,6 +409,8 @@ export function PlatformEntryFlowShellImpl({
useState<PuzzleWorkSummary | null>(null);
const [puzzleDetailReturnTarget, setPuzzleDetailReturnTarget] =
useState<PuzzleDetailReturnTarget | null>(null);
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [puzzleGenerationState, setPuzzleGenerationState] =
@@ -944,6 +949,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleOperation(null);
setPuzzleWorks([]);
setSelectedPuzzleDetail(null);
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setPuzzleRun(null);
setPuzzleGenerationState(null);
setIsPuzzleNextLevelGenerating(false);
@@ -1087,6 +1093,7 @@ export function PlatformEntryFlowShellImpl({
const { item } = await getPuzzleGalleryDetail(profileId);
setSelectedPuzzleDetail(item);
setPuzzleRun(startLocalPuzzleRun(item));
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setSelectionStage('puzzle-runtime');
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
@@ -1097,6 +1104,57 @@ export function PlatformEntryFlowShellImpl({
[isPuzzleBusy, resolvePuzzleErrorMessage, setSelectionStage],
);
const buildPuzzleTestWork = useCallback(
(draft: PuzzleResultDraft) => {
const profileId =
puzzleSession?.publishedProfileId ??
`draft-${puzzleSession?.sessionId ?? 'puzzle'}-test`;
const now = new Date().toISOString();
return {
workId: `test-${profileId}`,
profileId,
ownerUserId: authUi?.user?.id ?? 'current-user',
sourceSessionId: puzzleSession?.sessionId ?? null,
authorDisplayName: authUi?.user?.displayName ?? '玩家',
levelName: draft.levelName,
summary: draft.summary,
themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
publicationStatus: 'draft',
updatedAt: now,
publishedAt: null,
playCount: 0,
publishReady: Boolean(puzzleSession?.resultPreview?.publishReady),
} satisfies PuzzleWorkSummary;
},
[
authUi?.user?.displayName,
authUi?.user?.id,
puzzleSession?.publishedProfileId,
puzzleSession?.resultPreview?.publishReady,
puzzleSession?.sessionId,
],
);
const startPuzzleTestRunFromDraft = useCallback(
(draft: PuzzleResultDraft) => {
if (!draft.coverImageSrc) {
setPuzzleError('请先选择一张正式拼图图片。');
return;
}
const testWork = buildPuzzleTestWork(draft);
setSelectedPuzzleDetail(testWork);
setPuzzleRun(startLocalPuzzleRun(testWork));
setPuzzleRuntimeReturnStage('puzzle-result');
setPuzzleError(null);
setSelectionStage('puzzle-runtime');
},
[buildPuzzleTestWork, setSelectionStage],
);
const submitBigFishInput = useCallback(
(payload: SubmitBigFishInputRequest) => {
if (!bigFishRun || bigFishInputInFlightRef.current) {
@@ -2186,7 +2244,6 @@ export function PlatformEntryFlowShellImpl({
<Suspense fallback={<LazyPanelFallback label="正在加载拼图结果..." />}>
<PuzzleResultView
session={puzzleSession}
author={authUi?.user ?? null}
isBusy={isPuzzleBusy}
error={puzzleError}
onBack={() => {
@@ -2195,6 +2252,7 @@ export function PlatformEntryFlowShellImpl({
onExecuteAction={(payload) => {
void executePuzzleAction(payload);
}}
onStartTestRun={startPuzzleTestRunFromDraft}
/>
</Suspense>
</motion.div>
@@ -2252,7 +2310,7 @@ export function PlatformEntryFlowShellImpl({
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
error={puzzleError}
onBack={() => {
setSelectionStage('puzzle-gallery-detail');
setSelectionStage(puzzleRuntimeReturnStage);
}}
onSwapPieces={(payload) => {
void swapPuzzlePiecesInRun(payload);
@@ -0,0 +1,425 @@
// @vitest-environment jsdom
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import { PuzzleResultView } from './PuzzleResultView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
puzzleAssetClient: {
listHistoryAssets: vi.fn(),
},
}));
function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
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',
},
},
draft: {
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
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',
generationStatus: 'ready',
metadata: null,
},
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-26T10:00:00.000Z',
};
const session = {
...baseSession,
resultPreview: {
draft: baseSession.draft!,
publishReady: true,
blockers: [],
qualityFindings: [],
},
...overrides,
} satisfies PuzzleAgentSessionSnapshot;
return session;
}
describe('PuzzleResultView', () => {
test('uses two tabs without author preview or persistent publish validation', () => {
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '基本信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '拼图图片' })).toBeTruthy();
expect(screen.queryByText('作者预览')).toBeNull();
expect(screen.queryByText('发布校验')).toBeNull();
expect(screen.getByRole('button', { name: /作品测试/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /发布/u })).toBeTruthy();
});
test('edits theme tags with chips instead of a persistent tag input', () => {
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
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.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: '暖灯猫街',
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
});
test('generates one image from the picture description and replaces current image', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
expect(screen.getByText('画面描述')).toBeTruthy();
expect(screen.queryByText(/候选图/u)).toBeNull();
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,
});
});
test('selects a history puzzle asset as reference image for the next generation', async () => {
const onExecuteAction = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
{
assetObjectId: 'asset-history-1',
assetKind: 'puzzle_cover_image',
imageSrc: '/generated-puzzle-assets/history/image.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: 'puzzle-session-1',
createdAt: '2026-04-27T10:00:00.000Z',
updatedAt: '2026-04-27T10:00:00.000Z',
},
]);
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
const dialog = await screen.findByRole('dialog', {
name: '选择历史拼图素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: /账号 user-1/u }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史拼图素材' })).toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: /生成并替换当前图片/u }));
expect(onExecuteAction).toHaveBeenLastCalledWith({
action: 'generate_puzzle_images',
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={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
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={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
await waitFor(() => {
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
'/puzzle/candidate-2.png',
);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: () => ({
resolvedUrl: '',
isResolving: false,
shouldResolve: false,
}),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
}));
const clearedRun: PuzzleRunSnapshot = {
runId: 'run-1',
entryProfileId: 'profile-1',
clearedLevelCount: 1,
currentLevelIndex: 1,
currentGridSize: 3,
playedProfileIds: ['profile-1'],
previousLevelTags: ['奇幻'],
recommendedNextProfileId: 'profile-2',
leaderboardEntries: [
{
rank: 1,
nickname: '测试作者',
elapsedMs: 12_340,
isCurrentPlayer: true,
},
{
rank: 2,
nickname: '星桥旅人',
elapsedMs: 18_120,
},
],
currentLevel: {
runId: 'run-1',
levelIndex: 1,
gridSize: 3,
profileId: 'profile-1',
levelName: '潮雾拼图',
authorDisplayName: '测试作者',
themeTags: ['奇幻'],
coverImageSrc: null,
status: 'cleared',
startedAtMs: 1000,
clearedAtMs: 13_340,
elapsedMs: 12_340,
leaderboardEntries: [
{
rank: 1,
nickname: '测试作者',
elapsedMs: 12_340,
isCurrentPlayer: true,
},
{
rank: 2,
nickname: '星桥旅人',
elapsedMs: 18_120,
},
],
board: {
rows: 3,
cols: 3,
selectedPieceId: null,
allTilesResolved: true,
mergedGroups: [],
pieces: Array.from({ length: 9 }, (_, index) => ({
pieceId: `piece-${index}`,
correctRow: Math.floor(index / 3),
correctCol: index % 3,
currentRow: Math.floor(index / 3),
currentCol: index % 3,
mergedGroupId: null,
})),
},
},
};
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
const onAdvanceNextLevel = vi.fn();
render(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={onAdvanceNextLevel}
/>,
);
const dialog = screen.getByRole('dialog', { name: '通关完成' });
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
expect(within(dialog).getByText('排行榜')).toBeTruthy();
expect(within(dialog).getByText('#1')).toBeTruthy();
expect(within(dialog).getByText('测试作者')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
});
test('关闭通关弹窗后保留底部下一关入口', () => {
render(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy();
});
File diff suppressed because it is too large Load Diff
@@ -2070,6 +2070,9 @@ function SceneActPreviewRuntime({
...current,
worldType: WorldType.CUSTOM,
customWorldProfile: profile,
// 中文注释:幕预览只复用运行时表现,不应进入正式存档和个人游玩记录。
runtimeMode: 'preview',
runtimePersistenceDisabled: true,
currentScene: 'Story',
currentScenePreset: previewScenePreset,
currentEncounter: previewEncounter,
@@ -4873,42 +4876,6 @@ export function WorldEditor({
rows={3}
/>
</Field>
<Field label="玩家原始设定">
<TextArea
value={draft.settingText}
onChange={(value) =>
setDraft((current) => ({
...current,
settingText: value,
creatorIntent: current.creatorIntent
? {
...current.creatorIntent,
rawSettingText: value,
}
: current.creatorIntent,
}))
}
rows={4}
/>
</Field>
<WorldAttributeSchemaEditor
value={draft.attributeSchema}
onChange={(attributeSchema) =>
setDraft((current) => ({
...current,
attributeSchema,
ownedSettingLayers: current.ownedSettingLayers
? {
...current.ownedSettingLayers,
ruleProfile: {
...current.ownedSettingLayers.ruleProfile,
attributeSchema,
},
}
: current.ownedSettingLayers,
}))
}
/>
<SaveBar
onClose={onClose}
onSave={() => {
@@ -5134,6 +5101,9 @@ export function WorldFoundationEditor({
}) {
const initialDraft = useMemo(() => buildFoundationDraft(profile), [profile]);
const [draft, setDraft] = useDraft(initialDraft);
const [attributeSchemaDraft, setAttributeSchemaDraft] = useDraft(
profile.attributeSchema,
);
return (
<ModalShell
@@ -5170,10 +5140,27 @@ export function WorldFoundationEditor({
</div>
</Field>
))}
<WorldAttributeSchemaEditor
value={attributeSchemaDraft}
onChange={setAttributeSchemaDraft}
/>
<SaveBar
onClose={onClose}
onSave={() => {
onSave(applyFoundationDraftToProfile(profile, draft));
const nextProfile = applyFoundationDraftToProfile(profile, draft);
onSave({
...nextProfile,
attributeSchema: attributeSchemaDraft,
ownedSettingLayers: nextProfile.ownedSettingLayers
? {
...nextProfile.ownedSettingLayers,
ruleProfile: {
...nextProfile.ownedSettingLayers.ruleProfile,
attributeSchema: attributeSchemaDraft,
},
}
: nextProfile.ownedSettingLayers,
});
onClose();
}}
/>
@@ -883,7 +883,6 @@ export function RpgAdventurePanelOverlays({
setIsSettingsPanelOpen,
isStatsPanelOpen,
setIsStatsPanelOpen,
chapterState,
journeyBeat,
goalStack,
goalPulse,
@@ -1645,7 +1644,7 @@ export function RpgAdventurePanelOverlays({
<div className="mt-3 flex flex-wrap gap-2">
{battleReward.defeatedHostileNpcs.map((hostileNpc) => (
<span
key={`${battleReward.id}-${hostileNpc.id}`}
key={`${battleReward.id}-${hostileNpc.renderKey ?? hostileNpc.id}`}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-50"
>
{hostileNpc.name}
+2 -2
View File
@@ -38,9 +38,9 @@ import {
ItemUseProfile,
KnowledgeFact,
RoleAttributeProfile,
SceneNarrativeResidue,
SceneActBlueprint,
SceneChapterBlueprint,
SceneNarrativeResidue,
ThemePack,
ThreadContract,
WorldStoryGraph,
@@ -990,7 +990,7 @@ function normalizeSceneActBlueprint(
return {
id: toText(value.id, `saved-scene-act-${sceneId}-${index + 1}`),
sceneId,
sceneId: toText(value.sceneId, sceneId),
title: title || `第 ${index + 1} 幕`,
summary: summary || title || `围绕${sceneId}继续推进`,
stageCoverage:
+345 -1
View File
@@ -1,16 +1,20 @@
import { describe, expect, it } from 'vitest';
import { resolveActiveSceneActEncounterNpcIds } from '../services/customWorldSceneActRuntime';
import {
AnimationState,
type Character,
type CustomWorldProfile,
type Encounter,
type GameState,
type SceneNpc,
WorldType,
} from '../types';
import { getMonsterPresetsByWorld } from './hostileNpcPresets';
import { createSceneHostileNpc } from './hostileNpcs';
import { buildInitialNpcState } from './npcInteractions';
import {
createSceneEncounterPreview,
hasAutoBattleSceneEncounter,
resolveSceneEncounterPreview,
} from './sceneEncounterPreviews';
@@ -150,5 +154,345 @@ describe('sceneEncounterPreviews', () => {
expect(monster?.encounter?.hostile).toBe(true);
expect(monster?.encounter?.initialAffinity).toBe(-40);
});
});
it('resolves active act npc ids when runtime scene id differs from landmark id', () => {
const profile = {
id: 'custom-profile',
name: '测试世界',
settingText: '',
subtitle: '',
summary: '',
tone: '',
playerGoal: '',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
attributes: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [
{
id: 'landmark-raw-1',
name: '旧桥',
description: '旧桥',
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'chapter-1',
sceneId: 'landmark-raw-1',
title: '旧桥章节',
summary: '',
sceneTaskDescription: '',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-raw-1'],
acts: [
{
id: 'act-1',
sceneId: 'landmark-raw-1',
title: '第一幕',
summary: '',
stageCoverage: ['opening'],
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-front',
oppositeNpcId: 'npc-front',
eventDescription: '',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '',
transitionHook: '',
},
],
},
],
} as CustomWorldProfile;
expect(
resolveActiveSceneActEncounterNpcIds({
profile,
sceneId: 'custom-scene-landmark-1',
}),
).toEqual(['npc-front', 'npc-back-1', 'npc-back-2']);
});
it('resolves active act npc ids from act scene id even when chapter scene id is abstract', () => {
const profile = {
id: 'custom-profile',
name: '测试世界',
settingText: '',
subtitle: '',
summary: '',
tone: '',
playerGoal: '',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
attributes: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [
{
id: 'landmark-raw-1',
name: '旧桥',
description: '旧桥',
sceneNpcIds: [],
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'chapter-1',
sceneId: 'chapter-abstract-scene',
title: '旧桥章节',
summary: '',
sceneTaskDescription: '',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-1',
sceneId: 'landmark-raw-1',
title: '第一幕',
summary: '',
stageCoverage: ['opening'],
encounterNpcIds: [],
primaryNpcId: '',
oppositeNpcId: 'npc-front',
eventDescription: '',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '',
transitionHook: '',
},
],
},
],
} as CustomWorldProfile;
expect(
resolveActiveSceneActEncounterNpcIds({
profile,
sceneId: 'custom-scene-landmark-1',
}),
).toEqual(['npc-front']);
});
it('uses the active act opposite npc as the formal scene encounter', () => {
const state = {
...createBaseState(),
worldType: WorldType.CUSTOM,
customWorldProfile: {
id: 'custom-profile',
name: '测试世界',
settingText: '',
subtitle: '',
summary: '',
tone: '',
playerGoal: '',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
attributes: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [
{
id: 'landmark-raw-1',
name: '旧桥',
description: '旧桥',
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'chapter-1',
sceneId: 'landmark-raw-1',
title: '旧桥章节',
summary: '',
sceneTaskDescription: '',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-raw-1'],
acts: [
{
id: 'act-1',
sceneId: 'landmark-raw-1',
title: '第一幕',
summary: '',
stageCoverage: ['opening'],
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-back-1',
oppositeNpcId: 'npc-front',
eventDescription: '',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '',
transitionHook: '',
},
],
},
],
} as CustomWorldProfile,
currentEncounter: null,
currentScenePreset: {
id: 'custom-scene-landmark-1',
name: '旧桥',
description: '旧桥',
imageSrc: '/bridge.png',
connectedSceneIds: [],
npcs: [
{
id: 'hostile-side',
name: '旁路敌人',
description: '旁路敌人',
avatar: '敌',
role: '敌对角色',
monsterPresetId: 'monster-01',
initialAffinity: -40,
hostile: true,
},
{
id: 'npc-back-1',
name: '后排甲',
description: '后排甲',
avatar: '甲',
role: '同幕角色',
},
{
id: 'npc-front',
name: '主角色',
description: '主角色',
avatar: '主',
role: '主角色',
},
{
id: 'npc-back-2',
name: '后排乙',
description: '后排乙',
avatar: '乙',
role: '同幕角色',
},
] satisfies SceneNpc[],
treasureHints: [],
},
} satisfies GameState;
const preview = createSceneEncounterPreview(state);
expect(preview.currentEncounter?.id).toBe('npc-front');
expect(preview.currentEncounter?.npcName).toBe('主角色');
});
it('uses active act opposite npc even when that npc is hostile', () => {
const state = {
...createBaseState(),
worldType: WorldType.CUSTOM,
customWorldProfile: {
id: 'custom-profile',
name: '测试世界',
settingText: '',
subtitle: '',
summary: '',
tone: '',
playerGoal: '',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
attributes: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
sceneChapterBlueprints: [
{
id: 'chapter-1',
sceneId: 'custom-scene-camp',
title: '开局章节',
summary: '',
sceneTaskDescription: '',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-1',
sceneId: 'custom-scene-camp',
title: '第一幕',
summary: '',
stageCoverage: ['opening'],
encounterNpcIds: ['npc-hostile-opposite', 'npc-back'],
primaryNpcId: 'npc-back',
oppositeNpcId: 'npc-hostile-opposite',
eventDescription: '',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '',
transitionHook: '',
},
],
},
],
} as CustomWorldProfile,
currentEncounter: null,
currentScenePreset: {
id: 'custom-scene-camp',
name: '营地',
description: '营地',
imageSrc: '/camp.png',
connectedSceneIds: [],
npcs: [
{
id: 'npc-hostile-opposite',
name: '敌意对面角色',
description: '第一幕先开口的敌意角色',
avatar: '敌',
role: '第一幕对面角色',
initialAffinity: -20,
hostile: true,
},
{
id: 'npc-back',
name: '后排角色',
description: '同幕后排角色',
avatar: '后',
role: '同幕角色',
},
] satisfies SceneNpc[],
treasureHints: [],
},
} satisfies GameState;
const preview = createSceneEncounterPreview(state);
const resolved = resolveSceneEncounterPreview({
...state,
...preview,
npcStates: {
'npc-hostile-opposite': {
...buildInitialNpcState(
preview.currentEncounter!,
WorldType.CUSTOM,
state,
),
affinity: -20,
},
},
});
expect(preview.currentEncounter?.id).toBe('npc-hostile-opposite');
expect(preview.currentEncounter?.npcName).toBe('敌意对面角色');
expect(resolved.inBattle).toBe(false);
expect(resolved.currentEncounter?.id).toBe('npc-hostile-opposite');
});
});
+103 -26
View File
@@ -1,3 +1,8 @@
import {
canUseLimitedPrimaryNpcChat,
resolveActiveSceneActEncounterFocusNpcId,
resolveActiveSceneActEncounterNpcIds,
} from '../services/customWorldSceneActRuntime';
import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types';
import { getRecruitedNpcIds } from './companionRoster';
import {
@@ -15,10 +20,6 @@ import {
getSceneHostileNpcs,
getWorldCampScenePreset,
} from './scenePresets';
import {
canUseLimitedPrimaryNpcChat,
resolveActiveSceneActEncounterNpcIds,
} from '../services/customWorldSceneActRuntime';
export const EXPLORE_APPROACH_DURATION_MS = 4000;
export const PREVIEW_ENTITY_X_METERS = 12;
@@ -115,7 +116,11 @@ function getAvailableFriendlySceneNpcs(state: GameState) {
const activeActNpcIdSet = new Set(activeActNpcIds);
return getSceneFriendlyNpcs(state.currentScenePreset)
.filter(candidate => !isCampScene || Boolean(candidate.characterId))
.filter(candidate =>
!isCampScene ||
Boolean(candidate.characterId) ||
activeActNpcIdSet.has(candidate.id),
)
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
.filter(candidate => !recruitedNpcIds.has(candidate.id))
.filter(candidate =>
@@ -126,6 +131,29 @@ function getAvailableFriendlySceneNpcs(state: GameState) {
);
}
function getAvailableActiveSceneActNpcs(state: GameState) {
const recruitedNpcIds = getRecruitedNpcIds(state);
const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({
profile: state.customWorldProfile,
sceneId: state.currentScenePreset?.id ?? null,
storyEngineMemory: state.storyEngineMemory,
});
const activeActNpcIdSet = new Set(activeActNpcIds);
if (activeActNpcIdSet.size === 0) {
return [];
}
return (state.currentScenePreset?.npcs ?? [])
.filter(candidate => {
const candidateIds = [candidate.id, candidate.characterId].filter(
(value): value is string => Boolean(value),
);
return candidateIds.some(id => activeActNpcIdSet.has(id));
})
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
.filter(candidate => !recruitedNpcIds.has(candidate.id));
}
function getAvailableHostileSceneNpcs(state: GameState) {
const recruitedNpcIds = getRecruitedNpcIds(state);
@@ -142,6 +170,54 @@ function pickEncounterHostileNpcs(hostileNpcs: Array<SceneNpc & { monsterPresetI
return hostileNpcs.filter(npc => selectedMonsterIds.has(npc.monsterPresetId));
}
function pickFriendlySceneNpcForActiveAct(state: GameState, npcs: SceneNpc[]) {
const focusNpcId = resolveActiveSceneActEncounterFocusNpcId({
profile: state.customWorldProfile,
sceneId: state.currentScenePreset?.id ?? null,
storyEngineMemory: state.storyEngineMemory,
});
return (
npcs.find(
(npc) =>
npc.id === focusNpcId ||
(npc.characterId ? npc.characterId === focusNpcId : false),
) ?? pickRandomItem(npcs)
);
}
function hasActiveSceneActEncounterTarget(state: GameState) {
return resolveActiveSceneActEncounterNpcIds({
profile: state.customWorldProfile,
sceneId: state.currentScenePreset?.id ?? null,
storyEngineMemory: state.storyEngineMemory,
}).length > 0;
}
function buildEmptyEncounterPreview() {
return {
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
};
}
function buildActiveSceneActNpcEncounter(
state: GameState,
availableNpcs: SceneNpc[],
xMeters: number,
) {
const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs);
return {
sceneHostileNpcs: [],
currentEncounter: npc ? buildFriendlyEncounter(npc, xMeters) : null,
npcInteractionActive: false,
inBattle: false,
};
}
function buildHostileEncounterGroup(
state: GameState,
entryX: number,
@@ -218,12 +294,15 @@ function buildResolvedHostileBattleState(state: GameState, hostileEncounters: En
export function createSceneEncounterPreview(state: GameState) {
if (!state.worldType || !state.currentScenePreset) {
return {
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
};
return buildEmptyEncounterPreview();
}
if (hasActiveSceneActEncounterTarget(state)) {
return buildActiveSceneActNpcEncounter(
state,
getAvailableActiveSceneActNpcs(state),
PREVIEW_ENTITY_X_METERS,
);
}
const availableNpcs = getAvailableFriendlySceneNpcs(state);
@@ -237,12 +316,7 @@ export function createSceneEncounterPreview(state: GameState) {
const kind = pickRandomItem(availableKinds);
if (!kind) {
return {
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
};
return buildEmptyEncounterPreview();
}
if (kind === 'hostile') {
@@ -255,7 +329,7 @@ export function createSceneEncounterPreview(state: GameState) {
}
if (kind === 'npc') {
const npc = pickRandomItem(availableNpcs);
const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs);
return {
sceneHostileNpcs: [],
@@ -276,19 +350,22 @@ export function createSceneEncounterPreview(state: GameState) {
export function createSceneCallOutEncounter(state: GameState) {
if (!state.worldType || !state.currentScenePreset) {
return {
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
};
return buildEmptyEncounterPreview();
}
if (hasActiveSceneActEncounterTarget(state)) {
return buildActiveSceneActNpcEncounter(
state,
getAvailableActiveSceneActNpcs(state),
CALL_OUT_ENTRY_X_METERS,
);
}
const availableNpcs = getAvailableFriendlySceneNpcs(state);
const availableKinds: Array<'hostile' | 'npc' | 'treasure'> = [];
const availableHostiles = getAvailableHostileSceneNpcs(state);
if (availableHostiles.length > 0) availableKinds.push('hostile');
const availableNpcs = getAvailableFriendlySceneNpcs(state);
if (availableNpcs.length > 0) availableKinds.push('npc');
if (TREASURE_ENCOUNTERS_ENABLED && (state.currentScenePreset.treasureHints?.length ?? 0) > 0) {
availableKinds.push('treasure');
@@ -305,7 +382,7 @@ export function createSceneCallOutEncounter(state: GameState) {
}
if (kind === 'npc') {
const npc = pickRandomItem(availableNpcs);
const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs);
return {
sceneHostileNpcs: [],
currentEncounter: npc ? buildFriendlyEncounter(npc, CALL_OUT_ENTRY_X_METERS) : null,
+83 -4
View File
@@ -381,6 +381,40 @@ function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
return kind === 'camp' ? 'custom-scene-camp' : `custom-scene-landmark-${index + 1}`;
}
function collectSceneActNpcIdsForScene(
profile: CustomWorldProfile,
sceneAliases: string[],
) {
const aliasSet = new Set(sceneAliases.map((alias) => alias.trim()).filter(Boolean));
const npcIds: string[] = [];
const pushNpcId = (npcId: string | null | undefined) => {
const normalizedNpcId = npcId?.trim() ?? '';
if (normalizedNpcId && !npcIds.includes(normalizedNpcId)) {
npcIds.push(normalizedNpcId);
}
};
profile.sceneChapterBlueprints?.forEach((chapter) => {
const chapterSceneIds = [
chapter.sceneId,
...chapter.linkedLandmarkIds,
...chapter.acts.map((act) => act.sceneId),
].map((sceneId) => sceneId.trim()).filter(Boolean);
if (!chapterSceneIds.some((sceneId) => aliasSet.has(sceneId))) {
return;
}
chapter.acts.forEach((act) => {
pushNpcId(act.primaryNpcId);
pushNpcId(act.oppositeNpcId);
act.encounterNpcIds.forEach(pushNpcId);
});
});
return npcIds;
}
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const campSceneProfile = resolveCustomWorldCampScene(profile);
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
@@ -403,6 +437,37 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const customStoryNpcById = new Map(
profile.storyNpcs.map((npc) => [npc.id, npc]),
);
const buildCustomSceneNpcByRoleId = (roleId: string) => {
const storyNpc = customStoryNpcById.get(roleId);
if (storyNpc) {
return buildCustomSceneNpc(storyNpc, profile);
}
const playableNpc = profile.playableNpcs.find((npc) => npc.id === roleId);
if (!playableNpc) {
return null;
}
return buildCharacterNpc(playableNpc.id, WorldType.CUSTOM, profile);
};
const pushUniqueSceneNpc = (sceneNpcs: SceneNpc[], npc: SceneNpc | null) => {
if (!npc) {
return;
}
const candidateIds = [npc.id, npc.characterId].filter(Boolean);
if (
sceneNpcs.some((sceneNpc) =>
[sceneNpc.id, sceneNpc.characterId]
.filter(Boolean)
.some((sceneNpcId) => candidateIds.includes(sceneNpcId)),
)
) {
return;
}
sceneNpcs.push(npc);
};
const campNpcs = playableCharacters.slice(1).map(character => {
const npc = buildCharacterNpc(character.id, WorldType.CUSTOM, profile);
return npc
@@ -413,6 +478,12 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
}
: null;
}).filter(Boolean) as SceneNpc[];
collectSceneActNpcIdsForScene(profile, [
campSceneId,
profile.camp?.id ?? '',
]).forEach((npcId) =>
pushUniqueSceneNpc(campNpcs, buildCustomSceneNpcByRoleId(npcId)),
);
const campConnections = profile.landmarks
.slice(0, 3)
@@ -445,12 +516,20 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
npcs: campNpcs,
},
...profile.landmarks.map((landmark, index): ScenePreset => {
const sceneNpcs = landmark.sceneNpcIds
const runtimeSceneId = buildCustomSceneId('landmark', index);
const sceneActNpcIds = collectSceneActNpcIdsForScene(profile, [
landmark.id,
runtimeSceneId,
]);
const sceneNpcs = [...sceneActNpcIds, ...landmark.sceneNpcIds]
.map((npcId) => customStoryNpcById.get(npcId))
.filter(Boolean)
.map((npc) =>
buildCustomSceneNpc(npc!, profile),
);
sceneActNpcIds.forEach((npcId) =>
pushUniqueSceneNpc(sceneNpcs, buildCustomSceneNpcByRoleId(npcId)),
);
if (sceneNpcs.length < 3) {
profile.storyNpcs
.filter(
@@ -499,12 +578,12 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
const seedMonsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
const hostileNpcs = seedMonsterIds
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), WorldType.CUSTOM, monsterId))
.map((monsterId: string) => buildHostileSceneNpc(runtimeSceneId, WorldType.CUSTOM, monsterId))
.filter(Boolean) as SceneNpc[];
const combinedNpcs = [...sceneNpcs, ...hostileNpcs];
return {
id: buildCustomSceneId('landmark', index),
id: runtimeSceneId,
name: landmark.name,
description: landmark.description,
worldType: WorldType.CUSTOM,
@@ -521,7 +600,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
? landmark.narrativeResidues
: buildSceneNarrativeResidues({
sceneId: buildCustomSceneId('landmark', index),
sceneId: runtimeSceneId,
sceneName: landmark.name,
profile,
}),
@@ -272,6 +272,35 @@ describe('storyChoiceRuntime', () => {
);
});
it('keeps defeated hostile reward render keys unique for duplicate monster ids', async () => {
rollHostileNpcLootMock.mockResolvedValue([]);
const reward = await buildHostileNpcBattleReward(
createState({
inBattle: true,
sceneHostileNpcs: [
{ id: 'monster-16', name: '雷翼甲' },
{ id: 'monster-16', name: '雷翼乙', xMeters: 4.1, yOffset: 32 },
] as GameState['sceneHostileNpcs'],
currentBattleNpcId: null,
}),
createState({
inBattle: false,
sceneHostileNpcs: [],
}),
'battle',
(state) => state.sceneHostileNpcs,
);
expect(reward?.defeatedHostileNpcs).toHaveLength(2);
expect(reward?.defeatedHostileNpcs.map((npc) => npc.id)).toEqual([
'monster-16',
'monster-16',
]);
expect(new Set(reward?.defeatedHostileNpcs.map((npc) => npc.renderKey)).size)
.toBe(2);
});
it('applies server runtime responses and falls back locally when the request fails', async () => {
const gameState = createState();
const currentStory = createStory('当前故事');
@@ -165,9 +165,17 @@ export async function buildHostileNpcBattleReward(
id: `battle-reward-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`,
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc) => ({
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc, index) => ({
id: hostileNpc.id,
name: hostileNpc.name,
// 中文注释:同一场战斗可能击败多个同 preset 怪物,奖励弹层 key 不能只用怪物 id。
renderKey: [
hostileNpc.id,
hostileNpc.name,
hostileNpc.xMeters,
hostileNpc.yOffset ?? 0,
index,
].join(':'),
})),
items: addInventoryItems([], rolledItems),
};
+1 -1
View File
@@ -95,7 +95,7 @@ export interface GoalFlowUi {
export interface BattleRewardSummary {
id: string;
defeatedHostileNpcs: Array<{ id: string; name: string }>;
defeatedHostileNpcs: Array<{ id: string; name: string; renderKey?: string }>;
items: InventoryItem[];
}
@@ -0,0 +1,213 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type { Character, Encounter, GameState, StoryMoment } from '../../types';
import { AnimationState, WorldType } from '../../types';
import { useRpgRuntimeStoryController } from './useRpgRuntimeStoryController';
const aiServiceMocks = vi.hoisted(() => ({
generateInitialStory: vi.fn(),
generateNextStep: vi.fn(),
}));
vi.mock('../../services/aiService', async () => {
const actual =
await vi.importActual<typeof import('../../services/aiService')>(
'../../services/aiService',
);
return {
...actual,
generateInitialStory: aiServiceMocks.generateInitialStory,
generateNextStep: aiServiceMocks.generateNextStep,
};
});
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '在风声里辨认危险的旅人。',
personality: '谨慎而果断',
skills: [],
} as unknown as Character;
}
function createGameState(params: {
currentEncounter?: Encounter | null;
} = {}): GameState {
return {
worldType: WorldType.CUSTOM,
customWorldProfile: null,
playerCharacter: createCharacter(),
currentScene: 'Story',
storyHistory: [],
animationState: AnimationState.IDLE,
currentEncounter: params.currentEncounter ?? null,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-opening',
name: '证券交易所大厅',
description: '高耸大厅里仍残留着开盘前的低声交谈。',
imageSrc: '',
npcs: [],
hostileNpcIds: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
playerCurrency: 0,
npcStates: {},
quests: [],
roster: [],
companions: [],
} as unknown as GameState;
}
function buildStoryContextFromState(
_state: GameState,
): StoryGenerationContext {
return {
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: 'idle',
skillCooldowns: {},
sceneId: 'scene-opening',
sceneName: '证券交易所大厅',
sceneDescription: '高耸大厅里仍残留着开盘前的低声交谈。',
pendingSceneEncounter: false,
} as StoryGenerationContext;
}
function Harness() {
const [gameState, setGameState] = useState<GameState>(() =>
createGameState(),
);
const controller = useRpgRuntimeStoryController({
gameState,
setGameState,
buildStoryContextFromState,
});
return (
<div>
<div data-testid="loading">{controller.isLoading ? 'yes' : 'no'}</div>
<div data-testid="story">{controller.currentStory?.text ?? ''}</div>
<button
type="button"
onClick={() =>
setGameState((current) => ({
...current,
currentScene: 'Selection',
playerCharacter: null,
}))
}
>
返回选择
</button>
</div>
);
}
function HarnessWithEncounter() {
const encounter = {
id: 'npc-luheng',
kind: 'npc',
npcName: '陆衡',
npcDescription: '正在核对异常账本的人。',
npcAvatar: '',
context: '第一幕主NPC',
} satisfies Encounter;
const [gameState, setGameState] = useState<GameState>(() =>
createGameState({
currentEncounter: encounter,
}),
);
const controller = useRpgRuntimeStoryController({
gameState,
setGameState,
buildStoryContextFromState,
});
return (
<div>
<div data-testid="loading">{controller.isLoading ? 'yes' : 'no'}</div>
<div data-testid="story">{controller.currentStory?.text ?? ''}</div>
</div>
);
}
describe('useRpgRuntimeStoryController', () => {
beforeEach(() => {
vi.clearAllMocks();
aiServiceMocks.generateInitialStory.mockResolvedValue({
storyText: '大厅里的报价屏忽明忽暗,一条异常交易记录浮了上来。',
options: [],
} satisfies { storyText: string; options: StoryMoment['options'] });
aiServiceMocks.generateNextStep.mockResolvedValue({
storyText: '下一步剧情',
options: [],
});
});
it('进入 Story 场景且首段剧情为空时自动请求开局剧情', async () => {
render(<Harness />);
await waitFor(() => {
expect(aiServiceMocks.generateInitialStory).toHaveBeenCalledTimes(1);
});
expect(aiServiceMocks.generateInitialStory).toHaveBeenCalledWith(
WorldType.CUSTOM,
expect.objectContaining({ id: 'hero' }),
[],
expect.objectContaining({
sceneId: 'scene-opening',
sceneName: '证券交易所大厅',
}),
undefined,
);
await waitFor(() => {
expect(screen.getByTestId('story').textContent).toContain(
'异常交易记录',
);
});
expect(screen.getByTestId('loading').textContent).toBe('no');
});
it('已有当前幕 NPC 遭遇时不抢先请求普通开局剧情', async () => {
render(<HarnessWithEncounter />);
await new Promise((resolve) => window.setTimeout(resolve, 20));
expect(aiServiceMocks.generateInitialStory).not.toHaveBeenCalled();
expect(screen.getByTestId('story').textContent).toBe('');
expect(screen.getByTestId('loading').textContent).toBe('no');
});
});
@@ -1,4 +1,12 @@
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { generateInitialStory, generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
@@ -7,6 +15,7 @@ import {
appendStoryHistory,
createStoryProgressionActions,
} from './progressionActions';
import type { StoryContextBuilderExtras } from './storyContextBuilder';
import {
createStoryStateResolvers,
getStoryGenerationHostileNpcs,
@@ -16,9 +25,8 @@ import {
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
getTypewriterDelay,
} from './storyPresentation';
import { buildNpcStory } from './storyRuntimeSupport';
import { createGenerateStoryForState } from './storyRequestRuntime';
import type { StoryContextBuilderExtras } from './storyContextBuilder';
import { buildNpcStory } from './storyRuntimeSupport';
type BuildStoryContextFromState = (
state: GameState,
@@ -39,6 +47,7 @@ export function useRpgRuntimeStoryController(params: {
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
const [aiError, setAiError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const openingStoryRequestKeyRef = useRef<string | null>(null);
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
() =>
@@ -104,6 +113,80 @@ export function useRpgRuntimeStoryController(params: {
buildFallbackStoryForState,
});
useEffect(() => {
const playerCharacter = gameState.playerCharacter;
if (
!playerCharacter ||
!gameState.worldType ||
gameState.currentScene !== 'Story' ||
gameState.currentEncounter
) {
openingStoryRequestKeyRef.current = null;
return;
}
if (currentStory) {
return;
}
const requestKey = [
gameState.runtimeSessionId ?? 'local',
playerCharacter.id,
gameState.currentScenePreset?.id ?? 'scene',
gameState.storyHistory.length,
].join(':');
if (openingStoryRequestKeyRef.current === requestKey) {
return;
}
openingStoryRequestKeyRef.current = requestKey;
let cancelled = false;
// 首段剧情属于运行态启动数据;这里补齐后,冒险面板才会按真实 story 挂载。
setAiError(null);
setIsLoading(true);
void generateStoryForState({
state: gameState,
character: playerCharacter,
history: gameState.storyHistory,
})
.then((openingStory) => {
if (!cancelled) {
setCurrentStory(openingStory);
}
})
.catch((error) => {
if (cancelled) {
return;
}
console.error('Failed to start opening RPG story:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
})
.finally(() => {
if (!cancelled) {
if (openingStoryRequestKeyRef.current === requestKey) {
openingStoryRequestKeyRef.current = null;
}
setIsLoading(false);
}
});
return () => {
cancelled = true;
if (openingStoryRequestKeyRef.current === requestKey) {
openingStoryRequestKeyRef.current = null;
}
};
}, [
buildFallbackStoryForState,
currentStory,
gameState,
generateStoryForState,
]);
return {
currentStory,
setCurrentStory,
+247 -10
View File
@@ -24,7 +24,13 @@ import {
ensureSceneEncounterPreview,
RESOLVED_ENTITY_X_METERS,
} from '../../data/sceneEncounterPreviews';
import { getScenePreset, getWorldCampScenePreset } from '../../data/scenePresets';
import {
buildEncounterFromSceneNpc,
getScenePreset,
getScenePresetById,
getWorldCampScenePreset,
} from '../../data/scenePresets';
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
@@ -34,6 +40,8 @@ import {
EquipmentLoadout,
GameState,
InventoryItem,
SceneActBlueprint,
SceneChapterBlueprint,
SceneNpc,
WorldType,
} from '../../types';
@@ -208,6 +216,220 @@ function createInitialGameState(): GameState {
};
}
function resolveOpeningActScenePreset(
profile: CustomWorldProfile | null,
): NonNullable<GameState['currentScenePreset']> | null {
if (!profile) {
return null;
}
const openingChapter = profile.sceneChapterBlueprints?.[0] ?? null;
const openingSceneIds = [
openingChapter?.acts[0]?.sceneId,
openingChapter?.sceneId,
...(openingChapter?.linkedLandmarkIds ?? []),
]
.map((sceneId) => sceneId?.trim() ?? '')
.filter(Boolean);
for (const sceneId of openingSceneIds) {
const directScene = resolveCustomWorldScenePresetByConfiguredId(
profile,
sceneId,
);
if (directScene) {
return directScene;
}
}
const fallbackLandmarkIndex = profile.landmarks.findIndex(
(landmark) => landmark.sceneNpcIds.length > 0,
);
if (fallbackLandmarkIndex >= 0) {
return getScenePresetById(
WorldType.CUSTOM,
`custom-scene-landmark-${fallbackLandmarkIndex + 1}`,
);
}
const firstLandmarkId = profile.landmarks[0]?.id?.trim() ?? '';
if (firstLandmarkId) {
const firstLandmarkScene = getScenePresetById(
WorldType.CUSTOM,
'custom-scene-landmark-1',
);
if (firstLandmarkScene) {
return firstLandmarkScene;
}
}
return profile.landmarks.length > 0
? getScenePresetById(WorldType.CUSTOM, 'custom-scene-landmark-1')
: null;
}
function resolveOpeningSceneActBlueprint(
profile: CustomWorldProfile | null,
): { chapter: SceneChapterBlueprint; act: SceneActBlueprint } | null {
const openingChapter = profile?.sceneChapterBlueprints?.[0] ?? null;
const openingAct = openingChapter?.acts[0] ?? null;
return openingChapter && openingAct
? { chapter: openingChapter, act: openingAct }
: null;
}
function resolveCustomWorldScenePresetByConfiguredId(
profile: CustomWorldProfile,
sceneId: string | null | undefined,
): NonNullable<GameState['currentScenePreset']> | null {
const normalizedSceneId = sceneId?.trim() ?? '';
if (!normalizedSceneId) {
return null;
}
const directScene = getScenePresetById(WorldType.CUSTOM, normalizedSceneId);
if (directScene) {
return directScene;
}
const campId = profile.camp?.id?.trim() ?? '';
if (
normalizedSceneId === campId ||
normalizedSceneId === 'custom-scene-camp'
) {
return getScenePresetById(WorldType.CUSTOM, 'custom-scene-camp');
}
const landmarkIndex = profile.landmarks.findIndex(
(landmark) => landmark.id === normalizedSceneId,
);
if (landmarkIndex < 0) {
return null;
}
return getScenePresetById(
WorldType.CUSTOM,
`custom-scene-landmark-${landmarkIndex + 1}`,
);
}
function resolveOpeningActNpcIdPriority(openingAct: SceneActBlueprint) {
return [
openingAct.oppositeNpcId,
openingAct.primaryNpcId,
...openingAct.encounterNpcIds,
]
.map((npcId) => npcId.trim())
.filter((npcId, index, list) => npcId && list.indexOf(npcId) === index);
}
function findSceneNpcByRuntimeRoleId(
scenePreset: GameState['currentScenePreset'],
roleId: string,
) {
return (
scenePreset?.npcs?.find(
(npc) => npc.id === roleId || npc.characterId === roleId,
) ?? null
);
}
function buildOpeningEncounterFromCustomWorldRole(
profile: CustomWorldProfile,
roleId: string,
): Encounter | null {
const role =
profile.storyNpcs.find((npc) => npc.id === roleId) ??
profile.playableNpcs.find((npc) => npc.id === roleId) ??
null;
if (!role) {
return null;
}
const isHostile = role.initialAffinity < 0;
return {
id: role.id,
kind: 'npc',
characterId: role.id,
npcName: role.name,
npcDescription: role.description,
npcAvatar: role.imageSrc ?? role.name.slice(0, 1) ?? '?',
context: role.role,
xMeters: RESOLVED_ENTITY_X_METERS,
initialAffinity: role.initialAffinity,
hostile: isHostile,
title: role.title,
backstory: role.backstory,
personality: role.personality,
motivation: role.motivation,
combatStyle: role.combatStyle,
relationshipHooks: [...role.relationshipHooks],
tags: [...role.tags],
backstoryReveal: role.backstoryReveal,
skills: role.skills.map((skill) => ({ ...skill })),
initialItems: role.initialItems.map((item) => ({
...item,
tags: [...item.tags],
})),
imageSrc: role.imageSrc,
visual: (role as { visual?: Encounter['visual'] }).visual,
narrativeProfile: role.narrativeProfile,
attributeProfile: role.attributeProfile,
};
}
function resolveOpeningActEncounter(params: {
profile: CustomWorldProfile | null;
scenePreset: GameState['currentScenePreset'];
playerCharacter: Character;
}) {
const opening = resolveOpeningSceneActBlueprint(params.profile);
if (!opening || !params.profile) {
return null;
}
for (const npcId of resolveOpeningActNpcIdPriority(opening.act)) {
if (npcId === params.playerCharacter.id) {
continue;
}
const sceneNpc = findSceneNpcByRuntimeRoleId(params.scenePreset, npcId);
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
return {
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
xMeters: RESOLVED_ENTITY_X_METERS,
};
}
const roleEncounter = buildOpeningEncounterFromCustomWorldRole(
params.profile,
npcId,
);
if (roleEncounter) {
return roleEncounter;
}
}
return null;
}
function buildOpeningStoryEngineMemory(
profile: CustomWorldProfile | null,
sceneId: string | null | undefined,
) {
const storyEngineMemory = createEmptyStoryEngineMemoryState();
return {
...storyEngineMemory,
currentSceneActState:
buildInitialSceneActRuntimeState({
profile,
sceneId,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
};
}
/**
* RPG session bootstrap 主实现。
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
@@ -287,14 +509,23 @@ export function useRpgSessionBootstrap() {
setGameState((prev) => {
const resolvedWorldType = prev.worldType;
const resolvedCustomWorldProfile = prev.customWorldProfile;
const initialScenePreset = resolvedWorldType
? (getWorldCampScenePreset(resolvedWorldType) ??
getScenePreset(resolvedWorldType, 0))
: null;
const initialEncounter = createInitialCampEncounter(
resolvedWorldType,
character,
);
const initialScenePreset =
resolvedWorldType === WorldType.CUSTOM
? (resolveOpeningActScenePreset(resolvedCustomWorldProfile) ??
getWorldCampScenePreset(resolvedWorldType) ??
getScenePreset(resolvedWorldType, 0))
: resolvedWorldType
? (getWorldCampScenePreset(resolvedWorldType) ??
getScenePreset(resolvedWorldType, 0))
: null;
const initialEncounter =
resolvedWorldType === WorldType.CUSTOM
? resolveOpeningActEncounter({
profile: resolvedCustomWorldProfile,
scenePreset: initialScenePreset,
playerCharacter: character,
})
: createInitialCampEncounter(resolvedWorldType, character);
const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
: null;
@@ -330,7 +561,13 @@ export function useRpgSessionBootstrap() {
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
storyEngineMemory:
resolvedWorldType === WorldType.CUSTOM
? buildOpeningStoryEngineMemory(
resolvedCustomWorldProfile,
initialScenePreset?.id,
)
: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId:
@@ -11,6 +11,9 @@ const AUTO_SAVE_DELAY_MS = 400;
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
return (
gameState.runtimePersistenceDisabled !== true &&
gameState.runtimeMode !== 'preview' &&
gameState.runtimeMode !== 'test' &&
gameState.currentScene === 'Story' &&
Boolean(gameState.worldType) &&
Boolean(gameState.playerCharacter) &&
+172 -4
View File
@@ -3,17 +3,34 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useMemo } from 'react';
import { afterEach, expect, test } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
buildCustomWorldPlayableCharacters,
setRuntimeCharacterOverrides,
} from '../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
import { WorldType } from '../types';
import { useRpgRuntimeStory } from './rpg-runtime-story/useRpgRuntimeStory';
import { useRpgSessionBootstrap } from './rpg-session';
const aiServiceMocks = vi.hoisted(() => ({
streamNpcChatTurn: vi.fn(),
}));
vi.mock('../services/aiService', async () => {
const actual =
await vi.importActual<typeof import('../services/aiService')>(
'../services/aiService',
);
return {
...actual,
streamNpcChatTurn: aiServiceMocks.streamNpcChatTurn,
};
});
function buildBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
@@ -208,6 +225,40 @@ function buildSavedProfile() {
reactionHooks: ['原始灯册', '封灯令'],
},
},
{
id: 'story-primary-only',
name: '沈砺旧识',
title: '旧潮案记录员',
role: '第一幕主线记录者',
description: '负责整理旧潮案脉络的人。',
backstory: '他知道异常账本的来源,但不会第一时间正面对话。',
personality: '沉默、谨慎。',
motivation: '保住旧案原始记录。',
combatStyle: '以防守和牵制为主。',
initialAffinity: 8,
relationshipHooks: ['旧案记录'],
tags: ['记录', '主线'],
backstoryReveal: buildBackstoryReveal('沈砺旧识'),
skills: [],
initialItems: [],
},
{
id: 'story-act-only',
name: '陆衡',
title: '航运公会审计员',
role: '第一幕主NPC',
description: '正在交易所大厅核对异常账本的人。',
backstory: '他掌握着旧航路资金流向的第一份实证。',
personality: '克制、警惕,习惯先观察再开口。',
motivation: '确认谁在开盘前转移了旧案资金。',
combatStyle: '用短杖和账册压制对手节奏。',
initialAffinity: 6,
relationshipHooks: ['异常账本'],
tags: ['审计', '第一幕'],
backstoryReveal: buildBackstoryReveal('陆衡'),
skills: [],
initialItems: [],
},
],
items: [],
camp: {
@@ -219,7 +270,7 @@ function buildSavedProfile() {
id: 'landmark-1',
name: '回潮旧灯塔',
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
sceneNpcIds: ['story-1'],
sceneNpcIds: [],
connections: [
{
targetLandmarkId: 'landmark-2',
@@ -251,6 +302,60 @@ function buildSavedProfile() {
],
},
],
sceneChapterBlueprints: [
{
id: 'chapter-1',
sceneId: 'custom-scene-camp',
title: '交易所第一幕',
summary: '玩家在交易大厅被异常账本牵住。',
sceneTaskDescription: '查清异常账本指向谁。',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-1',
sceneId: 'custom-scene-camp',
title: '第一幕',
summary: '陆衡先开口试探玩家。',
stageCoverage: ['opening'],
encounterNpcIds: ['story-primary-only', 'story-act-only'],
primaryNpcId: 'story-primary-only',
oppositeNpcId: 'story-act-only',
eventDescription: '陆衡拿着异常账本,在开盘前拦住玩家。',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '确认异常账本的第一条线索。',
transitionHook: '账本指向旧灯塔的潮痕。',
},
],
},
{
id: 'chapter-late',
sceneId: 'landmark-2',
title: '雾栈后续幕',
summary: '后续场景不应抢走开局。',
sceneTaskDescription: '处理雾栈尽头的后续问题。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-2'],
acts: [
{
id: 'act-late',
sceneId: 'landmark-2',
title: '后续幕',
summary: '雾栈里有人影闪过。',
stageCoverage: ['aftermath'],
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
oppositeNpcId: 'story-1',
eventDescription: '后续角色在雾栈尽头等待。',
linkedThreadIds: [],
advanceRule: 'after_active_step_complete',
actGoal: '后续推进。',
transitionHook: '继续深入雾栈。',
},
],
},
],
scenarioPackId: 'scenario-pack:tide',
campaignPackId: 'campaign-pack:tide',
generationMode: 'full',
@@ -275,6 +380,13 @@ function readSnapshot() {
currentScenePresetId: string | null;
currentScenePresetName: string | null;
currentSceneConnectedIds: string[];
currentSceneActId: string | null;
currentEncounterId: string | null;
currentEncounterName: string | null;
currentStoryDisplayMode: string | null;
currentStoryNpcName: string | null;
currentStoryDialogueTexts: string[];
isStoryLoading: boolean;
firstLandmarkResidueTitle: string | null;
playerCharacterName: string | null;
playerInventoryNames: string[];
@@ -293,8 +405,19 @@ function GameFlowHarness() {
[profile],
);
const selectedCharacter = playableCharacters[0] ?? null;
const { gameState, handleCustomWorldSelect, handleCharacterSelect } =
const {
gameState,
setGameState,
handleCustomWorldSelect,
handleCharacterSelect,
} =
useRpgSessionBootstrap();
const story = useRpgRuntimeStory({
gameState,
setGameState,
buildResolvedChoiceState: () => ({}) as never,
playResolvedChoice: async (state) => state,
});
const snapshot = {
worldType: gameState.worldType,
@@ -305,6 +428,15 @@ function GameFlowHarness() {
currentScenePresetId: gameState.currentScenePreset?.id ?? null,
currentScenePresetName: gameState.currentScenePreset?.name ?? null,
currentSceneConnectedIds: gameState.currentScenePreset?.connectedSceneIds ?? [],
currentSceneActId:
gameState.storyEngineMemory?.currentSceneActState?.currentActId ?? null,
currentEncounterId: gameState.currentEncounter?.id ?? null,
currentEncounterName: gameState.currentEncounter?.npcName ?? null,
currentStoryDisplayMode: story.currentStory?.displayMode ?? null,
currentStoryNpcName: story.currentStory?.npcChatState?.npcName ?? null,
currentStoryDialogueTexts:
story.currentStory?.dialogue?.map((entry) => entry.text) ?? [],
isStoryLoading: story.isLoading,
firstLandmarkResidueTitle:
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
?.title ?? null,
@@ -345,6 +477,16 @@ afterEach(() => {
setRuntimeCharacterOverrides(null);
});
beforeEach(() => {
aiServiceMocks.streamNpcChatTurn.mockReset();
aiServiceMocks.streamNpcChatTurn.mockResolvedValue({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '开盘前别靠近账本。你先告诉我,是谁让你来查这笔异常资金?',
suggestions: ['我先说明来意', '你先说账本哪里异常', '我不是来抢账本的'],
});
});
test('saved custom world result settings flow into game state after entering the world', async () => {
const user = userEvent.setup();
@@ -378,4 +520,30 @@ test('saved custom world result settings flow into game state after entering the
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
expect(readSnapshot().playerEquipment.relic).toBe('旧潮图残页');
expect(readSnapshot().playerEquipment.armor).toBeTruthy();
expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp');
expect(readSnapshot().currentSceneActId).toBe('act-1');
expect(readSnapshot().currentEncounterId).toBe('story-act-only');
expect(readSnapshot().currentEncounterName).toBe('陆衡');
expect(readSnapshot().currentEncounterId).not.toBe('story-primary-only');
await waitFor(() => {
expect(readSnapshot().currentStoryNpcName).toBe('陆衡');
});
expect(readSnapshot().currentStoryDisplayMode).toBe('dialogue');
expect(readSnapshot().currentStoryDialogueTexts).toContain(
'开盘前别靠近账本。你先告诉我,是谁让你来查这笔异常资金?',
);
expect(aiServiceMocks.streamNpcChatTurn).toHaveBeenCalledWith(
WorldType.CUSTOM,
expect.objectContaining({ name: '沈砺' }),
expect.objectContaining({ id: 'story-act-only', npcName: '陆衡' }),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'',
expect.anything(),
expect.objectContaining({
npcInitiatesConversation: true,
}),
);
});
+57 -1
View File
@@ -66,6 +66,63 @@ describe('useResolvedAssetReadUrl', () => {
);
});
test('refreshKey changes force a refreshed signed image url', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey:
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
signedUrl: 'https://signed.example.com/puzzle.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
);
const { rerender } = render(
<ResolvedAssetImage
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
refreshKey="first-version"
alt="候选图"
/>,
);
const firstImage = await screen.findByRole('img', { name: '候选图' });
expect(firstImage.getAttribute('src')).toContain('_v=first-version');
rerender(
<ResolvedAssetImage
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
refreshKey="second-version"
alt="候选图"
/>,
);
await waitFor(() => {
expect(screen.getByRole('img', { name: '候选图' }).getAttribute('src')).toContain(
'_v=second-version',
);
});
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
@@ -105,4 +162,3 @@ describe('useResolvedAssetReadUrl', () => {
expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
});
});

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