#!/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]

EXPECTED_EQUIPMENT_TEXT = {
    "equipmentNameSource": "exe-text-table-equipment",
    "equipmentTextTableKey": "equipment",
    "equipmentTextTableIndex": 0,
    "equipmentTextTableRefVaHex": "0x0048b244",
    "equipmentTextTableTextVaHex": "0x0048b52c",
}


def verify_equipment_text_provenance(row: dict, *, prefix: str = "equipment") -> None:
    expected = {
        f"{prefix}NameSource": EXPECTED_EQUIPMENT_TEXT["equipmentNameSource"],
        f"{prefix}TextTableKey": EXPECTED_EQUIPMENT_TEXT["equipmentTextTableKey"],
        f"{prefix}TextTableIndex": EXPECTED_EQUIPMENT_TEXT["equipmentTextTableIndex"],
        f"{prefix}TextTableRefVaHex": EXPECTED_EQUIPMENT_TEXT["equipmentTextTableRefVaHex"],
        f"{prefix}TextTableTextVaHex": EXPECTED_EQUIPMENT_TEXT["equipmentTextTableTextVaHex"],
    }
    for key, value in expected.items():
        if row.get(key) != value:
            raise WebDriverError(f"equipment text provenance mismatch for {key}: {row!r}")


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 equipment_menu_start_script() -> str:
    return """
window.__hwanseEquipmentMenuStart = null;
const finish = () => {
  const items = menuItems();
  const index = items.findIndex((item) => item.command === 'openEquipmentEquipMenu');
  const mainLabels = items.map((item) => menuItemLabel(item));
  const directEquipmentReviewRows = items.filter((item) => item.command === 'showEquipmentMenu').length;
  if (index < 0) {
    window.__hwanseEquipmentMenuStart = {
      ok: false,
      reason: 'missing equipment equip command',
      mainLabels,
      directEquipmentReviewRows,
    };
    return;
  }
  selectedMenuItemIndex = index;
  const openResult = useSelectedMenuItem();
  const afterOpenMenuMode = menuMode;
  const submenuItems = menuItems();
  const labels = submenuItems.map((item) => menuItemLabel(item));
  const reviewIndex = submenuItems.findIndex((item) => item.command === 'showEquipmentMenu');
  if (reviewIndex < 0) {
    window.__hwanseEquipmentMenuStart = {
      ok: false,
      reason: 'missing equipment review command',
      mainLabels,
      labels,
      directEquipmentReviewRows,
      openResult,
      afterOpenMenuMode,
    };
    return;
  }
  selectedMenuItemIndex = reviewIndex;
  const commandResult = useSelectedMenuItem();
  window.setTimeout(() => {
    window.__hwanseEquipmentMenuStart = {
      ok: true,
      index,
      reviewIndex,
      mainLabels,
      labels,
      openResult,
      commandResult,
      afterOpenMenuMode,
      directEquipmentReviewRows,
      notice: menuNotice,
      activeId: activeDialogue?.block?.blockId || '',
      lines: activeDialogue?.lines || [],
      equipmentMenu: window.HWANSE_LAST_EQUIPMENT_MENU || null,
    };
  }, 0);
};
if (runtimeState && loadedSaveSummary) {
  finish();
} else {
  window.setTimeout(() => {
    if (runtimeState && loadedSaveSummary) finish();
    else window.__hwanseEquipmentMenuStart = {
      ok: false,
      reason: 'savedat not loaded',
      labels: menuItems().map((item) => menuItemLabel(item)),
      loadedSaveSummary: loadedSaveSummary || null,
      runtimeState: runtimeState || null,
    };
  }, 300);
}
return true;
"""


def equipment_menu_state_script() -> str:
    return "return window.__hwanseEquipmentMenuStart || null;"


def equipment_equip_start_script() -> str:
    return """
window.__hwanseEquipmentEquipStart = null;
const fail = (reason, extra = {}) => {
  window.__hwanseEquipmentEquipStart = {
    ok: false,
    reason,
    labels: menuItems().map((item) => menuItemLabel(item)),
    map: map?.name || '',
    runtimeState: runtimeState || null,
    ...extra,
  };
};
const resetEquipmentChangeFeedback = () => {
  window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_LOG = [];
  window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_RENDER = [];
  window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK = null;
  window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK_RENDER = null;
  if (typeof activeEquipmentChangeFeedbacks !== 'undefined') activeEquipmentChangeFeedbacks = [];
};
const resetSoundCapture = () => {
  window.HWANSE_SOUND_COUNTS = {};
  window.HWANSE_SOUND_LOG = [];
  window.HWANSE_LAST_SOUND = null;
};
const captureEquipmentChangeFeedback = () => ({
  log: Array.isArray(window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_LOG)
    ? [...window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_LOG]
    : [],
  render: Array.isArray(window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_RENDER)
    ? [...window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_RENDER]
    : [],
  last: window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK || null,
  lastRender: window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK_RENDER || null,
});
const captureSoundState = () => ({
  counts: { ...(window.HWANSE_SOUND_COUNTS || {}) },
  last: window.HWANSE_LAST_SOUND || null,
  log: Array.isArray(window.HWANSE_SOUND_LOG) ? [...window.HWANSE_SOUND_LOG] : [],
});
const equipmentSnapshot = (character) => character?.prototypeEquipment
  ? { ...character.prototypeEquipment }
  : null;
const memberStatsSnapshot = (character) => ({
  atk: battleMemberStat(character, 'atk'),
  def: battleMemberStat(character, 'def'),
  tech: battleMemberStat(character, 'tech'),
  quick: battleMemberStat(character, 'quick'),
  luck: battleMemberStat(character, 'luck'),
  equipment: equipmentSnapshot(character),
});
if (!runtimeState) {
  fail('runtimeState missing');
  return true;
}
menuMode = 'main';
menuOpen = true;
selectedMenuItemIndex = menuItems().findIndex((item) => item.command === 'openEquipmentEquipMenu');
if (selectedMenuItemIndex < 0) {
  fail('missing openEquipmentEquipMenu command');
  return true;
}
const openResult = useSelectedMenuItem();
const equipItems = menuItems();
const equipIndex = equipItems.findIndex((item) => item.command === 'equipPrototypeItem' && item.equipmentItem?.index === 0);
if (equipIndex < 0) {
  fail('missing cat claw equip command', { equipItems: equipItems.map((item) => menuItemLabel(item)) });
  return true;
}
const ataho = runtimeState.characters.find((character) => character.name === 'Ataho');
const before = {
  labels: equipItems.map((item) => menuItemLabel(item)),
  mode: menuMode,
  ...memberStatsSnapshot(ataho),
};
resetEquipmentChangeFeedback();
resetSoundCapture();
selectedMenuItemIndex = equipIndex;
const equipResult = useSelectedMenuItem();
if (typeof render === 'function') render();
const equipmentFeedback = captureEquipmentChangeFeedback();
const soundState = captureSoundState();
const after = {
  notice: menuNotice,
  change: window.HWANSE_LAST_EQUIPMENT_CHANGE || null,
  progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
  labels: menuItems().map((item) => menuItemLabel(item)),
  mode: menuMode,
  ...memberStatsSnapshot(ataho),
  statusMenu: partyStatusMenuState(),
  statusReview: statusMenuReviewBlock(partyStatusMenuState()),
  equipmentFeedbackLog: equipmentFeedback.log,
  equipmentFeedbackRender: equipmentFeedback.render,
  equipmentFeedbackLast: equipmentFeedback.last,
  equipmentFeedbackLastRender: equipmentFeedback.lastRender,
  soundState,
};
const autoSave = window.HWANSE_LAST_EQUIPMENT_CHANGE_AUTO_SAVE || after.change?.autoSave || null;
if (ataho?.prototypeEquipment) ataho.prototypeEquipment.weapon = null;
quickLoadRuntime()
  .then((loaded) => {
    const restoredAtaho = runtimeState?.characters?.find((character) => character.name === 'Ataho') || null;
    const restored = {
      map: map?.name || '',
      ...memberStatsSnapshot(restoredAtaho),
      progressCount: (prototypeProgress?.events || []).filter((event) => event.kind === 'equipment-prototype').length,
      progressReview: prototypeProgressReviewBlock(),
      statusMenu: partyStatusMenuState(),
      statusReview: statusMenuReviewBlock(partyStatusMenuState()),
      change: window.HWANSE_LAST_EQUIPMENT_CHANGE || null,
      loadedSummaryEquipment: equipmentSnapshot((loadedSaveSummary?.characters || []).find((character) => character.name === 'Ataho')),
    };
    const unequip = { ok: false, reason: '' };
    menuMode = 'main';
    menuOpen = true;
    selectedMenuItemIndex = menuItems().findIndex((item) => item.command === 'openEquipmentEquipMenu');
    if (selectedMenuItemIndex < 0) {
      unequip.reason = 'missing openEquipmentEquipMenu command after restore';
    } else {
      unequip.openResult = useSelectedMenuItem();
      const unequipItems = menuItems();
      const unequipIndex = unequipItems.findIndex((item) => item.command === 'equipPrototypeItem' && item.equipmentItem?.index === 0);
      if (unequipIndex < 0) {
        unequip.reason = 'missing cat claw unequip command after restore';
        unequip.labels = unequipItems.map((item) => menuItemLabel(item));
      } else {
        const unequipAtahoBefore = runtimeState?.characters?.find((character) => character.name === 'Ataho') || null;
        unequip.before = {
          labels: unequipItems.map((item) => menuItemLabel(item)),
          mode: menuMode,
          ...memberStatsSnapshot(unequipAtahoBefore),
        };
        resetEquipmentChangeFeedback();
        resetSoundCapture();
        selectedMenuItemIndex = unequipIndex;
        unequip.result = useSelectedMenuItem();
        if (typeof render === 'function') render();
        const unequipFeedback = captureEquipmentChangeFeedback();
        const unequipSoundState = captureSoundState();
        const unequipAtahoAfter = runtimeState?.characters?.find((character) => character.name === 'Ataho') || null;
        unequip.after = {
          notice: menuNotice,
          change: window.HWANSE_LAST_EQUIPMENT_CHANGE || null,
          progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
          labels: menuItems().map((item) => menuItemLabel(item)),
          mode: menuMode,
          ...memberStatsSnapshot(unequipAtahoAfter),
          statusMenu: partyStatusMenuState(),
          statusReview: statusMenuReviewBlock(partyStatusMenuState()),
          equipmentFeedbackLog: unequipFeedback.log,
          equipmentFeedbackRender: unequipFeedback.render,
          equipmentFeedbackLast: unequipFeedback.last,
          equipmentFeedbackLastRender: unequipFeedback.lastRender,
          soundState: unequipSoundState,
        };
        unequip.autoSave = window.HWANSE_LAST_EQUIPMENT_CHANGE_AUTO_SAVE || unequip.after.change?.autoSave || null;
        unequip.saved = unequip.autoSave?.saved === true;
        unequip.equipmentFeedbackLog = unequipFeedback.log;
        unequip.equipmentFeedbackRender = unequipFeedback.render;
        unequip.equipmentFeedbackLast = unequipFeedback.last;
        unequip.equipmentFeedbackLastRender = unequipFeedback.lastRender;
        unequip.soundState = unequipSoundState;
        unequip.ok = true;
      }
    }
    window.__hwanseEquipmentEquipStart = {
      ok: true,
      openResult,
      equipResult,
      saved: autoSave?.saved === true,
      autoSave,
      loaded,
      before,
      after,
      equipmentFeedbackLog: equipmentFeedback.log,
      equipmentFeedbackRender: equipmentFeedback.render,
      equipmentFeedbackLast: equipmentFeedback.last,
      equipmentFeedbackLastRender: equipmentFeedback.lastRender,
      soundState,
      restored,
      unequip,
    };
  })
  .catch((error) => fail(error.message || String(error), { before, after }));
return true;
"""


