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

View File

@@ -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();
});

View File

@@ -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}

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;
}