/* @vitest-environment jsdom */ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useMemo } from 'react'; import { afterEach, expect, test } from 'vitest'; import { buildCustomWorldPlayableCharacters, setRuntimeCharacterOverrides, } from '../data/characterPresets'; import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime'; import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary'; import { WorldType } from '../types'; import { useRpgSessionBootstrap } from './rpg-session'; 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() { 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, schemaName: '测试', 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: ['原始灯册', '封灯令'], }, }, ], items: [], camp: { name: '回潮暂栖所', description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', }, landmarks: [ { id: 'landmark-1', name: '回潮旧灯塔', description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', sceneNpcIds: ['story-1'], 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: '退回灯塔还能重新整理路线。', }, ], }, ], 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[]; firstLandmarkResidueTitle: string | null; playerCharacterName: string | null; playerInventoryNames: string[]; playerEquipment: { weapon: string | null; armor: string | null; relic: string | null; }; }; } function GameFlowHarness() { const profile = useMemo(() => buildSavedProfile(), []); const playableCharacters = useMemo( () => buildCustomWorldPlayableCharacters(profile), [profile], ); const selectedCharacter = playableCharacters[0] ?? null; const { gameState, handleCustomWorldSelect, handleCharacterSelect } = useRpgSessionBootstrap(); 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 ?? [], firstLandmarkResidueTitle: gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0] ?.title ?? null, playerCharacterName: gameState.playerCharacter?.name ?? null, 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)}