def equipment_equip_state_script() -> str:
    return "return window.__hwanseEquipmentEquipStart || null;"


def equipment_target_menu_start_script() -> str:
    return """
window.__hwanseEquipmentTargetMenu = null;
const fail = (reason, extra = {}) => {
  window.__hwanseEquipmentTargetMenu = {
    ok: false,
    reason,
    labels: menuItems().map((item) => menuItemLabel(item)),
    menuMode,
    map: map?.name || '',
    runtimeState: runtimeState || null,
    loadedSaveSummary: loadedSaveSummary || null,
    ...extra,
  };
};
if (!runtimeState || !loadedSaveSummary) {
  fail('expected loaded savedat runtime state');
  return true;
}
activeDialogue = null;
menuMode = 'main';
menuOpen = true;
selectedMenuItemIndex = 0;
if (typeof selectedEquipmentTargetName !== 'undefined') selectedEquipmentTargetName = '';
window.HWANSE_LAST_EQUIPMENT_TARGET_SELECT_MENU = null;
window.HWANSE_LAST_EQUIPMENT_TARGET_MENU_SELECTION = null;
const beforeItems = menuItems();
const commandIndex = beforeItems.findIndex((item) => item.command === 'openEquipmentTargetMenu');
const beforeLabels = beforeItems.map((item) => menuItemLabel(item));
if (commandIndex < 0) {
  fail('missing equipment target menu command', { beforeLabels });
  return true;
}
selectedMenuItemIndex = commandIndex;
const commandResult = useSelectedMenuItem();
const afterOpenMenuMode = menuMode;
const afterOpenMarker = window.HWANSE_LAST_EQUIPMENT_TARGET_SELECT_MENU || null;
const afterOpenItems = menuItems();
const afterOpenLabels = afterOpenItems.map((item) => menuItemLabel(item));
const selectIndex = afterOpenItems.findIndex(
  (item) => item.command === 'selectEquipmentTarget' && item.equipmentTargetName === 'Ataho'
);
if (selectIndex < 0) {
  fail('missing Ataho equipment target menu item', {
    beforeLabels,
    commandResult,
    afterOpenMenuMode,
    afterOpenLabels,
    afterOpenMarker,
  });
  return true;
}
selectedMenuItemIndex = selectIndex;
const selectResult = useSelectedMenuItem();
const afterMenuMode = menuMode;
const selection = window.HWANSE_LAST_EQUIPMENT_TARGET_MENU_SELECTION || null;
const filteredItems = menuItems();
const filteredLabels = filteredItems.map((item) => menuItemLabel(item));
window.__hwanseEquipmentTargetMenu = {
  ok: true,
  beforeLabels,
  commandIndex,
  commandResult,
  afterOpenMenuMode,
  afterOpenLabels,
  afterOpenMarker,
  selectIndex,
  selectResult,
  afterMenuMode,
  selectedEquipmentTargetName: typeof selectedEquipmentTargetName === 'undefined' ? '' : selectedEquipmentTargetName,
  selection,
  filteredLabels,
  filteredItems: filteredItems.map((item) => ({
    key: item.key,
    name: item.name,
    command: item.command || '',
    target: item.equipmentItem?.target || '',
    slot: item.equipmentItem?.slot || '',
    equipmentIndex: item.equipmentItem?.index ?? null,
    equipmentName: item.equipmentItem?.name || '',
    usable: item.usable,
  })),
};
return true;
"""


def equipment_target_menu_state_script() -> str:
    return "return window.__hwanseEquipmentTargetMenu || null;"


def equipment_battle_effect_start_script() -> str:
    return """
window.__hwanseEquipmentBattleEffect = null;
const fail = (reason, extra = {}) => {
  window.__hwanseEquipmentBattleEffect = {
    ok: false,
    reason,
    scene,
    map: map?.name || '',
    runtimeState: runtimeState || null,
    loadedSaveSummary: loadedSaveSummary || null,
    ...extra,
  };
};
const ataho = runtimeState?.characters?.find((character) => character.name === 'Ataho') || null;
if (!ataho || !loadedSaveSummary) {
  fail('expected loaded savedat Ataho runtime state');
  return true;
}
const resetBattleMarkers = () => {
  window.HWANSE_BATTLE_HIT_EFFECT_LOG = [];
  window.HWANSE_BATTLE_DAMAGE_TEXT_LOG = [];
  window.HWANSE_LAST_BATTLE_HIT_EFFECT = null;
  window.HWANSE_LAST_BATTLE_DAMAGE_TEXT = null;
  window.HWANSE_LAST_BATTLE_TURN_RESULT = null;
  window.HWANSE_LAST_BATTLE_TURN_AUTO_SAVE = null;
};
const restoreMapScene = () => {
  scene = 'map';
  battleState = null;
  activeDialogue = null;
  menuOpen = false;
  menuMode = 'main';
  selectedMenuItemIndex = 0;
};
const setAtahoEquipment = (weaponIndex, armorIndex = null) => {
  ataho.prototypeEquipment = { ...(ataho.prototypeEquipment || {}) };
  ataho.prototypeEquipment.weapon = Number.isInteger(weaponIndex) ? weaponIndex : null;
  ataho.prototypeEquipment.armor = Number.isInteger(armorIndex) ? armorIndex : null;
  ataho.hp = ataho.hpMax || ataho.hp || 1;
  ataho.statuses = [];
};
const runBattleProbe = (label, weaponIndex, armorIndex = null, enemyAtkOverride = null) => {
  restoreMapScene();
  setAtahoEquipment(weaponIndex, armorIndex);
  resetBattleMarkers();
  return startBattlePrototype().then(() => {
    const actor = currentBattleActor();
    if (!actor || actor.name !== 'Ataho') throw new Error(`${label}: Ataho was not the first actor`);
    battleState.enemy.hpMax = Math.max(Number(battleState.enemy.hpMax || 0), 99999);
    battleState.enemy.hp = battleState.enemy.hpMax;
    if (Number.isFinite(enemyAtkOverride)) {
      battleState.enemy.atk = enemyAtkOverride;
      battleState.enemy.profile = { ...(battleState.enemy.profile || {}), atk: enemyAtkOverride };
    }
    const command = battleCommandItems(actor)[0];
    const predictedDamage = battleAttackDamage(actor, command, battleState.enemy);
    const equipmentEffect = battleEquipmentEffectSummary(actor);
    const predictedIncomingDamage = battleIncomingDamage(actor, false, battleState.enemy);
    const enemyHpBefore = battleState.enemy.hp;
    const actorHpBefore = actor.hp;
    const commandResult = useSelectedBattleCommand();
    const hitLog = Array.isArray(window.HWANSE_BATTLE_HIT_EFFECT_LOG)
      ? [...window.HWANSE_BATTLE_HIT_EFFECT_LOG]
      : [];
    const damageLog = Array.isArray(window.HWANSE_BATTLE_DAMAGE_TEXT_LOG)
      ? [...window.HWANSE_BATTLE_DAMAGE_TEXT_LOG]
      : [];
    const playerHit = hitLog.find((entry) => entry.targetType === 'enemy') || null;
    const enemyHit = hitLog.find((entry) => entry.targetType === 'party') || null;
    const turnResult = window.HWANSE_LAST_BATTLE_TURN_RESULT || null;
    const result = {
      label,
      commandResult,
      battleBackground: battleState.background?.name || battleState.candidate?.battleBackground || '',
      enemyName: battleState.enemy?.name || '',
      commandName: command?.name || '',
      actorAtk: battleMemberStat(actor, 'atk'),
      actorDef: battleMemberStat(actor, 'def'),
      equipment: { ...(ataho.prototypeEquipment || {}) },
      equipmentEffect,
      predictedDamage,
      predictedIncomingDamage,
      enemyHpBefore,
      enemyHpAfter: battleState.enemy?.hp ?? null,
      playerHit,
      enemyHit,
      hitLog,
      damageLog,
      turnResult,
      actorHpBefore,
      actorHpAfter: actor.hp,
    };
    restoreMapScene();
    return result;
  });
};
Promise.resolve()
  .then(() => runBattleProbe('baseline', null))
  .then((baseline) => runBattleProbe('equipped', 0).then((equipped) => {
    return runBattleProbe('defenseBaseline', null, null, 200).then((defenseBaseline) => (
      runBattleProbe('defenseEquipped', null, 14, 200).then((defenseEquipped) => {
        setAtahoEquipment(null);
        window.__hwanseEquipmentBattleEffect = {
          ok: true,
          baseline,
          equipped,
          defenseBaseline,
          defenseEquipped,
          damageDelta: Number(equipped.playerHit?.damage || 0) - Number(baseline.playerHit?.damage || 0),
          atkDelta: Number(equipped.actorAtk || 0) - Number(baseline.actorAtk || 0),
          incomingDamageDelta: Number(defenseBaseline.enemyHit?.damage || 0) - Number(defenseEquipped.enemyHit?.damage || 0),
          defDelta: Number(defenseEquipped.actorDef || 0) - Number(defenseBaseline.actorDef || 0),
          source: 'prototype-equipment-battle-effect',
          prototypeEquipmentBattleEffectImplemented: true,
          prototypeEquipmentBattleDefenseEffectImplemented: true,
          originalEquipmentEffectsImplemented: false,
          originalCombatFormulaImplemented: false,
          originalStoryFlagRuntimeImplemented: false,
        };
      })
    ));
  }))
  .catch((error) => fail(error.message || String(error)));
return true;
"""


def equipment_battle_effect_state_script() -> str:
    return "return window.__hwanseEquipmentBattleEffect || null;"


