from __future__ import annotations

import queue
import threading
import time
import json
from contextlib import contextmanager

from . import catalog
from . import execution_rules
from .connector import Connector
from .errors import Cancelled, GameClientClosed, CoordinatePreparationRequired, MiniError, InputInterrupted, require
from .errors import DungeonObservationPending, ObservationUnavailable
from .dungeon_currency import DungeonCurrencyExhausted
from .gather_packets import GatherReceipt
from .packet_capture import NpcapSource
from .store import Store
from .timing import DEFAULT_REPEAT_INTERVAL, RESULT_BUTTON_SETTLE, ABYSS_FOOD_INTERVAL, repeat_interval, repeat_delay, input_delay
from .timing import RESULT_REVEAL_SETTLE, RESULT_REVEAL_RETRY, ABYSS_RETRY_RECHECK, DUNGEON_UNKNOWN_LIMIT
from .timing import PROCESSING_INPUT_SPEED, coordinate_interval_seconds
from .timing import coordinate_interval_randomized, sample_input_interval
from .windows import GameWindow, wait_input_idle, seconds_since_input, last_input_stamp, input_resume_delay_seconds
from .windows import UnsentInputInterrupted
from .focused_ocr import FocusedOCR
from .fullscreen_popups import recover_fullscreen_popup
from .source_common_observation import (common_field_ready, common_result_buttons,
    common_dungeon_signal, common_result_reveal_blocker, common_result_text_regions, common_footer_roi)
from .focused_ocr import normalized
from .focused_ocr import result_text_regions
from .coordinates import is_half_window, region_center
from .half_window_data import half_data
from .snack import TravelSnack, SNACK, MATERIALS, LOW_STOCK
from .dungeon_profiles import (
    canonical_mode, validate_destination, dungeon_food_allowed,
)
from .gather_geometry import prepared_requirement_locked
from .dungeon_routes import (
    matching_toggle_state, entry_option_state, result_footer_buttons, ResultButtonStability, ResultRevealGate,
)


def _input_is_owned(service, stamp):
    return (stamp is not None and getattr(service, "_last_own_input_stamp", None) == stamp
            and getattr(getattr(service, "window", None), "is_foreground", lambda: False)())


def wait_route_input_idle(service, idle_wait):
    """Keep route observations after user idle, without waiting on our own release."""
    check = getattr(service, "check", lambda: None)
    check()
    stamp = last_input_stamp()
    if not _input_is_owned(service, stamp) or last_input_stamp() != stamp:
        idle_wait(service.cancel, service.status,
                  lambda: input_resume_delay_seconds(getattr(service, "store", None)), check)
    check()
    sync = getattr(service, "sync_input_generation", None)
    if callable(sync):
        sync(last_input_stamp())


def _flag(activity, name, group):
    return activity.get(name, activity.get(group, {}).get(name))


def _activity_idle(activity):
    return (
        activity.get("Dungeon", {}).get("State") == "NotInDungeon"
        and _flag(activity, "IsDead", "combatState") is False
        and _flag(activity, "IsInCombat", "combatState") is False
        and _flag(activity, "IsAutoPlaying", "autoPlay") is False
        and _flag(activity, "IsAutoTraveling", "autoTravel") is False
    )


def _field_blockers(activity):
    blockers = []
    for name in ("IsDead", "IsInCombat"):
        if _flag(activity, name, "combatState") is not False:
            blockers.append(name)
    if activity.get("Dungeon", {}).get("State") != "NotInDungeon":
        blockers.append("Dungeon.State")
    for name in ("IsDialoguePlaying", "IsWaitingForSelection"):
        if _flag(activity, name, "dialogue") is True:
            blockers.append(name)
    if activity.get("IsAbyssResultSequencePlaying") is True:
        blockers.append("IsAbyssResultSequencePlaying")
    return tuple(blockers)


def _should_stop_gather(completed, receipt_count, native_goal, requested_goal):
    """Only a final partial native batch needs an explicit stop."""
    return (requested_goal is not None and completed + receipt_count >= requested_goal
            and 0 < receipt_count < native_goal)


