/* @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 runtimeStoryClientMocks = vi.hoisted(() => ({
  beginRuntimeStorySession: vi.fn(),
}));

vi.mock('../services/aiService', async () => {
  const actual =
    await vi.importActual<typeof import('../services/aiService')>(
      '../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,
    beginRuntimeStorySession:
      runtimeStoryClientMocks.beginRuntimeStorySession,
  };
});

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<typeof buildSavedProfile>) {
  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<typeof buildSavedProfile>;
  character: NonNullable<ReturnType<typeof buildCustomWorldPlayableCharacters>[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) {
    runtimeStoryClientMocks.beginRuntimeStorySession.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 (
    <div>
      <button
        type="button"
        onClick={() => handleCustomWorldSelect(profile, { mode: 'play' })}
      >
        选择世界
      </button>
      <button
        type="button"
        onClick={() => {
          if (selectedCharacter) {
            handleCharacterSelect(selectedCharacter);
          }
        }}
      >
        确认角色
      </button>
      <pre data-testid="state-snapshot">{JSON.stringify(snapshot)}</pre>
    </div>
  );
}

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(<GameFlowHarness />);

  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(<GameFlowHarness openingOppositeNpcId="character-npc-story-act-only" />);

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

test('switching between custom worlds sends the newly selected profile to runtime bootstrap', async () => {
  const user = userEvent.setup();
  const oldProfile = buildSavedProfile();
  const openedDraftProfile = normalizeCustomWorldProfileRecord({
    ...oldProfile,
    id: 'opened-draft-profile',
    name: '星砂废都',
    subtitle: '坠星沙海与废都钟楼',
    summary: '本次从草稿架打开并启动的目标草稿。',
    settingText: '星砂覆盖旧废都，钟楼下埋着旧约。',
    playableNpcs: [
      {
        ...oldProfile.playableNpcs[0],
        id: 'opened-playable-1',
        name: '砂眠',
        title: '废都引路人',
      },
    ],
  });

  if (!openedDraftProfile) {
    throw new Error('failed to build opened draft profile');
  }
  const normalizedOpenedDraftProfile = openedDraftProfile;

  function SwitchWorldHarness() {
    const {
      gameState,
      handleCustomWorldSelect,
      handleCharacterSelect,
    } = useRpgSessionBootstrap();
    const openedCharacters = buildCustomWorldPlayableCharacters(
      normalizedOpenedDraftProfile,
    );
    const selectedCharacter = openedCharacters[0] ?? null;

    return (
      <div>
        <button
          type="button"
          onClick={() => handleCustomWorldSelect(oldProfile, { mode: 'play' })}
        >
          选择旧世界
        </button>
        <button
          type="button"
          onClick={() =>
            handleCustomWorldSelect(normalizedOpenedDraftProfile, {
              mode: 'play',
            })
          }
        >
          选择打开草稿
        </button>
        <button
          type="button"
          onClick={() => {
            if (selectedCharacter) {
              handleCharacterSelect(selectedCharacter);
            }
          }}
        >
          确认草稿角色
        </button>
        <pre data-testid="state-snapshot">
          {JSON.stringify({
            profileId: gameState.customWorldProfile?.id ?? null,
            profileName: gameState.customWorldProfile?.name ?? null,
          })}
        </pre>
      </div>
    );
  }

  runtimeStoryClientMocks.beginRuntimeStorySession.mockResolvedValue(
    buildRuntimeStoryBootstrapSnapshot({
      profile: normalizedOpenedDraftProfile,
      character: buildCustomWorldPlayableCharacters(
        normalizedOpenedDraftProfile,
      )[0]!,
    }),
  );

  render(<SwitchWorldHarness />);

  await user.click(screen.getByRole('button', { name: '选择旧世界' }));
  await waitFor(() => {
    expect(
      JSON.parse(screen.getByTestId('state-snapshot').textContent ?? '{}')
        .profileName,
    ).toBe('回潮群岛');
  });

  await user.click(screen.getByRole('button', { name: '选择打开草稿' }));
  await waitFor(() => {
    expect(
      JSON.parse(screen.getByTestId('state-snapshot').textContent ?? '{}')
        .profileName,
    ).toBe('星砂废都');
  });

  await user.click(screen.getByRole('button', { name: '确认草稿角色' }));

  await waitFor(() => {
    expect(runtimeStoryClientMocks.beginRuntimeStorySession).toHaveBeenCalledWith(
      expect.objectContaining({
        customWorldProfile: expect.objectContaining({
          id: 'opened-draft-profile',
          name: '星砂废都',
          summary: '本次从草稿架打开并启动的目标草稿。',
        }),
        character: expect.objectContaining({
          id: 'opened-playable-1',
          name: '砂眠',
        }),
      }),
    );
  });
});
