/* @vitest-environment jsdom */ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useMemo } from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { buildCustomWorldPlayableCharacters, setRuntimeCharacterOverrides, } from '../data/characterPresets'; import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary'; import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime'; import { WorldType } from '../types'; import { useRpgRuntimeStory } from './rpg-runtime-story/useRpgRuntimeStory'; import { useRpgSessionBootstrap } from './rpg-session'; const aiServiceMocks = vi.hoisted(() => ({ streamNpcChatTurn: vi.fn(), })); const rpgRuntimeStoryClientMocks = vi.hoisted(() => ({ beginRpgRuntimeStorySession: vi.fn(), })); vi.mock('../services/aiService', async () => { const actual = await vi.importActual( '../services/aiService', ); return { ...actual, streamNpcChatTurn: aiServiceMocks.streamNpcChatTurn, }; }); vi.mock('../services/rpg-runtime/rpgRuntimeStoryClient', async () => { const actual = await vi.importActual< typeof import('../services/rpg-runtime/rpgRuntimeStoryClient') >('../services/rpg-runtime/rpgRuntimeStoryClient'); return { ...actual, beginRpgRuntimeStorySession: rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession, }; }); function buildBackstoryReveal(label: string) { return { publicSummary: `${label}的公开背景`, privateChatUnlockAffinity: 40, chapters: [ { id: `${label}-surface`, title: '表层来意', affinityRequired: 15, teaser: `${label}先只肯说表面的来意。`, content: `${label}表面上只愿意谈当前局势。`, contextSnippet: `${label}表面上还在收着话。`, }, { id: `${label}-scar`, title: '旧事裂痕', affinityRequired: 30, teaser: `${label}背后还有一段旧伤。`, content: `${label}曾在旧案里留下无法轻易揭开的伤口。`, contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`, }, { id: `${label}-hidden`, title: '隐藏执念', affinityRequired: 60, teaser: `${label}真正想追的不是表面那件事。`, content: `${label}真正挂着的是旧案里还没结的那条线。`, contextSnippet: `${label}真正执念指向旧案深处。`, }, { id: `${label}-final`, title: '最终底牌', affinityRequired: 90, teaser: `${label}手里还压着最后一张牌。`, content: `${label}手里还握着能直接证明真相的关键证据。`, contextSnippet: `${label}最后的底牌足以改写局势。`, }, ], }; } function buildSavedProfile(options: { openingOppositeNpcId?: string; } = {}) { const profile = normalizeCustomWorldProfileRecord({ id: 'saved-runtime-profile', settingText: '被海雾吞没的旧航路群岛', name: '回潮群岛', subtitle: '旧灯塔与断续潮路', summary: '围绕旧灯塔、假航灯和沉船旧案展开的结果页世界。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船夜与封航记录被改动的真相。', templateWorldType: WorldType.WUXIA, compatibilityTemplateWorldType: WorldType.WUXIA, majorFactions: ['守灯会', '航运公会'], coreConflicts: ['封航争夺', '沉船真相'], attributeSchema: { id: 'schema:test', worldId: 'CUSTOM', schemaVersion: 1, generatedFrom: { worldType: WorldType.CUSTOM, worldName: '回潮群岛', settingSummary: '潮雾旧航路', tone: '压抑', conflictCore: '沉船真相', }, slots: [], }, playableNpcs: [ { id: 'playable-1', name: '沈砺', title: '旧航路引路人', role: '关键同行者', description: '最熟悉旧潮路的人。', backstory: '他在沉船夜里带着半支船队逃出过假航灯。', personality: '表面沉稳,心里一直在算退路。', motivation: '想赶在封航前查清真相。', combatStyle: '借潮路换位,先拉扯再压近。', initialAffinity: 18, relationshipHooks: ['旧友', '沉船旧案'], tags: ['潮路', '引路'], backstoryReveal: buildBackstoryReveal('沈砺'), skills: [ { id: 'skill-playable-1', name: '潮行引路', summary: '踩着旧潮阶切线前压,替队伍打开角度。', style: '机动周旋', }, { id: 'skill-playable-2', name: '回雾折返', summary: '借海雾遮住身位,再从侧线拉开。', style: '起手压制', }, { id: 'skill-playable-3', name: '旧图定标', summary: '用旧潮图锁定退路和突入口。', style: '爆发终结', }, ], initialItems: [ { id: 'item-playable-1', name: '旧潮短刃', category: '武器', quantity: 1, rarity: 'rare', description: '专门在湿滑甲板上近身换位用的短刃。', tags: ['潮路', '近战'], }, { id: 'item-playable-2', name: '雾盐药包', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '压住寒潮后遗症的随身药包。', tags: ['补给'], }, { id: 'item-playable-3', name: '旧潮图残页', category: '专属物品', quantity: 1, rarity: 'rare', description: '足够指向沉船夜另一条线的残页。', tags: ['线索', '真相'], }, ], }, ], storyNpcs: [ { id: 'story-1', name: '顾潮音', title: '守灯会值夜人', role: '场景关键角色', description: '夜里巡灯与封锁禁航区的人。', backstory: '她在第一次海雾吞船那夜守到了最后一盏灯。', personality: '冷静克制,但提到旧灯册会明显变调。', motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。', combatStyle: '借塔顶视角与风向压制,再用灯火错位扰乱。', initialAffinity: 8, relationshipHooks: ['禁航记录', '灯塔值夜'], tags: ['守灯会', '灯塔'], backstoryReveal: buildBackstoryReveal('顾潮音'), skills: [ { id: 'skill-story-1', name: '夜潮灯语', summary: '借灯语与潮声干扰对方判断。', style: '起手压制', }, { id: 'skill-story-2', name: '禁航暗潮', summary: '封住错误航线,把人逼回她熟悉的区域。', style: '机动周旋', }, { id: 'skill-story-3', name: '回声巡线', summary: '借塔顶回声迅速锁定异动方向。', style: '爆发终结', }, ], initialItems: [ { id: 'item-story-1', name: '值夜灯尺', category: '武器', quantity: 1, rarity: 'rare', description: '兼作警械和测灯尺的长柄器具。', tags: ['守灯会'], }, ], narrativeProfile: { publicMask: '守灯会值夜人,对外总像比别人更冷静一步。', firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。', visibleLine: '她表面上只是在守灯和封线。', hiddenLine: '她真正盯着的是那本被改过的原始灯册。', contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。', debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。', taboo: '最忌讳别人把那夜的失踪当成单纯天灾。', immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。', relatedThreadIds: ['thread-visible-1'], relatedScarIds: ['scar-1'], reactionHooks: ['原始灯册', '封灯令'], }, }, { id: 'story-primary-only', name: '沈砺旧识', title: '旧潮案记录员', role: '第一幕主线记录者', description: '负责整理旧潮案脉络的人。', backstory: '他知道异常账本的来源,但不会第一时间正面对话。', personality: '沉默、谨慎。', motivation: '保住旧案原始记录。', combatStyle: '以防守和牵制为主。', initialAffinity: 8, relationshipHooks: ['旧案记录'], tags: ['记录', '主线'], backstoryReveal: buildBackstoryReveal('沈砺旧识'), skills: [], initialItems: [], }, { id: 'story-act-only', name: '陆衡', title: '航运公会审计员', role: '第一幕主NPC', description: '正在交易所大厅核对异常账本的人。', backstory: '他掌握着旧航路资金流向的第一份实证。', personality: '克制、警惕,习惯先观察再开口。', motivation: '确认谁在开盘前转移了旧案资金。', combatStyle: '用短杖和账册压制对手节奏。', initialAffinity: 6, relationshipHooks: ['异常账本'], tags: ['审计', '第一幕'], backstoryReveal: buildBackstoryReveal('陆衡'), skills: [], initialItems: [], }, ], items: [], camp: { name: '回潮暂栖所', description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', }, landmarks: [ { id: 'landmark-1', name: '回潮旧灯塔', description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', sceneNpcIds: [], connections: [ { targetLandmarkId: 'landmark-2', relativePosition: 'forward', summary: '沿着旧潮阶继续前压到雾栈尽头。', }, ], narrativeResidues: [ { id: 'residue-1', title: '潮痕', visibleClue: '塔壁上有一圈不该出现在高处的潮痕。', linkedFactIds: ['fact-1'], linkedThreadIds: ['thread-visible-1'], }, ], }, { id: 'landmark-2', name: '雾栈尽头', description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。', sceneNpcIds: [], connections: [ { targetLandmarkId: 'landmark-1', relativePosition: 'back', summary: '退回灯塔还能重新整理路线。', }, ], }, ], sceneChapterBlueprints: [ { id: 'chapter-1', sceneId: 'custom-scene-camp', title: '交易所第一幕', summary: '玩家在交易大厅被异常账本牵住。', sceneTaskDescription: '查清异常账本指向谁。', linkedThreadIds: [], linkedLandmarkIds: [], acts: [ { id: 'act-1', sceneId: 'custom-scene-camp', title: '第一幕', summary: '陆衡先开口试探玩家。', stageCoverage: ['opening'], encounterNpcIds: ['沈砺旧识', '陆衡'], primaryNpcId: '沈砺旧识', oppositeNpcId: options.openingOppositeNpcId ?? '陆衡', eventDescription: '陆衡拿着异常账本,在开盘前拦住玩家。', linkedThreadIds: [], advanceRule: 'after_primary_contact', actGoal: '确认异常账本的第一条线索。', transitionHook: '账本指向旧灯塔的潮痕。', }, ], }, { id: 'chapter-late', sceneId: 'landmark-2', title: '雾栈后续幕', summary: '后续场景不应抢走开局。', sceneTaskDescription: '处理雾栈尽头的后续问题。', linkedThreadIds: [], linkedLandmarkIds: ['landmark-2'], acts: [ { id: 'act-late', sceneId: 'landmark-2', title: '后续幕', summary: '雾栈里有人影闪过。', stageCoverage: ['aftermath'], encounterNpcIds: ['story-1'], primaryNpcId: 'story-1', oppositeNpcId: 'story-1', eventDescription: '后续角色在雾栈尽头等待。', linkedThreadIds: [], advanceRule: 'after_active_step_complete', actGoal: '后续推进。', transitionHook: '继续深入雾栈。', }, ], }, ], scenarioPackId: 'scenario-pack:tide', campaignPackId: 'campaign-pack:tide', generationMode: 'full', generationStatus: 'complete', }); if (!profile) { throw new Error('failed to build saved custom world profile'); } return profile; } function readSnapshot() { const raw = screen.getByTestId('state-snapshot').textContent ?? '{}'; return JSON.parse(raw) as { worldType: string | null; currentScene: string; profileName: string | null; activeScenarioPackId: string | null; activeCampaignPackId: string | null; currentScenePresetId: string | null; currentScenePresetName: string | null; currentSceneConnectedIds: string[]; currentSceneActId: string | null; currentEncounterId: string | null; currentEncounterName: string | null; currentStoryDisplayMode: string | null; currentStoryNpcName: string | null; currentStoryDialogueTexts: string[]; isStoryLoading: boolean; firstLandmarkResidueTitle: string | null; playerCharacterName: string | null; runtimeMode: string | null; runtimePersistenceDisabled: boolean; playerInventoryNames: string[]; playerEquipment: { weapon: string | null; armor: string | null; relic: string | null; }; }; } function findRuntimeNpc(profile: ReturnType) { const npc = profile.storyNpcs.find((candidate) => candidate.id === 'story-act-only'); if (!npc) { throw new Error('test npc story-act-only not found'); } return npc; } function buildRuntimeStoryBootstrapSnapshot(params: { profile: ReturnType; character: NonNullable[number]>; }) { const npc = findRuntimeNpc(params.profile); const playableSource = params.profile.playableNpcs.find( (candidate) => candidate.id === params.character.id, ); const initialItems = playableSource?.initialItems ?? []; const currentScenePreset = { id: 'custom-scene-camp', name: '回潮暂栖所', description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', imageSrc: '', connectedSceneIds: ['custom-scene-landmark-1', 'custom-scene-landmark-2'], }; const weapon = initialItems.find( (item) => item.id === 'item-playable-1', ); const relic = initialItems.find( (item) => item.id === 'item-playable-3', ); return { sessionId: 'runtime-main', serverVersion: 1, snapshot: { version: 2, savedAt: '2026-04-29T00:00:00.000Z', bottomTab: 'adventure', currentStory: null, gameState: { worldType: WorldType.CUSTOM, customWorldProfile: params.profile, playerCharacter: params.character, runtimeSessionId: 'runtime-main', storySessionId: 'storysess-main', runtimeActionVersion: 1, runtimeMode: 'play', runtimePersistenceDisabled: false, 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: [], storyEngineMemory: { discoveredFactIds: [], inferredFactIds: [], activeThreadIds: [], resolvedScarIds: [], recentCarrierIds: [], openedSceneChapterIds: ['chapter-1'], currentSceneActState: { sceneId: 'custom-scene-camp', chapterId: 'chapter-1', currentActId: 'act-1', currentActIndex: 0, completedActIds: [], visitedActIds: ['act-1'], }, }, chapterState: null, campaignState: null, activeScenarioPackId: 'scenario-pack:tide', activeCampaignPackId: 'campaign-pack:tide', characterChats: {}, lastObserveSignsSceneId: null, lastObserveSignsReport: null, animationState: 'idle', currentEncounter: { id: 'story-act-only', kind: 'npc', npcName: npc.name, npcDescription: npc.description, npcAvatar: '', context: '陆衡拿着异常账本,在开盘前拦住玩家。', characterId: npc.id, initialAffinity: npc.initialAffinity, title: npc.title, backstory: npc.backstory, personality: npc.personality, motivation: npc.motivation, combatStyle: npc.combatStyle, relationshipHooks: npc.relationshipHooks, tags: npc.tags, backstoryReveal: npc.backstoryReveal, skills: npc.skills, initialItems: npc.initialItems, narrativeProfile: npc.narrativeProfile, }, npcInteractionActive: false, currentScenePreset, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 180, playerMaxHp: 180, playerMana: 0, playerMaxMana: 0, playerSkillCooldowns: {}, activeBuildBuffs: [], activeCombatEffects: [], playerCurrency: 0, playerInventory: initialItems, playerEquipment: { weapon: weapon ?? null, armor: { id: 'test-armor', category: '防具', name: '潮雾外衣', quantity: 1, rarity: 'common', tags: ['防具'], equipmentSlotId: 'armor', }, relic: relic ?? null, }, npcStates: {}, quests: [], roster: [], companions: [], currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }, }, }; } function GameFlowHarness({ openingOppositeNpcId, }: { openingOppositeNpcId?: string; } = {}) { const profile = useMemo( () => buildSavedProfile({ openingOppositeNpcId }), [openingOppositeNpcId], ); const playableCharacters = useMemo( () => buildCustomWorldPlayableCharacters(profile), [profile], ); const selectedCharacter = playableCharacters[0] ?? null; if (selectedCharacter) { rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession.mockResolvedValue( buildRuntimeStoryBootstrapSnapshot({ profile, character: selectedCharacter, }), ); } const { gameState, setGameState, handleCustomWorldSelect, handleCharacterSelect, } = useRpgSessionBootstrap(); const story = useRpgRuntimeStory({ gameState, setGameState, buildResolvedChoiceState: () => ({}) as never, playResolvedChoice: async (state) => state, }); const snapshot = { worldType: gameState.worldType, currentScene: gameState.currentScene, profileName: gameState.customWorldProfile?.name ?? null, activeScenarioPackId: gameState.activeScenarioPackId ?? null, activeCampaignPackId: gameState.activeCampaignPackId ?? null, currentScenePresetId: gameState.currentScenePreset?.id ?? null, currentScenePresetName: gameState.currentScenePreset?.name ?? null, currentSceneConnectedIds: gameState.currentScenePreset?.connectedSceneIds ?? [], currentSceneActId: gameState.storyEngineMemory?.currentSceneActState?.currentActId ?? null, currentEncounterId: gameState.currentEncounter?.id ?? null, currentEncounterName: gameState.currentEncounter?.npcName ?? null, currentStoryDisplayMode: story.currentStory?.displayMode ?? null, currentStoryNpcName: story.currentStory?.npcChatState?.npcName ?? null, currentStoryDialogueTexts: story.currentStory?.dialogue?.map((entry) => entry.text) ?? [], isStoryLoading: story.isLoading, firstLandmarkResidueTitle: gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0] ?.title ?? null, playerCharacterName: gameState.playerCharacter?.name ?? null, runtimeMode: gameState.runtimeMode ?? null, runtimePersistenceDisabled: gameState.runtimePersistenceDisabled === true, playerInventoryNames: gameState.playerInventory.map((item) => item.name), playerEquipment: { weapon: gameState.playerEquipment.weapon?.name ?? null, armor: gameState.playerEquipment.armor?.name ?? null, relic: gameState.playerEquipment.relic?.name ?? null, }, }; return (
{JSON.stringify(snapshot)}
); } afterEach(() => { setRuntimeCustomWorldProfile(null); setRuntimeCharacterOverrides(null); }); beforeEach(() => { aiServiceMocks.streamNpcChatTurn.mockReset(); aiServiceMocks.streamNpcChatTurn.mockResolvedValue({ affinityDelta: 0, affinityText: '这轮对话暂时没有带来明显关系变化。', npcReply: '开盘前别靠近账本。你先告诉我,是谁让你来查这笔异常资金?', suggestions: ['我先说明来意', '你先说账本哪里异常', '我不是来抢账本的'], }); }); test('saved custom world result settings flow into game state after entering the world', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '选择世界' })); await waitFor(() => { expect(readSnapshot().worldType).toBe(WorldType.CUSTOM); }); expect(readSnapshot().profileName).toBe('回潮群岛'); expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp'); expect(readSnapshot().currentScenePresetName).toBe('回潮暂栖所'); expect(readSnapshot().currentSceneConnectedIds).toContain( 'custom-scene-landmark-1', ); expect(readSnapshot().firstLandmarkResidueTitle).toBe('潮痕'); expect(readSnapshot().activeScenarioPackId).toBe('scenario-pack:tide'); expect(readSnapshot().activeCampaignPackId).toBe('campaign-pack:tide'); await user.click(screen.getByRole('button', { name: '确认角色' })); await waitFor(() => { expect(readSnapshot().currentScene).toBe('Story'); }); expect(readSnapshot().playerCharacterName).toBe('沈砺'); expect(readSnapshot().runtimeMode).toBe('play'); expect(readSnapshot().runtimePersistenceDisabled).toBe(false); expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃'); expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页'); expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃'); expect(readSnapshot().playerEquipment.relic).toBe('旧潮图残页'); expect(readSnapshot().playerEquipment.armor).toBeTruthy(); expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp'); expect(readSnapshot().currentSceneActId).toBe('act-1'); expect(readSnapshot().currentEncounterId).toBe('story-act-only'); expect(readSnapshot().currentEncounterName).toBe('陆衡'); expect(readSnapshot().currentEncounterId).not.toBe('story-primary-only'); await waitFor(() => { expect(readSnapshot().currentStoryNpcName).toBe('陆衡'); }); expect(readSnapshot().currentStoryDisplayMode).toBe('dialogue'); expect(readSnapshot().currentStoryDialogueTexts).toContain( '开盘前别靠近账本。你先告诉我,是谁让你来查这笔异常资金?', ); expect(aiServiceMocks.streamNpcChatTurn).toHaveBeenCalledWith( WorldType.CUSTOM, expect.objectContaining({ name: '沈砺' }), expect.objectContaining({ id: 'story-act-only', npcName: '陆衡' }), expect.anything(), expect.anything(), expect.anything(), [], '', expect.anything(), expect.objectContaining({ npcInitiatesConversation: true, }), ); }); test('custom world opening act accepts runtime npc id references and still starts configured npc chat', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '选择世界' })); await waitFor(() => { expect(readSnapshot().worldType).toBe(WorldType.CUSTOM); }); await user.click(screen.getByRole('button', { name: '确认角色' })); await waitFor(() => { expect(readSnapshot().currentEncounterId).toBe('story-act-only'); }); expect(readSnapshot().currentEncounterName).toBe('陆衡'); await waitFor(() => { expect(readSnapshot().currentStoryNpcName).toBe('陆衡'); }); expect(aiServiceMocks.streamNpcChatTurn).toHaveBeenCalledWith( WorldType.CUSTOM, expect.objectContaining({ name: '沈砺' }), expect.objectContaining({ id: 'story-act-only', npcName: '陆衡' }), expect.anything(), expect.anything(), expect.anything(), [], '', expect.anything(), expect.objectContaining({ npcInitiatesConversation: true, }), ); });