def new_game_equipment_start_script() -> str:
    return """
window.__hwanseNewGameEquipment = null;
const fail = (reason, extra = {}) => {
  window.__hwanseNewGameEquipment = {
    ok: false,
    reason,
    labels: menuItems().map((item) => menuItemLabel(item)),
    map: map?.name || '',
    runtimeState: runtimeState || null,
    loadedSaveSummary: loadedSaveSummary || null,
    ...extra,
  };
};
const resetEquipmentChangeFeedback = () => {
  window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_LOG = [];
  window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_RENDER = [];
  window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK = null;
  window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK_RENDER = null;
  if (typeof activeEquipmentChangeFeedbacks !== 'undefined') activeEquipmentChangeFeedbacks = [];
};
const resetSoundCapture = () => {
  window.HWANSE_SOUND_COUNTS = {};
  window.HWANSE_SOUND_LOG = [];
  window.HWANSE_LAST_SOUND = null;
};
const captureEquipmentChangeFeedback = () => ({
  log: Array.isArray(window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_LOG)
    ? [...window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_LOG]
    : [],
  render: Array.isArray(window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_RENDER)
    ? [...window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_RENDER]
    : [],
  last: window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK || null,
  lastRender: window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK_RENDER || null,
});
const captureSoundState = () => ({
  counts: { ...(window.HWANSE_SOUND_COUNTS || {}) },
  last: window.HWANSE_LAST_SOUND || null,
  log: Array.isArray(window.HWANSE_SOUND_LOG) ? [...window.HWANSE_SOUND_LOG] : [],
});
const equipmentSnapshot = (character) => character?.prototypeEquipment
  ? { ...character.prototypeEquipment }
  : null;
const memberStatsSnapshot = (character) => ({
  atk: battleMemberStat(character, 'atk'),
  def: battleMemberStat(character, 'def'),
  tech: battleMemberStat(character, 'tech'),
  quick: battleMemberStat(character, 'quick'),
  luck: battleMemberStat(character, 'luck'),
  equipment: equipmentSnapshot(character),
});
if (!loadedSaveSummary && typeof ensurePrototypeRuntimeState === 'function') ensurePrototypeRuntimeState();
if (!runtimeState || loadedSaveSummary) {
  fail('expected prototype runtime without loaded savedat');
  return true;
}
localStorage.removeItem(RUNTIME_SAVE_KEY);
menuMode = 'main';
menuOpen = true;
selectedMenuItemIndex = menuItems().findIndex((item) => item.command === 'openEquipmentEquipMenu');
if (selectedMenuItemIndex < 0) {
  fail('missing new-game openEquipmentEquipMenu command');
  return true;
}
const openResult = useSelectedMenuItem();
const equipItems = menuItems();
const equipIndex = equipItems.findIndex((item) => item.command === 'equipPrototypeItem' && item.equipmentItem?.index === 0);
if (equipIndex < 0) {
  fail('missing new-game cat claw equip command', { equipItems: equipItems.map((item) => menuItemLabel(item)) });
  return true;
}
const ataho = runtimeState.characters.find((character) => character.name === 'Ataho');
const before = {
  labels: equipItems.map((item) => menuItemLabel(item)),
  mode: menuMode,
  ...memberStatsSnapshot(ataho),
  loadedSaveSummary: loadedSaveSummary || null,
};
resetEquipmentChangeFeedback();
resetSoundCapture();
selectedMenuItemIndex = equipIndex;
const equipResult = useSelectedMenuItem();
if (typeof render === 'function') render();
const equipmentFeedback = captureEquipmentChangeFeedback();
const soundState = captureSoundState();
const after = {
  notice: menuNotice,
  change: window.HWANSE_LAST_EQUIPMENT_CHANGE || null,
  progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
  labels: menuItems().map((item) => menuItemLabel(item)),
  mode: menuMode,
  ...memberStatsSnapshot(ataho),
  statusReview: statusMenuReviewBlock(partyStatusMenuState()),
  loadedSaveSummary: loadedSaveSummary || null,
  equipmentFeedbackLog: equipmentFeedback.log,
  equipmentFeedbackRender: equipmentFeedback.render,
  equipmentFeedbackLast: equipmentFeedback.last,
  equipmentFeedbackLastRender: equipmentFeedback.lastRender,
  soundState,
};
const autoSave = window.HWANSE_LAST_EQUIPMENT_CHANGE_AUTO_SAVE || after.change?.autoSave || null;
if (ataho?.prototypeEquipment) ataho.prototypeEquipment.weapon = null;
quickLoadRuntime()
  .then((loaded) => {
    const restoredAtaho = runtimeState?.characters?.find((character) => character.name === 'Ataho') || null;
    if (typeof activeDialogue !== 'undefined') activeDialogue = null;
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_EQUIPMENT_COMPLETION_NOTICE = null;
    resetEquipmentChangeFeedback();
    resetSoundCapture();
    const objectiveBefore = typeof prototypeObjectiveState === 'function' ? prototypeObjectiveState() : null;
    const objectiveResult = typeof activatePrototypeObjectiveAction === 'function'
      ? activatePrototypeObjectiveAction()
      : false;
    if (typeof render === 'function') render();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_EQUIPMENT_COMPLETION_NOTICE || null;
    const objectiveEquipmentFeedback = captureEquipmentChangeFeedback();
    const objectiveSoundState = captureSoundState();
    const objectiveActiveDialogueBlock = activeDialogue
      ? {
          blockId: activeDialogue.block?.blockId || '',
          line: activeDialogue.lines?.[activeDialogue.index || 0] || '',
          lineCount: activeDialogue.lines?.length || 0,
        }
      : null;
    if (typeof activeDialogue !== 'undefined') activeDialogue = null;
    window.__hwanseNewGameEquipment = {
      ok: true,
      openResult,
      equipResult,
      saved: autoSave?.saved === true,
      autoSave,
      loaded,
      before,
      after,
      equipmentFeedbackLog: equipmentFeedback.log,
      equipmentFeedbackRender: equipmentFeedback.render,
      equipmentFeedbackLast: equipmentFeedback.last,
      equipmentFeedbackLastRender: equipmentFeedback.lastRender,
      soundState,
      restored: {
        map: map?.name || '',
        ...memberStatsSnapshot(restoredAtaho),
        progressCount: (prototypeProgress?.events || []).filter((event) => event.kind === 'equipment-prototype').length,
        progressReview: prototypeProgressReviewBlock(),
        statusReview: statusMenuReviewBlock(partyStatusMenuState()),
        change: window.HWANSE_LAST_EQUIPMENT_CHANGE || null,
        loadedSaveSummary: loadedSaveSummary || null,
      },
      objectiveBefore,
      objectiveResult,
      objectiveAction,
      objectiveCompletionNotice,
      objectiveEquipmentFeedbackLog: objectiveEquipmentFeedback.log,
      objectiveEquipmentFeedbackRender: objectiveEquipmentFeedback.render,
      objectiveEquipmentFeedbackLast: objectiveEquipmentFeedback.last,
      objectiveEquipmentFeedbackLastRender: objectiveEquipmentFeedback.lastRender,
      objectiveSoundState,
      objectiveActiveDialogueBlock,
    };
  })
  .catch((error) => fail(error.message || String(error), { before, after }));
return true;
"""


def new_game_equipment_state_script() -> str:
    return "return window.__hwanseNewGameEquipment || null;"


def capture_new_game_equipment_after_title_continue_script() -> str:
    return """
window.__hwanseNewGameEquipmentTitleRestore = null;
Promise.resolve()
  .then(() => {
    const resetEquipmentChangeFeedback = () => {
      window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_LOG = [];
      window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_RENDER = [];
      window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK = null;
      window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK_RENDER = null;
      if (typeof activeEquipmentChangeFeedbacks !== 'undefined') activeEquipmentChangeFeedbacks = [];
    };
    const resetSoundCapture = () => {
      window.HWANSE_SOUND_COUNTS = {};
      window.HWANSE_SOUND_LOG = [];
      window.HWANSE_LAST_SOUND = null;
    };
    const captureEquipmentChangeFeedback = () => ({
      log: Array.isArray(window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_LOG)
        ? [...window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_LOG]
        : [],
      render: Array.isArray(window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_RENDER)
        ? [...window.HWANSE_EQUIPMENT_CHANGE_FEEDBACK_RENDER]
        : [],
      last: window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK || null,
      lastRender: window.HWANSE_LAST_EQUIPMENT_CHANGE_FEEDBACK_RENDER || null,
    });
    const captureSoundState = () => ({
      counts: { ...(window.HWANSE_SOUND_COUNTS || {}) },
      last: window.HWANSE_LAST_SOUND || null,
      log: Array.isArray(window.HWANSE_SOUND_LOG) ? [...window.HWANSE_SOUND_LOG] : [],
    });
    const ataho = runtimeState?.characters?.find((character) => character.name === 'Ataho') || 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) };
    }
    if (typeof activeDialogue !== 'undefined') activeDialogue = null;
    window.HWANSE_LAST_OBJECTIVE_ACTION = null;
    window.HWANSE_LAST_EQUIPMENT_COMPLETION_NOTICE = null;
    resetEquipmentChangeFeedback();
    resetSoundCapture();
    const objectiveBefore = typeof prototypeObjectiveState === 'function' ? prototypeObjectiveState() : null;
    const objectiveResult = typeof activatePrototypeObjectiveAction === 'function'
      ? activatePrototypeObjectiveAction()
      : false;
    if (typeof render === 'function') render();
    const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
    const objectiveCompletionNotice = window.HWANSE_LAST_EQUIPMENT_COMPLETION_NOTICE || null;
    const objectiveEquipmentFeedback = captureEquipmentChangeFeedback();
    const objectiveSoundState = captureSoundState();
    const objectiveActiveDialogueBlock = activeDialogue
      ? {
          blockId: activeDialogue.block?.blockId || '',
          line: activeDialogue.lines?.[activeDialogue.index || 0] || '',
          lineCount: activeDialogue.lines?.length || 0,
        }
      : null;
    if (typeof activeDialogue !== 'undefined') activeDialogue = null;
    window.__hwanseNewGameEquipmentTitleRestore = {
      ok: true,
      loaded: true,
      titleContinue: true,
      scene,
      map: map?.name || '',
      search: window.location.search,
      atk: battleMemberStat(ataho, 'atk'),
      def: battleMemberStat(ataho, 'def'),
      equipment: ataho?.prototypeEquipment || null,
      progressCount: (prototypeProgress?.events || []).filter((event) => event.kind === 'equipment-prototype').length,
      progressReview: prototypeProgressReviewBlock(),
      statusReview: statusMenuReviewBlock(partyStatusMenuState()),
      loadedSaveSummary: loadedSaveSummary || null,
      savedPayload,
      objectiveBefore,
      objectiveResult,
      objectiveAction,
      objectiveCompletionNotice,
      objectiveEquipmentFeedbackLog: objectiveEquipmentFeedback.log,
      objectiveEquipmentFeedbackRender: objectiveEquipmentFeedback.render,
      objectiveEquipmentFeedbackLast: objectiveEquipmentFeedback.last,
      objectiveEquipmentFeedbackLastRender: objectiveEquipmentFeedback.lastRender,
      objectiveSoundState,
      objectiveActiveDialogueBlock,
      quickLoadHidden: document.getElementById('quickLoadButton')?.hidden ?? null,
      quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
    };
  })
  .catch((error) => {
    window.__hwanseNewGameEquipmentTitleRestore = { ok: false, error: String(error && error.message || error) };
  });
return true;
"""


