1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user