#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import time
from pathlib import Path
from urllib.parse import urlencode, urljoin

from verify_mobile_browser_controls import (
    WebDriverError,
    execute_js,
    free_port,
    request_json,
    wait_for_driver,
    wait_for_map_runtime,
    wait_for_page,
)

ROOT = Path(__file__).resolve().parents[1]


def load_map(base: str, port: int, session_id: str, params: dict[str, str]) -> str:
    query = urlencode({**params, "_": str(time.time_ns())})
    url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{query}")
    request_json(port, "POST", f"/session/{session_id}/url", {"url": url}, timeout=30)
    wait_for_page(port, session_id)
    wait_for_map_runtime(port, session_id, params["map"])
    return url


def title_state_script() -> str:
    return """
const titleMenu = typeof titleMenuItems === 'function' ? titleMenuItems() : [];
return {
  readyState: document.readyState,
  href: location.href,
  search: location.search,
  hasScreen: !!document.getElementById('screen'),
  scene: typeof scene === 'undefined' ? '' : scene,
  mapName: typeof map === 'undefined' || !map ? '' : map.name,
  titleLoaded: !!images?.title?.complete && (images.title.naturalWidth || 0) > 0,
  quickLoadHidden: document.getElementById('quickLoadButton')?.hidden ?? null,
  quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
  titleMenuMode: typeof titleMenuMode === 'undefined' ? '' : titleMenuMode,
  titleMenuKeys: titleMenu.map((item) => item.key),
  titleMenuLabels: titleMenu.map((item) => item.label),
  selectedTitleMenuIndex: typeof selectedTitleMenuIndex === 'undefined' ? null : selectedTitleMenuIndex,
  bootError: window.HWANSE_BOOT_ERROR || null,
};
"""


def click_title_continue_script() -> str:
    return """
if (typeof updateRuntimeSaveControls === 'function') updateRuntimeSaveControls();
const items = typeof titleMenuItems === 'function' ? titleMenuItems() : [];
const button = document.getElementById('quickLoadButton');
if (!button || button.hidden || button.textContent !== '이어하기' || !items.some((item) => item.key === 'continue')) {
  return {
    ok: false,
    hidden: button?.hidden ?? null,
    text: button?.textContent || '',
    titleMenuKeys: items.map((item) => item.key),
    titleMenuLabels: items.map((item) => item.label),
  };
}
const index = items.findIndex((item) => item.key === 'continue');
selectedTitleMenuIndex = index;
activateSelectedTitleMenuItem();
return {
  ok: index >= 0,
  text: button.textContent,
  title: button.title,
  titleMenuKeys: items.map((item) => item.key),
  titleMenuLabels: items.map((item) => item.label),
  selectedTitleMenuIndex,
};
"""


def start_party_join_script() -> str:
    return """
window.__hwanseCandidatePartyJoin = null;
localStorage.removeItem(RUNTIME_SAVE_KEY);
prototypeProgress = createPrototypeProgressState();
publishPrototypeProgress();
window.HWANSE_PARTY_JOIN_FEEDBACK_LOG = [];
window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER = [];
window.HWANSE_LAST_PARTY_JOIN_FEEDBACK = null;
window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER = null;
if (typeof activePartyJoinFeedbacks !== 'undefined') activePartyJoinFeedbacks = [];
setManualPartyMembers([], { notice: false })
  .then(() => {
    const clone = (value) => JSON.parse(JSON.stringify(value || null));
    const menuBefore = commandMenuItems().map((item) => ({
      key: item.key,
      name: item.name,
      command: item.command,
      memberKey: item.memberKey || '',
      usable: item.usable,
    }));
    return joinPrototypePartyMember('rinshan')
      .then((joinedRinshan) => joinPrototypePartyMember('smash')
        .then((joinedSmash) => {
          render();
          const partyJoinFeedbackLog = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_LOG || []);
          const partyJoinFeedbackRender = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER || []);
          const partyJoinFeedbackLast = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK || null);
          const partyJoinFeedbackLastRender = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER || null);
          const autoSave = window.HWANSE_LAST_PARTY_JOIN_AUTO_SAVE || null;
          const payload = JSON.parse(localStorage.getItem(RUNTIME_SAVE_KEY) || '{}');
          startPrototypeProgressReview();
          const reviewBlock = activeDialogue ? {
            blockId: activeDialogue.block?.blockId || '',
            lines: activeDialogue.lines?.slice(0, 12) || [],
          } : null;
          activeDialogue = null;
          const objectiveBefore = prototypeObjectiveState();
          window.HWANSE_LAST_OBJECTIVE_ACTION = null;
          window.HWANSE_LAST_PARTY_COMPLETION_NOTICE = null;
          window.HWANSE_PARTY_JOIN_FEEDBACK_LOG = [];
          window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER = [];
          window.HWANSE_LAST_PARTY_JOIN_FEEDBACK = null;
          window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER = null;
          if (typeof activePartyJoinFeedbacks !== 'undefined') activePartyJoinFeedbacks = [];
          const objectiveResult = activatePrototypeObjectiveAction();
          render();
          const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
          const objectiveCompletionNotice = window.HWANSE_LAST_PARTY_COMPLETION_NOTICE || null;
          const objectivePartyJoinFeedbackLog = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_LOG || []);
          const objectivePartyJoinFeedbackRender = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER || []);
          const objectivePartyJoinFeedbackLast = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK || null);
          const objectivePartyJoinFeedbackLastRender = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER || null);
          const objectiveActiveDialogueBlock = activeDialogue ? {
            blockId: activeDialogue.block?.blockId || '',
            line: activeDialogue.lines?.[activeDialogue.index] || '',
            lineCount: activeDialogue.lines?.length || 0,
          } : null;
          activeDialogue = null;
          const menuAfter = commandMenuItems().map((item) => ({
            key: item.key,
            name: item.name,
            command: item.command,
            memberKey: item.memberKey || '',
            usable: item.usable,
          }));
          window.__hwanseCandidatePartyJoin = {
            joinedRinshan,
            joinedSmash,
            saved: autoSave?.saved === true,
            autoSave,
            scene,
            map: map?.name || '',
            manualLabel: manualPartyLabel(),
            members: activePartyMembers().map((member) => member.name),
            labels: activePartyMembers().map((member) => member.label),
            search: window.location.search,
            marker: window.HWANSE_LAST_PARTY_JOIN || null,
            partyJoinFeedbackLog,
            partyJoinFeedbackRender,
            partyJoinFeedbackLast,
            partyJoinFeedbackLastRender,
            progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
            completion: window.HWANSE_LAST_PROTOTYPE_COMPLETION || null,
            savedManualParty: payload.manualParty || null,
            savedProgress: payload.prototypeProgress || null,
            menuBefore,
            menuAfter,
            reviewBlock,
            objectiveBefore,
            objectiveResult,
            objectiveAction,
            objectiveCompletionNotice,
            objectivePartyJoinFeedbackLog,
            objectivePartyJoinFeedbackRender,
            objectivePartyJoinFeedbackLast,
            objectivePartyJoinFeedbackLastRender,
            objectiveActiveDialogueBlock,
          };
        }));
  })
  .catch((error) => {
    window.__hwanseCandidatePartyJoin = { error: String(error && error.message || error) };
  });
return true;
"""


def start_party_join_menu_script() -> str:
    return """
window.__hwanseCandidatePartyJoinMenu = null;
localStorage.removeItem(RUNTIME_SAVE_KEY);
prototypeProgress = createPrototypeProgressState();
publishPrototypeProgress();
window.HWANSE_PARTY_JOIN_FEEDBACK_LOG = [];
window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER = [];
window.HWANSE_LAST_PARTY_JOIN_FEEDBACK = null;
window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER = null;
window.HWANSE_LAST_PARTY_JOIN_CANDIDATE_SELECT_MENU = null;
window.HWANSE_LAST_PARTY_JOIN_CANDIDATE_MENU_SELECTION = null;
if (typeof activePartyJoinFeedbacks !== 'undefined') activePartyJoinFeedbacks = [];
setManualPartyMembers([], { notice: false })
  .then(() => {
    const clone = (value) => JSON.parse(JSON.stringify(value || null));
    menuMode = 'main';
    menuOpen = true;
    selectedMenuItemIndex = 0;
    const beforeItems = commandMenuItems().map((item) => ({
      key: item.key,
      name: item.name,
      command: item.command,
      memberKey: item.memberKey || '',
      usable: item.usable,
    }));
    const commandIndex = menuItems().findIndex((item) => item.command === 'openPartyJoinCandidateMenu');
    if (commandIndex < 0) throw new Error('missing party join candidate command');
    selectedMenuItemIndex = commandIndex;
    const commandResult = useSelectedMenuItem();
    const afterOpenItems = menuItems().map((item) => ({
      key: item.key,
      name: item.name,
      command: item.command,
      memberKey: item.memberKey || '',
      menuCandidateIndex: item.menuCandidateIndex ?? null,
      menuCandidateCount: item.menuCandidateCount ?? null,
      usable: item.usable,
    }));
    const targetIndex = afterOpenItems.findIndex((item) =>
      item.command === 'selectPartyJoinCandidate' && item.memberKey === 'smash'
    );
    if (targetIndex < 0) throw new Error('missing smash party join candidate');
    const targetName = afterOpenItems[targetIndex]?.name || '';
    selectedMenuItemIndex = targetIndex;
    window.HWANSE_PARTY_JOIN_FEEDBACK_LOG = [];
    window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER = [];
    window.HWANSE_LAST_PARTY_JOIN_FEEDBACK = null;
    window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER = null;
    if (typeof activePartyJoinFeedbacks !== 'undefined') activePartyJoinFeedbacks = [];
    const selectResult = useSelectedMenuItem();
    const selectionPromise = window.HWANSE_LAST_PARTY_JOIN_CANDIDATE_MENU_SELECTION_PROMISE || Promise.resolve(null);
    return selectionPromise.then(() => {
      render();
      const payload = JSON.parse(localStorage.getItem(RUNTIME_SAVE_KEY) || '{}');
      const afterSelectItems = commandMenuItems().map((item) => ({
        key: item.key,
        name: item.name,
        command: item.command,
        memberKey: item.memberKey || '',
        usable: item.usable,
      }));
      window.__hwanseCandidatePartyJoinMenu = {
        commandResult,
        selectResult,
        beforeItems,
        afterOpenMenuMode: 'party-candidate',
        afterOpenItems,
        targetName,
        marker: clone(window.HWANSE_LAST_PARTY_JOIN_CANDIDATE_SELECT_MENU || null),
        selection: clone(window.HWANSE_LAST_PARTY_JOIN_CANDIDATE_MENU_SELECTION || null),
        join: clone(window.HWANSE_LAST_PARTY_JOIN || null),
        autoSave: clone(window.HWANSE_LAST_PARTY_JOIN_AUTO_SAVE || null),
        partyJoinFeedbackLog: clone(window.HWANSE_PARTY_JOIN_FEEDBACK_LOG || []),
        partyJoinFeedbackRender: clone(window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER || []),
        partyJoinFeedbackLast: clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK || null),
        partyJoinFeedbackLastRender: clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER || null),
        progress: clone(window.HWANSE_LAST_PROTOTYPE_PROGRESS || null),
        savedProgress: payload.prototypeProgress || null,
        savedManualParty: payload.manualParty || null,
        afterSelectItems,
        menuMode,
        menuOpen,
        manualLabel: manualPartyLabel(),
        members: activePartyMembers().map((member) => member.name),
        labels: activePartyMembers().map((member) => member.label),
        playHudLines: [...(window.HWANSE_LAST_PLAY_HUD_LINES || [])],
      };
    });
  })
  .catch((error) => {
    window.__hwanseCandidatePartyJoinMenu = { error: String(error && error.message || error) };
  });
return true;
"""