def new_game_equipment_title_restore_state_script() -> str:
    return "return window.__hwanseNewGameEquipmentTitleRestore || null;"


def wait_for_equipment_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, equipment_menu_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state and state.get("ok") is True and state.get("activeId", "").startswith("equipment-menu:"):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate equipment menu did not become ready: {state!r}")


def wait_for_equipment_equip_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, equipment_equip_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state and state.get("ok") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate equipment equip did not become ready: {state!r}")


def wait_for_equipment_target_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, equipment_target_menu_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state and state.get("ok") is True and state.get("afterMenuMode") == "equipment-equip":
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate equipment target menu did not become ready: {state!r}")


def wait_for_equipment_battle_effect_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, equipment_battle_effect_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state and state.get("ok") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"candidate equipment battle effect did not become ready: {state!r}")


def wait_for_new_game_equipment_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, new_game_equipment_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state and state.get("ok") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"new-game equipment equip did not become ready: {state!r}")


def wait_for_new_game_equipment_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"new-game equipment title continue did not become ready: {state!r}")


def wait_for_new_game_equipment_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, new_game_equipment_title_restore_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if (
            state
            and state.get("ok") is True
            and state.get("titleContinue") is True
            and state.get("map") == "map1_02b"
            and state.get("progressCount") == 1
            and (state.get("equipment") or {}).get("weapon") == 0
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"new-game equipment title continue restore did not become ready: {state!r}")


def equipment_change_feedback_entries(state: dict, key: str) -> list[dict]:
    return [entry for entry in (state.get(key) or []) if isinstance(entry, dict)]


def equipment_change_feedback_summary(state: dict, key: str) -> str:
    return ",".join(
        f"{entry.get('source')}:{entry.get('character')}:{entry.get('slot')}:{entry.get('equipmentName')}:{entry.get('text')}"
        for entry in equipment_change_feedback_entries(state, key)
    )


def equipment_change_feedback_rendered(state: dict, key: str) -> bool:
    return any(
        entry.get("browserEquipmentChangeFeedbackImplemented") is True
        for entry in equipment_change_feedback_entries(state, key)
    )


def verify_equipment_sound_state(state: dict) -> None:
    sound_state = (state.get("after") or {}).get("soundState") or state.get("soundState") or {}
    counts = sound_state.get("counts") or {}
    log = sound_state.get("log") or []
    last = sound_state.get("last") or {}
    if (
        int(counts.get("item") or 0) < 1
        or last.get("key") != "item"
        or not str(last.get("src") or "").endswith("/extract_wlk/11.wav")
        or not any(isinstance(entry, dict) and entry.get("key") == "item" for entry in log)
    ):
        raise WebDriverError(f"missing equipment WLK item sound state: {sound_state!r}")


def verify_equipment_change_feedback(
    state: dict,
    key: str,
    *,
    character: str,
    slot: str,
    equipment_index: int,
    equipment_name: str,
    action: str,
    stat_text: str,
    atk_before: int,
    atk_after: int,
    def_before: int,
    def_after: int,
    text: str,
    quick_before: int | None = None,
    quick_after: int | None = None,
    rendered: bool = False,
) -> None:
    entries = equipment_change_feedback_entries(state, key)
    for entry in entries:
        if (
            entry.get("source") == "equipment-change-feedback"
            and entry.get("character") == character
            and entry.get("slot") == slot
            and entry.get("equipmentIndex") == equipment_index
            and entry.get("equipmentName") == equipment_name
            and entry.get("equipmentNameSource") == EXPECTED_EQUIPMENT_TEXT["equipmentNameSource"]
            and entry.get("equipmentTextTableKey") == EXPECTED_EQUIPMENT_TEXT["equipmentTextTableKey"]
            and entry.get("equipmentTextTableIndex") == EXPECTED_EQUIPMENT_TEXT["equipmentTextTableIndex"]
            and entry.get("equipmentTextTableRefVaHex") == EXPECTED_EQUIPMENT_TEXT["equipmentTextTableRefVaHex"]
            and entry.get("equipmentTextTableTextVaHex") == EXPECTED_EQUIPMENT_TEXT["equipmentTextTableTextVaHex"]
            and entry.get("action") == action
            and entry.get("statText") == stat_text
            and entry.get("atkBefore") == atk_before
            and entry.get("atkAfter") == atk_after
            and entry.get("defBefore") == def_before
            and entry.get("defAfter") == def_after
            and (quick_before is None or entry.get("quickBefore") == quick_before)
            and (quick_after is None or entry.get("quickAfter") == quick_after)
            and entry.get("text") == text
            and entry.get("durationMs") == 1100
            and entry.get("active") is True
            and entry.get("equipmentSound") == "item"
            and str(entry.get("equipmentSoundSrc") or "").endswith("/extract_wlk/11.wav")
            and entry.get("equipmentSoundPlayed") is True
            and entry.get("browserEquipmentChangeFeedbackImplemented") is True
            and entry.get("prototypeEquipmentEffectsImplemented") is True
            and entry.get("originalEquipmentOffsetsMapped") is False
            and entry.get("originalEquipmentEffectsImplemented") is False
            and entry.get("originalStoryFlagRuntimeImplemented") is False
            and (not rendered or (entry.get("x", 0) > 0 and entry.get("y", 0) > 0 and entry.get("alpha", 0) > 0))
        ):
            return
    raise WebDriverError(f"missing equipment change feedback {key}: {entries!r}")


def verify_equipment_menu_state(state: dict) -> None:
    main_labels = state.get("mainLabels") or []
    labels = state.get("labels") or []
    lines = "\n".join(state.get("lines") or [])
    equipment_menu = state.get("equipmentMenu") or {}
    rows = equipment_menu.get("rows") or []
    first_row = next((row for row in rows if row.get("index") == 0), {})
    names = {row.get("name") for row in rows}
    evidence = equipment_menu.get("evidence") or {}
    if (
        "장비 후보 18" in main_labels
        or "장비 변경 18" not in main_labels
        or "장비 후보 설명" not in labels
        or state.get("directEquipmentReviewRows") != 0
        or state.get("openResult") is not True
        or state.get("commandResult") is not True
        or state.get("afterOpenMenuMode") != "equipment-equip"
        or state.get("activeId") != "equipment-menu:map2_02d"
        or equipment_menu.get("count") != 18
        or equipment_menu.get("source") != "exe-text-table-equipment-candidates"
        or equipment_menu.get("tableStartVaHex") != "0x0048b244"
        or equipment_menu.get("inboundRefCount") != 2
        or evidence.get("status") != "offsets-unmapped"
        or evidence.get("source") != "HDNua/HandyHwanseEditor"
        or equipment_menu.get("originalEquipmentOffsetsMapped") is not False
        or equipment_menu.get("prototypeEquipmentEffectsImplemented") is not True
        or equipment_menu.get("originalEquipmentEffectsImplemented") is not False
        or equipment_menu.get("originalStoryFlagRuntimeImplemented") is not False
        or not {"고양이 발톱", "팬톰크로우 *", "청룡도", "투신의 갑옷"}.issubset(names)
        or "장비 후보 18개" not in lines
        or "EXE text table에서 추출한 장비명 후보를 표시합니다." not in lines
        or "savedat 소유/착용 offset과 원본 장비 효과는 아직 매핑하지 않았습니다." not in lines
        or "웹 prototype 장착은 캐릭터별 weapon/armor 슬롯과 전투 능력치 보너스만 적용합니다." not in lines
        or "MainForm.cs does not define savedat equipment offsets." not in lines
        or "MainForm.Designer.cs has disabled equipment UI labels only." not in lines
        or "out/text_tables.js exposes equipment name candidates" not in lines
        or "#0 Ataho weapon 고양이 발톱 ATK+4 QUICK+1" not in lines
        or "#17 Smashu armor 투신의 갑옷" not in lines
        or "prototypeEquipmentEffectsImplemented=True" not in lines
    ):
        raise WebDriverError(f"candidate equipment menu state is incomplete: {state!r}")
    verify_equipment_text_provenance(first_row)


def verify_equipment_target_menu_state(state: dict) -> None:
    before_labels = state.get("beforeLabels") or []
    after_open_labels = state.get("afterOpenLabels") or []
    marker = state.get("afterOpenMarker") or {}
    choices = marker.get("choices") or []
    selection = state.get("selection") or {}
    filtered_labels = state.get("filteredLabels") or []
    filtered_items = state.get("filteredItems") or []
    equip_items = [item for item in filtered_items if item.get("command") == "equipPrototypeItem"]
    if (
        "장비 대상 3" not in before_labels
        or "장비 변경 18" not in before_labels
        or "장비 후보 18" in before_labels
        or state.get("commandResult") is not True
        or state.get("selectResult") is not True
        or state.get("afterOpenMenuMode") != "equipment-target"
        or state.get("afterMenuMode") != "equipment-equip"
        or state.get("selectedEquipmentTargetName") != "Ataho"
        or marker.get("source") != "prototype-equipment-target-menu"
        or marker.get("map") != "map2_02d"
        or marker.get("count") != 3
        or marker.get("itemCount") != 18
        or [choice.get("targetName") for choice in choices] != ["Ataho", "Rinshan", "Smashu"]
        or [choice.get("itemCount") for choice in choices] != [5, 6, 7]
        or "대상 1/3 Ataho 5" not in after_open_labels
        or "대상 2/3 Rinshan 6" not in after_open_labels
        or "대상 3/3 Smashu 7" not in after_open_labels
        or "장비 대상 닫기" not in after_open_labels
        or selection.get("source") != "prototype-equipment-target-menu"
        or selection.get("map") != "map2_02d"
        or selection.get("targetName") != "Ataho"
        or selection.get("menuCandidateIndex") != 0
        or selection.get("menuCandidateCount") != 3
        or selection.get("itemCount") != 5
        or selection.get("opensMenuMode") != "equipment-equip"
        or "장비 후보 설명 Ataho" not in filtered_labels
        or "장착 Ataho 고양이 발톱 ATK+4 QUICK+1" not in filtered_labels
        or "장착 Ataho 가죽 갑옷 DEF+3" not in filtered_labels
        or "장착 Ataho 흑장속 DEF+4 QUICK+2" not in filtered_labels
        or any(str(label).startswith("장착 Rinshan") for label in filtered_labels)
        or any(str(label).startswith("장착 Smashu") for label in filtered_labels)
        or len(equip_items) != 5
        or {item.get("target") for item in equip_items} != {"Ataho"}
        or {item.get("slot") for item in equip_items} != {"weapon", "armor"}
        or [item.get("equipmentIndex") for item in equip_items] != [0, 1, 2, 13, 14]
        or marker.get("prototypeEquipmentEffectsImplemented") is not True
        or marker.get("originalEquipmentOffsetsMapped") is not False
        or marker.get("originalEquipmentEffectsImplemented") is not False
        or marker.get("originalStoryFlagRuntimeImplemented") is not False
        or selection.get("prototypeEquipmentEffectsImplemented") is not True
        or selection.get("originalEquipmentOffsetsMapped") is not False
        or selection.get("originalEquipmentEffectsImplemented") is not False
        or selection.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"candidate equipment target menu state is incomplete: {state!r}")


