1
This commit is contained in:
+21
-3
@@ -2,7 +2,10 @@ import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useAuthUi } from './components/auth/AuthUiContext';
|
||||
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
|
||||
import type { SelectionStage } from './components/platform-entry/platformEntryTypes';
|
||||
import type {
|
||||
CustomWorldRuntimeLaunchOptions,
|
||||
SelectionStage,
|
||||
} from './components/platform-entry/platformEntryTypes';
|
||||
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
APP_RUNTIME_ROUTES,
|
||||
@@ -40,6 +43,8 @@ export default function App() {
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
const [runtimeReturnStage, setRuntimeReturnStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
|
||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||
setRawSelectionStage(stage);
|
||||
@@ -86,10 +91,17 @@ export default function App() {
|
||||
);
|
||||
|
||||
const handleCustomWorldSelect = useCallback(
|
||||
(customWorldProfile: CustomWorldProfile) => {
|
||||
(
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => {
|
||||
// 中文注释:作品测试需要在结束测试后精确返回启动它的结果页;
|
||||
// 正式进入世界仍保持既有平台首页返回语义。
|
||||
setRuntimeReturnStage(options?.returnStage ?? 'platform');
|
||||
createRuntimeIntent({
|
||||
kind: 'custom-world',
|
||||
profile: customWorldProfile,
|
||||
mode: options?.mode ?? 'play',
|
||||
});
|
||||
},
|
||||
[createRuntimeIntent],
|
||||
@@ -102,7 +114,13 @@ export default function App() {
|
||||
if (isRuntimeActive) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<RpgRuntimeApp initialIntent={runtimeIntent} />
|
||||
<RpgRuntimeApp
|
||||
initialIntent={runtimeIntent}
|
||||
onExitRuntime={() => {
|
||||
setIsRuntimeActive(false);
|
||||
setSelectionStage(runtimeReturnStage);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ export default function PuzzlePlaygroundApp() {
|
||||
return (
|
||||
<PuzzleRuntimeShell
|
||||
run={run}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={handleRestart}
|
||||
onSwapPieces={handleSwapPieces}
|
||||
onDragPiece={handleDragPiece}
|
||||
|
||||
@@ -10,6 +10,7 @@ export type RpgRuntimeAppIntent =
|
||||
token: number;
|
||||
kind: 'custom-world';
|
||||
profile: CustomWorldProfile;
|
||||
mode?: 'play' | 'test';
|
||||
}
|
||||
| {
|
||||
token: number;
|
||||
@@ -19,8 +20,10 @@ export type RpgRuntimeAppIntent =
|
||||
|
||||
export function RpgRuntimeApp({
|
||||
initialIntent,
|
||||
onExitRuntime,
|
||||
}: {
|
||||
initialIntent: RpgRuntimeAppIntent | null;
|
||||
onExitRuntime?: () => void;
|
||||
}) {
|
||||
const gameShellProps = useRpgRuntimeSession();
|
||||
const handledIntentTokenRef = useRef<number | null>(null);
|
||||
@@ -32,14 +35,16 @@ export function RpgRuntimeApp({
|
||||
|
||||
handledIntentTokenRef.current = initialIntent.token;
|
||||
if (initialIntent.kind === 'custom-world') {
|
||||
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile);
|
||||
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile, {
|
||||
mode: initialIntent.mode ?? 'play',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
gameShellProps.entry.handleContinueGame(initialIntent.snapshot);
|
||||
}, [gameShellProps.entry, initialIntent]);
|
||||
|
||||
return <RpgRuntimeShell {...gameShellProps} />;
|
||||
return <RpgRuntimeShell {...gameShellProps} onExitTestRuntime={onExitRuntime} />;
|
||||
}
|
||||
|
||||
export default RpgRuntimeApp;
|
||||
|
||||
@@ -187,6 +187,55 @@ describe('GameCanvasEntityLayer', () => {
|
||||
expect(html).not.toContain('好感度变化 +3');
|
||||
});
|
||||
|
||||
it('keeps hostile combat hp bar visible during post-hit afterimage frames', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
sceneActAmbientEncounters={[]}
|
||||
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={[
|
||||
createHostileNpc({
|
||||
hp: 4,
|
||||
maxHp: 10,
|
||||
animation: 'die',
|
||||
}),
|
||||
]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '70%'}
|
||||
groundBottom="18%"
|
||||
stageLiftPx={68}
|
||||
encounter={null}
|
||||
sideAnchor="15%"
|
||||
cameraAnchorX={0}
|
||||
monsterAnchorMeters={3.2}
|
||||
playerX={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('from-rose-500 to-red-400');
|
||||
});
|
||||
|
||||
it('renders scene act back-row encounters alongside the primary encounter', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DialogueBubbleIcon,
|
||||
type GameCanvasEntitySelection,
|
||||
GENERIC_NPC_SCENE_SCALE,
|
||||
getBattleCompanionSlotOffset,
|
||||
getCompanionSlotOffset,
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
@@ -222,11 +223,23 @@ export function GameCanvasEntityLayer({
|
||||
const [combatFeedbackEvents, setCombatFeedbackEvents] = useState<CombatFeedbackEvent[]>([]);
|
||||
const previousCombatSamplesRef = useRef<Map<string, CombatFeedbackHealthSample> | null>(null);
|
||||
const combatFeedbackSequenceRef = useRef(0);
|
||||
const hasCombatAfterimage = useMemo(
|
||||
() =>
|
||||
combatFeedbackEvents.length > 0 ||
|
||||
sceneCombatants.some(
|
||||
(hostileNpc) =>
|
||||
hostileNpc.hp < hostileNpc.maxHp ||
|
||||
hostileNpc.animation === 'attack' ||
|
||||
hostileNpc.animation === 'die',
|
||||
),
|
||||
[combatFeedbackEvents.length, sceneCombatants],
|
||||
);
|
||||
const shouldRenderCombatPresentation = inBattle || hasCombatAfterimage;
|
||||
const shouldRenderPeacefulEncounter =
|
||||
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
|
||||
const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
|
||||
() => {
|
||||
if (!inBattle) return [];
|
||||
if (!shouldRenderCombatPresentation) return [];
|
||||
|
||||
return [
|
||||
{key: 'player', kind: 'player', hp: playerHp},
|
||||
@@ -242,7 +255,7 @@ export function GameCanvasEntityLayer({
|
||||
})),
|
||||
];
|
||||
},
|
||||
[companions, inBattle, playerHp, sceneCombatants],
|
||||
[companions, playerHp, sceneCombatants, shouldRenderCombatPresentation],
|
||||
);
|
||||
const combatFeedbackByTarget = useMemo(() => {
|
||||
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
|
||||
@@ -259,7 +272,7 @@ export function GameCanvasEntityLayer({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!inBattle) {
|
||||
if (!shouldRenderCombatPresentation) {
|
||||
previousCombatSamplesRef.current = null;
|
||||
setCombatFeedbackEvents([]);
|
||||
return;
|
||||
@@ -283,12 +296,14 @@ export function GameCanvasEntityLayer({
|
||||
previousCombatSamplesRef.current = new Map(
|
||||
combatHealthSamples.map(sample => [sample.key, sample]),
|
||||
);
|
||||
}, [combatHealthSamples, inBattle]);
|
||||
}, [combatHealthSamples, shouldRenderCombatPresentation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{companions.map(companion => {
|
||||
const slotOffset = getCompanionSlotOffset(companion.slot);
|
||||
const slotOffset = inBattle
|
||||
? getBattleCompanionSlotOffset(companion.slot)
|
||||
: getCompanionSlotOffset(companion.slot);
|
||||
const feedbackTargetKey = `companion:${companion.npcId}`;
|
||||
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
|
||||
const companionFacing = companion.facing ?? 'right';
|
||||
@@ -314,7 +329,7 @@ export function GameCanvasEntityLayer({
|
||||
style={{
|
||||
left: companionAnchorLeft,
|
||||
bottom: companionAnchorBottom,
|
||||
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
|
||||
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom) + (inBattle ? 1 : 0),
|
||||
transition: 'left 260ms linear, bottom 180ms ease',
|
||||
}}
|
||||
>
|
||||
@@ -336,7 +351,7 @@ export function GameCanvasEntityLayer({
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
||||
{inBattle && (
|
||||
{shouldRenderCombatPresentation && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||
@@ -385,7 +400,7 @@ export function GameCanvasEntityLayer({
|
||||
events={combatFeedbackByTarget.get('player') ?? []}
|
||||
onDone={removeCombatFeedbackEvent}
|
||||
/>
|
||||
{inBattle && (
|
||||
{shouldRenderCombatPresentation && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||
@@ -484,7 +499,7 @@ export function GameCanvasEntityLayer({
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
|
||||
{inBattle && (
|
||||
{shouldRenderCombatPresentation && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${npcCombatHpTop}px`}}
|
||||
|
||||
@@ -108,6 +108,12 @@ export function getCompanionSlotOffset(slot: CompanionRenderState['slot']) {
|
||||
: {left: -34, bottom: 10};
|
||||
}
|
||||
|
||||
export function getBattleCompanionSlotOffset(slot: CompanionRenderState['slot']) {
|
||||
return slot === 'upper'
|
||||
? {left: -118, bottom: 86}
|
||||
: {left: -92, bottom: 26};
|
||||
}
|
||||
|
||||
export function mapHostileNpcAnimationToCharacterState(animation: SceneHostileNpc['animation']) {
|
||||
if (animation === 'move') return AnimationState.RUN;
|
||||
if (animation === 'attack') return AnimationState.ATTACK;
|
||||
|
||||
@@ -28,7 +28,10 @@ import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type {
|
||||
PuzzleRunSnapshot,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
@@ -80,7 +83,10 @@ import {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
|
||||
import {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
submitPuzzleLeaderboard,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
dragLocalPuzzlePiece,
|
||||
startLocalPuzzleRun,
|
||||
@@ -428,6 +434,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
useState<PuzzleDetailReturnTarget | null>(null);
|
||||
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
|
||||
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
|
||||
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
|
||||
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
|
||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||
const [puzzleGenerationState, setPuzzleGenerationState] =
|
||||
@@ -1285,6 +1293,50 @@ export function PlatformEntryFlowShellImpl({
|
||||
[isPuzzleBusy, puzzleRun],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentLevel = puzzleRun?.currentLevel ?? null;
|
||||
if (!puzzleRun || !currentLevel || currentLevel.status !== 'cleared') {
|
||||
return;
|
||||
}
|
||||
if (currentLevel.elapsedMs === null) {
|
||||
return;
|
||||
}
|
||||
if ((currentLevel.leaderboardEntries ?? []).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submitKey = `${puzzleRun.runId}:${currentLevel.profileId}:${currentLevel.gridSize}:${currentLevel.elapsedMs}`;
|
||||
if (submittedPuzzleLeaderboardKeysRef.current.has(submitKey)) {
|
||||
return;
|
||||
}
|
||||
submittedPuzzleLeaderboardKeysRef.current.add(submitKey);
|
||||
setIsPuzzleLeaderboardBusy(true);
|
||||
|
||||
const payload: SubmitPuzzleLeaderboardRequest = {
|
||||
profileId: currentLevel.profileId,
|
||||
gridSize: currentLevel.gridSize,
|
||||
elapsedMs: currentLevel.elapsedMs,
|
||||
nickname: authUi?.user?.displayName?.trim() || '玩家',
|
||||
};
|
||||
|
||||
void submitPuzzleLeaderboard(puzzleRun.runId, payload)
|
||||
.then(({ run }) => {
|
||||
setPuzzleRun(run);
|
||||
})
|
||||
.catch((error) => {
|
||||
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPuzzleLeaderboardBusy(false);
|
||||
});
|
||||
}, [
|
||||
authUi?.user?.displayName,
|
||||
puzzleRun,
|
||||
resolvePuzzleErrorMessage,
|
||||
setPuzzleError,
|
||||
]);
|
||||
|
||||
const advancePuzzleLevel = useCallback(async () => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
return;
|
||||
@@ -2467,13 +2519,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
|
||||
>
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
}}
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={
|
||||
isPuzzleBusy ||
|
||||
isPuzzleNextLevelGenerating ||
|
||||
isPuzzleLeaderboardBusy
|
||||
}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
}}
|
||||
onSwapPieces={(payload) => {
|
||||
void swapPuzzlePiecesInRun(payload);
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,13 @@ import type {
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type CustomWorldRuntimeLaunchMode = 'play' | 'test';
|
||||
|
||||
export type CustomWorldRuntimeLaunchOptions = {
|
||||
mode?: CustomWorldRuntimeLaunchMode;
|
||||
returnStage?: SelectionStage | null;
|
||||
};
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'detail'
|
||||
@@ -38,5 +45,8 @@ export type PlatformEntryFlowShellProps = {
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => void;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
@@ -18,6 +19,34 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
function createAuthValue() {
|
||||
return {
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action: () => void) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light' as const,
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPuzzleRuntime(
|
||||
ui: React.ReactElement,
|
||||
authValue = createAuthValue(),
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider value={authValue}>{ui}</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
const clearedRun: PuzzleRunSnapshot = {
|
||||
runId: 'run-1',
|
||||
entryProfileId: 'profile-1',
|
||||
@@ -85,9 +114,10 @@ const clearedRun: PuzzleRunSnapshot = {
|
||||
};
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
|
||||
render(
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
@@ -97,6 +127,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
expect(screen.getByTestId('puzzle-clear-flash')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
|
||||
expect(within(dialog).getByText('排行榜')).toBeTruthy();
|
||||
@@ -106,10 +143,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
render(
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
@@ -119,8 +159,91 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
const authValue = createAuthValue();
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
authValue,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
|
||||
const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' });
|
||||
fireEvent.change(slider, { target: { value: '77' } });
|
||||
|
||||
expect(within(dialog).getByText('第 1 关')).toBeTruthy();
|
||||
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
|
||||
});
|
||||
|
||||
test('合并块按实际拼块外轮廓描边', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [
|
||||
{
|
||||
groupId: 'group-l',
|
||||
pieceIds: ['piece-0', 'piece-1', 'piece-3'],
|
||||
occupiedCells: [
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
|
||||
['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
|
||||
? { ...piece, mergedGroupId: 'group-l' }
|
||||
: piece,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const outlinedPieces = container.querySelectorAll(
|
||||
'[data-merged-piece-outline="true"]',
|
||||
);
|
||||
|
||||
expect(outlinedPieces).toHaveLength(3);
|
||||
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
|
||||
expect(outlinedPieces[0]?.className).toContain('border-r-0');
|
||||
expect(outlinedPieces[0]?.className).toContain('border-b-0');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-tr-none');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-bl-none');
|
||||
expect(outlinedPieces[1]?.className).toContain('border-l-0');
|
||||
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
|
||||
expect(outlinedPieces[2]?.className).toContain('border-t-0');
|
||||
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
@@ -2526,6 +2526,10 @@ test('agent draft result test button enters current draft without publish gate',
|
||||
await waitFor(() => {
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: '潮雾列岛' }),
|
||||
expect.objectContaining({
|
||||
mode: 'test',
|
||||
returnStage: 'custom-world-result',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { act, render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { WorldType, type CustomWorldProfile } from '../../types';
|
||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||||
|
||||
function buildProfile(params: {
|
||||
@@ -88,7 +88,11 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
stage: 'ready_to_publish',
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: { isReady: true, completedKeys: [], missingKeys: [] },
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: null,
|
||||
@@ -110,15 +114,15 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
}
|
||||
|
||||
describe('useRpgCreationEnterWorld', () => {
|
||||
it('Agent 草稿进入游戏时使用 session draft profile 的角色形象', async () => {
|
||||
it('Agent 草稿测试进入游戏时使用结果页当前 profile 的角色形象', async () => {
|
||||
const staleResultProfile = buildProfile({
|
||||
id: 'stale-result',
|
||||
name: '旧结果页快照',
|
||||
imageSrc: '/template/old-role.png',
|
||||
});
|
||||
const draftProfile = buildProfile({
|
||||
const resultProfile = buildProfile({
|
||||
id: 'draft-profile',
|
||||
name: '草稿真相源',
|
||||
name: '结果页真相源',
|
||||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||||
});
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
@@ -130,7 +134,7 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
isAgentDraftResultView: true,
|
||||
activeAgentSessionId: 'session-1',
|
||||
generatedCustomWorldProfile: staleResultProfile,
|
||||
agentSessionProfile: draftProfile,
|
||||
agentSessionProfile: resultProfile,
|
||||
agentSession: buildSession(),
|
||||
handleCustomWorldSelect,
|
||||
executePublishWorld,
|
||||
@@ -138,7 +142,10 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => void enterWorldForTestFromCurrentResult()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void enterWorldForTestFromCurrentResult()}
|
||||
>
|
||||
进入
|
||||
</button>
|
||||
);
|
||||
@@ -150,9 +157,12 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
});
|
||||
|
||||
expect(executePublishWorld).not.toHaveBeenCalled();
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(draftProfile);
|
||||
expect(handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/draft-role/portrait.png',
|
||||
);
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, {
|
||||
mode: 'test',
|
||||
returnStage: 'custom-world-result',
|
||||
});
|
||||
expect(
|
||||
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
|
||||
).toBe('/generated-characters/draft-role/portrait.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
@@ -10,14 +11,17 @@ type UseRpgCreationEnterWorldParams = {
|
||||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||||
agentSessionProfile: CustomWorldProfile | null;
|
||||
agentSession: CustomWorldAgentSessionSnapshot | null;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => void;
|
||||
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
|
||||
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一“进入世界”前的最终同步策略。
|
||||
* Agent 草稿结果进入游戏时只读 session.draftProfile,不再把结果页快照回写成新的运行时 profile。
|
||||
* Agent 草稿结果进入游戏时只读当前结果页 profile,不再静默回退到基础 draftProfile。
|
||||
*/
|
||||
export function useRpgCreationEnterWorld(
|
||||
params: UseRpgCreationEnterWorldParams,
|
||||
@@ -39,13 +43,22 @@ export function useRpgCreationEnterWorld(
|
||||
}
|
||||
|
||||
if (!isAgentDraftResultView || !activeAgentSessionId) {
|
||||
handleCustomWorldSelect(generatedCustomWorldProfile);
|
||||
handleCustomWorldSelect(generatedCustomWorldProfile, {
|
||||
mode: 'test',
|
||||
returnStage: 'custom-world-result',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
handleCustomWorldSelect(latestProfile);
|
||||
if (!agentSessionProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratedCustomWorldProfile(agentSessionProfile);
|
||||
handleCustomWorldSelect(agentSessionProfile, {
|
||||
mode: 'test',
|
||||
returnStage: 'custom-world-result',
|
||||
});
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
agentSessionProfile,
|
||||
@@ -64,8 +77,11 @@ export function useRpgCreationEnterWorld(
|
||||
return generatedCustomWorldProfile;
|
||||
}
|
||||
|
||||
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
if (!agentSessionProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setGeneratedCustomWorldProfile(agentSessionProfile);
|
||||
|
||||
const latestSession = agentSession;
|
||||
const canEnterPublishedWorld =
|
||||
@@ -73,13 +89,13 @@ export function useRpgCreationEnterWorld(
|
||||
latestSession.resultPreview?.canEnterWorld;
|
||||
|
||||
if (canEnterPublishedWorld) {
|
||||
return latestProfile;
|
||||
return agentSessionProfile;
|
||||
}
|
||||
|
||||
const publishedSession = await executePublishWorld();
|
||||
const publishedProfile =
|
||||
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
|
||||
latestProfile;
|
||||
agentSessionProfile;
|
||||
|
||||
setGeneratedCustomWorldProfile(publishedProfile);
|
||||
return publishedProfile;
|
||||
@@ -89,7 +105,6 @@ export function useRpgCreationEnterWorld(
|
||||
agentSessionProfile,
|
||||
executePublishWorld,
|
||||
generatedCustomWorldProfile,
|
||||
handleCustomWorldSelect,
|
||||
isAgentDraftResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
]);
|
||||
|
||||
@@ -219,7 +219,7 @@ export function useRpgCreationResultAutosave(
|
||||
}
|
||||
|
||||
// Agent 结果页不再把前端 profile 回写到 session。
|
||||
// session.draftProfile 是真相源;这里只刷新后端最新快照,避免在采集/生成早期误触 sync_result_profile。
|
||||
// 这里只刷新后端结果页快照,避免在采集/生成早期误触 sync_result_profile。
|
||||
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
|
||||
const latestProfile = normalizeAgentBackedProfile(
|
||||
buildDraftResultProfile(latestSession) ?? profile,
|
||||
|
||||
@@ -52,6 +52,7 @@ function renderPanel(
|
||||
isLoading?: boolean;
|
||||
onSubmitNpcChatInput?: (input: string) => boolean;
|
||||
onExitNpcChat?: () => boolean;
|
||||
inBattle?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
@@ -97,7 +98,7 @@ function renderPanel(
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
inBattle={overrides.inBattle ?? false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
@@ -283,3 +284,53 @@ test('adventure panel renders narrative story text without italics and hides opt
|
||||
expect(html).toContain('text-[15px]');
|
||||
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
|
||||
});
|
||||
|
||||
test('adventure panel hides narrative story section during battle', () => {
|
||||
const option = createOption('battle_attack_basic', '挥剑压上');
|
||||
const currentStory: StoryMoment = {
|
||||
text: '敌人的刀光逼到眼前,这段剧情框在战斗中不应该占位。',
|
||||
options: [option],
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [option], {
|
||||
inBattle: true,
|
||||
});
|
||||
|
||||
expect(html).not.toContain('敌人的刀光逼到眼前');
|
||||
expect(html).toContain('挥剑压上');
|
||||
});
|
||||
|
||||
test('adventure panel limits battle choices before viewport measurement', () => {
|
||||
const options = Array.from({ length: 6 }, (_, index) =>
|
||||
createOption('battle_attack_basic', `战斗动作${index + 1}`),
|
||||
);
|
||||
const currentStory: StoryMoment = {
|
||||
text: '战斗中剧情框不占底部空间。',
|
||||
options,
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, options, {
|
||||
inBattle: true,
|
||||
});
|
||||
|
||||
expect(html).toContain('战斗动作1');
|
||||
expect(html).toContain('战斗动作4');
|
||||
expect(html).not.toContain('战斗动作5');
|
||||
expect(html).not.toContain('战斗动作6');
|
||||
});
|
||||
|
||||
test('adventure panel uses combat settlement loading copy during battle', () => {
|
||||
const option = createOption('battle_attack_basic', '挥剑压上');
|
||||
const currentStory: StoryMoment = {
|
||||
text: '战斗中的加载态不该再提示剧情推演。',
|
||||
options: [option],
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [option], {
|
||||
inBattle: true,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
expect(html).toContain('战斗结算中...');
|
||||
expect(html).not.toContain('剧情推演中...');
|
||||
});
|
||||
|
||||
@@ -53,6 +53,10 @@ import {
|
||||
import { HostileNpcAnimator } from '../HostileNpcAnimator';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
|
||||
const BATTLE_OPTION_ROW_MIN_HEIGHT = 58;
|
||||
const BATTLE_OPTION_ROW_GAP = 6;
|
||||
const DEFAULT_BATTLE_VISIBLE_OPTION_COUNT = 4;
|
||||
|
||||
export interface RpgAdventurePanelProps {
|
||||
aiError: string | null;
|
||||
currentStory: StoryMoment;
|
||||
@@ -140,6 +144,57 @@ function getOptionActionTextClass(option: StoryOption) {
|
||||
return 'text-zinc-300 group-hover:text-white';
|
||||
}
|
||||
|
||||
function getBattleVisibleOptionCount(availableHeight: number, total: number) {
|
||||
if (total <= 0) return 0;
|
||||
|
||||
if (!Number.isFinite(availableHeight) || availableHeight <= 0) {
|
||||
return Math.min(total, DEFAULT_BATTLE_VISIBLE_OPTION_COUNT);
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
total,
|
||||
Math.floor(
|
||||
(availableHeight + BATTLE_OPTION_ROW_GAP) /
|
||||
(BATTLE_OPTION_ROW_MIN_HEIGHT + BATTLE_OPTION_ROW_GAP),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function useMeasuredElementHeight<T extends HTMLElement>(enabled: boolean) {
|
||||
const elementRef = useRef<T | null>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setHeight(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setHeight(element.getBoundingClientRect().height);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(updateHeight);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateHeight);
|
||||
return () => window.removeEventListener('resize', updateHeight);
|
||||
}, [enabled]);
|
||||
|
||||
return [elementRef, height] as const;
|
||||
}
|
||||
|
||||
function getOptionFunctionTagText(option: StoryOption) {
|
||||
const tagByFunctionId: Record<string, string> = {
|
||||
battle_all_in_crush: '战斗',
|
||||
@@ -692,11 +747,14 @@ function RpgAdventureStorySection(props: {
|
||||
isStoryStreaming,
|
||||
currentStory,
|
||||
} = props;
|
||||
const storyPanelClassName = isNpcChatMode
|
||||
? 'flex-[1.18] sm:min-h-[15rem]'
|
||||
: 'flex-1 sm:min-h-[14rem]';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={storyScrollContainerRef}
|
||||
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
|
||||
className={`pixel-nine-slice pixel-panel mb-3 min-h-0 overflow-y-auto pr-1 scrollbar-hide ${storyPanelClassName}`}
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel)}
|
||||
>
|
||||
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
|
||||
@@ -787,6 +845,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
setNpcChatDraft: (value: string) => void;
|
||||
npcChatPlaceholder: string;
|
||||
submitNpcChatDraft: () => void;
|
||||
inBattle: boolean;
|
||||
}) {
|
||||
const {
|
||||
isNpcChatMode,
|
||||
@@ -813,11 +872,29 @@ function RpgAdventureChoiceSection(props: {
|
||||
setNpcChatDraft,
|
||||
npcChatPlaceholder,
|
||||
submitNpcChatDraft,
|
||||
inBattle,
|
||||
} = props;
|
||||
const [battleChoiceViewportRef, battleChoiceViewportHeight] =
|
||||
useMeasuredElementHeight<HTMLDivElement>(
|
||||
inBattle && !isNpcChatMode && !shouldHideChoiceUi,
|
||||
);
|
||||
const visibleDisplayedOptions =
|
||||
inBattle && !isNpcChatMode && !shouldHideChoiceUi
|
||||
? displayedOptions.slice(
|
||||
0,
|
||||
getBattleVisibleOptionCount(
|
||||
battleChoiceViewportHeight,
|
||||
displayedOptions.length,
|
||||
),
|
||||
)
|
||||
: displayedOptions;
|
||||
|
||||
return (
|
||||
<div className="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
|
||||
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<div
|
||||
className={`mt-auto min-h-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.1rem)] pt-1.5 ${inBattle ? 'flex flex-1 flex-col' : 'shrink-0'}`}
|
||||
>
|
||||
{/* 让底部操作区整体更贴近屏幕底部,同时把上方聊天区腾出更稳定的展示高度。 */}
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -880,12 +957,15 @@ function RpgAdventureChoiceSection(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
ref={battleChoiceViewportRef}
|
||||
className={`space-y-1.5 ${inBattle ? 'min-h-0 flex-1 overflow-hidden' : ''}`}
|
||||
>
|
||||
{isLoading && !isStoryStreaming ? (
|
||||
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-xs uppercase tracking-widest">
|
||||
剧情推演中...
|
||||
{inBattle ? '战斗结算中...' : '剧情推演中...'}
|
||||
</span>
|
||||
</div>
|
||||
) : isStoryStreaming ? (
|
||||
@@ -896,7 +976,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
<div className="p-4" aria-hidden="true" />
|
||||
) : (
|
||||
<>
|
||||
{displayedOptions.map((option, index) => {
|
||||
{visibleDisplayedOptions.map((option, index) => {
|
||||
const optionImpactSummary = getOptionImpactSummary(
|
||||
option,
|
||||
playerCharacter,
|
||||
@@ -970,7 +1050,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
);
|
||||
})}
|
||||
{isNpcChatMode && !isNpcQuestOfferMode ? (
|
||||
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
|
||||
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 px-1.5 pb-1.5 pt-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
value={npcChatDraft}
|
||||
@@ -985,7 +1065,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
}
|
||||
}}
|
||||
placeholder={npcChatPlaceholder}
|
||||
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
|
||||
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
|
||||
maxLength={80}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -993,7 +1073,7 @@ function RpgAdventureChoiceSection(props: {
|
||||
type="button"
|
||||
onClick={submitNpcChatDraft}
|
||||
disabled={isLoading || !npcChatDraft.trim()}
|
||||
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
|
||||
className="inline-flex h-10 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
@@ -1161,6 +1241,7 @@ export function RpgAdventurePanel({
|
||||
playerMana,
|
||||
playerMaxMana,
|
||||
playerSkillCooldowns,
|
||||
inBattle,
|
||||
currentNpcBattleMode,
|
||||
statistics,
|
||||
musicVolume,
|
||||
@@ -1550,18 +1631,20 @@ export function RpgAdventurePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RpgAdventureStorySection
|
||||
currentSceneActTitle={currentSceneActTitle}
|
||||
currentSceneActIndex={currentSceneActIndex}
|
||||
currentSceneActCount={currentSceneActCount}
|
||||
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
|
||||
storyScrollContainerRef={storyScrollContainerRef}
|
||||
isDialogueStory={isDialogueStory}
|
||||
dialogueTurns={dialogueTurns}
|
||||
isNpcChatMode={isNpcChatMode}
|
||||
isStoryStreaming={isStoryStreaming}
|
||||
currentStory={currentStory}
|
||||
/>
|
||||
{!inBattle ? (
|
||||
<RpgAdventureStorySection
|
||||
currentSceneActTitle={currentSceneActTitle}
|
||||
currentSceneActIndex={currentSceneActIndex}
|
||||
currentSceneActCount={currentSceneActCount}
|
||||
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
|
||||
storyScrollContainerRef={storyScrollContainerRef}
|
||||
isDialogueStory={isDialogueStory}
|
||||
dialogueTurns={dialogueTurns}
|
||||
isNpcChatMode={isNpcChatMode}
|
||||
isStoryStreaming={isStoryStreaming}
|
||||
currentStory={currentStory}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<RpgAdventureChoiceSection
|
||||
isNpcChatMode={isNpcChatMode}
|
||||
@@ -1590,6 +1673,7 @@ export function RpgAdventurePanel({
|
||||
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
|
||||
}
|
||||
submitNpcChatDraft={submitNpcChatDraft}
|
||||
inBattle={inBattle}
|
||||
/>
|
||||
|
||||
<RpgAdventureOverlaySection
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AnimationState, WorldType, type GameState } from '../../types';
|
||||
import { RpgRuntimeShell } from './RpgRuntimeShell';
|
||||
import type { RpgRuntimeShellProps } from './types';
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./useRpgRuntimeShellViewModel', () => ({
|
||||
useRpgRuntimeShellViewModel: () => ({
|
||||
selectionStage: 'platform',
|
||||
setSelectionStage: () => {},
|
||||
overlayPanel: null,
|
||||
openOverlayPanel: () => {},
|
||||
closeOverlayPanel: () => {},
|
||||
selectedSceneEntity: null,
|
||||
setSelectedSceneEntity: () => {},
|
||||
openPartyMemberDetails: () => {},
|
||||
closeAdventureEntityModal: () => {},
|
||||
showTeamModal: false,
|
||||
openCampModal: () => {},
|
||||
closeCampModal: () => {},
|
||||
resetForSaveAndExit: () => {},
|
||||
shouldMountAdventureEntityModal: false,
|
||||
shouldMountCampModal: false,
|
||||
shouldMountMapModal: false,
|
||||
shouldMountCharacterChatModal: false,
|
||||
shouldMountNpcModals: false,
|
||||
visibleGameState: mockVisibleGameState,
|
||||
visibleCurrentStory: {
|
||||
storyText: '测试故事',
|
||||
options: [],
|
||||
},
|
||||
sceneTransitionPhase: 'idle',
|
||||
sceneTransitionToken: 0,
|
||||
setSceneTransitionDurations: () => {},
|
||||
isCharacterSelectionStage: false,
|
||||
shouldHideStoryOptions: false,
|
||||
hideSelectionHero: false,
|
||||
dialogueIndicator: {
|
||||
showPlayer: false,
|
||||
showEncounter: false,
|
||||
activeSpeaker: null,
|
||||
},
|
||||
characterChatSummaries: {},
|
||||
canvasCompanionRenderStates: [],
|
||||
adventureStatistics: {
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '测试场景',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
},
|
||||
handleSceneTransitionChoice: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./RpgRuntimeCanvasStage', () => ({
|
||||
RpgRuntimeCanvasStage: () => <div>画布舞台</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./RpgRuntimeOverlayHost', () => ({
|
||||
RpgRuntimeOverlayHost: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./RpgRuntimeStageRouter', () => ({
|
||||
RpgRuntimeStageRouter: () => <div>运行时主面板</div>,
|
||||
}));
|
||||
|
||||
let mockVisibleGameState: GameState;
|
||||
|
||||
function createGameState(runtimeMode: GameState['runtimeMode']): GameState {
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: {
|
||||
id: 'player-1',
|
||||
name: '测试角色',
|
||||
title: '测试者',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
personality: '冷静',
|
||||
motivation: '完成测试',
|
||||
combatStyle: '均衡',
|
||||
role: '主角',
|
||||
avatar: '',
|
||||
portrait: '',
|
||||
imageSrc: '',
|
||||
initialAffinity: 0,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
backstoryReveal: {
|
||||
publicSummary: '测试',
|
||||
privateChatUnlockAffinity: 60,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
runtimeMode,
|
||||
runtimePersistenceDisabled: runtimeMode !== 'play',
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
playerProgression: {
|
||||
level: 1,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 0,
|
||||
xpToNextLevel: 100,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 50,
|
||||
playerMaxMana: 50,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProps(runtimeMode: GameState['runtimeMode']): RpgRuntimeShellProps {
|
||||
const gameState = createGameState(runtimeMode);
|
||||
mockVisibleGameState = gameState;
|
||||
return {
|
||||
session: {
|
||||
gameState,
|
||||
currentStory: {
|
||||
storyText: '测试故事',
|
||||
options: [],
|
||||
},
|
||||
isLoading: false,
|
||||
aiError: null,
|
||||
bottomTab: 'adventure',
|
||||
setBottomTab: () => {},
|
||||
isMapOpen: false,
|
||||
setIsMapOpen: () => {},
|
||||
},
|
||||
story: {
|
||||
displayedOptions: [],
|
||||
canRefreshOptions: false,
|
||||
handleRefreshOptions: () => {},
|
||||
handleChoice: () => {},
|
||||
handleNpcChatInput: () => false,
|
||||
refreshNpcChatOptions: () => false,
|
||||
exitNpcChat: () => false,
|
||||
handleMapTravelToScene: () => false,
|
||||
npcUi: {
|
||||
isNpcModalOpen: false,
|
||||
currentNpcEncounter: null,
|
||||
selectedNpc: null,
|
||||
isGeneratingNpcResponse: false,
|
||||
npcResponseError: null,
|
||||
generatedNpcText: '',
|
||||
npcResponseOptions: [],
|
||||
selectedOptionId: null,
|
||||
},
|
||||
characterChatUi: {
|
||||
isCharacterChatModalOpen: false,
|
||||
activeCharacter: null,
|
||||
},
|
||||
inventoryUi: {
|
||||
isInventoryOpen: false,
|
||||
},
|
||||
battleRewardUi: {
|
||||
isRewardModalOpen: false,
|
||||
rewards: [],
|
||||
},
|
||||
questUi: {
|
||||
isQuestPanelOpen: false,
|
||||
},
|
||||
npcChatQuestOfferUi: {
|
||||
isOfferModalOpen: false,
|
||||
pendingQuest: null,
|
||||
},
|
||||
goalUi: {
|
||||
isGoalPanelOpen: false,
|
||||
entries: [],
|
||||
},
|
||||
},
|
||||
entry: {
|
||||
hasSavedGame: false,
|
||||
savedSnapshot: null,
|
||||
handleContinueGame: () => {},
|
||||
handleStartNewGame: () => {},
|
||||
handleSaveAndExit: () => {},
|
||||
handleCustomWorldSelect: () => {},
|
||||
handleBackToWorldSelect: () => {},
|
||||
handleCharacterSelect: () => {},
|
||||
},
|
||||
companions: {
|
||||
companionRenderStates: [],
|
||||
buildCompanionRenderStates: () => [],
|
||||
onBenchCompanion: () => {},
|
||||
onActivateRosterCompanion: () => {},
|
||||
},
|
||||
audio: {
|
||||
musicVolume: 0.5,
|
||||
onMusicVolumeChange: () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockVisibleGameState = createGameState('play');
|
||||
});
|
||||
|
||||
test('测试态显示结束测试按钮并触发退出回调', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExitTestRuntime = vi.fn();
|
||||
|
||||
render(
|
||||
<RpgRuntimeShell
|
||||
{...buildProps('test')}
|
||||
onExitTestRuntime={onExitTestRuntime}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '结束测试' }));
|
||||
|
||||
expect(onExitTestRuntime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('正式运行态不显示结束测试按钮', () => {
|
||||
render(<RpgRuntimeShell {...buildProps('play')} onExitTestRuntime={() => {}} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '结束测试' })).toBeNull();
|
||||
});
|
||||
@@ -37,6 +37,7 @@ export function RpgRuntimeShell({
|
||||
companions,
|
||||
audio,
|
||||
chrome,
|
||||
onExitTestRuntime,
|
||||
}: RpgRuntimeShellComponentProps) {
|
||||
const authUi = useAuthUi();
|
||||
const isPlatformShell = !session.gameState.worldType;
|
||||
@@ -132,6 +133,7 @@ export function RpgRuntimeShell({
|
||||
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
|
||||
),
|
||||
);
|
||||
const isTestRuntime = gameState.runtimeMode === 'test';
|
||||
|
||||
useEffect(() => {
|
||||
if (gameState.worldType && !gameState.playerCharacter) {
|
||||
@@ -207,6 +209,23 @@ export function RpgRuntimeShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState.worldType && isTestRuntime && onExitTestRuntime ? (
|
||||
<div
|
||||
className="fixed inset-x-0 z-[170] flex justify-center px-4"
|
||||
style={{
|
||||
top: 'calc(36vh - 3.25rem)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExitTestRuntime}
|
||||
className="inline-flex min-h-[2.75rem] items-center justify-center rounded-full border border-white/15 bg-black/65 px-5 text-sm font-semibold text-white shadow-[0_12px_30px_rgba(0,0,0,0.38)] backdrop-blur-sm transition hover:border-white/28 hover:bg-black/78"
|
||||
>
|
||||
结束测试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RpgRuntimeStageRouter
|
||||
gameState={gameState}
|
||||
visibleGameState={visibleGameState}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
|
||||
|
||||
export interface RpgRuntimeSessionProps {
|
||||
gameState: GameState;
|
||||
@@ -53,7 +54,10 @@ export interface RpgEntrySessionProps {
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: Character) => void;
|
||||
}
|
||||
@@ -107,4 +111,5 @@ export interface RpgRuntimeShellProps {
|
||||
companions: RpgRuntimeCompanionProps;
|
||||
audio: RpgRuntimeAudioProps;
|
||||
chrome?: RpgRuntimeShellChromeOptions;
|
||||
onExitTestRuntime?: () => void;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,51 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('把幕配置里的角色名归一到真实角色 id', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '雾港归航',
|
||||
settingText: '海雾旧案',
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-cendeng',
|
||||
name: '岑灯',
|
||||
title: '返乡守灯人',
|
||||
role: '主角代理',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-luheng',
|
||||
name: '陆衡',
|
||||
title: '航运公会审计员',
|
||||
role: '第一幕主NPC',
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '开局章节',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
title: '第一幕',
|
||||
summary: '陆衡先拦住玩家。',
|
||||
encounterNpcIds: ['陆衡'],
|
||||
primaryNpcId: '航运公会审计员',
|
||||
oppositeNpcId: '陆衡',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const act = profile?.sceneChapterBlueprints?.[0]?.acts[0];
|
||||
expect(act?.encounterNpcIds).toEqual(['story-luheng']);
|
||||
expect(act?.primaryNpcId).toBe('story-luheng');
|
||||
expect(act?.oppositeNpcId).toBe('story-luheng');
|
||||
});
|
||||
|
||||
it('直接读取 Rust 草稿角色字段和形象资源', () => {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
name: '雾港归航',
|
||||
@@ -121,4 +166,3 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
normalizeCustomWorldLockState,
|
||||
} from '../services/customWorldCreatorIntent';
|
||||
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
|
||||
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
|
||||
import {
|
||||
AnimationState,
|
||||
CharacterAnimationConfig,
|
||||
@@ -971,18 +972,30 @@ function normalizeSceneActBlueprint(
|
||||
value: unknown,
|
||||
index: number,
|
||||
sceneId: string,
|
||||
profileRoles?: {
|
||||
playableNpcs: CustomWorldPlayableNpc[];
|
||||
storyNpcs: CustomWorldNpc[];
|
||||
} | null,
|
||||
): SceneActBlueprint | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encounterNpcIds = toStringArray(value.encounterNpcIds);
|
||||
const encounterNpcIds = toStringArray(value.encounterNpcIds).map((npcId) =>
|
||||
resolveCustomWorldRoleIdReference(profileRoles, npcId),
|
||||
);
|
||||
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
|
||||
const advanceRule = toText(value.advanceRule);
|
||||
const title = toText(value.title);
|
||||
const summary = toText(value.summary);
|
||||
const primaryNpcId = toText(value.primaryNpcId, encounterNpcIds[0] ?? '');
|
||||
const oppositeNpcId = toText(value.oppositeNpcId, primaryNpcId);
|
||||
const primaryNpcId = resolveCustomWorldRoleIdReference(
|
||||
profileRoles,
|
||||
toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
|
||||
);
|
||||
const oppositeNpcId = resolveCustomWorldRoleIdReference(
|
||||
profileRoles,
|
||||
toText(value.oppositeNpcId, primaryNpcId),
|
||||
);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
return null;
|
||||
@@ -1020,7 +1033,13 @@ function normalizeSceneActBlueprint(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSceneChapterBlueprints(value: unknown) {
|
||||
function normalizeSceneChapterBlueprints(
|
||||
value: unknown,
|
||||
profileRoles?: {
|
||||
playableNpcs: CustomWorldPlayableNpc[];
|
||||
storyNpcs: CustomWorldNpc[];
|
||||
} | null,
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
@@ -1036,7 +1055,12 @@ function normalizeSceneChapterBlueprints(value: unknown) {
|
||||
const acts = Array.isArray(entry.acts)
|
||||
? entry.acts
|
||||
.map((act, actIndex) =>
|
||||
normalizeSceneActBlueprint(act, actIndex, sceneId),
|
||||
normalizeSceneActBlueprint(
|
||||
act,
|
||||
actIndex,
|
||||
sceneId,
|
||||
profileRoles,
|
||||
),
|
||||
)
|
||||
.filter((act): act is SceneActBlueprint => Boolean(act))
|
||||
: [];
|
||||
@@ -1126,6 +1150,11 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
|
||||
: [];
|
||||
|
||||
const playableNpcs = Array.isArray(value.playableNpcs)
|
||||
? value.playableNpcs
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [];
|
||||
const normalizedProfile = {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
settingText,
|
||||
@@ -1144,11 +1173,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
value.attributeSchema,
|
||||
generatedAttributeSchema,
|
||||
),
|
||||
playableNpcs: Array.isArray(value.playableNpcs)
|
||||
? value.playableNpcs
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [],
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
items: Array.isArray(value.items)
|
||||
? value.items
|
||||
@@ -1168,6 +1193,10 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
|
||||
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
|
||||
value.sceneChapterBlueprints,
|
||||
{
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
},
|
||||
),
|
||||
anchorContent: preserveStructuredRecord<EightAnchorContent>(
|
||||
value.anchorContent,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
|
||||
import {
|
||||
canUseLimitedPrimaryNpcChat,
|
||||
resolveActiveSceneActEncounterFocusNpcId,
|
||||
@@ -145,9 +146,16 @@ function getAvailableActiveSceneActNpcs(state: GameState) {
|
||||
|
||||
return (state.currentScenePreset?.npcs ?? [])
|
||||
.filter(candidate => {
|
||||
const candidateIds = [candidate.id, candidate.characterId].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
const candidateIds = [
|
||||
candidate.id,
|
||||
candidate.characterId,
|
||||
candidate.name,
|
||||
candidate.title,
|
||||
]
|
||||
.map((value) =>
|
||||
resolveCustomWorldRoleIdReference(state.customWorldProfile, value),
|
||||
)
|
||||
.filter(Boolean);
|
||||
return candidateIds.some(id => activeActNpcIdSet.has(id));
|
||||
})
|
||||
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
||||
@@ -180,8 +188,19 @@ function pickFriendlySceneNpcForActiveAct(state: GameState, npcs: SceneNpc[]) {
|
||||
return (
|
||||
npcs.find(
|
||||
(npc) =>
|
||||
npc.id === focusNpcId ||
|
||||
(npc.characterId ? npc.characterId === focusNpcId : false),
|
||||
resolveCustomWorldRoleIdReference(state.customWorldProfile, npc.id) === focusNpcId ||
|
||||
resolveCustomWorldRoleIdReference(
|
||||
state.customWorldProfile,
|
||||
npc.characterId,
|
||||
) === focusNpcId ||
|
||||
resolveCustomWorldRoleIdReference(
|
||||
state.customWorldProfile,
|
||||
npc.name,
|
||||
) === focusNpcId ||
|
||||
resolveCustomWorldRoleIdReference(
|
||||
state.customWorldProfile,
|
||||
npc.title,
|
||||
) === focusNpcId,
|
||||
) ?? pickRandomItem(npcs)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
|
||||
import { resolveCustomWorldRoleIdReferences } from '../services/customWorldRoleReferences';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
@@ -406,9 +407,11 @@ function collectSceneActNpcIdsForScene(
|
||||
}
|
||||
|
||||
chapter.acts.forEach((act) => {
|
||||
pushNpcId(act.primaryNpcId);
|
||||
pushNpcId(act.oppositeNpcId);
|
||||
act.encounterNpcIds.forEach(pushNpcId);
|
||||
resolveCustomWorldRoleIdReferences(profile, [
|
||||
act.primaryNpcId,
|
||||
act.oppositeNpcId,
|
||||
...act.encounterNpcIds,
|
||||
]).forEach(pushNpcId);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -229,7 +229,92 @@ describe('buildBattlePlan', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not turn recovery fallback into a random player attack', () => {
|
||||
it('keeps battle_attack_basic as a single basic attack instead of randomly selecting another skill', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerMana: 20,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: '山狼',
|
||||
action: '压低身体',
|
||||
description: '测试敌人',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 80,
|
||||
maxHp: 80,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: {
|
||||
...createBattleOption(),
|
||||
functionId: 'battle_attack_basic',
|
||||
},
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 900,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 1,
|
||||
});
|
||||
|
||||
const playerTurns = plan.turns.filter((turn) => turn.actor === 'player');
|
||||
|
||||
expect(playerTurns).toHaveLength(1);
|
||||
expect(playerTurns[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
selectedSkillId: 'battle-basic-attack',
|
||||
}),
|
||||
);
|
||||
expect(plan.finalState.playerMana).toBe(state.playerMana);
|
||||
});
|
||||
|
||||
it('resolves one full speed-ordered round when combat continues', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: '山狼',
|
||||
action: '压低身体',
|
||||
description: '测试敌人',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 120,
|
||||
maxHp: 120,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: {
|
||||
...createBattleOption(),
|
||||
functionId: 'battle_attack_basic',
|
||||
},
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 6000,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 6,
|
||||
});
|
||||
|
||||
expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']);
|
||||
expect(plan.finalState.inBattle).toBe(true);
|
||||
expect(plan.finalState.sceneHostileNpcs[0]?.hp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps recovery as a player turn without converting it into an attack', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerHp: 40,
|
||||
@@ -265,8 +350,77 @@ describe('buildBattlePlan', () => {
|
||||
minTurnCount: 1,
|
||||
});
|
||||
|
||||
expect(plan.turns.some((turn) => turn.actor === 'player')).toBe(false);
|
||||
expect(plan.preparedState.playerHp).toBeGreaterThan(state.playerHp);
|
||||
expect(plan.preparedState.playerMana).toBeGreaterThan(state.playerMana);
|
||||
const playerTurn = plan.turns.find((turn) => turn.actor === 'player');
|
||||
|
||||
expect(playerTurn).toEqual(
|
||||
expect.objectContaining({
|
||||
actor: 'player',
|
||||
actionKind: 'recover',
|
||||
selectedSkillId: null,
|
||||
damage: 0,
|
||||
}),
|
||||
);
|
||||
expect(plan.finalState.playerHp).toBeGreaterThan(state.playerHp);
|
||||
expect(plan.finalState.playerMana).toBeGreaterThan(state.playerMana);
|
||||
});
|
||||
|
||||
it('includes companion turns in fight mode and orders the round by speed', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
companions: [
|
||||
{
|
||||
npcId: 'companion-1',
|
||||
characterId: 'archer-hero',
|
||||
joinedAtAffinity: 10,
|
||||
hp: 60,
|
||||
maxHp: 60,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: '山狼',
|
||||
action: '压低身体',
|
||||
description: '测试敌人',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 0.5,
|
||||
hp: 120,
|
||||
maxHp: 120,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: {
|
||||
...createBattleOption(),
|
||||
functionId: 'battle_attack_basic',
|
||||
},
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 6000,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 6,
|
||||
});
|
||||
|
||||
expect(plan.turns.map((turn) => turn.actor)).toEqual([
|
||||
'companion',
|
||||
'player',
|
||||
'monster',
|
||||
]);
|
||||
expect(plan.turns[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
actor: 'companion',
|
||||
companionNpcId: 'companion-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+653
-313
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user