class AutomationService:
    """The automation boundary contains no paid CLI action method."""

    def __init__(self, connector: Connector, store: Store, *, status=lambda _message: None,
                 repeat_range=DEFAULT_REPEAT_INTERVAL):
        self.connector = connector
        self.store = store
        self._ui_status = status
        self.status = self._emit
        self.cancel = threading.Event()
        self.window = None
        self.ocr = None
        self.repeat_range = repeat_interval(*repeat_range)
        self.input_resume_generation = 0
        self._last_own_input_stamp = None

    def _emit(self, message):
        self.store.log(message)
        self._ui_status(message)

    def stop(self):
        self.cancel.set()

    @property
    def ocr(self):
        return getattr(self, "_ocr", None)

    @ocr.setter
    def ocr(self, reader):
        self._ocr = reader
        bind = getattr(reader, "bind_observation", None)
        if callable(bind):
            bind(self.check, self.status)

    def sync_input_generation(self, stamp):
        """Account for an ended user event BEFORE snapshotting a new route.

        This does not mark the user's input as our own or skip the idle wait.
        """
        previous = getattr(self, "_last_own_input_stamp", None)
        foreign = getattr(self, "_last_foreign_input_stamp", None)
        if previous is None and foreign is None:
            self._last_foreign_input_stamp = stamp
            return
        baseline = previous if previous is not None else foreign
        if (stamp != baseline and
                stamp != getattr(self, "_last_foreign_input_stamp", None)):
            self.input_resume_generation = getattr(self, "input_resume_generation", 0) + 1
            self._last_foreign_input_stamp = stamp

    @property
    def use_travel_wings(self):
        """Read the user's shared setting; CLI command costs are unrelated."""
        return self.store.preference(("travel", "use_wings"), True) is True

    @property
    def input_resume_delay_seconds(self):
        return input_resume_delay_seconds(getattr(self, "store", None))

    def check(self):
        if self.cancel.is_set():
            raise Cancelled("사용자가 작업을 중단했습니다.")
        window = getattr(self, "window", None)
        hwnd = getattr(window, "hwnd", None)
        if hwnd:
            from .windows import user32
            if not user32.IsWindow(hwnd):
                raise GameClientClosed("게임 창이 종료되었습니다.")
        # A cancellation/rules/passive-observation check is not an input request.
        # Acquire focus only when input or a visible game frame is needed; CLI
        # and packet observation must not bring the game over another app.
        while True:
            try:
                execution_rules.service_boundary(self)
                return
            except ObservationUnavailable as exc:
                self.status(f"실행 상태 조회 재확인 · 현재 단계 유지 · {exc}")
                if self.cancel.wait(1.0):
                    raise Cancelled("사용자가 작업을 중단했습니다.")
                if hwnd and not user32.IsWindow(hwnd):
                    raise GameClientClosed("게임 창이 종료되었습니다.")

    def observation_foreground(self):
        """A screen-dependent wait may recover focus once idle, never block reads."""
        window = self.window
        if getattr(window, "is_foreground", lambda: True)():
            return True
        recover = getattr(window, "ensure_foreground", None)
        return bool(callable(recover) and recover(block=False))

    def _before_game_input(self):
        """Fresh execution ownership at writes, not every passive wait tick."""
        while True:
            self.check()
            guard = getattr(self, "before_game_input", None)
            try:
                if callable(guard):
                    guard()
                return
            except ObservationUnavailable as exc:
                self.status("실행 환경 재조회 · 입력 없이 현재 단계 유지 · " + str(exc))
                if self.cancel.wait(1):
                    raise Cancelled("사용자가 작업을 중단했습니다.")

    def sleep(self, seconds):
        end = time.monotonic() + seconds
        while True:
            self.check()
            # A process/cancel/rules check may outlast the remaining wait.
            # Calculate once *after* that guard; never send a negative duration.
            remaining = end - time.monotonic()
            if remaining <= 0:
                return
            time.sleep(min(0.1, remaining))

    @contextmanager
    def coordinate_sequence(self):
        """Run the existing tape under one rules snapshot and one input cadence."""
        self.check()
        depth = getattr(self, "_coordinate_depth", 0)
        window = getattr(self, "window", None)
        previous_window_depth = getattr(window, "_coordinate_sequence_depth", 0)
        with execution_rules.manager().hold():
            self._coordinate_depth = depth + 1
            if window is not None:
                window._coordinate_sequence_depth = previous_window_depth + 1
            try:
                yield
            finally:
                self._coordinate_depth = depth
                if window is not None:
                    window._coordinate_sequence_depth = previous_window_depth

    def coordinate_step(self):
        """Wait only the remainder of the user's shared press-to-press interval.

        Also usable before reading the outcome of the final tape input. Calling
        it twice adds no second delay; real travel/production waits stay separate.
        """
        self.check()
        previous = getattr(self, "_coordinate_last_event", None)
        if previous is None:
            return
        self.wait_input_interval(previous)

    def wait_input_interval(self, previous):
        """Shared press-to-press pacing, with one stable draw per input gap.

        A child service shares its GameWindow and therefore the same sampled
        gap. Live setting changes replace that gap's choice once, not per poll.
        """
        owner = getattr(self, "window", None) or self
        while True:
            self.check()
            store = getattr(self, "store", None)
            base = coordinate_interval_seconds(store)
            randomized = coordinate_interval_randomized(store)
            policy = (previous, base, randomized)
            choice = getattr(owner, "_input_interval_choice", None)
            if choice is None or choice[:3] != policy:
                choice = (*policy, sample_input_interval(base, randomized))
                owner._input_interval_choice = choice
            interval = choice[3]
            remaining = previous + interval - time.monotonic()
            if remaining <= 0.000001:
                return
            # Keep edits and cancellation live even during a long custom wait.
            self.sleep(min(.1, remaining))

    @execution_rules.stable_input
    def input(self, kind, value, *, guard=None, skip_if_guard_fails=False, wait_for_idle=True, before_send=None):
        if kind in ("click", "key", "quest_key", "scroll", "snack_text", "bag_text", "map_long_press", "map_drag") and not getattr(self, "_coordinate_depth", 0):
            # Result ACKs and other single-step connectors share the same
            # press-to-press cadence; do not add Windows before/after delays.
            with self.coordinate_sequence():
                return self._input_loop(kind, value, guard=guard, skip_if_guard_fails=skip_if_guard_fails,
                    wait_for_idle=wait_for_idle, before_send=before_send)
        return self._input_loop(kind, value, guard=guard, skip_if_guard_fails=skip_if_guard_fails,
            wait_for_idle=wait_for_idle, before_send=before_send)

    def _input_loop(self, kind, value, *, guard=None, skip_if_guard_fails=False, wait_for_idle=True, before_send=None):
        receipt_started = False
        def reserve():
            nonlocal receipt_started
            receipt_started = True  # Even a partially failed receipt is not replayable.
            before_send()
        while True:
            self.check()
            self.window._dispatch_started = False
            try:
                return self._input_once(kind, value, guard=guard,
                    skip_if_guard_fails=skip_if_guard_fails, wait_for_idle=wait_for_idle,
                    before_send=reserve if before_send is not None else None)
            except UnsentInputInterrupted as exc:
                if receipt_started or getattr(self.window, "_dispatch_started", False):
                    raise  # A wheel/text prefix or press may already have been sent.
                self._input_defer_reason = str(exc)
                if not wait_for_idle:
                    return False
                self.input_resume_generation = getattr(self, "input_resume_generation", 0) + 1
                self.status("사용자 입력으로 미전송 · 같은 단계의 현재 화면을 다시 확인합니다.")

    def _input_once(self, kind, value, *, guard=None, skip_if_guard_fails=False, wait_for_idle=True, before_send=None):
        self.check()
        in_sequence = bool(getattr(self, "_coordinate_depth", 0))
        if in_sequence and kind in ("click", "key", "quest_key", "scroll", "snack_text", "bag_text", "map_long_press", "map_drag"):
            self.coordinate_step()
        geometry_before = self._sync_geometry()
        self._input_defer_reason = None
        stamp = last_input_stamp()
        previous = getattr(self, "_last_own_input_stamp", None)
        speed = getattr(self, "_processing_input_speed", 1.0)
        owned_input = _input_is_owned(self, stamp)
        if wait_for_idle and not owned_input:
            wait_input_idle(self.cancel, self.status, lambda: self.input_resume_delay_seconds, self.check)
        elif not wait_for_idle and not owned_input and seconds_since_input() < self.input_resume_delay_seconds:
            self._input_defer_reason = "최근 입력 안정 대기 · 클릭 없이 관찰 유지"
            return False  # Result recovery keeps observing while the user is busy.
        self.check()
        current_stamp = last_input_stamp()
        if owned_input and current_stamp != stamp:
            if wait_for_idle:
                wait_input_idle(self.cancel, self.status, lambda: self.input_resume_delay_seconds, self.check)
                self.check()
                current_stamp = last_input_stamp()
            elif seconds_since_input() < self.input_resume_delay_seconds:
                self._input_defer_reason = "최근 입력 안정 대기 · 클릭 없이 관찰 유지"
                return False
        if previous is None:
            self.sync_input_generation(current_stamp)
        elif stamp != previous or current_stamp != previous:
            # A failed guard may retry without having sent an input. The same
            # already-observed user event must not invalidate every new proof.
            foreign_stamp = current_stamp if current_stamp != previous else stamp
            if foreign_stamp != getattr(self, "_last_foreign_input_stamp", None):
                self.input_resume_generation = getattr(self, "input_resume_generation", 0) + 1
                self._last_foreign_input_stamp = foreign_stamp
        recover = getattr(self.window, "ensure_foreground", None)
        if callable(recover) and not recover(block=wait_for_idle, for_input=True):
            self._input_defer_reason = "사용자 입력/게임 전경 대기 · 기존 단계 유지"
            return False
        if self._sync_geometry() != geometry_before and kind in ("click", "scroll", "move", "park", "map_long_press", "map_drag"):
            raise InputInterrupted("입력 대기 중 게임 UI 기하가 변경되어 현재 단계의 좌표를 다시 계산합니다.")
        if guard is not None:
            if not guard():
                if skip_if_guard_fails:
                    self._input_defer_reason = self._input_defer_reason or "입력 직전 화면/활동 확인 불일치"
                    return False
                raise InputInterrupted("입력 직전 화면 변경 · 미전송한 현재 단계 재확인")
        # The fresh ownership query may wait on a transient OS failure. It is
        # read-only, so any input event during it invalidates the route proof
        # above, even if the user is already idle when the query returns.
        ownership_stamp = last_input_stamp()
        self._before_game_input()
        if self._sync_geometry() != geometry_before:
            raise InputInterrupted("실행 권한 확인 중 게임 UI 기하 변경 · 현재 단계의 좌표 재확인")
        if ownership_stamp is None or last_input_stamp() != ownership_stamp:
            raise UnsentInputInterrupted("실행 권한 확인 중 사용자 입력 변경 · 미전송한 현재 단계 재확인")
        self.window._automation_input_guard = self._before_game_input
        input_wait = self.sleep if speed == 1.0 else lambda seconds: self.sleep(seconds / speed)
        # Only this dispatch's physical release/wheel metadata proves ownership.
        # Methods without that physical-event metadata remain unknown.
        self.window._automation_own_stamp = getattr(self, "_last_own_input_stamp", None)
        self.window.last_sent_input_stamp = None
        before_event = before_send
        if in_sequence:
            def before_event():
                if before_send is not None:
                    before_send()
                self._coordinate_last_event = time.monotonic()
        if kind == "key":
            if before_event is None:
                self.window.key(value, wait=input_wait, check=self.check)
            else:
                self.window.key(value, wait=input_wait, check=self.check, before_press=before_event)
        elif kind == "quest_key":
            def hold(seconds):
                if self.cancel.wait(seconds):
                    raise Cancelled("사용자가 작업을 중단했습니다.")
            self.window.quest_key(value, wait=hold, check=self.check, before_press=before_event)
        elif kind == "fishing_key":
            # Fishing is a timed key action, not a coordinate tape. In
            # particular, do not run slow ownership/rules queries while W/S
            # is held: both directions have the same bounded hold duration.
            def hold(seconds):
                if self.cancel.wait(seconds):
                    raise Cancelled("사용자가 작업을 중단했습니다.")
            if isinstance(value, tuple):
                name, hold_seconds = value
                self.window.fishing_key(name, hold_seconds=hold_seconds,
                                        wait=hold, check=self.check, before_press=before_event)
            else:
                self.window.fishing_key(value, wait=hold, check=self.check, before_press=before_event)
        elif kind == "click":
            if before_event is None:
                self.window.click(value, wait=input_wait, check=self.check)
            else:
                self.window.click(value, wait=input_wait, check=self.check, before_press=before_event)
        elif kind in ("map_long_press", "map_drag"):
            def hold(seconds):
                if self.cancel.wait(seconds):
                    raise Cancelled("사용자가 작업을 중단했습니다.")
            if kind == "map_long_press":
                point, seconds = value
                self.window.map_long_press(point, hold_seconds=seconds, wait=hold,
                                           check=self.check, before_press=before_event)
            else:
                start,end = value
                self.window.map_drag(start,end,wait=hold,check=self.check,before_press=before_event)
        elif kind == "scroll":
            point, delta = value
            if before_event is None:
                self.window.scroll(point, delta, wait=input_wait, check=self.check)
            else:
                self.window.scroll(point, delta, wait=input_wait, check=self.check, before_press=before_event)
        elif kind == "move":
            self.window.move(value)
        elif kind == "park":
            self.window.park_pointer(value)
        elif kind == "snack_text":
            if before_event is None:
                self.window.snack_text(value, wait=input_wait, check=self.check)
            else:
                self.window.snack_text(value, wait=input_wait, check=self.check, before_press=before_event)
        elif kind == "bag_text":
            if before_event is None:
                self.window.bag_text(value, wait=input_wait, check=self.check)
            else:
                self.window.bag_text(value, wait=input_wait, check=self.check, before_press=before_event)
        else:
            raise MiniError("등록되지 않은 입력 종류")
        # Capture at physical release, not after a delay during which the user
        # may have acted. Missing OS provenance keeps the conservative idle gate.
        self._last_own_input_stamp = getattr(self.window, "last_sent_input_stamp", None)
        return True

    def processing_input(self, kind, value, **kwargs):
        previous = getattr(self, "_processing_input_speed", 1.0)
        self._processing_input_speed = PROCESSING_INPUT_SPEED
        try:
            return self.input(kind, value, **kwargs)
        finally:
            self._processing_input_speed = previous


    def processing_pause(self, seconds):
        """Only authored short UI transitions, never production/repeat timers."""
        self.sleep(seconds / PROCESSING_INPUT_SPEED)

    def _wait_next_cycle(self, label):
        self.check()
        delay = repeat_delay(self.repeat_range)
        low, high = self.repeat_range
        self.status(f"{label} 완료 · 다음 실행까지 {delay:.2f}초 대기 ({low:g}~{high:g}초 랜덤)")
        self.sleep(delay)
        self.check()

    def prepare(self):
        # Every run owns a fresh service/event.  Clearing here used to lose a
        # cancellation pressed while the worker was still preparing Npcap or
        # the connector.
        self.check()
        bind = getattr(self.connector, "bind_observation", None)
        if callable(bind):
            bind(self.check, self.status)
        bind_input = getattr(self.connector, "bind_input_guard", None)
        if callable(bind_input):
            bind_input(self._before_game_input)
        while True:
            try:
                self.window = GameWindow(coordinate_runtime=self._ui_route_runtime())
                break
            except ObservationUnavailable as exc:
                self.status(f"게임 창 정보 재조회 · 준비 단계 유지 · {exc}")
                self.sleep(.5)
        bind = getattr(self.window, "bind_automation", None)
        if callable(bind):
            bind(self.cancel, self.status, idle_seconds=lambda: self.input_resume_delay_seconds)
        self.window._automation_input_guard = self._before_game_input
        self.check()
        size = self._window_size()
        if size is not None:
            self.status(f"실행 게임 화면 · {size[0]}×{size[1]} · "
                        f"{getattr(self.window, 'profile', '미확인')} · 물리 게임 영역 기준")
            settings = getattr(self.window, "ui_settings", None)
            if settings is not None:
                self.status(f"게임 UI 설정 · {settings.style} / {settings.percent}% · 게임 설정 기록에서 조회"
                            if settings.available else f"게임 UI 설정 조회 보류 · {settings.reason} · 현재 화면 판독 경로 유지")
            self.status("좌표 경로 · 16:9 PC70은 기존 좌표 유지 · 그 외는 클라이언트 수식/버튼 ID 참조")
        self.connector.connect()
        self.check()
        recover_fullscreen_popup(self)

    def _window_size(self):
        self._sync_geometry()
        width, height = getattr(self.window, "width", None), getattr(self.window, "height", None)
        return (width, height) if isinstance(width, int) and isinstance(height, int) else None

    def _sync_geometry(self):
        """Refresh read-only geometry; a new generation discards only UI caches."""
        window = getattr(self, "window", None)
        if window is None or not hasattr(window, "layout_generation"):
            return None
        window.refresh()
        generation = window.layout_generation
        previous = getattr(self, "_layout_generation", None)
        if generation != previous:
            self._layout_generation = generation
            if previous is not None:
                self.input_resume_generation = getattr(self, "input_resume_generation", 0) + 1
                self._half_processing_viewport = None
                if getattr(window, "coordinate_runtime", None) is None:
                    raise MiniError("고정 좌표 실행 중 게임 크기/UI 설정이 변경되었습니다. 수행 기록을 유지합니다. 작업을 다시 시작해 주세요.")
                self.status("게임 크기/UI 설정 변경 · 이전 좌표 폐기 · 현재 단계에서 다시 계산")
        return generation

    def _ui_route_runtime(self):
        from .ui_route_runtime import UiRouteRuntime
        attached = getattr(getattr(self, "window", None), "coordinate_runtime", None)
        if isinstance(attached, UiRouteRuntime):
            # Material-gather children share the parent's GameWindow. Their
            # producer MUST publish into the very store that window consumes.
            self._rule_runtime = attached
            return attached
        if not hasattr(self, "_rule_runtime"):
            self._rule_runtime = UiRouteRuntime(require_prepared=True)
        return self._rule_runtime

    def _half_window(self):
        if getattr(self.window, "dynamic_layout", False):
            return False
        size = self._window_size()
        return bool(size and is_half_window(*size))

    def _adaptive_window(self):
        self._sync_geometry()
        return bool(getattr(self.window, "dynamic_layout", False))

    def _source_window(self):
        return self._adaptive_window() and getattr(self.window, "coordinate_runtime", None) is not None

    def _adaptive_life(self):
        from .source_life_port import SourceLife
        if self.ocr is None:
            self.ocr = FocusedOCR()
        previous = getattr(self, "_adaptive_life_port", None)
        if previous is None or type(previous) is not SourceLife:
            self._adaptive_life_port = SourceLife(self)
            if previous is not None:
                self._adaptive_life_port.group = previous.group
                self._adaptive_life_port.recipe = previous.recipe
        return self._adaptive_life_port

    def _adaptive_dungeon(self):
        from .source_dungeon_port import SourceDungeon as AdaptiveDungeon
        if getattr(self, '_dungeon_module', None) == 'hunt':
            from .source_hunt_port import SourceHunt as AdaptiveDungeon
        if self.ocr is None:
            self.ocr = FocusedOCR()
        previous = getattr(self, "_adaptive_dungeon_port", None)
        if previous is None or type(previous) is not AdaptiveDungeon:
            self._adaptive_dungeon_port = AdaptiveDungeon(self)
            port = self._adaptive_dungeon_port
            run = getattr(self.store, "value", {}).get("run", {})
            destination = (getattr(previous, "destination", None) or run.get("destination", {}))
            port.destination = dict(destination)
            port.free = getattr(previous, "free", run.get("mode") in ("free_count", "free_infinite")
                                or port.module in ("abyss", "vanguard"))
            port.double_loot = getattr(previous, "double_loot", False)
            if hasattr(port, "observer"):
                port.observer.module, port.observer.destination = port.module, dict(port.destination)
                port.observer.free, port.observer.double_loot = port.free, port.double_loot
                port.selected_party = {"함께하기": "together", "혼자하기": "solo"}.get(destination.get("party"))
        return self._adaptive_dungeon_port

    def _source_neutral_target(self, role, guard):
        """Authored blank-background action, not a client button prediction."""
        return self._ui_route_runtime().prepared_ref("ui.neutral", role)

    def _dungeon_region(self, name):
        if self._adaptive_window():
            return self._adaptive_dungeon().region(name)
        return tuple(self._ui_route_runtime().prepared_bundle("dungeon.fixed")[name]["metadata"]["normalized_roi"])

    def _dungeon_target(self, name):
        if self._adaptive_window():
            return self._adaptive_dungeon().region(name)
        return self._ui_route_runtime().prepared_ref("dungeon.fixed", name)

    def _dungeon_pointer(self, role):
        return self._ui_route_runtime().prepared_ref("ui.pointer.dungeon", "pointer." + role)

    def _dungeon_result_target(self, action, buttons):
        from .source_result_targets import footer_composition
        composition = footer_composition(buttons,
            capsule_count=getattr(buttons["exit"], "footer_count", len(buttons)),
            include_other=action == "other")
        runtime = self._ui_route_runtime()
        parameters = dict(ordered_roles=composition.ordered_roles, party_info_present=None,
            auto_equip=False, auto_retry_toggle=False, show_buttons=True)
        width, height = self._window_size()
        # Role/count/alignment choose an EXISTING prepared MMML branch. These
        # observed boxes never become a coordinate record or an input target.
        def alignment_error(party):
            rows = runtime.prepared_bundle("dungeon.result", **dict(parameters, party_info_present=party))
            error = 0.0
            for role, observed in buttons.items():
                center = rows["dungeon.result."+role]["rect"].center
                error += abs(center[0]/width-(observed[0]+observed[2])/2)
                error += abs(center[1]/height-(observed[1]+observed[3])/2)
            return error
        parameters["party_info_present"] = min((None, True), key=alignment_error)
        return runtime.prepared_ref("dungeon.result", "dungeon.result." + action, **parameters)

    def _gather_link(self, *, node_id):
        self.coordinate_step()
        image = self.window.capture()
        runtime = self._ui_route_runtime()
        rows = runtime.prepared_bundle("gather.fixed")
        observation = rows["gather.requirements." + node_id]["metadata"]
        roi = observation["normalized_roi"]
        require(not prepared_requirement_locked(image, roi,
            fraction=execution_rules.number("life.requirement.fixed_red_fraction", .015),
            exclusions=observation.get("normalized_exclusions", ())),
            "현재 대상의 채집 레벨 또는 해제 조건이 부족하여 이동을 시작하지 않았습니다.")
        return runtime.prepared_ref("gather.fixed", "gather.travel." + node_id)

    def _gather_tape(self, node):
        from .processing_recovery import ProcessingViewUncertain
        while True:
            self.check()
            try:
                return self._gather_tape_once(node)
            except (ProcessingViewUncertain, InputInterrupted, ObservationUnavailable) as exc:
                self.status(f"채집 선택 단계 재확인 · 작업/완료 횟수 유지 · {exc}")
                point = self._adaptive_life().recover_gather_selection(node)
                if point is not None:
                    return self._recovered_gather_link(node, point)

    def _gather_tape_once(self, node):
        focus = getattr(self.window, "focus", None)
        if callable(focus):
            focus()
        if self._adaptive_window():
            return self._adaptive_life().gather_tape(node)
        # The fixed PC70 tape shares the same foreign-input exception boundary
        # as the source tape. Establish it after idle, not from a stale click
        # on Mini's Start button. No screen/OCR approval is added here.
        wait_route_input_idle(self, wait_input_idle)
        generation = getattr(self, "input_resume_generation", 0)
        def current():
            return getattr(self, "input_resume_generation", 0) == generation
        paths = catalog.action_paths()
        entry = paths["gather_navigation"]["nodes"][node["id"]]
        require(entry["name"] == node["name"] and entry["skill"] == node["skill"], "채집 도감과 좌표 묶음이 다릅니다.")
        steps = paths["batches"][entry["batch"]]["steps"]
        for step in steps:
            kind = step["kind"]
            if kind == "key":
                self.input("key", step["key"], guard=current)
            elif kind == "scroll":
                target = self._ui_route_runtime().prepared_ref("gather.fixed", step["control"])
                self.input("scroll", (target, int(step["delta"])), guard=current)
            else:
                target = self._ui_route_runtime().prepared_ref("gather.fixed", step["control"])
                self.input("click", target, guard=current)
        # The full tape owns this exact selection. Its item-specific frame,
        # UHD198/-100 offset and original random region are already prepared.
        return self._gather_link(node_id=node["id"])

    def _recovered_gather_link(self, node, observed):
        # The shared exception reader has just identified the selected detail.
        # Keep the successful fixed PC70 input region for that profile; do not
        # replace its actuator with a source-layout candidate after recovery.
        if self._adaptive_window():
            return observed
        return self._ui_route_runtime().prepared_ref("gather.fixed", "gather.travel." + node["id"])

    def _send_gather_start(self, node, point):
        """Retry only an unsent link; a dispatched prefix stays on observation."""
        from .processing_recovery import ProcessingViewUncertain
        while True:
            generation = getattr(self, "input_resume_generation", 0)
            def current():
                # _input_once advances the foreign-input generation before
                # evaluating this guard, on fixed AND adaptive profiles.
                return (getattr(self, "input_resume_generation", 0) == generation and
                        (not self._adaptive_window() or self._adaptive_life().guard_target(point)))
            try:
                sent = self.input("click", point, guard=current)
                if sent is not False:
                    return
            except (InputInterrupted, ProcessingViewUncertain, ObservationUnavailable) as exc:
                if getattr(self.window, "_dispatch_started", False):
                    self.status("채집 이동 입력 결과 재확인 · 재클릭 없이 기존 행동 관찰")
                    return
                self.status(f"채집 이동 미전송 · 현재 선택 단계 재확인 · {exc}")
            point = self._adaptive_life().recover_gather_selection(node)
            if point is None:
                point = self._gather_tape(node)
            else:
                point = self._recovered_gather_link(node, point)

    def _follow_gather_travel(self):
        seen_travel = False
        for _ in range(12):
            if self._recover_gather_popup():
                continue  # Re-read movement after the owned popup chain closes.
            activity = self.connector.read("get_activity")
            travelling = _flag(activity, "IsAutoTraveling", "autoTravel") is True
            playing = _flag(activity, "IsAutoPlaying", "autoPlay") is True
            if travelling:
                seen_travel = True
                if getattr(self, "use_travel_wings", True):
                    self.status("가까운 위치 이동 확인 · 이동용 날개 T 입력")
                    try:
                        self.input("key", "T", guard=lambda: getattr(self, "use_travel_wings", True),
                                   skip_if_guard_fails=True)
                    except InputInterrupted:
                        # T may have been delivered. The original native travel
                        # continues either way; never replay its wing shortcut.
                        self.status("이동용 날개 입력 결과 재확인 · 재전송 없이 기존 이동 관찰")
                else:
                    self.status("가까운 위치 이동 확인 · 날개 사용 안 함 · 일반 이동 계속")
                return {"travel_seen": True}
            if playing:
                self.status("현장 채집 시작 확인 · 추가 이동 입력 없음")
                return
            self.sleep(0.5)
        # The link input was sent exactly once. Do not resend it. The packet
        # receipt below can still prove a nearby target started directly.
        self.status("이동/채집 상태 전환 대기 · 가까운 위치 찾기 재클릭 없음")

    def _wait_packet_source(self, source):
        source.start()
        self._resume_packet_source(source, starting=True)
        self.status("Npcap 패킷 수신 준비 완료 · 채집 횟수 판독 대기")

    def _resume_packet_source(self, source, receipt=None, *, starting=False):
        if source.healthy:
            return
        reasons = {
            "game_tcp_unavailable": "게임 서버 연결(포트 16000)을 찾지 못했습니다.",
            "game_adapter_unavailable": "게임 연결이 사용하는 네트워크 어댑터를 찾지 못했습니다.",
            "npcap_devices_unavailable": "Npcap 어댑터 목록을 읽지 못했습니다.",
            "npcap_open_unavailable": "Npcap 어댑터를 열지 못했습니다. Npcap 설치의 관리자 제한 설정을 확인하세요.",
            "game_identity_unverified": "선택한 프로세스가 MabinogiMobile.exe인지 확인하지 못했습니다.",
            "ImportError": "미니 패킷 모듈 의존성을 불러오지 못했습니다.",
        }
        permanent = {"npcap_open_unavailable", "ImportError", "npcap_windows_only",
                     "npcap_link_unsupported", "npcap_filter_unavailable"}
        if receipt is not None:
            self._drain_gather_events(source, receipt)
            if receipt.result() is None:
                receipt.observation_gap = True
        while not source.healthy:
            self.check()
            # The passive reader may finish setup while check() yields. A
            # previous unhealthy observation is not permission to restart it.
            if source.healthy:
                return
            if starting and not source.ready.is_set():
                source.ready.wait(timeout=.1)
                continue
            reason = source.reason
            if source.healthy:
                return
            require(reason not in permanent and not (starting and reason == "OSError"),
                    "Npcap 설치/구성 확인 필요: " + reasons.get(reason, reason))
            self.status("채집 수신 연결 재확인 · 기존 작업/확인 횟수 유지 · " + reasons.get(reason, reason))
            self.sleep(1.0)
            self.check()
            if source.healthy:
                return
            starting = source.restart()

    def _recover_gather_popup(self):
        """Inspect only declared popups periodically, not on every packet tick."""
        now = time.monotonic()
        if now < getattr(self, "_gather_popup_check_at", 0):
            return False
        self._gather_popup_check_at = now + 3.0
        if not self.observation_foreground():
            return False  # Keep collecting packets while the user is active.
        handled = recover_fullscreen_popup(self)
        self._gather_popup_check_at = time.monotonic() + 3.0
        return handled

    def _wait_idle(self, seconds=180):
        while True:
            self.check()
            if _activity_idle(self.connector.read("get_activity")):
                return
            if self._recover_gather_popup():
                continue  # Same activity; do not reopen its route or clear counts.
            self.sleep(1)

    def _drain_gather_events(self, source, receipt):
        changed = False
        while True:
            try:
                event = source.events.get_nowait()
            except queue.Empty:
                return changed
            changed = receipt.accept(event) or changed

    def _stop_gather_at_goal(self, source, receipt, completed, node, *, timeout=180):
        """Wait for a stoppable frame; retry only explicit no-effect rejection."""
        attempts = 0
        accepted = False
        uncertain = False
        idle_reads = 0
        while True:
            self.check()
            self._resume_packet_source(source, receipt)
            if self._drain_gather_events(source, receipt):
                current = completed + receipt.count
                self.store.set_run(actions_completed=current, packet_count=receipt.count, packet_goal=receipt.goal)
                self.status(f"{node['name']} · 정지 대기 중 실제 완료 {current}")
            if self._recover_gather_popup():
                continue
            activity = self.connector.read("get_activity")
            blockers = _field_blockers(activity)
            button = activity.get("Mode", {}).get("MainButtonState")
            idle = not blockers and _activity_idle(activity) and button in (
                "Compass", "Interaction", "Combat", "Fishing")
            idle_reads = idle_reads + 1 if idle else 0
            if idle_reads >= 2:
                receipt.idle_ended = True
                self.status(f"{node['name']} · 정지 완료 확인 · 실제 {completed + receipt.count}회")
                return {"accepted": accepted, "attempts": attempts}
            if not blockers and button == "Stop" and not accepted and not uncertain:
                # Reserve before dispatch: a missing response never permits a
                # second stop call. Only explicit no-effect rejection does.
                uncertain = True
                attempts += 1
                try:
                    response = self.connector.stop_action()
                except (Cancelled, GameClientClosed):
                    raise
                except MiniError:
                    response = None
                if not isinstance(response, dict):
                    self.status("채집 정지 응답 재확인 · 재전송 없이 현재 행동 관찰")
                    self.sleep(.5)
                    continue
                if response.get("error") == "invalid_state":
                    uncertain = False
                    self.status("채집 동작 전환 구간 · 다음 정지 가능 상태까지 1초 대기")
                    self.sleep(1.0)
                    continue
                accepted = (not response.get("error") and isinstance(response.get("message"), str)
                            and bool(response["message"].strip()))
                self.status("채집 정지 명령 수락 · 공식 대기 상태 재확인" if accepted else
                            "채집 정지 결과 재확인 · 재전송 없이 현재 행동 관찰")
            self.sleep(0.5)

    def _observe_gather_quantity(self, output_item, **progress):
        """Accumulate observed gains; spending stock does not erase progress."""
        read_run = getattr(self.store, "run_snapshot", None)
        run = read_run() if callable(read_run) else self.store.value["run"]
        current = self.connector.item_quantity(output_item)
        earned = int(run.get("completed", 0))
        previous = run.get("last_quantity", run.get("current_quantity"))
        if previous is None:
            previous = int(run.get("baseline_quantity") or 0) + earned
        earned += max(0, current - previous)
        self.store.set_run(**dict(progress, completed=earned, current_quantity=current, last_quantity=current))
        return earned

    def bag_cleanup(self, *, name, category, keep):
        """One material surplus sale; independent and life queues share this route."""
        from .bag_cleanup import BagCleanup
        return BagCleanup(self).run(name=name, category=category, keep=keep)

    def fishing(self):
        """Manual fishing at the user's already-prepared fishing spot."""
        from .manual_fishing import ManualFishing
        return ManualFishing(self).run()

    def gather(self, node, goal=None, *, goal_kind="actions", output_item=None):
        """Click gather with packet action counts or accumulated item gains."""
        require(goal_kind in ("actions", "quantity"), "알 수 없는 채집 종료 조건입니다.")
        require(goal_kind != "quantity" or (isinstance(output_item, str) and output_item.strip()),
                "수량 목표는 정확한 수량 확인 품목명이 필요합니다.")
        self.prepare()
        source = NpcapSource(self.window.pid)
        try:
            self._wait_packet_source(source)
            parameters=dict(node=node["id"],goal=goal,goal_kind=goal_kind,output_item=output_item)
            if getattr(self,"_task_identity",None) is not None:
                parameters["queue_task_id"]=self._task_identity
            signature=json.dumps(parameters,
                                 sort_keys=True,ensure_ascii=False)
            saved=self.store.begin_run("gather",signature,node=node["id"],goal=goal,goal_kind=goal_kind,
                                       output_item=output_item,completed=0,actions_completed=0,
                                       baseline_quantity=None,state="running")
            actions_completed = int(saved.get("actions_completed", saved.get("completed", 0) if goal_kind == "actions" else 0))
            baseline_quantity = saved.get("baseline_quantity")
            if goal_kind == "quantity" and baseline_quantity is None:
                baseline_quantity = self.connector.item_quantity(output_item)
                self.store.set_run(baseline_quantity=baseline_quantity, last_quantity=baseline_quantity)
                completed = 0
            elif goal_kind == "quantity":
                completed = self._observe_gather_quantity(output_item)
            else:
                completed = actions_completed
            self.store.set_run(state="running", completed=completed, actions_completed=actions_completed)
        except BaseException:
            source.close()
            raise
        try:
            while goal is None or completed < goal:
                self.check()
                recover_fullscreen_popup(self)
                self._gather_popup_check_at = time.monotonic() + 3.0
                with self.coordinate_sequence():
                    start_point = self._gather_tape(node)
                    receipt = GatherReceipt(f"gather/{actions_completed}", node["id"], time.time(), allow_partial=True)
                    unit = "개" if goal_kind == "quantity" else "회"
                    self.status(f"{node['name']} 시작 · 완료 {completed}{unit}" + ("" if goal is None else f"/{goal}{unit}"))
                    self._send_gather_start(node, start_point)
                self._follow_gather_travel()
                stop_sent = False
                next_quantity_check = 0.0
                gap_idle_reads = 0
                gap_check_at = 0.0
                missing_start_check_at = time.monotonic() + 3.0
                while True:
                    self.check()
                    self._resume_packet_source(source, receipt)
                    changed = False
                    try:
                        event = source.events.get(timeout=0.25)
                    except queue.Empty:
                        event = None
                    if event is not None:
                        changed = receipt.accept(event)
                    missing_start = receipt.quest is None and time.monotonic() >= missing_start_check_at
                    if (receipt.observation_gap or receipt.invalid or missing_start) and time.monotonic() >= gap_check_at:
                        gap_check_at = time.monotonic() + 3.0
                        activity = self.connector.read("get_activity")
                        idle = _activity_idle(activity)
                        if receipt.quest is None:
                            # The same positive native stop boundary as the
                            # existing goal-stop path. Stop, loading/unknown,
                            # dialogue and missing Mode never prove completion.
                            idle = (idle and not _field_blockers(activity) and
                                    activity.get("Mode", {}).get("MainButtonState") in
                                    ("Compass", "Interaction", "Combat", "Fishing"))
                        gap_idle_reads = gap_idle_reads + 1 if idle else 0
                        if gap_idle_reads >= 2:
                            was_unowned = receipt.quest is None
                            if was_unowned:
                                # A CLI read may take time while packets keep
                                # arriving. Give already-buffered START/progress
                                # their normal producer path before retiring the
                                # unowned attempt; do not throw them away.
                                changed = self._drain_gather_events(source, receipt) or changed
                            if was_unowned and receipt.quest is not None:
                                gap_idle_reads = 0
                            else:
                                # A healthy receiver does not prove that this
                                # operation's START was received. Never adopt an
                                # unrelated progress packet or replay while native
                                # travel/play is active. Two explicit idle reads
                                # end only this observation, with zero invented
                                # actions; the existing route recovery runs next.
                                if receipt.quest is None:
                                    receipt.observation_gap = True
                                receipt.idle_ended = True
                                self.status(("채집 시작 수신 미확인 · 공식 행동 종료 확인 · 현재 경로 재확인" if receipt.quest is None
                                             else "채집 행동 종료 확인 · 수신 누락분은 만들지 않고 확인된 횟수만 유지"))
                    action_current = actions_completed + receipt.count
                    progress = (dict(actions_completed=action_current, packet_count=receipt.count,
                                     packet_goal=receipt.goal) if changed else {})
                    quantity_due = goal_kind == "quantity" and time.monotonic() >= next_quantity_check
                    if changed:
                        if goal_kind == "actions":
                            completed = action_current
                            self.store.set_run(**dict(progress, completed=completed))
                            self.status(f"{node['name']} · 완료 {completed}회" + ("" if goal is None else f"/{goal}회"))
                        elif not quantity_due:
                            self.store.set_run(**progress)
                    if quantity_due:
                        completed = self._observe_gather_quantity(output_item,
                            **dict(progress, actions_completed=action_current))
                        self.status(f"{node['name']} · {output_item} 추가 {completed}개" +
                                    ("" if goal is None else f"/{goal}개") + f" · 행동 {action_current}회")
                        next_quantity_check = time.monotonic() + 3.0
                    stop_required = (_should_stop_gather(actions_completed, receipt.count, receipt.goal, goal)
                                     if goal_kind == "actions" else
                                     goal is not None and completed >= goal and 0 < receipt.count < receipt.goal)
                    if stop_required and not stop_sent:
                        self._stop_gather_at_goal(source, receipt, actions_completed, node)
                        stop_sent = True
                    result = receipt.result()
                    if result:
                        actions_completed += result["actions"]
                        if goal_kind == "quantity":
                            completed = self._observe_gather_quantity(output_item,
                                actions_completed=actions_completed, state="between_batches")
                        else:
                            completed = actions_completed
                            self.store.set_run(completed=completed, actions_completed=actions_completed,
                                               state="between_batches")
                        break
                    self._recover_gather_popup()  # Buffered packet receipt remains the same operation.
                if goal is not None and completed >= goal:
                    break
                self._wait_idle()
                self._wait_next_cycle("채집 한 묶음")
            self.store.set_run(completed=completed, state="complete")
            return completed
        finally:
            source.close()

    def _works_for_group(self, group):
        names = catalog.facility_recipe_names(group)
        value = self.connector.altering_works()
        rows = [r for r in value["works"] if isinstance(r, dict) and r.get("DisplayName") in names]
        facilities = {r.get("FacilityName") for r in rows if r.get("FacilityName")}
        require(len(facilities) <= 1, "선택 가공 분야의 시설 응답이 둘 이상입니다.")
        return rows

    @staticmethod
    def _processing_wait_seconds(rows, recipe_seconds):
        pending = [row for row in rows if row.get("IsCompleted") is not True]
        if not pending:
            return 0.0
        official = []
        for row in pending:
            try:
                value = float(row.get("RemainingSeconds"))
            except (TypeError, ValueError):
                continue
            if value >= 0:
                official.append(value)
        # Existing jobs can have a different recipe duration, so their fresh
        # official remaining time wins.  Newly registered jobs fall back to
        # the catalog's surveyed duration for the selected material.
        return max(official) + 3.0 if official else float(recipe_seconds) + 3.0

    def processing(self, recipe, goal=None, *, goal_kind="runs", shortage_gather=False, navigate=False):
        from .single_processing_goal import normalize_goal
        options = normalize_goal(goal, goal_kind, shortage_gather, navigate)
        multi = self.store.value.get("multi_processing_checkpoint", {})
        require(not self.store.value.get("multi_processing_pending") and
                not any(f.get("batch") for f in multi.get("facilities", {}).values()),
                "26에서 제거된 멀티 가공의 기록이 남아 있습니다. 원본 확인 후 작업 완전 초기화해 주세요.")
        self.prepare()
        return self._processing_adaptive(recipe, **options)

    def _single_collection_port(self):
        from .multi_processing_port import MultiProcessingPort
        if not hasattr(self, "_single_collect_port"):
            self._single_collect_port = MultiProcessingPort(self, single=True)
        return self._single_collect_port

    def _settled_processing_works(self, group):
        from .multi_processing_port import work_counts
        from .processing_recovery import receipt_require
        first = self._works_for_group(group)
        self.sleep(3.0)
        second = self._works_for_group(group)
        receipt_require(work_counts(first) == work_counts(second),
                        "가공 작업 목록 갱신 중 · 두 차례 일치할 때까지 재확인")
        return second

    def _resume_single_collection(self, recipe):
        self._single_collection_port().finish_collection(recipe)
        rows = self._half_reconcile_processing_pending(recipe, collection_ui_settled=True)
        self._half_processing_owned_reward = False
        return rows

    def _single_collect_processing(self, recipe, rows, *, allow_partial=False):
        if self.store.value.get("processing_pending"):
            return self._resume_single_collection(recipe)
        if not rows or not (any(row.get("IsCompleted") is True for row in rows) if allow_partial
                            else all(row.get("IsCompleted") is True for row in rows)):
            return rows
        port = self._single_collection_port()
        collected = int(self.store.value["run"].get("collected", 0))
        goal = getattr(self, "_single_processing_goal", None)
        goal_receipt = goal.collection_receipt(rows, port) if goal is not None else None
        def reserve_collection():
            before = getattr(port, "collection_before", rows)
            self.store.set_processing_pending(dict(kind="collect_batch", recipe=recipe["id"],
                group=recipe["group"], before_count=len(before), expected_count=0,
                before_works=before, collected_before=collected, single_goal=goal_receipt,
                task_identity=getattr(self, "_task_identity", None)))
        collect_options = {"allow_partial": True} if allow_partial else {}
        result = port.collect(recipe["group"], rows, before_send=reserve_collection, **collect_options)
        if result.get("confirmed") and (not result["after"] or allow_partial and result.get("collected", 0)):
            current = self._half_reconcile_processing_pending(recipe, rows=result["after"])
            self._half_processing_owned_reward = False
            return current
        if self.store.value.get("processing_pending"):
            # Keep ownership until a missed Confirm/slot-detail close has
            # actually returned to the facility; clearing it here loses the
            # only route that is allowed to finish the reward UI.
            return self._resume_single_collection(recipe)
        return self._half_reconcile_processing_pending(recipe)

    def _processing_adaptive(self, recipe, goal=None, *, goal_kind="runs", shortage_gather=False, navigate=False):
        """Existing facility/registration/collection tapes with supply-first goals.

        Normal batches read their outcome once. Only interrupted or missing
        registration uses two settled reads before filling actual empty slots.
        """
        from .single_processing_goal import SingleProcessingGoal, normalize_goal, output_per_batch
        options = normalize_goal(goal, goal_kind, shortage_gather, navigate)
        shortage_gather, navigate = options["shortage_gather"], options["navigate"]
        if options["goal_kind"] == "quantity":
            output_per_batch(recipe)
        port = self._single_collection_port()
        group = recipe["group"]
        # Keep old no-option signatures resumable; queue item identity separates
        # two consecutive identical requests without resetting user history.
        identity = dict(recipe=recipe["id"])
        if options != normalize_goal():
            identity.update(options)
        if getattr(self, "_task_identity", None) is not None:
            identity["task_identity"] = self._task_identity
        signature = json.dumps(identity, sort_keys=True)
        pending = self.store.value.get("processing_pending")
        previous = self.store.value.get("run", {})
        stage = previous.get("processing_stage") or {}
        stage_recipe = next((r for r in catalog.processing_catalog()["recipes"]
                             if r["id"] == stage.get("recipe_id")), recipe)
        stage_group = stage_recipe["group"]
        owned_recipe_pairs = {(recipe["id"], group)}
        if previous.get("signature") == signature and stage.get("recipe_id") == stage_recipe["id"]:
            owned_recipe_pairs.add((stage_recipe["id"], stage_group))
        cleanup = self.store.value.get("processing_cleanup")
        if ((pending or cleanup) and
                (options["goal_kind"] == "register_only" or previous.get("goal_kind") == "register_only")):
            require(previous.get("signature") == signature,
                    "이전 가공 입력/수령 기록이 남아 있습니다. 같은 종료 조건과 작업으로 재개해 주세요.")
        require(not cleanup or cleanup.get("kind") == "processing" and
                cleanup.get("task_identity") == getattr(self, "_task_identity", None) and
                (cleanup.get("recipe"), cleanup.get("group")) in owned_recipe_pairs,
                "이전 가공 완료창 기록이 남아 있습니다. 같은 작업으로 재개해 주세요.")
        require(not pending or isinstance(pending, dict) and
                pending.get("task_identity") == getattr(self, "_task_identity", None) and
                (pending.get("recipe"), pending.get("group")) in owned_recipe_pairs and
                pending.get("kind") in ("register_batch", "collect_batch"),
                "이전 품목의 가공 기록이 있습니다. 같은 품목으로 재개해 주세요.")
        if not (cleanup and previous.get("signature") == signature and previous.get("state") == "complete"):
            self.store.begin_run("processing", signature, recipe=recipe["id"], registered=0,
                                 collected=0, completed=0, target_collected=0, produced=0,
                                 processing_stage=None, processing_supply=None, processing_owned={},
                                 processing_initial_drained=False, processing_registration_pass=None,
                                 state="running", **options)
        target = self._single_processing_goal = SingleProcessingGoal(self, recipe, options)
        selected = target.selected()
        seconds = int(recipe["displayed_processing_seconds"])
        require(seconds > 0, "가공 품목의 소요시간이 없습니다.")
        # Direct and queue starts both observe their real initial surface.
        # No initial visit belongs outside the transient-error retry boundary.
        resume_cleanup = bool(cleanup)
        start_pending = not resume_cleanup and not target.run.get("processing_supply")
        if resume_cleanup:
            port._owned_view = True
        missing_registration = False
        fill_needed = True
        while True:
            self.check()
            try:
                if resume_cleanup:
                    port.settle_collection_tail()
                    resume_cleanup = False
                    if (target.complete and not self.store.value.get("processing_pending") and
                            (target.register_only or not any(target.run.get("processing_owned", {}).values()))):
                        return target.result(port, "complete")
                if target.run.get("processing_supply"):
                    target.supply(port)
                    fill_needed = True
                selected = target.selected()
                group = selected["group"]
                rows = None
                pending = self.store.value.get("processing_pending")
                if pending:
                    if pending["kind"] == "collect_batch":
                        rows = self._resume_single_collection(selected)
                    else:
                        rows = self._adaptive_reconcile_processing_pending(selected)
                        missing_registration = True
                    fill_needed = True
                if target.register_only and target.complete:
                    return self._finish_registration_only(target, port)
                if rows == [] and target.complete:
                    return target.result(port, "complete")
                if start_pending:
                    if navigate:
                        port.visit(selected)
                    else:
                        port.adopt_local(selected)
                    start_pending = False
                if not port.local_owned(group):
                    # Only a prior interruption/recovery may have left the
                    # user-prepared facility. Normal cycles never visit it.
                    port.visit(selected)
                if rows is None:
                    rows = (self._settled_processing_works(group) if missing_registration else
                            self._works_for_group(group))
                missing_registration = False
                initial_work = not target.run.get("processing_initial_drained", False)
                if initial_work and not rows:
                    self.store.set_run(processing_initial_drained=True)
                    initial_work = False
                if target.complete and not rows:
                    return target.result(port, "complete")
                collect_ready = rows and (any(row.get("IsCompleted") is True for row in rows)
                    if target.register_only else all(row.get("IsCompleted") is True for row in rows))
                # Registration-only collects the initial ready output but never
                # newly registered work, even if it finishes during recovery.
                if collect_ready and (not target.register_only or target.run.get("processing_registration_pass") is None):
                    rows = self._single_collect_processing(selected, rows, allow_partial=target.register_only)
                    if not rows:
                        target.clear_stage()
                        if selected["id"] == recipe["id"]:
                            self.store.set_run(processing_preparation_jobs=None)
                        self.store.set_run(processing_initial_drained=True)
                    if target.complete and not rows:
                        return target.result(port, "complete")
                    if not target.register_only:
                        self._wait_next_cycle("가공 수령")
                    fill_needed = True
                    continue
                # Like multi processing, drain pre-existing work before a new
                # finite target. Its output is not this queue item's production.
                drain_first = initial_work and (target.finite or shortage_gather)
                capacity = port.capacity(group, rows) if fill_needed and not drain_first else len(rows)
                free = max(0, capacity - len(rows))
                fill_needed = False
                requested = target.requested_jobs(selected, rows, free)
                stock = None
                if target.register_only:
                    plan = target.run.get("processing_registration_pass")
                    if plan is None:
                        # With no free slot this visit is already complete.
                        # Unrelated stock/transfer reads cannot delay it. Ready
                        # initial output was handled above as before; running
                        # jobs are neither waited for nor collected here.
                        if free:
                            stock = port.inventory()
                            available = port._materials(selected, free, stock, partial=True)
                        else:
                            available = 0
                        plan = dict(jobs=available, free=free, shortage=available < free)
                        # Durable one-pass quota precedes the first consuming
                        # press. Cancel/retry cannot open a second batch.
                        self.store.set_run(processing_registration_pass=plan, processing_initial_drained=True)
                    requested = min(free, max(0, plan["jobs"] - target.progress))
                    if not requested:
                        return self._finish_registration_only(target, port)
                    stock = port.inventory() if stock is None else stock
                    available = port._materials(selected, requested, stock, partial=True)
                    if not available:
                        plan = dict(plan, shortage=True)
                        self.store.set_run(processing_registration_pass=plan)
                        return self._finish_registration_only(target, port)
                    requested = min(requested, available)
                if shortage_gather and not rows and free:
                    stock = port.inventory()
                    preparation_jobs = target.supply_jobs(capacity)
                    if not target.finite:
                        self.store.set_run(processing_preparation_jobs=preparation_jobs)
                    plan = target.shortage_plan(stock, preparation_jobs)
                    if plan.get("blocked"):
                        return target.result(port, "incomplete", " / ".join(plan["blocked"]))
                    if plan.get("gathers"):
                        target.clear_stage()
                        target.supply(port, plan)
                        fill_needed = True
                        continue
                    if plan.get("stages"):
                        first = plan["stages"][0]
                        if first["recipe_id"] != selected["id"]:
                            if first["recipe_id"] == recipe["id"]:
                                target.clear_stage()
                            else:
                                target.start_stage(first)
                            fill_needed = True
                            continue
                        requested = min(free, first["jobs"])
                if requested and not target.register_only:
                    stock = port.inventory() if stock is None else stock
                    available = port._materials(selected, requested, stock, partial=True)
                    if shortage_gather and rows and available < requested:
                        requested = 0  # Drain owned work, then prepare its successor.
                    if not available and not rows:
                        if shortage_gather:
                            # Only a changed observation can contradict the
                            # prepared first stage. Replan before any input.
                            fill_needed = True
                            self.sleep(.5)
                            continue
                        return target.result(port, "incomplete" if target.finite else "complete",
                                             "다음 1회분 재료 부족 · 부족시 종료")
                    requested = min(requested, available)
                if requested:
                    registered = int(self.store.value["run"].get("registered", 0))
                    def reserve_registration():
                        before = getattr(port, "registration_before", rows)
                        self.store.set_processing_pending(dict(kind="register_batch", recipe=selected["id"],
                            group=group, before_count=len(before), expected_count=len(before) + requested,
                            registered_before=registered, before_works=before,
                            single_registration=target.registration_receipt(selected),
                            task_identity=getattr(self, "_task_identity", None)))
                    def save_storage_tail(tail):
                        pending = dict(self.store.value["processing_pending"])
                        if tail is None:
                            pending.pop("storage_confirmation", None)
                        else:
                            pending["storage_confirmation"] = tail
                        self.store.update_processing_pending(pending)
                    port.registration_tail_update = save_storage_tail
                    self.status(f"{selected['name']} · 현재 빈 슬롯 {free}개 중 {requested}건 등록")
                    result = port.register(selected, requested, before_send=reserve_registration,
                                           rows=rows, capacity=capacity, stock=stock)
                    missing_registration = result["registered"] < result["attempted"]
                    rows = result["after"]
                    if not missing_registration:
                        rows = self._half_reconcile_processing_pending(selected, rows=rows)
                    fill_needed = missing_registration or not rows or result["attempted"] < requested
                if target.register_only:
                    if not self.store.value.get("processing_pending") and target.complete:
                        return self._finish_registration_only(target, port)
                    # The storage-question branch sends one owned registration;
                    # continue filling its original quota without production
                    # waits. Missing input is reconciled by the next iteration.
                    fill_needed = True
                    continue
                if rows:
                    completed = sum(row.get("IsCompleted") is True for row in rows)
                    self.status(f"가공 진행 중 · 완료 {completed}/{len(rows)} · 빈 슬롯과 목록 재확인")
                    duration = int(selected["displayed_processing_seconds"])
                    self.sleep(min(15.0, max(3.0, self._processing_wait_seconds(rows, duration))))
                else:
                    self.status("등록된 작업 없음 · 재고·빈 슬롯 재확인 후 등록 재시도")
                    fill_needed = True
                    self.sleep(3.0)
            except (Cancelled, GameClientClosed, CoordinatePreparationRequired):
                raise
            except MiniError as exc:
                # Only this processing operation retries; input checks and
                # coordinates remain in the port. Pending state is retained
                # until two fresh reads agree. Disk/programming errors escape.
                self.status(f"가공 재확인 · {exc} · 현재 작업 목록에서 계속 진행")
                missing_registration = True
                fill_needed = True
                self.sleep(3.0)
                if target.run.get("processing_supply"):
                    # The saved gather child owns this route until its own
                    # idle/recovery path finishes; do not visit a facility in
                    # the middle of that child's resumption.
                    continue
                if (self.store.value.get("processing_pending") or {}).get("storage_confirmation"):
                    # The current storage question belongs to that register.
                    # Do not ESC it, revisit, or repeat a consuming press.
                    continue
                try:
                    port.recover(group, timeout=15.0,
                                 owned_receipt=bool(self.store.value.get("processing_pending")))
                except (Cancelled, GameClientClosed, CoordinatePreparationRequired):
                    raise
                except MiniError:
                    pass

    def _finish_registration_only(self, target, port):
        """Report stock shortage only after all possible registration inputs."""
        plan = target.run.get("processing_registration_pass") or {}
        reason = ("재료 부족 · 가능한 등록 완료 · 부족분 보충 안 함" if plan.get("shortage")
                  else "남은 슬롯 없음" if not plan.get("free") else "등록만 하기 완료")
        return target.result(port, "complete", reason)

    def _adaptive_reconcile_processing_pending(self, recipe):
        return self._half_reconcile_processing_pending(recipe)

    def _half_processing_text(self, image, key):
        if self.ocr is None:
            self.ocr = FocusedOCR()
        return normalized(self.ocr.text(image, half_data()["processing_controls"][key]))

    def _half_processing_state(self, group, image=None):
        image = self.window.capture() if image is None else image
        controls = half_data()["processing_controls"]
        title = next(row["name"] for row in catalog.processing_catalog()["groups"] if row["id"] == group)
        if ("가공완료" in self._half_processing_text(image, "result_heading") and
                "확인" in self._half_processing_text(image, "result_caption")):
            return "reward"
        local_heading = normalized(title) in self._half_processing_text(image, "local_title")
        remote_heading = normalized(title) in self._half_processing_text(image, "remote_title")
        if local_heading or remote_heading:
            caption = self._half_processing_text(image, "recipe_caption")
            materials = self._half_processing_text(image, "materials_text")
            if "필요한재료" in materials:
                if "가공하러가기" in caption:
                    return "remote_detail"
                if "가공하기" in caption:
                    return "local_detail"
            if "설비로이동" in self._half_processing_text(image, "travel_text"):
                return "remote"
            if local_heading and "모두받기" in self._half_processing_text(image, "collect_text"):
                return "local"
        if common_field_ready(self, image):
            return "field"
        # The six-facility list has a plain '가공' heading and the selected
        # facility's own card label. Never treat an arbitrary K submenu as it.
        header = normalized(self.ocr.text(image, execution_rules.geometry(
            "observation.automation.HALF_PROCESSING_HEADER", image=image)))
        card = normalized(self.ocr.text(image, controls["facilities"][group]))
        if header == "가공" and normalized(title) in card:
            return "facilities"
        return "unknown"

    def _half_reconcile_processing_pending(self, recipe, *, rows=None, collection_ui_settled=False):
        from copy import deepcopy
        pending = deepcopy(self.store.value.get("processing_pending"))
        if not pending:
            return rows if rows is not None else self._settled_processing_works(recipe["group"])
        require(isinstance(pending, dict) and pending.get("recipe") == recipe["id"] and
                pending.get("task_identity") == getattr(self, "_task_identity", None) and
                pending.get("group") == recipe["group"] and pending.get("kind") in ("register_batch", "collect_batch"),
                "이전 가공 품목의 작업 기록을 확인해 주세요.")
        if rows is None:
            rows = self._settled_processing_works(recipe["group"])
        if pending["kind"] == "register_batch" and pending.get("storage_confirmation"):
            rows = self._single_collection_port().settle_registration_tail(recipe, pending,
                lambda: self.store.update_processing_pending(pending), rows=rows)
        before_rows = pending.get("before_works")
        if before_rows is not None:
            from .multi_processing_port import work_counts
            before, current = work_counts(before_rows), work_counts(rows)
            if pending["kind"] == "register_batch":
                name = catalog.ALTERING_NAMES.get(recipe["id"], recipe["name"])
                changed = sum(n for (_, display), n in (current - before).items() if display == name)
            else:
                changed = sum((before - current).values())
        else:
            changed = max(0, (len(rows) - pending["before_count"]) if pending["kind"] == "register_batch"
                          else (pending["before_count"] - len(rows)))
        if pending["kind"] == "register_batch":
            from .single_processing_goal import registration_updates
            self.store.set_run(registered=max(int(self.store.value["run"].get("registered", 0)),
                                             int(pending["registered_before"]) + changed),
                               last_batch_registered=changed,
                               **registration_updates(pending, rows, changed))
        else:
            if changed and not collection_ui_settled:
                self._single_collection_port().remember_collected_result(recipe)
            from .single_processing_goal import collection_updates
            updates = collection_updates(pending, rows, self._single_collection_port().inventory)
            for key in ("target_collected", "produced", "completed"):
                if key in updates:
                    updates[key] = max(int(self.store.value["run"].get(key, 0)), updates[key])
            self.store.set_run(collected=max(int(self.store.value["run"].get("collected", 0)),
                                            int(pending["collected_before"]) + changed),
                               state="between_batches", **updates)
            self._half_processing_owned_reward = True
        self.store.set_processing_pending(None)
        self.status(f"가공 작업 목록 재확인 · 실제 변경 {changed}건 · 남은 작업에서 계속")
        return rows

    def _processing_half(self, recipe):
        return self._processing_adaptive(recipe)

    def _dungeon_activity(self):
        return self.connector.read("get_activity")

    def _check_dungeon_currency(self, *, image=None, balance_only=False, refusal_only=False, observation=None):
        """Only the selected exhaustion mode reads this real entry boundary."""
        if not getattr(self, "_dungeon_stop_on_currency", False):
            return
        from .dungeon_currency import CURRENCY_NAMES, current_balance, current_entry_cost, explicit_shortage
        from .adaptive_ui import read_rows
        from .source_common_observation import common_modal_roi
        currency = CURRENCY_NAMES[self._dungeon_module]
        context = observation if observation is not None else self._dungeon_observation_context()
        refusal = False
        if refusal_only:
            if image is None:
                return
            if self.ocr is None:
                self.ocr = FocusedOCR()
            refusal = explicit_shortage(read_rows(self.ocr, image,
                common_modal_roi(self, image)), currency)
            if not refusal:
                return
        self.check()
        try:
            self.connector.require_command("get_currencies")
            rows = self.connector.read("get_currencies", recovery_seconds=0)
        except (Cancelled, GameClientClosed, CoordinatePreparationRequired):
            raise
        except MiniError as exc:
            self.status("던전 재화 조회 미확인 · 소진으로 처리하지 않음 · " + str(exc))
            return
        self.check()
        amount = current_balance(rows, currency)
        if amount is None or not self._same_dungeon_observation(context):
            self.status("던전 재화 수량/관측 문맥 미확인 · 소진으로 처리하지 않음")
            return
        evidence = dict(currency=currency, balance=amount, basis="official_cli_zero")
        if amount == 0:
            raise DungeonCurrencyExhausted(evidence)
        if balance_only:
            return
        if refusal:
            raise DungeonCurrencyExhausted(dict(evidence, basis="current_refusal_and_official_balance"))
        self.coordinate_step()
        image = self.window.capture() if image is None else image
        if self.ocr is None:
            self.ocr = FocusedOCR()
        if self._source_window() or self._dungeon_module == 'hunt':
            entries = self._adaptive_dungeon().observer._read(image, ("footer",), "goddess.entry")
        else:
            entries = read_rows(self.ocr, image, self._dungeon_region("COMBAT_ENTER_REGION"))
        cost = current_entry_cost(entries)
        if cost is not None and amount < cost and self._same_dungeon_observation(context):
            raise DungeonCurrencyExhausted(dict(evidence, cost=cost,
                basis="current_entry_price_and_official_balance"))

    def _finish_currency_exhausted(self, completed, evidence, *, at_result=False):
        if at_result:
            self._adaptive_result_action("exit")
        self.store.set_run(completed=completed, state="complete_currency_exhausted",
                           currency_exhausted=evidence)
        self.status(f"{evidence['currency']} 소진 확인 · 완료 {completed} · 무료 입장/재전송 없이 종료")
        return completed

    def _maybe_abyss_food(self, activity, image, signal):
        """One opted-in food use per interval, only on a fresh native HUD."""
        due = getattr(self, "_abyss_food_next_at", None)
        if due is None or time.monotonic() < due or signal != "unknown":
            return
        if (activity.get("Dungeon", {}).get("State") in ("Completed", "Complete", "Result", "Cleared", "Finished") or
                activity.get("IsAbyssResultSequencePlaying") is True or any(
                _flag(activity, name, group) is True for name, group in (
                    ("IsDead", "combatState"), ("IsDialoguePlaying", "dialogue"),
                    ("IsWaitingForSelection", "dialogue")))):
            return
        if not common_field_ready(self, image):
            return
        self.check()
        if getattr(self, "_travel_snack", False):
            if getattr(self, "_snack_empty_wait", False):
                return  # Already awaiting this dungeon's completion, no polling storm.
            before = self._snack.stock()
            self.status(f"여행자 간식 사용 시점 수량 확인 · 가방 {before[SNACK]['total']}개")
            self.store.set_run(snack_quantity=before[SNACK]["total"])
            can_craft = all(before[name]["total"] > before[name]["locked"] for name in MATERIALS)
            stop_when_empty = getattr(self, "_stop_on_snack_empty", True)
            if before[SNACK]["total"] <= LOW_STOCK and (can_craft or
                    before[SNACK]["total"] == 0 and stop_when_empty):
                self._mark_snack_restock()
            if before[SNACK]["total"] == 0:
                self._snack_empty_wait = getattr(self, "_snack_restock_due", False)
                self._abyss_food_next_at = time.monotonic() + ABYSS_FOOD_INTERVAL
                if not self._snack_empty_wait:
                    self.status("여행자 간식·제작 재료 소진 · 사용자 설정에 따라 음식 없이 던전 반복 계속")
                return  # Finish the current dungeon, then leave/craft; never use another food.
            # User preassigns the snack as the game's default B item. A press
            # during the 15-minute reuse lock is expected to do nothing. Never
            # open/search the bag, require consumption, or poll for a buff.
        # Reserve the next interval before input; never resend an ambiguous B.
        self._abyss_food_next_at = time.monotonic() + ABYSS_FOOD_INTERVAL
        try:
            self.input("key", "B")
        except InputInterrupted as exc:
            # A key prefix may already have reached the game. Keep this
            # interval reserved instead of retrying the consuming food input.
            self.status("음식 B 입력 결과 미확인 · 같은 입력 재전송 없이 다음 사용 주기 유지 · " + str(exc))
            return
        self._abyss_food_next_at = time.monotonic() + ABYSS_FOOD_INTERVAL
        self._abyss_food_presses += 1
        self.store.set_run(abyss_food_presses=self._abyss_food_presses)
        label = "여행자 간식" if getattr(self, "_travel_snack", False) else "던전 음식"
        self.status(f"{label} B 입력 · 누적 {self._abyss_food_presses}회 · 다음 2분 뒤 (사용 제한 중 무반응은 정상 · 섭취/효과 미판독)")

    def _mark_snack_restock(self):
        self._snack_restock_due = True
        self.store.set_run(snack_restock_due=True)
        count = getattr(self, "_snack_batch_count", 10)
        self.status(f"여행자 간식 2개 이하 · 이번 던전 완료 후 나가기 → 최대 {count}회 제작 예약 (보유 재료 범위)")

    @staticmethod
    def _reentry_inactive(activity, *, result_visible=False):
        # 17 live evidence: NotInDungeon + visible Exit/Retry can retain
        # IsInCombat=True. Override ONLY for an owned, currently proven result,
        # never an entry menu or unknown frame. Death/dialogue still veto.
        return activity.get("Dungeon", {}).get("State") in (
            "NotInDungeon", "Completed", "Complete", "Result", "Cleared", "Finished") and not any(
            _flag(activity, n, g) is True for n, g in (
            ("IsDead", "combatState"), ("IsDialoguePlaying", "dialogue"), ("IsWaitingForSelection", "dialogue"))) and (
            _flag(activity, "IsInCombat", "combatState") is not True or result_visible)

    @staticmethod
    def _check_dungeon_unknown(since, unknown, now):
        if not unknown:
            return None
        # Observation age is diagnostic, not proof that the route failed.
        # Callers keep fresh-frame recovery and cancellation/client checks.
        return now if since is None else since

    def _current_result_buttons(self, image, module):
        if module in ("abyss", "vanguard", "hunt") or self._adaptive_window():
            return common_result_buttons(self, image, module)
        return result_footer_buttons(image)

    def _park_result_pointer(self):
        """No click/focus stealing: move only an idle pointer covering a named ROI."""
        foreground = getattr(self.window, "is_foreground", None)
        position = getattr(self.window, "pointer_position", None)
        if not callable(foreground) or not callable(position) or not foreground():
            return False
        regions = (common_result_text_regions(self) if self._source_window() else
                   execution_rules.geometry("observation.automation.RESULT_POINTER_BANDS", runtime=self._ui_route_runtime())
                   if self._adaptive_window() else result_text_regions(self._window_size()))
        # This is pointer-only movement, never a candidate Retry/Exit click.
        def covered():
            point = position()
            return point is not None and any(a <= point[0] <= c and b <= point[1] <= d for a,b,c,d in regions)
        if not covered():
            return False
        try:
            target = (self._source_neutral_target("ui.pointer.park", lambda: foreground() and covered())
                      if self._source_window() else self._ui_route_runtime().prepared_ref("dungeon.fixed", "pointer.result"))
            moved = self.input("park", target,
                guard=lambda:foreground() and covered(), skip_if_guard_fails=True, wait_for_idle=False)
        except InputInterrupted:
            return False
        if moved is not False:
            self.status("결과 판독 문구의 포인터 가림 해소 · 안전 위치로 이동만 수행")
        return moved

    def _check_result_stalled(self, since, now):
        if (since is not None and now-since >= DUNGEON_UNKNOWN_LIMIT and
                now-getattr(self, "_last_result_stall_report", -100.) >= 30):
            self.status("결과 전환 복구 계속 · 현재 화면 재확인/메뉴 펼치기/확인된 결과 버튼 재시도 · 완료 횟수 유지")
            self._last_result_stall_report = now

    def _observed_result_input(self, region, guard):
        """Only non-consuming reveals/free reentry. Never catch cancel or paid errors."""
        try:
            if region is None:
                self._input_defer_reason = "현재 화면의 입력 대상 위치 미확인"
                result = False
            else:
                with self.coordinate_sequence():
                    result = self.input("click", region, guard=guard, skip_if_guard_fails=True, wait_for_idle=False)
            if result is False and time.monotonic()-getattr(self, "_last_defer_report", -100.) >= 10:
                self.status("결과 입력 보류 · " + (getattr(self, "_input_defer_reason", None) or "현재 화면 재확인 필요"))
                self._last_defer_report = time.monotonic()
            return result
        except InputInterrupted as exc:
            self.status(f"입력 방해 · 좌표 재사용 없이 화면 다시 확인: {exc}")
            return False

    def _result_reveal_scope(self):
        # Ownership is local to one observer/encounter, never persisted. A
        # resized/replaced game window cannot inherit the previous hit region.
        return (id(self.window), getattr(self.window, "hwnd", None),
                getattr(self.window, "pid", None), self._window_size(), getattr(self, "_dungeon_module", "dungeon"),
                getattr(self.window, "layout_generation", None))

    def _dungeon_observation_context(self):
        """RAM-only provenance of a frame; no OCR, capture or activity query."""
        from .dungeon_execution import scope
        # The OS stamp itself invalidates user input. The resume counter is a
        # derivative updated by input dispatch and cannot veto that same fresh
        # recovery observation before every send.
        return (scope(self, include_resume=False), self._result_reveal_scope(), last_input_stamp())

    def _same_dungeon_observation(self, context):
        return (context is not None and context[-1] is not None and
                getattr(self.window, "is_foreground", lambda: True)() and
                context == self._dungeon_observation_context())

    def _remember_result_buttons(self, buttons, context):
        self._result_buttons = dict(buttons)
        self._result_buttons_observation = (
            (dict(buttons), context) if self._same_dungeon_observation(context) else None)

    def _send_owned_result_action(self, role):
        """Consume the just-confirmed result once; changed context uses recovery."""
        observed = getattr(self, "_result_buttons_observation", None)
        if observed is None:
            return None
        buttons, context = observed
        def current():
            return (getattr(self, "_result_buttons_observation", None) is observed and
                    getattr(self, "_result_buttons", None) == buttons and
                    self._same_dungeon_observation(context))
        if role not in buttons or not current():
            self._result_buttons_observation = None
            return None
        try:
            target = self._dungeon_result_target(role, buttons)
        except InputInterrupted:
            self._result_buttons_observation = None
            return False
        if self._observed_result_input(target, current) is False:
            self._result_buttons_observation = None
            return False
        # Keep the footer values for Vanguard's subsequent outcome comparison,
        # but never let this observation authorize a second result action.
        self._result_buttons_observation = None
        self._result_reveal_owned = None
        if getattr(self, "_dungeon_module", None) == "abyss" and role == "other":
            from .dungeon_execution import scope, owns
            current_scope = scope(self)
            self._abyss_reentry_transition = current_scope
            self._abyss_repeat_scope = (current_scope if
                getattr(self, "_abyss_applied_scope", None) == current_scope and
                owns(self, current_scope) else None)
        return True

    def _abyss_repeat_current(self):
        from .dungeon_execution import owns
        return owns(self, getattr(self, "_abyss_repeat_scope", None))

    def _reveal_abyss_sequence(self, activity, context):
        """The CLI sequence edge owns the two existing reveal coordinates."""
        from .dungeon_execution import scope, owns
        if (getattr(self, "_dungeon_module", None) != "abyss" or
                activity.get("IsAbyssResultSequencePlaying") is not True or
                getattr(self, "_abyss_result_tape_sent", False) or
                getattr(self, "_dungeon_admission_owned_scope", None) != self._result_reveal_scope() or
                not self._same_dungeon_observation(context) or not owns(self, scope(self))):
            return False
        # Reserve this one-shot tape. An interrupted prefix goes through the
        # existing visible-phase recovery instead of replaying both touches.
        self._abyss_result_tape_sent = True
        try:
            with self.coordinate_sequence():
                for role in ("CLEAR_TOUCH", "LOOT_REVEAL"):
                    guard = lambda: self._same_dungeon_observation(context)
                    target = (self._source_neutral_target("dungeon.result.reveal", guard)
                              if self._source_window() else self._dungeon_target(role))
                    if self.input("click", target, guard=guard, skip_if_guard_fails=True,
                                  wait_for_idle=False) is False:
                        return False
                    if not owns(self, scope(self)):
                        return False
                    context = self._dungeon_observation_context()
                self.coordinate_step()
        except InputInterrupted as exc:
            self.status("어비스 완료 입력 방해 · 현재 결과 단계 재관측 · 두 번 터치 묶음 재전송 없음 · " + str(exc))
            return False
        self.status("어비스 CLI 완료 연출 · 전리품 공개/메뉴 펼치기 좌표 묶음 전송")
        return True

    def _observe_result_recovery(self, image, signal, activity, reveal, now, *, hud=False):
        """Completion state may recover neutral background, never an admission."""
        cli_completed = (signal == "unknown" and not hud and
            getattr(self.window, "is_foreground", lambda: True)() and
            self._cli_result_recovery(activity))
        phase = "loot_intro" if cli_completed else signal
        reveal.observe("hud" if hud else phase, now)
        if phase in ("clear_intro", "loot_intro") and not hud:
            self._remember_result_reveal(image, phase, cli_completed=cli_completed)
            return phase
        if hud or signal != "unknown":
            self._result_reveal_owned = None
        return None

    def _cli_result_recovery(self, activity):
        from . import execution_rules as rules
        state = activity.get("Dungeon", {}).get("State")
        if state in rules.value("dungeon.result.completed_states",
                ("Completed", "Complete", "Result", "Cleared", "Finished")):
            return True
        # NotInDungeon also occurs throughout Abyss. The positive current
        # sequence flag is usable only after THIS run sent/observed admission.
        return (getattr(self, "_dungeon_admission_owned_scope", None) == self._result_reveal_scope()
                and state in rules.value("dungeon.result.sequence_idle_states", ("NotInDungeon",))
                and activity.get("IsAbyssResultSequencePlaying") is True)

    def _remember_result_reveal(self, image, phase, *, cli_completed=False):
        """A visible result or current completed state owns neutral recovery.

        Unknown OCR is not an exit from that tape. Do not require the same
        obscured title before every background touch. CLI-origin ownership
        still uses the current observation's completed state; the following
        input guard verifies provenance without repeating that observation.
        """
        if phase not in ("clear_intro", "loot_intro"):
            return
        scope = self._result_reveal_scope()
        previous = getattr(self, "_result_reveal_owned", None)
        if previous and previous["scope"] == scope:
            if not cli_completed:
                previous["cli_completed"] = False
            return
        self._result_reveal_owned = {"scope": scope,
            "role": "CLEAR_TOUCH" if phase == "clear_intro" else "LOOT_REVEAL",
            "cli_completed": cli_completed}

    def _reveal_current_result(self, phase, module, *, observed=None):
        name = "CLEAR_TOUCH" if phase == "clear_intro" else "LOOT_REVEAL"
        owned = getattr(self, "_result_reveal_owned", None)
        if owned and owned["scope"] != self._result_reveal_scope():
            self._result_reveal_owned = None
            return False
        target = None if self._source_window() else self._dungeon_target(owned["role"] if owned else name)

        def accepted(image, *, signal=None, activity=None, buttons_checked=False):
            if owned and owned["scope"] != self._result_reveal_scope():
                self._result_reveal_owned = None
                return False
            foreground = getattr(self.window, "is_foreground", None)
            if callable(foreground) and not foreground():
                self._input_defer_reason = "게임 창 가림/포커스 대기"
                return False
            if not buttons_checked and self._current_result_buttons(image, module):
                self._result_reveal_owned = None
                return False  # The footer takes over; never touch behind it.
            if signal is None:
                signal = common_dungeon_signal(self, image)
            if signal not in ("clear_intro", "loot_intro", "unknown"):
                self._result_reveal_owned = None
                return False
            blocker = common_result_reveal_blocker(self, image)
            hud = blocker == "hud"
            if blocker or hud:
                if hud or blocker in ("hud", "matching", "entry", "result"):
                    self._result_reveal_owned = None
                self._input_defer_reason = "완료 메뉴 펼치기 보류 · " + (blocker or "hud")
                return False
            if signal == "unknown":
                if not owned or owned is not getattr(self, "_result_reveal_owned", None):
                    return False
            if activity is None:
                activity = self._dungeon_activity()
            state = activity.get("Dungeon", {}).get("State")
            if owned and owned.get("cli_completed") and not self._cli_result_recovery(activity):
                self._result_reveal_owned = None
                return False
            # Positive matching/choices can overlay an old clear banner too.
            # Residual combat/death are not used to veto this background
            # reveal; this is not a Retry/Enter/revive authorization.
            if (state == "Matching" or any(_flag(activity, n, g) is True for n, g in (
                    ("IsDialoguePlaying", "dialogue"), ("IsWaitingForSelection", "dialogue"),
                    ("IsAutoTraveling", "autoTravel")))):
                self._result_reveal_owned = None
                return False
            # An owned visual result survives missing text and a stale
            # InProgress CLI value. Unknown alone cannot acquire ownership;
            # HUD/menu/matching vetoes above still end the reveal phase.
            return True
        def still_visible():
            return accepted(self.window.capture())
        guard = still_visible
        if observed is not None:
            image, signal, activity, context = observed
            if not self._same_dungeon_observation(context):
                return False  # The owning observer will recover on its next frame.
            if not accepted(image, signal=signal, activity=activity, buttons_checked=True):
                return False
            # Result/phase/activity were just read on this frame. Only input
            # provenance is checked again, not another copy of the same scene.
            guard = lambda: (self._same_dungeon_observation(context) and
                (owned is None or owned is getattr(self, "_result_reveal_owned", None)))
        if self._source_window():
            try:
                target = self._source_neutral_target("dungeon.result.reveal", guard)
            except InputInterrupted as exc:
                self._input_defer_reason = str(exc)
                return False  # Nothing was sent; keep the owned observation loop.
        return self._observed_result_input(target, guard)

    def _open_abyss_list_from_result(self):
        """Owned result -> Other Dungeon -> list; never use Retry to repeat."""
        if self._send_owned_result_action("other") is True:
            self.coordinate_step()
            self._result_buttons = {}
            self.status("다른 던전 가기 입력 · 어비스 선택 좌표 경로 계속")
            return False
        started = time.monotonic()
        stable = ResultButtonStability(repeat_delay(execution_rules.value("dungeon.result.button_settle_seconds", (2.5, 3.5))))
        reveal = ResultRevealGate(execution_rules.number("dungeon.result.reveal_settle_seconds", 3.0))
        last_click = last_reveal = last_report = -100.
        self._result_reveal_owned = None
        if self.ocr is None:
            self.ocr = FocusedOCR()
        while True:
            self.check()
            now = time.monotonic()
            self._check_result_stalled(started, now)
            self._park_result_pointer()
            observation = self._dungeon_observation_context()
            image = self.window.capture()
            if recover_fullscreen_popup(self, image):
                stable.observe(None, now)
                continue
            buttons = self._current_result_buttons(image, "abyss")
            if not buttons:
                screen = (self._adaptive_dungeon().observer._abyss_start(image)
                          if self._source_window() else self.ocr.abyss_start_screen(image))
                if screen == "list":
                    self._result_buttons = {}
                    self._result_reveal_owned = None
                    self.status("어비스 목록 복귀 확인 · 던전·파티·난이도 다시 설정")
                    return False  # Already in the list: no ESC/menu reopen.
                if screen == "field":
                    self._wait_owned_field()
                    return True  # Known field fallback uses the first-entry tape.
            owned_result = self._same_result_buttons(buttons, getattr(self, "_result_buttons", {}))
            settled = stable.observe(buttons if buttons and "other" in buttons else None, now)
            if buttons and "other" in buttons and (owned_result or settled):
                if now - last_click >= execution_rules.number("dungeon.result.retry_seconds", 15.0):
                    if not self._reentry_inactive(self._dungeon_activity(), result_visible=True):
                        self.sleep(2)
                        continue
                    self._remember_result_buttons(buttons, observation)
                    sent = self._send_owned_result_action("other")
                    if sent is True:
                        self.coordinate_step()
                        self._result_buttons = {}
                        self.status("다른 던전 가기 입력 · 어비스 선택 좌표 경로 계속")
                        return False
                    if sent is False:
                        continue
                    expected = dict(buttons)
                    def still_same():
                        current = self._current_result_buttons(self.window.capture(), "abyss")
                        return (self._same_result_buttons(current, expected) and
                                self._reentry_inactive(self._dungeon_activity(), result_visible=True))
                    try:
                        target = self._dungeon_result_target("other", buttons)
                    except InputInterrupted:
                        self.sleep(.5)
                        continue
                    if self._observed_result_input(target, still_same) is not False:
                        self.coordinate_step()
                        self._result_buttons = {}
                        self._result_reveal_owned = None
                        self.status("다른 던전 가기 입력 · 어비스 선택 좌표 경로 계속")
                        return False  # The authored next endpoint is the list.
            phase = "result" if buttons else common_dungeon_signal(self, image)
            activity = self._dungeon_activity() if phase == "unknown" else None
            self._observe_result_recovery(image, phase,
                activity or {}, reveal, now)
            if reveal.ready(now) and now - last_reveal >= execution_rules.number("dungeon.result.reveal_retry_seconds", 8.0):
                if self._reveal_current_result(reveal.phase, "abyss",
                        observed=(image, phase, activity, observation)) is not False:
                    last_reveal = time.monotonic()
                    self.status("어비스 결과 메뉴 다시 펼치기 · 다른 던전 가기 확인")
            if now - last_report >= 15:
                self.status("어비스 목록 전환 재확인 · 다시 하기 미사용 · 완료 횟수 유지")
                last_report = now
            self.sleep(2)

    def _send_result_retry(self, region):
        module = self._dungeon_module
        if module == 'hunt':
            return self._adaptive_result_action('other')  # New Mission, never Stay.
        require(module in ("dungeon", "deep", "vanguard") or
                module == "abyss" and getattr(self, "_abyss_reentry", "other_dungeon") == "retry",
                "어비스 다시 하기는 재입장 방식에서 선택한 경우에만 사용합니다.")
        # The supplied legacy region is not permission to replay a stale result
        # after user input. Both geometries share the owned action/recovery path.
        return self._adaptive_result_action("retry")

    def _adaptive_result_action(self, role):
        """Direct confirmed result action, or the separate unowned recovery."""
        require(role in ("exit", "retry") or role == "other" and
                getattr(self, "_dungeon_module", None) == "hunt", "등록된 결과 동작이 아닙니다.")
        if self._send_owned_result_action(role) is True:
            return True
        since = time.monotonic()
        module = self._dungeon_module
        reveal = ResultRevealGate(execution_rules.number("dungeon.result.reveal_settle_seconds", 3.0))
        last_reveal = -100.
        while True:
            self.check()
            now = time.monotonic()
            self._check_result_stalled(since, now)
            self._park_result_pointer()
            observation = self._dungeon_observation_context()
            image = self.window.capture()
            if recover_fullscreen_popup(self, image):
                continue
            current = self._current_result_buttons(image, module)
            if current and role in current:
                if module == "vanguard" and role == "retry" and not self._reentry_inactive(
                        self._dungeon_activity(), result_visible=True):
                    self.sleep(2)
                    continue
                self._remember_result_buttons(current, observation)
                sent = self._send_owned_result_action(role)
                if sent is True:
                    return True
                if sent is False:
                    continue  # Input context changed; reread in the recovery loop.
                target = current[role]
                def unchanged():
                    fresh = self._current_result_buttons(self.window.capture(), module)
                    other = fresh.get(role) if fresh else None
                    if other is None or max(abs(a-b) for a,b in zip(target, other)) > .003:
                        return False
                    return (module != "vanguard" or role != "retry" or
                            self._reentry_inactive(self._dungeon_activity(), result_visible=True))
                try:
                    reference = self._dungeon_result_target(role, current)
                except InputInterrupted:
                    self.sleep(.5)
                    continue
                if self._observed_result_input(reference, unchanged) is not False:
                    return True
            if not current:
                signal = common_dungeon_signal(self, image)
                if signal == "entry" and self._result_entry_checkpoint(image, signal, self._dungeon_activity(), observation):
                    return False  # Already advanced; never replay an old result action.
                activity = self._dungeon_activity() if signal == "unknown" else None
                self._observe_result_recovery(image, signal,
                    activity or {}, reveal, now)
                if reveal.ready(now) and now-last_reveal >= execution_rules.number("dungeon.result.reveal_retry_seconds", 8.0):
                    if self._reveal_current_result(reveal.phase, module,
                            observed=(image, signal, activity, observation)) is not False:
                        last_reveal = time.monotonic()
                        self.status("결과 선택 복구 · 메뉴 다시 펼치기 · 현재 버튼 재확인")
            self.sleep(2)

    def _wait_dungeon_started(self, timeout=120):
        module = getattr(self, "_dungeon_module", "dungeon")
        persistent = module in ("abyss", "vanguard")  # No paid admission replay.
        deadline = time.monotonic() + min(timeout, 15)
        from_result = getattr(self, "_entry_from_result", False)
        expected = dict(getattr(self, "_result_buttons", {}))
        gate = ResultButtonStability(repeat_delay(execution_rules.value("dungeon.result.button_settle_seconds", (2.5, 3.5))))
        entry_gate = ResultButtonStability(repeat_delay(execution_rules.value("dungeon.result.button_settle_seconds", (2.5, 3.5))))
        last_action = getattr(self, "_retry_click_at", time.monotonic())
        last_report = -100.; last_reveal = -100.; last_reveal_probe = -100.
        reveal = ResultRevealGate(execution_rules.number("dungeon.result.reveal_settle_seconds", 3.0))
        result_wait_since = time.monotonic() if from_result else None
        attempts = 0; hud_count = 0; unknown_since = None
        self._result_reveal_owned = None
        if self.ocr is None:self.ocr = FocusedOCR()
        while True:
            self.check()
            now = time.monotonic()
            if not persistent and now >= deadline and not self._dungeon_admission_pending():
                context = getattr(self, "_admission_context", None)
                self.status("입장 시작 미확인 · 작업 중단 대신 현재 화면 복구")
                if context is not None:
                    _, destination, free, double_loot = context
                    checkpoint = self._recover_dungeon_entry(module, destination, free, "입장 시작 확인")
                    if checkpoint != "started":
                        self._prepare_dungeon_admission(module, destination, free, double_loot,
                            first_entry=checkpoint == "list", from_field=checkpoint == "field")
                deadline = time.monotonic() + min(timeout, 15)
            activity = self._dungeon_activity(); state = activity.get("Dungeon", {}).get("State")
            if from_result or activity.get("IsAbyssResultSequencePlaying") is True:
                self._park_result_pointer()
            observation = self._dungeon_observation_context()
            image = self.window.capture()
            if module == "abyss":
                from .abyss_wipe import observe_wipe
                if observe_wipe(self, image, observation):
                    return "abyss_wipe"
            buttons = self._current_result_buttons(image, module)
            if recover_fullscreen_popup(self, image):
                deadline = time.monotonic() + min(timeout, 15)
                continue
            signal = "result" if buttons else common_dungeon_signal(self, image)
            is_result = signal in ("clear_intro", "loot_intro", "result")
            if signal == "matching" and self._dungeon_admission_pending():
                self._observe_dungeon_admission()
                self.status("입장 입력 접수 확인 · 현재 매칭 관찰로 연결 · 입장 재전송 없음")
                return
            if signal == "matching" and module in ("abyss", "vanguard"):
                self.status("우연한 만남 매칭 패널 확인 · 결과 배경을 다시 누르지 않습니다.")
                return
            # A single unreadable frame is NOT a new run. Positive HUD/activity
            # proof is required before the already credited result can be left.
            hud = common_field_ready(self, image) if signal == "unknown" else False
            hud_count = hud_count + 1 if hud and activity.get("IsAbyssResultSequencePlaying") is not True else 0
            active = (state and state not in ("NotInDungeon", "Completed", "Complete", "Result", "Cleared", "Finished")) or _flag(activity, "IsInCombat", "combatState") is True
            if not is_result and signal != "entry" and (active and (not from_result or hud) or hud_count >= 2):
                self._notify_chat_entry(activity, hud, signal)
                self.status(f"던전 입장 시작 확인 · CLI={state} · 새 진행 화면")
                self._observe_dungeon_admission()
                return
            if not from_result and is_result:
                self._observe_dungeon_admission()
                return  # First admission may finish before its first poll.
            self._check_result_stalled(result_wait_since, now)
            unknown_since = self._check_dungeon_unknown(unknown_since, signal == "unknown" and not hud, now)
            inactive = self._reentry_inactive(activity)
            if inactive and not is_result:
                self._check_dungeon_currency(image=image, refusal_only=True, observation=observation)
            same = from_result and self._same_result_buttons(buttons, expected)
            result_inactive = self._reentry_inactive(activity, result_visible=bool(from_result and is_result))
            ready = gate.observe(buttons if same and result_inactive else None, now)
            # Vanguard and explicitly selected Abyss Retry own the direct
            # result-to-matching transition; default Abyss still uses Other.
            direct_retry = (module == "vanguard" or module == "abyss" and
                            getattr(self, "_abyss_reentry", "other_dungeon") == "retry")
            if direct_retry and ready and now - last_action >= execution_rules.number("dungeon.result.retry_seconds", 15.0):
                def still_same():
                    same_now = self._same_result_buttons(self._current_result_buttons(self.window.capture(), module), expected)
                    return same_now and self._reentry_inactive(self._dungeon_activity(), result_visible=True)
                retry_target = self._dungeon_result_target("retry", buttons)
                if self._observed_result_input(retry_target, still_same) is not False:
                    attempts += 1; last_action = time.monotonic()
                    self.status(f"다시 하기 재시도 {attempts} · 같은 결과창 재확인 · 최소 15초 간격")
                gate = ResultButtonStability(repeat_delay(execution_rules.value("dungeon.result.button_settle_seconds", (2.5, 3.5))))
            # An interrupted clear/loot reveal belongs to the previous credited
            # run; recover its footer without returning/crediting it again.
            self._observe_result_recovery(image, signal, activity, reveal, now, hud=hud)
            if (persistent and from_result and reveal.ready(now) and now-last_reveal_probe >= 2
                    and now - last_reveal >= execution_rules.number("dungeon.result.reveal_retry_seconds", 8.0)):
                last_reveal_probe = now
                if self._reveal_current_result(reveal.phase, module,
                        observed=(image, signal, activity, observation)) is not False:
                    last_reveal = time.monotonic()
                    self.status("재입장 대기 중 결과 연출 다시 펼치기 · 완료 횟수 유지")
            # Only the already authored free entry button can be re-sent.
            # Paid normal/deep recovery requires a newly recognized inactive
            # entry screen and a separate recheck of its configured options.
            free_region = None
            if persistent and inactive:
                if self._source_window():
                    free_region = self._adaptive_dungeon().entry_target()
                elif self._fixed_free_entry_ready():
                    free_region = self._dungeon_target("ABYSS_ENTER" if module == "abyss" else "VANGUARD_ENTER")
            free_caption = free_region is not None
            stable_region = free_region
            if free_caption:
                stable_region = self._ui_route_runtime().resolve_input(free_region, self.window)
            entry_ready = entry_gate.observe({"enter": stable_region} if free_caption else None, now)
            if entry_ready and now - last_action >= execution_rules.number("dungeon.result.retry_seconds", 15.0):
                def still_free_entry():
                    if not self._reentry_inactive(self._dungeon_activity()):
                        return False
                    return (self._adaptive_dungeon().entry_target() is not None if self._source_window()
                            else self._fixed_free_entry_ready())
                if self._observed_result_input(free_region, still_free_entry) is not False:
                    last_action = time.monotonic()
                    self.status("무료 입장 버튼 재확인 후 재시도 · 진행 중 중복 입력 없음")
                entry_gate = ResultButtonStability(repeat_delay(execution_rules.value("dungeon.result.button_settle_seconds", (2.5, 3.5))))
            if now - last_report >= 15:
                self.status(f"재입장 계속 확인 · CLI={state} · 화면={signal} · 완료 횟수 유지 · 사용자 입력/가림 해제 대기")
                last_report = now
            self.sleep(2)

    @staticmethod
    def _same_result_buttons(current, expected):
        return (bool(current) and "retry" in current and current.keys() == expected.keys() and
                all(max(abs(a-b) for a,b in zip(current[key],expected[key])) <= .003 for key in current))

    def _result_entry_checkpoint(self, image, signal, activity, observation):
        """Fresh unexpected entry is a recovery checkpoint, NEVER completion."""
        if (signal != "entry" or getattr(self, "_dungeon_module", None) not in ("dungeon", "deep")
                or not self._source_window() or self._dungeon_admission_pending()
                or _flag(activity, "IsAutoTraveling", "autoTravel") is True
                or not self._reentry_inactive(activity)
                or not self._same_dungeon_observation(observation)):
            return False
        if self._adaptive_dungeon().classify_entry_screen(image) != "entry":
            return False
        if not self._same_dungeon_observation(observation):
            return False
        self._result_buttons = {}
        self._result_buttons_observation = None
        self._result_reveal_owned = None
        self._result_entry_recovery = True
        self.status("결과 대기 중 현재 입장 화면 확인 · 완료 횟수 유지 · 층 목록 복구로 전환")
        return True

    def _wait_dungeon_result(self):
        module = getattr(self, "_dungeon_module", "dungeon")
        self._result_buttons = {}  # Never keep last encounter's button boxes.
        self._result_buttons_observation = None
        attempts = {"clear_intro": 0, "loot_intro": 0}
        last_reveal=-100.;last_ocr=-100.;last_signal="unknown";last_reveal_probe=-100.
        reveal = ResultRevealGate(execution_rules.number("dungeon.result.reveal_settle_seconds", 3.0))
        result_seen_at=None
        last_button_diagnostic=-100.
        unknown_since = None
        self._result_reveal_owned = None
        while True:
            self.check()
            activity_context = self._dungeon_observation_context()
            activity=self._dungeon_activity();state=activity.get("Dungeon",{}).get("State")
            # A dead player may still carry an old result-sequence flag. First
            # inspect that frame for a party wipe instead of touching its UI.
            revealed_sequence = (_flag(activity, "IsDead", "combatState") is not True and
                                 self._reveal_abyss_sequence(activity, activity_context))
            if revealed_sequence:
                last_ocr = float("-inf")
            now=time.monotonic()
            self._check_result_stalled(result_seen_at, now)
            # NotInDungeon describes field/unsupported Abyss status too. It
            # must NOT start a result timeout or consume reveal attempts.
            completed_state=state in ("Completed","Complete","Result","Cleared","Finished")
            result_sequence=activity.get("IsAbyssResultSequencePlaying") is True
            fast_abyss_result = module == "abyss" and (result_sequence or
                getattr(self, "_abyss_result_tape_sent", False))
            ocr_interval = .5 if fast_abyss_result else (
                2 if completed_state or result_sequence or result_seen_at is not None else 8)
            fresh_phase=None
            if now-last_ocr>=ocr_interval:
                last_ocr=now
                if result_sequence or result_seen_at is not None:
                    self._park_result_pointer()
                observation = self._dungeon_observation_context()
                image=self.window.capture()
                if module == "abyss":
                    from .abyss_wipe import observe_wipe
                    if observe_wipe(self, image, observation):
                        return "abyss_wipe"
                if module != "abyss" and recover_fullscreen_popup(self, image):
                    last_ocr = 0
                    continue
                if module in ("abyss", "vanguard", "hunt") or self._adaptive_window():
                    if self.ocr is None:self.ocr=FocusedOCR()
                    buttons=self._current_result_buttons(image, module)
                else:
                    buttons=result_footer_buttons(image)
                if buttons is not None:
                    self._result_reveal_owned = None
                    unknown_since = None
                    if result_seen_at is None:result_seen_at=now
                    self._remember_result_buttons(buttons, observation)
                    self.status(f"던전 완료 · CLI={state} · 결과 메뉴 확인 · 추가 안정 대기 없이 다음 좌표 묶음")
                    return
                if module == "abyss" and recover_fullscreen_popup(self, image):
                    last_ocr = 0
                    continue
                if self.ocr is None:self.ocr=FocusedOCR()
                last_signal=common_dungeon_signal(self, image)
                if self._result_entry_checkpoint(image, last_signal, activity, observation):
                    return "entry_recovery"
                active = (state and state not in ("NotInDungeon", "Completed", "Complete", "Result", "Cleared", "Finished")) or _flag(activity, "IsInCombat", "combatState") is True
                # In-progress combat is not a failed result read. After any
                # actual result evidence, require fresh HUD to clear a stale
                # combat flag instead of extending the unknown budget forever.
                hud = last_signal == "unknown" and common_field_ready(self, image)
                progressing = hud and active
                self._notify_chat_entry(activity, hud, last_signal)
                unknown_since = self._check_dungeon_unknown(unknown_since, last_signal == "unknown" and not progressing, now)
                fresh_phase = self._observe_result_recovery(
                    image, last_signal, activity, reveal, now, hud=hud)
                if progressing:
                    result_seen_at = None
                self.status(f"던전 진행 관찰 · CLI={state} · 결과영역={last_signal}")
                if now-last_button_diagnostic>=15:
                    details=getattr(self.ocr, "last_result_diagnostics", {})
                    footer=getattr(self.ocr, "last_dungeon_footer", "")
                    self.status(f"결과 버튼 확인 · 후보={details.get('capsules', '미확인')} · "
                                f"버튼문구={details.get('captions', [])} · 하단문구={footer or '미판독'} · "
                                f"CLI연출={result_sequence} · 보조판독차단={getattr(self, '_result_footer_blocker', None)} · "
                                f"공개입력보류={getattr(self, '_input_defer_reason', None)} · "
                                f"경과={int(now-result_seen_at) if result_seen_at is not None else 0}초")
                    last_button_diagnostic=now
                self._maybe_abyss_food(activity, image, last_signal)
            # Abyss's owned CLI sequence has already sent its two-touch tape.
            # Other modules and an interrupted/missed tape retain phase recovery.
            phase = fresh_phase
            if phase and result_seen_at is None:
                result_seen_at=now
            # Repeated labels/unknown must not restart either reveal readiness
            # or the no-progress clock; only actual progress ends this phase.
            if result_seen_at is not None:
                # Elapsed OCR uncertainty is not failure. Keep the same run
                # until its footer is confirmed or the user cancels.
                if now-result_seen_at >= 180 and now-last_button_diagnostic >= 15:
                    self.status("결과 선택창 계속 확인 · 미판독/입력 방해 복구 대기 · 완료 횟수 유지")
                    last_button_diagnostic=now
            if reveal.ready(now) and now-last_reveal >= execution_rules.number("dungeon.result.reveal_retry_seconds", 8.0) and now-last_reveal_probe >= 2:
                last_reveal_probe = now
                # The visually owned phase survives unreadable labels. Fresh
                # screen checks veto a new menu/HUD/footer, but do not demand
                # another positive title before every neutral reveal touch.
                current_frame = (image, last_signal, activity, observation) if fresh_phase else None
                if self._reveal_current_result(reveal.phase, module, observed=current_frame) is not False:
                    attempts[reveal.phase]+=1;last_reveal=time.monotonic()
                    self.status(("클리어 연출 터치" if reveal.phase == "clear_intro" else "전리품 일괄 공개 배경 클릭") +
                                f" · 메뉴 펼치기 {sum(attempts.values())}회 · 미표시 시 8초 후 재시도")
                self.sleep(1)
                # Read the new screen after each reveal instead of reusing the
                # old banner through the normal eight-second combat interval.
                last_signal="unknown";last_ocr=0.0
                continue
            self.sleep(.5 if fast_abyss_result else 1)

    def _wait_owned_field(self, timeout=120):
        """After our own Abyss Exit: current HUD pair AND non-combat status."""
        started = time.monotonic()
        deadline = started+timeout
        if self.ocr is None:self.ocr=FocusedOCR()
        consecutive = 0
        module = getattr(self, "_dungeon_module", "abyss")
        stable = ResultButtonStability(repeat_delay(execution_rules.value("dungeon.result.button_settle_seconds", (2.5, 3.5))))
        reveal = ResultRevealGate(execution_rules.number("dungeon.result.reveal_settle_seconds", 3.0))
        last_exit, last_reveal = started, -100.
        while True:
            self.check()
            now = time.monotonic()
            if now >= deadline:
                self.status("퇴장 전환 복구 계속 · 현재 결과 메뉴/나가기/기본 화면 재확인")
                deadline = now+max(15, timeout)
            activity=self._dungeon_activity()
            # Always examine the current HUD, even if a CLI result-animation
            # flag lingers. It is not an additional basic-screen approval.
            observation = self._dungeon_observation_context()
            image = self.window.capture()
            if recover_fullscreen_popup(self, image):
                deadline = time.monotonic() + timeout
                consecutive = 0
                continue
            hud = common_field_ready(self, image)
            blockers = [name for name, group in (("IsInCombat", "combatState"),
                ("IsDead", "combatState"), ("IsDialoguePlaying", "dialogue"))
                if _flag(activity, name, group) is True]
            consecutive = consecutive + 1 if hud and not blockers else 0
            markers = getattr(self.ocr, "last_hud_markers", {})
            self.status(f"어비스 퇴장 후 기본화면 확인 · ESC={markers.get('menu', hud)} · M={markers.get('map', hud)} · "
                        f"연속 {consecutive}/2 · 진행 차단={','.join(blockers) or '없음'}")
            if consecutive >= 2:
                snack = getattr(self, "_snack", None)
                remember = getattr(snack, "remember_field", None)
                if callable(remember):
                    remember()
                return
            if not hud:
                self._park_result_pointer()
                buttons = self._current_result_buttons(image, module)
                if stable.observe(buttons if buttons and "exit" in buttons else None, now):
                    if now-last_exit >= execution_rules.number("dungeon.result.retry_seconds", 15.0):
                        target = buttons["exit"]
                        def unchanged():
                            fresh = self._current_result_buttons(self.window.capture(), module)
                            other = fresh.get("exit") if fresh else None
                            if other is None or max(abs(a-b) for a,b in zip(target, other)) > .003:
                                return False
                            return self._reentry_inactive(self._dungeon_activity(), result_visible=True)
                        try:
                            reference = self._dungeon_result_target("exit", buttons)
                        except InputInterrupted:
                            reference = None
                        if self._observed_result_input(reference, unchanged) is not False:
                            last_exit = time.monotonic()
                            self.status("퇴장 전환 복구 · 현재 나가기 버튼 재시도 · 최소15초 간격")
                phase = "result" if buttons else common_dungeon_signal(self, image)
                self._observe_result_recovery(image, phase, activity, reveal, now)
                if reveal.ready(now) and now-last_reveal >= execution_rules.number("dungeon.result.reveal_retry_seconds", 8.0):
                    if self._reveal_current_result(reveal.phase, module,
                            observed=(image, phase, activity, observation)) is not False:
                        last_reveal = time.monotonic()
                        self.status("퇴장 전환 복구 · 결과 메뉴 다시 펼치기")
            else:
                stable.observe(None, now)
                reveal.observe("hud", now)
                self._result_reveal_owned = None
            self.sleep(2)

    def _free_panel_caption(self, region):
        self.coordinate_step()
        if self.ocr is None:self.ocr=FocusedOCR()
        text = normalized(self.ocr.text(self.window.capture(), region))
        for canonical, alternatives in execution_rules.value("dungeon.labels.aliases", []):
            for alias in alternatives:
                text = text.replace(normalized(alias), normalized(canonical))
        return text

    def _initial_abyss_screen(self, timeout=8.0):
        """Read-only screen choice before the first game key/click of a new run."""
        self.check()
        wait_route_input_idle(self, wait_input_idle)
        self.check()
        # The Start button was in Mini; expose the game before reading its HUD
        # or list. This changes focus only, never normalizes an unknown menu.
        self.window.focus()
        if self.ocr is None:self.ocr=FocusedOCR()
        timeout = min(timeout, 15.0)
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            self.check()
            if self.observation_foreground():
                image = self.window.capture()
                state = self.ocr.abyss_start_screen(image)
                if state in ("field", "list"):
                    return state
                if recover_fullscreen_popup(self, image):
                    deadline = time.monotonic() + timeout
                    continue
            self.sleep(0.5)
        raise DungeonObservationPending("어비스 시작 화면 미확인 · 기본 화면/목록/팝업 복구 후 다시 확인합니다.")

    def _configure_abyss(self, destination, *, from_field=False, from_entry=False, reuse_options=False):
        if self._adaptive_window():
            return self._adaptive_dungeon().configure_abyss(destination, from_field=from_field,
                                                           reuse_options=reuse_options)
        self._dungeon_module, self._dungeon_party = "abyss", destination["party"]
        # None is used only by explicit first start. Owned repeat/restock
        # routes retain their existing known start and are not reclassified.
        initial_screen = self._initial_abyss_screen() if from_field is None and not from_entry else None
        if initial_screen is not None:
            from_field = initial_screen == "field"
            self.status("어비스 시작 화면 확인 · " +
                        ("기본 화면 → ESC → 어비스 목록" if from_field else "어비스 목록 · ESC 없이 바로 선택"))
        from .fixed_dungeon_geometry import ABYSS_GROUPS, PARTIES, ABYSS_DIFFICULTIES
        ref = lambda role: self._ui_route_runtime().prepared_ref("dungeon.fixed", role)
        prefix = (("key", "ESC"), ("click", ref("ABYSS_MENU"))) if from_field and not from_entry else ()
        tape = prefix + (("click", ref("abyss.target."+ABYSS_GROUPS[destination["name"]])),
            ("click", ref("abyss.party."+PARTIES[destination["party"]])),
            ("click", ref("abyss.difficulty."+ABYSS_DIFFICULTIES[destination["difficulty"]])))
        if from_entry:
            tape = tape[1:]  # Current owned entry: retain party/difficulty and the common footer path.
        self.status("어비스 입장 화면 → 파티 → 난이도 고정 좌표 적용" if from_entry else
                    "어비스 목록 → 던전 → 파티 → 난이도 고정 좌표 적용")
        from .dungeon_execution import run_selection
        def send(kind, region, **kwargs):
            if reuse_options:
                original_guard = kwargs.get("guard")
                kwargs["guard"] = lambda: self._abyss_repeat_current() and (
                    original_guard is None or original_guard())
                kwargs["skip_if_guard_fails"] = True
            sent = self.input(kind, region, **kwargs)
            if sent is False:
                raise InputInterrupted("어비스 재입장 좌표 묶음 중 입력 문맥 변경")
            return sent
        for index, (kind, region) in enumerate(tape[:-2]):
            with self.coordinate_sequence():
                if index == 0 and initial_screen is not None:
                    sent = send(kind, region, guard=self.window.is_foreground,
                        skip_if_guard_fails=True)
                    if sent is False:
                        raise DungeonObservationPending("어비스 첫 입력 전 시작 화면 변경 · 입력 없이 경로를 다시 확인합니다.")
                else:
                    send(kind, region)
        detail = dict(zip(("party", "difficulty"), tape[-2:]))
        handlers = {name: (lambda guard, action=action: send(*action))
                    for name, action in detail.items()}
        run_selection(self, "dungeon.select.abyss.detail", handlers, context=destination)
        if reuse_options:
            return  # Same owned destination: no matching/solo-arrival reapproval.
        footer = self._dungeon_region("ABYSS_FOOTER")
        if destination["party"] == "함께하기":
            self._set_fixed_matching("ABYSS_MATCH", True)
            return
        caption=self._free_panel_caption(footer)
        if destination["party"] == "혼자하기":
            if "이동하기" in caption:
                self.status("어비스 혼자하기 · 현장으로 일반 이동 · 날개/T 사용 없음")
                with self.coordinate_sequence():
                    self.input("click", self._dungeon_target("ABYSS_ENTER"))
                # Existing solo route's movement, without optional paid T.
                # An entry caption, not an elapsed travel guess, ends the wait.
                self._wait_free_entry_caption(footer, timeout=600)
            else:
                if "입장하기" not in caption:
                    raise DungeonObservationPending("어비스 혼자하기 이동/입장 버튼 미확인 · 현재 경로를 다시 확인합니다.")
    def _wait_free_entry_caption(self, region, timeout=15):
        deadline=time.monotonic()+min(timeout,execution_rules.number("dungeon.observation.cycle_seconds", 15))
        next_report=time.monotonic()+10
        while time.monotonic()<deadline:
            self.check()
            if self._fixed_free_entry_ready() or "입장하기" in self._free_panel_caption(region):
                return
            if time.monotonic() >= next_report:
                self.status("무료 입장 메뉴 계속 확인 · 미확인 좌표 입력 없이 현재 화면 재조회")
                next_report=time.monotonic()+10
            self.sleep(2)
        raise DungeonObservationPending("무료 입장 문구 미확인 · 메뉴/팝업 복구 후 현재 입장 경로를 다시 확인합니다.")

    def _fixed_matching_state(self, image=None):
        module = getattr(self, "_dungeon_module", None)
        if module not in ("abyss", "vanguard"):
            return None
        if image is None and not self.observation_foreground():
            return None
        if image is not None and not getattr(self.window, "is_foreground", lambda: True)():
            return None
        image = self.window.capture() if image is None else image
        if not callable(getattr(image, "crop", None)):
            return None
        return matching_toggle_state(image,
            self._dungeon_region("ABYSS_MATCH" if module == "abyss" else "VANGUARD_MATCH"))

    def _set_fixed_matching(self, name, selected):
        """Keep the existing pixel toggle decision, then continue the tape."""
        from .dungeon_execution import pending_toggle, remember_toggle
        self.check()
        region = self._dungeon_region(name)
        key = ("matching", tuple(region))
        if pending_toggle(self, key, selected):
            return
        self.coordinate_step()
        current = matching_toggle_state(self.window.capture(), region)
        if current is None:
            self.status("우연한 만남 판독 없음 · 토글 재클릭 없이 입장 시도 후 진행 확인")
            return
        if current == selected:
            remember_toggle(self, key, selected)
            return
        with self.coordinate_sequence():
            self.input("click", self._dungeon_target(name))
        remember_toggle(self, key, selected)

    def _fixed_free_entry_ready(self):
        module = getattr(self, "_dungeon_module", None)
        if module not in ("abyss", "vanguard") or (
                module == "abyss" and getattr(self, "_dungeon_party", None) != "함께하기"):
            return False
        if not self.observation_foreground():
            return False
        image = self.window.capture()
        if self._fixed_matching_state(image) is not True:
            return False
        from .dungeon_routes import free_cta_backing
        return free_cta_backing(image,
            self._dungeon_region("ABYSS_ENTER" if module == "abyss" else "VANGUARD_ENTER"))

    def _fixed_entry_checkpoint(self, image, module):
        """Owned selections + present control bodies, not title-OCR approval."""
        from .dungeon_execution import owns
        from .dungeon_routes import free_cta_backing
        if not callable(getattr(image, "crop", None)) or not any(
                owns(self, item) for item in getattr(self, "_dungeon_sent_steps", {}).values()):
            return False
        if module in ("dungeon", "deep"):
            return (self._entry_option_state(image, self._dungeon_region("CHALLENGE_REGION")) is not None
                and free_cta_backing(image, self._dungeon_region("COMBAT_ENTER_REGION")))
        if module in ("abyss", "vanguard"):
            return self._fixed_matching_state(image) is not None and free_cta_backing(image,
                self._dungeon_region("ABYSS_ENTER" if module == "abyss" else "VANGUARD_ENTER"))
        return False

    def _configure_vanguard(self):
        if self._adaptive_window():
            return self._adaptive_dungeon().configure_vanguard()
        self._dungeon_module, self._dungeon_party = "vanguard", "함께하기"
        from .dungeon_execution import run_selection
        start = self._adaptive_dungeon().prepare_vanguard_start()
        if start == "list":
            run_selection(self, "dungeon.select.vanguard", {
                "tab": lambda guard: self.input("click", self._dungeon_target("VANGUARD_TAB"))})
        self._set_fixed_matching("VANGUARD_MATCH", True)

    def _entry_option_state(self, image, region):
        module = getattr(self, "_dungeon_module", "dungeon")
        capsule=entry_option_state(image, region, module=module)
        # Current authored position and distinct backing palettes establish
        # state. An obscured caption must not discard that current evidence.
        return capsule

    def _read_entry_option(self, region, label, timeout=DUNGEON_UNKNOWN_LIMIT):
        self.coordinate_step()
        deadline=time.monotonic()+min(timeout,15.0)
        next_report=time.monotonic()+10
        while time.monotonic()<deadline:
            self.check()
            if not self.observation_foreground():
                if time.monotonic() >= next_report:
                    self.status(f"{label} 게임 화면 대기 · 다른 창을 판독하거나 선택 입력하지 않음")
                    next_report=time.monotonic()+10
                self.sleep(.25)
                continue
            image = self.window.capture()
            state=self._entry_option_state(image, region)
            if state is not None:
                if region == self._dungeon_region("DOUBLE_LOOT_REGION"):
                    description={"selected":"선택됨 · 현재 버튼색", "available":"도전 · 현재 버튼색",
                                 "disabled":"버튼 비활성"}[state]
                    self.status(f"더블 루팅 현재 상태 · {description}")
                return state
            if time.monotonic() >= next_report:
                self.status(f"{label} 현재 상태 재관측 · 화면 {getattr(image, 'size', None)} · "
                            "선택/입장 입력 없이 대기")
                next_report=time.monotonic()+10
            self.sleep(0.25)
        raise DungeonObservationPending(f"{label} 현재 선택 상태 미확인 · 화면 복구 후 다시 판독합니다.")

    def _set_entry_option(self, name, selected, label, *, observed_state=None):
        from .dungeon_execution import pending_toggle, remember_toggle, clear_toggle
        region = self._dungeon_region(name)
        deadline=time.monotonic()+min(DUNGEON_UNKNOWN_LIMIT,15.0)
        while time.monotonic()<deadline:
            self.check()
            state = observed_state
            observed_state = None  # The caller's same decision, never a retry cache.
            if state is None:
                state=self._read_entry_option(region, label, timeout=max(0, deadline-time.monotonic()))
            if (state == "selected") == selected:
                clear_toggle(self, tuple(region))
                return
            if pending_toggle(self, tuple(region), selected):
                raise DungeonObservationPending(label + " 입력 유지 · 실제 반영 재확인 · 토글 재전송 없음")
            if state == "disabled":
                if selected and getattr(self, "_dungeon_stop_on_currency", False):
                    self._check_dungeon_currency()
                # The same nonterminal recovery applies to count goals. One
                # transient gray frame must not stop the entire dungeon task.
                raise DungeonObservationPending(label + " 선택 비활성 · 같은 단계에서 재확인")
            self.status(f"{label} {'선택' if selected else '해제'} 영역 입력")
            try:
                with self.coordinate_sequence():
                    sent = self.input("click", self._dungeon_target(name), guard=lambda:
                        getattr(self.window, "is_foreground", lambda: True)(),
                        skip_if_guard_fails=True)
                if sent is False:
                    raise InputInterrupted(f"{label} 입력 전 상태 변경")
            except InputInterrupted:
                # The input boundary guarantees no press was sent. Start
                # from a fresh state, not from the old selected/off reading.
                self.status(f"{label} 입력 전 보류 · 입력 없이 현재 상태 재관측")
                self.sleep(.5)
                continue
            remember_toggle(self, tuple(region), selected)
            break
        else:
            raise DungeonObservationPending(f"{label} 입력 전 상태 미확인 · 선택 입력 없이 경로를 다시 확인합니다.")
        self.input("move", self._dungeon_pointer("center"))

    def _configure_free_double(self):
        # Some normal dungeons omit the entire double-loot panel. Do not
        # require that optional panel before turning the base challenge off.
        self.coordinate_step()
        region = self._dungeon_region("DOUBLE_LOOT_REGION")
        deadline = time.monotonic() + min(DUNGEON_UNKNOWN_LIMIT, 15.0)
        next_report = time.monotonic() + 10
        while time.monotonic() < deadline:
            self.check()
            if not self.observation_foreground():
                self.sleep(.25)
                continue
            image = self.window.capture()
            state = self._entry_option_state(image, region)
            if state == "selected":
                self._set_entry_option("DOUBLE_LOOT_REGION", False, "더블 루팅", observed_state=state)
                return
            if state in ("available", "disabled"):
                return
            if self._entry_option_state(image, self._dungeon_region("CHALLENGE_REGION")) in ("available", "disabled"):
                self.status("더블 루팅 별도 판독 없음 · 현재 무료 입장 조건 직접 확인")
                return
            if time.monotonic() >= next_report:
                self.status("더블 루팅/무료 입장 조건 재관측 · 선택 또는 입장 입력 없이 대기")
                next_report = time.monotonic() + 10
            self.sleep(.25)
        raise DungeonObservationPending("더블 루팅/무료 입장 조건 미확인 · 입장 입력 없이 화면 복구 후 다시 확인합니다.")

    def _dungeon_admission_pending(self):
        receipt = getattr(self, "_dungeon_admission_receipt", None)
        return bool(receipt and receipt["state"] == "possibly_sent")

    def _reserve_dungeon_admission(self):
        receipt = getattr(self, "_dungeon_admission_receipt", None)
        if receipt is not None:
            receipt["state"] = "possibly_sent"  # Reserve BEFORE physical press.

    def _observe_dungeon_admission(self):
        receipt = getattr(self, "_dungeon_admission_receipt", None)
        if receipt is not None:
            receipt["state"] = "effect_observed"

    def _enter_dungeon(self, free):
        if getattr(self, '_dungeon_module', None) == 'hunt':
            return self._adaptive_dungeon().enter(free)
        if self._adaptive_window():
            return self._adaptive_dungeon().enter(free)
        module=getattr(self, "_dungeon_module", "dungeon")
        target = self._dungeon_target({"abyss": "ABYSS_ENTER", "vanguard": "VANGUARD_ENTER"}.get(module, "COMBAT_ENTER_REGION"))
        with self.coordinate_sequence():
            self.status("입장하기 좌표 입력")
            if module == "abyss" and getattr(self, "_reuse_abyss_options", False):
                if self.input("click", target, guard=self._abyss_repeat_current,
                              skip_if_guard_fails=True) is False:
                    raise InputInterrupted("어비스 재입장 입력 전 실행 문맥 변경")
            else:
                arguments = ({"before_send": self._reserve_dungeon_admission}
                             if getattr(self, "_dungeon_admission_receipt", None) is not None else {})
                self.input("click", target, **arguments)
            if free:
                self.status("재화 없이 입장 확인 좌표 입력")
                self.input("click",self._dungeon_target("FREE_ENTRY_CONFIRM_REGION"))

    def _configure_entry_options(self, module, destination, free, double_loot=False):
        if module == 'hunt':
            return self._adaptive_dungeon().configure_options(module,destination,free,double_loot)
        if self._adaptive_window():
            return self._adaptive_dungeon().configure_options(module, destination, free, double_loot)
        if module not in ("dungeon", "deep"):
            return
        self._dungeon_module = module
        self.input("move", self._dungeon_pointer("center"))
        self._set_entry_option("CHALLENGE_REGION", not free, "재화 사용")
        if module == "dungeon" and free:
            self._configure_free_double()
        if module == "dungeon" and not free:
            self._set_entry_option("DOUBLE_LOOT_REGION", bool(double_loot), "더블 루팅")
        desired_match = destination.get("party") == "함께하기"
        self._set_fixed_matching("MATCHING_TOGGLE_REGION", desired_match)

    def _configure_initial_dungeon(self, module, destination, free, double_loot=False):
        if module == 'hunt':
            return self._adaptive_dungeon().configure_initial(module,destination,free,double_loot)
        if self._adaptive_window():
            return self._adaptive_dungeon().configure_initial(module, destination, free, double_loot)
        if module == "abyss":
            self._configure_abyss(destination, from_field=None)
            return
        if module == "vanguard":
            self._configure_vanguard()
            return
        self.status("시작 화면 고정 경로 · 모드 → 난이도 → 구역 → 구역 진입")
        from .dungeon_execution import run_selection
        from .fixed_dungeon_geometry import DIFFICULTIES
        from .dungeon_routes import destination_key
        floor, zone = destination_key(destination)
        ref = lambda role: self._ui_route_runtime().prepared_ref("dungeon.fixed", role)
        actions = {"mode": ("click", ref("dungeon.mode."+module)),
            "difficulty": ("click", ref(f"dungeon.difficulty.{module}.{DIFFICULTIES[destination['difficulty']]}")),
            "section": ("click", ref(f"dungeon.section.{module}.{floor}-{zone}")),
            "floor_enter": ("click", ref("FLOOR_ENTER_REGION"))}
        run_selection(self, "dungeon.select." + module,
            {name: (lambda guard, action=action: self.input(*action)) for name, action in actions.items()},
            context=destination)
        self._configure_entry_options(module, destination, free, double_loot)


    def _recover_dungeon_entry(self, module, destination, free, reason):
        while True:
            self.check()
            try:
                return self._recover_dungeon_checkpoint(module, destination, free, reason)
            except InputInterrupted as exc:
                self.status("던전 복구 입력 전 화면 변화 · 새 관측으로 재시도 · " + str(exc))
                self.sleep(.5)

    def _recover_dungeon_checkpoint(self, module, destination, free, reason):
        """Recover a transient observation without guessing a new route.

        No admission or toggle is sent here. Recognized result navigation may
        be retried; entry settings are reapplied by the normal fresh-state path.
        Unknown views rotate prepared text context, pointer parking and popup
        handling until a known checkpoint returns or the user/client exits.
        """
        attempt = 0
        reveal = ResultRevealGate(execution_rules.number("dungeon.result.reveal_settle_seconds", 3.0))
        last_reveal = -100.
        while True:
            self.check()
            attempt += 1
            refresh = getattr(self.window, "refresh", None)
            if callable(refresh):
                refresh()  # Detect client closure before waiting for foreground.
            if not self.observation_foreground():
                self.status("던전 복구 · 게임 전경 대기 · 작업 기록 유지")
                self.sleep(2)
                continue
            source = module == 'hunt' or getattr(self, "_source_window", lambda: False)()
            if self.ocr is None:
                self.ocr = FocusedOCR()
            if source:
                port = self._adaptive_dungeon()
                reader = port.observer
                # Both alternatives were calculated at screen confirmation.
                reader.recovery_crops = execution_rules.value("dungeon.recovery.alternate_crops", True) and attempt % 2 == 1
            else:
                from .adaptive_dungeon import AdaptiveDungeon
                reader = AdaptiveDungeon(self)
                reader.module, reader.destination = module, dict(destination)
                reader.free = free or module in ("abyss", "vanguard")
            observation = self._dungeon_observation_context()
            image = self.window.capture()
            if recover_fullscreen_popup(self, image):
                self.status("던전 복구 · 팝업 닫기 후 같은 단계 재확인")
                self.sleep(.5)
                continue
            activity = self._dungeon_activity()
            state = activity.get("Dungeon", {}).get("State")
            signal = common_dungeon_signal(self, image) if source else self.ocr.dungeon_signal(image)
            if state in ("InProgress", "Matching") or signal == "matching":
                return "started"
            hud = common_field_ready(self, image) if source else self.ocr.field_ready(image)
            if (hud and
                    _flag(activity, "IsInCombat", "combatState") is True and
                    state not in ("Completed", "Complete", "Result", "Cleared", "Finished")):
                return "started"
            buttons = self._current_result_buttons(image, module)
            if buttons:
                self._remember_result_buttons(buttons, observation)
                if (module == "abyss" and getattr(self, "_abyss_reentry", "other_dungeon") == "retry"
                        and "retry" in buttons):
                    self._send_result_retry(buttons["retry"])
                    self.sleep(2)
                    continue
                if (module == "abyss" and getattr(self, "_abyss_reentry", "other_dungeon") != "retry"
                        and "other" in buttons):
                    return "field" if self._open_abyss_list_from_result() else "list"
                if module != "abyss" and "retry" in buttons:
                    self._send_result_retry(buttons["retry"])
                    self.sleep(2)
                    continue
            if not buttons and self._reentry_inactive(activity):
                self._check_dungeon_currency(image=image, refusal_only=True, observation=observation)
            if source:
                screen = port.classify_entry_screen(image)
            elif self._fixed_entry_checkpoint(image, module):
                screen = "entry"
            elif module == "abyss":
                screen = reader._abyss_start(image)
                if screen is None and reader._entry_context(image):
                    screen = "entry"
                if screen is None and reader._exact(image, "어비스", execution_rules.geometry(
                        "observation.adaptive_dungeon.MENU", image=image), "abyss.menu"):
                    screen = "menu"
            else:
                from .adaptive_dungeon import observation_roi
                screen = "entry" if reader._entry_context(image) else None
                if screen is None and module in ("dungeon", "deep"):
                    if reader._name_present(image) and "진입" in reader._text(image, observation_roi("FOOTER", image=image)):
                        screen = "list"
                if screen is None and module == "vanguard":
                    if "늑대의숲" in reader._text(image, observation_roi("TITLE", image=image)):
                        screen = "list"
            if screen == "entry" and not self._reentry_inactive(activity):
                screen = None  # Do not replay admission while activity is uncertain.
            if screen in ("entry", "list") or module == "abyss" and screen in ("field", "menu"):
                self.status(f"던전 복구 {attempt} · {screen} 확인 · 조건 재적용 후 계속")
                return screen
            now = time.monotonic()
            self._observe_result_recovery(image, signal, activity, reveal, now, hud=hud)
            if reveal.ready(now) and now-last_reveal >= execution_rules.number("dungeon.result.reveal_retry_seconds", 8.0):
                if self._reveal_current_result(reveal.phase, module,
                        observed=(image, signal, activity, observation)) is not False:
                    last_reveal = time.monotonic()
                    self.status("던전 복구 · 소유 완료 연출의 중립 배경 펼치기 · 입장/선택 재전송 없음")
            self.status(f"던전 복구 {attempt} · 판독 문맥 교체/가림 해소 후 재확인 · {reason}")
            def park():
                if source:
                    reference = self._source_neutral_target("ui.pointer.park",
                        lambda: getattr(self.window, "is_foreground", lambda: True)())
                    try:
                        self.input("park", reference)
                    except InputInterrupted:
                        pass  # Focus changed before the non-click recovery move.
                else:
                    self.input("move", self._dungeon_pointer("recovery"))
            from .dungeon_execution import recover_unknown
            recover_unknown(self, park)

    def _prepare_dungeon_admission(self, module, destination, free, double_loot,
                                   *, first_entry, from_field, already_entered=False):
        from .dungeon_execution import owns
        transition = getattr(self, "_abyss_reentry_transition", None)
        interrupted_reentry = (module == "abyss" and not first_entry and not from_field and
                               transition is not None and not owns(self, transition))
        reuse_abyss = (module == "abyss" and not first_entry and not from_field and
            getattr(self, "_abyss_applied_destination", None) == destination and self._abyss_repeat_current())
        self._dungeon_module = module
        self._admission_context = (module, dict(destination), bool(free), bool(double_loot))
        if module == "abyss":
            self._abyss_result_tape_sent = False
        if self._dungeon_admission_pending():
            self.status("입장 수신 미확정 · 이미 보냈을 수 있는 유료 입력은 재전송하지 않고 현재 진행 확인")
            self._wait_dungeon_started()
            return
        if already_entered:
            self._dungeon_admission_owned_scope = self._result_reveal_scope()
            return  # Vanguard/selected Abyss Retry is observed, never followed by settings clicks.
        checkpoint = None
        if interrupted_reentry:
            checkpoint = self._recover_dungeon_entry(module, destination, free,
                "어비스 다른 던전 입력 이후 사용자 입력/화면 변경")
            if checkpoint == "started":
                self._dungeon_admission_owned_scope = self._result_reveal_scope()
                return
            first_entry, from_field = True, checkpoint == "field"
            reuse_abyss = False
        while True:
            self.check()
            try:
                self._reuse_abyss_options = reuse_abyss and checkpoint is None
                if module == "abyss" and checkpoint == "entry" and not self._adaptive_window():
                    self._configure_abyss(destination, from_field=False, from_entry=True)
                elif checkpoint == "menu" and module == "abyss" and not self._adaptive_window():
                    with self.coordinate_sequence():
                        self.input("click", self._dungeon_target("ABYSS_MENU"))
                    self._configure_abyss(destination, from_field=False)
                elif first_entry or getattr(self, "_result_entry_recovery", False) or checkpoint == "list" or module == "vanguard" or module == "hunt" and checkpoint is not None:
                    self._configure_initial_dungeon(module, destination, free, double_loot)
                elif module == "abyss":
                    self._configure_abyss(destination, from_field=from_field,
                                          reuse_options=self._reuse_abyss_options)
                else:
                    self._configure_entry_options(module, destination, free, double_loot)
                self._check_dungeon_currency()
                if module in ("dungeon", "deep") and not free:
                    self._dungeon_admission_receipt = dict(state="unsent", context=self._admission_context)
                else:
                    self._dungeon_admission_receipt = None
                self._enter_dungeon(free)
                self.coordinate_step()
                self._dungeon_admission_owned_scope = self._result_reveal_scope()
                from .dungeon_execution import clear
                clear(self)
                if module == "abyss":
                    from .dungeon_execution import scope
                    self._abyss_applied_destination = dict(destination)
                    self._abyss_applied_scope = scope(self)
                    self._abyss_repeat_scope = None
                    self._abyss_reentry_transition = None
                return
            except (DungeonObservationPending, InputInterrupted) as exc:
                if self._dungeon_admission_pending():
                    self._dungeon_admission_owned_scope = self._result_reveal_scope()
                    self.status("유료 입장 입력 일부 전송 가능 · 옵션/입장 묶음 재실행 없이 접수 결과 확인")
                    self._wait_dungeon_started()
                    return
                self._reuse_abyss_options = reuse_abyss = False
                self._abyss_repeat_scope = None
                self.status("던전 인식 재시도 · 중단 없이 현재 단계 복구 · " + str(exc))
                checkpoint = self._recover_dungeon_entry(module, destination, free, str(exc))
                if checkpoint == "started":
                    self._dungeon_admission_owned_scope = self._result_reveal_scope()
                    return
                first_entry = (checkpoint == "list" or module == "abyss" and self._adaptive_window())
                from_field = checkpoint == "field"
                self.sleep(.5)
            finally:
                self._reuse_abyss_options = False

    def _notify_chat_entry(self, activity, hud, signal):
        """Auxiliary one-shot event, never matching/entry/result or an input gate."""
        if getattr(self,"_chat_entry_notified",True):return
        state=activity.get("Dungeon",{}).get("State")
        if signal in ("matching","entry","clear_intro","loot_intro","result"):return
        if activity.get("IsAbyssResultSequencePlaying") is True:return
        # Existing CLI InProgress or fresh combat HUD is positive progress.
        # A field HUD, matching token, or unknown state alone is not admission.
        if not (state=="InProgress" or (hud and _flag(activity,"IsInCombat","combatState") is True
                and state not in ("Matching","Completed","Complete","Result","Cleared","Finished"))):return
        self._chat_entry_notified=True
        callback=getattr(self,"on_dungeon_entered",None)
        if callback is not None:
            try:callback(self._chat_entry_id)
            except Exception:
                pass  # Optional chat must not terminate/retry game automation.

    def dungeon(self, module, mode, repetitions=1, destination=None, double_loot=False, abyss_food=False,
                travel_snack=False, abyss_reentry="other_dungeon", snack_batch_count=10,
                stop_on_snack_empty=True):
        """User-prepared start; opted-in B food, no boxes or paid CLI."""
        destination=destination or {}
        validate_destination(module, destination)
        mode=canonical_mode(module, mode)
        self._dungeon_stop_on_currency = mode == "currency_exhausted" and module in ("dungeon", "deep", "hunt")
        require(abyss_reentry in ("other_dungeon", "retry"), "어비스 재입장 방식이 올바르지 않습니다.")
        self._abyss_reentry = abyss_reentry if module == "abyss" else "other_dungeon"
        require(type(repetitions) is int and repetitions > 0, "반복 횟수는 1 이상의 정수여야 합니다.")
        self._dungeon_module=module
        self._abyss_wipe_pending = None  # A new run cannot inherit a prior frame's proof.
        self.prepare()
        # The old half-window restriction applies only to its measured tape.
        # Source profiles validate destination identity here and their current
        # calculated viewport/visibility inside the source adapter.
        validate_destination(module, destination, size=None if self._source_window() else self._window_size())
        food_enabled = bool(abyss_food and dungeon_food_allowed(module, mode, repetitions))
        require(type(snack_batch_count) is int and snack_batch_count > 0, "간식 제작 수량은 1 이상의 정수여야 합니다.")
        require(type(stop_on_snack_empty) is bool, "간식 소진 시 중단 설정은 체크 값이어야 합니다.")
        self._snack_batch_count = snack_batch_count
        self._stop_on_snack_empty = stop_on_snack_empty
        self._travel_snack = bool(module in ("abyss", "vanguard") and food_enabled and travel_snack)
        if self._travel_snack:
            require(self._source_window() or (not self._half_window() and not self._adaptive_window()),
                    "이 창모드에서는 여행자 간식 제작 완료 화면이 아직 수집되지 않았습니다. "
                    "자동 제작을 해제하고 기본 음식 B 사용을 선택해 주세요. 던전 입력은 보내지 않았습니다.")
            if self._source_window():
                from .source_snack_port import SourceTravelSnack
                self._snack = SourceTravelSnack(self)
            else:
                self._snack = TravelSnack(self)
            self._snack.reconcile_pending()
        double_loot = bool(double_loot and module in ("dungeon", "hunt"))
        signature_values = dict(module=module, mode=mode, repetitions=repetitions,
                                destination=destination or {}, double_loot=double_loot)
        if module == "abyss":
            signature_values["abyss_reentry"] = self._abyss_reentry
        signature=json.dumps(signature_values, sort_keys=True, ensure_ascii=False)
        # Same unfinished 1.0.12 free-only task: rename only its obsolete
        # currency mode, keeping the user's confirmed completion receipt.
        current=self.store.value.get("run", {})
        if module in ("abyss", "vanguard") and isinstance(current, dict) and current.get("kind") == "dungeon":
            try:
                previous=json.loads(current.get("signature", ""))
                if isinstance(previous, dict) and previous.get("module") == module:
                    previous["mode"]=canonical_mode(module, previous["mode"])
                    if module == "abyss":
                        previous.setdefault("abyss_reentry", "other_dungeon")
                    if json.dumps(previous,sort_keys=True,ensure_ascii=False) == signature:
                        self.store.set_run(signature=signature, mode=mode)
            except (ValueError, TypeError, KeyError, MiniError):
                pass  # Unrelated or malformed old tasks are never merged.
        saved=self.store.begin_run("dungeon",signature,module=module,mode=mode,destination=destination or {},
                                   goal=repetitions,completed=0,state="running", abyss_reentry=self._abyss_reentry)
        completed=int(saved.get("completed",0));self.store.set_run(state="running")
        self._abyss_food_next_at = time.monotonic() + ABYSS_FOOD_INTERVAL if food_enabled else None
        self._abyss_food_presses = int(saved.get("abyss_food_presses", 0))
        self._snack_restock_due = bool(self._travel_snack and saved.get("snack_restock_due"))
        self._snack_empty_wait = False
        self.store.set_run(abyss_food=food_enabled, travel_snack=self._travel_snack,
                           snack_batch_count=snack_batch_count, stop_on_snack_empty=stop_on_snack_empty)
        if food_enabled:
            self.status("던전 음식 2분 주기 · 최초 사용은 시작 2분 후 · " +
                        (f"여행자 간식은 사용자가 기본 음식으로 등록 · B 입력/최대 {snack_batch_count}회 보충" if self._travel_snack else "일반 음식 B 입력"))
        infinite = mode in ("currency_exhausted", "free_infinite", "until_stopped")
        free=mode in ("free_count","free_infinite") and module in ("dungeon","deep")
        first_entry = True
        retry_already_entered = False
        abyss_from_field = True
        while infinite or completed < repetitions:
            self.check()
            desired_double = bool(double_loot and not free and
                                  (infinite or repetitions - completed >= 2))
            self.status(f"던전 입장 · 완료 {completed}" + ("" if infinite else f"/{repetitions}"))
            try:
                if first_entry:
                    self._check_dungeon_currency(balance_only=True)
                self._prepare_dungeon_admission(module, destination or {}, free, desired_double,
                    first_entry=first_entry, from_field=abyss_from_field,
                    already_entered=retry_already_entered)
                self._entry_from_result=retry_already_entered
                self._chat_entry_notified=False
                self._chat_entry_id=f"{id(self)}:{time.monotonic_ns()}:{completed}"
                # Only positive current currency evidence can finish this
                # mode. Other failures and cancellation retain their meaning.
                outcome = self._wait_dungeon_started()
                if module != "abyss" or outcome != "abyss_wipe":
                    outcome = self._wait_dungeon_result()
            except DungeonCurrencyExhausted as exc:
                return self._finish_currency_exhausted(completed, exc.evidence)
            if outcome == "entry_recovery":
                first_entry = True
                retry_already_entered = False
                continue  # No result receipt: do not invent 1/2 completions.
            if module == "abyss" and outcome == "abyss_wipe":
                from .abyss_wipe import recover_wipe
                recover_wipe(self)  # Returns only after the actual exit is confirmed.
                first_entry = True
                abyss_from_field = True
                retry_already_entered = False
                self.status("어비스 전멸 퇴장 확인 · 완료 횟수 유지 · 처음 입장 경로로 재시작")
                continue
            completed += 2 if desired_double else 1
            self.store.set_run(completed=completed, state="result")
            self.status(f"던전 완료 {completed}" + (" · 무한 반복" if infinite else f"/{repetitions}"))
            if not infinite and completed >= repetitions:
                self._adaptive_result_action("exit")
                self.store.set_run(state="complete")
                return completed
            try:
                self._check_dungeon_currency(balance_only=True)
            except DungeonCurrencyExhausted as exc:
                return self._finish_currency_exhausted(completed, exc.evidence, at_result=True)
            if self._travel_snack and self._snack_restock_due:
                retry_already_entered = False
                abyss_from_field = True
                self.status("간식 보충 예약 · 다시 하기 대신 나가기")
                self._adaptive_result_action("exit")
                self._wait_owned_field()
                # TravelSnack recovers its current read/selection/receipt/ACK
                # stage. Do not retry the whole craft or classify an uncertain
                # screen as a permanent shortage/stop here.
                stock = self._snack.craft(self._snack_batch_count)
                self._snack_restock_due = False
                self._snack_empty_wait = False
                self.store.set_run(snack_restock_due=False, snack_quantity=stock[SNACK]["total"])
                if stock[SNACK]["total"] == 0:
                    if self._stop_on_snack_empty:
                        self._abyss_food_next_at = None
                        self.store.set_run(state="stopped_snack_empty")
                        self.status("여행자 간식 소진·추가 제작 불가 · 사용자 설정에 따라 던전 반복 중단 (현재 회차 완료 보존)")
                        return completed
                    self._abyss_food_next_at = time.monotonic() + ABYSS_FOOD_INTERVAL
                    self.status("여행자 간식 소진·추가 제작 불가 · 사용자 설정에 따라 음식 없이 계속")
                self._wait_next_cycle("간식 보충 후 던전")
            elif module == "abyss":
                retry_already_entered = False
                if self._abyss_reentry == "retry":
                    sent = self._send_result_retry(None)
                    retry_already_entered = sent is not False
                    self._retry_click_at = time.monotonic()
                elif "other" in getattr(self, "_result_buttons", {}):
                    abyss_from_field = self._open_abyss_list_from_result()
                else:
                    # Older result layouts have no Other Dungeon button.
                    # Exit and use the same initial route, never Retry.
                    self._adaptive_result_action("exit")
                    self._wait_owned_field()
                    abyss_from_field = True
            else:
                self._wait_next_cycle("던전 한 회차")
                result_buttons = getattr(self, "_result_buttons", {})
                retry_region = self._dungeon_result_target("retry", result_buttons) if "retry" in result_buttons else None
                if retry_region is None and not self._source_window():
                    retry_region = self._dungeon_target("VANGUARD_RETRY" if module == "vanguard" else "COMBAT_RETRY_REGION")
                sent = self._send_result_retry(retry_region)
                if sent is False:
                    self.status("다시 하기 입력 보류 · 사용자 입력/화면 변화 재확인 후 계속 시도")
                self._retry_click_at=time.monotonic()
                retry_already_entered = module == "vanguard"
            self.coordinate_step()
            first_entry = bool(getattr(self, "_result_entry_recovery", False))