def verify_equipment_equip_state(state: dict) -> None:
    before = state.get("before") or {}
    after = state.get("after") or {}
    restored = state.get("restored") or {}
    change = after.get("change") or {}
    auto_save = state.get("autoSave") or change.get("autoSave") or {}
    auto_save_runtime = auto_save.get("runtimeState") or {}
    auto_save_characters = auto_save_runtime.get("characters") or []
    auto_save_ataho = next((row for row in auto_save_characters if row.get("name") == "Ataho"), {})
    auto_save_progress = auto_save.get("progress") or {}
    auto_save_tile = auto_save.get("payloadTile") or {}
    restored_review = "\n".join((restored.get("statusReview") or {}).get("lines") or [])
    progress_review = "\n".join((restored.get("progressReview") or {}).get("lines") or [])
    verify_equipment_text_provenance(change)
    verify_equipment_text_provenance(auto_save)
    verify_equipment_text_provenance((change.get("progressEvent") or {}).get("detail") or {})
    verify_equipment_change_feedback(
        state,
        "equipmentFeedbackLog",
        character="Ataho",
        slot="weapon",
        equipment_index=0,
        equipment_name="고양이 발톱",
        action="equip",
        stat_text="ATK+4 QUICK+1",
        atk_before=before.get("atk"),
        atk_after=after.get("atk"),
        def_before=before.get("def"),
        def_after=after.get("def"),
        text="고양이 발톱 장착 / ATK+4 QUICK+1",
        quick_before=before.get("quick"),
        quick_after=after.get("quick"),
    )
    verify_equipment_change_feedback(
        state,
        "equipmentFeedbackRender",
        character="Ataho",
        slot="weapon",
        equipment_index=0,
        equipment_name="고양이 발톱",
        action="equip",
        stat_text="ATK+4 QUICK+1",
        atk_before=before.get("atk"),
        atk_after=after.get("atk"),
        def_before=before.get("def"),
        def_after=after.get("def"),
        text="고양이 발톱 장착 / ATK+4 QUICK+1",
        quick_before=before.get("quick"),
        quick_after=after.get("quick"),
        rendered=True,
    )
    verify_equipment_sound_state(state)
    if (
        state.get("openResult") is not True
        or state.get("equipResult") is not True
        or state.get("saved") is not True
        or auto_save.get("saved") is not True
        or auto_save.get("source") != "equipment-prototype"
        or auto_save.get("payloadMap") != "map2_02d"
        or auto_save_tile.get("x") is None
        or auto_save_tile.get("y") is None
        or (auto_save_progress.get("counts") or {}).get("equipment-prototype") != 1
        or ((auto_save_ataho.get("prototypeEquipment") or {}).get("weapon") != 0)
        or auto_save.get("prototypeEquipmentEffectsImplemented") is not True
        or auto_save.get("originalEquipmentOffsetsMapped") is not False
        or auto_save.get("originalEquipmentEffectsImplemented") is not False
        or auto_save.get("originalStoryFlagRuntimeImplemented") is not False
        or state.get("loaded") is not True
        or before.get("mode") != "equipment-equip"
        or "장착 Ataho 고양이 발톱 ATK+4 QUICK+1" not in (before.get("labels") or [])
        or after.get("mode") != "equipment-equip"
        or after.get("atk") < before.get("atk")
        or after.get("def") != before.get("def")
        or after.get("quick") != before.get("quick") + 1
        or (after.get("equipment") or {}).get("weapon") != 0
        or change.get("character") != "Ataho"
        or change.get("slot") != "weapon"
        or change.get("equipmentName") != "고양이 발톱"
        or change.get("action") != "equip"
        or change.get("statText") != "ATK+4 QUICK+1"
        or change.get("atkBefore") != before.get("atk")
        or change.get("atkAfter") != after.get("atk")
        or change.get("defBefore") != before.get("def")
        or change.get("defAfter") != after.get("def")
        or change.get("quickBefore") != before.get("quick")
        or change.get("quickAfter") != after.get("quick")
        or change.get("source") != "prototype-equipment-effects"
        or change.get("prototypeEquipmentEffectsImplemented") is not True
        or change.get("originalEquipmentOffsetsMapped") is not False
        or change.get("originalEquipmentEffectsImplemented") is not False
        or restored.get("map") != "map2_02d"
        or restored.get("atk") != after.get("atk")
        or restored.get("def") != after.get("def")
        or restored.get("quick") != after.get("quick")
        or (restored.get("equipment") or {}).get("weapon") != 0
        or restored.get("progressCount") != 1
        or (restored.get("loadedSummaryEquipment") or {}).get("weapon") != 0
        or "장비 weapon:고양이 발톱 ATK+4 QUICK+1" not in restored_review
        or "ATK " not in restored_review
        or "equipment:Ataho:weapon" not in progress_review
        or "고양이 발톱" not in progress_review
    ):
        raise WebDriverError(f"candidate equipment equip state is incomplete: {state!r}")
    verify_equipment_unequip_state(state)


def verify_equipment_unequip_state(state: dict) -> None:
    unequip = state.get("unequip") or {}
    before = unequip.get("before") or {}
    after = unequip.get("after") or {}
    change = after.get("change") or {}
    auto_save = unequip.get("autoSave") or change.get("autoSave") or {}
    auto_save_runtime = auto_save.get("runtimeState") or {}
    auto_save_characters = auto_save_runtime.get("characters") or []
    auto_save_ataho = next((row for row in auto_save_characters if row.get("name") == "Ataho"), {})
    auto_save_progress = auto_save.get("progress") or {}
    auto_save_tile = auto_save.get("payloadTile") or {}
    verify_equipment_text_provenance(change)
    verify_equipment_text_provenance(auto_save)
    verify_equipment_text_provenance((change.get("progressEvent") or {}).get("detail") or {})
    verify_equipment_change_feedback(
        unequip,
        "equipmentFeedbackLog",
        character="Ataho",
        slot="weapon",
        equipment_index=0,
        equipment_name="고양이 발톱",
        action="unequip",
        stat_text="ATK-4 QUICK-1",
        atk_before=before.get("atk"),
        atk_after=after.get("atk"),
        def_before=before.get("def"),
        def_after=after.get("def"),
        quick_before=before.get("quick"),
        quick_after=after.get("quick"),
        text="고양이 발톱 해제 / ATK-4 QUICK-1",
    )
    verify_equipment_change_feedback(
        unequip,
        "equipmentFeedbackRender",
        character="Ataho",
        slot="weapon",
        equipment_index=0,
        equipment_name="고양이 발톱",
        action="unequip",
        stat_text="ATK-4 QUICK-1",
        atk_before=before.get("atk"),
        atk_after=after.get("atk"),
        def_before=before.get("def"),
        def_after=after.get("def"),
        quick_before=before.get("quick"),
        quick_after=after.get("quick"),
        text="고양이 발톱 해제 / ATK-4 QUICK-1",
        rendered=True,
    )
    verify_equipment_sound_state(unequip)
    if (
        unequip.get("ok") is not True
        or unequip.get("openResult") is not True
        or unequip.get("result") is not True
        or unequip.get("saved") is not True
        or auto_save.get("saved") is not True
        or auto_save.get("source") != "equipment-prototype"
        or auto_save.get("payloadMap") != "map2_02d"
        or auto_save_tile.get("x") is None
        or auto_save_tile.get("y") is None
        or (auto_save_progress.get("counts") or {}).get("equipment-prototype") != 2
        or "prototypeEquipment" not in auto_save_ataho
        or (auto_save_ataho.get("prototypeEquipment") or {}).get("weapon") is not None
        or auto_save.get("action") != "unequip"
        or auto_save.get("statText") != "ATK-4 QUICK-1"
        or auto_save.get("prototypeEquipmentEffectsImplemented") is not True
        or auto_save.get("originalEquipmentOffsetsMapped") is not False
        or auto_save.get("originalEquipmentEffectsImplemented") is not False
        or auto_save.get("originalStoryFlagRuntimeImplemented") is not False
        or before.get("mode") != "equipment-equip"
        or "해제 Ataho 고양이 발톱 ATK+4 QUICK+1" not in (before.get("labels") or [])
        or after.get("mode") != "equipment-equip"
        or "장착 Ataho 고양이 발톱 ATK+4 QUICK+1" not in (after.get("labels") or [])
        or after.get("atk") != before.get("atk") - 4
        or after.get("def") != before.get("def")
        or after.get("quick") != before.get("quick") - 1
        or (after.get("equipment") or {}).get("weapon") is not None
        or "Ataho 고양이 발톱 해제" not in str(after.get("notice") or "")
        or change.get("character") != "Ataho"
        or change.get("slot") != "weapon"
        or change.get("equipmentName") != "고양이 발톱"
        or change.get("action") != "unequip"
        or change.get("statText") != "ATK-4 QUICK-1"
        or change.get("atkBefore") != before.get("atk")
        or change.get("atkAfter") != after.get("atk")
        or change.get("defBefore") != before.get("def")
        or change.get("defAfter") != after.get("def")
        or change.get("quickBefore") != before.get("quick")
        or change.get("quickAfter") != after.get("quick")
        or change.get("source") != "prototype-equipment-effects"
        or change.get("prototypeEquipmentEffectsImplemented") is not True
        or change.get("originalEquipmentOffsetsMapped") is not False
        or change.get("originalEquipmentEffectsImplemented") is not False
    ):
        raise WebDriverError(f"candidate equipment unequip state is incomplete: {unequip!r}")


