This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions
+21 -3
View File
@@ -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>
);
}
+2
View File
@@ -80,6 +80,8 @@ export default function PuzzlePlaygroundApp() {
return (
<PuzzleRuntimeShell
run={run}
isBusy={false}
error={null}
onBack={handleRestart}
onSwapPieces={handleSwapPieces}
onDragPiece={handleDragPiece}
+7 -2
View File
@@ -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}
+6 -1
View File
@@ -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;
}
+45 -1
View File
@@ -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', () => {
);
});
});
+39 -10
View File
@@ -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,
+24 -5
View File
@@ -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)
);
}
+6 -3
View File
@@ -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);
});
});
+158 -4
View File
@@ -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',
}),
);
});
});
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