def party_join_state_script() -> str:
    return "return window.__hwanseCandidatePartyJoin || null;"


def party_join_menu_state_script() -> str:
    return "return window.__hwanseCandidatePartyJoinMenu || null;"


def start_party_follower_render_script() -> str:
    return """
window.__hwanseCandidatePartyFollowerRender = null;
Promise.resolve()
  .then(() => Promise.all(activePartyMembers().map((member) => ensureImage(member.sprite))))
  .then(() => {
    activeDialogue = null;
    menuOpen = false;
    resetPartyTrail();
    render();
    const clone = (value) => JSON.parse(JSON.stringify(value || null));
    const insideTransition = (tile) => (map.transitions || []).some((entry) =>
      tile.x >= entry.x &&
      tile.x < entry.x + entry.width &&
      tile.y >= entry.y &&
      tile.y < entry.y + entry.height
    );
    const directionNameForVector = (dx, dy) => dx
      ? (dx > 0 ? 'right' : 'left')
      : (dy > 0 ? 'down' : 'up');
    const pickMovement = () => {
      const tile = footTile();
      const options = [
        [1, 0],
        [0, 1],
        [-1, 0],
        [0, -1],
      ];
      for (const [dx, dy] of options) {
        const movement = chooseMovementStep(tile, dx, dy);
        if (movement && !insideTransition(movement.nextTile)) return movement;
      }
      return null;
    };
    const before = clone(window.HWANSE_LAST_PARTY_RENDER);
    const steps = [];
    for (let index = 0; index < 7; index += 1) {
      const movement = pickMovement();
      if (!movement) break;
      player.dir = movement.dx ? (movement.dx > 0 ? 2 : 1) : (movement.dy > 0 ? 0 : 3);
      recordPartyTrailPosition(movement.target);
      player.step = {
        fromX: player.x,
        fromY: player.y,
        toX: movement.target.x,
        toY: movement.target.y,
        elapsed: 0,
        duration: TILE_STEP_SECONDS,
        walkScriptStartPhase: player.walkScriptPhase,
      };
      player.moving = true;
      player.frame = PLAYER_FIRST_WALK_FRAME;
      update(TILE_STEP_SECONDS + 0.01);
      render();
      steps.push({
        index: index + 1,
        direction: directionNameForVector(movement.dx, movement.dy),
        map: map.name,
        tile: footTile(),
        render: clone(window.HWANSE_LAST_PARTY_RENDER),
      });
      if (map.name !== 'map1_02b') break;
    }
    const after = clone(window.HWANSE_LAST_PARTY_RENDER);
    const afterActors = after?.actors || [];
    const playerActor = afterActors.find((actor) => actor.sprite === 'player') || null;
    const followerActors = afterActors.filter((actor) => String(actor.sprite || '').startsWith('party_'));
    const actorCollisionTargets = followerActors.map((actor) => {
      const passable = canStandAt(actor.x, actor.y);
      const overlapBlocked = activeActorCollisionBlocks(actor.x, actor.y);
      return {
        sprite: actor.sprite,
        x: actor.x,
        y: actor.y,
        passable,
        overlapBlocked,
        canMoveTo: canMoveToTarget(actor.x, actor.y, 0, 0),
        footprint: standFootprintTiles(actor.x, actor.y),
      };
    });
    const playerCollisionCheck = playerActor ? {
      passable: canStandAt(playerActor.x, playerActor.y),
      overlapBlocked: activeActorCollisionBlocks(playerActor.x, playerActor.y),
      canMoveTo: canMoveToTarget(playerActor.x, playerActor.y, 0, 0),
      footprint: standFootprintTiles(playerActor.x, playerActor.y),
    } : null;
    window.__hwanseCandidatePartyFollowerRender = {
      ok: true,
      map: map.name,
      before,
      after,
      steps,
      stepCount: steps.length,
      members: activePartyMembers().map((member) => member.name),
      labels: activePartyMembers().map((member) => member.label),
      trailFilledCount: (partyTrail || []).filter(Boolean).length,
      trailCursors: [...partyTrailCursors],
      actorCollisionEnabled,
      actorCollisionTargets,
      playerCollisionCheck,
      actorCollisionTargetCount: actorCollisionTargets.filter((row) =>
        row.passable === true && row.overlapBlocked === true && row.canMoveTo === false
      ).length,
      source: after?.source || '',
    };
  })
  .catch((error) => {
    window.__hwanseCandidatePartyFollowerRender = { ok: false, error: String(error && error.message || error) };
  });
return true;
"""


def party_follower_render_state_script() -> str:
    return "return window.__hwanseCandidatePartyFollowerRender || null;"


def start_party_restore_script() -> str:
    return """
window.__hwanseCandidatePartyRestore = null;
quickLoadRuntime()
  .then((loaded) => {
    const clone = (value) => JSON.parse(JSON.stringify(value || null));
    startPrototypeProgressReview();
    const reviewBlock = activeDialogue ? {
      blockId: activeDialogue.block?.blockId || '',
      lines: activeDialogue.lines?.slice(0, 12) || [],
    } : null;
    activeDialogue = null;
    const objectiveBefore = prototypeObjectiveState();
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_PARTY_COMPLETION_NOTICE = null;
    window.HWANSE_PARTY_JOIN_FEEDBACK_LOG = [];
    window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER = [];
    window.HWANSE_LAST_PARTY_JOIN_FEEDBACK = null;
    window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER = null;
    if (typeof activePartyJoinFeedbacks !== 'undefined') activePartyJoinFeedbacks = [];
    const objectiveResult = activatePrototypeObjectiveAction();
    render();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_PARTY_COMPLETION_NOTICE || null;
    const objectivePartyJoinFeedbackLog = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_LOG || []);
    const objectivePartyJoinFeedbackRender = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER || []);
    const objectivePartyJoinFeedbackLast = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK || null);
    const objectivePartyJoinFeedbackLastRender = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER || null);
    const objectiveActiveDialogueBlock = activeDialogue ? {
      blockId: activeDialogue.block?.blockId || '',
      line: activeDialogue.lines?.[activeDialogue.index] || '',
      lineCount: activeDialogue.lines?.length || 0,
    } : null;
    activeDialogue = null;
    window.__hwanseCandidatePartyRestore = {
      loaded,
      scene,
      map: map?.name || '',
      manualLabel: manualPartyLabel(),
      members: activePartyMembers().map((member) => member.name),
      labels: activePartyMembers().map((member) => member.label),
      search: window.location.search,
      progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
      completion: window.HWANSE_LAST_PROTOTYPE_COMPLETION || null,
      menuCommands: commandMenuItems().map((item) => ({
        key: item.key,
        name: item.name,
        command: item.command,
        memberKey: item.memberKey || '',
        usable: item.usable,
      })),
      reviewBlock,
      objectiveBefore,
      objectiveResult,
      objectiveAction,
      objectiveCompletionNotice,
      objectivePartyJoinFeedbackLog,
      objectivePartyJoinFeedbackRender,
      objectivePartyJoinFeedbackLast,
      objectivePartyJoinFeedbackLastRender,
      objectiveActiveDialogueBlock,
    };
  })
  .catch((error) => {
    window.__hwanseCandidatePartyRestore = { error: String(error && error.message || error) };
  });
return true;
"""


def party_restore_state_script() -> str:
    return "return window.__hwanseCandidatePartyRestore || null;"