def verify_equipment_battle_effect_state(state: dict) -> None:
    baseline = state.get("baseline") or {}
    equipped = state.get("equipped") or {}
    baseline_hit = baseline.get("playerHit") or {}
    equipped_hit = equipped.get("playerHit") or {}
    baseline_turn = baseline.get("turnResult") or {}
    equipped_turn = equipped.get("turnResult") or {}
    defense_baseline = state.get("defenseBaseline") or {}
    defense_equipped = state.get("defenseEquipped") or {}
    defense_baseline_hit = defense_baseline.get("enemyHit") or {}
    defense_equipped_hit = defense_equipped.get("enemyHit") or {}
    defense_baseline_turn = defense_baseline.get("turnResult") or {}
    defense_equipped_turn = defense_equipped.get("turnResult") or {}
    equipped_effect = equipped.get("equipmentEffect") or {}
    equipped_bonus = equipped_effect.get("bonus") or {}
    equipped_hit_effect = equipped_hit.get("actorEquipmentEffect") or {}
    equipped_hit_bonus = equipped_hit.get("actorEquipmentBonus") or {}
    equipped_turn_effect = equipped_turn.get("playerEquipmentEffect") or {}
    defense_equipped_effect = defense_equipped.get("equipmentEffect") or {}
    defense_equipped_bonus = defense_equipped_effect.get("bonus") or {}
    defense_equipped_hit_effect = defense_equipped_hit.get("targetEquipmentEffect") or {}
    defense_equipped_hit_bonus = defense_equipped_hit.get("targetEquipmentBonus") or {}
    defense_equipped_turn_effect = defense_equipped_turn.get("targetEquipmentEffect") or {}
    baseline_damage = baseline_hit.get("damage")
    equipped_damage = equipped_hit.get("damage")
    defense_baseline_damage = defense_baseline_hit.get("damage")
    defense_equipped_damage = defense_equipped_hit.get("damage")
    if (
        state.get("ok") is not True
        or state.get("source") != "prototype-equipment-battle-effect"
        or state.get("prototypeEquipmentBattleEffectImplemented") is not True
        or state.get("prototypeEquipmentBattleDefenseEffectImplemented") is not True
        or state.get("originalEquipmentEffectsImplemented") is not False
        or state.get("originalCombatFormulaImplemented") is not False
        or state.get("originalStoryFlagRuntimeImplemented") is not False
        or baseline.get("battleBackground") != equipped.get("battleBackground")
        or baseline.get("enemyName") != equipped.get("enemyName")
        or baseline.get("commandName") != equipped.get("commandName")
        or baseline.get("commandResult") is not True
        or equipped.get("commandResult") is not True
        or (baseline.get("equipment") or {}).get("weapon") is not None
        or (equipped.get("equipment") or {}).get("weapon") != 0
        or int(state.get("atkDelta") or 0) != 4
        or int(state.get("damageDelta") or 0) <= 0
        or not isinstance(baseline_damage, int)
        or not isinstance(equipped_damage, int)
        or equipped_damage <= baseline_damage
        or equipped.get("predictedDamage") != equipped_damage
        or baseline.get("predictedDamage") != baseline_damage
        or equipped_bonus.get("atk") != 4
        or equipped_effect.get("active") is not True
        or equipped_effect.get("source") != "prototype-equipment-effects"
        or equipped_effect.get("prototypeEquipmentBattleEffectImplemented") is not True
        or equipped_effect.get("originalEquipmentEffectsImplemented") is not False
        or equipped_hit.get("source") != "player-attack"
        or equipped_hit.get("targetType") != "enemy"
        or equipped_hit.get("damage") != equipped_damage
        or equipped_hit.get("actorAtk") != equipped.get("actorAtk")
        or equipped_hit.get("actorEquipmentActive") is not True
        or equipped_hit.get("prototypeEquipmentBattleEffectImplemented") is not True
        or equipped_hit.get("originalEquipmentEffectsImplemented") is not False
        or equipped_hit_bonus.get("atk") != 4
        or equipped_hit_effect.get("source") != "prototype-equipment-effects"
        or equipped_hit_effect.get("prototypeEquipmentBattleEffectImplemented") is not True
        or equipped_turn.get("source") != "prototype-battle-turn-result"
        or equipped_turn.get("playerDamage") != equipped_damage
        or equipped_turn.get("playerEnemyHpBefore") != equipped.get("enemyHpBefore")
        or equipped_turn.get("playerEnemyHpAfter") != equipped.get("enemyHpBefore") - equipped_damage
        or equipped_turn.get("prototypeEquipmentBattleEffectImplemented") is not True
        or equipped_turn.get("originalEquipmentEffectsImplemented") is not False
        or (equipped_turn_effect.get("bonus") or {}).get("atk") != 4
        or equipped_turn_effect.get("prototypeEquipmentBattleEffectImplemented") is not True
        or baseline_turn.get("prototypeEquipmentBattleEffectImplemented") is not True
        or (baseline_turn.get("playerEquipmentEffect") or {}).get("active") is not False
        or defense_baseline.get("battleBackground") != defense_equipped.get("battleBackground")
        or defense_baseline.get("enemyName") != defense_equipped.get("enemyName")
        or defense_baseline.get("commandName") != defense_equipped.get("commandName")
        or defense_baseline.get("commandResult") is not True
        or defense_equipped.get("commandResult") is not True
        or (defense_baseline.get("equipment") or {}).get("armor") is not None
        or (defense_equipped.get("equipment") or {}).get("armor") != 14
        or int(state.get("defDelta") or 0) != 4
        or int(state.get("incomingDamageDelta") or 0) <= 0
        or not isinstance(defense_baseline_damage, int)
        or not isinstance(defense_equipped_damage, int)
        or defense_equipped_damage >= defense_baseline_damage
        or defense_baseline.get("predictedIncomingDamage") != defense_baseline_damage
        or defense_equipped.get("predictedIncomingDamage") != defense_equipped_damage
        or defense_equipped_bonus.get("def") != 4
        or defense_equipped_effect.get("active") is not True
        or defense_equipped_effect.get("source") != "prototype-equipment-effects"
        or defense_equipped_effect.get("prototypeEquipmentBattleEffectImplemented") is not True
        or defense_equipped_effect.get("originalEquipmentEffectsImplemented") is not False
        or defense_equipped_hit.get("source") != "enemy-attack"
        or defense_equipped_hit.get("targetType") != "party"
        or defense_equipped_hit.get("damage") != defense_equipped_damage
        or defense_equipped_hit.get("targetDef") != defense_equipped.get("actorDef")
        or defense_equipped_hit.get("targetEquipmentActive") is not True
        or defense_equipped_hit.get("prototypeEquipmentBattleDefenseEffectImplemented") is not True
        or defense_equipped_hit.get("originalEquipmentEffectsImplemented") is not False
        or defense_equipped_hit_bonus.get("def") != 4
        or defense_equipped_hit_effect.get("source") != "prototype-equipment-effects"
        or defense_equipped_hit_effect.get("prototypeEquipmentBattleEffectImplemented") is not True
        or defense_equipped_turn.get("source") != "prototype-battle-turn-result"
        or defense_equipped_turn.get("incomingDamage") != defense_equipped_damage
        or defense_equipped_turn.get("targetDef") != defense_equipped.get("actorDef")
        or defense_equipped_turn.get("prototypeEquipmentBattleDefenseEffectImplemented") is not True
        or defense_equipped_turn.get("originalEquipmentEffectsImplemented") is not False
        or (defense_equipped_turn_effect.get("bonus") or {}).get("def") != 4
        or defense_equipped_turn_effect.get("prototypeEquipmentBattleEffectImplemented") is not True
        or defense_baseline_turn.get("prototypeEquipmentBattleDefenseEffectImplemented") is not True
        or (defense_baseline_turn.get("targetEquipmentEffect") or {}).get("active") is not False
    ):
        raise WebDriverError(f"candidate equipment battle effect state is incomplete: {state!r}")


def verify_new_game_equipment_state(state: dict) -> None:
    before = state.get("before") or {}
    after = state.get("after") or {}
    restored = state.get("restored") or {}
    change = after.get("change") or {}
    auto_save = state.get("autoSave") or change.get("autoSave") or {}
    auto_save_runtime = auto_save.get("runtimeState") or {}
    auto_save_characters = auto_save_runtime.get("characters") or []
    auto_save_ataho = next((row for row in auto_save_characters if row.get("name") == "Ataho"), {})
    auto_save_progress = auto_save.get("progress") or {}
    auto_save_tile = auto_save.get("payloadTile") or {}
    restored_review = "\n".join((restored.get("statusReview") or {}).get("lines") or [])
    progress_review = "\n".join((restored.get("progressReview") or {}).get("lines") or [])
    verify_equipment_text_provenance(change)
    verify_equipment_text_provenance(auto_save)
    verify_equipment_text_provenance((change.get("progressEvent") or {}).get("detail") or {})
    verify_equipment_change_feedback(
        state,
        "equipmentFeedbackLog",
        character="Ataho",
        slot="weapon",
        equipment_index=0,
        equipment_name="고양이 발톱",
        action="equip",
        stat_text="ATK+4 QUICK+1",
        atk_before=before.get("atk"),
        atk_after=after.get("atk"),
        def_before=before.get("def"),
        def_after=after.get("def"),
        text="고양이 발톱 장착 / ATK+4 QUICK+1",
        quick_before=before.get("quick"),
        quick_after=after.get("quick"),
    )
    verify_equipment_change_feedback(
        state,
        "equipmentFeedbackRender",
        character="Ataho",
        slot="weapon",
        equipment_index=0,
        equipment_name="고양이 발톱",
        action="equip",
        stat_text="ATK+4 QUICK+1",
        atk_before=before.get("atk"),
        atk_after=after.get("atk"),
        def_before=before.get("def"),
        def_after=after.get("def"),
        text="고양이 발톱 장착 / ATK+4 QUICK+1",
        quick_before=before.get("quick"),
        quick_after=after.get("quick"),
        rendered=True,
    )
    verify_equipment_sound_state(state)
    if (
        state.get("openResult") is not True
        or state.get("equipResult") is not True
        or state.get("saved") is not True
        or auto_save.get("saved") is not True
        or auto_save.get("source") != "equipment-prototype"
        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("equipment-prototype") != 1
        or ((auto_save_ataho.get("prototypeEquipment") or {}).get("weapon") != 0)
        or auto_save.get("prototypeEquipmentEffectsImplemented") is not True
        or auto_save.get("originalEquipmentOffsetsMapped") is not False
        or auto_save.get("originalEquipmentEffectsImplemented") is not False
        or auto_save.get("originalStoryFlagRuntimeImplemented") is not False
        or state.get("loaded") is not True
        or before.get("loadedSaveSummary") is not None
        or after.get("loadedSaveSummary") is not None
        or restored.get("loadedSaveSummary") is not None
        or before.get("mode") != "equipment-equip"
        or "장착 Ataho 고양이 발톱 ATK+4 QUICK+1" not in (before.get("labels") or [])
        or after.get("mode") != "equipment-equip"
        or after.get("atk") != before.get("atk") + 4
        or after.get("def") != before.get("def")
        or after.get("quick") != before.get("quick") + 1
        or (after.get("equipment") or {}).get("weapon") != 0
        or "Ataho 고양이 발톱 장착" not in str(after.get("notice") or "")
        or change.get("character") != "Ataho"
        or change.get("slot") != "weapon"
        or change.get("equipmentName") != "고양이 발톱"
        or change.get("action") != "equip"
        or change.get("atkAfter") != after.get("atk")
        or change.get("quickBefore") != before.get("quick")
        or change.get("quickAfter") != after.get("quick")
        or change.get("source") != "prototype-equipment-effects"
        or change.get("prototypeEquipmentEffectsImplemented") is not True
        or change.get("originalEquipmentOffsetsMapped") is not False
        or change.get("originalEquipmentEffectsImplemented") is not False
        or restored.get("map") != "map1_02b"
        or restored.get("atk") != after.get("atk")
        or restored.get("def") != after.get("def")
        or restored.get("quick") != after.get("quick")
        or (restored.get("equipment") or {}).get("weapon") != 0
        or restored.get("progressCount") != 1
        or "장비 weapon:고양이 발톱 ATK+4 QUICK+1" not in restored_review
        or "ATK " not in restored_review
        or "equipment:Ataho:weapon" not in progress_review
        or "고양이 발톱" not in progress_review
    ):
        raise WebDriverError(f"new-game equipment equip state is incomplete: {state!r}")
    verify_equipment_completion_objective(state)


