diff --git a/Py4GWCoreLib/routines_src/reliable_interaction.py b/Py4GWCoreLib/routines_src/reliable_interaction.py new file mode 100644 index 000000000..8b4803025 --- /dev/null +++ b/Py4GWCoreLib/routines_src/reliable_interaction.py @@ -0,0 +1,801 @@ +"""Opt-in, profile-aware interaction reliability for BottingTree and coroutine bots. + +Legacy interaction wrappers intentionally do not import or call this module. Callers provide a +policy plus a runtime adapter and must opt in at each interaction site. +""" +from __future__ import annotations + +import math +import time +from collections.abc import Callable, Generator, Mapping +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any, Protocol + + +class InteractionProfile(str, Enum): + QUEST_CONVERSATION = "quest_conversation" + VISIBLE_CHOICE = "visible_choice" + REWARD_ACCEPT = "reward_accept" + AUTOMATIC_TRIGGER = "automatic_npc_or_beacon_trigger" + GADGET = "gadget_door_or_chest" + WALK_OVER = "walk_over_trigger" + CHALLENGE_THEN_REINTERACT = "challenge_then_reinteract" + + +class TargetKind(str, Enum): + LIVING = "living" + NPC = "npc" + GADGET = "gadget" + ITEM = "item" + LOCATION = "location" + + +class PostconditionKind(str, Enum): + CUSTOM = "custom" + QUEST_ACTIVE = "quest_active" + QUEST_COMPLETED = "quest_completed" + QUEST_CLEARED = "quest_cleared" + EFFECT_PRESENT = "effect_present" + MAP_CHANGED = "map_changed" + ITEM_DROP_PRESENT = "item_drop_present" + BUNDLE_CHANGED = "bundle_changed" + HOSTILITY_CHANGED = "hostility_changed" + GADGET_STATE_CHANGED = "gadget_state_changed" + + +class InteractionStatus(str, Enum): + RUNNING = "running" + SUCCESS = "success" + FAILURE = "failure" + + +@dataclass(frozen=True) +class TargetSpec: + kind: TargetKind + model_id: int = 0 + name_contains: str = "" + expected_xy: tuple[float, float] | None = None + search_radius: float = 1200.0 + + +@dataclass(frozen=True) +class ApproachSpec: + player_approach_xy: tuple[float, float] | None = None + tolerance: float = 90.0 + target_tolerance: float | None = None + timeout_ms: int = 12_000 + move_reissue_ms: int = 600 + + +@dataclass(frozen=True) +class DialogSpec: + visible_button: int | None = None + raw_context_ids: tuple[int, ...] = () + response_timeout_ms: int = 4_000 + raw_step_delay_ms: int = 350 + allow_raw_without_visible_dialog: bool = False + close_dialog_after_success: bool = False + post_success_dialog_timeout_ms: int = 1_500 + + +@dataclass(frozen=True) +class PostconditionSpec: + kind: PostconditionKind + value: int | str | tuple[int, ...] | None = None + description: str = "" + + +@dataclass(frozen=True) +class RetryPolicy: + max_attempts: int = 3 + poll_ms: int = 100 + verify_timeout_ms: int = 4_000 + retry_delay_ms: int = 400 + pause_settle_ms: int = 350 + challenge_timeout_ms: int = 90_000 + + +@dataclass(frozen=True) +class InteractionSpec: + name: str + profile: InteractionProfile + target: TargetSpec + approach: ApproachSpec + postcondition: PostconditionSpec + dialog: DialogSpec = field(default_factory=DialogSpec) + retry: RetryPolicy = field(default_factory=RetryPolicy) + close_stale_dialog: bool = True + allow_preexisting_postcondition: bool = True + source_capture_ids: tuple[str, ...] = () + + def to_metadata(self) -> dict[str, Any]: + data = asdict(self) + data["profile"] = self.profile.value + data["target"]["kind"] = self.target.kind.value + data["postcondition"]["kind"] = self.postcondition.kind.value + return data + + +@dataclass(frozen=True) +class CaptureInteractionMetadata: + """Normalized capture metadata; runtime IDs are deliberately excluded.""" + + schema_version: int + interaction_kind: InteractionProfile + target_kind: TargetKind + target_xy: tuple[float, float] | None + player_approach_xy: tuple[float, float] | None + model_id: int + target_name: str + postcondition: PostconditionSpec + source_capture_ids: tuple[str, ...] = () + + def to_dict(self) -> dict[str, Any]: + result = asdict(self) + result["interaction_kind"] = self.interaction_kind.value + result["target_kind"] = self.target_kind.value + result["postcondition"]["kind"] = self.postcondition.kind.value + return result + + +def normalize_capture_metadata( + capture: Mapping[str, Any], + *, + interaction_kind: InteractionProfile, + postcondition: PostconditionSpec, + target_kind: TargetKind = TargetKind.LIVING, +) -> CaptureInteractionMetadata: + """Normalize either capture widget's record without requiring a second capture.""" + + target = capture.get("target") if isinstance(capture.get("target"), Mapping) else {} + player = capture.get("player") if isinstance(capture.get("player"), Mapping) else {} + point = capture.get("point") if isinstance(capture.get("point"), Mapping) else capture + target_xy = target.get("xy") or point.get("target_xy") or point.get("npc_xy") + approach_xy = ( + point.get("player_approach_xy") + or player.get("xy") + or point.get("xy") + ) + + def _xy(value: Any) -> tuple[float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) < 2: + return None + return float(value[0]), float(value[1]) + + capture_id = str(capture.get("capture_id") or "") + return CaptureInteractionMetadata( + schema_version=2, + interaction_kind=interaction_kind, + target_kind=target_kind, + target_xy=_xy(target_xy), + player_approach_xy=_xy(approach_xy), + model_id=int(target.get("model_id") or point.get("preferred_npc_model_id") or 0), + target_name=str(target.get("name") or point.get("npc_name") or ""), + postcondition=postcondition, + source_capture_ids=(capture_id,) if capture_id else (), + ) + + +class InteractionRuntime(Protocol): + def now(self) -> float: ... + def pause_automation(self) -> None: ... + def restore_automation(self) -> None: ... + def cancel_movement(self) -> None: ... + def close_stale_dialog(self) -> None: ... + def resolve_target(self, target: TargetSpec) -> int | None: ... + def agent_xy(self, agent_id: int) -> tuple[float, float]: ... + def agent_model_id(self, agent_id: int) -> int: ... + def player_xy(self) -> tuple[float, float]: ... + def move_to(self, xy: tuple[float, float]) -> None: ... + def interact(self, agent_id: int) -> None: ... + def dialog_visible(self) -> bool: ... + def dialog_button_count(self) -> int: ... + def click_dialog_button(self, choice: int) -> bool: ... + def send_dialog(self, dialog_id: int) -> None: ... + def verify(self, postcondition: PostconditionSpec) -> bool: ... + def is_hostile(self, agent_id: int) -> bool: ... + def emit(self, event: dict[str, Any]) -> None: ... + + +class ReliableInteractionController: + """Tick-driven controller shared by both adapters.""" + + def __init__(self, spec: InteractionSpec, runtime: InteractionRuntime): + self.spec = spec + self.runtime = runtime + self.stage = "new" + self.attempt = 0 + self.agent_id = 0 + self.deadline = 0.0 + self.last_move_at = 0.0 + self.raw_index = 0 + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self.paused = False + self.finished = False + self.result = InteractionStatus.RUNNING + + def _event(self, event: str, **values: Any) -> None: + self.runtime.emit( + { + "component": "reliable_interaction", + "event": event, + "interaction": self.spec.name, + "profile": self.spec.profile.value, + "stage": self.stage, + "attempt": self.attempt, + "agent_id_runtime_only": self.agent_id, + **values, + } + ) + + def _finish(self, success: bool, reason: str) -> InteractionStatus: + if not self.finished: + try: + self.runtime.cancel_movement() + finally: + if self.paused: + self.runtime.restore_automation() + self.paused = False + self.finished = True + self.result = InteractionStatus.SUCCESS if success else InteractionStatus.FAILURE + self.stage = "complete" if success else "failed" + self._event("result", success=success, reason=reason) + return self.result + + def _postcondition_succeeded(self, now: float, reason: str) -> InteractionStatus: + self._event( + "postcondition_verified", + postcondition=self.spec.postcondition.kind.value, + description=self.spec.postcondition.description, + ) + if self.spec.dialog.close_dialog_after_success: + self.stage = "wait_post_success_dialog" + self.deadline = now + self.spec.dialog.post_success_dialog_timeout_ms / 1000.0 + self._event( + "post_success_dialog_wait_started", + timeout_ms=self.spec.dialog.post_success_dialog_timeout_ms, + ) + return InteractionStatus.RUNNING + return self._finish(True, reason) + + def cancel(self, reason: str = "cancelled") -> None: + if not self.finished: + self._finish(False, reason) + + def _retry(self, reason: str) -> InteractionStatus: + self._event("attempt_failed", reason=reason) + if self.attempt >= max(1, self.spec.retry.max_attempts): + return self._finish(False, reason) + self.runtime.cancel_movement() + if self.spec.close_stale_dialog: + self.runtime.close_stale_dialog() + self.agent_id = 0 + self.raw_index = 0 + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self.stage = "retry_delay" + self.deadline = self.runtime.now() + self.spec.retry.retry_delay_ms / 1000.0 + return InteractionStatus.RUNNING + + def _destination(self) -> tuple[float, float] | None: + if self.spec.approach.player_approach_xy is not None: + return self.spec.approach.player_approach_xy + target_xy = self.runtime.agent_xy(self.agent_id) if self.agent_id else self.spec.target.expected_xy + if target_xy is None: + return None + if self.spec.target.kind == TargetKind.LOCATION: + return target_xy + player_xy = self.runtime.player_xy() + distance = math.dist(player_xy, target_xy) + stand_off = max(80.0, self.spec.approach.tolerance) + if distance <= stand_off: + return player_xy + scale = stand_off / distance + return ( + target_xy[0] + (player_xy[0] - target_xy[0]) * scale, + target_xy[1] + (player_xy[1] - target_xy[1]) * scale, + ) + + def _agent_model_id(self, agent_id: int) -> int | None: + getter = getattr(self.runtime, "agent_model_id", None) + return int(getter(agent_id)) if callable(getter) else None + + def _effective_target_tolerance(self) -> float | None: + if self.spec.target.kind == TargetKind.LOCATION: + return None + if self.spec.approach.target_tolerance is not None: + return self.spec.approach.target_tolerance + if self.spec.approach.player_approach_xy is not None: + return self.spec.approach.tolerance + return max(80.0, self.spec.approach.tolerance) + + def _position_snapshot(self, destination: tuple[float, float] | None) -> dict[str, Any]: + player_xy = self.runtime.player_xy() + agent_xy = self.runtime.agent_xy(self.agent_id) if self.agent_id else None + expected_xy = self.spec.target.expected_xy + return { + "player_xy": player_xy, + "agent_xy": agent_xy, + "expected_target_xy": expected_xy, + "player_approach_xy": destination, + "player_to_approach_distance": ( + round(math.dist(player_xy, destination), 2) if destination is not None else None + ), + "player_to_agent_distance": ( + round(math.dist(player_xy, agent_xy), 2) if agent_xy is not None else None + ), + "agent_to_expected_target_distance": ( + round(math.dist(agent_xy, expected_xy), 2) + if agent_xy is not None and expected_xy is not None + else None + ), + "resolved_model_id": self._agent_model_id(self.agent_id) if self.agent_id else None, + "expected_model_id": self.spec.target.model_id or None, + } + + def _resolved_target_failure(self, snapshot: dict[str, Any]) -> str | None: + expected_model_id = self.spec.target.model_id + resolved_model_id = snapshot["resolved_model_id"] + if expected_model_id and resolved_model_id != expected_model_id: + return f"resolved model {resolved_model_id!r} did not match expected model {expected_model_id}" + expected_distance = snapshot["agent_to_expected_target_distance"] + if expected_distance is not None and expected_distance > self.spec.target.search_radius: + return ( + f"resolved target was {expected_distance:.2f} from expected location, " + f"outside search radius {self.spec.target.search_radius:.2f}" + ) + return None + + def tick(self) -> InteractionStatus: + if self.finished: + return self.result + try: + return self._tick() + except Exception as error: + self._event("exception", error=f"{type(error).__name__}: {error}") + return self._finish(False, "runtime exception") + + def _tick(self) -> InteractionStatus: + now = self.runtime.now() + if self.stage == "new": + if self.runtime.verify(self.spec.postcondition): + if self.spec.allow_preexisting_postcondition: + return self._finish(True, "postcondition already satisfied") + self._event("preexisting_postcondition_ignored") + self.runtime.pause_automation() + self.paused = True + self.runtime.cancel_movement() + if self.spec.close_stale_dialog: + self.runtime.close_stale_dialog() + self.stage = "pause_settle" + self.deadline = now + self.spec.retry.pause_settle_ms / 1000.0 + self._event("start", policy=self.spec.to_metadata()) + return InteractionStatus.RUNNING + + if self.stage == "pause_settle": + if now < self.deadline: + return InteractionStatus.RUNNING + self.attempt += 1 + self.stage = "resolve" + + if self.stage == "retry_delay": + if now < self.deadline: + return InteractionStatus.RUNNING + self.attempt += 1 + self.stage = "resolve" + + if self.stage == "resolve": + if self.spec.target.kind != TargetKind.LOCATION: + resolved = self.runtime.resolve_target(self.spec.target) + if not resolved: + return self._retry("target not found") + self.agent_id = int(resolved) + destination = self._destination() + snapshot = self._position_snapshot(destination) + failure = self._resolved_target_failure(snapshot) + if failure: + return self._retry(failure) + self._event("target_resolved", **snapshot) + self.stage = "approach" + self.deadline = now + self.spec.approach.timeout_ms / 1000.0 + self.last_move_at = 0.0 + destination = self._destination() + self._event( + "approach_started", + tolerance=self.spec.approach.tolerance, + target_tolerance=self._effective_target_tolerance(), + configured_target_tolerance=self.spec.approach.target_tolerance, + **self._position_snapshot(destination), + ) + + if self.stage == "approach": + destination = self._destination() + if destination is None: + return self._retry("no safe approach coordinate") + snapshot = self._position_snapshot(destination) + approach_distance = float(snapshot["player_to_approach_distance"]) + agent_distance = snapshot["player_to_agent_distance"] + target_tolerance = self._effective_target_tolerance() + target_close_enough = ( + target_tolerance is None + or (agent_distance is not None and float(agent_distance) <= target_tolerance) + ) + if approach_distance <= self.spec.approach.tolerance and target_close_enough: + self.runtime.cancel_movement() + self._event( + "approach_reached", + tolerance=self.spec.approach.tolerance, + target_tolerance=target_tolerance, + configured_target_tolerance=self.spec.approach.target_tolerance, + **snapshot, + ) + self.stage = "verify" if self.spec.profile == InteractionProfile.WALK_OVER else "reacquire" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + elif now >= self.deadline: + return self._retry("safe approach timed out") + elif now - self.last_move_at >= self.spec.approach.move_reissue_ms / 1000.0: + self.runtime.move_to(destination) + self.last_move_at = now + self._event( + "approach_progress", + tolerance=self.spec.approach.tolerance, + target_tolerance=target_tolerance, + configured_target_tolerance=self.spec.approach.target_tolerance, + movement_command_sent=True, + **snapshot, + ) + return InteractionStatus.RUNNING + + if self.stage == "reacquire": + resolved = self.runtime.resolve_target(self.spec.target) + if not resolved: + return self._retry("target disappeared before interaction") + old_id = self.agent_id + self.agent_id = int(resolved) + destination = self._destination() + snapshot = self._position_snapshot(destination) + failure = self._resolved_target_failure(snapshot) + if failure: + return self._retry(f"reacquired target validation failed: {failure}") + self._event( + "target_reacquired", + previous_agent_id_runtime_only=old_id, + **snapshot, + ) + self.stage = "interact" + + if self.stage == "interact": + self.runtime.interact(self.agent_id) + self._event("interaction_sent", target_xy=self.runtime.agent_xy(self.agent_id)) + if self.spec.profile in ( + InteractionProfile.AUTOMATIC_TRIGGER, + InteractionProfile.GADGET, + ): + self.stage = "verify" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + else: + self.stage = "wait_response" + self.deadline = now + self.spec.dialog.response_timeout_ms / 1000.0 + return InteractionStatus.RUNNING + + if self.stage == "wait_response": + visible = self.runtime.dialog_visible() + button_count = self.runtime.dialog_button_count() if visible else 0 + if visible and not self.dialog_visible_logged: + self.dialog_visible_logged = True + self._event("dialog_visible") + if visible and button_count > 0 and button_count != self.dialog_button_count_logged: + self.dialog_button_count_logged = button_count + self._event("dialog_buttons_populated", button_count=button_count) + + visible_button = self.spec.dialog.visible_button + if visible_button is not None: + if visible and button_count > 0: + choice = int(visible_button) + if choice <= button_count and self.runtime.click_dialog_button(choice): + self._event("visible_button_clicked", button=choice, button_count=button_count) + if self.spec.dialog.raw_context_ids: + # Some quest NPCs expose a visible quest-list choice + # followed by a quest-detail context whose *01 + # dialog performs acceptance. Preserve both steps. + self.stage = "wait_raw_response" + self.deadline = now + self.spec.dialog.response_timeout_ms / 1000.0 + else: + self.stage = "verify" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + return InteractionStatus.RUNNING + if now >= self.deadline: + return self._retry("expected visible dialog choice did not become clickable") + return InteractionStatus.RUNNING + + if visible and self.raw_index < len(self.spec.dialog.raw_context_ids): + dialog_id = self.spec.dialog.raw_context_ids[self.raw_index] + self.runtime.send_dialog(dialog_id) + self.raw_index += 1 + self._event("raw_dialog_sent", dialog_id=dialog_id, dialog_id_hex=f"0x{dialog_id:X}") + if self.raw_index >= len(self.spec.dialog.raw_context_ids): + self.stage = "verify" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + else: + self.deadline = now + self.spec.dialog.raw_step_delay_ms / 1000.0 + return InteractionStatus.RUNNING + if now >= self.deadline: + if ( + self.raw_index < len(self.spec.dialog.raw_context_ids) + and self.spec.dialog.allow_raw_without_visible_dialog + ): + dialog_id = self.spec.dialog.raw_context_ids[self.raw_index] + self.runtime.send_dialog(dialog_id) + self.raw_index += 1 + self._event( + "raw_dialog_sent_without_visible_frame", + dialog_id=dialog_id, + dialog_id_hex=f"0x{dialog_id:X}", + ) + self.stage = "verify" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + return InteractionStatus.RUNNING + return self._retry("expected dialogue response did not appear") + return InteractionStatus.RUNNING + + if self.stage == "wait_raw_response": + # A visible choice can itself complete the requested action. Check + # the real postcondition before requiring a follow-up raw context; + # otherwise an already-accepted quest is retried while automation + # remains paused simply because its detail frame disappeared. + if self.runtime.verify(self.spec.postcondition): + return self._postcondition_succeeded( + now, + "postcondition verified after visible choice", + ) + visible = self.runtime.dialog_visible() + if visible and self.raw_index < len(self.spec.dialog.raw_context_ids): + dialog_id = self.spec.dialog.raw_context_ids[self.raw_index] + self.runtime.send_dialog(dialog_id) + self.raw_index += 1 + self._event( + "raw_dialog_sent_after_visible_choice", + dialog_id=dialog_id, + dialog_id_hex=f"0x{dialog_id:X}", + ) + if self.raw_index >= len(self.spec.dialog.raw_context_ids): + self.stage = "verify" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + else: + self.deadline = now + self.spec.dialog.raw_step_delay_ms / 1000.0 + return InteractionStatus.RUNNING + if now >= self.deadline: + return self._retry("quest detail response did not become available") + return InteractionStatus.RUNNING + + if self.stage == "verify": + if self.runtime.verify(self.spec.postcondition): + return self._postcondition_succeeded(now, "postcondition verified") + if self.spec.profile == InteractionProfile.CHALLENGE_THEN_REINTERACT and self.agent_id: + if self.runtime.is_hostile(self.agent_id): + self._event("challenge_hostile") + self.runtime.restore_automation() + self.paused = False + self.stage = "wait_challenge" + self.deadline = now + self.spec.retry.challenge_timeout_ms / 1000.0 + return InteractionStatus.RUNNING + if now >= self.deadline: + return self._retry("postcondition was not verified") + return InteractionStatus.RUNNING + + if self.stage == "wait_challenge": + if self.runtime.verify(self.spec.postcondition): + return self._postcondition_succeeded(now, "challenge postcondition verified") + resolved = self.runtime.resolve_target(self.spec.target) + if resolved and not self.runtime.is_hostile(int(resolved)): + self.runtime.pause_automation() + self.paused = True + self.agent_id = int(resolved) + self.stage = "pause_settle" + self.deadline = now + self.spec.retry.pause_settle_ms / 1000.0 + self._event("challenge_finished_reinteract") + return InteractionStatus.RUNNING + if now >= self.deadline: + return self._finish(False, "challenge did not finish before timeout") + return InteractionStatus.RUNNING + + if self.stage == "wait_post_success_dialog": + if self.runtime.dialog_visible(): + self._event("post_success_dialog_visible") + self.runtime.close_stale_dialog() + self._event("post_success_dialog_closed") + return self._finish(True, "postcondition verified and follow-up dialog closed") + if now >= self.deadline: + self._event("post_success_dialog_not_seen") + return self._finish(True, "postcondition verified; no follow-up dialog appeared") + return InteractionStatus.RUNNING + + return self._finish(False, f"unknown stage {self.stage!r}") + + +def run_coroutine_adapter( + controller: ReliableInteractionController, + wait: Callable[[int], Generator[Any, Any, Any]], +) -> Generator[Any, Any, bool]: + """Run the shared controller inside a coroutine/FSM custom state.""" + + try: + while True: + status = controller.tick() + if status != InteractionStatus.RUNNING: + return status == InteractionStatus.SUCCESS + yield from wait(max(10, controller.spec.retry.poll_ms)) + finally: + if not controller.finished: + controller.cancel("coroutine closed") + + +class Py4GWInteractionRuntime: + """Active-runtime adapter. Persistence is delegated to the diagnostic sink.""" + + def __init__( + self, + *, + pause_automation: Callable[[], None], + restore_automation: Callable[[], None], + verify: Callable[[PostconditionSpec], bool], + diagnostic_sink: Callable[[dict[str, Any]], None] | None = None, + ): + self._pause = pause_automation + self._restore = restore_automation + self._verify = verify + self._sink = diagnostic_sink or (lambda _event: None) + + def now(self) -> float: + return time.monotonic() + + def pause_automation(self) -> None: + from Py4GWCoreLib.Py4GWcorelib import ActionQueueManager + + self._pause() + ActionQueueManager().ResetAllQueues() + + def restore_automation(self) -> None: + self._restore() + + def cancel_movement(self) -> None: + from Py4GWCoreLib.Py4GWcorelib import ActionQueueManager + + ActionQueueManager().ResetAllQueues() + + def close_stale_dialog(self) -> None: + from Py4GWCoreLib.UIManager import UIManager + from Py4GWCoreLib.enums_src.UI_enums import ControlAction + + if UIManager.IsNPCDialogVisible(): + keybind = ControlAction.ControlAction_CloseAllPanels.value + UIManager.Keydown(keybind, 0) + UIManager.Keyup(keybind, 0) + + def _all_agents(self) -> list[int]: + from Py4GWCoreLib.AgentArray import AgentArray + from Py4GWCoreLib.Context import GWContext + + shared = list(AgentArray.GetAgentArray() or []) + context = GWContext.AgentArray.GetContext() + context_agents = list(context.GetAgentArray() or []) if context is not None else [] + return list(dict.fromkeys([*shared, *context_agents])) + + def resolve_target(self, target: TargetSpec) -> int | None: + from Py4GWCoreLib.Agent import Agent + from Py4GWCoreLib.Player import Player + + origin = target.expected_xy or Player.GetXY() + wanted_name = target.name_contains.strip().lower() + candidates: list[int] = [] + for agent_id in self._all_agents(): + if not Agent.IsValid(agent_id): + continue + if target.kind == TargetKind.LIVING and not Agent.IsLiving(agent_id): + continue + if target.kind == TargetKind.NPC and not (Agent.IsLiving(agent_id) and Agent.IsNPC(agent_id)): + continue + if target.kind == TargetKind.GADGET and not Agent.IsGadget(agent_id): + continue + if target.kind == TargetKind.ITEM and not Agent.IsItem(agent_id): + continue + if target.model_id and int(Agent.GetModelID(agent_id)) != target.model_id: + continue + if wanted_name and wanted_name not in str(Agent.GetNameByID(agent_id) or "").strip().lower(): + continue + if math.dist(origin, Agent.GetXY(agent_id)) > target.search_radius: + continue + candidates.append(int(agent_id)) + if not candidates: + return None + return min(candidates, key=lambda agent_id: math.dist(origin, Agent.GetXY(agent_id))) + + def agent_xy(self, agent_id: int) -> tuple[float, float]: + from Py4GWCoreLib.Agent import Agent + + xy = Agent.GetXY(agent_id) + return float(xy[0]), float(xy[1]) + + def agent_model_id(self, agent_id: int) -> int: + from Py4GWCoreLib.Agent import Agent + + return int(Agent.GetModelID(agent_id)) + + def player_xy(self) -> tuple[float, float]: + from Py4GWCoreLib.Player import Player + + xy = Player.GetXY() + return float(xy[0]), float(xy[1]) + + def move_to(self, xy: tuple[float, float]) -> None: + from Py4GWCoreLib.Player import Player + + Player.Move(*xy) + + def interact(self, agent_id: int) -> None: + from Py4GWCoreLib.Player import Player + + Player.ChangeTarget(agent_id) + Player.Interact(agent_id, call_target=False) + + def dialog_visible(self) -> bool: + from Py4GWCoreLib.UIManager import UIManager + + return bool(UIManager.IsNPCDialogVisible()) + + def dialog_button_count(self) -> int: + from Py4GWCoreLib.UIManager import UIManager + + return int(UIManager.GetDialogButtonCount()) + + def click_dialog_button(self, choice: int) -> bool: + from Py4GWCoreLib.UIManager import UIManager + + return bool(UIManager.ClickDialogButton(choice, debug=False)) + + def send_dialog(self, dialog_id: int) -> None: + from Py4GWCoreLib.Player import Player + + Player.SendDialog(dialog_id) + + def verify(self, postcondition: PostconditionSpec) -> bool: + return bool(self._verify(postcondition)) + + def is_hostile(self, agent_id: int) -> bool: + from Py4GWCoreLib.Agent import Agent + + allegiance = Agent.GetAllegiance(agent_id) + name = str(allegiance[1] if isinstance(allegiance, tuple) and len(allegiance) > 1 else allegiance) + return name.strip().lower() == "enemy" + + def emit(self, event: dict[str, Any]) -> None: + self._sink(dict(event)) + + +def build_botting_tree_action(spec: InteractionSpec, runtime: InteractionRuntime): + """Build a reset-safe BottingTree leaf without changing any legacy wrapper.""" + + from Py4GWCoreLib.py4gwcorelib_src.BehaviorTree import BehaviorTree + + controller_box = [ReliableInteractionController(spec, runtime)] + + class _ReliableInteractionActionNode(BehaviorTree.ActionNode): + def reset(self) -> None: + controller = controller_box[0] + if not controller.finished: + controller.cancel("BottingTree node reset") + controller_box[0] = ReliableInteractionController(spec, runtime) + super().reset() + + def _tick() -> BehaviorTree.NodeState: + controller = controller_box[0] + status = controller.tick() + if status == InteractionStatus.RUNNING: + return BehaviorTree.NodeState.RUNNING + if status == InteractionStatus.SUCCESS: + return BehaviorTree.NodeState.SUCCESS + return BehaviorTree.NodeState.FAILURE + + return BehaviorTree(_ReliableInteractionActionNode(name=spec.name, action_fn=_tick, aftercast_ms=0)) diff --git a/Widgets/Automation/Bots/Missions/Dungeons/Darkrime Delves.py b/Widgets/Automation/Bots/Missions/Dungeons/Darkrime Delves.py new file mode 100644 index 000000000..9f2b7509e --- /dev/null +++ b/Widgets/Automation/Bots/Missions/Dungeons/Darkrime Delves.py @@ -0,0 +1,1884 @@ +import importlib +import math +import os +import time +import traceback +import types +from typing import Any, Generator, Tuple + +import PyImGui +from Py4GWCoreLib import * +from Py4GWCoreLib.AgentArray import AgentArray +from Py4GWCoreLib.UIManager import UIManager +from Py4GWCoreLib.routines_src import reliable_interaction as _reliable_interaction +from Widgets.Data.dungeons import darkrime_delves_data as _darkrime_data + + +# Widget Manager hot-reloads this file without evicting imported modules. Refresh +# both dependencies before binding their classes/constants so shared API changes +# cannot leave this widget holding stale dataclass constructors. +_reliable_interaction = importlib.reload(_reliable_interaction) +ApproachSpec = _reliable_interaction.ApproachSpec +DialogSpec = _reliable_interaction.DialogSpec +InteractionProfile = _reliable_interaction.InteractionProfile +InteractionSpec = _reliable_interaction.InteractionSpec +PostconditionKind = _reliable_interaction.PostconditionKind +PostconditionSpec = _reliable_interaction.PostconditionSpec +Py4GWInteractionRuntime = _reliable_interaction.Py4GWInteractionRuntime +ReliableInteractionController = _reliable_interaction.ReliableInteractionController +RetryPolicy = _reliable_interaction.RetryPolicy +TargetKind = _reliable_interaction.TargetKind +TargetSpec = _reliable_interaction.TargetSpec +run_coroutine_adapter = _reliable_interaction.run_coroutine_adapter + +_darkrime_data = importlib.reload(_darkrime_data) +APPROACH_SOURCE_ROUTE_ID = _darkrime_data.APPROACH_SOURCE_ROUTE_ID +BJORA_ENTRY_TO_BLESSING = _darkrime_data.BJORA_ENTRY_TO_BLESSING +LONGEYES_OUTPOST_POINTS = _darkrime_data.LONGEYES_OUTPOST_POINTS +LEVEL_1_LEVER_SAFE_WAIT = _darkrime_data.LEVEL_1_LEVER_SAFE_WAIT +LEVEL_1_LEVER_HERO_SAFE_FLAG = _darkrime_data.LEVEL_1_LEVER_HERO_SAFE_FLAG +LEVEL_1_HERO_REGROUP = _darkrime_data.LEVEL_1_HERO_REGROUP +LEVEL_1_HAVOK_KEY_KILL = _darkrime_data.LEVEL_1_HAVOK_KEY_KILL +LEVEL_1_OTHER_BRIDGE = _darkrime_data.LEVEL_1_OTHER_BRIDGE +LEVEL_1_OTHER_BRIDGE_BLESSING_CAPTURE_ID = _darkrime_data.LEVEL_1_OTHER_BRIDGE_BLESSING_CAPTURE_ID +LEVEL_1_OTHER_BRIDGE_BLESSING_INDEX = _darkrime_data.LEVEL_1_OTHER_BRIDGE_BLESSING_INDEX +LEVEL_1_OTHER_BRIDGE_SOURCE_ROUTE_ID = _darkrime_data.LEVEL_1_OTHER_BRIDGE_SOURCE_ROUTE_ID +LEVEL_1_KEY_FLAG = _darkrime_data.LEVEL_1_KEY_FLAG +LEVEL_1_KEY_LOCATION = _darkrime_data.LEVEL_1_KEY_LOCATION +LEVEL_2_BOSS_LOCK_TO_DOOR = _darkrime_data.LEVEL_2_BOSS_LOCK_TO_DOOR +LEVEL_2_BOSS_LOCK_TO_DOOR_SOURCE_ROUTE_ID = _darkrime_data.LEVEL_2_BOSS_LOCK_TO_DOOR_SOURCE_ROUTE_ID +LEVEL_2_PENDULUM_TRAP_ROOM = _darkrime_data.LEVEL_2_PENDULUM_TRAP_ROOM +LEVEL_2_MAP_ROOM = _darkrime_data.LEVEL_2_MAP_ROOM +LEVEL_2_SAFE_ROUTE = _darkrime_data.LEVEL_2_SAFE_ROUTE +LEVEL_2_SAFE_ROUTE_SOURCE_ROUTE_ID = _darkrime_data.LEVEL_2_SAFE_ROUTE_SOURCE_ROUTE_ID +SNOWBALL_DODGE_2 = _darkrime_data.SNOWBALL_DODGE_2 +SNOWBALL_DODGE_SOURCE_ROUTE_ID = _darkrime_data.SNOWBALL_DODGE_SOURCE_ROUTE_ID +MARKERS = _darkrime_data.MARKERS +ROUTE_POINTS = _darkrime_data.ROUTE_POINTS +SOURCE_ROUTE_ID = _darkrime_data.SOURCE_ROUTE_ID +CAPTURE_JOIN_TO_EINARR_POINTS = _darkrime_data.CAPTURE_JOIN_TO_EINARR_POINTS +CAPTURE_JOIN_XY = _darkrime_data.CAPTURE_JOIN_XY +YAVB_BLESSING_TO_CAPTURE_JOIN = _darkrime_data.YAVB_BLESSING_TO_CAPTURE_JOIN + + +BOT_NAME = "Darkrime Delves" +MODULE_NAME = BOT_NAME +MODULE_ICON = "Textures\\Module_Icons\\Survivor Title - Kath Hammers.png" +MODULE_TAGS = ["Dungeon", "EOTN", "Deldrimor", "Route"] + +BJORA_MARCHES = 482 +LONGEYES_LEDGE = 650 +DARKRIME_LEVEL_1 = 635 +DARKRIME_LEVEL_2 = 636 +DARKRIME_LEVEL_3 = 637 +COLD_VENGEANCE_QUEST_ID = 810 +DUNGEON_KEY_MODEL_ID = 25410 +BOSS_KEY_MODEL_ID = 25416 +ALT_BRIDGE_BLESSING_POINT = 1160 +DWARVEN_RAIDER_EFFECT_IDS = (2445, 2446, 2447, 2448, 2549, 2565, 2566, 2567, 2568) +EOTN_BLESSING_EFFECT_IDS = ( + *DWARVEN_RAIDER_EFFECT_IDS, + 2457, + 2458, + 2459, + 2460, + 2550, + 2578, + 2434, + 2435, + 2436, + 2481, + 2548, + 2552, + 2469, + 2470, + 2471, + 2472, + 2551, + 2591, + 2592, + 2593, + 2594, +) + +LEVEL_1_EXIT_XY = (-18979.55, 11133.68) +# Deliberately beyond the Level 2 portal. The previous target was within the +# movement arrival tolerance while still on the Level 2 side of the boundary. +LEVEL_2_EXIT_XY = (-20250.0, -953.0) + +INTERACTIONS = { + 1007: { + "profile": InteractionProfile.VISIBLE_CHOICE, + "target_kind": TargetKind.NPC, + "xy": (17838.55, -18278.86), + "model_id": 6425, + "postcondition": "any_blessing", + "visible_button": 1, + "allow_preexisting_postcondition": False, + "required": True, + "capture_id": "NPC-20260806-221039-593", + }, + 1: { + "profile": InteractionProfile.VISIBLE_CHOICE, + "target_kind": TargetKind.NPC, + # Einarr is the stationary NPC at this fixed location before the quest. + # Model 4512 was a party follower near the player, not Einarr. + "xy": (-15864.09, 13716.09), + "target_xy": (-15915.0, 13669.0), + "model_id": 6428, + "search_radius": 250.0, + "approach_tolerance": 35.0, + "target_tolerance": 120.0, + "max_attempts": 10, + "retry_delay_ms": 1_500, + # Poll the quest log after clicking Accept. This is a timeout ceiling, + # not a fixed delay: the route advances on the first successful poll. + "verify_timeout_ms": 15_000, + "postcondition": "quest_active", + "visible_button": 1, + # Accept-dialog encoding is 0x800001 | (quest_id << 8). + # Cold Vengeance is quest 810 (0x32A), so this must be 0x832A01. + "raw_dialog": 0x832A01, + "required": True, + "capture_id": "NPC-20260807-115226-136", + }, + 29: { + "profile": InteractionProfile.VISIBLE_CHOICE, "target_kind": TargetKind.NPC, + "xy": (15168.7, 16664.88), "model_id": 5916, "postcondition": "blessing", + "visible_button": 1, + "allow_preexisting_postcondition": False, + "required": True, "capture_id": "NPC-20260806-211806-048", + }, + 81: { + "profile": InteractionProfile.GADGET, "target_kind": TargetKind.GADGET, + "xy": (8056.44, 2469.58), "postcondition": "bundle_present", "required": True, + "capture_id": "NPC-20260806-212134-059", + }, + 92: { + "profile": InteractionProfile.AUTOMATIC_TRIGGER, "target_kind": TargetKind.NPC, + "xy": (5977.53, -1512.51), "model_id": 5916, "postcondition": "beacon_response", + "required": False, "capture_id": "NPC-20260806-212214-809", + }, + 127: { + "profile": InteractionProfile.GADGET, "target_kind": TargetKind.GADGET, + "xy": (-369.9, 1412.45), "postcondition": "bundle_present", "required": True, + "capture_id": "NPC-20260806-212532-703", + }, + 136: { + "profile": InteractionProfile.GADGET, "target_kind": TargetKind.GADGET, + "xy": (1515.58, 3768.29), "postcondition": "interaction_sent", "required": True, + "capture_id": "NPC-20260806-212700-265", + }, + 141: { + "profile": InteractionProfile.AUTOMATIC_TRIGGER, "target_kind": TargetKind.NPC, + "xy": (798.87, 5764.11), "model_id": 5916, "postcondition": "beacon_response", + "required": False, "capture_id": "NPC-20260806-212734-393", + }, + 192: { + "profile": InteractionProfile.AUTOMATIC_TRIGGER, "target_kind": TargetKind.NPC, + "xy": (-14953.15, 4260.19), "model_id": 5916, "postcondition": "beacon_response", + "required": False, "capture_id": "NPC-20260806-213114-043", + }, + 207: { + "profile": InteractionProfile.GADGET, "target_kind": TargetKind.GADGET, + "xy": (-12254.41, -1617.48), "postcondition": "lever_activation_settled", "required": True, + "allow_preexisting_postcondition": False, + "verify_timeout_ms": 5_000, + "max_attempts": 3, + "capture_id": "NPC-20260806-213152-710", + }, + 258: { + "profile": InteractionProfile.GADGET, "target_kind": TargetKind.GADGET, + "xy": (-15524.7, 2530.11), "postcondition": "interaction_sent", "required": True, + "capture_id": "NPC-20260806-213508-669", + }, + 284: { + "profile": InteractionProfile.VISIBLE_CHOICE, "target_kind": TargetKind.NPC, + "xy": (19873.92, 14303.41), "model_id": 5916, "postcondition": "blessing", + "visible_button": 1, + "allow_preexisting_postcondition": False, + "required": True, "capture_id": "NPC-20260806-213636-987", + }, + 354: { + "profile": InteractionProfile.AUTOMATIC_TRIGGER, "target_kind": TargetKind.NPC, + "xy": (8163.97, 3351.43), "model_id": 5916, "postcondition": "beacon_response", + "required": False, "capture_id": "NPC-20260806-214155-827", + }, + 419: { + "profile": InteractionProfile.AUTOMATIC_TRIGGER, "target_kind": TargetKind.NPC, + "xy": (-15264.56, -4812.22), "model_id": 5916, "postcondition": "beacon_response", + "required": False, "capture_id": "NPC-20260806-214534-939", + }, + 456: { + "profile": InteractionProfile.AUTOMATIC_TRIGGER, "target_kind": TargetKind.NPC, + "xy": (-13037.7, -14565.53), "model_id": 5916, "postcondition": "beacon_response", + "required": False, "capture_id": "NPC-20260806-214724-274", + }, + 519: { + "profile": InteractionProfile.GADGET, "target_kind": TargetKind.GADGET, + "xy": (-19243.37, -960.09), "postcondition": "interaction_sent", "required": True, + "capture_id": "NPC-20260806-215049-287", + }, + 524: { + "profile": InteractionProfile.VISIBLE_CHOICE, "target_kind": TargetKind.NPC, + "xy": (-16402.23, 17848.92), "model_id": 5916, "postcondition": "blessing", + "approach_tolerance": 35.0, + "visible_button": 1, + "allow_preexisting_postcondition": False, + "required": True, "capture_id": "NPC-20260806-231723-937", + }, + 577: { + "profile": InteractionProfile.AUTOMATIC_TRIGGER, "target_kind": TargetKind.NPC, + "xy": (-16192.74, 5469.67), "model_id": 5916, "postcondition": "beacon_response", + "required": False, "capture_id": "NPC-20260806-215614-535", + }, + 593: { + "profile": InteractionProfile.GADGET, "target_kind": TargetKind.GADGET, + "xy": (-16804.47, 11386.86), "postcondition": "chest_opened", "required": True, + "capture_id": "NPC-20260806-215838-607", + }, + ALT_BRIDGE_BLESSING_POINT: { + "profile": InteractionProfile.AUTOMATIC_TRIGGER, + "target_kind": TargetKind.NPC, + "xy": (-9178.22, 14943.58), + "model_id": 5916, + "postcondition": "beacon_response", + "required": False, + "capture_id": LEVEL_1_OTHER_BRIDGE_BLESSING_CAPTURE_ID, + }, +} + +# These mechanics do not expose a reliable activation bit through the current +# gadget API. Their following route segment remains the end-to-end gate, but we +# log them explicitly instead of presenting command dispatch as strong proof. +DEFERRED_GATE_VERIFICATION_POINTS = (136, 207, 258, 519) + +DROP_BUNDLE_POINTS = {94: "Drop first Powder Keg", 130: "Drop second Powder Keg"} +KEY_PICKUP_POINTS = { + 227: "Pick up the Level 1 dungeon key", + 465: "Defeat Grelk Icelash and pick up the Level 2 dungeon key", +} +KEY_PICKUP_MAPS = { + 227: DARKRIME_LEVEL_1, + 465: DARKRIME_LEVEL_2, +} +KEY_PICKUP_MODEL_IDS = { + 227: DUNGEON_KEY_MODEL_ID, + 465: BOSS_KEY_MODEL_ID, +} +KEY_PICKUP_ITEM_NAMES = { + 227: "Dungeon Key", + 465: "Boss Key", +} + +PHASES = [ + ("Full run: Longeyes Ledge to Darkrime", "DD:APPROACH"), + ("Approach: Bjora entry / get Longeyes blessing", "DD:APPROACH_BJORA"), + ("Approach: after Longeyes blessing", "DD:APPROACH_YAVB"), + ("Approach: at Einarr / accept Cold Vengeance", "DD:APPROACH_EINARR"), + ("Approach: after accepting Cold Vengeance", "DD:APPROACH_POST_QUEST"), + ("Level 1: entrance / first keg", "DD:L1_ENTRANCE"), + ("Level 1: after snowball dodge", "DD:L1_AFTER_SNOWBALL"), + ("Level 1: Havok-kin / second keg", "DD:L1_HAVOK_KIN"), + ("Level 1: bridges", "DD:L1_BRIDGES"), + ("Level 1: lever / key / exit", "DD:L1_EXIT"), + ("Level 1: after lever / go to dungeon key", "DD:L1_AFTER_LEVER"), + ("Level 1: at dungeon key / pick it up", "DD:L1_KEY"), + ("Level 1: after key / return to dungeon lock", "DD:L1_AFTER_KEY"), + ("Level 1: after dungeon lock / go to exit", "DD:L1_AFTER_LOCK"), + ("Level 2: entrance", "DD:L2_ENTRANCE"), + ("Level 2: after entrance Beacon / safe route", "DD:L2_AFTER_ENTRANCE_BEACON"), + ("Level 2: after safe route / upper loop", "DD:L2_AFTER_SAFE_ROUTE"), + ("Level 2: approach Pendulum Trap Room", "DD:L2_UPPER_HALLS"), + ("Level 2: Pendulum Trap Room", "DD:L2_PENDULUM_TRAP_ROOM"), + ("Level 2: descent to second Beacon/Map", "DD:L2_SECOND_BEACON_APPROACH"), + ("Level 2: Map Room / Second Beacon", "DD:L2_MAP_ROOM"), + ("Level 2: middle", "DD:L2_MIDDLE"), + ("Level 2: The Great Rift Hall", "DD:L2_LOWER_CENTRAL"), + ("Level 2: Chromatic Drake Corner / Third Beacon", "DD:L2_WESTERN_HALLS"), + ("Level 2: approach Grelk", "DD:L2_GRELK_APPROACH"), + ("Level 2: midway to Grelk", "DD:L2_GRELK_MIDWAY"), + ("Level 2: after final Beacon / Grelk corridor", "DD:L2_AFTER_FINAL_BEACON"), + ("Level 2: at Grelk / collect key", "DD:L2_GRELK_KEY"), + ("Level 2: after Grelk / return with key", "DD:L2_AFTER_GRELK"), + ("Level 2: return midpoint", "DD:L2_RETURN_MIDPOINT"), + ("Level 2: return north corridor", "DD:L2_RETURN_NORTH"), + ("Level 2: boss lock / exit", "DD:L2_EXIT"), + ("Level 3: entrance", "DD:L3_ENTRANCE"), + ("Level 3: Havok Soulwail / chest", "DD:L3_BOSS"), +] + +class _RuntimeLog: + """Send route diagnostics through the project's existing console.""" + + def write(self, message: str) -> None: + ConsoleLog(MODULE_NAME, str(message), Console.MessageType.Info) + + +SESSION_LOG = _RuntimeLog() +_phase_index = 0 +_interaction_events = [] +_registered_interaction_points: list[int] = [] +_initialization_error = "" + +bot = Botting( + bot_name=BOT_NAME, + upkeep_hero_ai_active=True, + upkeep_auto_loot_active=True, + upkeep_morale_active=True, +) +# Match the working Shards of Orr pattern: make both dungeon key models +# explicit auto-loot priorities. Once collected, these become dungeon UI/ +# instance state rather than ordinary bag inventory. +bot.Items.AddModelToLootWhitelist(DUNGEON_KEY_MODEL_ID) +bot.Items.AddModelToLootWhitelist(BOSS_KEY_MODEL_ID) + + +def _anchor() -> Generator[Any, Any, None]: + yield + + +def _idle_forever() -> Generator[Any, Any, None]: + while bot.config.fsm_running: + yield from Routines.Yield.wait(250) + + +def _runtime_snapshot() -> str: + return f"map={Map.GetMapID()} player_xy={Player.GetXY()} step={bot.config.FSM.get_current_step_name()!r}" + + +def _hold_failure(message: str) -> Generator[Any, Any, bool]: + SESSION_LOG.write(f"blocked reason={message!r} {_runtime_snapshot()}") + bot.config.state_description = message + while bot.config.fsm_running: + yield from Routines.Yield.wait(250) + return False + + +def _require_map(map_id: int, label: str) -> Generator[Any, Any, bool]: + if Map.GetMapID() == map_id: + return True + yield from _hold_failure(f"{label} requires map {map_id}; current map is {Map.GetMapID()}") + return False + + +def _configure_combat(bot: Botting) -> None: + bot.Properties.Enable("pause_on_danger") + bot.Properties.Disable("halt_on_death") + bot.Properties.Set("movement_timeout", value=-1) + bot.Properties.Enable("hero_ai") + bot.Templates.Aggressive() + + +def _gadget_signature(agent_id: int) -> tuple: + if not agent_id or not Agent.IsValid(agent_id) or not Agent.IsGadget(agent_id): + return () + try: + return ( + int(Agent.GetGadgetID(agent_id)), + int(Agent.GetGadgetAgentExtraType(agent_id)), + int(Agent.GetGadgetAgenth00C4(agent_id)), + int(Agent.GetGadgetAgenth00C8(agent_id)), + tuple(Agent.GetGadgetAgenth00D4(agent_id) or ()), + ) + except Exception: + return (int(agent_id),) + + +class _DarkrimeInteractionRuntime(Py4GWInteractionRuntime): + def __init__(self, point_index: int, verification_key: str, expected_xy: Tuple[float, float]): + self.point_index = int(point_index) + self.hero_ai_status = bot.config.upkeep.hero_ai.is_active() + self.hero_ai_pause_status = bool( + hasattr(bot.config.upkeep, "hero_ai_paused") + and bot.config.upkeep.hero_ai_paused.is_active() + ) + super().__init__( + pause_automation=self._pause_hero_ai, + restore_automation=self._restore_hero_ai, + verify=lambda _postcondition: False, + diagnostic_sink=self._record_event, + ) + self.verification_key = verification_key + self.expected_xy = expected_xy + self.interacted = False + self.interaction_sent_at = 0.0 + self.lever_activation_settle_logged = False + self.dialog_choice_clicked = False + self.dialog_choice_clicked_at = 0.0 + self.quest_log_snapshot: tuple[int, ...] | None = None + self.quest_accept_fallback_logged = False + self.interacted_agent_id = 0 + self.before_gadget_signature: tuple = () + self.before_items = set(int(agent_id) for agent_id in (AgentArray.GetItemArray() or [])) + + def _record_event(self, event: dict[str, Any]) -> None: + _record_interaction_event({"point_index": self.point_index, **dict(event)}) + + def _pause_hero_ai(self) -> None: + if hasattr(bot.config.upkeep, "hero_ai_paused"): + bot.config.upkeep.hero_ai_paused.set_now("active", True) + else: + bot.config.upkeep.hero_ai.set_now("active", False) + + def _restore_hero_ai(self) -> None: + if hasattr(bot.config.upkeep, "hero_ai_paused"): + bot.config.upkeep.hero_ai_paused.set_now("active", self.hero_ai_pause_status) + bot.config.upkeep.hero_ai.set_now("active", self.hero_ai_status) + + def interact(self, agent_id: int) -> None: + self.interacted_agent_id = int(agent_id) + self.before_gadget_signature = _gadget_signature(agent_id) + super().interact(agent_id) + self.interacted = True + self.interaction_sent_at = time.monotonic() + + def click_dialog_button(self, choice: int) -> bool: + clicked = bool(super().click_dialog_button(choice)) + if clicked: + self.dialog_choice_clicked = True + self.dialog_choice_clicked_at = time.monotonic() + return clicked + + def verify(self, _postcondition: PostconditionSpec) -> bool: + if self.verification_key == "quest_accept_clicked": + return self.dialog_choice_clicked + if self.verification_key == "quest_active": + quest_ids = tuple(sorted(int(value) for value in (Quest.GetQuestLogIds() or []))) + if quest_ids != self.quest_log_snapshot: + self.quest_log_snapshot = quest_ids + SESSION_LOG.write( + f"einarr_quest_log_check quest_id={COLD_VENGEANCE_QUEST_ID} " + f"quest_log_ids={quest_ids}" + ) + if COLD_VENGEANCE_QUEST_ID in quest_ids: + return True + + # On this dungeon, the quest-log binding can remain stale even though + # the visible Accept choice succeeded. A confirmed click followed by + # the dialog closing is the game's immediate acceptance handoff; use + # it as a narrow fallback so the controller cannot pull the player + # back to Einarr for ten false-negative retries. + accept_handoff_complete = ( + self.dialog_choice_clicked + and self.dialog_choice_clicked_at > 0.0 + and time.monotonic() - self.dialog_choice_clicked_at >= 0.75 + and not UIManager.IsNPCDialogVisible() + ) + if accept_handoff_complete and not self.quest_accept_fallback_logged: + self.quest_accept_fallback_logged = True + SESSION_LOG.write( + "einarr_quest_accept_verified source=visible_accept_click_and_dialog_closed " + f"quest_log_ids={quest_ids}" + ) + return accept_handoff_complete + if self.verification_key == "blessing": + player_id = Player.GetAgentID() + return any( + Effects.EffectExists(player_id, effect_id) or Effects.BuffExists(player_id, effect_id) + for effect_id in DWARVEN_RAIDER_EFFECT_IDS + ) + if self.verification_key == "any_blessing": + player_id = Player.GetAgentID() + return any( + Effects.EffectExists(player_id, effect_id) or Effects.BuffExists(player_id, effect_id) + for effect_id in EOTN_BLESSING_EFFECT_IDS + ) + if self.verification_key == "bundle_present": + return Agent.IsHoldingItem(Player.GetAgentID()) + if self.verification_key == "beacon_response": + # A blessing that was already active does not prove this Beacon + # responded. Require an actual dialog response after interaction. + return self.interacted and UIManager.IsNPCDialogVisible() + if self.verification_key == "gadget_changed": + return self.interacted and ( + not Agent.IsValid(self.interacted_agent_id) + or _gadget_signature(self.interacted_agent_id) != self.before_gadget_signature + ) + if self.verification_key == "lever_activation_settled": + gadget_changed = self.interacted and ( + not Agent.IsValid(self.interacted_agent_id) + or _gadget_signature(self.interacted_agent_id) != self.before_gadget_signature + ) + # Interacting with this lever is asynchronous. Previously the generic + # interaction_sent check completed after ~0.1s and the retreat move + # canceled the lever use. Hold position for two seconds after the + # command (or proceed sooner if the gadget exposes a state change). + activation_settled = ( + self.interacted + and self.interaction_sent_at > 0.0 + and time.monotonic() - self.interaction_sent_at >= 2.0 + and Utils.Distance(Player.GetXY(), self.expected_xy) <= 150.0 + ) + if (gadget_changed or activation_settled) and not self.lever_activation_settle_logged: + self.lever_activation_settle_logged = True + SESSION_LOG.write( + "level_1_lever_activation_verified " + f"source={'gadget_changed' if gadget_changed else 'interaction_settled'} " + f"elapsed_ms={int((time.monotonic() - self.interaction_sent_at) * 1000)} " + f"{_runtime_snapshot()}" + ) + return gadget_changed or activation_settled + if self.verification_key == "interaction_sent": + return self.interacted + if self.verification_key == "chest_opened": + current_items = set(int(agent_id) for agent_id in (AgentArray.GetItemArray() or [])) + return self.interacted and ( + bool(current_items - self.before_items) + or not Agent.IsValid(self.interacted_agent_id) + or _gadget_signature(self.interacted_agent_id) != self.before_gadget_signature + ) + return False + + +def _record_interaction_event(event: dict[str, Any]) -> None: + _interaction_events.append(dict(event)) + if len(_interaction_events) > 100: + del _interaction_events[:-100] + SESSION_LOG.write(f"reliable_interaction {event}") + + +def _run_interaction_impl(point_index: int) -> Generator[Any, Any, bool]: + policy = INTERACTIONS[point_index] + marker = MARKERS[point_index] + label = marker["name"] + xy = tuple(policy["xy"]) + target_xy = tuple(policy.get("target_xy", xy)) + runtime = _DarkrimeInteractionRuntime(point_index, str(policy["postcondition"]), xy) + raw_dialog = int(policy.get("raw_dialog", 0)) + spec = InteractionSpec( + name=label, + profile=policy["profile"], + target=TargetSpec( + kind=policy["target_kind"], + model_id=int(policy.get("model_id", 0)), + name_contains=str(policy.get("name_contains", "")), + expected_xy=target_xy, + search_radius=float(policy.get("search_radius", 500.0)), + ), + approach=ApproachSpec( + player_approach_xy=xy, + tolerance=float(policy.get("approach_tolerance", 90.0)), + target_tolerance=( + float(policy["target_tolerance"]) + if policy.get("target_tolerance") is not None + else None + ), + timeout_ms=15_000, + ), + dialog=DialogSpec( + visible_button=policy.get("visible_button"), + raw_context_ids=(raw_dialog,) if raw_dialog else (), + response_timeout_ms=4_000, + allow_raw_without_visible_dialog=False, + close_dialog_after_success=bool(policy.get("close_dialog_after_success", False)), + ), + postcondition=PostconditionSpec( + kind=PostconditionKind.CUSTOM, + value=str(policy["postcondition"]), + description=f"Darkrime verification: {policy['postcondition']}", + ), + retry=RetryPolicy( + max_attempts=int(policy.get("max_attempts", 3)), + poll_ms=100, + verify_timeout_ms=int(policy.get("verify_timeout_ms", 5_000)), + retry_delay_ms=int(policy.get("retry_delay_ms", 500)), + pause_settle_ms=350, + ), + allow_preexisting_postcondition=bool( + policy.get("allow_preexisting_postcondition", True) + ), + source_capture_ids=(str(policy["capture_id"]),), + ) + SESSION_LOG.write( + f"interaction_spec_built point={point_index} label={label!r} " + f"profile={policy['profile'].value!r} required={bool(policy['required'])} " + f"capture_id={policy['capture_id']!r}" + ) + controller = ReliableInteractionController(spec, runtime) + SESSION_LOG.write( + f"interaction_controller_start point={point_index} label={label!r} " + f"{_runtime_snapshot()}" + ) + success = yield from run_coroutine_adapter( + controller, + Routines.Yield.wait, + ) + if success: + SESSION_LOG.write( + f"interaction_state_success point={point_index} label={label!r} " + f"{_runtime_snapshot()}" + ) + else: + SESSION_LOG.write( + f"interaction_state_failure point={point_index} label={label!r} " + f"required={bool(policy['required'])} {_runtime_snapshot()}" + ) + if not success and policy["required"]: + yield from _hold_failure(f"Reliable interaction failed: {label}") + return False + if not success: + SESSION_LOG.write(f"optional_interaction_skipped point={point_index} label={label!r}") + return True + + +def _run_interaction(point_index: int) -> Generator[Any, Any, bool]: + SESSION_LOG.write( + f"interaction_state_enter point={point_index} {_runtime_snapshot()}" + ) + try: + return bool((yield from _run_interaction_impl(point_index))) + except Exception as error: + detail = traceback.format_exc() + SESSION_LOG.write( + f"interaction_state_exception point={point_index} " + f"error_type={type(error).__name__!r} error={str(error)!r} traceback={detail!r} " + f"{_runtime_snapshot()}" + ) + ConsoleLog( + MODULE_NAME, + f"Interaction point {point_index} crashed: {type(error).__name__}: {error}", + Console.MessageType.Error, + ) + required = bool(INTERACTIONS.get(point_index, {}).get("required", True)) + if required: + yield from _hold_failure( + f"Interaction point {point_index} crashed: {type(error).__name__}: {error}" + ) + return False + return True + + +def _drop_bundle(point_index: int) -> Generator[Any, Any, bool]: + label = DROP_BUNDLE_POINTS[point_index] + if not Agent.IsHoldingItem(Player.GetAgentID()): + yield from _hold_failure(f"{label}: no Powder Keg is being carried") + return False + for attempt in range(3): + yield from Routines.Yield.Keybinds.DropBundle() + yield from Routines.Yield.wait(500) + if not Agent.IsHoldingItem(Player.GetAgentID()): + SESSION_LOG.write(f"drop_bundle_success point={point_index} attempt={attempt + 1}") + return True + yield from _hold_failure(f"{label}: bundle remained in hand") + return False + + +def _wait_for_einarr_conversation() -> Generator[Any, Any, bool]: + if COLD_VENGEANCE_QUEST_ID in set(int(value) for value in (Quest.GetQuestLogIds() or [])): + return True + bot.config.state_description = "Waiting 30 seconds for Einarr's conversation" + SESSION_LOG.write(f"einarr_conversation_wait_start {_runtime_snapshot()}") + yield from Routines.Yield.wait(30_000) + bot.config.state_description = "Running" + SESSION_LOG.write(f"einarr_conversation_wait_finish {_runtime_snapshot()}") + return True + + +def _wait_for_party_regroup() -> Generator[Any, Any, bool]: + radius = float(LEVEL_1_HERO_REGROUP["radius"]) + timeout_ms = int(LEVEL_1_HERO_REGROUP["timeout_ms"]) + started = time.monotonic() + bot.config.state_description = "Waiting for living heroes to cross the bridge" + SESSION_LOG.write( + f"party_regroup_start radius={radius} timeout_ms={timeout_ms} {_runtime_snapshot()}" + ) + while True: + player_xy = Player.GetXY() + followers = [ + *list(GLOBAL_CACHE.Party.GetHeroes() or []), + *list(GLOBAL_CACHE.Party.GetHenchmen() or []), + ] + waiting: list[tuple[int, float]] = [] + for member in followers: + agent_id = int(getattr(member, "agent_id", 0) or 0) + if not agent_id or not Agent.IsValid(agent_id) or not Agent.IsAlive(agent_id): + continue + distance = math.dist(player_xy, Agent.GetXY(agent_id)) + if distance > radius: + waiting.append((agent_id, round(distance, 1))) + if not waiting: + SESSION_LOG.write(f"party_regroup_success {_runtime_snapshot()}") + bot.config.state_description = "Running" + return True + elapsed_ms = int((time.monotonic() - started) * 1000) + if elapsed_ms >= timeout_ms: + SESSION_LOG.write( + f"party_regroup_timeout elapsed_ms={elapsed_ms} waiting={waiting!r} " + f"{_runtime_snapshot()}" + ) + bot.config.state_description = "Running" + return True + yield from Routines.Yield.wait(250) + + +def _flag_all_heroes_at( + xy: tuple[float, float], + label: str, +) -> Generator[Any, Any, bool]: + GLOBAL_CACHE.Party.Heroes.FlagAllHeroes(*xy) + SESSION_LOG.write(f"heroes_flagged label={label!r} xy={xy} {_runtime_snapshot()}") + yield from Routines.Yield.wait(500) + return True + + +def _unflag_all_heroes(label: str) -> Generator[Any, Any, bool]: + hero_count = int(GLOBAL_CACHE.Party.GetHeroCount()) + for hero_index in range(1, hero_count + 1): + GLOBAL_CACHE.Party.Heroes.UnflagHero(hero_index) + GLOBAL_CACHE.Party.Heroes.UnflagAllHeroes() + SESSION_LOG.write(f"heroes_unflagged label={label!r} {_runtime_snapshot()}") + yield from Routines.Yield.wait(500) + return True + + +def _follow_recorded_path( + points: list[Tuple[float, float]], label: str, source_route_id: str = SOURCE_ROUTE_ID +) -> Generator[Any, Any, bool]: + if not points: + return True + SESSION_LOG.write( + f"path_start label={label!r} source={source_route_id!r} count={len(points)} " + f"first={points[0]} last={points[-1]} {_runtime_snapshot()}" + ) + success = yield from bot.Move._coro_follow_path(points) + SESSION_LOG.write(f"path_finish label={label!r} success={success} {_runtime_snapshot()}") + if not success: + yield from _hold_failure(f"Recorded path failed: {label}") + return False + return True + + +def _retreat_from_level_1_lever_and_wait() -> Generator[Any, Any, bool]: + safe_xy = tuple(LEVEL_1_LEVER_SAFE_WAIT["xy"]) + wait_ms = int(LEVEL_1_LEVER_SAFE_WAIT["wait_ms"]) + capture_id = str(LEVEL_1_LEVER_SAFE_WAIT["capture_id"]) + bot.config.state_description = "Retreating from the Level 1 lever trap" + SESSION_LOG.write( + f"level_1_lever_retreat_start safe_xy={safe_xy} wait_ms={wait_ms} " + f"capture_id={capture_id!r} {_runtime_snapshot()}" + ) + bot.ResetHeroAICombatState( + active=True, + following=True, + avoidance=True, + looting=False, + targeting=False, + combat=False, + skills=False, + ) + try: + success = yield from Routines.Yield.Movement.FollowPath( + path_points=[safe_xy], + custom_pause_fn=lambda: False, + tolerance=80.0, + timeout=-1, + autopath=False, + ) + if not success: + yield from _hold_failure("Could not reach the safe wait location after the Level 1 lever") + return False + bot.config.state_description = "Waiting 15 seconds for the Level 1 bridge to lower" + SESSION_LOG.write(f"level_1_bridge_wait_start {_runtime_snapshot()}") + yield from Routines.Yield.wait(wait_ms) + SESSION_LOG.write(f"level_1_bridge_wait_finish {_runtime_snapshot()}") + bot.config.state_description = "Running" + return True + finally: + bot.ResetHeroAICombatState( + active=True, + following=True, + avoidance=True, + looting=True, + targeting=True, + combat=True, + skills=True, + ) + + +def _pick_up_key(point_index: int) -> Generator[Any, Any, bool]: + map_id = KEY_PICKUP_MAPS[point_index] + key_model_id = KEY_PICKUP_MODEL_IDS[point_index] + key_name = KEY_PICKUP_ITEM_NAMES[point_index] + point = next( + ((x, y) for index, x, y in _capture_segment(map_id) if index == point_index), + None, + ) + if point is None: + yield from _hold_failure(f"Missing captured key location for point {point_index}") + return False + + bot.config.state_description = KEY_PICKUP_POINTS[point_index] + spawn_deadline = time.monotonic() + 12.0 + clear_since: float | None = None + candidates: list[int] = [] + while bot.config.fsm_running and time.monotonic() < spawn_deadline: + candidates = _ground_keys(key_model_id, near_xy=point, max_distance=700.0) + if candidates: + break + + nearby_enemies = [ + int(agent_id) + for agent_id in (AgentArray.GetEnemyArray() or []) + if Agent.IsValid(agent_id) + and Agent.IsAlive(agent_id) + and Utils.Distance(point, Agent.GetXY(agent_id)) <= 2_000.0 + ] + if nearby_enemies: + clear_since = None + elif clear_since is None: + clear_since = time.monotonic() + elif time.monotonic() - clear_since >= 1.0: + SESSION_LOG.write( + f"key_pickup_already_retrieved point={point_index} key_name={key_name!r} " + f"model_id={key_model_id} reason='area clear and no matching key on ground' " + f"{_runtime_snapshot()}" + ) + bot.config.state_description = "Running" + return True + yield from Routines.Yield.wait(200) + + if not candidates: + yield from _hold_failure( + f"No {key_name} appeared near the captured Level " + f"{1 if map_id == DARKRIME_LEVEL_1 else 2} key location while enemies remain" + ) + return False + + key_agent_id = candidates[0] + SESSION_LOG.write( + f"key_pickup_start point={point_index} key_agent_id={key_agent_id} " + f"candidate_ids={candidates} key_xy={Agent.GetXY(key_agent_id)} {_runtime_snapshot()}" + ) + yield from Routines.Yield.Items.LootItems([key_agent_id], pickup_timeout=8_000) + verify_deadline = time.monotonic() + 3.0 + key_still_present = True + while bot.config.fsm_running and time.monotonic() < verify_deadline: + key_still_present = key_agent_id in { + int(agent_id) for agent_id in (AgentArray.GetItemArray() or []) + } + if not key_still_present: + break + yield from Routines.Yield.wait(200) + if key_still_present: + yield from _hold_failure( + f"The {key_name} remained on the ground after the pickup attempt" + ) + return False + SESSION_LOG.write( + f"key_pickup_success point={point_index} key_name={key_name!r} " + f"model_id={key_model_id} verification='ground agent disappeared' {_runtime_snapshot()}" + ) + bot.config.state_description = "Running" + return True + + +def _ground_keys( + model_id: int, + near_xy: tuple[float, float] | None = None, + max_distance: float | None = None, +) -> list[int]: + keys: list[int] = [] + for agent_id in (AgentArray.GetItemArray() or []): + agent_id = int(agent_id) + if not Agent.IsValid(agent_id): + continue + try: + item_id = int(Agent.GetItemAgentItemID(agent_id)) + if not item_id or int(Item.GetModelID(item_id)) != model_id: + continue + if ( + near_xy is not None + and max_distance is not None + and Utils.Distance(near_xy, Agent.GetXY(agent_id)) > max_distance + ): + continue + keys.append(agent_id) + except Exception: + continue + keys.sort(key=lambda agent_id: Utils.Distance(Player.GetXY(), Agent.GetXY(agent_id))) + return keys + + +def _secure_havok_boss_key() -> Generator[Any, Any, bool]: + """Use the SoO pattern: clear, loot the ground key if present, then use the lock as proof.""" + capture_xy = tuple(LEVEL_1_HAVOK_KEY_KILL["xy"]) + capture_id = str(LEVEL_1_HAVOK_KEY_KILL["capture_id"]) + deadline = time.monotonic() + 90.0 + clear_since: float | None = None + bot.config.state_description = "Defeat Havok-kin and retrieve the Boss Key" + SESSION_LOG.write( + f"havok_key_gate_start capture_xy={capture_xy} capture_id={capture_id!r} " + "verification='ground key disappearance plus boss-lock progression' " + f"{_runtime_snapshot()}" + ) + + while bot.config.fsm_running and time.monotonic() < deadline: + # The later Level 1 Dungeon Key can already be visible through a wall. + # Havok drops a Boss Key, so only that exact model is valid here. + ground_keys = _ground_keys( + BOSS_KEY_MODEL_ID, + near_xy=capture_xy, + max_distance=2_000.0, + ) + if ground_keys: + key_agent_id = ground_keys[0] + key_xy = Agent.GetXY(key_agent_id) + SESSION_LOG.write( + f"havok_key_ground_found key_agent_id={key_agent_id} key_xy={key_xy} " + f"all_ground_key_ids={ground_keys!r} {_runtime_snapshot()}" + ) + # The carrier is dead once the key exists. Move directly to the + # drop even if another nearby mob still keeps danger active. + reached = yield from Routines.Yield.Movement.FollowPath( + path_points=[key_xy], + custom_pause_fn=lambda: False, + tolerance=80.0, + timeout=8_000, + autopath=False, + ) + if reached and Agent.IsValid(key_agent_id): + Player.Interact(key_agent_id, call_target=False) + yield from Routines.Yield.wait(500) + if not Agent.IsValid(key_agent_id) or key_agent_id not in { + int(agent_id) for agent_id in (AgentArray.GetItemArray() or []) + }: + SESSION_LOG.write( + f"havok_key_ground_disappeared key_agent_id={key_agent_id} " + f"{_runtime_snapshot()}" + ) + bot.config.state_description = "Running" + return True + clear_since = None + continue + + nearby_enemies = [ + int(agent_id) + for agent_id in (AgentArray.GetEnemyArray() or []) + if Agent.IsValid(agent_id) + and Agent.IsAlive(agent_id) + and Utils.Distance(capture_xy, Agent.GetXY(agent_id)) <= 2_000.0 + ] + if nearby_enemies: + clear_since = None + yield from Routines.Yield.wait(250) + continue + + # Dungeon keys are instance/UI state after pickup, not bag inventory. + # If the fight is clear and no Boss Key remains on the ground, HeroAI + # already collected it (or this phase was resumed after collection). + if clear_since is None: + clear_since = time.monotonic() + elif time.monotonic() - clear_since >= 1.0: + SESSION_LOG.write( + "havok_key_gate_success reason='area clear and no Boss Key on ground; " + f"proceeding to boss lock' {_runtime_snapshot()}" + ) + bot.config.state_description = "Running" + return True + yield from Routines.Yield.wait(250) + + remaining_enemies = [ + int(agent_id) + for agent_id in (AgentArray.GetEnemyArray() or []) + if Agent.IsValid(agent_id) + and Agent.IsAlive(agent_id) + and Utils.Distance(capture_xy, Agent.GetXY(agent_id)) <= 2_000.0 + ] + yield from _hold_failure( + "Havok-kin Boss Key was not acquired; " + f"nearby enemies={remaining_enemies!r}. The boss lock will not be attempted" + ) + return False + + +def _require_level_1_boss_key() -> Generator[Any, Any, bool]: + SESSION_LOG.write( + "level_1_boss_key_handoff verification='no bag representation; boss lock is progression gate' " + f"{_runtime_snapshot()}" + ) + yield + return True + + +def _open_lock_until_passable( + point_index: int, + destination: tuple[float, float], + label: str, +) -> Generator[Any, Any, bool]: + """Retry a dungeon lock until a known point beyond its door is reachable.""" + for attempt in range(1, 4): + bot.config.state_description = f"Opening {label} (attempt {attempt}/3)" + SESSION_LOG.write( + f"lock_passage_attempt point={point_index} label={label!r} attempt={attempt} " + f"destination={destination} " + f"{_runtime_snapshot()}" + ) + if not (yield from _run_interaction(point_index)): + return False + # Lock activation is asynchronous. Give the key-use animation time to + # complete before the passage probe starts moving the player away. + yield from Routines.Yield.wait(1_500) + passed = yield from Routines.Yield.Movement.FollowPath( + path_points=[destination], + custom_pause_fn=lambda: False, + tolerance=60.0, + timeout=6_000, + autopath=False, + ) + SESSION_LOG.write( + f"lock_passage_result point={point_index} label={label!r} attempt={attempt} " + f"passed={bool(passed)} destination={destination} {_runtime_snapshot()}" + ) + if passed: + bot.config.state_description = "Running" + return True + Player.Move(*Player.GetXY()) + yield from Routines.Yield.wait(350) + + yield from _hold_failure( + f"{label} did not open after 3 interactions; the retrieved key was not consumed" + ) + return False + + +def _open_level_1_dungeon_lock() -> Generator[Any, Any, bool]: + # Point 259 is close enough to be falsely accepted from the locked side of + # the door. Point 260 is deep enough that reaching it proves passage. + beyond_door = _captured_path(DARKRIME_LEVEL_1, 260, 260) + if not beyond_door: + yield from _hold_failure("Missing Level 1 route point 260 beyond the dungeon lock") + return False + return bool( + (yield from _open_lock_until_passable(258, beyond_door[0], "Level 1 dungeon lock")) + ) + + +def _schedule_path( + points: list[Tuple[float, float]], label: str, source_route_id: str = SOURCE_ROUTE_ID +) -> None: + frozen = list(points) + bot.States.AddCustomState( + lambda: _follow_recorded_path(frozen, label, source_route_id), + label, + ) + + +def _capture_segment(map_id: int) -> list[Tuple[int, float, float]]: + return list(ROUTE_POINTS.get(map_id, ())) + + +def _captured_path(map_id: int, first: int, last: int) -> list[Tuple[float, float]]: + return [ + (x, y) + for point_index, x, y in _capture_segment(map_id) + if first <= point_index <= last + ] + + +def _cross_available_level_1_bridge() -> Generator[Any, Any, bool]: + """Probe the primary bridge, falling back to the captured alternate route.""" + primary_probe = _captured_path(DARKRIME_LEVEL_1, 161, 162) + primary_tail = _captured_path(DARKRIME_LEVEL_1, 163, 187) + alternate = list(LEVEL_1_OTHER_BRIDGE) + blessing_index = int(LEVEL_1_OTHER_BRIDGE_BLESSING_INDEX) + + bot.config.state_description = "Checking whether the first Level 1 bridge is available" + SESSION_LOG.write( + f"level_1_bridge_probe_start probe={primary_probe!r} {_runtime_snapshot()}" + ) + # Combat on the approach has already been cleared by the preceding + # combat-aware path. Keep this probe direct so autopath cannot silently + # choose the other bridge for us. Timeout is per waypoint. + primary_available = yield from Routines.Yield.Movement.FollowPath( + path_points=primary_probe, + custom_pause_fn=lambda: False, + tolerance=100.0, + timeout=8_000, + autopath=False, + ) + SESSION_LOG.write( + f"level_1_bridge_probe_result primary_available={primary_available} " + f"{_runtime_snapshot()}" + ) + + if primary_available: + bot.config.state_description = "Crossing the first Level 1 bridge" + # The old two-point probe only reached the bridge edge. The missing + # span is first exercised by point 163, and the standard bot path has + # an unlimited movement timeout. Try the real crossing three times, + # ten seconds per unreachable waypoint, before selecting the fallback. + for attempt in range(1, 4): + current_xy = Player.GetXY() + nearest_index = min( + range(len(primary_tail)), + key=lambda index: Utils.Distance(current_xy, primary_tail[index]), + ) + remaining_tail = primary_tail[nearest_index:] + attempt_start_xy = Player.GetXY() + SESSION_LOG.write( + f"level_1_primary_bridge_attempt attempt={attempt} " + f"resume_index={nearest_index} target={remaining_tail[0]} " + f"{_runtime_snapshot()}" + ) + crossed = yield from Routines.Yield.Movement.FollowPath( + path_points=remaining_tail, + custom_pause_fn=lambda: False, + tolerance=100.0, + timeout=10_000, + autopath=False, + ) + attempt_end_xy = Player.GetXY() + SESSION_LOG.write( + f"level_1_primary_bridge_attempt_result attempt={attempt} " + f"success={bool(crossed)} displacement=" + f"{Utils.Distance(attempt_start_xy, attempt_end_xy):.2f} " + f"start_xy={attempt_start_xy} end_xy={attempt_end_xy} " + f"{_runtime_snapshot()}" + ) + if crossed: + SESSION_LOG.write( + f"level_1_primary_bridge_success attempt={attempt} {_runtime_snapshot()}" + ) + bot.config.state_description = "Running" + return True + Player.Move(*Player.GetXY()) + yield from Routines.Yield.wait(250) + + SESSION_LOG.write( + "level_1_primary_bridge_stalled attempts=3 window_seconds=30 " + f"action='take alternate bridge' {_runtime_snapshot()}" + ) + + # Stop the failed move at the primary bridge edge before turning around. + Player.Move(*Player.GetXY()) + bot.config.state_description = "First bridge unavailable; taking the other bridge" + SESSION_LOG.write( + f"level_1_bridge_fallback source={LEVEL_1_OTHER_BRIDGE_SOURCE_ROUTE_ID!r} " + f"count={len(alternate)} {_runtime_snapshot()}" + ) + reached_blessing = yield from _follow_recorded_path( + alternate[: blessing_index + 1], + "Other Level 1 bridge route to Beacon", + LEVEL_1_OTHER_BRIDGE_SOURCE_ROUTE_ID, + ) + if not reached_blessing: + return False + if not (yield from _run_interaction(ALT_BRIDGE_BLESSING_POINT)): + return False + reached_join = yield from _follow_recorded_path( + alternate[blessing_index + 1 :], + "Other Level 1 bridge route from Beacon to shared join", + LEVEL_1_OTHER_BRIDGE_SOURCE_ROUTE_ID, + ) + bot.config.state_description = "Running" + return bool(reached_join) + + +def _add_interaction_state(map_id: int, point_index: int, source: str) -> None: + if point_index not in INTERACTIONS: + raise RuntimeError(f"Missing interaction policy for point {point_index}") + if point_index not in MARKERS: + raise RuntimeError(f"Missing interaction marker for point {point_index}") + label = MARKERS[point_index]["name"] + state_name = f"DD:INTERACTION:{map_id}:{point_index}:{label}" + SESSION_LOG.write( + f"interaction_state_added map={map_id} point={point_index} " + f"state={state_name!r} source={source!r} label={label!r}" + ) + _registered_interaction_points.append(point_index) + bot.States.AddCustomState( + lambda index=point_index: _run_interaction(index), + state_name, + ) + + +def _log_phase_interactions( + phase: str, + map_id: int, + first: int, + last: int, + expected: list[int], + registered: list[int], +) -> None: + SESSION_LOG.write( + f"phase_interactions phase={phase!r} map={map_id} range={first}-{last} " + f"ordered_points={registered} expected_points={expected}" + ) + if registered != expected: + raise RuntimeError( + f"Interaction registration mismatch for {phase}: " + f"expected {expected}, registered {registered}" + ) + + +def _add_route_points(map_id: int, first: int, last: int) -> None: + phase = f"map {map_id} capture points {first}-{last}" + expected_interactions = [ + point_index + for point_index, _x, _y in _capture_segment(map_id) + if first <= point_index <= last and point_index in INTERACTIONS + ] + registered_interactions: list[int] = [] + buffered: list[Tuple[float, float]] = [] + buffered_first = -1 + buffered_last = -1 + + def flush() -> None: + nonlocal buffered, buffered_first, buffered_last + if buffered: + _schedule_path( + buffered, + f"Replay map {map_id} capture points {buffered_first}-{buffered_last}", + ) + buffered = [] + buffered_first = -1 + buffered_last = -1 + + for point_index, x, y in _capture_segment(map_id): + if point_index < first or point_index > last: + continue + if point_index in KEY_PICKUP_POINTS: + # Do not blindly walk to the captured drop coordinate first. The + # key may already have been auto-looted while approaching. Check + # the live ground agent before entering the dangerous key pocket; + # LootItems will move to the real agent only when it still exists. + flush() + bot.States.AddCustomState( + lambda index=point_index: _pick_up_key(index), + KEY_PICKUP_POINTS[point_index], + ) + continue + if buffered_first < 0: + buffered_first = point_index + buffered_last = point_index + buffered.append((x, y)) + if point_index in INTERACTIONS: + flush() + _add_interaction_state(map_id, point_index, "generated route boundary") + registered_interactions.append(point_index) + if point_index == 207: + bot.States.AddCustomState( + _retreat_from_level_1_lever_and_wait, + "Retreat from lever trap and wait for bridge", + ) + elif point_index in DROP_BUNDLE_POINTS: + flush() + bot.States.AddCustomState( + lambda index=point_index: _drop_bundle(index), + DROP_BUNDLE_POINTS[point_index], + ) + flush() + _log_phase_interactions( + phase, + map_id, + first, + last, + expected_interactions, + registered_interactions, + ) + + +def darkrime_delves_routine(bot: Botting) -> None: + _registered_interaction_points.clear() + SESSION_LOG.write( + f"interaction_policy_audit total={len(INTERACTIONS)} " + f"deferred_gate_verification_points={list(DEFERRED_GATE_VERIFICATION_POINTS)}" + ) + bot.States.AddHeader(BOT_NAME) + + bot.States.AddHeader("MENU") + bot.States.AddCustomState(_anchor, "MENU_IDLE") + bot.States.AddCustomState(_idle_forever, "MENU_IDLE_LOOP") + + bot.States.AddHeader("Approach: Longeyes Ledge to Einarr Frostcleft") + bot.States.AddCustomState(_anchor, "DD:APPROACH") + _configure_combat(bot) + bot.Map.Travel(target_map_id=LONGEYES_LEDGE) + bot.Wait.ForMapLoad(target_map_id=LONGEYES_LEDGE) + _schedule_path( + [(x, y) for _point_index, x, y in LONGEYES_OUTPOST_POINTS], + "Recorded path through Longeyes Ledge", + APPROACH_SOURCE_ROUTE_ID, + ) + bot.Move.XYAndExitMap(-26375.0, 16180.0, target_map_id=BJORA_MARCHES) + bot.Wait.ForMapLoad(target_map_id=BJORA_MARCHES) + + bot.States.AddHeader("Approach: Bjora entry to Longeyes blessing") + bot.States.AddCustomState(_anchor, "DD:APPROACH_BJORA") + _configure_combat(bot) + bot.States.AddCustomState( + lambda: _require_map(BJORA_MARCHES, "Bjora entry blessing approach"), + "Require Bjora Marches", + ) + _schedule_path( + list(BJORA_ENTRY_TO_BLESSING), + "Recorded path to the blessing outside Longeyes", + APPROACH_SOURCE_ROUTE_ID, + ) + _add_interaction_state(BJORA_MARCHES, 1007, "explicit Bjora blessing control") + _log_phase_interactions( + "Bjora entry to Longeyes blessing", + BJORA_MARCHES, + 1007, + 1007, + [1007], + [1007], + ) + + bot.States.AddHeader("Approach: blessing to Einarr Frostcleft") + bot.States.AddCustomState(_anchor, "DD:APPROACH_YAVB") + _configure_combat(bot) + bot.States.AddCustomState( + lambda: _require_map(BJORA_MARCHES, "YAVB-safe approach"), + "Require Bjora Marches", + ) + _schedule_path( + [*YAVB_BLESSING_TO_CAPTURE_JOIN, CAPTURE_JOIN_XY], + "YAVB-safe path from the blessing to the captured route", + "YAVB 2.0 Bjora Marches route", + ) + _schedule_path( + [(x, y) for _point_index, x, y in CAPTURE_JOIN_TO_EINARR_POINTS], + "Captured route continuation to Einarr Frostcleft", + APPROACH_SOURCE_ROUTE_ID, + ) + # Only the continuous arrival route needs to wait for Einarr's scripted + # conversation. Jumping directly to DD:APPROACH_EINARR starts after this + # state and can interact immediately. + bot.States.AddCustomState( + _wait_for_einarr_conversation, + "Wait 30 seconds for Einarr's conversation", + ) + + bot.States.AddHeader("Approach: At Einarr Frostcleft") + bot.States.AddCustomState(_anchor, "DD:APPROACH_EINARR") + _configure_combat(bot) + bot.States.AddCustomState( + lambda: _require_map(BJORA_MARCHES, "Einarr quest interaction"), + "Require Bjora Marches", + ) + _schedule_path( + [ + (-15412.21, 13828.47), + (-15668.46, 13787.23), + (-15864.09, 13716.09), + ], + "Move into Einarr interaction range", + APPROACH_SOURCE_ROUTE_ID, + ) + _add_interaction_state(BJORA_MARCHES, 1, "explicit Einarr quest interaction") + _log_phase_interactions( + "Einarr quest acceptance", + BJORA_MARCHES, + 1, + 1, + [1], + [1], + ) + + bot.States.AddHeader("Approach: Einarr to Darkrime after accepting Cold Vengeance") + bot.States.AddCustomState(_anchor, "DD:APPROACH_POST_QUEST") + _configure_combat(bot) + bot.States.AddCustomState( + lambda: _require_map(BJORA_MARCHES, "Post-quest approach"), + "Require Bjora Marches", + ) + _add_route_points(BJORA_MARCHES, 2, 25) + bot.Move.XYAndExitMap(-6343.24, 19591.38, target_map_id=DARKRIME_LEVEL_1) + bot.Wait.ForMapLoad(target_map_id=DARKRIME_LEVEL_1) + + bot.States.AddHeader("Level 1: Entrance and first Powder Keg") + bot.States.AddCustomState(_anchor, "DD:L1_ENTRANCE") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_1, "Level 1 entrance"), "Require level 1") + _add_route_points(DARKRIME_LEVEL_1, 27, 29) + _schedule_path( + list(SNOWBALL_DODGE_2), + "Snowball Dodge 2 from entrance Beacon", + SNOWBALL_DODGE_SOURCE_ROUTE_ID, + ) + + bot.States.AddHeader("Level 1: After snowball dodge") + bot.States.AddCustomState(_anchor, "DD:L1_AFTER_SNOWBALL") + _configure_combat(bot) + bot.States.AddCustomState( + lambda: _require_map(DARKRIME_LEVEL_1, "Level 1 after snowball dodge"), + "Require level 1", + ) + _add_route_points(DARKRIME_LEVEL_1, 43, 94) + + bot.States.AddHeader("Level 1: Havok-kin and second Powder Keg") + bot.States.AddCustomState(_anchor, "DD:L1_HAVOK_KIN") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_1, "Level 1 Havok-kin"), "Require level 1") + _add_route_points(DARKRIME_LEVEL_1, 95, 112) + _schedule_path( + [tuple(LEVEL_1_HAVOK_KEY_KILL["xy"])], + "Move to Havok-kin key carrier before collecting second Powder Keg", + str(LEVEL_1_HAVOK_KEY_KILL["capture_id"]), + ) + bot.States.AddCustomState( + _secure_havok_boss_key, + "Defeat Havok-kin and retrieve Boss Key", + ) + _add_route_points(DARKRIME_LEVEL_1, 113, 135) + bot.States.AddCustomState( + _require_level_1_boss_key, + "Proceed to Level 1 boss lock with retrieved Boss Key", + ) + _schedule_path( + _captured_path(DARKRIME_LEVEL_1, 136, 136), + "Approach Level 1 boss lock", + ) + _registered_interaction_points.append(136) + SESSION_LOG.write( + "interaction_state_added map=635 point=136 source='retry-until-passage lock control' " + "label='Level 1 boss lock'" + ) + bot.States.AddCustomState( + lambda: _open_lock_until_passable( + 136, + # Point 137 can be reached closely enough from the locked side to + # satisfy movement tolerance. Point 138 proves the boss lock opened. + _captured_path(DARKRIME_LEVEL_1, 138, 138)[0], + "Level 1 boss lock", + ), + "Open Level 1 boss lock and verify passage", + ) + # The passage verifier has already carried the continuous run through 138. + _add_route_points(DARKRIME_LEVEL_1, 139, 141) + + bot.States.AddHeader("Level 1: Bridge route") + bot.States.AddCustomState(_anchor, "DD:L1_BRIDGES") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_1, "Level 1 bridges"), "Require level 1") + _add_route_points(DARKRIME_LEVEL_1, 142, 160) + # This interaction is embedded in the alternate branch rather than added + # as an unconditional FSM state, but it is still part of the registration + # audit so hot reload cannot silently omit its policy. + _registered_interaction_points.append(ALT_BRIDGE_BLESSING_POINT) + SESSION_LOG.write( + f"interaction_state_added map={DARKRIME_LEVEL_1} " + f"point={ALT_BRIDGE_BLESSING_POINT} source='alternate bridge branch' " + f"label={MARKERS[ALT_BRIDGE_BLESSING_POINT]['name']!r}" + ) + bot.States.AddCustomState( + _cross_available_level_1_bridge, + "Use available Level 1 bridge", + ) + _add_route_points(DARKRIME_LEVEL_1, 188, 206) + + bot.States.AddHeader("Level 1: Lever, key, and exit") + bot.States.AddCustomState(_anchor, "DD:L1_EXIT") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_1, "Level 1 exit"), "Require level 1") + bot.States.AddCustomState( + lambda: _flag_all_heroes_at( + tuple(LEVEL_1_LEVER_HERO_SAFE_FLAG["xy"]), + "Level 1 lever safe spot", + ), + "Flag heroes at safe spot before using lever", + ) + _add_route_points(DARKRIME_LEVEL_1, 207, 207) + bot.States.AddCustomState( + lambda: _unflag_all_heroes("Level 1 lever bridge lowered"), + "Unflag heroes after bridge-lowering wait", + ) + + bot.States.AddHeader("Level 1: After lever to dungeon key") + bot.States.AddCustomState(_anchor, "DD:L1_AFTER_LEVER") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_1, "Level 1 key route"), "Require level 1") + _add_route_points(DARKRIME_LEVEL_1, 208, 215) + _schedule_path( + [tuple(LEVEL_1_HERO_REGROUP["xy"])], + "Cross bridge to hero regroup point", + str(LEVEL_1_HERO_REGROUP["capture_id"]), + ) + bot.States.AddCustomState( + _wait_for_party_regroup, + "Wait for living heroes to cross the bridge", + ) + _add_route_points(DARKRIME_LEVEL_1, 216, 222) + bot.States.AddCustomState( + lambda: _flag_all_heroes_at( + tuple(LEVEL_1_KEY_FLAG["xy"]), + "Level 1 dungeon-key rescue position", + ), + "Flag heroes before the dungeon-key excursion", + ) + _add_route_points(DARKRIME_LEVEL_1, 223, 226) + + bot.States.AddHeader("Level 1: Pick up dungeon key") + bot.States.AddCustomState(_anchor, "DD:L1_KEY") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_1, "Level 1 key pickup"), "Require level 1") + bot.States.AddCustomState( + lambda: _flag_all_heroes_at( + tuple(LEVEL_1_KEY_FLAG["xy"]), + "Level 1 dungeon-key rescue position (phase start)", + ), + "Ensure heroes are flagged before picking up the dungeon key", + ) + _add_route_points(DARKRIME_LEVEL_1, 227, 227) + + bot.States.AddHeader("Level 1: Return with key to dungeon lock") + bot.States.AddCustomState(_anchor, "DD:L1_AFTER_KEY") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_1, "Level 1 return route"), "Require level 1") + _add_route_points(DARKRIME_LEVEL_1, 228, 230) + bot.States.AddCustomState( + lambda: _unflag_all_heroes("Level 1 dungeon key retrieved and player returned"), + "Unflag heroes after returning with the dungeon key", + ) + _add_route_points(DARKRIME_LEVEL_1, 231, 257) + _schedule_path( + _captured_path(DARKRIME_LEVEL_1, 258, 258), + "Approach Level 1 dungeon lock", + ) + _registered_interaction_points.append(258) + SESSION_LOG.write( + "interaction_state_added map=635 point=258 source='retry-until-passage lock control' " + "label='Level 1 dungeon lock'" + ) + bot.States.AddCustomState( + _open_level_1_dungeon_lock, + "Open Level 1 dungeon lock and verify passage", + ) + + bot.States.AddHeader("Level 1: Dungeon lock to exit") + bot.States.AddCustomState(_anchor, "DD:L1_AFTER_LOCK") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_1, "Level 1 exit route"), "Require level 1") + # The lock verifier has already carried the continuous run through point + # 260, so continue forward without backtracking toward the lock. + _add_route_points(DARKRIME_LEVEL_1, 261, 280) + bot.Move.XYAndExitMap(*LEVEL_1_EXIT_XY, target_map_id=DARKRIME_LEVEL_2) + bot.Wait.ForMapLoad(target_map_id=DARKRIME_LEVEL_2) + + bot.States.AddHeader("Level 2: Entrance") + bot.States.AddCustomState(_anchor, "DD:L2_ENTRANCE") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 entrance"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 282, 284) + + bot.States.AddHeader("Level 2: After entrance Beacon and safe route") + bot.States.AddCustomState(_anchor, "DD:L2_AFTER_ENTRANCE_BEACON") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 safe route"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 285, 288) + _schedule_path( + list(LEVEL_2_SAFE_ROUTE), + "Level 2 aggro-safe route", + LEVEL_2_SAFE_ROUTE_SOURCE_ROUTE_ID, + ) + + bot.States.AddHeader("Level 2: After safe route and upper loop") + bot.States.AddCustomState(_anchor, "DD:L2_AFTER_SAFE_ROUTE") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 upper loop"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 296, 312) + + bot.States.AddHeader("Level 2: Approach Pendulum Trap Room") + bot.States.AddCustomState(_anchor, "DD:L2_UPPER_HALLS") + _configure_combat(bot) + bot.States.AddCustomState( + lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 Pendulum Trap approach"), + "Require level 2", + ) + _add_route_points(DARKRIME_LEVEL_2, 318, 327) + + bot.States.AddHeader("Level 2: Pendulum Trap Room") + bot.States.AddCustomState(_anchor, "DD:L2_PENDULUM_TRAP_ROOM") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 Pendulum Trap Room"), "Require level 2") + _schedule_path( + [tuple(LEVEL_2_PENDULUM_TRAP_ROOM["xy"])], + "Enter Pendulum Trap Room", + str(LEVEL_2_PENDULUM_TRAP_ROOM["capture_id"]), + ) + _add_route_points(DARKRIME_LEVEL_2, 328, 334) + + bot.States.AddHeader("Level 2: Descent to second Beacon") + bot.States.AddCustomState(_anchor, "DD:L2_SECOND_BEACON_APPROACH") + _configure_combat(bot) + bot.States.AddCustomState( + lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 second Beacon approach"), + "Require level 2", + ) + _add_route_points(DARKRIME_LEVEL_2, 335, 351) + + bot.States.AddHeader("Level 2: Map Room / Second Beacon") + bot.States.AddCustomState(_anchor, "DD:L2_MAP_ROOM") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 Map Room"), "Require level 2") + _schedule_path( + [tuple(LEVEL_2_MAP_ROOM["xy"])], + "Enter Map Room", + str(LEVEL_2_MAP_ROOM["capture_id"]), + ) + _add_route_points(DARKRIME_LEVEL_2, 352, 354) + + bot.States.AddHeader("Level 2: Middle") + bot.States.AddCustomState(_anchor, "DD:L2_MIDDLE") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 middle"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 355, 376) + + bot.States.AddHeader("Level 2: The Great Rift Hall") + bot.States.AddCustomState(_anchor, "DD:L2_LOWER_CENTRAL") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 The Great Rift Hall"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 377, 397) + + bot.States.AddHeader("Level 2: Chromatic Drake Corner / Third Beacon") + bot.States.AddCustomState(_anchor, "DD:L2_WESTERN_HALLS") + _configure_combat(bot) + bot.States.AddCustomState( + lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 Chromatic Drake Corner / Third Beacon"), + "Require level 2", + ) + _add_route_points(DARKRIME_LEVEL_2, 398, 419) + + bot.States.AddHeader("Level 2: Approach Grelk") + bot.States.AddCustomState(_anchor, "DD:L2_GRELK_APPROACH") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 Grelk approach"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 420, 438) + + bot.States.AddHeader("Level 2: Midway to Grelk") + bot.States.AddCustomState(_anchor, "DD:L2_GRELK_MIDWAY") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 midway to Grelk"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 439, 456) + + bot.States.AddHeader("Level 2: After final Beacon to Grelk") + bot.States.AddCustomState(_anchor, "DD:L2_AFTER_FINAL_BEACON") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 final Grelk corridor"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 457, 464) + + bot.States.AddHeader("Level 2: Grelk and dungeon key") + bot.States.AddCustomState(_anchor, "DD:L2_GRELK_KEY") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 Grelk key"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 465, 465) + + bot.States.AddHeader("Level 2: Return with key") + bot.States.AddCustomState(_anchor, "DD:L2_AFTER_GRELK") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 return with key"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 466, 482) + + bot.States.AddHeader("Level 2: Return midpoint") + bot.States.AddCustomState(_anchor, "DD:L2_RETURN_MIDPOINT") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 return midpoint"), "Require level 2") + _add_route_points(DARKRIME_LEVEL_2, 483, 500) + + bot.States.AddHeader("Level 2: Return north corridor") + bot.States.AddCustomState(_anchor, "DD:L2_RETURN_NORTH") + _configure_combat(bot) + bot.States.AddCustomState( + lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 return north corridor"), + "Require level 2", + ) + _add_route_points(DARKRIME_LEVEL_2, 501, 518) + + bot.States.AddHeader("Level 2: Boss lock and exit") + bot.States.AddCustomState(_anchor, "DD:L2_EXIT") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_2, "Level 2 exit"), "Require level 2") + _schedule_path( + _captured_path(DARKRIME_LEVEL_2, 519, 519), + "Approach Level 2 boss lock", + ) + _registered_interaction_points.append(519) + SESSION_LOG.write( + "interaction_state_added map=636 point=519 source='retry-until-passage lock control' " + "label='Level 2 boss lock'" + ) + bot.States.AddCustomState( + lambda: _open_lock_until_passable( + 519, + # The middle point is still reachable from the locked side. The + # final captured point is beyond the obstruction and is the real + # passage proof used to decide whether another click is needed. + tuple(LEVEL_2_BOSS_LOCK_TO_DOOR[-1]), + "Level 2 boss lock", + ), + "Open Level 2 boss lock and verify passage", + ) + bot.Move.XYAndExitMap(*LEVEL_2_EXIT_XY, target_map_id=DARKRIME_LEVEL_3) + bot.Wait.ForMapLoad(target_map_id=DARKRIME_LEVEL_3) + + bot.States.AddHeader("Level 3: Entrance") + bot.States.AddCustomState(_anchor, "DD:L3_ENTRANCE") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_3, "Level 3 entrance"), "Require level 3") + _schedule_path( + _captured_path(DARKRIME_LEVEL_3, 522, 524), + "Replay map 637 capture points 522-524", + ) + _add_interaction_state(DARKRIME_LEVEL_3, 524, "explicit Level 3 entrance boundary") + _schedule_path( + _captured_path(DARKRIME_LEVEL_3, 538, 577), + "Replay map 637 capture points 538-577", + ) + _add_interaction_state(DARKRIME_LEVEL_3, 577, "explicit Level 3 interior Beacon boundary") + _log_phase_interactions( + "Level 3 entrance", + DARKRIME_LEVEL_3, + 522, + 577, + [524, 577], + [524, 577], + ) + + bot.States.AddHeader("Level 3: Havok Soulwail and chest") + bot.States.AddCustomState(_anchor, "DD:L3_BOSS") + _configure_combat(bot) + bot.States.AddCustomState(lambda: _require_map(DARKRIME_LEVEL_3, "Level 3 boss"), "Require level 3") + _schedule_path( + _captured_path(DARKRIME_LEVEL_3, 578, 593), + "Replay map 637 capture points 578-593", + ) + _add_interaction_state(DARKRIME_LEVEL_3, 593, "explicit Level 3 chest boundary") + _log_phase_interactions( + "Level 3 boss and chest", + DARKRIME_LEVEL_3, + 578, + 593, + [593], + [593], + ) + missing_interactions = [ + point_index + for point_index in INTERACTIONS + if point_index not in _registered_interaction_points + ] + duplicate_interactions = sorted( + { + point_index + for point_index in _registered_interaction_points + if _registered_interaction_points.count(point_index) > 1 + } + ) + SESSION_LOG.write( + f"interaction_registration_audit ordered_points={_registered_interaction_points} " + f"missing_points={missing_interactions} duplicate_points={duplicate_interactions}" + ) + if missing_interactions or duplicate_interactions: + raise RuntimeError( + "Darkrime interaction registration audit failed: " + f"missing={missing_interactions}, duplicates={duplicate_interactions}" + ) + bot.States.JumpToStepName("MENU_IDLE") + + +bot.SetMainRoutine(darkrime_delves_routine) + + +def _start_at_phase(step_name: str) -> None: + try: + bot.Stop() + GLOBAL_CACHE.Coroutines.clear() + bot.config.FSM.reset() + bot.config.fsm_running = True + bot.config.state_description = "Running" + bot.config.FSM.jump_to_state_by_name(step_name) + bot.config.FSM.resume() + SESSION_LOG.write(f"start_phase step={step_name!r} {_runtime_snapshot()}") + except Exception as error: + bot.config.fsm_running = False + ConsoleLog(MODULE_NAME, f"Could not start {step_name}: {error}", Console.MessageType.Error) + + +def _draw_main_child_minimal(self, main_child_dimensions=(420, 220), icon_path="", iconwidth=0): + fsm = self._config.FSM + running = bool(self._config.fsm_running) + paused = bool(fsm.is_paused()) if running else False + PyImGui.text(f"Status: {'Paused' if paused else 'Running' if running else 'Idle'}") + PyImGui.text_wrapped(f"Step: {fsm.get_current_step_name() or 'Not started'}") + if running: + if PyImGui.button(f"{'Resume' if paused else 'Pause'}##DDPause"): + if paused: + fsm.resume() + self._config.state_description = "Running" + else: + fsm.pause() + self._config.state_description = "Paused" + Player.Move(*Player.GetXY()) + PyImGui.same_line(0.0, 8.0) + if PyImGui.button("Stop##DDStop"): + bot.Stop() + GLOBAL_CACHE.Coroutines.clear() + self._config.state_description = "Idle" + + +bot.UI._draw_main_child = types.MethodType(_draw_main_child_minimal, bot.UI) + + +def _draw_controls() -> None: + global _phase_index + PyImGui.separator() + PyImGui.text("Starting phase") + labels = [label for label, _ in PHASES] + _phase_index = PyImGui.combo("##DDPhase", _phase_index, labels) + if PyImGui.button("Run selected phase##DDRunPhase"): + _start_at_phase(PHASES[_phase_index][1]) + if _phase_index > 0: + PyImGui.text_wrapped( + "Checkpoint starts assume you are on the correct level and earlier keys, locks, " + "and objectives are complete." + ) + counts = ", ".join( + f"{Map.GetMapName(map_id)}: {len(_capture_segment(map_id))}" + for map_id in (BJORA_MARCHES, DARKRIME_LEVEL_1, DARKRIME_LEVEL_2, DARKRIME_LEVEL_3) + ) + PyImGui.text_wrapped(f"Normalized capture points: {counts}.") + if _initialization_error: + PyImGui.text_wrapped(f"Route initialization error: {_initialization_error}") + PyImGui.text_wrapped( + "The full run travels to Longeyes Ledge, takes the outside blessing, follows the YAVB-safe " + "Bjora route to the captured continuation, waits for Einarr's conversation, accepts Cold Vengeance, " + "and enters Darkrime." + ) + if _interaction_events: + latest = _interaction_events[-1] + PyImGui.text_wrapped( + f"Latest interaction: {latest.get('interaction', '')} / {latest.get('event', '')}" + ) + PyImGui.text_wrapped("Runtime details are written to the Py4GW console.") + + +def tooltip() -> None: + PyImGui.begin_tooltip() + PyImGui.text("Darkrime Delves") + PyImGui.separator() + PyImGui.text_wrapped( + "Replays the normalized three-level route with restartable phases and opt-in Reliable Interaction." + ) + PyImGui.end_tooltip() + + +def main() -> None: + global _initialization_error + try: + bot.Update() + except Exception as error: + _initialization_error = f"{type(error).__name__}: {error}" + bot.config.initialized = True + bot.config.fsm_running = False + SESSION_LOG.write(f"route_initialization_error error={_initialization_error!r}") + ConsoleLog(MODULE_NAME, _initialization_error, Console.MessageType.Error) + bot.UI.draw_window( + icon_path=os.path.join(PySystem.Console.get_projects_path(), MODULE_ICON), + main_child_dimensions=(440, 280), + additional_ui=_draw_controls, + ) + + +if __name__ == "__main__": + main() diff --git a/Widgets/Data/dungeons/darkrime_delves.json b/Widgets/Data/dungeons/darkrime_delves.json new file mode 100644 index 000000000..91d63db81 --- /dev/null +++ b/Widgets/Data/dungeons/darkrime_delves.json @@ -0,0 +1,143 @@ +{ + "schema_version": 1, + "name": "Darkrime Delves", + "source_route_id": "ROUTE-20260806-211625-835", + "approach_source_route_id": "ROUTE-20260806-221011-047", + "source_capture": "Logs/Route_Waypoint_Captures.jsonl", + "route_module": "darkrime_delves_data.py", + "maps": [482, 635, 636, 637], + "status": "live_route_validated", + "approach": { + "outpost_map_id": 650, + "outpost_map_name": "Longeyes Ledge", + "explorable_map_id": 482, + "explorable_map_name": "Bjora Marches", + "quest_id": 810, + "quest_name": "Cold Vengeance", + "quest_npc": "Einarr Frostcleft", + "quest_npc_model_id": 6428, + "quest_npc_target_xy": [-15915.0, 13669.0], + "quest_npc_capture_id": "NPC-20260807-115226-136", + "outpost_exit_xy": [-26375.0, 16180.0], + "blessing_xy": [17838.55, -18278.86], + "blessing_model_id": 6425, + "yavb_splice_finish_xy": [388.0, -1491.0], + "capture_join_xy": [464.49, -1019.08], + "level_1_lever_safe_wait": { + "xy": [-13256.14, -2042.54], + "wait_ms": 15000, + "capture_id": "NPC-20260806-224917-468" + }, + "level_2_grelk_checkpoint": { + "xy": [-11323.99, -18605.6], + "capture_id": "NPC-20260806-231153-892" + }, + "level_3_entrance_beacon": { + "xy": [-16402.23, 17848.92], + "model_id": 5916, + "capture_id": "NPC-20260806-231723-937" + }, + "quest_interaction_xy": [-15864.09, 13716.09], + "dungeon_entry_xy": [-6343.24, 19591.38], + "einarr_conversation_wait_ms": 30000, + "normalization": "Discard captured points 8-67 after the blessing. Use the YAVB 2.0 Bjora route from the blessing through (388,-1491), join the captured route, then retain captured points 69-126 to Einarr." + }, + "segments": [ + { + "map_id": 635, + "map_name": "Darkrime Delves (level 1)", + "source_point_range": [27, 280], + "normalized_point_count": 258, + "snowball_dodge_source_route_id": "ROUTE-20260807-120413-664", + "snowball_dodge_replaces_source_points": [30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42], + "snowball_dodge_resume_point": 43, + "snowball_dodge_checkpoint": "DD:L1_AFTER_SNOWBALL", + "havok_key_kill_checkpoint": { + "after_source_point": 112, + "before_second_powder_keg_point": 127, + "xy": [-1493.77, -3060.69], + "capture_id": "NPC-20260807-141426-562", + "required_inventory_model_id": 25410, + "failure_behavior": "hold before the powder keg and never attempt the boss lock without a verified Dungeon Key" + }, + "random_bridge_choice": { + "primary_probe_source_points": [161, 162], + "primary_route_tail_source_points": [163, 187], + "alternate_source_route_id": "ROUTE-20260807-142003-458", + "alternate_point_count": 44, + "alternate_blessing_point": 12, + "alternate_blessing_xy": [-9178.22, 14943.58], + "alternate_blessing_capture_id": "NPC-20260807-142035-173", + "shared_resume_source_point": 188, + "behavior": "probe the first bridge with a finite direct crossing; if unreachable, take the captured other bridge route, interact with its Beacon, and rejoin the original route" + }, + "hero_regroup_after_bridge": { + "xy": [-8250.15, -2386.21], + "radius": 350.0, + "timeout_ms": 20000, + "capture_id": "NPC-20260807-122219-412" + }, + "key_hero_flag": { + "xy": [-5488.69, -2881.93], + "capture_id": "NPC-20260807-142434-178", + "purpose": "keep heroes outside the frost trap but close enough to resurrect the player during the solo dungeon-key pickup" + }, + "key_location": { + "xy": [-5032.16, -2603.14], + "capture_id": "NPC-20260807-122128-904" + }, + "exit_transition_xy": [-18979.55, 11133.68], + "exit_to_map_id": 636 + }, + { + "map_id": 636, + "map_name": "Darkrime Delves (level 2)", + "source_point_range": [282, 520], + "normalized_point_count": 234, + "removed_source_points": [313, 314, 315, 316, 317], + "removed_reason": "Party-resurrection detour explicitly marked to ignore.", + "boss_lock_point": 519, + "boss_lock_to_door_source_route_id": "ROUTE-20260807-124835-082", + "boss_lock_to_door_points": [[-19273.02, -1004.83], [-19692.08, -728.73], [-19777.63, -763.18]], + "replaced_source_exit_point": 520, + "exit_transition_xy": [-20250.0, -953.0], + "exit_to_map_id": 637 + }, + { + "map_id": 637, + "map_name": "Darkrime Delves (level 3)", + "source_point_ranges": [[522, 524], [538, 593]], + "normalized_point_count": 59, + "removed_source_points": [525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537], + "removed_reason": "Death path and teleport back to the level entrance; splice joins at the entrance beacon.", + "final_chest_point": 593, + "final_chest_name": "Havok's Chest" + } + ], + "marker_corrections": [ + {"point_index": 94, "kind": "drop_bundle", "name": "Drop first Powder Keg"}, + {"point_index": 112, "kind": "objective", "name": "Defeat Havok-kin and collect the key"}, + {"point_index": 130, "kind": "drop_bundle", "name": "Drop second Powder Keg"}, + {"point_index": 134, "kind": "objective", "name": "Defeat Havok-kin"}, + {"point_index": 136, "kind": "gadget", "name": "Level 1 boss lock"}, + {"point_index": 160, "kind": "bridge", "name": "First possible bridge"}, + {"point_index": 177, "kind": "bridge", "name": "Alternate bridge"}, + {"point_index": 207, "kind": "gadget", "name": "Lever"}, + {"point_index": 227, "kind": "objective", "name": "Collect the dungeon key"}, + {"point_index": 258, "kind": "gadget", "name": "Level 1 dungeon lock"}, + {"point_index": 465, "kind": "objective", "name": "Defeat Grelk Icelash and collect the key"}, + {"point_index": 519, "kind": "gadget", "name": "Level 2 boss lock"}, + {"point_index": 593, "kind": "reward_chest", "name": "Havok's Chest"} + ], + "interaction_automation": { + "implementation": "Py4GWCoreLib.routines_src.reliable_interaction", + "opt_in": true, + "quest_npc": "after the 30-second Einarr conversation wait, use the visible quest choice and quest-810 accept context 0x832A01; verify quest 810 when the quest-log binding updates, otherwise accept the confirmed visible-button click plus dialog closure as the immediate game handoff", + "beacons": "live model-5916 resolution, asynchronous dialog handling, and effect/dialog verification; post-blessing popups close naturally without CloseAllPanels", + "keg_stations": "nearest live gadget plus held-bundle verification", + "locks_and_lever": "nearest live gadget plus gadget-state transition verification", + "final_chest": "nearest live gadget plus gadget-state or new-drop verification", + "runtime_ids": "reacquired immediately before every interaction", + "hero_ai": "paused and restored to its prior state on every exit path" + } +} diff --git a/Widgets/Data/dungeons/darkrime_delves_data.py b/Widgets/Data/dungeons/darkrime_delves_data.py new file mode 100644 index 000000000..e7b28de4c --- /dev/null +++ b/Widgets/Data/dungeons/darkrime_delves_data.py @@ -0,0 +1,888 @@ +"""Normalized Darkrime Delves capture data for the dungeon widget.""" + +SOURCE_ROUTE_ID = "ROUTE-20260806-211625-835" +ROUTE_POINTS = { + 482: ( + (0, -15867.82, 13727.37), + (1, -15846.66, 13714.37), + (2, -15450.81, 14020.93), + (3, -14964.46, 14147.82), + (4, -14469.83, 14228.94), + (5, -13999.86, 14399.97), + (6, -13615.24, 14722.07), + (7, -13261.42, 15083.52), + (8, -12903.16, 15436.92), + (9, -12552.87, 15802.25), + (10, -12224.06, 16182.79), + (11, -11793.13, 16438.75), + (12, -11292.12, 16401.89), + (13, -10830.8, 16597.77), + (14, -10650.72, 17064.67), + (15, -10725.62, 17565.98), + (16, -10440.94, 17977.79), + (17, -10034.39, 18277.35), + (18, -9697, 18653.06), + (19, -9231.74, 18839.37), + (20, -8738.12, 18734.93), + (21, -8250.46, 18848.89), + (22, -7779.6, 19028.37), + (23, -7294.36, 19167.89), + (24, -6810.21, 19312.3), + (25, -6372.99, 19561.56), + ), + 635: ( + (27, 15068, 17224), + (28, 15134.14, 16728.37), + (29, 15168.7, 16664.88), + (30, 14687.64, 16519.98), + (31, 14187.37, 16550.88), + (32, 13683.78, 16582.51), + (33, 13184.08, 16551.33), + (34, 12690.69, 16459.25), + (35, 12191.52, 16403.3), + (36, 11694.36, 16315.47), + (37, 11829.11, 16800.46), + (38, 11347.42, 16655.51), + (39, 10890.41, 16445.6), + (40, 10387.97, 16392.62), + (41, 9895.48, 16496.72), + (42, 9424.06, 16671.19), + (43, 8918.31, 16664.73), + (44, 8422.44, 16575.85), + (45, 7922.43, 16525.37), + (46, 7420.75, 16490.06), + (47, 6918.1, 16442.97), + (48, 6701.97, 15988.82), + (49, 6559.03, 15508.19), + (50, 6429.24, 15023.14), + (51, 6310.22, 14533.42), + (52, 6074.64, 14091.33), + (53, 5752.13, 13703.38), + (54, 5407.34, 13338.31), + (55, 4989.31, 13053.27), + (56, 4563.78, 12783.87), + (57, 4138.39, 12514.56), + (58, 3940.7, 12051.12), + (59, 4050.69, 11562.19), + (60, 4201.76, 11084.27), + (61, 4302.2, 10589.86), + (62, 4282.78, 10087.18), + (63, 4355.61, 9587.6), + (64, 4659.82, 9184.96), + (65, 5102.08, 8943.06), + (66, 5523.54, 8664.91), + (67, 5964.79, 8427.37), + (68, 6399.37, 8179.46), + (69, 6819.33, 7905.31), + (70, 7218.47, 7601.72), + (71, 7619.91, 7296.39), + (72, 7944.89, 6913.1), + (73, 8013.65, 6414.69), + (74, 7937.42, 5914.61), + (75, 7857.04, 5419.72), + (76, 7783.88, 4920.69), + (77, 7748.33, 4417.28), + (78, 7742.94, 3914.65), + (79, 7819.58, 3415.42), + (80, 7903.34, 2920.09), + (81, 8056.44, 2469.58), + (82, 7554.62, 2464.54), + (83, 7053.55, 2433.49), + (84, 6754.81, 2030.24), + (85, 6663.6, 1535.08), + (86, 6428.21, 1092.79), + (87, 6130.99, 689.31), + (88, 6137.42, 186.8), + (89, 6143.86, -317.52), + (90, 6095.91, -816.15), + (91, 6011.25, -1312.23), + (92, 5977.53, -1512.51), + (93, 6479.06, -1534.8), + (94, 6710.77, -1550.93), + (95, 6989.14, -1967.02), + (96, 7111.68, -2458.72), + (97, 6875.34, -2903.24), + (98, 6703.49, -3375.18), + (99, 6510.11, -3840.38), + (100, 6067.49, -4076.59), + (101, 5582.68, -4201.94), + (102, 5082.39, -4171.27), + (103, 4601.34, -4028.59), + (104, 4117.35, -3882.92), + (105, 3636.11, -3738.08), + (106, 3151.93, -3598.23), + (107, 2668.19, -3467.06), + (108, 2184.13, -3326.26), + (109, 1689.72, -3215.99), + (110, 1188.68, -3172.29), + (111, 686.2, -3124.08), + (112, 479.48, -3144.36), + (113, 181.47, -2732.12), + (114, -282.25, -2531.24), + (115, -723.76, -2289.66), + (116, -1165.9, -2047.73), + (117, -1529.42, -1699.48), + (118, -1897.4, -1357.99), + (119, -2215.72, -972.17), + (120, -2646.6, -706.26), + (121, -2710.8, -202.96), + (122, -2591.51, 284.01), + (123, -2162.53, 546.99), + (124, -1709.07, 766.15), + (125, -1299.02, 1057.81), + (126, -828.17, 1244.36), + (127, -369.9, 1412.45), + (128, -0.26, 1753.93), + (129, 377.86, 2082.24), + (130, 604.53, 2250.06), + (131, 1014.07, 2539.14), + (132, 1440.89, 2806.82), + (133, 1876.34, 3054.63), + (134, 1845.44, 3096.88), + (135, 1679.43, 3574.8), + (136, 1515.58, 3768.29), + (137, 1563.61, 4266.38), + (138, 1258.07, 4663.27), + (139, 1013.12, 5101.95), + (140, 777.23, 5549.67), + (141, 798.87, 5764.11), + (142, 489.55, 6164.4), + (143, 196.49, 6572.57), + (144, -262.81, 6772.08), + (145, -738.3, 6938.44), + (146, -1203.98, 7126.35), + (147, -1625, 7406.65), + (148, -2032.04, 7706.59), + (149, -2427.74, 8013.84), + (150, -2787.26, 8365.16), + (151, -3113.93, 8748.04), + (152, -3416.51, 9151.94), + (153, -3715.23, 9555.2), + (154, -4034.93, 9947.25), + (155, -4353.34, 10336.09), + (156, -4457.62, 10826.14), + (157, -4214.89, 11267.24), + (158, -3982.68, 11716.4), + (159, -4461.29, 11862.5), + (160, -4599.61, 11919.24), + (161, -4584.86, 11415.55), + (162, -4587.57, 10909.91), + (163, -4599.39, 10405.13), + (164, -4631.93, 9900.38), + (165, -4659.84, 9400.04), + (166, -4687, 8898.58), + (167, -4705.6, 8392.17), + (168, -4713.72, 7889.68), + (169, -4721.82, 7387.9), + (170, -4696.97, 6885.11), + (171, -4941.58, 6446.51), + (172, -5359.31, 6169.19), + (173, -5831.56, 5987.16), + (174, -6312.5, 5847.4), + (175, -6811.47, 5791.62), + (176, -7311.98, 5750.44), + (177, -7767.15, 5712.99), + (178, -8271.27, 5673.49), + (179, -8774.19, 5637.16), + (180, -9273.58, 5605.47), + (181, -9777.05, 5582.43), + (182, -10277.94, 5558.44), + (183, -10782.05, 5542.77), + (184, -11281.56, 5489.82), + (185, -11764.74, 5355.56), + (186, -12238.35, 5181.89), + (187, -12683.96, 4948.82), + (188, -13150.37, 4753.65), + (189, -13633.11, 4620.8), + (190, -14123.74, 4522.46), + (191, -14611.67, 4395.81), + (192, -14953.15, 4260.19), + (193, -14495.9, 4057.79), + (194, -14452.14, 3554.57), + (195, -14459.05, 3054.58), + (196, -14416.5, 2556.32), + (197, -14318.2, 2060.99), + (198, -14172.25, 1577.14), + (199, -14049.64, 1092.26), + (200, -13981.79, 594.79), + (201, -13884.57, 102.13), + (202, -13773.62, -385.81), + (203, -13665.68, -877.01), + (204, -13274.01, -1194.69), + (205, -12839.88, -1451.27), + (206, -12374.15, -1633.63), + (207, -12254.41, -1617.48), + (208, -11754, -1629.11), + (209, -11249.35, -1654.28), + (210, -10743.8, -1679.5), + (211, -10245.12, -1719.97), + (212, -9743.24, -1766.13), + (213, -9244.58, -1812.01), + (214, -8742.98, -1826.44), + (215, -8345.25, -2129.6), + (216, -8349.32, -2629.7), + (217, -8417.05, -3125.48), + (218, -8476.34, -3626.23), + (219, -8254.49, -4076.94), + (220, -7758.72, -4171.75), + (221, -7266.34, -4060.56), + (222, -6820.62, -3826.66), + (223, -6430.02, -3511.77), + (224, -6032.35, -3205.76), + (225, -5634.7, -2902.58), + (226, -5195.42, -2654.73), + (227, -5032.16, -2603.14), + (228, -5482.21, -2823.1), + (229, -5892.07, -3119.33), + (230, -6317.38, -3392.08), + (231, -6697.71, -3723.46), + (232, -7094.6, -4031.17), + (233, -7593.29, -4118.88), + (234, -8094.74, -4161.81), + (235, -8273.08, -3694.5), + (236, -8183.7, -3201.09), + (237, -8116.55, -2705.58), + (238, -8177.18, -2205.78), + (239, -8587.78, -1918.15), + (240, -9067.71, -1760.56), + (241, -9564.11, -1693.55), + (242, -10067.32, -1647.79), + (243, -10569.51, -1609.68), + (244, -11068.84, -1571.78), + (245, -11570.98, -1601.58), + (246, -12053.84, -1469.73), + (247, -12550.56, -1553.66), + (248, -12989.12, -1306.91), + (249, -13288.98, -906.38), + (250, -13222.94, -407.52), + (251, -13390.77, 63.55), + (252, -13700.11, 463.46), + (253, -13900.75, 926.35), + (254, -14117.04, 1383.57), + (255, -14343.57, 1833.99), + (256, -14735.93, 2151.89), + (257, -15130.2, 2460.85), + (258, -15524.7, 2530.11), + (259, -16027.8, 2477.02), + (260, -16528.72, 2426.07), + (261, -17031.95, 2409.02), + (262, -17486.72, 2623.43), + (263, -17844.31, 2980.61), + (264, -18076.6, 3425.05), + (265, -18290.18, 3882.88), + (266, -18491.76, 4345.99), + (267, -18686.15, 4808.57), + (268, -18926.8, 5250.08), + (269, -19142.67, 5701.6), + (270, -19293.26, 6183.5), + (271, -19313.55, 6686.99), + (272, -19133.61, 7157.04), + (273, -18774.46, 7507.25), + (274, -18452.37, 7893.49), + (275, -18280.16, 8366.98), + (276, -18216.35, 8864.57), + (277, -18240.76, 9365.81), + (278, -18257.39, 9869.25), + (279, -18405.56, 10346.89), + (280, -18663.39, 10780.17), + ), + 636: ( + (282, 20408, 14933), + (283, 20067.97, 14563.79), + (284, 19873.92, 14303.41), + (285, 19376.42, 14224.54), + (286, 18995.68, 13897.51), + (287, 18606.47, 13582.31), + (288, 18209.01, 13274.75), + (289, 17803.37, 12976.84), + (290, 17367.79, 12724.71), + (291, 16865.83, 12754.89), + (292, 16360.97, 12701.51), + (293, 15902.35, 12913.36), + (294, 15506.21, 13224.58), + (295, 15209.89, 13633.05), + (296, 15070.52, 14118.29), + (297, 15077.25, 14622.78), + (298, 15157.77, 15122), + (299, 15258.16, 15612.96), + (300, 15348.98, 16107.78), + (301, 15420.45, 16604.5), + (302, 15491.82, 17100.51), + (303, 15563.7, 17600.09), + (304, 15603.35, 18101.84), + (305, 15471.91, 18588.6), + (306, 15030.51, 18827.19), + (307, 14570.05, 19038.78), + (308, 14076.87, 19132.3), + (309, 13570.78, 19124.04), + (310, 13071.93, 19047.34), + (311, 12612.7, 18846.13), + (312, 12224.21, 18525.67), + (318, 12481.23, 18493.44), + (319, 12136, 18131.74), + (320, 11763.28, 17789.3), + (321, 11408.76, 17435.84), + (322, 11066.77, 17070.54), + (323, 10685.34, 16743.5), + (324, 10315.49, 16401.65), + (325, 9940.87, 16069.08), + (326, 9587.79, 15707.39), + (327, 9528.85, 15204.27), + (328, 9207.35, 14819.87), + (329, 8869.83, 14443.15), + (330, 8544.1, 14061.18), + (331, 8209.22, 13687.41), + (332, 7875.73, 13311.56), + (333, 7593.53, 12891.14), + (334, 7470.96, 12402.47), + (335, 7313.33, 11920.18), + (336, 7363.29, 11420.9), + (337, 7414.76, 10920.62), + (338, 7600.48, 10450.04), + (339, 7616.46, 9945.46), + (340, 7507, 9452.12), + (341, 7383.62, 8963.03), + (342, 7242.12, 8479.3), + (343, 7147.47, 7985.24), + (344, 7085.26, 7485.11), + (345, 6999.27, 6989.23), + (346, 6726.42, 6564.76), + (347, 6897.47, 6090.63), + (348, 7248.46, 5727.78), + (349, 7476.15, 5282.35), + (350, 7573.85, 4789.38), + (351, 7495.36, 4290.38), + (352, 7610.74, 3802.1), + (353, 7927.67, 3409.35), + (354, 8163.97, 3351.43), + (355, 8001.56, 2870.41), + (356, 7903.51, 2373.69), + (357, 7788.34, 1885.12), + (358, 7596.13, 1421.94), + (359, 7401.95, 957.65), + (360, 7202.6, 491.62), + (361, 7000.23, 30.85), + (362, 6805.01, -433.41), + (363, 6563.58, -877.32), + (364, 6127.66, -1130.05), + (365, 5662.27, -1323.52), + (366, 5199.32, -1514.4), + (367, 4742.95, -1719.88), + (368, 4289.06, -1942.27), + (369, 3824.59, -2148.88), + (370, 3326.88, -2227.38), + (371, 2831.41, -2336.67), + (372, 2354.45, -2508.39), + (373, 1889.32, -2700.56), + (374, 1423.26, -2881.75), + (375, 933.56, -3005.54), + (376, 433.85, -3056.18), + (377, -70.17, -3066.17), + (378, -571.73, -3087.94), + (379, -1072.45, -3031.47), + (380, -1528.67, -2813.19), + (381, -1991.9, -2611.16), + (382, -2453.94, -2409.84), + (383, -2958.63, -2445.22), + (384, -3371.61, -2160.19), + (385, -3781.92, -1866.89), + (386, -4182.17, -1562.34), + (387, -4544.64, -1215.86), + (388, -4872.74, -832.34), + (389, -4958.17, -335.03), + (390, -4985.72, 168.08), + (391, -5415.95, 424.07), + (392, -5882.54, 618.38), + (393, -6365.52, 773.47), + (394, -6865.48, 793.53), + (395, -7342.16, 642.04), + (396, -7807.27, 832.11), + (397, -8180.13, 489.32), + (398, -8665.16, 337.29), + (399, -9049.05, 11.32), + (400, -9404.12, -348.71), + (401, -9850.5, -576.14), + (402, -10258.81, -870.49), + (403, -10622.64, -1220.32), + (404, -10937.19, -1614.85), + (405, -11118.32, -2083.01), + (406, -11264.48, -2568.36), + (407, -11390.85, -3058.52), + (408, -11570.57, -3529.02), + (409, -11116.35, -3747.16), + (410, -11173.52, -4244.38), + (411, -11667.75, -4150.23), + (412, -12158.5, -4052.59), + (413, -12660.69, -3988.59), + (414, -13165.97, -3943.96), + (415, -13655.01, -3819.4), + (416, -14122.15, -4012.35), + (417, -14530.76, -4303.7), + (418, -14987.28, -4518.46), + (419, -15264.56, -4812.22), + (420, -15599.92, -4438.56), + (421, -16071.43, -4267.07), + (422, -16443.23, -4604.82), + (423, -16547.93, -5096.04), + (424, -16788.8, -5537.45), + (425, -17044, -5970.4), + (426, -17299.37, -6403.66), + (427, -17730.69, -6663.78), + (428, -18004.67, -7085.31), + (429, -18294.41, -7497.26), + (430, -18580.52, -7908.24), + (431, -18863.93, -8323.66), + (432, -19137.6, -8744.72), + (433, -19401.01, -9169.82), + (434, -19365.2, -9670.51), + (435, -19315.98, -10172.76), + (436, -19276.7, -10676.67), + (437, -19255.12, -11181.32), + (438, -19233.6, -11684.14), + (439, -18897.33, -12061.09), + (440, -18396.92, -12080.99), + (441, -17964.6, -11817.06), + (442, -17568.74, -11509.42), + (443, -17104.12, -11312.48), + (444, -16700.77, -11006.14), + (445, -16201.48, -10932.12), + (446, -15696.81, -10960.05), + (447, -15196.54, -11029.49), + (448, -14751.84, -11267.76), + (449, -14321.67, -11533.12), + (450, -13992.35, -11913.6), + (451, -13735.5, -12349.03), + (452, -13506.01, -12797.65), + (453, -13370.01, -13283.32), + (454, -13232.18, -13767.33), + (455, -13099.1, -14250.47), + (456, -13037.7, -14565.53), + (457, -12953.38, -15060.23), + (458, -12909.29, -15562.06), + (459, -12795.92, -16050.75), + (460, -12760.34, -16550.17), + (461, -12987.39, -17000.12), + (462, -12961.7, -17501.97), + (463, -12866.28, -17995.85), + (464, -12517.92, -18362.07), + # Hard turnaround checkpoint captured at Grelk. Keeping this outside + # the overlapping return leg prevents A-B-C-B-A resume skips. + (465, -11323.99, -18605.6), + (466, -12158.67, -18483.98), + (467, -12558.4, -18176.26), + (468, -12977.13, -17894.36), + (469, -12992.88, -17390.74), + (470, -12977.19, -16890.51), + (471, -12926.22, -16390.04), + (472, -12897.61, -15887.58), + (473, -12900.52, -15386.73), + (474, -12916.49, -14886.6), + (475, -12949.55, -14386.62), + (476, -13117.59, -13914.51), + (477, -13294.83, -13442.45), + (478, -13480.91, -12976.38), + (479, -13626.66, -12493.44), + (480, -13884.37, -12060.31), + (481, -14141.53, -11628.11), + (482, -14568.59, -11359.26), + (483, -15055.35, -11228.55), + (484, -15539.32, -11098.58), + (485, -16041.74, -11062.67), + (486, -16524.66, -11202.2), + (487, -16995.71, -11370.38), + (488, -17450.84, -11589.01), + (489, -17898.59, -11820.38), + (490, -18344.75, -12050.92), + (491, -18843.22, -11992.5), + (492, -19212.01, -11651.39), + (493, -19246.99, -11148.98), + (494, -19284.89, -10649.66), + (495, -19323.64, -10145.34), + (496, -19357.74, -9639.29), + (497, -19383.53, -9136.4), + (498, -19303.44, -8638.55), + (499, -19090.33, -8182.22), + (500, -18876.91, -7725.24), + (501, -18663.04, -7267.28), + (502, -18451.19, -6809.19), + (503, -18349.46, -6317.36), + (504, -18288.43, -5815.26), + (505, -18245.94, -5310.54), + (506, -18208.42, -4808.68), + (507, -18175.22, -4304.33), + (508, -18161.05, -3802.93), + (509, -18163.57, -3300.34), + (510, -18278.66, -2809.31), + (511, -18291.46, -2307.63), + (512, -17909.53, -1983.1), + (513, -17558.97, -1626.25), + (514, -17552.2, -1122.7), + (515, -17827.24, -704.38), + (516, -18301.12, -868.89), + (517, -18624.98, -480.92), + (518, -19032.11, -777.37), + (519, -19243.37, -960.09), + (520, -19741.66, -1010.9), + ), + 637: ( + (522, -15722, 17794), + (523, -16222.05, 17835.07), + (524, -16402.23, 17848.92), + (538, -15858.78, 17962.85), + (539, -15361.25, 18062.96), + (540, -14859.03, 18100.87), + (541, -14353.2, 18108.44), + (542, -13849.45, 18068.92), + (543, -13355.97, 17953.61), + (544, -12997.47, 17603.33), + (545, -12573.15, 17330.69), + (546, -12160.95, 17040.68), + (547, -11739.61, 16755.38), + (548, -11340.9, 16442.24), + (549, -10905.74, 16188.05), + (550, -10468.49, 15940.3), + (551, -10272.99, 15470.79), + (552, -9931.13, 15089.84), + (553, -10063.11, 14601.92), + (554, -10292.07, 14148.97), + (555, -10411.97, 13658.05), + (556, -10555.5, 13170.45), + (557, -10656.16, 12679.59), + (558, -10728.55, 12177.85), + (559, -10838.95, 11684.52), + (560, -10973.41, 11200.82), + (561, -11162.86, 10737.4), + (562, -11075.5, 10245.01), + (563, -11332.5, 9808.71), + (564, -11669.69, 9439.28), + (565, -11983.94, 9050.01), + (566, -12284.62, 8650.08), + (567, -12575.45, 8241.99), + (568, -12876.52, 7837.02), + (569, -13198.82, 7452.57), + (570, -13532, 7078.57), + (571, -13909.21, 6746.61), + (572, -14334.34, 6479.03), + (573, -14771.41, 6220.98), + (574, -15211.28, 5972.19), + (575, -15610.57, 5664.82), + (576, -16063.37, 5440.92), + (577, -16192.74, 5469.67), + (578, -16531.08, 5844.14), + (579, -16790.98, 6271.86), + (580, -16867.7, 6772.41), + (581, -16848.82, 7274.58), + (582, -16578.25, 7696.26), + (583, -16348.42, 8146.76), + (584, -16174.93, 8619.64), + (585, -16011.02, 9094.35), + (586, -16154.09, 9577.38), + (587, -16279.81, 10068.04), + (588, -16044.08, 9625.77), + (589, -16032.37, 10125.78), + (590, -16015.87, 10628.8), + (591, -16349.2, 11005.62), + (592, -16736.71, 11325.29), + (593, -16804.47, 11386.86), + ), +} + +MARKERS = { + 1007: {"kind": "blessing", "name": "Norn blessing outside Longeyes Ledge"}, + 1: {"kind": "quest_npc", "name": "Einarr Frostcleft / accept Cold Vengeance"}, + 29: {"kind": "beacon", "name": "Level 1 entrance Beacon of Droknar"}, + 81: {"kind": "gadget", "name": "Dwarven Powder Keg Station"}, + 92: {"kind": "beacon", "name": "Level 1 Beacon of Droknar"}, + 94: {"kind": "drop_bundle", "name": "Drop Powder Keg"}, + 112: {"kind": "objective", "name": "Defeat Havok-kin and collect the key"}, + 127: {"kind": "gadget", "name": "Dwarven Powder Keg Station"}, + 130: {"kind": "drop_bundle", "name": "Drop Powder Keg"}, + 134: {"kind": "objective", "name": "Defeat Havok-kin"}, + 136: {"kind": "gadget", "name": "Level 1 boss lock"}, + 141: {"kind": "beacon", "name": "Level 1 Beacon of Droknar"}, + 160: {"kind": "bridge", "name": "First possible bridge"}, + 177: {"kind": "bridge", "name": "Alternate bridge"}, + 192: {"kind": "beacon", "name": "Level 1 Beacon of Droknar"}, + 207: {"kind": "gadget", "name": "Lever"}, + 227: {"kind": "objective", "name": "Collect the dungeon key"}, + 258: {"kind": "gadget", "name": "Level 1 dungeon lock"}, + 284: {"kind": "beacon", "name": "Level 2 Beacon of Droknar"}, + 318: {"kind": "waypoint", "name": "Clean route resumes after removed resurrection detour"}, + 354: {"kind": "beacon", "name": "Level 2 Beacon of Droknar"}, + 419: {"kind": "beacon", "name": "Level 2 Beacon of Droknar"}, + 456: {"kind": "beacon", "name": "Level 2 Beacon of Droknar"}, + 465: {"kind": "objective", "name": "Defeat Grelk Icelash and collect the key"}, + 519: {"kind": "gadget", "name": "Level 2 boss lock"}, + 524: {"kind": "beacon", "name": "Level 3 entrance Beacon of Droknar"}, + 577: {"kind": "beacon", "name": "Level 3 Beacon of Droknar"}, + 593: {"kind": "chest", "name": "Havok's Chest"}, + 1160: {"kind": "beacon", "name": "Level 1 alternate-bridge Beacon of Droknar"}, +} + +REMOVED_CAPTURE_RANGES = { + 636: ((313, 317, "party resurrection detour"),), + 637: ((525, 537, "death and entrance teleport loop"),), +} + +APPROACH_SOURCE_ROUTE_ID = "ROUTE-20260806-221011-047" + +LONGEYES_OUTPOST_POINTS = ( + (0, -25008.81, 15525.25), + (1, -25455.42, 15752.02), + (2, -25922.67, 15940.93), + (3, -26374.67, 16154.75), +) + +BJORA_ENTRY_TO_BLESSING = ( + (17810.0, -17649.0), + (17813.29, -18150.37), + (17838.55, -18278.86), +) + +YAVB_BLESSING_TO_CAPTURE_JOIN = ( + (17810, -17649), + (17516, -17270), + (17166, -16813), + (16862, -16324), + (16472, -15934), + (15929, -15731), + (15387, -15521), + (14849, -15312), + (14311, -15101), + (13776, -14882), + (13249, -14642), + (12729, -14386), + (12235, -14086), + (11748, -13776), + (11274, -13450), + (10839, -13065), + (10572, -12590), + (10412, -12036), + (10238, -11485), + (10125, -10918), + (10029, -10348), + (9909, -9778), + (9599, -9327), + (9121, -9009), + (8674, -8645), + (8215, -8289), + (7755, -7945), + (7339, -7542), + (6962, -7103), + (6587, -6666), + (6210, -6226), + (5834, -5788), + (5457, -5349), + (5081, -4911), + (4703, -4470), + (4379, -3990), + (4063, -3507), + (3773, -3031), + (3452, -2540), + (3117, -2070), + (2678, -1703), + (2115, -1593), + (1541, -1614), + (960, -1563), + (388, -1491), +) + +CAPTURE_JOIN_XY = (464.49, -1019.08) + +CAPTURE_JOIN_TO_EINARR_POINTS = ( + (69, -11.89, -859.51), + (70, -449.11, -604.58), + (71, -878.59, -337.08), + (72, -1295.7, -50.38), + (73, -1616.56, 338.43), + (74, -1981.29, 680.89), + (75, -2456.14, 838.24), + (76, -2959.21, 827.18), + (77, -3460.1, 772.78), + (78, -3946.27, 904.41), + (79, -4379.89, 1162.86), + (80, -4824.2, 1393.71), + (81, -5280.1, 1602.53), + (82, -5704.43, 1869.4), + (83, -5738.33, 2372.58), + (84, -6066.77, 2750.59), + (85, -6351.81, 3166.88), + (86, -6615.2, 3592.16), + (87, -6909.96, 4002.45), + (88, -7196.72, 4416.34), + (89, -7484.95, 4830.15), + (90, -7802.36, 5217.79), + (91, -8120.8, 5606.7), + (92, -8530.84, 5318.14), + (93, -9023.64, 5208.9), + (94, -9512.17, 5078.13), + (95, -10003.63, 4979.05), + (96, -10502.44, 4908.88), + (97, -11005.88, 4930.49), + (98, -11505.73, 4996.31), + (99, -11985.29, 5158.74), + (100, -12442.77, 5370.98), + (101, -12822.09, 5704.64), + (102, -13169.31, 6070.19), + (103, -13491.93, 6456.65), + (104, -13773.33, 6876.13), + (105, -13885.44, 7366.34), + (106, -13910.09, 7869.55), + (107, -13588.09, 8257.05), + (108, -13842.8, 7825.75), + (109, -13626.52, 8279.67), + (110, -13245.12, 8612.32), + (111, -12945.61, 9013.23), + (112, -12758.81, 9482.56), + (113, -12670.44, 9976.29), + (114, -12537.64, 10460.31), + (115, -12459.23, 10954.26), + (116, -12514.21, 11458.52), + (117, -12668.08, 11935.51), + (118, -12862, 12401.33), + (119, -13089.77, 12847.28), + (120, -13422.84, 13225.46), + (121, -13668.51, 13682.62), + (122, -13926.08, 14115.22), + (123, -14420.25, 14015.78), + (124, -14917.37, 13926.4), + (125, -15412.21, 13828.47), + (126, -15668.46, 13787.23), +) + +LEVEL_1_LEVER_SAFE_WAIT = { + "xy": (-13256.14, -2042.54), + "wait_ms": 15_000, + "capture_id": "NPC-20260806-224917-468", +} + +LEVEL_1_LEVER_HERO_SAFE_FLAG = { + "xy": (-12795.29, -1712.53), + "capture_id": "NPC-20260807-154351-189", + "notes": "Safe Spot", +} + +LEVEL_1_HERO_REGROUP = { + "xy": (-8250.15, -2386.21), + "radius": 350.0, + "timeout_ms": 20_000, + "capture_id": "NPC-20260807-122219-412", +} + +LEVEL_1_KEY_FLAG = { + "xy": (-5488.69, -2881.93), + "capture_id": "NPC-20260807-142434-178", + "notes": "Rez Flag Location", +} + +LEVEL_1_KEY_LOCATION = { + "xy": (-5032.16, -2603.14), + "capture_id": "NPC-20260807-122128-904", +} + +LEVEL_1_HAVOK_KEY_KILL = { + "xy": (-1493.77, -3060.69), + "capture_id": "NPC-20260807-141426-562", + "notes": "Make sure kill guy for key here", +} + +LEVEL_1_OTHER_BRIDGE_SOURCE_ROUTE_ID = "ROUTE-20260807-142003-458" +LEVEL_1_OTHER_BRIDGE_BLESSING_CAPTURE_ID = "NPC-20260807-142035-173" +LEVEL_1_OTHER_BRIDGE_BLESSING_INDEX = 12 +LEVEL_1_OTHER_BRIDGE = ( + (-4768.79, 11739.41), + (-5087.36, 12134.74), + (-5454.17, 12486.81), + (-5833.51, 12821.49), + (-6234.14, 13136.18), + (-6632.31, 13447.78), + (-7033.65, 13751.45), + (-7442.51, 14052.97), + (-7848.9, 14354.43), + (-8255.51, 14651.59), + (-8717.0, 14857.39), + (-9210.68, 14947.59), + (-9178.22, 14943.58), + (-9421.42, 15380.6), + (-9826.13, 15685.33), + (-10289.64, 15898.29), + (-10797.19, 15924.64), + (-11286.53, 15804.12), + (-11566.32, 15386.36), + (-11476.42, 14888.62), + (-11450.2, 14386.97), + (-11463.52, 13883.91), + (-11488.96, 13379.58), + (-11503.9, 12873.64), + (-11468.42, 12372.89), + (-11428.0, 11874.15), + (-11410.69, 11364.27), + (-11407.25, 10861.83), + (-11389.13, 10356.03), + (-11362.94, 9851.89), + (-11469.32, 10345.86), + (-11238.36, 9894.21), + (-11321.86, 9399.5), + (-11276.12, 8897.58), + (-11204.86, 8394.56), + (-11160.69, 7891.41), + (-11139.44, 7389.52), + (-11281.54, 6906.28), + (-11477.19, 6441.32), + (-11722.79, 5999.67), + (-12018.81, 5593.01), + (-12323.28, 5189.64), + (-12626.02, 4785.45), + (-12807.35, 4695.39), +) + +LEVEL_2_BOSS_LOCK_TO_DOOR_SOURCE_ROUTE_ID = "ROUTE-20260807-124835-082" +LEVEL_2_BOSS_LOCK_TO_DOOR = ( + (-19273.02, -1004.83), + (-19692.08, -728.73), + (-19777.63, -763.18), +) + +LEVEL_2_PENDULUM_TRAP_ROOM = { + "xy": (9342.78, 14968.73), + "capture_id": "NPC-20260807-235923-005", + "notes": "Pendulum Trap Room", +} + +LEVEL_2_MAP_ROOM = { + "xy": (7579.2, 4086.43), + "capture_id": "NPC-20260808-000005-458", + "notes": "Map Room", +} + +LEVEL_2_SAFE_ROUTE_SOURCE_ROUTE_ID = "ROUTE-20260807-154700-596" +LEVEL_2_SAFE_ROUTE = ( + (17974.94, 13902.38), + (17484.45, 13782.48), + (17044.53, 13493.87), + (16631.64, 13205.35), + (16127.85, 13223.78), + (15820.26, 13621.66), + (15732.81, 14050.86), +) + +SNOWBALL_DODGE_SOURCE_ROUTE_ID = "ROUTE-20260807-120413-664" +SNOWBALL_DODGE_2 = ( + (15167.34, 16646.4), + (14694.09, 16485.0), + (14210.97, 16640.29), + (13715.37, 16706.94), + (13224.89, 16584.7), + (12736.48, 16461.56), + (12241.95, 16569.82), + (11904.02, 16945.49), + (11739.43, 17420.25), + (11500.83, 17866.48), + (11159.79, 18239.35), + (10702.04, 18453.59), + (10206.36, 18379.34), + (9833.82, 18044.07), + (9445.4, 17728.35), + (9334.16, 17637.37), + (8967.43, 17337.43), +)