def capture_party_join_after_title_continue_script() -> str:
    return """
window.__hwanseCandidatePartyTitleRestore = null;
Promise.resolve()
  .then(() => {
    const clone = (value) => JSON.parse(JSON.stringify(value || null));
    startPrototypeProgressReview();
    const reviewBlock = activeDialogue ? {
      blockId: activeDialogue.block?.blockId || '',
      lines: activeDialogue.lines?.slice(0, 12) || [],
    } : null;
    activeDialogue = null;
    const objectiveBefore = prototypeObjectiveState();
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_PARTY_COMPLETION_NOTICE = null;
    window.HWANSE_PARTY_JOIN_FEEDBACK_LOG = [];
    window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER = [];
    window.HWANSE_LAST_PARTY_JOIN_FEEDBACK = null;
    window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER = null;
    if (typeof activePartyJoinFeedbacks !== 'undefined') activePartyJoinFeedbacks = [];
    const objectiveResult = activatePrototypeObjectiveAction();
    render();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_PARTY_COMPLETION_NOTICE || null;
    const objectivePartyJoinFeedbackLog = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_LOG || []);
    const objectivePartyJoinFeedbackRender = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER || []);
    const objectivePartyJoinFeedbackLast = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK || null);
    const objectivePartyJoinFeedbackLastRender = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER || null);
    const objectiveActiveDialogueBlock = activeDialogue ? {
      blockId: activeDialogue.block?.blockId || '',
      line: activeDialogue.lines?.[activeDialogue.index] || '',
      lineCount: activeDialogue.lines?.length || 0,
    } : null;
    activeDialogue = null;
    const text = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
    let savedPayload = null;
    try {
      savedPayload = text ? JSON.parse(text) : null;
    } catch (error) {
      savedPayload = { error: String(error && error.message || error) };
    }
    window.__hwanseCandidatePartyTitleRestore = {
      loaded: true,
      titleContinue: true,
      scene,
      map: map?.name || '',
      manualLabel: manualPartyLabel(),
      members: activePartyMembers().map((member) => member.name),
      labels: activePartyMembers().map((member) => member.label),
      search: window.location.search,
      progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
      completion: window.HWANSE_LAST_PROTOTYPE_COMPLETION || null,
      menuCommands: commandMenuItems().map((item) => ({
        key: item.key,
        name: item.name,
        command: item.command,
        memberKey: item.memberKey || '',
        usable: item.usable,
      })),
      reviewBlock,
      savedPayload,
      quickLoadHidden: document.getElementById('quickLoadButton')?.hidden ?? null,
      quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
      objectiveBefore,
      objectiveResult,
      objectiveAction,
      objectiveCompletionNotice,
      objectivePartyJoinFeedbackLog,
      objectivePartyJoinFeedbackRender,
      objectivePartyJoinFeedbackLast,
      objectivePartyJoinFeedbackLastRender,
      objectiveActiveDialogueBlock,
    };
  })
  .catch((error) => {
    window.__hwanseCandidatePartyTitleRestore = { error: String(error && error.message || error) };
  });
return true;
"""


def party_title_restore_state_script() -> str:
    return "return window.__hwanseCandidatePartyTitleRestore || null;"


def start_dialogue_party_join_script() -> str:
    return """
window.__hwanseCandidateDialoguePartyJoin = null;
localStorage.removeItem(RUNTIME_SAVE_KEY);
prototypeProgress = createPrototypeProgressState();
publishPrototypeProgress();
window.HWANSE_PARTY_JOIN_FEEDBACK_LOG = [];
window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER = [];
window.HWANSE_LAST_PARTY_JOIN_FEEDBACK = null;
window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER = null;
if (typeof activePartyJoinFeedbacks !== 'undefined') activePartyJoinFeedbacks = [];
setManualPartyMembers([], { notice: false })
  .then(() => ensureEventDialogueBlocks())
  .then(() => {
    const clone = (value) => JSON.parse(JSON.stringify(value || null));
    const block = eventDialogueBlocks.find((row) => row.blockId === 'event-dialogue-block-003');
    if (!block) throw new Error('missing dialogue party join block');
    const started = startDialogue(block);
    let advances = 0;
    while (activeDialogue && advances < 64) {
      advanceDialogue();
      advances += 1;
    }
    return (window.HWANSE_LAST_DIALOGUE_PARTY_JOIN_PROMISE || Promise.resolve(null)).then((dialogueJoin) => {
      render();
      const partyJoinFeedbackLog = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_LOG || []);
      const partyJoinFeedbackRender = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER || []);
      const partyJoinFeedbackLast = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK || null);
      const partyJoinFeedbackLastRender = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER || null);
      const autoSave = window.HWANSE_LAST_DIALOGUE_PARTY_JOIN_AUTO_SAVE || window.HWANSE_LAST_PARTY_JOIN_AUTO_SAVE || null;
      const payload = JSON.parse(localStorage.getItem(RUNTIME_SAVE_KEY) || '{}');
      startPrototypeProgressReview();
      const reviewBlock = activeDialogue ? {
        blockId: activeDialogue.block?.blockId || '',
        lines: activeDialogue.lines?.slice(0, 12) || [],
      } : null;
      activeDialogue = null;
      const objectiveBefore = prototypeObjectiveState();
      window.HWANSE_LAST_OBJECTIVE_ACTION = null;
      window.HWANSE_LAST_DIALOGUE_PARTY_JOIN_COMPLETION_NOTICE = null;
      window.HWANSE_PARTY_JOIN_FEEDBACK_LOG = [];
      window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER = [];
      window.HWANSE_LAST_PARTY_JOIN_FEEDBACK = null;
      window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER = null;
      if (typeof activePartyJoinFeedbacks !== 'undefined') activePartyJoinFeedbacks = [];
      const objectiveResult = activatePrototypeObjectiveAction();
      render();
      const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
      const objectiveCompletionNotice = window.HWANSE_LAST_DIALOGUE_PARTY_JOIN_COMPLETION_NOTICE || null;
      const objectivePartyJoinFeedbackLog = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_LOG || []);
      const objectivePartyJoinFeedbackRender = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER || []);
      const objectivePartyJoinFeedbackLast = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK || null);
      const objectivePartyJoinFeedbackLastRender = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER || null);
      const objectiveActiveDialogueBlock = activeDialogue ? {
        blockId: activeDialogue.block?.blockId || '',
        line: activeDialogue.lines?.[activeDialogue.index] || '',
        lineCount: activeDialogue.lines?.length || 0,
      } : null;
      activeDialogue = null;
      window.__hwanseCandidateDialoguePartyJoin = {
        started,
        advances,
        saved: autoSave?.saved === true,
        autoSave,
        scene,
        map: map?.name || '',
        blockId: block.blockId,
        manualLabel: manualPartyLabel(),
        members: activePartyMembers().map((member) => member.name),
        labels: activePartyMembers().map((member) => member.label),
        search: window.location.search,
        marker: window.HWANSE_LAST_DIALOGUE_PARTY_JOIN || dialogueJoin || null,
        partyJoinFeedbackLog,
        partyJoinFeedbackRender,
        partyJoinFeedbackLast,
        partyJoinFeedbackLastRender,
        progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
        completion: window.HWANSE_LAST_PROTOTYPE_COMPLETION || null,
        savedManualParty: payload.manualParty || null,
        savedProgress: payload.prototypeProgress || null,
        reviewBlock,
        objectiveBefore,
        objectiveResult,
        objectiveAction,
        objectiveCompletionNotice,
        objectivePartyJoinFeedbackLog,
        objectivePartyJoinFeedbackRender,
        objectivePartyJoinFeedbackLast,
        objectivePartyJoinFeedbackLastRender,
        objectiveActiveDialogueBlock,
      };
    });
  })
  .catch((error) => {
    window.__hwanseCandidateDialoguePartyJoin = { error: String(error && error.message || error) };
  });
return true;
"""


def dialogue_party_join_state_script() -> str:
    return "return window.__hwanseCandidateDialoguePartyJoin || null;"


def start_dialogue_party_restore_script() -> str:
    return """
window.__hwanseCandidateDialoguePartyRestore = null;
quickLoadRuntime()
  .then((loaded) => {
    const clone = (value) => JSON.parse(JSON.stringify(value || null));
    startPrototypeProgressReview();
    const reviewBlock = activeDialogue ? {
      blockId: activeDialogue.block?.blockId || '',
      lines: activeDialogue.lines?.slice(0, 12) || [],
    } : null;
    activeDialogue = null;
    const objectiveBefore = prototypeObjectiveState();
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_DIALOGUE_PARTY_JOIN_COMPLETION_NOTICE = null;
    window.HWANSE_PARTY_JOIN_FEEDBACK_LOG = [];
    window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER = [];
    window.HWANSE_LAST_PARTY_JOIN_FEEDBACK = null;
    window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER = null;
    if (typeof activePartyJoinFeedbacks !== 'undefined') activePartyJoinFeedbacks = [];
    const objectiveResult = activatePrototypeObjectiveAction();
    render();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_DIALOGUE_PARTY_JOIN_COMPLETION_NOTICE || null;
    const objectivePartyJoinFeedbackLog = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_LOG || []);
    const objectivePartyJoinFeedbackRender = clone(window.HWANSE_PARTY_JOIN_FEEDBACK_RENDER || []);
    const objectivePartyJoinFeedbackLast = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK || null);
    const objectivePartyJoinFeedbackLastRender = clone(window.HWANSE_LAST_PARTY_JOIN_FEEDBACK_RENDER || null);
    const objectiveActiveDialogueBlock = activeDialogue ? {
      blockId: activeDialogue.block?.blockId || '',
      line: activeDialogue.lines?.[activeDialogue.index] || '',
      lineCount: activeDialogue.lines?.length || 0,
    } : null;
    activeDialogue = null;
    window.__hwanseCandidateDialoguePartyRestore = {
      loaded,
      scene,
      map: map?.name || '',
      manualLabel: manualPartyLabel(),
      members: activePartyMembers().map((member) => member.name),
      labels: activePartyMembers().map((member) => member.label),
      search: window.location.search,
      progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
      completion: window.HWANSE_LAST_PROTOTYPE_COMPLETION || null,
      menuCommands: commandMenuItems().map((item) => ({
        key: item.key,
        name: item.name,
        command: item.command,
        memberKey: item.memberKey || '',
        usable: item.usable,
      })),
      reviewBlock,
      objectiveBefore,
      objectiveResult,
      objectiveAction,
      objectiveCompletionNotice,
      objectivePartyJoinFeedbackLog,
      objectivePartyJoinFeedbackRender,
      objectivePartyJoinFeedbackLast,
      objectivePartyJoinFeedbackLastRender,
      objectiveActiveDialogueBlock,
    };
  })
  .catch((error) => {
    window.__hwanseCandidateDialoguePartyRestore = { error: String(error && error.message || error) };
  });
return true;
"""


def dialogue_party_restore_state_script() -> str:
    return "return window.__hwanseCandidateDialoguePartyRestore || null;"