class QuickActions:
    """Explicit user-click actions. This is the only class exposing paid gather."""

    def __init__(self, connector: Connector, *, status=lambda _message: None, coordinate_runtime=None):
        self.connector = connector
        self.status = status
        self.cancel = threading.Event()
        self.coordinate_runtime = coordinate_runtime

    def stop(self):
        self.cancel.set()

    def check(self):
        if self.cancel.is_set():
            raise Cancelled("사용자가 작업을 취소했습니다.")

    def gather(self, display_name):
        self.check()
        self.connector.connect()
        self.check()
        self.status(f"CLI 퀵사용 전송 · {display_name} · 날개 5개")
        self.connector.quick_gather(display_name, before_send=self.check)

    def fishing(self, node):
        self.check()
        if self.coordinate_runtime is None:
            raise CoordinatePreparationRequired("화면비 확인을 눌러 전체 경로 좌표를 준비해 주세요. 창 크기/UI 변경 후 다시 확인.")
        self.coordinate_runtime.ensure_prepared()
        helper = AutomationService(self.connector, Store(), status=self.status)
        helper.before_game_input = getattr(self, "before_game_input", None)
        helper._rule_runtime = self.coordinate_runtime
        helper.cancel = self.cancel
        helper.prepare()
        with helper.coordinate_sequence():
            point = helper._gather_tape(node)
            helper._send_gather_start(node, point)
        travel = helper._follow_gather_travel()
        seen_travel = bool(isinstance(travel, dict) and travel.get("travel_seen"))
        arrived = False
        while True:
            self.check()
            if helper._recover_gather_popup():
                continue
            activity = self.connector.read("get_activity")
            travelling = _flag(activity, "IsAutoTraveling", "autoTravel") is True
            seen_travel = seen_travel or travelling
            if seen_travel and not travelling:
                arrived = True
                break
            helper.sleep(2)
        self.status(f"{node['name']} 도착 · CLI 낚시 시작 · 날개 5개")
        self.connector.quick_gather("잡어", before_send=self.check)

    def collect(self, group):
        self.check()
        self.connector.connect()
        self.check()
        names = catalog.facility_recipe_names(group)
        self.status("CLI 퀵사용 · 가공 완료품 모두 받기")
        self.connector.quick_collect_facility(names, before_send=self.check)