def verify_equipment_completion_notice_feedback(state: dict) -> None:
    feedback_log = equipment_change_feedback_entries(state, "objectiveEquipmentFeedbackLog")
    feedback_render_log = equipment_change_feedback_entries(state, "objectiveEquipmentFeedbackRender")
    feedback = state.get("objectiveEquipmentFeedbackLast") or {}
    feedback_render = state.get("objectiveEquipmentFeedbackLastRender") or {}
    sound_state = state.get("objectiveSoundState") or {}
    sound_counts = sound_state.get("counts") or {}
    if (
        len(feedback_log) < 1
        or len(feedback_render_log) < 1
        or int(sound_counts.get("menuConfirm") or 0) < 1
        or feedback.get("source") != "equipment-completion-notice-feedback"
        or feedback.get("text") != "장비 변경 완료 1/1"
        or feedback.get("map") != "map1_02b"
        or feedback.get("character") != "Ataho"
        or feedback.get("slot") != "weapon"
        or feedback.get("equipmentName") != "고양이 발톱"
        or feedback.get("action") != "equip"
        or feedback.get("statText") != "ATK 4 DEF 0"
        or feedback.get("atkAfter") != 4
        or feedback.get("defAfter") != 0
        or feedback.get("equipmentSound") != "menuConfirm"
        or not str(feedback.get("equipmentSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback.get("equipmentSoundPlayed") is not True
        or feedback.get("durationMs") != 1100
        or feedback.get("browserEquipmentChangeFeedbackImplemented") is not True
        or feedback.get("prototypeEquipmentEffectsImplemented") is not True
        or feedback.get("originalEquipmentOffsetsMapped") is not False
        or feedback.get("originalEquipmentEffectsImplemented") is not False
        or feedback.get("originalStoryFlagRuntimeImplemented") is not False
        or feedback_render.get("active") is not True
        or feedback_render.get("source") != "equipment-completion-notice-feedback"
        or feedback_render.get("text") != "장비 변경 완료 1/1"
        or feedback_render.get("map") != "map1_02b"
        or feedback_render.get("character") != "Ataho"
        or feedback_render.get("slot") != "weapon"
        or feedback_render.get("equipmentName") != "고양이 발톱"
        or feedback_render.get("action") != "equip"
        or feedback_render.get("statText") != "ATK 4 DEF 0"
        or feedback_render.get("equipmentSound") != "menuConfirm"
        or not str(feedback_render.get("equipmentSoundSrc") or "").endswith("/extract_wlk/04.wav")
        or feedback_render.get("equipmentSoundPlayed") is not True
        or feedback_render.get("durationMs") != 1100
        or feedback_render.get("browserEquipmentChangeFeedbackImplemented") is not True
        or feedback_render.get("prototypeEquipmentEffectsImplemented") is not True
        or feedback_render.get("originalEquipmentOffsetsMapped") is not False
        or feedback_render.get("originalEquipmentEffectsImplemented") is not False
        or feedback_render.get("originalStoryFlagRuntimeImplemented") is not False
    ):
        raise WebDriverError(f"equipment completion notice feedback is incomplete: {state!r}")


def equipment_completion_notice_feedback_summary(state: dict) -> str:
    feedback = state.get("objectiveEquipmentFeedbackLast") or {}
    rendered = state.get("objectiveEquipmentFeedbackLastRender") or {}
    return (
        f"equipmentNoticeFeedback={feedback.get('source')}:{feedback.get('text')} "
        f"equipmentNoticeFeedbackRender={rendered.get('active')} "
        f"equipmentNoticeSound={feedback.get('equipmentSound')}:"
        f"{feedback.get('equipmentSoundSrc')}:{feedback.get('equipmentSoundPlayed')}"
    )


def verify_equipment_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 {}
    notice_lines = " ".join(objective_notice.get("lines") or [])
    completion = objective_notice.get("completion") or {}
    if (
        objective_before.get("title") != "후보 장비 완료 map1_02b"
        or objective_before.get("nextAction") != "완료 알림 확인"
        or objective_before.get("detail") != "Ataho weapon 고양이 발톱"
        or objective_before.get("source") != "prototype-equipment-completion"
        or state.get("objectiveResult") is not True
        or objective_action.get("action") != "equipment-completion-notice"
        or objective_action.get("activeId") != "equipment-complete:map1_02b"
        or objective_action.get("prototypeEquipmentEffectsImplemented") is not True
        or objective_action.get("originalEquipmentOffsetsMapped") is not False
        or objective_action.get("originalEquipmentEffectsImplemented") is not False
        or objective_action.get("originalStoryFlagRuntimeImplemented") is not False
        or objective_notice.get("blockId") != "equipment-complete:map1_02b"
        or objective_notice.get("map") != "map1_02b"
        or "장비 변경 완료 1/1" not in notice_lines
        or "Ataho weapon 고양이 발톱 장착" not in notice_lines
        or "ATK 4 DEF 0" not in notice_lines
        or completion.get("completed") is not True
        or completion.get("eventCount") != 1
        or completion.get("equipmentName") != "고양이 발톱"
        or completion.get("source") != "prototype-equipment-effects"
        or completion.get("prototypeEquipmentEffectsImplemented") is not True
        or completion.get("originalEquipmentOffsetsMapped") is not False
        or completion.get("originalEquipmentEffectsImplemented") is not False
        or completion.get("originalStoryFlagRuntimeImplemented") is not False
        or objective_notice.get("prototypeEquipmentEffectsImplemented") is not True
        or objective_notice.get("originalEquipmentOffsetsMapped") is not False
        or objective_notice.get("originalEquipmentEffectsImplemented") is not False
        or objective_notice.get("originalStoryFlagRuntimeImplemented") is not False
        or objective_dialogue.get("blockId") != "equipment-complete:map1_02b"
        or objective_dialogue.get("line") != "장비 변경 완료 1/1"
        or objective_dialogue.get("lineCount", 0) < 4
    ):
        raise WebDriverError(f"unexpected equipment completion objective state: {state!r}")
    verify_equipment_completion_notice_feedback(state)


def verify_new_game_equipment_title_continue(title_state: dict, title_click: dict, state: dict) -> None:
    title_labels = [str(label) for label in (title_state.get("titleMenuLabels") or [])]
    status_review = "\n".join((state.get("statusReview") or {}).get("lines") or [])
    progress_review = "\n".join((state.get("progressReview") or {}).get("lines") or [])
    saved_payload = 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 state.get("titleContinue") is not True
        or state.get("loaded") is not True
        or state.get("scene") != "map"
        or state.get("map") != "map1_02b"
        or state.get("loadedSaveSummary") is not None
        or state.get("atk") != 4
        or state.get("def") != 0
        or (state.get("equipment") or {}).get("weapon") != 0
        or state.get("progressCount") != 1
        or saved_payload.get("map") != "map1_02b"
        or (saved_payload.get("loadedSaveSummary") is not None)
        or state.get("quickLoadText") != "임시 불러오기"
        or "장비 weapon:고양이 발톱 ATK+4 QUICK+1" not in status_review
        or "equipment:Ataho:weapon" not in progress_review
        or "고양이 발톱" not in progress_review
    ):
        raise WebDriverError(
            f"unexpected new-game equipment title continue state: title={title_state!r} "
            f"click={title_click!r} restore={state!r}"
        )
    verify_equipment_completion_objective(state)


def write_report(report: dict) -> None:
    out_dir = ROOT / "out"
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "candidate_equipment_menu_browser_smoke.json").write_text(
        json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    lines = [
        "# Candidate Equipment Menu Browser Smoke",
        "",
        f"- status: `{report.get('status')}`",
        f"- equipment menu: `{report.get('equipmentMenu')}`",
        f"- equipment target menu: `{report.get('equipmentTargetMenu')}`",
        f"- equipment equip: `{report.get('equipmentEquip')}`",
        f"- equipment unequip: `{report.get('equipmentUnequip')}`",
        f"- equipment battle effect: `{report.get('equipmentBattleEffect')}`",
        f"- new game equipment: `{report.get('newGameEquipment')}`",
        f"- new game equipment title continue: `{report.get('newGameEquipmentTitleContinue')}`",
        "",
    ]


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_equipment_menu_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,
            )

            url = load_map(
                base,
                port,
                session_id,
                {
                    "map": "map2_02d",
                    "startTile": "11,12",
                    "publicSave": "flack3r-savedat2",
                },
            )
            execute_js(port, session_id, equipment_menu_start_script(), timeout=3)
            state = wait_for_equipment_menu_state(port, session_id)
            verify_equipment_menu_state(state)
            equipment_menu = state.get("equipmentMenu") or {}
            names = ",".join(row.get("name") or "" for row in (equipment_menu.get("rows") or [])[:4])
            execute_js(port, session_id, equipment_target_menu_start_script(), timeout=3)
            equipment_target_state = wait_for_equipment_target_menu_state(port, session_id)
            verify_equipment_target_menu_state(equipment_target_state)
            execute_js(port, session_id, equipment_equip_start_script(), timeout=3)
            equip_state = wait_for_equipment_equip_state(port, session_id)
            verify_equipment_equip_state(equip_state)
            execute_js(port, session_id, equipment_battle_effect_start_script(), timeout=3)
            equipment_battle_effect_state = wait_for_equipment_battle_effect_state(port, session_id)
            verify_equipment_battle_effect_state(equipment_battle_effect_state)
            url_new_game_equipment = load_map(
                base,
                port,
                session_id,
                {
                    "map": "map1_02b",
                    "startTile": "11,12",
                },
            )
            execute_js(port, session_id, new_game_equipment_start_script(), timeout=3)
            new_game_equipment_state = wait_for_new_game_equipment_state(port, session_id)
            verify_new_game_equipment_state(new_game_equipment_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_new_game_equipment_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"new-game equipment title continue button was not usable: {title_click!r}")
            wait_for_map_runtime(port, session_id, "map1_02b")
            execute_js(port, session_id, capture_new_game_equipment_after_title_continue_script(), timeout=3)
            new_game_equipment_title_state = wait_for_new_game_equipment_title_restore_state(port, session_id)
            verify_new_game_equipment_title_continue(title_state, title_click, new_game_equipment_title_state)
            unequip_state = equip_state.get("unequip") or {}
            unequip_before = unequip_state.get("before") or {}
            unequip_after = unequip_state.get("after") or {}
            unequip_auto_save = unequip_state.get("autoSave") or {}
            report = {
                "status": "passed",
                "url": url,
                "newGameEquipmentUrl": url_new_game_equipment,
                "titleUrl": title_url,
                "equipmentMenu": (
                    f"label=장비 후보 설명 via=장비 변경 18 directEquipmentReviewRows={state.get('directEquipmentReviewRows', 0) > 0} active=equipment-menu:map2_02d "
                    f"source={equipment_menu.get('source')} "
                    f"table={equipment_menu.get('tableStartVaHex')} "
                    "equipmentTextTable=equipment "
                    "equipmentTextRef=0x0048b244 "
                    "equipmentTextVa=0x0048b52c "
                    f"names={names} "
                    "originalEquipmentOffsetsMapped=False "
                    "prototypeEquipmentEffectsImplemented=True "
                    "originalEquipmentEffectsImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "equipmentTargetMenu": (
                    "menuCount=3 selected=Ataho "
                    "selectionSource=prototype-equipment-target-menu "
                    "targetItems=5 "
                    "targetCounts=Ataho:5,Rinshan:6,Smashu:7 "
                    f"commandResult={equipment_target_state.get('commandResult')} "
                    f"selectResult={equipment_target_state.get('selectResult')} "
                    f"afterOpenMenuMode={equipment_target_state.get('afterOpenMenuMode')} "
                    f"afterMenuMode={equipment_target_state.get('afterMenuMode')} "
                    f"filteredItems={','.join(str(item.get('equipmentName') or '') for item in (equipment_target_state.get('filteredItems') or []) if item.get('command') == 'equipPrototypeItem')} "
                    "prototypeEquipmentEffectsImplemented=True "
                    "originalEquipmentOffsetsMapped=False "
                    "originalEquipmentEffectsImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "equipmentEquip": (
                    "item=고양이 발톱 character=Ataho slot=weapon "
                    f"atkBefore={(equip_state.get('before') or {}).get('atk')} "
                    f"atkAfter={(equip_state.get('after') or {}).get('atk')} "
                    f"restoredAtk={(equip_state.get('restored') or {}).get('atk')} "
                    f"progressCount={(equip_state.get('restored') or {}).get('progressCount')} "
                    f"autoSaved={(equip_state.get('autoSave') or {}).get('saved')} "
                    f"autoSource={(equip_state.get('autoSave') or {}).get('source')} "
                    "equipmentTextTable=equipment "
                    "equipmentTextRef=0x0048b244 "
                    "equipmentTextVa=0x0048b52c "
                    f"equipmentFeedback={equipment_change_feedback_summary(equip_state, 'equipmentFeedbackLog')} "
                    f"equipmentFeedbackRender={equipment_change_feedback_rendered(equip_state, 'equipmentFeedbackRender')} "
                    f"equipmentSound={((equip_state.get('equipmentFeedbackLast') or {}).get('equipmentSound') or '')} "
                    f"equipmentSoundSrc={((equip_state.get('equipmentFeedbackLast') or {}).get('equipmentSoundSrc') or '')} "
                    f"equipmentSoundPlayed={((equip_state.get('equipmentFeedbackLast') or {}).get('equipmentSoundPlayed'))} "
                    f"equipmentSoundItemCount={((((equip_state.get('after') or {}).get('soundState') or {}).get('counts') or {}).get('item'))} "
                    "source=prototype-equipment-effects "
                    "originalEquipmentOffsetsMapped=False "
                    "originalEquipmentEffectsImplemented=False"
                ),
                "equipmentUnequip": (
                    "item=고양이 발톱 character=Ataho slot=weapon action=unequip "
                    f"atkBefore={unequip_before.get('atk')} "
                    f"atkAfter={unequip_after.get('atk')} "
                    f"quickBefore={unequip_before.get('quick')} "
                    f"quickAfter={unequip_after.get('quick')} "
                    f"weaponAfter={(unequip_after.get('equipment') or {}).get('weapon')} "
                    f"autoSaved={unequip_auto_save.get('saved')} "
                    f"autoSource={unequip_auto_save.get('source')} "
                    "equipmentTextTable=equipment "
                    "equipmentTextRef=0x0048b244 "
                    "equipmentTextVa=0x0048b52c "
                    f"equipmentFeedback={equipment_change_feedback_summary(unequip_state, 'equipmentFeedbackLog')} "
                    f"equipmentFeedbackRender={equipment_change_feedback_rendered(unequip_state, 'equipmentFeedbackRender')} "
                    f"equipmentSound={((unequip_state.get('equipmentFeedbackLast') or {}).get('equipmentSound') or '')} "
                    f"equipmentSoundSrc={((unequip_state.get('equipmentFeedbackLast') or {}).get('equipmentSoundSrc') or '')} "
                    f"equipmentSoundPlayed={((unequip_state.get('equipmentFeedbackLast') or {}).get('equipmentSoundPlayed'))} "
                    f"equipmentSoundItemCount={(((unequip_after.get('soundState') or {}).get('counts') or {}).get('item'))} "
                    "source=prototype-equipment-effects "
                    "prototypeEquipmentEffectsImplemented=True "
                    "originalEquipmentOffsetsMapped=False "
                    "originalEquipmentEffectsImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "equipmentBattleEffect": (
                    "item=고양이 발톱 character=Ataho slot=weapon "
                    f"battle={((equipment_battle_effect_state.get('equipped') or {}).get('battleBackground'))} "
                    f"enemy={((equipment_battle_effect_state.get('equipped') or {}).get('enemyName'))} "
                    f"command={((equipment_battle_effect_state.get('equipped') or {}).get('commandName'))} "
                    f"atkDelta={equipment_battle_effect_state.get('atkDelta')} "
                    f"damageBaseline={(((equipment_battle_effect_state.get('baseline') or {}).get('playerHit') or {}).get('damage'))} "
                    f"damageEquipped={(((equipment_battle_effect_state.get('equipped') or {}).get('playerHit') or {}).get('damage'))} "
                    f"damageDelta={equipment_battle_effect_state.get('damageDelta')} "
                    "equipmentBonusAtk=4 "
                    "hitMarker=prototypeEquipmentBattleEffectImplemented=True "
                    "turnMarker=prototypeEquipmentBattleEffectImplemented=True "
                    "armor=흑장속 character=Ataho slot=armor "
                    f"defDelta={equipment_battle_effect_state.get('defDelta')} "
                    f"incomingDamageBaseline={(((equipment_battle_effect_state.get('defenseBaseline') or {}).get('enemyHit') or {}).get('damage'))} "
                    f"incomingDamageEquipped={(((equipment_battle_effect_state.get('defenseEquipped') or {}).get('enemyHit') or {}).get('damage'))} "
                    f"incomingDamageDelta={equipment_battle_effect_state.get('incomingDamageDelta')} "
                    "equipmentBonusDef=4 "
                    "defenseHitMarker=prototypeEquipmentBattleDefenseEffectImplemented=True "
                    "defenseTurnMarker=prototypeEquipmentBattleDefenseEffectImplemented=True "
                    "source=prototype-equipment-battle-effect "
                    "originalEquipmentEffectsImplemented=False "
                    "originalCombatFormulaImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "newGameEquipment": (
                    "map=map1_02b item=고양이 발톱 character=Ataho slot=weapon "
                    f"atkBefore={(new_game_equipment_state.get('before') or {}).get('atk')} "
                    f"atkAfter={(new_game_equipment_state.get('after') or {}).get('atk')} "
                    f"restoredAtk={(new_game_equipment_state.get('restored') or {}).get('atk')} "
                    f"progressCount={(new_game_equipment_state.get('restored') or {}).get('progressCount')} "
                    f"autoSaved={(new_game_equipment_state.get('autoSave') or {}).get('saved')} "
                    f"autoSource={(new_game_equipment_state.get('autoSave') or {}).get('source')} "
                    "equipmentTextTable=equipment "
                    "equipmentTextRef=0x0048b244 "
                    "equipmentTextVa=0x0048b52c "
                    f"equipmentFeedback={equipment_change_feedback_summary(new_game_equipment_state, 'equipmentFeedbackLog')} "
                    f"equipmentFeedbackRender={equipment_change_feedback_rendered(new_game_equipment_state, 'equipmentFeedbackRender')} "
                    f"equipmentSound={((new_game_equipment_state.get('equipmentFeedbackLast') or {}).get('equipmentSound') or '')} "
                    f"equipmentSoundSrc={((new_game_equipment_state.get('equipmentFeedbackLast') or {}).get('equipmentSoundSrc') or '')} "
                    f"equipmentSoundPlayed={((new_game_equipment_state.get('equipmentFeedbackLast') or {}).get('equipmentSoundPlayed'))} "
                    f"equipmentSoundItemCount={((((new_game_equipment_state.get('after') or {}).get('soundState') or {}).get('counts') or {}).get('item'))} "
                    f"objective={(new_game_equipment_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveNext={(new_game_equipment_state.get('objectiveBefore') or {}).get('nextAction')} "
                    f"objectiveAction={(new_game_equipment_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(new_game_equipment_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{equipment_completion_notice_feedback_summary(new_game_equipment_state)} "
                    "loadedSaveSummary=False source=prototype-equipment-effects "
                    "prototypeEquipmentEffectsImplemented=True "
                    "originalEquipmentOffsetsMapped=False "
                    "originalEquipmentEffectsImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "newGameEquipmentTitleContinue": (
                    "map=map1_02b titleContinue=True "
                    f"label={next((label for label in (title_state.get('titleMenuLabels') or []) if '이어하기 map1_02b' in str(label)), '')} "
                    "item=고양이 발톱 character=Ataho slot=weapon "
                    f"restoredAtk={new_game_equipment_title_state.get('atk')} "
                    f"progressCount={new_game_equipment_title_state.get('progressCount')} "
                    f"quickLoadText={new_game_equipment_title_state.get('quickLoadText')} "
                    f"search={new_game_equipment_title_state.get('search')} "
                    f"objective={(new_game_equipment_title_state.get('objectiveBefore') or {}).get('title')} "
                    f"objectiveNext={(new_game_equipment_title_state.get('objectiveBefore') or {}).get('nextAction')} "
                    f"objectiveAction={(new_game_equipment_title_state.get('objectiveAction') or {}).get('action')} "
                    f"objectiveActiveId={(new_game_equipment_title_state.get('objectiveAction') or {}).get('activeId')} "
                    f"{equipment_completion_notice_feedback_summary(new_game_equipment_title_state)} "
                    "loadedSaveSummary=False source=prototype-equipment-effects "
                    "prototypeEquipmentEffectsImplemented=True "
                    "originalEquipmentOffsetsMapped=False "
                    "originalEquipmentEffectsImplemented=False "
                    "originalStoryFlagRuntimeImplemented=False"
                ),
                "snapshots": {
                    "equipmentMenu": state,
                    "equipmentTargetMenu": equipment_target_state,
                    "equipmentEquip": equip_state,
                    "equipmentBattleEffect": equipment_battle_effect_state,
                    "newGameEquipment": new_game_equipment_state,
                    "newGameEquipmentTitle": title_state,
                    "newGameEquipmentTitleRestore": new_game_equipment_title_state,
                },
            }
            write_report(report)
            print(
                f"ok candidate equipment menu browser {report['equipmentMenu']} "
                f"equipmentUnequip={report['equipmentUnequip']} "
                f"equipmentBattleEffect={report['equipmentBattleEffect']} "
                f"newGameEquipmentTitleContinue={report['newGameEquipmentTitleContinue']}"
            )
        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()