def wait_for_join_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, party_join_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        progress = state.get("progress") or {}
        saved_progress = state.get("savedProgress") or {}
        if (
            state
            and state.get("error") is None
            and state.get("saved") is True
            and progress.get("counts", {}).get("party-join-prototype") == 2
            and len(saved_progress.get("events") or []) == 2
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate party join did not become ready: {state!r}")


def wait_for_party_join_menu_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, party_join_menu_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        selection = state.get("selection") or {}
        progress = state.get("progress") or {}
        saved_progress = state.get("savedProgress") or {}
        if (
            state
            and state.get("error") is None
            and selection.get("memberKey") == "smash"
            and selection.get("autoSaved") is True
            and progress.get("counts", {}).get("party-join-prototype") == 1
            and len(saved_progress.get("events") or []) == 1
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate party join menu did not become ready: {state!r}")


def wait_for_restore_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, party_restore_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        progress = state.get("progress") or {}
        if (
            state
            and state.get("error") is None
            and state.get("loaded") is True
            and progress.get("counts", {}).get("party-join-prototype") == 2
            and state.get("members") == ["rinshan", "smash"]
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate party restore did not become ready: {state!r}")


def wait_for_party_follower_render_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, party_follower_render_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if (
            state
            and state.get("ok") is True
            and state.get("source") == "prototype-party-trail-render"
            and state.get("stepCount", 0) >= 7
            and state.get("trailFilledCount", 0) >= 7
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate party follower render did not become ready: {state!r}")


def wait_for_party_join_title_ready(port: int, session_id: str, timeout: float = 10) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, title_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        labels = [str(label) for label in (state.get("titleMenuLabels") or [])]
        if (
            state.get("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "title"
            and state.get("titleLoaded") is True
            and state.get("bootError") is None
            and state.get("quickLoadHidden") is False
            and state.get("quickLoadText") == "이어하기"
            and "continue" in (state.get("titleMenuKeys") or [])
            and any("이어하기 map1_02b 11,12" in label for label in labels)
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"party join title continue did not become ready: {state!r}")


def wait_for_party_title_restore_state(port: int, session_id: str, timeout: float = 10) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, party_title_restore_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        progress = state.get("progress") or {}
        if (
            state
            and state.get("error") is None
            and state.get("titleContinue") is True
            and state.get("map") == "map1_02b"
            and progress.get("counts", {}).get("party-join-prototype") == 2
            and state.get("members") == ["rinshan", "smash"]
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate party title continue restore did not become ready: {state!r}")


def wait_for_dialogue_join_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, dialogue_party_join_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        progress = state.get("progress") or {}
        counts = progress.get("counts") or {}
        saved_progress = state.get("savedProgress") or {}
        if (
            state
            and state.get("error") is None
            and state.get("saved") is True
            and counts.get("party-join-prototype") == 1
            and counts.get("dialogue-complete") == 1
            and state.get("members") == ["rinshan"]
            and len(saved_progress.get("events") or []) >= 3
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate dialogue party join did not become ready: {state!r}")


def wait_for_dialogue_restore_state(port: int, session_id: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    state: dict = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, dialogue_party_restore_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        progress = state.get("progress") or {}
        counts = progress.get("counts") or {}
        if (
            state
            and state.get("error") is None
            and state.get("loaded") is True
            and counts.get("party-join-prototype") == 1
            and counts.get("dialogue-complete") == 1
            and state.get("members") == ["rinshan"]
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate dialogue party restore did not become ready: {state!r}")


def verify_party_completion_notice_feedback(state: dict) -> None:
    feedback_log = state.get("objectivePartyJoinFeedbackLog") or []
    feedback_render_log = state.get("objectivePartyJoinFeedbackRender") or []
    feedback = state.get("objectivePartyJoinFeedbackLast") or {}
    feedback_render = state.get("objectivePartyJoinFeedbackLastRender") or {}
    if (
        len(feedback_log) < 1
        or len(feedback_render_log) < 1
        or feedback.get("source") != "party-join-completion-notice-feedback"
        or feedback.get("joinSource") != "party-completion-notice"
        or feedback.get("text") != "동료 합류 완료 2/2"
        or feedback.get("map") != "map1_02b"
        or feedback.get("memberLabel") != "완료 2/2"
        or feedback.get("memberCount") != 2
        or feedback.get("partyKey") != "rinshan,smash"
        or feedback.get("partyJoinSound") != "menuConfirm"
        or not str(feedback.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("partyJoinSoundPlayed") is not True
        or feedback.get("durationMs") != 1100
        or feedback.get("browserPartyJoinFeedbackImplemented") is not True
        or feedback.get("prototypePartyJoinImplemented") is not True
        or feedback.get("originalPartyJoinEventImplemented") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or feedback_render.get("active") is not True
        or feedback_render.get("source") != "party-join-completion-notice-feedback"
        or feedback_render.get("joinSource") != "party-completion-notice"
        or feedback_render.get("text") != "동료 합류 완료 2/2"
        or feedback_render.get("map") != "map1_02b"
        or feedback_render.get("memberLabel") != "완료 2/2"
        or feedback_render.get("memberCount") != 2
        or feedback_render.get("partyKey") != "rinshan,smash"
        or feedback_render.get("partyJoinSound") != "menuConfirm"
        or not str(feedback_render.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback_render.get("partyJoinSoundPlayed") is not True
        or feedback_render.get("durationMs") != 1100
        or feedback_render.get("browserPartyJoinFeedbackImplemented") is not True
        or feedback_render.get("prototypePartyJoinImplemented") is not True
        or feedback_render.get("originalPartyJoinEventImplemented") is not False
        or feedback_render.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"party completion notice feedback is incomplete: {state!r}")


def party_completion_notice_feedback_summary(state: dict) -> str:
    feedback = state.get("objectivePartyJoinFeedbackLast") or {}
    rendered = state.get("objectivePartyJoinFeedbackLastRender") or {}
    return (
        f"partyNoticeFeedback={feedback.get('source')}:{feedback.get('text')} "
        f"partyNoticeFeedbackRender={rendered.get('active')} "
        f"partyNoticeSound={feedback.get('partyJoinSound')}:"
        f"{feedback.get('partyJoinSoundSrc')}:{feedback.get('partyJoinSoundPlayed')}"
    )


def verify_party_completion_objective(state: dict) -> None:
    objective_before = state.get("objectiveBefore") or {}
    objective_action = state.get("objectiveAction") or {}
    objective_notice = state.get("objectiveCompletionNotice") or {}
    objective_dialogue = state.get("objectiveActiveDialogueBlock") or {}
    if (
        objective_before.get("title") != "후보 동료 완료 map1_02b"
        or objective_before.get("nextAction") != "완료 알림 확인"
        or objective_before.get("detail") != "동료 합류 2/2"
        or objective_before.get("source") != "prototype-party-completion"
        or state.get("objectiveResult") is not True
        or objective_action.get("action") != "party-completion-notice"
        or objective_action.get("activeId") != "party-complete:map1_02b"
        or objective_action.get("originalPartyJoinEventImplemented") is not False
        or objective_action.get("originalStoryFlagRuntimeImplemented") is not False
        or objective_notice.get("blockId") != "party-complete:map1_02b"
        or objective_notice.get("map") != "map1_02b"
        or "동료 합류 완료 2/2" not in " ".join(objective_notice.get("lines") or [])
        or "Rinshan, Smashu" not in " ".join(objective_notice.get("lines") or [])
        or (objective_notice.get("completion") or {}).get("completed") is not True
        or objective_notice.get("originalPartyJoinEventImplemented") is not False
        or objective_notice.get("originalStoryFlagRuntimeImplemented") is not False
        or objective_dialogue.get("blockId") != "party-complete:map1_02b"
        or objective_dialogue.get("line") != "동료 합류 완료 2/2"
        or objective_dialogue.get("lineCount", 0) < 3
    ):
        raise WebDriverError(f"unexpected party completion objective state: {state!r}")
    verify_party_completion_notice_feedback(state)


def verify_dialogue_party_join_completion_notice_feedback(state: dict) -> None:
    feedback_log = state.get("objectivePartyJoinFeedbackLog") or []
    feedback_render_log = state.get("objectivePartyJoinFeedbackRender") or []
    feedback = state.get("objectivePartyJoinFeedbackLast") or {}
    feedback_render = state.get("objectivePartyJoinFeedbackLastRender") or {}
    if (
        len(feedback_log) < 1
        or len(feedback_render_log) < 1
        or feedback.get("source") != "dialogue-party-join-completion-notice-feedback"
        or feedback.get("joinSource") != "dialogue-party-join-completion-notice"
        or feedback.get("triggerBlockId") != "event-dialogue-block-003"
        or feedback.get("text") != "대사 동료 합류 완료 1/1"
        or feedback.get("map") != "map1_02b"
        or feedback.get("memberKey") != "rinshan"
        or feedback.get("memberName") != "Rinshan"
        or feedback.get("memberLabel") != "완료 1/1"
        or feedback.get("memberCount") != 1
        or feedback.get("partyKey") != "rinshan"
        or feedback.get("partyJoinSound") != "menuConfirm"
        or not str(feedback.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("partyJoinSoundPlayed") is not True
        or feedback.get("durationMs") != 1100
        or feedback.get("browserPartyJoinFeedbackImplemented") is not True
        or feedback.get("prototypePartyJoinImplemented") is not True
        or feedback.get("originalPartyJoinEventImplemented") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or feedback_render.get("active") is not True
        or feedback_render.get("source") != "dialogue-party-join-completion-notice-feedback"
        or feedback_render.get("joinSource") != "dialogue-party-join-completion-notice"
        or feedback_render.get("triggerBlockId") != "event-dialogue-block-003"
        or feedback_render.get("text") != "대사 동료 합류 완료 1/1"
        or feedback_render.get("map") != "map1_02b"
        or feedback_render.get("memberKey") != "rinshan"
        or feedback_render.get("memberLabel") != "완료 1/1"
        or feedback_render.get("memberCount") != 1
        or feedback_render.get("partyKey") != "rinshan"
        or feedback_render.get("partyJoinSound") != "menuConfirm"
        or not str(feedback_render.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback_render.get("partyJoinSoundPlayed") is not True
        or feedback_render.get("durationMs") != 1100
        or feedback_render.get("browserPartyJoinFeedbackImplemented") is not True
        or feedback_render.get("prototypePartyJoinImplemented") is not True
        or feedback_render.get("originalPartyJoinEventImplemented") is not False
        or feedback_render.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"dialogue party join completion notice feedback is incomplete: {state!r}")


def dialogue_party_join_completion_notice_feedback_summary(state: dict) -> str:
    feedback = state.get("objectivePartyJoinFeedbackLast") or {}
    rendered = state.get("objectivePartyJoinFeedbackLastRender") or {}
    return (
        f"dialoguePartyNoticeFeedback={feedback.get('source')}:{feedback.get('text')} "
        f"dialoguePartyNoticeFeedbackRender={rendered.get('active')} "
        f"dialoguePartyNoticeSound={feedback.get('partyJoinSound')}:"
        f"{feedback.get('partyJoinSoundSrc')}:{feedback.get('partyJoinSoundPlayed')}"
    )


def verify_dialogue_party_join_completion_objective(state: dict) -> None:
    objective_before = state.get("objectiveBefore") or {}
    objective_action = state.get("objectiveAction") or {}
    objective_notice = state.get("objectiveCompletionNotice") or {}
    objective_dialogue = state.get("objectiveActiveDialogueBlock") or {}
    completion = (objective_notice.get("completion") or {})
    if (
        objective_before.get("title") != "후보 대사 합류 완료 map1_02b"
        or objective_before.get("nextAction") != "완료 알림 확인"
        or objective_before.get("detail") != "린샹 event-dialogue-block-003"
        or objective_before.get("source") != "prototype-dialogue-party-join-completion"
        or state.get("objectiveResult") is not True
        or objective_action.get("action") != "dialogue-party-join-completion-notice"
        or objective_action.get("activeId") != "dialogue-party-join-complete:map1_02b:event-dialogue-block-003"
        or objective_action.get("originalEventVmRuntimeImplemented") is not False
        or objective_action.get("originalPartyJoinEventImplemented") is not False
        or objective_action.get("originalStoryFlagRuntimeImplemented") is not False
        or objective_notice.get("blockId") != "dialogue-party-join-complete:map1_02b:event-dialogue-block-003"
        or objective_notice.get("map") != "map1_02b"
        or "대사 동료 합류 완료 1/1" not in " ".join(objective_notice.get("lines") or [])
        or "event-dialogue-block-003" not in " ".join(objective_notice.get("lines") or [])
        or "동료 린샹" not in " ".join(objective_notice.get("lines") or [])
        or completion.get("source") != "prototype-dialogue-party-join-completion"
        or completion.get("completed") is not True
        or completion.get("completedCount") != 1
        or completion.get("count") != 1
        or completion.get("blockId") != "event-dialogue-block-003"
        or completion.get("memberKey") != "rinshan"
        or completion.get("memberLabel") != "린샹"
        or completion.get("members") != ["rinshan"]
        or objective_notice.get("originalEventVmRuntimeImplemented") is not False
        or objective_notice.get("originalPartyJoinEventImplemented") is not False
        or objective_notice.get("originalStoryFlagRuntimeImplemented") is not False
        or objective_dialogue.get("blockId") != "dialogue-party-join-complete:map1_02b:event-dialogue-block-003"
        or objective_dialogue.get("line") != "대사 동료 합류 완료 1/1"
        or objective_dialogue.get("lineCount", 0) < 4
    ):
        raise WebDriverError(f"unexpected dialogue party join completion objective state: {state!r}")
    verify_dialogue_party_join_completion_notice_feedback(state)


def verify_party_join_menu_state(state: dict) -> None:
    before_items = state.get("beforeItems") or []
    after_open_items = state.get("afterOpenItems") or []
    after_select_items = state.get("afterSelectItems") or []
    marker = state.get("marker") or {}
    selection = state.get("selection") or {}
    join = state.get("join") or {}
    auto_save = state.get("autoSave") or {}
    auto_save_progress = auto_save.get("progress") or {}
    auto_save_manual_party = auto_save.get("manualParty") or {}
    saved_manual_party = state.get("savedManualParty") or {}
    saved_progress = state.get("savedProgress") or {}
    saved_events = saved_progress.get("events") or []
    progress = state.get("progress") or {}
    feedback_log = state.get("partyJoinFeedbackLog") or []
    feedback_render_log = state.get("partyJoinFeedbackRender") or []
    feedback = state.get("partyJoinFeedbackLast") or {}
    feedback_render = state.get("partyJoinFeedbackLastRender") or {}
    marker_choices = marker.get("choices") or []
    marker_keys = [choice.get("memberKey") for choice in marker_choices]
    after_open_labels = [str(row.get("name") or "") for row in after_open_items]
    after_select_labels = [str(row.get("name") or "") for row in after_select_items]
    if (
        state.get("commandResult") is not True
        or state.get("selectResult") is not True
        or state.get("afterOpenMenuMode") != "party-candidate"
        or state.get("menuMode") != "main"
        or state.get("menuOpen") is not True
        or state.get("targetName") != "동료 2/2 스마슈"
        or not any(row.get("command") == "openPartyJoinCandidateMenu" and row.get("name") == "동료 선택 2" for row in before_items)
        or any(row.get("command") == "joinPrototypePartyMember" for row in before_items)
        or "동료 1/2 린샹" not in after_open_labels
        or "동료 2/2 스마슈" not in after_open_labels
        or "동료 선택 닫기" not in after_open_labels
        or marker.get("source") != "prototype-party-candidate-menu"
        or marker.get("map") != "map1_02b"
        or marker.get("count") != 2
        or marker_keys != ["rinshan", "smash"]
        or marker.get("originalPartyJoinEventImplemented") is not False
        or marker.get("originalStoryFlagRuntimeImplemented") is not False
        or selection.get("source") != "prototype-party-candidate-menu"
        or selection.get("map") != "map1_02b"
        or selection.get("memberKey") != "smash"
        or selection.get("memberName") != "스마슈"
        or selection.get("menuCandidateIndex") != 1
        or selection.get("menuCandidateCount") != 2
        or selection.get("joined") is not True
        or selection.get("afterMenuMode") != "main"
        or selection.get("manualLabel") != "스마슈"
        or selection.get("members") != ["smash"]
        or selection.get("progressCount") != 1
        or selection.get("autoSaved") is not True
        or selection.get("autoSource") != "prototype-party-candidate-menu"
        or selection.get("joinSource") != "prototype-party-candidate-menu"
        or selection.get("originalPartyJoinEventImplemented") is not False
        or selection.get("originalStoryFlagRuntimeImplemented") is not False
        or join.get("source") != "prototype-party-candidate-menu"
        or join.get("memberKey") != "smash"
        or join.get("joined") is not True
        or auto_save.get("saved") is not True
        or auto_save.get("source") != "prototype-party-candidate-menu"
        or auto_save.get("memberKey") != "smash"
        or auto_save.get("payloadMap") != "map1_02b"
        or (auto_save_progress.get("counts") or {}).get("party-join-prototype") != 1
        or auto_save_manual_party.get("members") != ["smash"]
        or saved_manual_party.get("members") != ["smash"]
        or len(saved_events) != 1
        or saved_events[0].get("kind") != "party-join-prototype"
        or (saved_events[0].get("detail") or {}).get("source") != "prototype-party-candidate-menu"
        or progress.get("counts", {}).get("party-join-prototype") != 1
        or state.get("manualLabel") != "스마슈"
        or state.get("members") != ["smash"]
        or len(feedback_log) != 1
        or len(feedback_render_log) != 1
        or feedback.get("source") != "party-join-feedback"
        or feedback.get("joinSource") != "prototype-party-candidate-menu"
        or feedback.get("text") != "동료 합류 스마슈"
        or feedback.get("memberKey") != "smash"
        or feedback.get("memberLabel") != "스마슈"
        or feedback.get("memberCount") != 1
        or feedback.get("partyJoinSound") != "menuConfirm"
        or not str(feedback.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("partyJoinSoundPlayed") is not True
        or feedback.get("browserPartyJoinFeedbackImplemented") is not True
        or feedback.get("originalPartyJoinEventImplemented") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or feedback_render.get("active") is not True
        or feedback_render.get("joinSource") != "prototype-party-candidate-menu"
        or feedback_render.get("text") != "동료 합류 스마슈"
        or feedback_render.get("partyJoinSound") != "menuConfirm"
        or not str(feedback_render.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback_render.get("partyJoinSoundPlayed") is not True
        or "동료 선택 1" not in after_select_labels
        or any(row.get("command") == "joinPrototypePartyMember" and row.get("memberKey") == "smash" for row in after_select_items)
        or any(row.get("command") == "joinPrototypePartyMember" for row in after_select_items)
    ):
        raise WebDriverError(f"unexpected candidate party join menu state: {state!r}")


def verify_join_state(state: dict) -> None:
    progress = state.get("progress") or {}
    completion = state.get("completion") or {}
    party_completion = completion.get("party") or {}
    auto_save = state.get("autoSave") or {}
    auto_save_progress = auto_save.get("progress") or {}
    auto_save_manual_party = auto_save.get("manualParty") or {}
    auto_save_tile = auto_save.get("payloadTile") or {}
    saved_manual_party = state.get("savedManualParty") or {}
    saved_progress = state.get("savedProgress") or {}
    saved_events = saved_progress.get("events") or []
    menu_before = state.get("menuBefore") or []
    menu_after = state.get("menuAfter") or []
    feedback_log = state.get("partyJoinFeedbackLog") or []
    feedback = state.get("partyJoinFeedbackLast") or {}
    feedback_render = state.get("partyJoinFeedbackLastRender") or {}
    review_lines = "\n".join((state.get("reviewBlock") or {}).get("lines") or [])
    if (
        state.get("joinedRinshan") is not True
        or state.get("joinedSmash") is not True
        or state.get("saved") is not True
        or auto_save.get("saved") is not True
        or auto_save.get("source") != "prototype-party-join"
        or auto_save.get("memberKey") != "smash"
        or auto_save.get("payloadMap") != "map1_02b"
        or auto_save_tile.get("x") != 11
        or auto_save_tile.get("y") != 12
        or (auto_save_progress.get("counts") or {}).get("party-join-prototype") != 2
        or auto_save_manual_party.get("members") != ["rinshan", "smash"]
        or auto_save.get("originalPartyJoinEventImplemented") is not False
        or auto_save.get("originalStoryFlagRuntimeImplemented") is not False
        or state.get("scene") != "map"
        or state.get("map") != "map1_02b"
        or state.get("manualLabel") != "린샹+스마슈"
        or state.get("members") != ["rinshan", "smash"]
        or "party=rinshan%2Csmash" not in str(state.get("search") or "")
        or progress.get("counts", {}).get("party-join-prototype") != 2
        or party_completion.get("memberCount") != 2
        or party_completion.get("joinedPrototypeCount") != 2
        or party_completion.get("originalPartyJoinEventImplemented") is not False
        or saved_manual_party.get("members") != ["rinshan", "smash"]
        or saved_manual_party.get("originalPartyJoinEventImplemented") is not False
        or len(saved_events) != 2
        or not all(event.get("kind") == "party-join-prototype" for event in saved_events)
        or not any(row.get("command") == "openPartyJoinCandidateMenu" and row.get("name") == "동료 선택 2" for row in menu_before)
        or any(row.get("command") == "joinPrototypePartyMember" for row in menu_before)
        or any(row.get("command") == "joinPrototypePartyMember" for row in menu_after)
        or len(feedback_log) < 2
        or feedback.get("source") != "party-join-feedback"
        or feedback.get("joinSource") != "prototype-party-join"
        or feedback.get("text") != "동료 합류 스마슈"
        or feedback.get("memberKey") != "smash"
        or feedback.get("memberName") != "Smashu"
        or feedback.get("memberLabel") != "스마슈"
        or feedback.get("memberCount") != 2
        or feedback.get("partyJoinSound") != "menuConfirm"
        or not str(feedback.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("partyJoinSoundPlayed") is not True
        or feedback.get("durationMs") != 1100
        or feedback.get("browserPartyJoinFeedbackImplemented") is not True
        or feedback.get("prototypePartyJoinImplemented") is not True
        or feedback.get("originalPartyJoinEventImplemented") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or feedback_render.get("active") is not True
        or feedback_render.get("text") != "동료 합류 스마슈"
        or feedback_render.get("memberKey") != "smash"
        or feedback_render.get("memberCount") != 2
        or feedback_render.get("partyJoinSound") != "menuConfirm"
        or not str(feedback_render.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback_render.get("partyJoinSoundPlayed") is not True
        or feedback_render.get("durationMs") != 1100
        or feedback_render.get("browserPartyJoinFeedbackImplemented") is not True
        or "동료 합류" not in review_lines
        or "Rinshan" not in review_lines
        or "Smashu" not in review_lines
    ):
        raise WebDriverError(f"unexpected candidate party join state: {state!r}")
    verify_party_completion_objective(state)


def verify_party_follower_render_state(state: dict) -> None:
    before = state.get("before") or {}
    after = state.get("after") or {}
    before_actors = before.get("actors") or []
    after_actors = after.get("actors") or []
    after_followers = [row for row in after_actors if str(row.get("sprite") or "").startswith("party_")]
    before_followers = [row for row in before_actors if str(row.get("sprite") or "").startswith("party_")]
    collision_targets = state.get("actorCollisionTargets") or []
    player_collision = state.get("playerCollisionCheck") or {}
    follower_sprites = {row.get("sprite") for row in after_followers}
    player_after = next((row for row in after_actors if row.get("sprite") == "player"), {})
    follower_positions = {(row.get("x"), row.get("y")) for row in after_followers}
    visible_followers = [
        row for row in after_followers
        if -64 <= int(row.get("screenX") or 0) <= 640
        and -64 <= int(row.get("screenY") or 0) <= 416
    ]
    if (
        state.get("ok") is not True
        or state.get("map") != "map1_02b"
        or state.get("source") != "prototype-party-trail-render"
        or state.get("members") != ["rinshan", "smash"]
        or state.get("stepCount") < 7
        or state.get("trailFilledCount") < 7
        or state.get("actorCollisionEnabled") is not True
        or state.get("actorCollisionTargetCount", 0) < 1
        or before.get("source") != "prototype-party-trail-render"
        or after.get("source") != "prototype-party-trail-render"
        or before.get("memberCount") != 2
        or after.get("memberCount") != 2
        or len(before_followers) != 2
        or len(after_followers) != 2
        or follower_sprites != {"party_rinshan", "party_smash"}
        or len(visible_followers) != 2
        or len(follower_positions) != 2
        or any((row.get("x"), row.get("y")) == (player_after.get("x"), player_after.get("y")) for row in after_followers)
        or len(collision_targets) != 2
        or not all(row.get("overlapBlocked") is True and row.get("canMoveTo") is False for row in collision_targets)
        or not any(row.get("passable") is True and row.get("overlapBlocked") is True and row.get("canMoveTo") is False for row in collision_targets)
        or player_collision.get("passable") is not True
        or player_collision.get("overlapBlocked") is not False
        or player_collision.get("canMoveTo") is not True
        or after.get("originalPartyTrailRuntimeImplemented") is not False
        or after.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"unexpected candidate party follower render state: {state!r}")


def verify_restore_state(state: dict) -> None:
    progress = state.get("progress") or {}
    completion = state.get("completion") or {}
    party_completion = completion.get("party") or {}
    menu_commands = state.get("menuCommands") or []
    review_lines = "\n".join((state.get("reviewBlock") or {}).get("lines") or [])
    if (
        state.get("loaded") is not True
        or state.get("scene") != "map"
        or state.get("map") != "map1_02b"
        or state.get("manualLabel") != "린샹+스마슈"
        or state.get("members") != ["rinshan", "smash"]
        or "party=rinshan%2Csmash" not in str(state.get("search") or "")
        or progress.get("counts", {}).get("party-join-prototype") != 2
        or party_completion.get("memberCount") != 2
        or party_completion.get("joinedPrototypeCount") != 2
        or party_completion.get("originalStoryFlagRuntimeImplemented") is not False
        or not any(row.get("command") == "showPrototypeProgress" and row.get("name") == "진행 목표 2" for row in menu_commands)
        or not any(row.get("command") == "cyclePartyMembers" and row.get("name") == "동료 린샹+스마슈" for row in menu_commands)
        or any(row.get("command") == "joinPrototypePartyMember" for row in menu_commands)
        or "동료 합류" not in review_lines
        or "Rinshan" not in review_lines
        or "Smashu" not in review_lines
    ):
        raise WebDriverError(f"unexpected candidate party restore state: {state!r}")
    verify_party_completion_objective(state)


def verify_party_title_continue(title_state: dict, title_click: dict, restore_state: dict) -> None:
    title_labels = [str(label) for label in (title_state.get("titleMenuLabels") or [])]
    progress = restore_state.get("progress") or {}
    completion = restore_state.get("completion") or {}
    party_completion = completion.get("party") or {}
    menu_commands = restore_state.get("menuCommands") or []
    review_lines = "\n".join((restore_state.get("reviewBlock") or {}).get("lines") or [])
    saved_payload = restore_state.get("savedPayload") or {}
    if (
        title_state.get("scene") != "title"
        or title_state.get("quickLoadText") != "이어하기"
        or "continue" not in (title_state.get("titleMenuKeys") or [])
        or not any("이어하기 map1_02b 11,12" in label for label in title_labels)
        or title_click.get("ok") is not True
        or restore_state.get("titleContinue") is not True
        or restore_state.get("loaded") is not True
        or restore_state.get("scene") != "map"
        or restore_state.get("map") != "map1_02b"
        or restore_state.get("manualLabel") != "린샹+스마슈"
        or restore_state.get("members") != ["rinshan", "smash"]
        or "party=rinshan%2Csmash" not in str(restore_state.get("search") or "")
        or progress.get("counts", {}).get("party-join-prototype") != 2
        or party_completion.get("memberCount") != 2
        or party_completion.get("joinedPrototypeCount") != 2
        or party_completion.get("originalStoryFlagRuntimeImplemented") is not False
        or saved_payload.get("map") != "map1_02b"
        or (saved_payload.get("manualParty") or {}).get("members") != ["rinshan", "smash"]
        or not any(row.get("command") == "showPrototypeProgress" and row.get("name") == "진행 목표 2" for row in menu_commands)
        or not any(row.get("command") == "cyclePartyMembers" and row.get("name") == "동료 린샹+스마슈" for row in menu_commands)
        or any(row.get("command") == "joinPrototypePartyMember" for row in menu_commands)
        or restore_state.get("quickLoadText") != "임시 불러오기"
        or "동료 합류" not in review_lines
        or "Rinshan" not in review_lines
        or "Smashu" not in review_lines
    ):
        raise WebDriverError(
            f"unexpected candidate party title continue state: title={title_state!r} "
            f"click={title_click!r} restore={restore_state!r}"
        )
    verify_party_completion_objective(restore_state)


def verify_dialogue_join_state(state: dict) -> None:
    progress = state.get("progress") or {}
    counts = progress.get("counts") or {}
    completion = state.get("completion") or {}
    party_completion = completion.get("party") or {}
    marker = state.get("marker") or {}
    auto_save = state.get("autoSave") or {}
    auto_save_progress = auto_save.get("progress") or {}
    auto_save_counts = auto_save_progress.get("counts") or {}
    auto_save_manual_party = auto_save.get("manualParty") or {}
    auto_save_tile = auto_save.get("payloadTile") or {}
    saved_manual_party = state.get("savedManualParty") or {}
    saved_progress = state.get("savedProgress") or {}
    saved_events = saved_progress.get("events") or []
    marker_vm = marker.get("vmReplay") or {}
    auto_save_vm = auto_save.get("vmReplay") or {}
    saved_party_join_event = next(
        (
            event
            for event in saved_events
            if event.get("kind") == "party-join-prototype"
            and (event.get("detail") or {}).get("source") == "prototype-dialogue-party-join"
        ),
        {},
    )
    saved_party_join_vm = (saved_party_join_event.get("detail") or {}).get("vmReplay") or {}
    feedback_log = state.get("partyJoinFeedbackLog") or []
    feedback = state.get("partyJoinFeedbackLast") or {}
    feedback_render = state.get("partyJoinFeedbackLastRender") or {}
    review_lines = "\n".join((state.get("reviewBlock") or {}).get("lines") or [])
    if (
        state.get("started") is not True
        or state.get("saved") is not True
        or auto_save.get("saved") is not True
        or auto_save.get("source") != "dialogue-party-join"
        or auto_save.get("blockId") != "event-dialogue-block-003"
        or auto_save.get("payloadMap") != "map1_02b"
        or auto_save_tile.get("x") != 11
        or auto_save_tile.get("y") != 12
        or auto_save_counts.get("dialogue-complete") != 1
        or auto_save_counts.get("party-join-prototype") != 1
        or auto_save_manual_party.get("members") != ["rinshan"]
        or auto_save.get("originalEventVmRuntimeImplemented") is not False
        or auto_save.get("originalPartyJoinEventImplemented") is not False
        or auto_save.get("originalStoryFlagRuntimeImplemented") is not False
        or state.get("scene") != "map"
        or state.get("map") != "map1_02b"
        or state.get("blockId") != "event-dialogue-block-003"
        or state.get("manualLabel") != "린샹"
        or state.get("members") != ["rinshan"]
        or "party=rinshan" not in str(state.get("search") or "")
        or counts.get("dialogue-candidate") != 1
        or counts.get("dialogue-complete") != 1
        or counts.get("party-join-prototype") != 1
        or party_completion.get("memberCount") != 1
        or party_completion.get("joinedPrototypeCount") != 1
        or party_completion.get("originalPartyJoinEventImplemented") is not False
        or marker.get("blockId") != "event-dialogue-block-003"
        or marker.get("source") != "prototype-dialogue-party-join"
        or marker.get("pending") is not False
        or marker.get("members") != ["rinshan"]
        or marker_vm.get("blockId") != "event-dialogue-block-003"
        or marker_vm.get("browserEventVmPartialReplayImplemented") is not True
        or marker_vm.get("browserEventVmFullImplementation") is not False
        or marker_vm.get("renderEventCount") != 7
        or marker_vm.get("literalTextEventCount") != 7
        or marker_vm.get("firstRenderTextSourceValueHex") != "0x00030000"
        or "아타호" not in (marker_vm.get("literalTextSamples") or [])
        or auto_save_vm.get("blockId") != "event-dialogue-block-003"
        or auto_save_vm.get("literalTextEventCount") != 7
        or saved_party_join_vm.get("blockId") != "event-dialogue-block-003"
        or saved_party_join_vm.get("literalTextEventCount") != 7
        or saved_manual_party.get("members") != ["rinshan"]
        or saved_manual_party.get("originalStoryFlagRuntimeImplemented") is not False
        or not any(event.get("kind") == "dialogue-complete" and event.get("id") == "event-dialogue-block-003" for event in saved_events)
        or not any(event.get("kind") == "party-join-prototype" and (event.get("detail") or {}).get("source") == "prototype-dialogue-party-join" for event in saved_events)
        or len(feedback_log) < 1
        or feedback.get("source") != "party-join-feedback"
        or feedback.get("joinSource") != "prototype-dialogue-party-join"
        or feedback.get("text") != "동료 합류 린샹"
        or feedback.get("memberKey") != "rinshan"
        or feedback.get("memberName") != "Rinshan"
        or feedback.get("memberLabel") != "린샹"
        or feedback.get("memberCount") != 1
        or feedback.get("triggerBlockId") != "event-dialogue-block-003"
        or feedback.get("partyJoinSound") != "menuConfirm"
        or not str(feedback.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("partyJoinSoundPlayed") is not True
        or feedback.get("durationMs") != 1100
        or feedback.get("browserPartyJoinFeedbackImplemented") is not True
        or feedback.get("originalPartyJoinEventImplemented") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or feedback_render.get("active") is not True
        or feedback_render.get("text") != "동료 합류 린샹"
        or feedback_render.get("memberKey") != "rinshan"
        or feedback_render.get("joinSource") != "prototype-dialogue-party-join"
        or feedback_render.get("partyJoinSound") != "menuConfirm"
        or not str(feedback_render.get("partyJoinSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback_render.get("partyJoinSoundPlayed") is not True
        or feedback_render.get("browserPartyJoinFeedbackImplemented") is not True
        or "대사 완료" not in review_lines
        or "동료 합류" not in review_lines
        or "Rinshan" not in review_lines
    ):
        raise WebDriverError(f"unexpected candidate dialogue party join state: {state!r}")
    verify_dialogue_party_join_completion_objective(state)


def verify_dialogue_restore_state(state: dict) -> None:
    progress = state.get("progress") or {}
    counts = progress.get("counts") or {}
    completion = state.get("completion") or {}
    party_completion = completion.get("party") or {}
    menu_commands = state.get("menuCommands") or []
    review_lines = "\n".join((state.get("reviewBlock") or {}).get("lines") or [])
    if (
        state.get("loaded") is not True
        or state.get("scene") != "map"
        or state.get("map") != "map1_02b"
        or state.get("manualLabel") != "린샹"
        or state.get("members") != ["rinshan"]
        or "party=rinshan" not in str(state.get("search") or "")
        or counts.get("dialogue-complete") != 1
        or counts.get("party-join-prototype") != 1
        or party_completion.get("memberCount") != 1
        or party_completion.get("joinedPrototypeCount") != 1
        or party_completion.get("originalStoryFlagRuntimeImplemented") is not False
        or not any(row.get("command") == "showPrototypeProgress" and row.get("name") == "진행 목표 3" for row in menu_commands)
        or not any(row.get("command") == "cyclePartyMembers" and row.get("name") == "동료 린샹" for row in menu_commands)
        or not any(row.get("command") == "openPartyJoinCandidateMenu" and row.get("name") == "동료 선택 1" for row in menu_commands)
        or any(row.get("command") == "joinPrototypePartyMember" for row in menu_commands)
        or "대사 완료" not in review_lines
        or "동료 합류" not in review_lines
        or "Rinshan" not in review_lines
    ):
        raise WebDriverError(f"unexpected candidate dialogue party restore state: {state!r}")
    verify_dialogue_party_join_completion_objective(state)


def write_report(report: dict) -> None:
    out_dir = ROOT / "out"
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "candidate_party_join_browser_smoke.json").write_text(
        json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    lines = [
        "# Candidate Party Join Browser Smoke",
        "",
        f"- status: `{report.get('status')}`",
        f"- join menu: `{report.get('joinMenu')}`",
        f"- join: `{report.get('join')}`",
        f"- follower render: `{report.get('followerRender')}`",
        f"- title restored join: `{report.get('titleRestoredJoin')}`",
        f"- restore: `{report.get('restore')}`",
        f"- dialogue join: `{report.get('dialogueJoin')}`",
        f"- dialogue restore: `{report.get('dialogueRestore')}`",
        "",
    ]


def verify_browser(base: str) -> None:
    driver_path = shutil.which("WebKitWebDriver")
    if not driver_path:
        raise WebDriverError("WebKitWebDriver is not installed; install webkit2gtk-driver and run under Xvfb")

    port = free_port()
    log_path = ROOT / "out" / "candidate_party_join_webkitdriver.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with log_path.open("wb") as log:
        proc = subprocess.Popen(
            [
                driver_path,
                "--host=127.0.0.1",
                f"--port={port}",
                "--replace-on-new-session",
            ],
            stdout=log,
            stderr=subprocess.STDOUT,
        )
        session_id = ""
        try:
            wait_for_driver(port, proc)
            session = request_json(
                port,
                "POST",
                "/session",
                {"capabilities": {"alwaysMatch": {"browserName": "MiniBrowser"}}},
                timeout=30,
            )
            session_id = str(session["value"]["sessionId"])
            request_json(
                port,
                "POST",
                f"/session/{session_id}/window/rect",
                {"x": 0, "y": 0, "width": 390, "height": 844},
                timeout=8,
            )

            join_menu_url = load_map(base, port, session_id, {"map": "map1_02b", "startTile": "11,12"})
            execute_js(port, session_id, start_party_join_menu_script(), timeout=3)
            join_menu_state = wait_for_party_join_menu_state(port, session_id)
            verify_party_join_menu_state(join_menu_state)

            join_url = load_map(base, port, session_id, {"map": "map1_02b", "startTile": "11,12"})
            execute_js(port, session_id, start_party_join_script(), timeout=3)
            join_state = wait_for_join_state(port, session_id)
            verify_join_state(join_state)
            execute_js(port, session_id, start_party_follower_render_script(), timeout=3)
            follower_render_state = wait_for_party_follower_render_state(port, session_id)
            verify_party_follower_render_state(follower_render_state)

            title_url = urljoin(base.rstrip("/") + "/", "/web/game.html?game=1")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": title_url}, timeout=30)
            wait_for_page(port, session_id)
            title_state = wait_for_party_join_title_ready(port, session_id)
            title_click = execute_js(port, session_id, click_title_continue_script(), timeout=3)
            if not isinstance(title_click, dict) or title_click.get("ok") is not True:
                raise WebDriverError(f"party join title continue button was not usable: {title_click!r}")
            wait_for_map_runtime(port, session_id, "map1_02b")
            execute_js(port, session_id, capture_party_join_after_title_continue_script(), timeout=3)
            title_restore_state = wait_for_party_title_restore_state(port, session_id)
            verify_party_title_continue(title_state, title_click, title_restore_state)

            restore_url = load_map(base, port, session_id, {"map": "map1_02b", "startTile": "11,12"})
            execute_js(port, session_id, start_party_restore_script(), timeout=3)
            restore_state = wait_for_restore_state(port, session_id)
            verify_restore_state(restore_state)

            dialogue_join_url = load_map(base, port, session_id, {"map": "map1_02b", "startTile": "11,12"})
            execute_js(port, session_id, start_dialogue_party_join_script(), timeout=3)
            dialogue_join_state = wait_for_dialogue_join_state(port, session_id)
            verify_dialogue_join_state(dialogue_join_state)

            dialogue_restore_url = load_map(base, port, session_id, {"map": "map1_02b", "startTile": "11,12"})
            execute_js(port, session_id, start_dialogue_party_restore_script(), timeout=3)
            dialogue_restore_state = wait_for_dialogue_restore_state(port, session_id)
            verify_dialogue_restore_state(dialogue_restore_state)

            report = {
                "status": "passed",
                "base": base,
                "joinMenuUrl": join_menu_url,
                "joinUrl": join_url,
                "titleUrl": title_url,
                "restoreUrl": restore_url,
                "dialogueJoinUrl": dialogue_join_url,
                "dialogueRestoreUrl": dialogue_restore_url,
                "join": (
                    "party-join-prototype "
                    f"members={','.join(join_state.get('members') or [])} "
                    f"label={join_state.get('manualLabel')} "
                    f"progressCount={(join_state.get('progress') or {}).get('counts', {}).get('party-join-prototype')} "
                    f"savedProgressEvents={len((join_state.get('savedProgress') or {}).get('events') or [])} "
                    f"menuCommands=동료 선택 2 directJoinRows=False "
                    f"autoSaved={(join_state.get('autoSave') or {}).get('saved')} "
                    f"autoSource={(join_state.get('autoSave') or {}).get('source')} "
                    f"objective={(join_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveNext={(join_state.get('objectiveBefore') or {}).get('nextAction')} "
                    f"objectiveAction={(join_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(join_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{party_completion_notice_feedback_summary(join_state)} "
                    f"joinFeedback={((join_state.get('partyJoinFeedbackLast') or {}).get('source') or '')}:"
                    f"{((join_state.get('partyJoinFeedbackLast') or {}).get('text') or '')} "
                    f"joinFeedbackRender={bool(join_state.get('partyJoinFeedbackRender') or [])} "
                    f"partyJoinSound={((join_state.get('partyJoinFeedbackLast') or {}).get('partyJoinSound') or '')} "
                    f"partyJoinSoundSrc={((join_state.get('partyJoinFeedbackLast') or {}).get('partyJoinSoundSrc') or '')} "
                    f"partyJoinSoundPlayed={((join_state.get('partyJoinFeedbackLast') or {}).get('partyJoinSoundPlayed'))} "
                    "source=prototype-party-join "
                    "originalPartyJoinEventImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "joinMenu": (
                    "party join candidate menu "
                    f"menuCount={(join_menu_state.get('marker') or {}).get('count')} "
                    f"selected={(join_menu_state.get('selection') or {}).get('memberKey')} "
                    f"memberName={(join_menu_state.get('selection') or {}).get('memberName')} "
                    f"selectionSource={(join_menu_state.get('selection') or {}).get('source')} "
                    f"commandResult={join_menu_state.get('commandResult')} "
                    f"selectResult={join_menu_state.get('selectResult')} "
                    f"afterOpenMenuMode={join_menu_state.get('afterOpenMenuMode')} "
                    f"afterMenuMode={(join_menu_state.get('selection') or {}).get('afterMenuMode')} "
                    f"joined={(join_menu_state.get('selection') or {}).get('joined')} "
                    f"manualLabel={join_menu_state.get('manualLabel')} "
                    f"members={','.join(join_menu_state.get('members') or [])} "
                    f"progressCount={(join_menu_state.get('progress') or {}).get('counts', {}).get('party-join-prototype')} "
                    f"autoSaved={(join_menu_state.get('autoSave') or {}).get('saved')} "
                    f"autoSource={(join_menu_state.get('autoSave') or {}).get('source')} "
                    f"joinFeedback={((join_menu_state.get('partyJoinFeedbackLast') or {}).get('source') or '')}:"
                    f"{((join_menu_state.get('partyJoinFeedbackLast') or {}).get('text') or '')} "
                    f"joinFeedbackRender={bool(join_menu_state.get('partyJoinFeedbackRender') or [])} "
                    f"partyJoinSound={((join_menu_state.get('partyJoinFeedbackLast') or {}).get('partyJoinSound') or '')} "
                    f"partyJoinSoundSrc={((join_menu_state.get('partyJoinFeedbackLast') or {}).get('partyJoinSoundSrc') or '')} "
                    f"partyJoinSoundPlayed={((join_menu_state.get('partyJoinFeedbackLast') or {}).get('partyJoinSoundPlayed'))} "
                    "originalPartyJoinEventImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "followerRender": (
                    "party-follower-render "
                    f"source={follower_render_state.get('source')} "
                    f"members={','.join(follower_render_state.get('members') or [])} "
                    f"followers=party_rinshan,party_smash "
                    f"stepCount={follower_render_state.get('stepCount')} "
                    f"trailFilledCount={follower_render_state.get('trailFilledCount')} "
                    "visibleFollowers=2 "
                    "separateFromPlayer=True "
                    f"actorCollisionEnabled={follower_render_state.get('actorCollisionEnabled')} "
                    f"actorCollisionBlocked={follower_render_state.get('actorCollisionTargetCount')} "
                    "originalPartyTrailRuntimeImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "titleRestoredJoin": (
                    "party-join-prototype "
                    "titleContinue=True "
                    f"label={next((label for label in (title_state.get('titleMenuLabels') or []) if '이어하기 map1_02b' in str(label)), '')} "
                    f"members={','.join(title_restore_state.get('members') or [])} "
                    f"manualLabel={title_restore_state.get('manualLabel')} "
                    f"progressRestored={(title_restore_state.get('progress') or {}).get('counts', {}).get('party-join-prototype')} "
                    "menuProgress=진행 목표 2 "
                    f"quickLoadText={title_restore_state.get('quickLoadText')} "
                    f"search={title_restore_state.get('search')} "
                    f"objective={(title_restore_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveNext={(title_restore_state.get('objectiveBefore') or {}).get('nextAction')} "
                    f"objectiveAction={(title_restore_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(title_restore_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{party_completion_notice_feedback_summary(title_restore_state)} "
                    "originalPartyJoinEventImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "restore": (
                    f"loaded={restore_state.get('loaded')} "
                    f"members={','.join(restore_state.get('members') or [])} "
                    f"label={restore_state.get('manualLabel')} "
                    f"progressRestored={(restore_state.get('progress') or {}).get('counts', {}).get('party-join-prototype')} "
                    f"menuProgress=진행 목표 2 "
                    f"search={restore_state.get('search')} "
                    f"objective={(restore_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveNext={(restore_state.get('objectiveBefore') or {}).get('nextAction')} "
                    f"objectiveAction={(restore_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(restore_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{party_completion_notice_feedback_summary(restore_state)} "
                    "originalPartyJoinEventImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "dialogueJoin": (
                    "dialogue-party-join "
                    f"block={dialogue_join_state.get('blockId')} "
                    f"members={','.join(dialogue_join_state.get('members') or [])} "
                    f"label={dialogue_join_state.get('manualLabel')} "
                    f"dialogueCompleteCount={(dialogue_join_state.get('progress') or {}).get('counts', {}).get('dialogue-complete')} "
                    f"partyJoinCount={(dialogue_join_state.get('progress') or {}).get('counts', {}).get('party-join-prototype')} "
                    f"savedProgressEvents={len((dialogue_join_state.get('savedProgress') or {}).get('events') or [])} "
                    f"autoSaved={(dialogue_join_state.get('autoSave') or {}).get('saved')} "
                    f"autoSource={(dialogue_join_state.get('autoSave') or {}).get('source')} "
                    f"vmPartial={((dialogue_join_state.get('marker') or {}).get('vmReplay') or {}).get('browserEventVmPartialReplayImplemented')} "
                    f"vmRender={((dialogue_join_state.get('marker') or {}).get('vmReplay') or {}).get('renderEventCount')} "
                    f"vmLiteral={((dialogue_join_state.get('marker') or {}).get('vmReplay') or {}).get('literalTextEventCount')} "
                    f"vmSource={((dialogue_join_state.get('marker') or {}).get('vmReplay') or {}).get('firstRenderTextSourceValueHex')} "
                    f"joinFeedback={((dialogue_join_state.get('partyJoinFeedbackLast') or {}).get('source') or '')}:"
                    f"{((dialogue_join_state.get('partyJoinFeedbackLast') or {}).get('text') or '')} "
                    f"joinFeedbackRender={bool(dialogue_join_state.get('partyJoinFeedbackRender') or [])} "
                    f"partyJoinSound={((dialogue_join_state.get('partyJoinFeedbackLast') or {}).get('partyJoinSound') or '')} "
                    f"partyJoinSoundSrc={((dialogue_join_state.get('partyJoinFeedbackLast') or {}).get('partyJoinSoundSrc') or '')} "
                    f"partyJoinSoundPlayed={((dialogue_join_state.get('partyJoinFeedbackLast') or {}).get('partyJoinSoundPlayed'))} "
                    f"objective={(dialogue_join_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveNext={(dialogue_join_state.get('objectiveBefore') or {}).get('nextAction')} "
                    f"objectiveAction={(dialogue_join_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(dialogue_join_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{dialogue_party_join_completion_notice_feedback_summary(dialogue_join_state)} "
                    "source=prototype-dialogue-party-join "
                    "originalEventVmRuntimeImplemented=False "
                    "originalPartyJoinEventImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "dialogueRestore": (
                    f"loaded={dialogue_restore_state.get('loaded')} "
                    f"members={','.join(dialogue_restore_state.get('members') or [])} "
                    f"label={dialogue_restore_state.get('manualLabel')} "
                    f"dialogueCompleteRestored={(dialogue_restore_state.get('progress') or {}).get('counts', {}).get('dialogue-complete')} "
                    f"partyJoinRestored={(dialogue_restore_state.get('progress') or {}).get('counts', {}).get('party-join-prototype')} "
                    f"menuProgress=진행 목표 3 "
                    f"search={dialogue_restore_state.get('search')} "
                    f"objective={(dialogue_restore_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveNext={(dialogue_restore_state.get('objectiveBefore') or {}).get('nextAction')} "
                    f"objectiveAction={(dialogue_restore_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(dialogue_restore_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{dialogue_party_join_completion_notice_feedback_summary(dialogue_restore_state)} "
                    "originalPartyJoinEventImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "snapshots": {
                    "joinMenu": join_menu_state,
                    "join": join_state,
                    "followerRender": follower_render_state,
                    "title": title_state,
                    "titleRestore": title_restore_state,
                    "restore": restore_state,
                    "dialogueJoin": dialogue_join_state,
                    "dialogueRestore": dialogue_restore_state,
                },
            }
            write_report(report)
            print(
                "ok candidate party join browser "
                f"joinMenu={report['joinMenu']} join={report['join']} titleRestoredJoin={report['titleRestoredJoin']} "
                f"followerRender={report['followerRender']} "
                f"restore={report['restore']} "
                f"dialogueJoin={report['dialogueJoin']} dialogueRestore={report['dialogueRestore']}"
            )
        finally:
            if session_id:
                try:
                    request_json(port, "DELETE", f"/session/{session_id}", timeout=5)
                except Exception:
                    pass
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait(timeout=5)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--base", default="http://127.0.0.1:8013")
    args = parser.parse_args()
    verify_browser(args.base)


if __name__ == "__main__":
    main()
