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}