diff --git a/Py4GWCoreLib/Dialog.py b/Py4GWCoreLib/Dialog.py new file mode 100644 index 000000000..10663038b --- /dev/null +++ b/Py4GWCoreLib/Dialog.py @@ -0,0 +1,3598 @@ +""" +Core Dialog wrapper for the native PyDialog C++ module. +This module provides dialog access helpers for use by widgets or scripts. + +Layering in this module: +1. Live/native state via `PyDialog` (`get_active_dialog`, buttons, callback journal). +2. Static dialog metadata and decoded text via `DialogCatalog` when available. +3. Optional SQLite-backed history via the integrated dialog step pipeline. +4. Thin module-level wrapper functions at the bottom for ergonomic imports. + +When changing behavior, keep those responsibilities separate. Most regressions here come +from mixing live UI state, static catalog lookups, and persisted history in the same path. +""" + +from __future__ import annotations + +import hashlib +import importlib +import json +import os +import re +import sqlite3 +import threading +import time +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple + + +def _import_optional_attr(relative_module: str, absolute_module: str, attr_name: str) -> Any: + if __package__: + try: + module = importlib.import_module(relative_module, __package__) + return getattr(module, attr_name) + except Exception: + pass + try: + module = importlib.import_module(absolute_module) + return getattr(module, attr_name) + except Exception: + return None + + +def _safe_call(default: Any, callback: Callable[[], Any]) -> Any: + try: + return callback() + except Exception: + return default + + +def _call_native_dialog_method(method_name: str, default: Any, *args: Any, **kwargs: Any) -> Any: + if PyDialog is None: + return default + method = getattr(PyDialog.PyDialog, method_name, None) + if not callable(method): + return default + return _safe_call(default, lambda: method(*args, **kwargs)) + + +try: + import PyDialog +except Exception: # pragma: no cover - runtime environment specific + PyDialog = None + + +# Text sanitation helpers. +def _get_dialog_catalog_widget(): + factory = _import_optional_attr( + ".DialogCatalog", + "DialogCatalog", + "get_dialog_catalog_widget", + ) + if not callable(factory): + return None + return _safe_call(None, factory) + + +_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]") +_COLOR_TAG_RE = re.compile(r"]*)?>", re.IGNORECASE) +_GENERIC_TAG_RE = re.compile(r"]*)?>") +_LBRACKET_TOKEN_RE = re.compile(r"\[lbracket\]", re.IGNORECASE) +_RBRACKET_TOKEN_RE = re.compile(r"\[rbracket\]", re.IGNORECASE) +_ORPHAN_BREAK_TOKEN_RE = re.compile(r"(?]+)>(.*?)", re.IGNORECASE | re.DOTALL) +_SENTINEL_CANONICAL = { + "": "", + "": "", + "": "", + "": "", +} +_SENTINEL_RE = re.compile( + "|".join(re.escape(token) for token in _SENTINEL_CANONICAL.keys()), + re.IGNORECASE, +) + + +def _protect_sentinel_placeholders(text: str) -> tuple[str, dict[str, str]]: + protected: dict[str, str] = {} + + def _replace(match: re.Match[str]) -> str: + placeholder = f"__PY4GW_SENTINEL_{len(protected)}__" + canonical = _SENTINEL_CANONICAL.get(match.group(0).lower(), match.group(0)) + protected[placeholder] = canonical + return placeholder + + return _SENTINEL_RE.sub(_replace, text), protected + + +def _sanitize_dialog_text(value: Optional[str]) -> str: + """ + Normalize Guild Wars dialog text into a stable display/query form. + + This removes control characters and markup noise while preserving the + project-specific sentinel placeholders used by the dialog monitor. + """ + if not value: + return "" + text = str(value) + # Preserve project-specific sentinel placeholders before stripping generic markup so callers + # can still distinguish "empty" / "decoding" states after sanitation. + text, protected_sentinels = _protect_sentinel_placeholders(text) + text = text.replace("\r\n", "\n").replace("\r", "\n") + text = _LBRACKET_TOKEN_RE.sub("[", text) + text = _RBRACKET_TOKEN_RE.sub("]", text) + text = _COLOR_TAG_RE.sub("", text) + text = _GENERIC_TAG_RE.sub("", text) + # Some decoded GW strings leak markup tokens as plain words (e.g. "p", "brx"). + text = _ORPHAN_BREAK_TOKEN_RE.sub(" ", text) + text = _ORPHAN_PARAGRAPH_TOKEN_RE.sub(" ", text) + for placeholder, canonical in protected_sentinels.items(): + text = text.replace(placeholder, canonical) + # Repair collapsed separators caused by removed formatting tags. + text = _MISSING_SPACE_AFTER_PUNCT_RE.sub(r"\1 \2", text) + text = _MISSING_SPACE_ALPHA_NUM_RE.sub(r"\1 \2", text) + text = _MISSING_SPACE_NUM_ALPHA_RE.sub(r"\1 \2", text) + text = _MISSING_SPACE_CAMEL_RE.sub(r"\1 \2", text) + text = _CONTROL_CHARS_RE.sub("", text) + text = _MULTI_SPACE_RE.sub(" ", text) + text = "\n".join(line.strip() for line in text.split("\n")) + text = _MULTI_NEWLINE_RE.sub("\n\n", text) + return text.strip() + + +def sanitize_dialog_text(value: Optional[str]) -> str: + """Public sanitizer for any GW dialog-related text.""" + return _sanitize_dialog_text(value) + + +def _normalize_dialog_choice_text(value: Optional[str]) -> str: + return " ".join(_sanitize_dialog_text(value).strip().lower().split()) + + +def _get_dialog_button_label(button: Any) -> str: + if button is None: + return "" + decoded = getattr(button, "message_decoded", "") + if decoded: + return _sanitize_dialog_text(decoded) + return _sanitize_dialog_text(getattr(button, "message", "")) + + +def _append_unique_dialog_choice_text(values: List[str], value: Optional[str]) -> None: + text = _sanitize_dialog_text(value) + if text and text not in values: + values.append(text) + + +def _coerce_native_list(value: Any) -> List[Any]: + """ + Normalize pybind/native list-like return values into a concrete Python list. + + This keeps runtime behavior defensive and gives static type checkers a stable + iterable type for dynamic `getattr`-based native access paths. + """ + if value is None: + return [] + if isinstance(value, list): + return value + try: + return list(value) + except TypeError: + return [] + + +def _build_active_dialog_npc_filters(active_dialog: Optional["ActiveDialogInfo"]) -> Dict[str, Any]: + """ + Build the current NPC instance/archetype filters for persisted history queries. + + These filters keep history-based dialog matching scoped to the live NPC so + reused dialog ids from other actors do not bleed into the current screen. + """ + if active_dialog is None: + return {} + + agent_id = int(getattr(active_dialog, "agent_id", 0) or 0) + if agent_id <= 0: + return {} + + map_id = 0 + model_id = 0 + + try: + from .Map import Map + except Exception: + try: + from Map import Map # type: ignore + except Exception: + Map = None # type: ignore + + try: + from .Agent import Agent + except Exception: + try: + from Agent import Agent # type: ignore + except Exception: + Agent = None # type: ignore + + if Map is not None: + try: + map_id = int(Map.GetMapID() or 0) + except Exception: + map_id = 0 + + if Agent is not None: + try: + model_id = int(Agent.GetModelID(agent_id) or 0) + except Exception: + model_id = 0 + + if map_id <= 0 or model_id <= 0: + return {} + + # History lookups must stay scoped to the live NPC instance/archetype. Dialog ids are reused + # broadly enough that cross-NPC history can otherwise relabel the current visible buttons. + npc_uid_archetype = f"{map_id}:{model_id}" + return { + "npc_uid_instance": f"{npc_uid_archetype}:{agent_id}", + "npc_uid_archetype": npc_uid_archetype, + } + + +DEFAULT_DB_RELATIVE_PATH = os.path.join("Widgets", "Data", "Dialog", "dialog_journal.sqlite3") +DEFAULT_QUERY_LIMIT = 200 +DEFAULT_TIMEOUT_MS = 8000 +MAX_SEEN_EVENT_KEYS = 4096 + + +def _to_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except Exception: + return default + + +def _safe_text(value: Any) -> str: + if value is None: + return "" + return str(value) + + +def _event_field(event: Any, name: str, index: int, default: Any) -> Any: + if isinstance(event, dict): + return event.get(name, default) + if hasattr(event, name): + return getattr(event, name) + if isinstance(event, (tuple, list)) and len(event) > index: + return event[index] + return default + + +def _event_bytes_hex(event: Any, name: str, index: int) -> str: + data = _event_field(event, name, index, []) + if data is None: + return "" + try: + return "".join(f"{int(byte) & 0xFF:02x}" for byte in data) + except Exception: + return "" + + +def _event_bytes_list(event: Any, name: str, index: int) -> List[int]: + data = _event_field(event, name, index, []) + if data is None: + return [] + if isinstance(data, str): + text = data.strip().replace(" ", "") + if not text: + return [] + if len(text) % 2 != 0: + text = "0" + text + try: + return [int(text[i : i + 2], 16) for i in range(0, len(text), 2)] + except Exception: + return [] + try: + return [int(x) & 0xFF for x in data] + except Exception: + return [] + + +def _u32_at(data: Sequence[int], offset: int) -> int: + if len(data) < (offset + 4): + return 0 + return ( + (int(data[offset]) & 0xFF) + | ((int(data[offset + 1]) & 0xFF) << 8) + | ((int(data[offset + 2]) & 0xFF) << 16) + | ((int(data[offset + 3]) & 0xFF) << 24) + ) + + +def _dialog_raw_hints(message_id: int, w_bytes: Sequence[int]) -> Tuple[int, int, str]: + # Restep: (dialog_id, agent_id, event_type) + if message_id == 0x100000A3: # kDialogButton + return _u32_at(w_bytes, 8), 0, "recv_choice_raw" + if message_id == 0x100000A6: # kDialogBody + return 0, _u32_at(w_bytes, 4), "recv_body_raw" + if message_id in (0x30000014, 0x30000015): # kSendAgentDialog / kSendGadgetDialog + return _u32_at(w_bytes, 0), 0, "sent_choice_raw" + return 0, 0, "" + + +def _normalize_direction_filter(direction: Optional[str]) -> Optional[bool]: + if direction is None: + return None + value = str(direction).strip().lower() + if not value or value in {"all", "both", "*"}: + return None + if value in {"recv", "received", "incoming", "in"}: + return True + if value in {"sent", "outgoing", "out"}: + return False + raise ValueError(f"Unsupported direction filter: {direction}") + + +def _parse_message_type_filter(message_type: Optional[Any]) -> Tuple[Optional[int], Optional[str]]: + if message_type is None: + return None, None + if isinstance(message_type, bool): + raise TypeError("message_type must be int or str, not bool") + if isinstance(message_type, int): + return int(message_type), None + value = str(message_type).strip() + if not value: + return None, None + try: + return int(value, 0), None + except ValueError: + return None, value.lower() + + +def _normalize_npc_uid_filter(npc_uid: Optional[str]) -> Optional[str]: + if npc_uid is None: + return None + value = str(npc_uid).strip() + return value if value else None + + +def _sha1_key(payload: str) -> str: + return hashlib.sha1(payload.encode("utf-8", errors="replace")).hexdigest() + + +def _resolve_project_root() -> str: + try: + import Py4GW + + getter = getattr(Py4GW.Console, "get_projects_path", None) + if callable(getter): + root = getter() + if root: + return os.path.abspath(root) + except Exception: + pass + return os.getcwd() + + +def _build_npc_uid(map_id: int, model_id: int, agent_id: int) -> str: + if not agent_id: + return "" + return f"{int(map_id)}:{int(model_id)}:{int(agent_id)}" + + +def _build_npc_archetype_uid(map_id: int, model_id: int) -> str: + return f"{int(map_id)}:{int(model_id)}" + + +def _resolve_map_name(map_id: int) -> str: + resolved_map_id = int(map_id or 0) + if resolved_map_id <= 0: + return "" + try: + from .Map import Map + except Exception: + try: + from Map import Map # type: ignore + except Exception: + return "" + try: + name = _safe_text(Map.GetMapName(resolved_map_id)).strip() + except Exception: + return "" + if not name or name == "Unknown Map ID": + return "" + return name + + +def _resolve_current_map_id() -> int: + try: + from .Map import Map + except Exception: + try: + from Map import Map # type: ignore + except Exception: + return 0 + try: + return int(Map.GetMapID() or 0) + except Exception: + return 0 + + +def _resolve_model_id(agent_id: int) -> int: + resolved_agent_id = int(agent_id or 0) + if resolved_agent_id <= 0: + return 0 + try: + from .Agent import Agent + except Exception: + try: + from Agent import Agent # type: ignore + except Exception: + return 0 + try: + return int(Agent.GetModelID(resolved_agent_id) or 0) + except Exception: + return 0 + + +def _resolve_npc_name(agent_id: int) -> str: + resolved_agent_id = int(agent_id or 0) + if resolved_agent_id <= 0: + return "" + try: + from .Agent import Agent + except Exception: + try: + from Agent import Agent # type: ignore + except Exception: + return "" + try: + return _safe_text(Agent.GetNameByID(resolved_agent_id)).strip() + except Exception: + return "" + + +class DialogStepSQLitePipeline: + def __init__(self) -> None: + self._lock = threading.RLock() + self._conn: Optional[sqlite3.Connection] = None + self._db_path: Optional[str] = None + self._step_timeout_ms = DEFAULT_TIMEOUT_MS + self._pending_steps: Dict[str, Dict[str, Any]] = {} + self._body_text_to_dialog_id: Dict[str, int] = {} + self._dialog_id_to_body_text: Dict[int, Tuple[str, str]] = {} + self._seen_keys: set[str] = set() + self._seen_order: List[str] = [] + + def configure( + self, + *, + db_path: Optional[str] = None, + step_timeout_ms: Optional[int] = None, + ) -> str: + with self._lock: + if step_timeout_ms is not None and int(step_timeout_ms) > 0: + self._step_timeout_ms = int(step_timeout_ms) + if db_path: + resolved = os.path.abspath(str(db_path)) + if self._conn is not None and self._db_path and resolved != self._db_path: + self._conn.close() + self._conn = None + self._db_path = resolved + self._ensure_connection() + return self._db_path or "" + + def get_db_path(self) -> str: + with self._lock: + self._ensure_connection() + return self._db_path or "" + + def sync( + self, + *, + raw_events: Optional[Sequence[Any]] = None, + callback_journal: Optional[Sequence[Any]] = None, + ) -> Dict[str, int]: + with self._lock: + conn = self._ensure_connection() + inserted_raw = 0 + inserted_journal = 0 + finalized_steps = 0 + latest_tick = 0 + with conn: + if raw_events: + inserted_raw = self._insert_raw_callbacks(conn, raw_events) + if callback_journal: + inserted_journal, finalized_steps, latest_tick = self._insert_callback_journal(conn, callback_journal) + if latest_tick: + finalized_steps += self._finalize_stale_steps(conn, latest_tick, current_map_id=0) + self._repair_persisted_step_rows(conn) + self._backfill_display_names(conn) + return { + "raw_inserted": inserted_raw, + "journal_inserted": inserted_journal, + "steps_finalized": finalized_steps, + } + + def flush_pending(self) -> int: + with self._lock: + conn = self._ensure_connection() + finalized = 0 + with conn: + keys = list(self._pending_steps.keys()) + for npc_uid in keys: + if self._finalize_step(conn, npc_uid, reason="flush", end_tick=0): + finalized += 1 + return finalized + + def get_raw_callbacks( + self, + *, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = DEFAULT_QUERY_LIMIT, + offset: int = 0, + ) -> List[Dict[str, Any]]: + incoming_filter = _normalize_direction_filter(direction) + message_id_filter, event_type_filter = _parse_message_type_filter(message_type) + limit = max(1, int(limit)) + offset = max(0, int(offset)) + + where: List[str] = [] + params: List[Any] = [] + if incoming_filter is not None: + where.append("incoming = ?") + params.append(1 if incoming_filter else 0) + if message_id_filter is not None: + where.append("message_id = ?") + params.append(message_id_filter) + if event_type_filter: + where.append("LOWER(event_type) = ?") + params.append(event_type_filter) + + sql = ( + "SELECT id, tick, ts, message_id, incoming, map_id, map_name, agent_id, npc_name, model_id, npc_uid, " + "dialog_id, context_dialog_id, event_type, text_raw FROM raw_callbacks" + ) + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY id DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + with self._lock: + conn = self._ensure_connection() + rows = conn.execute(sql, params).fetchall() + return [self._raw_row_to_dict(row) for row in rows] + + def clear_raw_callbacks( + self, + *, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + ) -> int: + incoming_filter = _normalize_direction_filter(direction) + message_id_filter, event_type_filter = _parse_message_type_filter(message_type) + + where: List[str] = [] + params: List[Any] = [] + if incoming_filter is not None: + where.append("incoming = ?") + params.append(1 if incoming_filter else 0) + if message_id_filter is not None: + where.append("message_id = ?") + params.append(message_id_filter) + if event_type_filter: + where.append("LOWER(event_type) = ?") + params.append(event_type_filter) + + sql = "DELETE FROM raw_callbacks" + if where: + sql += " WHERE " + " AND ".join(where) + + with self._lock: + conn = self._ensure_connection() + with conn: + cursor = conn.execute(sql, params) + return int(cursor.rowcount or 0) + + def get_callback_journal( + self, + *, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = DEFAULT_QUERY_LIMIT, + offset: int = 0, + ) -> List[Dict[str, Any]]: + incoming_filter = _normalize_direction_filter(direction) + message_id_filter, event_type_filter = _parse_message_type_filter(message_type) + npc_uid_filter = _normalize_npc_uid_filter(npc_uid) + limit = max(1, int(limit)) + offset = max(0, int(offset)) + + where: List[str] = [] + params: List[Any] = [] + if npc_uid_filter: + where.append("npc_uid = ?") + params.append(npc_uid_filter) + if incoming_filter is not None: + where.append("incoming = ?") + params.append(1 if incoming_filter else 0) + if message_id_filter is not None: + where.append("message_id = ?") + params.append(message_id_filter) + if event_type_filter: + where.append("LOWER(event_type) = ?") + params.append(event_type_filter) + + sql = ( + "SELECT id, tick, ts, message_id, incoming, dialog_id, context_dialog_id, " + "agent_id, map_id, map_name, model_id, npc_uid, npc_name, event_type, text_raw, text_decoded " + "FROM callback_journal" + ) + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY id DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + with self._lock: + conn = self._ensure_connection() + rows = conn.execute(sql, params).fetchall() + return [self._callback_row_to_dict(row) for row in rows] + + def clear_callback_journal( + self, + *, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + ) -> int: + incoming_filter = _normalize_direction_filter(direction) + message_id_filter, event_type_filter = _parse_message_type_filter(message_type) + npc_uid_filter = _normalize_npc_uid_filter(npc_uid) + + where: List[str] = [] + params: List[Any] = [] + if npc_uid_filter: + where.append("npc_uid = ?") + params.append(npc_uid_filter) + if incoming_filter is not None: + where.append("incoming = ?") + params.append(1 if incoming_filter else 0) + if message_id_filter is not None: + where.append("message_id = ?") + params.append(message_id_filter) + if event_type_filter: + where.append("LOWER(event_type) = ?") + params.append(event_type_filter) + + sql = "DELETE FROM callback_journal" + if where: + sql += " WHERE " + " AND ".join(where) + + with self._lock: + conn = self._ensure_connection() + with conn: + cursor = conn.execute(sql, params) + return int(cursor.rowcount or 0) + + def get_dialog_steps( + self, + *, + map_id: Optional[int] = None, + npc_uid_instance: Optional[str] = None, + npc_uid_archetype: Optional[str] = None, + body_dialog_id: Optional[int] = None, + choice_dialog_id: Optional[int] = None, + limit: int = DEFAULT_QUERY_LIMIT, + offset: int = 0, + include_choices: bool = True, + ) -> List[Dict[str, Any]]: + limit = max(1, int(limit)) + offset = max(0, int(offset)) + where: List[str] = [] + params: List[Any] = [] + + if map_id is not None: + where.append("t.map_id = ?") + params.append(int(map_id)) + if npc_uid_instance: + where.append("t.npc_uid_instance = ?") + params.append(str(npc_uid_instance)) + if npc_uid_archetype: + where.append("t.npc_uid_archetype = ?") + params.append(str(npc_uid_archetype)) + if body_dialog_id is not None: + where.append("t.body_dialog_id = ?") + params.append(int(body_dialog_id)) + if choice_dialog_id is not None: + where.append("EXISTS (SELECT 1 FROM dialog_choices c WHERE c.step_id = t.id AND c.choice_dialog_id = ?)") + params.append(int(choice_dialog_id)) + + sql = ( + "SELECT t.id, t.start_tick, t.end_tick, t.map_id, t.map_name, t.agent_id, t.npc_name, t.model_id, " + "t.npc_uid_instance, t.npc_uid_archetype, t.body_dialog_id, t.body_text_raw, " + "t.body_text_decoded, t.selected_dialog_id, t.selected_source_message_id, " + "t.finalized_reason, t.created_at FROM dialog_steps t" + ) + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY t.id DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + with self._lock: + conn = self._ensure_connection() + rows = conn.execute(sql, params).fetchall() + steps = [self._step_row_to_dict(row) for row in rows] + if not include_choices or not steps: + return steps + + step_ids = [step["id"] for step in steps] + choices_by_step = self._get_choices_by_step_ids(conn, step_ids) + for step in steps: + step["choices"] = choices_by_step.get(step["id"], []) + return steps + + def get_dialog_step(self, step_id: int, *, include_choices: bool = True) -> Optional[Dict[str, Any]]: + with self._lock: + conn = self._ensure_connection() + row = conn.execute( + "SELECT id, start_tick, end_tick, map_id, map_name, agent_id, npc_name, model_id, " + "npc_uid_instance, npc_uid_archetype, body_dialog_id, body_text_raw, " + "body_text_decoded, selected_dialog_id, selected_source_message_id, " + "finalized_reason, created_at FROM dialog_steps WHERE id = ?", + (int(step_id),), + ).fetchone() + if row is None: + return None + step = self._step_row_to_dict(row) + if include_choices: + step["choices"] = self.get_dialog_choices(int(step_id)) + return step + + def get_dialog_choices(self, step_id: int) -> List[Dict[str, Any]]: + with self._lock: + conn = self._ensure_connection() + rows = conn.execute( + "SELECT id, step_id, choice_index, choice_dialog_id, choice_text_raw, " + "choice_text_decoded, skill_id, button_icon, decode_pending, selected, source_message_id " + "FROM dialog_choices WHERE step_id = ? ORDER BY choice_index ASC, id ASC", + (int(step_id),), + ).fetchall() + return [self._choice_row_to_dict(row) for row in rows] + + def export_raw_callbacks_json( + self, + path: str, + *, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 10000, + offset: int = 0, + ) -> int: + entries = self.get_raw_callbacks( + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + ) + payload = { + "generated_at": time.time(), + "count": len(entries), + "filters": { + "direction": direction, + "message_type": message_type, + "limit": int(limit), + "offset": int(offset), + }, + "entries": entries, + } + self._write_json(path, payload) + return len(entries) + + def export_callback_journal_json( + self, + path: str, + *, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 10000, + offset: int = 0, + ) -> int: + entries = self.get_callback_journal( + npc_uid=npc_uid, + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + ) + payload = { + "generated_at": time.time(), + "count": len(entries), + "filters": { + "npc_uid": npc_uid, + "direction": direction, + "message_type": message_type, + "limit": int(limit), + "offset": int(offset), + }, + "entries": entries, + } + self._write_json(path, payload) + return len(entries) + + def export_dialog_steps_json( + self, + path: str, + *, + map_id: Optional[int] = None, + npc_uid_instance: Optional[str] = None, + npc_uid_archetype: Optional[str] = None, + body_dialog_id: Optional[int] = None, + choice_dialog_id: Optional[int] = None, + limit: int = 5000, + offset: int = 0, + ) -> int: + steps = self.get_dialog_steps( + map_id=map_id, + npc_uid_instance=npc_uid_instance, + npc_uid_archetype=npc_uid_archetype, + body_dialog_id=body_dialog_id, + choice_dialog_id=choice_dialog_id, + limit=limit, + offset=offset, + include_choices=True, + ) + payload = { + "generated_at": time.time(), + "count": len(steps), + "filters": { + "map_id": map_id, + "npc_uid_instance": npc_uid_instance, + "npc_uid_archetype": npc_uid_archetype, + "body_dialog_id": body_dialog_id, + "choice_dialog_id": choice_dialog_id, + "limit": int(limit), + "offset": int(offset), + }, + "steps": steps, + } + self._write_json(path, payload) + return len(steps) + + def prune_dialog_logs( + self, + *, + max_raw_rows: Optional[int] = None, + max_journal_rows: Optional[int] = None, + max_step_rows: Optional[int] = None, + older_than_days: Optional[float] = None, + ) -> Dict[str, int]: + removed_raw = 0 + removed_journal = 0 + removed_steps = 0 + removed_choices = 0 + + with self._lock: + conn = self._ensure_connection() + with conn: + if older_than_days is not None and float(older_than_days) > 0: + cutoff = float(time.time()) - float(older_than_days) * 86400.0 + removed_raw += int(conn.execute("DELETE FROM raw_callbacks WHERE ts < ?", (cutoff,)).rowcount or 0) + removed_journal += int(conn.execute("DELETE FROM callback_journal WHERE ts < ?", (cutoff,)).rowcount or 0) + old_step_rows = conn.execute( + "SELECT id FROM dialog_steps WHERE created_at < ? ORDER BY id ASC", + (cutoff,), + ).fetchall() + old_step_ids = [int(row[0]) for row in old_step_rows] + if old_step_ids: + removed_choices += self._delete_choices_for_step_ids(conn, old_step_ids) + removed_steps += int( + conn.execute( + f"DELETE FROM dialog_steps WHERE id IN ({','.join('?' for _ in old_step_ids)})", + old_step_ids, + ).rowcount + or 0 + ) + + if max_raw_rows is not None and int(max_raw_rows) >= 0: + removed_raw += self._trim_table(conn, "raw_callbacks", int(max_raw_rows)) + + if max_journal_rows is not None and int(max_journal_rows) >= 0: + removed_journal += self._trim_table(conn, "callback_journal", int(max_journal_rows)) + + if max_step_rows is not None and int(max_step_rows) >= 0: + overflow_ids = self._overflow_ids(conn, "dialog_steps", int(max_step_rows)) + if overflow_ids: + removed_choices += self._delete_choices_for_step_ids(conn, overflow_ids) + removed_steps += int( + conn.execute( + f"DELETE FROM dialog_steps WHERE id IN ({','.join('?' for _ in overflow_ids)})", + overflow_ids, + ).rowcount + or 0 + ) + + return { + "removed_raw_callbacks": removed_raw, + "removed_callback_journal": removed_journal, + "removed_dialog_steps": removed_steps, + "removed_dialog_choices": removed_choices, + } + + def _ensure_connection(self) -> sqlite3.Connection: + if self._conn is not None: + return self._conn + + if not self._db_path: + self._db_path = os.path.join(_resolve_project_root(), DEFAULT_DB_RELATIVE_PATH) + db_dir = os.path.dirname(self._db_path) + if db_dir: + os.makedirs(db_dir, exist_ok=True) + + conn = sqlite3.connect(self._db_path, timeout=5.0, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") + conn.execute("PRAGMA foreign_keys = ON") + self._create_schema(conn) + self._conn = conn + return conn + + def _create_schema(self, conn: sqlite3.Connection) -> None: + self._create_base_schema(conn) + self._migrate_legacy_step_schema(conn) + self._ensure_display_name_columns(conn) + self._create_current_step_schema(conn) + self._create_display_name_indexes(conn) + self._backfill_display_names(conn) + + def _create_base_schema(self, conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS raw_callbacks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL UNIQUE, + tick INTEGER NOT NULL, + ts REAL NOT NULL, + message_id INTEGER NOT NULL, + incoming INTEGER NOT NULL, + is_frame_message INTEGER NOT NULL DEFAULT 0, + frame_id INTEGER NOT NULL DEFAULT 0, + w_bytes_hex TEXT NOT NULL DEFAULT '', + l_bytes_hex TEXT NOT NULL DEFAULT '', + map_id INTEGER NOT NULL DEFAULT 0, + map_name TEXT NOT NULL DEFAULT '', + agent_id INTEGER NOT NULL DEFAULT 0, + npc_name TEXT NOT NULL DEFAULT '', + model_id INTEGER NOT NULL DEFAULT 0, + npc_uid TEXT NOT NULL DEFAULT '', + dialog_id INTEGER NOT NULL DEFAULT 0, + context_dialog_id INTEGER NOT NULL DEFAULT 0, + event_type TEXT NOT NULL DEFAULT '', + text_raw TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS callback_journal ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL UNIQUE, + tick INTEGER NOT NULL, + ts REAL NOT NULL, + message_id INTEGER NOT NULL, + incoming INTEGER NOT NULL, + dialog_id INTEGER NOT NULL DEFAULT 0, + context_dialog_id INTEGER NOT NULL DEFAULT 0, + agent_id INTEGER NOT NULL DEFAULT 0, + map_id INTEGER NOT NULL DEFAULT 0, + map_name TEXT NOT NULL DEFAULT '', + model_id INTEGER NOT NULL DEFAULT 0, + npc_uid TEXT NOT NULL DEFAULT '', + npc_name TEXT NOT NULL DEFAULT '', + event_type TEXT NOT NULL DEFAULT '', + text_raw TEXT NOT NULL DEFAULT '', + text_decoded TEXT NOT NULL DEFAULT '' + ); + + CREATE INDEX IF NOT EXISTS idx_raw_tick ON raw_callbacks(tick); + CREATE INDEX IF NOT EXISTS idx_raw_message ON raw_callbacks(message_id); + CREATE INDEX IF NOT EXISTS idx_raw_npc_uid ON raw_callbacks(npc_uid); + CREATE INDEX IF NOT EXISTS idx_raw_map_id ON raw_callbacks(map_id); + + CREATE INDEX IF NOT EXISTS idx_journal_tick ON callback_journal(tick); + CREATE INDEX IF NOT EXISTS idx_journal_message ON callback_journal(message_id); + CREATE INDEX IF NOT EXISTS idx_journal_npc_uid ON callback_journal(npc_uid); + CREATE INDEX IF NOT EXISTS idx_journal_event_type ON callback_journal(event_type); + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS dialog_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_tick INTEGER NOT NULL, + end_tick INTEGER NOT NULL DEFAULT 0, + map_id INTEGER NOT NULL DEFAULT 0, + map_name TEXT NOT NULL DEFAULT '', + agent_id INTEGER NOT NULL DEFAULT 0, + npc_name TEXT NOT NULL DEFAULT '', + model_id INTEGER NOT NULL DEFAULT 0, + npc_uid_instance TEXT NOT NULL DEFAULT '', + npc_uid_archetype TEXT NOT NULL DEFAULT '', + body_dialog_id INTEGER NOT NULL DEFAULT 0, + body_text_raw TEXT NOT NULL DEFAULT '', + body_text_decoded TEXT NOT NULL DEFAULT '', + selected_dialog_id INTEGER NOT NULL DEFAULT 0, + selected_source_message_id INTEGER NOT NULL DEFAULT 0, + finalized_reason TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL + ) + """ + ) + + def _create_current_step_schema(self, conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS dialog_choices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + step_id INTEGER NOT NULL, + choice_index INTEGER NOT NULL DEFAULT 0, + choice_dialog_id INTEGER NOT NULL DEFAULT 0, + choice_text_raw TEXT NOT NULL DEFAULT '', + choice_text_decoded TEXT NOT NULL DEFAULT '', + skill_id INTEGER NOT NULL DEFAULT 0, + button_icon INTEGER NOT NULL DEFAULT 0, + decode_pending INTEGER NOT NULL DEFAULT 0, + selected INTEGER NOT NULL DEFAULT 0, + source_message_id INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(step_id) REFERENCES dialog_steps(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_steps_map ON dialog_steps(map_id); + CREATE INDEX IF NOT EXISTS idx_steps_npc_instance ON dialog_steps(npc_uid_instance); + CREATE INDEX IF NOT EXISTS idx_steps_npc_archetype ON dialog_steps(npc_uid_archetype); + CREATE INDEX IF NOT EXISTS idx_steps_body_dialog_id ON dialog_steps(body_dialog_id); + CREATE INDEX IF NOT EXISTS idx_steps_created_at ON dialog_steps(created_at); + CREATE INDEX IF NOT EXISTS idx_steps_map_name ON dialog_steps(map_name); + CREATE INDEX IF NOT EXISTS idx_steps_npc_name ON dialog_steps(npc_name); + + CREATE INDEX IF NOT EXISTS idx_choices_step ON dialog_choices(step_id); + CREATE INDEX IF NOT EXISTS idx_choices_dialog_id ON dialog_choices(choice_dialog_id); + CREATE INDEX IF NOT EXISTS idx_choices_selected ON dialog_choices(selected); + """ + ) + + def _ensure_display_name_columns(self, conn: sqlite3.Connection) -> None: + self._ensure_column(conn, "raw_callbacks", "map_name", "TEXT NOT NULL DEFAULT ''") + self._ensure_column(conn, "raw_callbacks", "npc_name", "TEXT NOT NULL DEFAULT ''") + self._ensure_column(conn, "callback_journal", "map_name", "TEXT NOT NULL DEFAULT ''") + self._ensure_column(conn, "callback_journal", "npc_name", "TEXT NOT NULL DEFAULT ''") + self._ensure_column(conn, "dialog_steps", "map_name", "TEXT NOT NULL DEFAULT ''") + self._ensure_column(conn, "dialog_steps", "npc_name", "TEXT NOT NULL DEFAULT ''") + + def _ensure_column(self, conn: sqlite3.Connection, table_name: str, column_name: str, definition: str) -> None: + columns = {str(row[1]) for row in conn.execute(f"PRAGMA table_info({table_name})")} + if column_name in columns: + return + conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {definition}") + + def _backfill_display_names(self, conn: sqlite3.Connection) -> None: + self._backfill_table_display_names(conn, "raw_callbacks") + self._backfill_table_display_names(conn, "callback_journal") + self._backfill_table_display_names(conn, "dialog_steps") + + def _create_display_name_indexes(self, conn: sqlite3.Connection) -> None: + conn.execute("CREATE INDEX IF NOT EXISTS idx_raw_map_name ON raw_callbacks(map_name)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_raw_npc_name ON raw_callbacks(npc_name)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_journal_map_name ON callback_journal(map_name)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_journal_npc_name ON callback_journal(npc_name)") + + def _backfill_table_display_names(self, conn: sqlite3.Connection, table_name: str) -> None: + rows = conn.execute( + f""" + SELECT id, map_id, IFNULL(map_name, ''), agent_id, IFNULL(npc_name, '') + FROM {table_name} + WHERE IFNULL(map_name, '') = '' OR IFNULL(npc_name, '') = '' + """ + ).fetchall() + if not rows: + return + + map_cache: Dict[int, str] = {} + npc_cache: Dict[int, str] = {} + updates: List[Tuple[str, str, int]] = [] + for row_id, map_id, map_name, agent_id, npc_name in rows: + resolved_map_id = int(map_id or 0) + resolved_agent_id = int(agent_id or 0) + next_map_name = _safe_text(map_name) + next_npc_name = _safe_text(npc_name) + if not next_map_name and resolved_map_id > 0: + next_map_name = map_cache.setdefault(resolved_map_id, _resolve_map_name(resolved_map_id)) + if not next_npc_name and resolved_agent_id > 0: + next_npc_name = npc_cache.setdefault(resolved_agent_id, _resolve_npc_name(resolved_agent_id)) + if next_map_name != _safe_text(map_name) or next_npc_name != _safe_text(npc_name): + updates.append((next_map_name, next_npc_name, int(row_id))) + if updates: + conn.executemany( + f"UPDATE {table_name} SET map_name = ?, npc_name = ? WHERE id = ?", + updates, + ) + + def _migrate_legacy_step_schema(self, conn: sqlite3.Connection) -> None: + table_names = {str(row[0]) for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} + if "dialog_turns" not in table_names and "dialog_choices" not in table_names: + return + + conn.execute("PRAGMA foreign_keys = OFF") + try: + if "dialog_turns" in table_names: + conn.execute( + """ + INSERT OR IGNORE INTO dialog_steps ( + id, start_tick, end_tick, map_id, agent_id, model_id, + npc_uid_instance, npc_uid_archetype, body_dialog_id, + body_text_raw, body_text_decoded, selected_dialog_id, + selected_source_message_id, finalized_reason, created_at + ) + SELECT + id, start_tick, end_tick, map_id, agent_id, model_id, + npc_uid_instance, npc_uid_archetype, body_dialog_id, + body_text_raw, body_text_decoded, selected_dialog_id, + selected_source_message_id, finalized_reason, created_at + FROM dialog_turns + """ + ) + conn.execute("DROP TABLE dialog_turns") + + if "dialog_choices" in table_names: + columns = {str(row[1]) for row in conn.execute("PRAGMA table_info(dialog_choices)")} + if "turn_id" in columns and "step_id" not in columns: + conn.execute("DROP INDEX IF EXISTS idx_choices_turn") + conn.execute("DROP INDEX IF EXISTS idx_choices_step") + conn.execute("ALTER TABLE dialog_choices RENAME TO dialog_choices_legacy") + conn.execute( + """ + CREATE TABLE dialog_choices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + step_id INTEGER NOT NULL, + choice_index INTEGER NOT NULL DEFAULT 0, + choice_dialog_id INTEGER NOT NULL DEFAULT 0, + choice_text_raw TEXT NOT NULL DEFAULT '', + choice_text_decoded TEXT NOT NULL DEFAULT '', + skill_id INTEGER NOT NULL DEFAULT 0, + button_icon INTEGER NOT NULL DEFAULT 0, + decode_pending INTEGER NOT NULL DEFAULT 0, + selected INTEGER NOT NULL DEFAULT 0, + source_message_id INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(step_id) REFERENCES dialog_steps(id) ON DELETE CASCADE + ) + """ + ) + conn.execute( + """ + INSERT INTO dialog_choices ( + id, step_id, choice_index, choice_dialog_id, choice_text_raw, + choice_text_decoded, skill_id, button_icon, decode_pending, + selected, source_message_id + ) + SELECT + c.id, c.turn_id, c.choice_index, c.choice_dialog_id, c.choice_text_raw, + c.choice_text_decoded, c.skill_id, c.button_icon, c.decode_pending, + c.selected, c.source_message_id + FROM dialog_choices_legacy c + WHERE EXISTS ( + SELECT 1 + FROM dialog_steps s + WHERE s.id = c.turn_id + ) + """ + ) + conn.execute("DROP TABLE dialog_choices_legacy") + finally: + conn.execute("PRAGMA foreign_keys = ON") + + def _insert_raw_callbacks(self, conn: sqlite3.Connection, raw_events: Sequence[Any]) -> int: + inserted = 0 + for event in raw_events: + tick = _to_int(_event_field(event, "tick", 0, 0), 0) + message_id = _to_int(_event_field(event, "message_id", 1, 0), 0) + incoming = bool(_event_field(event, "incoming", 2, False)) + is_frame_message = bool(_event_field(event, "is_frame_message", 3, False)) + frame_id = _to_int(_event_field(event, "frame_id", 4, 0), 0) + w_bytes = _event_bytes_list(event, "w_bytes", 5) + w_bytes_hex = _event_bytes_hex(event, "w_bytes", 5) + l_bytes_hex = _event_bytes_hex(event, "l_bytes", 6) + dialog_id_hint, agent_id_hint, event_type_hint = _dialog_raw_hints(message_id, w_bytes) + map_id = _to_int(_event_field(event, "map_id", 7, 0), 0) + if map_id <= 0: + map_id = _resolve_current_map_id() + agent_id = _to_int(_event_field(event, "agent_id", 8, agent_id_hint), 0) or agent_id_hint + model_id = _to_int(_event_field(event, "model_id", 9, 0), 0) + if model_id <= 0 and agent_id > 0: + model_id = _resolve_model_id(agent_id) + npc_uid = _safe_text(_event_field(event, "npc_uid", 10, "")) + if not npc_uid: + npc_uid = _build_npc_uid(map_id, model_id, agent_id) + dialog_id = _to_int(_event_field(event, "dialog_id", 11, dialog_id_hint), 0) or dialog_id_hint + context_dialog_id = _to_int(_event_field(event, "context_dialog_id", 12, 0), 0) + event_type = _safe_text(_event_field(event, "event_type", 13, event_type_hint)).strip().lower() + if not event_type: + event_type = event_type_hint + text_raw = _safe_text(_event_field(event, "text_raw", 14, _event_field(event, "text", 15, ""))) + map_name = _safe_text(_event_field(event, "map_name", 16, "")).strip() or _resolve_map_name(map_id) + npc_name = _safe_text(_event_field(event, "npc_name", 17, "")).strip() or _resolve_npc_name(agent_id) + event_key = _sha1_key( + f"raw|{tick}|{message_id}|{1 if incoming else 0}|{1 if is_frame_message else 0}|" + f"{frame_id}|{w_bytes_hex}|{l_bytes_hex}" + ) + if self._key_seen(event_key): + continue + cursor = conn.execute( + """ + INSERT OR IGNORE INTO raw_callbacks ( + event_key, tick, ts, message_id, incoming, is_frame_message, frame_id, + w_bytes_hex, l_bytes_hex, map_id, map_name, agent_id, npc_name, model_id, npc_uid, + dialog_id, context_dialog_id, event_type, text_raw + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_key, + tick, + float(time.time()), + message_id, + 1 if incoming else 0, + 1 if is_frame_message else 0, + frame_id, + w_bytes_hex, + l_bytes_hex, + map_id, + map_name, + agent_id, + npc_name, + model_id, + npc_uid, + dialog_id, + context_dialog_id, + event_type, + text_raw, + ), + ) + if int(cursor.rowcount or 0) > 0: + inserted += 1 + self._remember_key(event_key) + return inserted + + def _insert_callback_journal( + self, + conn: sqlite3.Connection, + callback_journal: Sequence[Any], + ) -> Tuple[int, int, int]: + inserted = 0 + finalized = 0 + latest_tick = 0 + for event in callback_journal: + normalized = self._normalize_callback_event(event) + latest_tick = max(latest_tick, normalized["tick"]) + event_key = _sha1_key( + "journal|{tick}|{message_id}|{incoming}|{dialog_id}|{context_dialog_id}|" + "{agent_id}|{map_id}|{model_id}|{npc_uid}|{event_type}|{text_raw}".format( + tick=normalized["tick"], + message_id=normalized["message_id"], + incoming=1 if normalized["incoming"] else 0, + dialog_id=normalized["dialog_id"], + context_dialog_id=normalized["context_dialog_id"], + agent_id=normalized["agent_id"], + map_id=normalized["map_id"], + model_id=normalized["model_id"], + npc_uid=normalized["npc_uid"], + event_type=normalized["event_type"], + text_raw=normalized["text_raw"], + ) + ) + if self._key_seen(event_key): + continue + cursor = conn.execute( + """ + INSERT OR IGNORE INTO callback_journal ( + event_key, tick, ts, message_id, incoming, dialog_id, context_dialog_id, + agent_id, map_id, map_name, model_id, npc_uid, npc_name, event_type, text_raw, text_decoded + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_key, + normalized["tick"], + float(time.time()), + normalized["message_id"], + 1 if normalized["incoming"] else 0, + normalized["dialog_id"], + normalized["context_dialog_id"], + normalized["agent_id"], + normalized["map_id"], + normalized["map_name"], + normalized["model_id"], + normalized["npc_uid"], + normalized["npc_name"], + normalized["event_type"], + normalized["text_raw"], + normalized["text_decoded"], + ), + ) + if int(cursor.rowcount or 0) <= 0: + continue + inserted += 1 + self._remember_key(event_key) + finalized += self._process_step_event(conn, normalized) + return inserted, finalized, latest_tick + + def _normalize_callback_event(self, event: Any) -> Dict[str, Any]: + tick = _to_int(_event_field(event, "tick", 0, 0), 0) + message_id = _to_int(_event_field(event, "message_id", 1, 0), 0) + incoming = bool(_event_field(event, "incoming", 2, False)) + dialog_id = _to_int(_event_field(event, "dialog_id", 3, 0), 0) + context_dialog_id = _to_int(_event_field(event, "context_dialog_id", 4, 0), 0) + agent_id = _to_int(_event_field(event, "agent_id", 5, 0), 0) + map_id = _to_int(_event_field(event, "map_id", 6, 0), 0) + model_id = _to_int(_event_field(event, "model_id", 7, 0), 0) + npc_uid = _safe_text(_event_field(event, "npc_uid", 8, "")) + event_type = _safe_text(_event_field(event, "event_type", 9, "")).strip().lower() + text_raw = _safe_text(_event_field(event, "text", 10, "")) + map_name = _safe_text(_event_field(event, "map_name", 11, "")).strip() + npc_name = _safe_text(_event_field(event, "npc_name", 12, "")).strip() + if not npc_uid: + npc_uid = _build_npc_uid(map_id, model_id, agent_id) + if not map_name: + map_name = _resolve_map_name(map_id) + if not npc_name: + npc_name = _resolve_npc_name(agent_id) + return { + "tick": tick, + "message_id": message_id, + "incoming": incoming, + "dialog_id": dialog_id, + "context_dialog_id": context_dialog_id, + "agent_id": agent_id, + "map_id": map_id, + "map_name": map_name, + "model_id": model_id, + "npc_uid": npc_uid, + "npc_name": npc_name, + "event_type": event_type, + "text_raw": text_raw, + "text_decoded": text_raw, + } + + def _process_step_event(self, conn: sqlite3.Connection, event: Dict[str, Any]) -> int: + finalized = self._finalize_stale_steps(conn, event["tick"], current_map_id=event["map_id"]) + event_type = event["event_type"] + step_key = self._event_step_key(event) + if not step_key: + return finalized + + if event_type == "recv_body": + self._remember_body_text_mapping(event) + existing = self._pending_steps.get(step_key) + if existing is not None: + if self._should_hydrate_pending_step(existing, event): + self._hydrate_pending_step(existing, event) + return finalized + if self._finalize_step(conn, step_key, reason="next_body", end_tick=event["tick"]): + finalized += 1 + self._pending_steps[step_key] = self._new_step_from_body(event) + return finalized + + if event_type == "recv_choice": + step = self._pending_steps.get(step_key) + if step is None: + if int(event.get("context_dialog_id", 0) or 0) == 0: + # Ignore contextless bootstrap choices; they create unresolved steps. + return finalized + step = self._new_step_from_choice(event) + self._pending_steps[step_key] = step + self._hydrate_step_from_choice_context(step, event) + self._append_choice(step, event) + step["last_tick"] = event["tick"] + return finalized + + if event_type == "sent_choice": + step = self._pending_steps.get(step_key) + if step is None: + if int(event.get("context_dialog_id", 0) or 0) == 0: + # Ignore contextless bootstrap sends; wait for a resolvable step context. + return finalized + step = self._new_step_from_choice(event) + self._pending_steps[step_key] = step + self._hydrate_step_from_choice_context(step, event) + step["selected_dialog_id"] = event["dialog_id"] + step["selected_source_message_id"] = event["message_id"] + self._mark_choice_selected(step, event["dialog_id"], event["message_id"], event["text_decoded"]) + step["last_tick"] = event["tick"] + if self._finalize_step(conn, step_key, reason="sent_choice", end_tick=event["tick"]): + finalized += 1 + return finalized + + step = self._pending_steps.get(step_key) + if step is not None: + step["last_tick"] = max(step.get("last_tick", 0), event["tick"]) + return finalized + + def _event_step_key(self, event: Dict[str, Any]) -> str: + npc_uid = _safe_text(event.get("npc_uid", "")).strip() + if npc_uid: + return npc_uid + agent_id = int(event.get("agent_id", 0) or 0) + if agent_id: + return _build_npc_uid( + int(event.get("map_id", 0) or 0), + int(event.get("model_id", 0) or 0), + agent_id, + ) + return "" + + def _new_step_from_body(self, event: Dict[str, Any]) -> Dict[str, Any]: + body_dialog_id = event["dialog_id"] or event["context_dialog_id"] + if body_dialog_id == 0: + inferred = self._infer_dialog_id_from_body_text(event.get("text_decoded", "")) + if inferred: + body_dialog_id = inferred + npc_uid_instance = self._event_step_key(event) + body_text_raw = event["text_raw"] + body_text_decoded = event["text_decoded"] + if body_dialog_id != 0 and (not body_text_raw or not body_text_decoded): + cached_raw, cached_decoded = self._cached_body_text_for_dialog(body_dialog_id) + body_text_raw = body_text_raw or cached_raw + body_text_decoded = body_text_decoded or cached_decoded + return { + "start_tick": event["tick"], + "last_tick": event["tick"], + "map_id": event["map_id"], + "map_name": event.get("map_name", "") or _resolve_map_name(int(event["map_id"] or 0)), + "agent_id": event["agent_id"], + "npc_name": event.get("npc_name", "") or _resolve_npc_name(int(event["agent_id"] or 0)), + "model_id": event["model_id"], + "npc_uid_instance": npc_uid_instance, + "npc_uid_archetype": _build_npc_archetype_uid(event["map_id"], event["model_id"]), + "body_dialog_id": body_dialog_id, + "body_text_raw": body_text_raw, + "body_text_decoded": body_text_decoded, + "selected_dialog_id": 0, + "selected_source_message_id": 0, + "choices": [], + } + + def _new_step_from_choice(self, event: Dict[str, Any]) -> Dict[str, Any]: + npc_uid_instance = self._event_step_key(event) + body_dialog_id = event["context_dialog_id"] if event["context_dialog_id"] else 0 + body_text_raw = "" + body_text_decoded = "" + if body_dialog_id != 0: + body_text_raw, body_text_decoded = self._cached_body_text_for_dialog(body_dialog_id) + return { + "start_tick": event["tick"], + "last_tick": event["tick"], + "map_id": event["map_id"], + "map_name": event.get("map_name", "") or _resolve_map_name(int(event["map_id"] or 0)), + "agent_id": event["agent_id"], + "npc_name": event.get("npc_name", "") or _resolve_npc_name(int(event["agent_id"] or 0)), + "model_id": event["model_id"], + "npc_uid_instance": npc_uid_instance, + "npc_uid_archetype": _build_npc_archetype_uid(event["map_id"], event["model_id"]), + "body_dialog_id": body_dialog_id, + "body_text_raw": body_text_raw, + "body_text_decoded": body_text_decoded, + "selected_dialog_id": 0, + "selected_source_message_id": 0, + "choices": [], + } + + def _append_choice(self, step: Dict[str, Any], event: Dict[str, Any]) -> None: + step["choices"].append( + { + "choice_index": len(step["choices"]), + "choice_dialog_id": event["dialog_id"], + "choice_text_raw": event["text_raw"], + "choice_text_decoded": event["text_decoded"], + "skill_id": 0, + "button_icon": 0, + "decode_pending": 0, + "selected": 0, + "source_message_id": event["message_id"], + } + ) + + def _hydrate_step_from_choice_context(self, step: Dict[str, Any], event: Dict[str, Any]) -> None: + context_dialog_id = int(event.get("context_dialog_id", 0) or 0) + if int(step.get("body_dialog_id", 0) or 0) == 0 and context_dialog_id != 0: + step["body_dialog_id"] = context_dialog_id + if int(step.get("map_id", 0) or 0) == 0: + step["map_id"] = int(event.get("map_id", 0) or 0) + if not _safe_text(step.get("map_name", "")): + step["map_name"] = _safe_text(event.get("map_name", "")) or _resolve_map_name(int(event.get("map_id", 0) or 0)) + if int(step.get("agent_id", 0) or 0) == 0: + step["agent_id"] = int(event.get("agent_id", 0) or 0) + if not _safe_text(step.get("npc_name", "")): + step["npc_name"] = _safe_text(event.get("npc_name", "")) or _resolve_npc_name(int(event.get("agent_id", 0) or 0)) + if int(step.get("model_id", 0) or 0) == 0: + step["model_id"] = int(event.get("model_id", 0) or 0) + if not _safe_text(step.get("npc_uid_instance", "")): + step["npc_uid_instance"] = self._event_step_key(event) + if not _safe_text(step.get("npc_uid_archetype", "")): + step["npc_uid_archetype"] = _build_npc_archetype_uid( + int(event.get("map_id", 0) or 0), + int(event.get("model_id", 0) or 0), + ) + dialog_id = int(step.get("body_dialog_id", 0) or 0) + if dialog_id != 0 and (not _safe_text(step.get("body_text_raw")) or not _safe_text(step.get("body_text_decoded"))): + cached_raw, cached_decoded = self._cached_body_text_for_dialog(dialog_id) + if cached_raw and not _safe_text(step.get("body_text_raw")): + step["body_text_raw"] = cached_raw + if cached_decoded and not _safe_text(step.get("body_text_decoded")): + step["body_text_decoded"] = cached_decoded + + def _should_hydrate_pending_step(self, step: Dict[str, Any], body_event: Dict[str, Any]) -> bool: + current_body_id = int(step.get("body_dialog_id", 0) or 0) + incoming_body_id = int((body_event.get("dialog_id", 0) or body_event.get("context_dialog_id", 0)) or 0) + current_text = _safe_text(step.get("body_text_decoded", "")).strip() + incoming_text = _safe_text(body_event.get("text_decoded", "")).strip() + if current_body_id == 0 and (incoming_body_id != 0 or incoming_text): + return True + if current_body_id != 0 and incoming_body_id == current_body_id: + if incoming_text and (not current_text or incoming_text == current_text): + return True + return False + + def _hydrate_pending_step(self, step: Dict[str, Any], body_event: Dict[str, Any]) -> None: + incoming_body_id = int((body_event.get("dialog_id", 0) or body_event.get("context_dialog_id", 0)) or 0) + if incoming_body_id != 0: + step["body_dialog_id"] = incoming_body_id + incoming_raw = _safe_text(body_event.get("text_raw", "")) + incoming_decoded = _safe_text(body_event.get("text_decoded", "")) + if incoming_raw and not _safe_text(step.get("body_text_raw", "")): + step["body_text_raw"] = incoming_raw + if incoming_decoded and not _safe_text(step.get("body_text_decoded", "")): + step["body_text_decoded"] = incoming_decoded + if int(step.get("map_id", 0) or 0) == 0: + step["map_id"] = int(body_event.get("map_id", 0) or 0) + if not _safe_text(step.get("map_name", "")): + step["map_name"] = _safe_text(body_event.get("map_name", "")) or _resolve_map_name(int(body_event.get("map_id", 0) or 0)) + if int(step.get("agent_id", 0) or 0) == 0: + step["agent_id"] = int(body_event.get("agent_id", 0) or 0) + if not _safe_text(step.get("npc_name", "")): + step["npc_name"] = _safe_text(body_event.get("npc_name", "")) or _resolve_npc_name(int(body_event.get("agent_id", 0) or 0)) + if int(step.get("model_id", 0) or 0) == 0: + step["model_id"] = int(body_event.get("model_id", 0) or 0) + if not _safe_text(step.get("npc_uid_instance", "")): + step["npc_uid_instance"] = self._event_step_key(body_event) + if not _safe_text(step.get("npc_uid_archetype", "")): + step["npc_uid_archetype"] = _build_npc_archetype_uid( + int(body_event.get("map_id", 0) or 0), + int(body_event.get("model_id", 0) or 0), + ) + step["last_tick"] = max(int(step.get("last_tick", 0) or 0), int(body_event.get("tick", 0) or 0)) + + def _remember_body_text_mapping(self, body_event: Dict[str, Any]) -> None: + dialog_id = int((body_event.get("dialog_id", 0) or body_event.get("context_dialog_id", 0)) or 0) + raw = _safe_text(body_event.get("text_raw", "")) + decoded = _safe_text(body_event.get("text_decoded", "")) + if dialog_id != 0 and (raw or decoded): + self._dialog_id_to_body_text[dialog_id] = (raw, decoded) + key = self._body_text_key(decoded) + if key: + self._body_text_to_dialog_id[key] = dialog_id + + def _cached_body_text_for_dialog(self, dialog_id: int) -> Tuple[str, str]: + value = self._dialog_id_to_body_text.get(int(dialog_id)) + if not value: + return "", "" + return _safe_text(value[0]), _safe_text(value[1]) + + def _body_text_key(self, text: Any) -> str: + value = _safe_text(text).strip() + return value.lower() if value else "" + + def _infer_dialog_id_from_body_text(self, text: Any) -> int: + key = self._body_text_key(text) + if not key: + return 0 + return int(self._body_text_to_dialog_id.get(key, 0) or 0) + + def _mark_choice_selected(self, step: Dict[str, Any], dialog_id: int, message_id: int, fallback_text: str) -> None: + matched = False + for choice in step["choices"]: + if int(choice.get("choice_dialog_id", 0)) == int(dialog_id): + choice["selected"] = 1 + choice["source_message_id"] = message_id + matched = True + if matched: + return + step["choices"].append( + { + "choice_index": len(step["choices"]), + "choice_dialog_id": int(dialog_id), + "choice_text_raw": _safe_text(fallback_text), + "choice_text_decoded": _safe_text(fallback_text), + "skill_id": 0, + "button_icon": 0, + "decode_pending": 0, + "selected": 1, + "source_message_id": int(message_id), + } + ) + + def _finalize_stale_steps(self, conn: sqlite3.Connection, current_tick: int, current_map_id: int) -> int: + finalized = 0 + keys = list(self._pending_steps.keys()) + for key in keys: + step = self._pending_steps.get(key) + if step is None: + continue + last_tick = int(step.get("last_tick", 0) or 0) + map_id = int(step.get("map_id", 0) or 0) + if current_map_id and map_id and map_id != current_map_id: + if self._finalize_step(conn, key, reason="map_change", end_tick=current_tick): + finalized += 1 + continue + if current_tick and last_tick and (current_tick - last_tick) > self._step_timeout_ms: + if self._finalize_step(conn, key, reason="timeout", end_tick=current_tick): + finalized += 1 + return finalized + + def _finalize_step(self, conn: sqlite3.Connection, key: str, *, reason: str, end_tick: int) -> bool: + step = self._pending_steps.pop(key, None) + if step is None: + return False + + body_dialog_id = int(step.get("body_dialog_id", 0) or 0) + body_text_raw = _safe_text(step.get("body_text_raw", "")) + body_text_decoded = _safe_text(step.get("body_text_decoded", "")) + selected_dialog_id = int(step.get("selected_dialog_id", 0) or 0) + choices = list(step.get("choices", [])) + map_id = int(step.get("map_id", 0) or 0) + agent_id = int(step.get("agent_id", 0) or 0) + model_id = int(step.get("model_id", 0) or 0) + map_name = _safe_text(step.get("map_name", "")).strip() or _resolve_map_name(map_id) + npc_name = _safe_text(step.get("npc_name", "")).strip() or _resolve_npc_name(agent_id) + + if body_dialog_id == 0 and body_text_decoded: + inferred_id = self._infer_dialog_id_from_body_text(body_text_decoded) + if inferred_id: + body_dialog_id = inferred_id + step["body_dialog_id"] = inferred_id + + if body_dialog_id != 0 and (not body_text_raw or not body_text_decoded): + cached_raw, cached_decoded = self._cached_body_text_for_dialog(body_dialog_id) + if cached_raw and not body_text_raw: + body_text_raw = cached_raw + step["body_text_raw"] = cached_raw + if cached_decoded and not body_text_decoded: + body_text_decoded = cached_decoded + step["body_text_decoded"] = cached_decoded + + # Drop pure bootstrap noise: no body, no text, no choices, no user selection. + if body_dialog_id == 0 and not body_text_decoded.strip() and not choices and selected_dialog_id == 0: + return False + + cursor = conn.execute( + """ + INSERT INTO dialog_steps ( + start_tick, end_tick, map_id, map_name, agent_id, npc_name, model_id, npc_uid_instance, npc_uid_archetype, + body_dialog_id, body_text_raw, body_text_decoded, selected_dialog_id, + selected_source_message_id, finalized_reason, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + int(step.get("start_tick", 0) or 0), + int(end_tick or step.get("last_tick", 0) or 0), + map_id, + map_name, + agent_id, + npc_name, + model_id, + _safe_text(step.get("npc_uid_instance", "")), + _safe_text(step.get("npc_uid_archetype", "")), + body_dialog_id, + body_text_raw, + body_text_decoded, + selected_dialog_id, + int(step.get("selected_source_message_id", 0) or 0), + _safe_text(reason), + float(time.time()), + ), + ) + step_id = int(cursor.lastrowid or 0) + if step_id <= 0: + return False + + for index, choice in enumerate(step.get("choices", [])): + conn.execute( + """ + INSERT INTO dialog_choices ( + step_id, choice_index, choice_dialog_id, choice_text_raw, choice_text_decoded, + skill_id, button_icon, decode_pending, selected, source_message_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + step_id, + int(choice.get("choice_index", index)), + int(choice.get("choice_dialog_id", 0) or 0), + _safe_text(choice.get("choice_text_raw", "")), + _safe_text(choice.get("choice_text_decoded", "")), + int(choice.get("skill_id", 0) or 0), + int(choice.get("button_icon", 0) or 0), + 1 if bool(choice.get("decode_pending", False)) else 0, + 1 if bool(choice.get("selected", False)) else 0, + int(choice.get("source_message_id", 0) or 0), + ), + ) + if body_dialog_id != 0 and body_text_decoded: + key_by_text = self._body_text_key(body_text_decoded) + if key_by_text: + self._body_text_to_dialog_id[key_by_text] = body_dialog_id + self._dialog_id_to_body_text[body_dialog_id] = (body_text_raw, body_text_decoded) + return True + + def _repair_persisted_step_rows(self, conn: sqlite3.Connection) -> None: + # 1) Backfill missing body dialog ids from exact body text matches for same NPC instance. + conn.execute( + """ + UPDATE dialog_steps + SET body_dialog_id = ( + SELECT t2.body_dialog_id + FROM dialog_steps t2 + WHERE t2.npc_uid_instance = dialog_steps.npc_uid_instance + AND t2.body_dialog_id <> 0 + AND t2.body_text_decoded = dialog_steps.body_text_decoded + ORDER BY t2.id DESC + LIMIT 1 + ) + WHERE body_dialog_id = 0 + AND IFNULL(body_text_decoded, '') <> '' + AND EXISTS ( + SELECT 1 + FROM dialog_steps t2 + WHERE t2.npc_uid_instance = dialog_steps.npc_uid_instance + AND t2.body_dialog_id <> 0 + AND t2.body_text_decoded = dialog_steps.body_text_decoded + ) + """ + ) + + # 2) Backfill missing body text from same NPC+body dialog rows. + conn.execute( + """ + UPDATE dialog_steps + SET body_text_raw = CASE + WHEN IFNULL(body_text_raw, '') <> '' THEN body_text_raw + ELSE COALESCE(( + SELECT t2.body_text_raw + FROM dialog_steps t2 + WHERE t2.npc_uid_instance = dialog_steps.npc_uid_instance + AND t2.body_dialog_id = dialog_steps.body_dialog_id + AND IFNULL(t2.body_text_raw, '') <> '' + ORDER BY t2.id DESC + LIMIT 1 + ), '') + END, + body_text_decoded = CASE + WHEN IFNULL(body_text_decoded, '') <> '' THEN body_text_decoded + ELSE COALESCE(( + SELECT t2.body_text_decoded + FROM dialog_steps t2 + WHERE t2.npc_uid_instance = dialog_steps.npc_uid_instance + AND t2.body_dialog_id = dialog_steps.body_dialog_id + AND IFNULL(t2.body_text_decoded, '') <> '' + ORDER BY t2.id DESC + LIMIT 1 + ), '') + END + WHERE body_dialog_id <> 0 + AND (IFNULL(body_text_raw, '') = '' OR IFNULL(body_text_decoded, '') = '') + """ + ) + + def _get_choices_by_step_ids(self, conn: sqlite3.Connection, step_ids: Sequence[int]) -> Dict[int, List[Dict[str, Any]]]: + if not step_ids: + return {} + placeholders = ",".join("?" for _ in step_ids) + rows = conn.execute( + f""" + SELECT id, step_id, choice_index, choice_dialog_id, choice_text_raw, choice_text_decoded, + skill_id, button_icon, decode_pending, selected, source_message_id + FROM dialog_choices + WHERE step_id IN ({placeholders}) + ORDER BY step_id ASC, choice_index ASC, id ASC + """, + list(step_ids), + ).fetchall() + out: Dict[int, List[Dict[str, Any]]] = {} + for row in rows: + choice = self._choice_row_to_dict(row) + out.setdefault(int(choice["step_id"]), []).append(choice) + return out + + def _overflow_ids(self, conn: sqlite3.Connection, table_name: str, max_rows: int) -> List[int]: + row = conn.execute(f"SELECT COUNT(*) AS total FROM {table_name}").fetchone() + total = int(row[0]) if row else 0 + overflow = max(0, total - int(max_rows)) + if overflow <= 0: + return [] + rows = conn.execute( + f"SELECT id FROM {table_name} ORDER BY id ASC LIMIT ?", + (overflow,), + ).fetchall() + return [int(item[0]) for item in rows] + + def _trim_table(self, conn: sqlite3.Connection, table_name: str, max_rows: int) -> int: + overflow_ids = self._overflow_ids(conn, table_name, max_rows) + if not overflow_ids: + return 0 + cursor = conn.execute( + f"DELETE FROM {table_name} WHERE id IN ({','.join('?' for _ in overflow_ids)})", + overflow_ids, + ) + return int(cursor.rowcount or 0) + + def _delete_choices_for_step_ids(self, conn: sqlite3.Connection, step_ids: Sequence[int]) -> int: + if not step_ids: + return 0 + cursor = conn.execute( + f"DELETE FROM dialog_choices WHERE step_id IN ({','.join('?' for _ in step_ids)})", + list(step_ids), + ) + return int(cursor.rowcount or 0) + + def _callback_row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]: + return { + "id": int(row["id"]), + "tick": int(row["tick"]), + "ts": float(row["ts"]), + "message_id": int(row["message_id"]), + "incoming": bool(row["incoming"]), + "dialog_id": int(row["dialog_id"]), + "context_dialog_id": int(row["context_dialog_id"]), + "agent_id": int(row["agent_id"]), + "map_id": int(row["map_id"]), + "map_name": _safe_text(row["map_name"]), + "model_id": int(row["model_id"]), + "npc_uid": _safe_text(row["npc_uid"]), + "npc_name": _safe_text(row["npc_name"]), + "event_type": _safe_text(row["event_type"]), + "text_raw": _safe_text(row["text_raw"]), + "text_decoded": _safe_text(row["text_decoded"]), + } + + def _raw_row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]: + return { + "id": int(row["id"]), + "tick": int(row["tick"]), + "ts": float(row["ts"]), + "message_id": int(row["message_id"]), + "incoming": bool(row["incoming"]), + "map_id": int(row["map_id"]), + "map_name": _safe_text(row["map_name"]), + "agent_id": int(row["agent_id"]), + "npc_name": _safe_text(row["npc_name"]), + "model_id": int(row["model_id"]), + "npc_uid": _safe_text(row["npc_uid"]), + "dialog_id": int(row["dialog_id"]), + "context_dialog_id": int(row["context_dialog_id"]), + "event_type": _safe_text(row["event_type"]), + "text_raw": _safe_text(row["text_raw"]), + } + + def _step_row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]: + return { + "id": int(row["id"]), + "start_tick": int(row["start_tick"]), + "end_tick": int(row["end_tick"]), + "map_id": int(row["map_id"]), + "map_name": _safe_text(row["map_name"]), + "agent_id": int(row["agent_id"]), + "npc_name": _safe_text(row["npc_name"]), + "model_id": int(row["model_id"]), + "npc_uid_instance": _safe_text(row["npc_uid_instance"]), + "npc_uid_archetype": _safe_text(row["npc_uid_archetype"]), + "body_dialog_id": int(row["body_dialog_id"]), + "body_text_raw": _safe_text(row["body_text_raw"]), + "body_text_decoded": _safe_text(row["body_text_decoded"]), + "selected_dialog_id": int(row["selected_dialog_id"]), + "selected_source_message_id": int(row["selected_source_message_id"]), + "finalized_reason": _safe_text(row["finalized_reason"]), + "created_at": float(row["created_at"]), + } + + def _choice_row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]: + return { + "id": int(row["id"]), + "step_id": int(row["step_id"]), + "choice_index": int(row["choice_index"]), + "choice_dialog_id": int(row["choice_dialog_id"]), + "choice_text_raw": _safe_text(row["choice_text_raw"]), + "choice_text_decoded": _safe_text(row["choice_text_decoded"]), + "skill_id": int(row["skill_id"]), + "button_icon": int(row["button_icon"]), + "decode_pending": bool(row["decode_pending"]), + "selected": bool(row["selected"]), + "source_message_id": int(row["source_message_id"]), + } + + def _write_json(self, path: str, payload: Dict[str, Any]) -> None: + out_path = os.path.abspath(str(path)) + out_dir = os.path.dirname(out_path) + if out_dir: + os.makedirs(out_dir, exist_ok=True) + with open(out_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + + def _key_seen(self, event_key: str) -> bool: + return event_key in self._seen_keys + + def _remember_key(self, event_key: str) -> None: + self._seen_keys.add(event_key) + self._seen_order.append(event_key) + if len(self._seen_order) <= MAX_SEEN_EVENT_KEYS: + return + overflow = len(self._seen_order) - MAX_SEEN_EVENT_KEYS + stale = self._seen_order[:overflow] + self._seen_order = self._seen_order[overflow:] + for key in stale: + self._seen_keys.discard(key) + + +_PIPELINE_INSTANCE: Optional[DialogStepSQLitePipeline] = None +_PIPELINE_INSTANCE_LOCK = threading.Lock() + + +def get_dialog_step_pipeline() -> DialogStepSQLitePipeline: + global _PIPELINE_INSTANCE + if _PIPELINE_INSTANCE is not None: + return _PIPELINE_INSTANCE + with _PIPELINE_INSTANCE_LOCK: + if _PIPELINE_INSTANCE is None: + _PIPELINE_INSTANCE = DialogStepSQLitePipeline() + return _PIPELINE_INSTANCE + + +# Diagnostics helpers for persisted dialog history. +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except Exception: + return default + + +def _as_text(value: Any) -> str: + if value is None: + return "" + return str(value) + + +def _build_diag_issue( + *, + severity: str, + rule: str, + message: str, + npc_uid: str = "", + step_id: int = 0, + dialog_id: int = 0, + details: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + return { + "severity": severity, + "rule": rule, + "message": message, + "npc_uid": npc_uid, + "step_id": int(step_id), + "dialog_id": int(dialog_id), + "details": details or {}, + } + + +def _analyze_dialog_steps( + steps: List[Dict[str, Any]], + *, + max_issues: int = 250, +) -> Dict[str, Any]: + """ + Run lightweight consistency checks over persisted dialog history rows. + + The diagnostics are intentionally conservative and meant for monitor/debug + surfaces, not for blocking runtime behavior. + """ + issues: List[Dict[str, Any]] = [] + + for step in steps: + step_id = _as_int(step.get("id", 0), 0) + npc_uid = _as_text(step.get("npc_uid_instance", "")).strip() + body_dialog_id = _as_int(step.get("body_dialog_id", 0), 0) + selected_dialog_id = _as_int(step.get("selected_dialog_id", 0), 0) + finalized_reason = _as_text(step.get("finalized_reason", "")).strip().lower() + body_text = _as_text(step.get("body_text_raw", "")) + choices = list(step.get("choices", []) or []) + + if body_dialog_id == 0 and choices: + issues.append( + _build_diag_issue( + severity="warning", + rule="orphan_choices_without_body", + message="Turn has choices but no body dialog id.", + npc_uid=npc_uid, + step_id=step_id, + dialog_id=0, + details={"choice_count": len(choices)}, + ) + ) + + if body_dialog_id != 0 and not body_text.strip(): + issues.append( + _build_diag_issue( + severity="warning", + rule="missing_body_text", + message="Turn body dialog id is set but body text is empty.", + npc_uid=npc_uid, + step_id=step_id, + dialog_id=body_dialog_id, + ) + ) + + if finalized_reason == "timeout": + issues.append( + _build_diag_issue( + severity="info", + rule="timeout_finalization", + message="Turn was finalized by timeout.", + npc_uid=npc_uid, + step_id=step_id, + dialog_id=body_dialog_id, + ) + ) + + choice_ids: List[int] = [ + _as_int(choice.get("choice_dialog_id", 0), 0) + for choice in choices + if _as_int(choice.get("choice_dialog_id", 0), 0) != 0 + ] + + if selected_dialog_id != 0 and selected_dialog_id not in set(choice_ids): + issues.append( + _build_diag_issue( + severity="error", + rule="selected_choice_not_offered", + message="Selected dialog id is not present in the offered choices for this step.", + npc_uid=npc_uid, + step_id=step_id, + dialog_id=selected_dialog_id, + details={"offered_choice_ids": choice_ids}, + ) + ) + + issues.sort( + key=lambda issue: ( + _DIAG_SEVERITY_ORDER.get(_as_text(issue.get("severity", "info")).lower(), 99), + -_as_int(issue.get("step_id", 0), 0), + ) + ) + + if max_issues > 0: + issues = issues[: int(max_issues)] + + summary = {"error": 0, "warning": 0, "info": 0, "total": len(issues)} + for issue in issues: + severity = _as_text(issue.get("severity", "info")).lower() + if severity not in summary: + summary[severity] = 0 + summary[severity] += 1 + + return { + "summary": summary, + "issues": issues, + "analyzed_steps": len(steps), + } + + +class DialogInfo: + """Python wrapper for native DialogInfo struct.""" + + def __init__(self, native_dialog_info): + self.native = native_dialog_info + self.dialog_id = native_dialog_info.dialog_id + self.flags = native_dialog_info.flags + self.frame_type = native_dialog_info.frame_type + self.event_handler = native_dialog_info.event_handler + self.content_id = native_dialog_info.content_id + self.property_id = native_dialog_info.property_id + self.content = _sanitize_dialog_text(native_dialog_info.content) + self.agent_id = native_dialog_info.agent_id + + def is_available(self) -> bool: + return (self.flags & 0x1) != 0 + + def __repr__(self) -> str: + return f"DialogInfo(id=0x{self.dialog_id:04x}, available={self.is_available()})" + + +class ActiveDialogInfo: + """Python wrapper for native ActiveDialogInfo struct.""" + + def __init__( + self, + native_active_dialog=None, + *, + dialog_id: int = 0, + context_dialog_id: int = 0, + agent_id: int = 0, + dialog_id_authoritative: bool = False, + message: str = "", + raw_message: str = "", + ): + if native_active_dialog is not None: + self.native = native_active_dialog + self.dialog_id = int(getattr(native_active_dialog, "dialog_id", 0)) + self.context_dialog_id = int(getattr(native_active_dialog, "context_dialog_id", 0)) + self.agent_id = int(getattr(native_active_dialog, "agent_id", 0)) + self.dialog_id_authoritative = bool(getattr(native_active_dialog, "dialog_id_authoritative", False)) + self.raw_message = str(getattr(native_active_dialog, "message", "") or "") + self.message = _sanitize_dialog_text(self.raw_message) + else: + self.native = None + self.dialog_id = dialog_id + self.context_dialog_id = context_dialog_id + self.agent_id = agent_id + self.dialog_id_authoritative = dialog_id_authoritative + self.raw_message = str(raw_message or message or "") + self.message = _sanitize_dialog_text(message) + + def __repr__(self) -> str: + return ( + "ActiveDialogInfo(" + f"dialog_id=0x{self.dialog_id:04x}, " + f"context_dialog_id=0x{self.context_dialog_id:04x}, " + f"authoritative={self.dialog_id_authoritative}, " + f"agent_id={self.agent_id})" + ) + + +class DialogButtonInfo: + """Python wrapper for native DialogButtonInfo struct.""" + + def __init__( + self, + native_button_info=None, + *, + dialog_id: int = 0, + button_icon: int = 0, + message: str = "", + message_decoded: str = "", + message_decode_pending: bool = False, + ): + if native_button_info is not None: + self.native = native_button_info + self.dialog_id = native_button_info.dialog_id + self.button_icon = native_button_info.button_icon + self.message = _sanitize_dialog_text(native_button_info.message) + self.message_decoded = _sanitize_dialog_text(native_button_info.message_decoded) + self.message_decode_pending = native_button_info.message_decode_pending + else: + self.native = None + self.dialog_id = dialog_id + self.button_icon = button_icon + self.message = _sanitize_dialog_text(message) + self.message_decoded = _sanitize_dialog_text(message_decoded) + self.message_decode_pending = message_decode_pending + + def __repr__(self) -> str: + return f"DialogButtonInfo(dialog_id=0x{self.dialog_id:04x})" + + +# Inline choice extraction helpers. +def _parse_inline_choice_dialog_id(raw_value: Any) -> int: + value = str(raw_value or "").strip() + if not value: + return 0 + try: + return int(value, 0) + except Exception: + return 0 + + +def _extract_inline_dialog_choices_from_text(body_text: Optional[str]) -> List[DialogButtonInfo]: + """ + Extract `...` style inline choices from raw dialog body text. + + Some GW dialogs expose choices inline instead of through the native active + button list, so this parser acts as the fallback source for those screens. + """ + text = str(body_text or "") + if not text or " List[DialogButtonInfo]: + """Parse inline GW dialog anchors like `...` from raw body text.""" + return _extract_inline_dialog_choices_from_text(body_text) + + +def _extract_raw_active_dialog_message(active_dialog: Any) -> str: + if active_dialog is None: + return "" + + raw_message = getattr(active_dialog, "raw_message", None) + if raw_message is not None: + return str(raw_message or "") + + native_dialog = getattr(active_dialog, "native", None) + if native_dialog is not None: + native_message = getattr(native_dialog, "message", None) + if native_message is not None: + return str(native_message or "") + + message = getattr(active_dialog, "message", None) + if message is not None: + return str(message or "") + return "" + + +def extract_inline_dialog_choices_from_active(active_dialog: Any) -> List[DialogButtonInfo]: + """Parse inline choices from either a wrapped ActiveDialogInfo or raw native active dialog object.""" + return _extract_inline_dialog_choices_from_text(_extract_raw_active_dialog_message(active_dialog)) + + +class DialogTextDecodedInfo: + """Python wrapper for decoded dialog text status.""" + + def __init__(self, native_info): + self.native = native_info + self.dialog_id = native_info.dialog_id + self.text = _sanitize_dialog_text(native_info.text) + self.pending = native_info.pending + + +class DialogCallbackJournalEntry: + """Python wrapper for native structured dialog callback journal entries.""" + + def __init__(self, native_info): + self.native = native_info + self.tick = int(getattr(native_info, "tick", 0)) + self.message_id = int(getattr(native_info, "message_id", 0)) + self.incoming = bool(getattr(native_info, "incoming", False)) + self.dialog_id = int(getattr(native_info, "dialog_id", 0)) + self.context_dialog_id = int(getattr(native_info, "context_dialog_id", 0)) + self.agent_id = int(getattr(native_info, "agent_id", 0)) + self.map_id = int(getattr(native_info, "map_id", 0)) + self.model_id = int(getattr(native_info, "model_id", 0)) + self.dialog_id_authoritative = bool(getattr(native_info, "dialog_id_authoritative", False)) + self.context_dialog_id_inferred = bool(getattr(native_info, "context_dialog_id_inferred", False)) + self.npc_uid = str(getattr(native_info, "npc_uid", "") or "") + self.event_type = str(getattr(native_info, "event_type", "") or "") + # Keep callback journal text raw; callers decide whether/how to sanitize. + self.text = str(getattr(native_info, "text", "") or "") + + +class DialogWidget: + """ + High-level wrapper around the native PyDialog module. + + Use this class when you want one object that exposes live dialog state, + static dialog metadata, callback journals, and optional persisted history. + """ + + def __init__(self) -> None: + self._initialized = False + + def initialize(self) -> bool: + """Initialize the native dialog module if it is available.""" + if PyDialog is None: + return False + try: + PyDialog.PyDialog.initialize() + self._initialized = True + return True + except Exception: + self._initialized = False + return False + + def terminate(self) -> None: + """Terminate the native dialog module and clear local initialized state.""" + if PyDialog is None: + return + try: + PyDialog.PyDialog.terminate() + finally: + self._initialized = False + + def get_active_dialog(self) -> Optional[ActiveDialogInfo]: + """ + Return the current live dialog body, or `None` when no dialog is active. + + This is the main entry point for live dialog automation. + """ + native_info = _call_native_dialog_method("get_active_dialog", None) + if native_info is None: + return None + if ( + getattr(native_info, "dialog_id", 0) == 0 + and getattr(native_info, "context_dialog_id", 0) == 0 + and getattr(native_info, "agent_id", 0) == 0 + ): + return None + return ActiveDialogInfo(native_info) + + def get_active_dialog_buttons(self) -> List[DialogButtonInfo]: + """ + Return the currently visible dialog buttons for the active screen. + + Falls back to parsing inline body markup when the native button list is + empty for dialogs that encode choices directly in the message text. + """ + native_list = _coerce_native_list(_call_native_dialog_method("get_active_dialog_buttons", [])) + buttons = [DialogButtonInfo(item) for item in native_list] + if buttons: + return buttons + # Some dialogs expose choices inline in the body markup instead of through the native + # button list. Falling back here keeps the public API stable for those screens. + native_active = _call_native_dialog_method("get_active_dialog", None) + return extract_inline_dialog_choices_from_active(native_active) + + def get_last_selected_dialog_id(self) -> int: + """Return the most recent dialog id sent through the native dialog API.""" + return int(_call_native_dialog_method("get_last_selected_dialog_id", 0) or 0) + + def _get_dialog_choice_catalog_text(self, dialog_id: int) -> str: + if int(dialog_id) == 0: + return "" + try: + dialog_info = self.get_dialog_info(int(dialog_id)) + except Exception: + dialog_info = None + if dialog_info is not None: + content = _sanitize_dialog_text(getattr(dialog_info, "content", "")) + if content: + return content + try: + return _sanitize_dialog_text(self.get_dialog_text_decoded(int(dialog_id))) + except Exception: + return "" + + def _get_dialog_choice_history_texts( + self, + dialog_id: int, + *, + active_dialog: Optional[ActiveDialogInfo] = None, + history_limit: int = 25, + ) -> List[str]: + """ + Collect historical labels for a choice dialog id from persisted dialog steps. + + This is a recovery helper for live screens whose visible button text is + missing or undecoded. + """ + if int(dialog_id) == 0: + return [] + + query_kwargs: Dict[str, Any] = { + "choice_dialog_id": int(dialog_id), + "limit": max(1, int(history_limit)), + "offset": 0, + "include_choices": True, + "sync": False, + } + if active_dialog is not None: + body_dialog_id = int( + getattr(active_dialog, "context_dialog_id", 0) + or getattr(active_dialog, "dialog_id", 0) + or 0 + ) + if body_dialog_id != 0: + query_kwargs["body_dialog_id"] = body_dialog_id + # The active NPC/body filters are what make fallback matching safe enough to use for + # automation. Without them, reused dialog ids from another NPC can match incorrectly. + query_kwargs.update(_build_active_dialog_npc_filters(active_dialog)) + + steps = self.get_dialog_steps(**query_kwargs) + + texts: List[str] = [] + for step in steps: + for choice in list(step.get("choices", []) or []): + if int(choice.get("choice_dialog_id", 0) or 0) != int(dialog_id): + continue + _append_unique_dialog_choice_text(texts, choice.get("choice_text_decoded", "")) + _append_unique_dialog_choice_text(texts, choice.get("choice_text_raw", "")) + return texts + + def get_active_dialog_choice_id_by_text(self, text: Optional[str]) -> int: + """Resolve a visible choice by its current on-screen label only.""" + needle = _normalize_dialog_choice_text(text) + if not needle or not self.is_dialog_active(): + return 0 + + for button in self.get_active_dialog_buttons(): + dialog_id = int(getattr(button, "dialog_id", 0) or 0) + if dialog_id == 0: + continue + if _normalize_dialog_choice_text(_get_dialog_button_label(button)) == needle: + return dialog_id + return 0 + + def get_active_dialog_choice_id_by_text_with_fallback( + self, + text: Optional[str], + *, + history_limit: int = 25, + ) -> int: + """ + Resolve a choice by text using live labels first, then catalog/history fallbacks. + + This is the safer automation helper when some labels are blank, inline, + or still waiting for decode status to catch up. + """ + needle = _normalize_dialog_choice_text(text) + if not needle or not self.is_dialog_active(): + return 0 + + buttons = list(self.get_active_dialog_buttons()) + if not buttons: + return 0 + + for button in buttons: + dialog_id = int(getattr(button, "dialog_id", 0) or 0) + if dialog_id == 0: + continue + if _normalize_dialog_choice_text(_get_dialog_button_label(button)) == needle: + return dialog_id + + # Resolution order matters: + # 1. live visible labels, + # 2. static catalog / decoded dialog text, + # 3. persisted history scoped to the current NPC/body. + # + # The earlier tiers are cheaper and less ambiguous. History is a recovery path only. + for button in buttons: + dialog_id = int(getattr(button, "dialog_id", 0) or 0) + if dialog_id == 0: + continue + if _normalize_dialog_choice_text(self._get_dialog_choice_catalog_text(dialog_id)) == needle: + return dialog_id + + active_dialog = self.get_active_dialog() + try: + self.sync_dialog_storage(include_raw=False, include_callback_journal=True) + except Exception: + pass + + for button in buttons: + dialog_id = int(getattr(button, "dialog_id", 0) or 0) + if dialog_id == 0: + continue + history_texts = self._get_dialog_choice_history_texts( + dialog_id, + active_dialog=active_dialog, + history_limit=history_limit, + ) + for candidate in history_texts: + if _normalize_dialog_choice_text(candidate) == needle: + return dialog_id + return 0 + + def send_active_dialog_choice_by_text(self, text: Optional[str]) -> bool: + """Send the live visible choice whose label matches `text`.""" + dialog_id = self.get_active_dialog_choice_id_by_text(text) + if dialog_id == 0: + return False + + try: + from .Player import Player + except Exception: + try: + from Player import Player # type: ignore + except Exception: + return False + + try: + Player.SendDialog(dialog_id) + return True + except Exception: + return False + + def send_active_dialog_choice_by_text_with_fallback( + self, + text: Optional[str], + *, + history_limit: int = 25, + ) -> bool: + """Send a choice by text using the fallback resolution path when needed.""" + dialog_id = self.get_active_dialog_choice_id_by_text_with_fallback( + text, + history_limit=history_limit, + ) + if dialog_id == 0: + return False + + try: + from .Player import Player + except Exception: + try: + from Player import Player # type: ignore + except Exception: + return False + + try: + Player.SendDialog(dialog_id) + return True + except Exception: + return False + + def get_dialog_text_decoded(self, dialog_id: int) -> str: + """Return decoded text for a dialog id using the catalog when available.""" + catalog = _get_dialog_catalog_widget() + if catalog is not None: + return catalog.get_dialog_text_decoded(dialog_id) + return _sanitize_dialog_text(_call_native_dialog_method("get_dialog_text_decoded", "", dialog_id)) + + def is_dialog_text_decode_pending(self, dialog_id: int) -> bool: + """Return whether a dialog id is still waiting for decoded text.""" + catalog = _get_dialog_catalog_widget() + if catalog is not None: + return catalog.is_dialog_text_decode_pending(dialog_id) + return bool(_call_native_dialog_method("is_dialog_text_decode_pending", False, dialog_id)) + + def is_dialog_active(self) -> bool: + """Return whether the game currently reports an active dialog screen.""" + return bool(_call_native_dialog_method("is_dialog_active", False)) + + def is_dialog_displayed(self, dialog_id: int) -> bool: + return bool(_call_native_dialog_method("is_dialog_displayed", False, dialog_id)) + + def get_dialog_text_decode_status(self) -> List[DialogTextDecodedInfo]: + """Return decode status rows for dialog ids currently known to the runtime/catalog.""" + catalog = _get_dialog_catalog_widget() + if catalog is not None: + return catalog.get_dialog_text_decode_status() + native_list = _coerce_native_list(_call_native_dialog_method("get_dialog_text_decode_status", [])) + return [DialogTextDecodedInfo(item) for item in native_list] + + def is_dialog_available(self, dialog_id: int) -> bool: + catalog = _get_dialog_catalog_widget() + if catalog is not None: + return catalog.is_dialog_available(dialog_id) + return bool(_call_native_dialog_method("is_dialog_available", False, dialog_id)) + + def get_dialog_info(self, dialog_id: int) -> Optional[DialogInfo]: + """Return static metadata for a dialog id, not the live active dialog screen.""" + catalog = _get_dialog_catalog_widget() + if catalog is not None: + return catalog.get_dialog_info(dialog_id) + native_info = _call_native_dialog_method("get_dialog_info", None, dialog_id) + if native_info is None: + return None + return DialogInfo(native_info) + + def enumerate_available_dialogs(self) -> List[DialogInfo]: + """Enumerate the currently available static dialog catalog entries.""" + catalog = _get_dialog_catalog_widget() + if catalog is not None: + return catalog.enumerate_available_dialogs() + native_list = _coerce_native_list(_call_native_dialog_method("enumerate_available_dialogs", [])) + return [DialogInfo(item) for item in native_list] + + def get_dialog_event_logs(self) -> List: + return _call_native_dialog_method("get_dialog_event_logs", []) + + def get_dialog_event_logs_received(self) -> List: + return _call_native_dialog_method("get_dialog_event_logs_received", []) + + def get_dialog_event_logs_sent(self) -> List: + return _call_native_dialog_method("get_dialog_event_logs_sent", []) + + def clear_dialog_event_logs(self) -> None: + _call_native_dialog_method("clear_dialog_event_logs", None) + + def clear_dialog_event_logs_received(self) -> None: + _call_native_dialog_method("clear_dialog_event_logs_received", None) + + def clear_dialog_event_logs_sent(self) -> None: + _call_native_dialog_method("clear_dialog_event_logs_sent", None) + + def get_dialog_callback_journal(self) -> List[DialogCallbackJournalEntry]: + """Return the full structured callback journal exposed by the native layer.""" + native_list = _coerce_native_list(_call_native_dialog_method("get_dialog_callback_journal", [])) + return [DialogCallbackJournalEntry(item) for item in native_list] + + def get_dialog_callback_journal_received(self) -> List[DialogCallbackJournalEntry]: + native_list = _coerce_native_list(_call_native_dialog_method("get_dialog_callback_journal_received", [])) + return [DialogCallbackJournalEntry(item) for item in native_list] + + def get_dialog_callback_journal_sent(self) -> List[DialogCallbackJournalEntry]: + native_list = _coerce_native_list(_call_native_dialog_method("get_dialog_callback_journal_sent", [])) + return [DialogCallbackJournalEntry(item) for item in native_list] + + def clear_dialog_callback_journal(self) -> None: + _call_native_dialog_method("clear_dialog_callback_journal", None) + + def clear_dialog_callback_journal_received(self) -> None: + _call_native_dialog_method("clear_dialog_callback_journal_received", None) + + def clear_dialog_callback_journal_sent(self) -> None: + _call_native_dialog_method("clear_dialog_callback_journal_sent", None) + + def get_callback_journal( + self, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + ) -> List[DialogCallbackJournalEntry]: + """ + Return filtered callback journal entries from the live native journal buffer. + + Use this when you need recent structured callback events without touching + the SQLite-backed persisted history. + """ + incoming_filter = _normalize_direction_filter(direction) + message_id_filter, event_type_filter = _parse_message_type_filter(message_type) + npc_uid_filter = _normalize_npc_uid_filter(npc_uid) + + if incoming_filter is True: + entries = self.get_dialog_callback_journal_received() + elif incoming_filter is False: + entries = self.get_dialog_callback_journal_sent() + else: + entries = self.get_dialog_callback_journal() + + out: List[DialogCallbackJournalEntry] = [] + for entry in entries: + if npc_uid_filter and entry.npc_uid != npc_uid_filter: + continue + if message_id_filter is not None and entry.message_id != message_id_filter: + continue + if event_type_filter and entry.event_type.lower() != event_type_filter: + continue + out.append(entry) + return out + + def clear_callback_journal( + self, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + ) -> None: + """ + Clear live callback journal entries using the best available native API. + + When filtered clear is unavailable natively, this method falls back to + the older coarse clear behavior. + """ + incoming_filter = _normalize_direction_filter(direction) + message_id_filter, event_type_filter = _parse_message_type_filter(message_type) + npc_uid_filter = _normalize_npc_uid_filter(npc_uid) + + # Fast path keeps backward-compatible clear behavior. + if npc_uid_filter is None and message_id_filter is None and event_type_filter is None: + if incoming_filter is True: + self.clear_dialog_callback_journal_received() + return + if incoming_filter is False: + self.clear_dialog_callback_journal_sent() + return + self.clear_dialog_callback_journal() + return + + if PyDialog is None: + return + + clearer = getattr(PyDialog.PyDialog, "clear_dialog_callback_journal_filtered", None) + if callable(clearer): + clearer( + npc_uid_filter, + incoming_filter, + message_id_filter, + event_type_filter, + ) + return + + # Legacy fallback: if filtered clear is unavailable, keep behavior conservative. + if incoming_filter is True: + self.clear_dialog_callback_journal_received() + elif incoming_filter is False: + self.clear_dialog_callback_journal_sent() + else: + self.clear_dialog_callback_journal() + + def _get_step_pipeline(self): + """Return the integrated SQLite history pipeline instance.""" + return _safe_call(None, get_dialog_step_pipeline) + + def _call_step_pipeline_method( + self, + method_name: str, + *, + default: Any, + sync: bool = False, + sync_include_raw: bool = True, + sync_include_callback_journal: bool = True, + **kwargs: Any, + ) -> Any: + pipeline = self._get_step_pipeline() + if pipeline is None: + return default + if sync: + self.sync_dialog_storage( + include_raw=sync_include_raw, + include_callback_journal=sync_include_callback_journal, + ) + method = getattr(pipeline, method_name, None) + if not callable(method): + return default + return _safe_call(default, lambda: method(**kwargs)) + + def configure_dialog_storage( + self, + *, + db_path: Optional[str] = None, + step_timeout_ms: Optional[int] = None, + ) -> str: + """Configure the SQLite-backed dialog step pipeline and return its DB path.""" + return str( + self._call_step_pipeline_method( + "configure", + default="", + db_path=db_path, + step_timeout_ms=step_timeout_ms, + ) + ) + + def get_dialog_storage_path(self) -> str: + """Return the configured SQLite database path for persisted dialog history.""" + return str(self._call_step_pipeline_method("get_db_path", default="")) + + def sync_dialog_storage( + self, + *, + include_raw: bool = True, + include_callback_journal: bool = True, + ) -> Dict[str, int]: + """ + Snapshot the live native logs into the SQLite-backed persisted dialog store. + + The returned counters are useful for monitors and maintenance scripts that + want to know how many rows were inserted/finalized during the sync. + """ + pipeline = self._get_step_pipeline() + if pipeline is None: + return {"raw_inserted": 0, "journal_inserted": 0, "steps_finalized": 0} + # Sync is snapshot-based: pull the current native in-memory logs, let the pipeline + # deduplicate/finalize them, then query persisted state separately. + raw_events = self.get_dialog_event_logs() if include_raw else None + callback_journal = self.get_dialog_callback_journal() if include_callback_journal else None + return _safe_call( + {"raw_inserted": 0, "journal_inserted": 0, "steps_finalized": 0}, + lambda: pipeline.sync(raw_events=raw_events, callback_journal=callback_journal), + ) + + def flush_dialog_storage(self) -> int: + """Force any pending in-memory dialog steps to be finalized into SQLite.""" + return int(self._call_step_pipeline_method("flush_pending", default=0) or 0) + + def get_persisted_raw_callbacks( + self, + *, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 200, + offset: int = 0, + sync: bool = True, + ) -> List[Dict[str, Any]]: + """Query persisted raw callback rows from the SQLite dialog store.""" + return list( + self._call_step_pipeline_method( + "get_raw_callbacks", + default=[], + sync=sync, + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + ) + ) + + def clear_persisted_raw_callbacks( + self, + *, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + ) -> int: + return int( + self._call_step_pipeline_method( + "clear_raw_callbacks", + default=0, + direction=direction, + message_type=message_type, + ) + or 0 + ) + + def get_persisted_callback_journal( + self, + *, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 200, + offset: int = 0, + sync: bool = True, + ) -> List[Dict[str, Any]]: + """Query persisted structured callback journal rows from SQLite.""" + return list( + self._call_step_pipeline_method( + "get_callback_journal", + default=[], + sync=sync, + npc_uid=npc_uid, + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + ) + ) + + def clear_persisted_callback_journal( + self, + *, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + ) -> int: + return int( + self._call_step_pipeline_method( + "clear_callback_journal", + default=0, + npc_uid=npc_uid, + direction=direction, + message_type=message_type, + ) + or 0 + ) + + def get_dialog_steps( + self, + *, + map_id: Optional[int] = None, + npc_uid_instance: Optional[str] = None, + npc_uid_archetype: Optional[str] = None, + body_dialog_id: Optional[int] = None, + choice_dialog_id: Optional[int] = None, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, + ) -> List[Dict[str, Any]]: + """ + Query persisted dialog steps from SQLite with optional filtering. + + A dialog step is one body screen plus the offered choices and any choice + selected before the next body, timeout, or map change. + """ + # Most callers want fresh persisted history by default. Hot UI paths can pass sync=False + # when they already called `sync_dialog_storage()` for the current frame/tick. + return list( + self._call_step_pipeline_method( + "get_dialog_steps", + default=[], + sync=sync, + map_id=map_id, + npc_uid_instance=npc_uid_instance, + npc_uid_archetype=npc_uid_archetype, + body_dialog_id=body_dialog_id, + choice_dialog_id=choice_dialog_id, + limit=limit, + offset=offset, + include_choices=include_choices, + ) + ) + + def get_dialog_step( + self, step_id: int, *, include_choices: bool = True, sync: bool = True + ) -> Optional[Dict[str, Any]]: + """Return one persisted dialog step by id.""" + result = self._call_step_pipeline_method( + "get_dialog_step", + default=None, + sync=sync, + step_id=int(step_id), + include_choices=include_choices, + ) + return result if isinstance(result, dict) else None + + def get_dialog_steps_by_map( + self, + map_id: int, + *, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, + ) -> List[Dict[str, Any]]: + return self.get_dialog_steps( + map_id=int(map_id), + limit=limit, + offset=offset, + include_choices=include_choices, + sync=sync, + ) + + def get_dialog_steps_by_npc_archetype( + self, + npc_uid_archetype: str, + *, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, + ) -> List[Dict[str, Any]]: + return self.get_dialog_steps( + npc_uid_archetype=npc_uid_archetype, + limit=limit, + offset=offset, + include_choices=include_choices, + sync=sync, + ) + + def get_dialog_steps_by_body_dialog_id( + self, + body_dialog_id: int, + *, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, + ) -> List[Dict[str, Any]]: + return self.get_dialog_steps( + body_dialog_id=int(body_dialog_id), + limit=limit, + offset=offset, + include_choices=include_choices, + sync=sync, + ) + + def get_dialog_steps_by_choice_dialog_id( + self, + choice_dialog_id: int, + *, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, + ) -> List[Dict[str, Any]]: + return self.get_dialog_steps( + choice_dialog_id=int(choice_dialog_id), + limit=limit, + offset=offset, + include_choices=include_choices, + sync=sync, + ) + + def get_dialog_choices(self, step_id: int, *, sync: bool = True) -> List[Dict[str, Any]]: + """Return the persisted choice rows that belong to a dialog step.""" + return list( + self._call_step_pipeline_method( + "get_dialog_choices", + default=[], + sync=sync, + step_id=int(step_id), + ) + ) + + def export_raw_callbacks_json( + self, + path: str, + *, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 10000, + offset: int = 0, + sync: bool = True, + ) -> int: + return int( + self._call_step_pipeline_method( + "export_raw_callbacks_json", + default=0, + sync=sync, + path=path, + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + ) + or 0 + ) + + def export_callback_journal_json( + self, + path: str, + *, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 10000, + offset: int = 0, + sync: bool = True, + ) -> int: + return int( + self._call_step_pipeline_method( + "export_callback_journal_json", + default=0, + sync=sync, + path=path, + npc_uid=npc_uid, + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + ) + or 0 + ) + + def export_dialog_steps_json( + self, + path: str, + *, + map_id: Optional[int] = None, + npc_uid_instance: Optional[str] = None, + npc_uid_archetype: Optional[str] = None, + body_dialog_id: Optional[int] = None, + choice_dialog_id: Optional[int] = None, + limit: int = 5000, + offset: int = 0, + sync: bool = True, + ) -> int: + """Export persisted dialog steps to JSON and return the exported row count.""" + return int( + self._call_step_pipeline_method( + "export_dialog_steps_json", + default=0, + sync=sync, + path=path, + map_id=map_id, + npc_uid_instance=npc_uid_instance, + npc_uid_archetype=npc_uid_archetype, + body_dialog_id=body_dialog_id, + choice_dialog_id=choice_dialog_id, + limit=limit, + offset=offset, + ) + or 0 + ) + + def prune_dialog_logs( + self, + *, + max_raw_rows: Optional[int] = None, + max_journal_rows: Optional[int] = None, + max_step_rows: Optional[int] = None, + older_than_days: Optional[float] = None, + ) -> Dict[str, int]: + """Prune persisted raw, journal, and step rows from the SQLite store.""" + return dict( + self._call_step_pipeline_method( + "prune_dialog_logs", + default={ + "removed_raw_callbacks": 0, + "removed_callback_journal": 0, + "removed_dialog_steps": 0, + "removed_dialog_choices": 0, + }, + max_raw_rows=max_raw_rows, + max_journal_rows=max_journal_rows, + max_step_rows=max_step_rows, + older_than_days=older_than_days, + ) + ) + + def get_dialog_diagnostics( + self, + *, + map_id: Optional[int] = None, + npc_uid_instance: Optional[str] = None, + npc_uid_archetype: Optional[str] = None, + body_dialog_id: Optional[int] = None, + choice_dialog_id: Optional[int] = None, + limit: int = 200, + offset: int = 0, + sync: bool = True, + max_issues: int = 250, + ) -> Dict[str, Any]: + """Run lightweight diagnostics over persisted dialog history rows.""" + steps = self.get_dialog_steps( + map_id=map_id, + npc_uid_instance=npc_uid_instance, + npc_uid_archetype=npc_uid_archetype, + body_dialog_id=body_dialog_id, + choice_dialog_id=choice_dialog_id, + limit=limit, + offset=offset, + include_choices=True, + sync=sync, + ) + return _analyze_dialog_steps(steps, max_issues=max_issues) + +_dialog_widget_instance: Optional[DialogWidget] = None + + +# Module-level convenience wrappers. +def get_dialog_widget() -> DialogWidget: + global _dialog_widget_instance + if _dialog_widget_instance is None: + # Keep a single widget wrapper so module-level helpers share the same lazy-initialized + # native/catalog/pipeline access path instead of each call re-building state. + _dialog_widget_instance = DialogWidget() + return _dialog_widget_instance + + +def get_active_dialog() -> Optional[ActiveDialogInfo]: + return get_dialog_widget().get_active_dialog() + + +def get_active_dialog_buttons() -> List[DialogButtonInfo]: + return get_dialog_widget().get_active_dialog_buttons() + + +def get_last_selected_dialog_id() -> int: + return get_dialog_widget().get_last_selected_dialog_id() + + +def get_active_dialog_choice_id_by_text(text: Optional[str]) -> int: + return get_dialog_widget().get_active_dialog_choice_id_by_text(text) + + +def send_active_dialog_choice_by_text(text: Optional[str]) -> bool: + return get_dialog_widget().send_active_dialog_choice_by_text(text) + + +def get_active_dialog_choice_id_by_text_with_fallback( + text: Optional[str], + *, + history_limit: int = 25, +) -> int: + return get_dialog_widget().get_active_dialog_choice_id_by_text_with_fallback( + text, + history_limit=history_limit, + ) + + +def send_active_dialog_choice_by_text_with_fallback( + text: Optional[str], + *, + history_limit: int = 25, +) -> bool: + return get_dialog_widget().send_active_dialog_choice_by_text_with_fallback( + text, + history_limit=history_limit, + ) + + +def get_dialog_text_decoded(dialog_id: int) -> str: + return get_dialog_widget().get_dialog_text_decoded(dialog_id) + + +def is_dialog_text_decode_pending(dialog_id: int) -> bool: + return get_dialog_widget().is_dialog_text_decode_pending(dialog_id) + + +def is_dialog_active() -> bool: + return get_dialog_widget().is_dialog_active() + + +def is_dialog_displayed(dialog_id: int) -> bool: + return get_dialog_widget().is_dialog_displayed(dialog_id) + + +def get_dialog_text_decode_status() -> List[DialogTextDecodedInfo]: + return get_dialog_widget().get_dialog_text_decode_status() + + +def is_dialog_available(dialog_id: int) -> bool: + return get_dialog_widget().is_dialog_available(dialog_id) + + +def get_dialog_info(dialog_id: int) -> Optional[DialogInfo]: + return get_dialog_widget().get_dialog_info(dialog_id) + + +def enumerate_available_dialogs() -> List[DialogInfo]: + return get_dialog_widget().enumerate_available_dialogs() + + +def get_dialog_event_logs() -> List: + return get_dialog_widget().get_dialog_event_logs() + + +def get_dialog_event_logs_received() -> List: + return get_dialog_widget().get_dialog_event_logs_received() + + +def get_dialog_event_logs_sent() -> List: + return get_dialog_widget().get_dialog_event_logs_sent() + + +def clear_dialog_event_logs() -> None: + get_dialog_widget().clear_dialog_event_logs() + + +def clear_dialog_event_logs_received() -> None: + get_dialog_widget().clear_dialog_event_logs_received() + + +def clear_dialog_event_logs_sent() -> None: + get_dialog_widget().clear_dialog_event_logs_sent() + + +def get_dialog_callback_journal() -> List[DialogCallbackJournalEntry]: + return get_dialog_widget().get_dialog_callback_journal() + + +def get_dialog_callback_journal_received() -> List[DialogCallbackJournalEntry]: + return get_dialog_widget().get_dialog_callback_journal_received() + + +def get_dialog_callback_journal_sent() -> List[DialogCallbackJournalEntry]: + return get_dialog_widget().get_dialog_callback_journal_sent() + + +def clear_dialog_callback_journal() -> None: + get_dialog_widget().clear_dialog_callback_journal() + + +def clear_dialog_callback_journal_received() -> None: + get_dialog_widget().clear_dialog_callback_journal_received() + + +def clear_dialog_callback_journal_sent() -> None: + get_dialog_widget().clear_dialog_callback_journal_sent() + + +def get_callback_journal( + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, +) -> List[DialogCallbackJournalEntry]: + return get_dialog_widget().get_callback_journal( + npc_uid=npc_uid, + direction=direction, + message_type=message_type, + ) + + +def clear_callback_journal( + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, +) -> None: + get_dialog_widget().clear_callback_journal( + npc_uid=npc_uid, + direction=direction, + message_type=message_type, + ) + + +def configure_dialog_storage( + *, + db_path: Optional[str] = None, + step_timeout_ms: Optional[int] = None, +) -> str: + return get_dialog_widget().configure_dialog_storage( + db_path=db_path, + step_timeout_ms=step_timeout_ms, + ) + + +def get_dialog_storage_path() -> str: + return get_dialog_widget().get_dialog_storage_path() + + +def sync_dialog_storage( + *, + include_raw: bool = True, + include_callback_journal: bool = True, +) -> Dict[str, int]: + return get_dialog_widget().sync_dialog_storage( + include_raw=include_raw, + include_callback_journal=include_callback_journal, + ) + + +def flush_dialog_storage() -> int: + return get_dialog_widget().flush_dialog_storage() + + +def get_persisted_raw_callbacks( + *, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 200, + offset: int = 0, + sync: bool = True, +) -> List[Dict[str, Any]]: + return get_dialog_widget().get_persisted_raw_callbacks( + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + sync=sync, + ) + + +def clear_persisted_raw_callbacks( + *, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, +) -> int: + return get_dialog_widget().clear_persisted_raw_callbacks( + direction=direction, + message_type=message_type, + ) + + +def get_persisted_callback_journal( + *, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 200, + offset: int = 0, + sync: bool = True, +) -> List[Dict[str, Any]]: + return get_dialog_widget().get_persisted_callback_journal( + npc_uid=npc_uid, + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + sync=sync, + ) + + +def clear_persisted_callback_journal( + *, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, +) -> int: + return get_dialog_widget().clear_persisted_callback_journal( + npc_uid=npc_uid, + direction=direction, + message_type=message_type, + ) + + +def get_dialog_steps( + *, + map_id: Optional[int] = None, + npc_uid_instance: Optional[str] = None, + npc_uid_archetype: Optional[str] = None, + body_dialog_id: Optional[int] = None, + choice_dialog_id: Optional[int] = None, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, +) -> List[Dict[str, Any]]: + return get_dialog_widget().get_dialog_steps( + map_id=map_id, + npc_uid_instance=npc_uid_instance, + npc_uid_archetype=npc_uid_archetype, + body_dialog_id=body_dialog_id, + choice_dialog_id=choice_dialog_id, + limit=limit, + offset=offset, + include_choices=include_choices, + sync=sync, + ) + + +def get_dialog_step(step_id: int, *, include_choices: bool = True, sync: bool = True) -> Optional[Dict[str, Any]]: + return get_dialog_widget().get_dialog_step( + step_id=step_id, + include_choices=include_choices, + sync=sync, + ) + + +def get_dialog_steps_by_map( + map_id: int, + *, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, +) -> List[Dict[str, Any]]: + return get_dialog_widget().get_dialog_steps_by_map( + map_id=map_id, + limit=limit, + offset=offset, + include_choices=include_choices, + sync=sync, + ) + + +def get_dialog_steps_by_npc_archetype( + npc_uid_archetype: str, + *, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, +) -> List[Dict[str, Any]]: + return get_dialog_widget().get_dialog_steps_by_npc_archetype( + npc_uid_archetype=npc_uid_archetype, + limit=limit, + offset=offset, + include_choices=include_choices, + sync=sync, + ) + + +def get_dialog_steps_by_body_dialog_id( + body_dialog_id: int, + *, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, +) -> List[Dict[str, Any]]: + return get_dialog_widget().get_dialog_steps_by_body_dialog_id( + body_dialog_id=body_dialog_id, + limit=limit, + offset=offset, + include_choices=include_choices, + sync=sync, + ) + + +def get_dialog_steps_by_choice_dialog_id( + choice_dialog_id: int, + *, + limit: int = 200, + offset: int = 0, + include_choices: bool = True, + sync: bool = True, +) -> List[Dict[str, Any]]: + return get_dialog_widget().get_dialog_steps_by_choice_dialog_id( + choice_dialog_id=choice_dialog_id, + limit=limit, + offset=offset, + include_choices=include_choices, + sync=sync, + ) + + +def get_dialog_choices(step_id: int, *, sync: bool = True) -> List[Dict[str, Any]]: + return get_dialog_widget().get_dialog_choices(step_id=step_id, sync=sync) + + +def export_raw_callbacks_json( + path: str, + *, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 10000, + offset: int = 0, + sync: bool = True, +) -> int: + return get_dialog_widget().export_raw_callbacks_json( + path=path, + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + sync=sync, + ) + + +def export_callback_journal_json( + path: str, + *, + npc_uid: Optional[str] = None, + direction: Optional[str] = "all", + message_type: Optional[Any] = None, + limit: int = 10000, + offset: int = 0, + sync: bool = True, +) -> int: + return get_dialog_widget().export_callback_journal_json( + path=path, + npc_uid=npc_uid, + direction=direction, + message_type=message_type, + limit=limit, + offset=offset, + sync=sync, + ) + + +def export_dialog_steps_json( + path: str, + *, + map_id: Optional[int] = None, + npc_uid_instance: Optional[str] = None, + npc_uid_archetype: Optional[str] = None, + body_dialog_id: Optional[int] = None, + choice_dialog_id: Optional[int] = None, + limit: int = 5000, + offset: int = 0, + sync: bool = True, +) -> int: + return get_dialog_widget().export_dialog_steps_json( + path=path, + map_id=map_id, + npc_uid_instance=npc_uid_instance, + npc_uid_archetype=npc_uid_archetype, + body_dialog_id=body_dialog_id, + choice_dialog_id=choice_dialog_id, + limit=limit, + offset=offset, + sync=sync, + ) + + +def prune_dialog_logs( + *, + max_raw_rows: Optional[int] = None, + max_journal_rows: Optional[int] = None, + max_step_rows: Optional[int] = None, + older_than_days: Optional[float] = None, +) -> Dict[str, int]: + return get_dialog_widget().prune_dialog_logs( + max_raw_rows=max_raw_rows, + max_journal_rows=max_journal_rows, + max_step_rows=max_step_rows, + older_than_days=older_than_days, + ) + + +def get_dialog_diagnostics( + *, + map_id: Optional[int] = None, + npc_uid_instance: Optional[str] = None, + npc_uid_archetype: Optional[str] = None, + body_dialog_id: Optional[int] = None, + choice_dialog_id: Optional[int] = None, + limit: int = 200, + offset: int = 0, + sync: bool = True, + max_issues: int = 250, +) -> Dict[str, Any]: + return get_dialog_widget().get_dialog_diagnostics( + map_id=map_id, + npc_uid_instance=npc_uid_instance, + npc_uid_archetype=npc_uid_archetype, + body_dialog_id=body_dialog_id, + choice_dialog_id=choice_dialog_id, + limit=limit, + offset=offset, + sync=sync, + max_issues=max_issues, + ) diff --git a/Py4GWCoreLib/DialogCatalog.py b/Py4GWCoreLib/DialogCatalog.py new file mode 100644 index 000000000..3664fb974 --- /dev/null +++ b/Py4GWCoreLib/DialogCatalog.py @@ -0,0 +1,133 @@ +""" +Static dialog catalog wrapper for the native PyDialogCatalog C++ module. +This module owns static dialog metadata and text lookup helpers. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import List, Optional + +try: + from .Dialog import DialogInfo, DialogTextDecodedInfo, _sanitize_dialog_text +except Exception: + from Dialog import DialogInfo, DialogTextDecodedInfo, _sanitize_dialog_text # type: ignore + +try: + import PyDialogCatalog +except Exception as exc: # pragma: no cover - runtime environment specific + PyDialogCatalog = None + _PYDIALOGCATALOG_IMPORT_ERROR = exc +else: + _PYDIALOGCATALOG_IMPORT_ERROR = None + + +def _wrap_dialog_info(native_info) -> Optional[DialogInfo]: + if native_info is None: + return None + if hasattr(native_info, "dialog_id"): + return DialogInfo(native_info) + if isinstance(native_info, dict): + return DialogInfo(SimpleNamespace(**native_info)) + return None + + +def _wrap_decode_status(native_info) -> Optional[DialogTextDecodedInfo]: + if native_info is None: + return None + if hasattr(native_info, "dialog_id"): + return DialogTextDecodedInfo(native_info) + if isinstance(native_info, dict): + return DialogTextDecodedInfo(SimpleNamespace(**native_info)) + return None + + +class DialogCatalogWidget: + """High-level wrapper around the native PyDialogCatalog module.""" + + def is_dialog_available(self, dialog_id: int) -> bool: + if PyDialogCatalog is None: + return False + return bool(PyDialogCatalog.PyDialogCatalog.is_dialog_available(dialog_id)) + + def get_dialog_info(self, dialog_id: int) -> Optional[DialogInfo]: + if PyDialogCatalog is None: + return None + return _wrap_dialog_info(PyDialogCatalog.PyDialogCatalog.get_dialog_info(dialog_id)) + + def enumerate_available_dialogs(self) -> List[DialogInfo]: + if PyDialogCatalog is None: + return [] + native_list = PyDialogCatalog.PyDialogCatalog.enumerate_available_dialogs() + out: List[DialogInfo] = [] + for item in native_list: + wrapped = _wrap_dialog_info(item) + if wrapped is not None: + out.append(wrapped) + return out + + def get_dialog_text_decoded(self, dialog_id: int) -> str: + if PyDialogCatalog is None: + return "" + return _sanitize_dialog_text(PyDialogCatalog.PyDialogCatalog.get_dialog_text_decoded(dialog_id)) + + def is_dialog_text_decode_pending(self, dialog_id: int) -> bool: + if PyDialogCatalog is None: + return False + return bool(PyDialogCatalog.PyDialogCatalog.is_dialog_text_decode_pending(dialog_id)) + + def get_dialog_text_decode_status(self) -> List[DialogTextDecodedInfo]: + if PyDialogCatalog is None: + return [] + native_list = PyDialogCatalog.PyDialogCatalog.get_dialog_text_decode_status() + out: List[DialogTextDecodedInfo] = [] + for item in native_list: + wrapped = _wrap_decode_status(item) + if wrapped is not None: + out.append(wrapped) + return out + + def clear_cache(self) -> None: + if PyDialogCatalog is None: + return + clearer = getattr(PyDialogCatalog.PyDialogCatalog, "clear_cache", None) + if callable(clearer): + clearer() + + +_dialog_catalog_widget: Optional[DialogCatalogWidget] = None + + +def get_dialog_catalog_widget() -> DialogCatalogWidget: + global _dialog_catalog_widget + if _dialog_catalog_widget is None: + _dialog_catalog_widget = DialogCatalogWidget() + return _dialog_catalog_widget + + +def is_dialog_available(dialog_id: int) -> bool: + return get_dialog_catalog_widget().is_dialog_available(dialog_id) + + +def get_dialog_info(dialog_id: int) -> Optional[DialogInfo]: + return get_dialog_catalog_widget().get_dialog_info(dialog_id) + + +def enumerate_available_dialogs() -> List[DialogInfo]: + return get_dialog_catalog_widget().enumerate_available_dialogs() + + +def get_dialog_text_decoded(dialog_id: int) -> str: + return get_dialog_catalog_widget().get_dialog_text_decoded(dialog_id) + + +def is_dialog_text_decode_pending(dialog_id: int) -> bool: + return get_dialog_catalog_widget().is_dialog_text_decode_pending(dialog_id) + + +def get_dialog_text_decode_status() -> List[DialogTextDecodedInfo]: + return get_dialog_catalog_widget().get_dialog_text_decode_status() + + +def clear_cache() -> None: + get_dialog_catalog_widget().clear_cache() diff --git a/Widgets/Automation/Bots/Missions/Zaishen Bounty Quest Taker.py b/Widgets/Automation/Bots/Missions/Zaishen Bounty Quest Taker.py new file mode 100644 index 000000000..f1c5c9323 --- /dev/null +++ b/Widgets/Automation/Bots/Missions/Zaishen Bounty Quest Taker.py @@ -0,0 +1,1159 @@ +from __future__ import annotations + +import time +from typing import Any, Dict, List + +# import PyImGui +from Py4GWCoreLib import Agent, AgentArray, Botting, Color, Dialog, GLOBAL_CACHE, ImGui, Map, Player, Py4GW, PyImGui, Routines, UIManager + +MODULE_NAME = "Zaishen Quest Taker" +MODULE_ICON = "Textures\\Module_Icons\\Quest Auto Runner.png" +EMBARK_BEACH = 857 +QUEST_TARGET_COUNT = 3 +QUEST_TYPE_SEQUENCE = [ + "Zaishen Mission", + "Zaishen Bounty", + "Zaishen Vanquish", +] +QUEST_LOG_REFRESH_TIMEOUT_MS = 800 +QUEST_VERIFY_TIMEOUT_SECONDS = 2.0 +POST_DIALOG_SEND_WAIT_MS = 200 +CLUSTER_NAME_BY_ORDINAL = { + 1: "Northwest", + 2: "Northeast", + 3: "Southeast", + 4: "Southwest", +} +ZAISHEN_QUEST_LIMIT_MESSAGES = { + "The Zaishen only allow 3 missions to be undertaken at one time.", + "Zaishen bounties are limited to 3 at one time.", + "Zaishen battle assignments are limited to 3 at one time.", +} +ZAISHEN_NO_MORE_QUESTS_MESSAGES = { + "There are no more quests available here today, but other signs may have more postings from the Zaishen.", +} +ZAISHEN_PENDING_OBJECTIVE_MESSAGES = { + "There are still threats that remain. Return when you have slain all of the foes that await you at your destination." +} +ZAISHEN_DECLINE_MESSAGES = { + "No, I'm way too busy today.", + "No, I am way too busy today.", +} + +RECORDED_ZAISHEN_ROUTE = [ + {'index': 1, 'kind': 'step', 'label': 'Step 1', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -3410.77, 'player_y': 416.35, 'player_z': -412.13}, + {'index': 2, 'kind': 'npc', 'label': 'NPC 2', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -3410.77, 'player_y': 416.35, 'player_z': -412.13, 'target_id': 13, 'target_name': 'Zaishen Mission', 'target_model_id': 1197, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': -3512.0, 'target_y': 460.0, 'target_z': -411.22}, + {'index': 3, 'kind': 'step', 'label': 'Step 3', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -3292.84, 'player_y': 573.26, 'player_z': -411.65}, + {'index': 4, 'kind': 'npc', 'label': 'NPC 4', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -3292.84, 'player_y': 573.26, 'player_z': -411.65, 'target_id': 14, 'target_name': 'Zaishen Bounty', 'target_model_id': 1198, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': -3379.0, 'target_y': 636.0, 'target_z': -411.78}, + {'index': 5, 'kind': 'step', 'label': 'Step 5', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -3204.26, 'player_y': 737.57, 'player_z': -412.44}, + {'index': 6, 'kind': 'npc', 'label': 'NPC 6', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -3204.26, 'player_y': 737.57, 'player_z': -412.44, 'target_id': 15, 'target_name': 'Zaishen Vanquish', 'target_model_id': 1200, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': -3284.0, 'target_y': 793.0, 'target_z': -412.76}, + {'index': 7, 'kind': 'step', 'label': 'Step 7', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -2960.45, 'player_y': 905.27, 'player_z': -412.0}, + {'index': 8, 'kind': 'step', 'label': 'Step 8', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -2056.49, 'player_y': 535.82, 'player_z': -412.24}, + {'index': 9, 'kind': 'step', 'label': 'Step 9', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -562.97, 'player_y': 154.79, 'player_z': -320.54}, + {'index': 10, 'kind': 'step', 'label': 'Step 10', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 1422.99, 'player_y': 172.28, 'player_z': -562.93}, + {'index': 11, 'kind': 'step', 'label': 'Step 11', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2271.03, 'player_y': 477.33, 'player_z': -560.87}, + {'index': 12, 'kind': 'step', 'label': 'Step 12', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2372.89, 'player_y': 2358.08, 'player_z': -993.67}, + {'index': 13, 'kind': 'step', 'label': 'Step 13', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2801.81, 'player_y': 3058.28, 'player_z': -971.8}, + {'index': 14, 'kind': 'step', 'label': 'Step 14', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 42, 'player_x': 2617.1, 'player_y': 3333.74, 'player_z': -886.97}, + {'index': 15, 'kind': 'npc', 'label': 'NPC 15', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 42, 'player_x': 2617.1, 'player_y': 3333.74, 'player_z': -886.97, 'target_id': 6, 'target_name': 'Zaishen Mission', 'target_model_id': 1197, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': 2700.0, 'target_y': 3278.0, 'target_z': -909.84}, + {'index': 16, 'kind': 'step', 'label': 'Step 16', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 42, 'player_x': 2452.44, 'player_y': 3090.11, 'player_z': -889.91}, + {'index': 17, 'kind': 'npc', 'label': 'NPC 17', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 42, 'player_x': 2452.44, 'player_y': 3090.11, 'player_z': -889.91, 'target_id': 7, 'target_name': 'Zaishen Bounty', 'target_model_id': 1198, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': 2533.0, 'target_y': 3025.0, 'target_z': -908.36}, + {'index': 18, 'kind': 'step', 'label': 'Step 18', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 42, 'player_x': 2235.34, 'player_y': 2830.28, 'player_z': -877.92}, + {'index': 19, 'kind': 'npc', 'label': 'NPC 19', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 42, 'player_x': 2235.34, 'player_y': 2830.28, 'player_z': -877.92, 'target_id': 8, 'target_name': 'Zaishen Vanquish', 'target_model_id': 1200, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': 2331.0, 'target_y': 2778.0, 'target_z': -904.63}, + {'index': 20, 'kind': 'step', 'label': 'Step 20', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2341.21, 'player_y': 2009.28, 'player_z': -1014.04}, + {'index': 21, 'kind': 'step', 'label': 'Step 21', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2267.43, 'player_y': -265.09, 'player_z': -440.24}, + {'index': 22, 'kind': 'step', 'label': 'Step 22', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2410.09, 'player_y': -1816.31, 'player_z': -79.93}, + {'index': 23, 'kind': 'step', 'label': 'Step 23', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2660.43, 'player_y': -2313.34, 'player_z': -84.99}, + {'index': 24, 'kind': 'npc', 'label': 'NPC 24', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2660.43, 'player_y': -2313.34, 'player_z': -84.99, 'target_id': 58, 'target_name': 'Zaishen Mission', 'target_model_id': 1197, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': 2702.0, 'target_y': -2411.0, 'target_z': -85.0}, + {'index': 25, 'kind': 'step', 'label': 'Step 25', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2535.92, 'player_y': -2416.33, 'player_z': -85.08}, + {'index': 26, 'kind': 'npc', 'label': 'NPC 26', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2535.92, 'player_y': -2416.33, 'player_z': -85.08, 'target_id': 56, 'target_name': 'Zaishen Bounty', 'target_model_id': 1198, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': 2603.0, 'target_y': -2500.0, 'target_z': -85.0}, + {'index': 27, 'kind': 'step', 'label': 'Step 27', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2426.98, 'player_y': -2537.65, 'player_z': -83.49}, + {'index': 28, 'kind': 'npc', 'label': 'NPC 28', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 2426.98, 'player_y': -2537.65, 'player_z': -83.49, 'target_id': 57, 'target_name': 'Zaishen Vanquish', 'target_model_id': 1200, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': 2505.0, 'target_y': -2610.0, 'target_z': -85.41}, + {'index': 29, 'kind': 'step', 'label': 'Step 29', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 1761.28, 'player_y': -2500.37, 'player_z': -107.32}, + {'index': 30, 'kind': 'step', 'label': 'Step 30', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': 870.37, 'player_y': -2776.29, 'player_z': -56.57}, + {'index': 31, 'kind': 'step', 'label': 'Step 31', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -195.43, 'player_y': -3501.85, 'player_z': -98.08}, + {'index': 32, 'kind': 'npc', 'label': 'NPC 32', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -195.43, 'player_y': -3501.85, 'player_z': -98.08, 'target_id': 7, 'target_name': 'Zaishen Mission', 'target_model_id': 1197, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': -277.0, 'target_y': -3561.0, 'target_z': -101.44}, + {'index': 33, 'kind': 'step', 'label': 'Step 33', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -345.17, 'player_y': -3373.6, 'player_z': -96.07}, + {'index': 34, 'kind': 'npc', 'label': 'NPC 34', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -345.17, 'player_y': -3373.6, 'player_z': -96.07, 'target_id': 9, 'target_name': 'Zaishen Vanquish', 'target_model_id': 1200, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': -428.0, 'target_y': -3439.0, 'target_z': -99.12}, + {'index': 35, 'kind': 'step', 'label': 'Step 35', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -491.14, 'player_y': -3260.51, 'player_z': -94.82}, + {'index': 36, 'kind': 'npc', 'label': 'NPC 36', 'map_id': 857, 'map_name': 'Embark Beach', 'player_agent_id': 1, 'player_x': -491.14, 'player_y': -3260.51, 'player_z': -94.82, 'target_id': 8, 'target_name': 'Zaishen Bounty', 'target_model_id': 1198, 'target_is_npc': True, 'target_allegiance': 'NPC/Minipet', 'target_x': -557.0, 'target_y': -3333.0, 'target_z': -94.42}, +] + + +def _route_point_from_record(record: Dict[str, Any]) -> tuple[float, float]: + return ( + float(record.get("player_x", 0.0) or 0.0), + float(record.get("player_y", 0.0) or 0.0), + ) + + +def _distance_xy(point_a: tuple[float, float], point_b: tuple[float, float]) -> float: + dx = float(point_a[0]) - float(point_b[0]) + dy = float(point_a[1]) - float(point_b[1]) + return (dx * dx + dy * dy) ** 0.5 + + +def _build_cluster_catalog_from_recorded_route() -> List[Dict[str, Any]]: + clusters: Dict[int, Dict[str, Any]] = {} + name_counts: Dict[str, int] = {} + + for record in RECORDED_ZAISHEN_ROUTE: + if int(record.get("map_id", 0) or 0) != EMBARK_BEACH: + continue + if str(record.get("kind", "") or "").strip().lower() != "npc": + continue + + npc_name = str(record.get("target_name", "") or "").strip() + if not npc_name: + continue + + ordinal = int(name_counts.get(npc_name, 0)) + 1 + name_counts[npc_name] = ordinal + cluster = clusters.setdefault( + ordinal, + { + "cluster_index": ordinal, + "cluster_name": CLUSTER_NAME_BY_ORDINAL.get(ordinal, f"Cluster {ordinal}"), + "targets": [], + }, + ) + + approach_xy = _route_point_from_record(record) + npc_xy = ( + float(record.get("target_x", 0.0) or 0.0), + float(record.get("target_y", 0.0) or 0.0), + ) + cluster["targets"].append( + { + "label": f"{npc_name} ({cluster['cluster_name']})", + "npc_name": npc_name, + "cluster_index": ordinal, + "cluster_name": cluster["cluster_name"], + "model_id": int(record.get("target_model_id", 0) or 0), + "route_points": [approach_xy], + "approach_xy": approach_xy, + "npc_xy": npc_xy, + } + ) + + ordered_clusters: List[Dict[str, Any]] = [] + for ordinal in sorted(clusters): + cluster = clusters[ordinal] + targets = list(cluster.get("targets", []) or []) + if targets: + center_x = sum(float(target["npc_xy"][0]) for target in targets) / len(targets) + center_y = sum(float(target["npc_xy"][1]) for target in targets) / len(targets) + cluster["center_xy"] = (center_x, center_y) + else: + cluster["center_xy"] = (0.0, 0.0) + ordered_clusters.append(cluster) + return ordered_clusters + + +def _select_cluster_for_spawn(player_xy: tuple[float, float]) -> Dict[str, Any] | None: + if not ZAISHEN_CLUSTERS: + return None + return min( + ZAISHEN_CLUSTERS, + key=lambda cluster: min( + _distance_xy(player_xy, tuple(target.get("approach_xy", cluster.get("center_xy", (0.0, 0.0))))) + for target in (cluster.get("targets", []) or []) + ), + ) + + +def _build_targets_for_cluster(cluster: Dict[str, Any], start_xy: tuple[float, float]) -> List[Dict[str, Any]]: + _ = start_xy + raw_targets = [dict(target) for target in (cluster.get("targets", []) or [])] + target_by_name = { + str(target.get("npc_name", "") or "").strip(): target + for target in raw_targets + } + ordered_targets: List[Dict[str, Any]] = [] + for quest_name in QUEST_TYPE_SEQUENCE: + target = target_by_name.get(quest_name) + if target is None: + return [] + ordered_targets.append(target) + for index, target in enumerate(ordered_targets, start=1): + target["run_order"] = index + return ordered_targets + + +ZAISHEN_CLUSTERS = _build_cluster_catalog_from_recorded_route() + +bot = Botting(MODULE_NAME, config_movement_timeout=20000) + + +class ZaishenBountyState: + def __init__(self) -> None: + self.confirmation_text = "I can do that!" + self.move_timeout_ms = 20000 + self.dialog_timeout_ms = 7000 + self.quest_targets: List[Dict[str, Any]] = [] + self.selected_cluster_name = "" + self.spawn_xy = (0.0, 0.0) + self.current_npc_name = "" + self.current_npc_ordinal = 0 + self.current_target_model_id = 0 + self.current_cluster_name = "" + self.current_route_points: List[tuple[float, float]] = [] + self.first_offer_dialog_id = 0 + self.offered_quest_name = "" + self.confirmation_dialog_id = 0 + self.offer_is_confirmation_step = False + self.npc_agent_id = 0 + self.npc_xy = (0.0, 0.0) + self.skip_current_npc = False + self.last_status = "Idle" + self.last_choices: List[str] = [] + self.last_quest_log_names: List[str] = [] + self.completed_results: List[str] = [] + self.current_pass_label = "initial" + self.successful_quest_names: List[str] = [] + self.retryable_failed_quest_names: List[str] = [] + self.final_retry_targets: List[str] = [] + self.last_error = "" + self.stop_requested = False + + def reset_run_state(self) -> None: + self.quest_targets = [] + self.selected_cluster_name = "" + self.spawn_xy = (0.0, 0.0) + self.current_npc_name = "" + self.current_npc_ordinal = 0 + self.current_target_model_id = 0 + self.current_cluster_name = "" + self.current_route_points = [] + self.first_offer_dialog_id = 0 + self.offered_quest_name = "" + self.confirmation_dialog_id = 0 + self.offer_is_confirmation_step = False + self.npc_agent_id = 0 + self.npc_xy = (0.0, 0.0) + self.skip_current_npc = False + self.last_choices = [] + self.last_quest_log_names = [] + self.completed_results = [] + self.current_pass_label = "initial" + self.successful_quest_names = [] + self.retryable_failed_quest_names = [] + self.final_retry_targets = [] + self.last_error = "" + self.stop_requested = False + + def config_error(self) -> str: + if not ZAISHEN_CLUSTERS: + return "Recorded Zaishen cluster data is empty." + for cluster in ZAISHEN_CLUSTERS: + cluster_names = { + str(target.get("npc_name", "") or "").strip() + for target in (cluster.get("targets", []) or []) + } + if any(required_name not in cluster_names for required_name in QUEST_TYPE_SEQUENCE): + return "Recorded Zaishen cluster data is incomplete." + return "" + + def quest_log_ids(self) -> List[int]: + try: + return [int(qid) for qid in (GLOBAL_CACHE.Quest.GetQuestLogIds() or [])] + except Exception: + return [] + + def set_status(self, message: str, *, error: bool = False) -> None: + self.last_status = message + if error: + self.last_error = message + Py4GW.Console.Log(MODULE_NAME, message, Py4GW.Console.MessageType.Error) + else: + Py4GW.Console.Log(MODULE_NAME, message, Py4GW.Console.MessageType.Info) + + def begin_npc(self, target: dict, *, pass_label: str = "initial") -> None: + self.current_npc_name = str(target.get("npc_name", "") or "") + self.current_npc_ordinal = int(target.get("run_order", 0) or 0) + self.current_target_model_id = int(target.get("model_id", 0) or 0) + self.current_cluster_name = str(target.get("cluster_name", "") or "") + self.current_route_points = [ + (float(point[0]), float(point[1])) + for point in (target.get("route_points", []) or []) + if isinstance(point, (list, tuple)) and len(point) >= 2 + ] + self.first_offer_dialog_id = 0 + self.offered_quest_name = "" + self.confirmation_dialog_id = 0 + self.offer_is_confirmation_step = False + self.npc_agent_id = 0 + npc_xy = target.get("npc_xy", (0.0, 0.0)) + self.npc_xy = (float(npc_xy[0]), float(npc_xy[1])) if isinstance(npc_xy, (list, tuple)) and len(npc_xy) >= 2 else (0.0, 0.0) + self.skip_current_npc = False + self.last_choices = [] + self.current_pass_label = str(pass_label or "initial") + + def append_result(self, message: str) -> None: + self.completed_results.append(message) + + def record_quest_failure(self, *, retryable: bool = True) -> None: + quest_name = str(self.current_npc_name or "").strip() + if not quest_name: + return + if self.current_pass_label == "final retry": + return + if retryable and quest_name not in self.retryable_failed_quest_names and quest_name not in self.successful_quest_names: + self.retryable_failed_quest_names.append(quest_name) + + def record_quest_success(self) -> None: + quest_name = str(self.current_npc_name or "").strip() + if not quest_name: + return + if quest_name not in self.successful_quest_names: + self.successful_quest_names.append(quest_name) + self.retryable_failed_quest_names = [ + name for name in self.retryable_failed_quest_names if name != quest_name + ] + self.final_retry_targets = [ + name for name in self.final_retry_targets if name != quest_name + ] + + +state = ZaishenBountyState() + + +def _normalize_dialog_label(value: str) -> str: + return " ".join(str(value or "").strip().lower().split()) + + +def _normalize_quest_name(value: str) -> str: + return " ".join(str(value or "").strip().lower().split()) + + +def _button_text(button) -> str: + return str(getattr(button, "message_decoded", "") or getattr(button, "message", "") or "").strip() + + +def _dialog_text_from_catalog(dialog_id: int) -> str: + if int(dialog_id) == 0: + return "" + try: + dialog_info = Dialog.get_dialog_info(int(dialog_id)) + content = str(getattr(dialog_info, "content", "") or "").strip() if dialog_info is not None else "" + if content: + return content + except Exception: + pass + try: + decoded = str(Dialog.get_dialog_text_decoded(int(dialog_id)) or "").strip() + if decoded: + return decoded + except Exception: + pass + return "" + + +def _current_target_label() -> str: + if state.current_npc_name and state.current_cluster_name: + return f"{state.current_npc_name} ({state.current_cluster_name})" + return state.current_npc_name or "Current NPC" + + +def _current_attempt_label() -> str: + label = _current_target_label() + if state.current_pass_label == "final retry": + return f"{label} [final retry]" + return label + + +def _get_selected_target_by_name(quest_name: str) -> Dict[str, Any] | None: + normalized = str(quest_name or "").strip() + for target in state.quest_targets: + if str(target.get("npc_name", "") or "").strip() == normalized: + return target + return None + + +def _active_dialog_message() -> str: + active_dialog = Dialog.get_active_dialog() + if active_dialog is None: + return "" + return str(getattr(active_dialog, "message", "") or "").strip() + + +def _is_zaishen_quest_limit_message(message: str) -> bool: + normalized = _normalize_dialog_label(message) + return any(normalized == _normalize_dialog_label(candidate) for candidate in ZAISHEN_QUEST_LIMIT_MESSAGES) + + +def _classify_zaishen_dialog_rejection(message: str) -> tuple[str, bool, bool] | None: + normalized = _normalize_dialog_label(message) + if not normalized: + return None + if any(normalized == _normalize_dialog_label(candidate) for candidate in ZAISHEN_QUEST_LIMIT_MESSAGES): + return ("quest limit reached", False, False) + if any(normalized == _normalize_dialog_label(candidate) for candidate in ZAISHEN_NO_MORE_QUESTS_MESSAGES): + return ("no more Zaishen quests are available from this sign today", False, False) + if any(normalized == _normalize_dialog_label(candidate) for candidate in ZAISHEN_PENDING_OBJECTIVE_MESSAGES): + return ("current Zaishen objective is still active and must be finished first", False, False) + return None + + +def _is_confirmation_button_text(value: str) -> bool: + return _normalize_dialog_label(value) == _normalize_dialog_label(state.confirmation_text) + + +def _is_decline_button_text(value: str) -> bool: + normalized = _normalize_dialog_label(value) + return any(normalized == _normalize_dialog_label(candidate) for candidate in ZAISHEN_DECLINE_MESSAGES) + + +def _dialog_is_open() -> bool: + return bool(UIManager.IsNPCDialogVisible() or Dialog.is_dialog_active()) + + +def _active_dialog_agent_id() -> int: + try: + active_dialog = Dialog.get_active_dialog() + return int(getattr(active_dialog, "agent_id", 0) or 0) if active_dialog is not None else 0 + except Exception: + return 0 + + +def _dialog_belongs_to_current_npc() -> bool: + if state.npc_agent_id == 0: + return False + agent_id = _active_dialog_agent_id() + return int(agent_id) != 0 and int(agent_id) == int(state.npc_agent_id) + + +def _current_npc_dialog_is_ready() -> bool: + return _dialog_is_open() and _dialog_belongs_to_current_npc() + + +def _distance_to_current_npc() -> float: + if state.npc_agent_id == 0: + return float("inf") + try: + player_x, player_y = Player.GetXY() + npc_x, npc_y = Agent.GetXY(state.npc_agent_id) + except Exception: + return float("inf") + dx = float(player_x) - float(npc_x) + dy = float(player_y) - float(npc_y) + return (dx * dx + dy * dy) ** 0.5 + + +def _resolve_offered_quest_name(button) -> str: + button_text = _button_text(button) + if button_text and not _is_confirmation_button_text(button_text): + return button_text + + catalog_text = _dialog_text_from_catalog(int(getattr(button, "dialog_id", 0) or 0)) + if catalog_text and not _is_confirmation_button_text(catalog_text): + return catalog_text + + active_message = _active_dialog_message() if _current_npc_dialog_is_ready() else "" + if active_message and not _is_confirmation_button_text(active_message) and not _is_zaishen_quest_limit_message(active_message): + return active_message + + return "" + + +def _yield_stop_with_status(message: str, *, error: bool = False): + state.set_status(message, error=error) + state.stop_requested = True + yield + + +def _yield_skip_current_npc( + message: str, + *, + error: bool = False, + retryable: bool = True, + counts_as_failure: bool = True, +): + result = f"{_current_attempt_label()}: {message}" + state.skip_current_npc = True + if counts_as_failure: + state.record_quest_failure(retryable=retryable) + state.append_result(result) + state.set_status(result, error=error) + yield from Routines.Yield.wait(100) + + +def _refresh_quest_log_names(timeout_ms: int = QUEST_LOG_REFRESH_TIMEOUT_MS): + state.last_quest_log_names = [] + quest_ids = state.quest_log_ids() + if not quest_ids: + yield + return + + for quest_id in quest_ids: + try: + GLOBAL_CACHE.Quest.RequestQuestName(quest_id) + except Exception: + pass + + pending = set(int(quest_id) for quest_id in quest_ids) + collected: List[str] = [] + deadline = time.monotonic() + (max(250, int(timeout_ms)) / 1000.0) + + while pending and time.monotonic() < deadline: + ready_now: List[int] = [] + for quest_id in list(pending): + try: + if GLOBAL_CACHE.Quest.IsQuestNameReady(quest_id): + quest_name = str(GLOBAL_CACHE.Quest.GetQuestName(quest_id) or "").strip() + if quest_name: + collected.append(quest_name) + ready_now.append(quest_id) + except Exception: + ready_now.append(quest_id) + for quest_id in ready_now: + pending.discard(quest_id) + if pending: + yield from Routines.Yield.wait(75) + + for quest_id in list(pending): + try: + if GLOBAL_CACHE.Quest.IsQuestNameReady(quest_id): + quest_name = str(GLOBAL_CACHE.Quest.GetQuestName(quest_id) or "").strip() + if quest_name: + collected.append(quest_name) + except Exception: + pass + + state.last_quest_log_names = collected + yield + + +def _initialize_run(): + state.reset_run_state() + config_error = state.config_error() + if config_error: + yield from _yield_stop_with_status(config_error, error=True) + return + + state.set_status("Run initialized. Traveling to Embark Beach and selecting the nearest Zaishen trio.") + yield from Routines.Yield.wait(100) + + +def _select_targets_for_current_spawn(): + config_error = state.config_error() + if config_error: + yield from _yield_stop_with_status(config_error, error=True) + return + + deadline = time.monotonic() + 8.0 + while time.monotonic() < deadline: + if int(Map.GetMapID() or 0) == EMBARK_BEACH and Routines.Checks.Map.IsMapReady(): + break + yield from Routines.Yield.wait(200) + else: + yield from _yield_stop_with_status("Failed to arrive in Embark Beach before selecting Zaishen targets.", error=True) + return + + try: + player_x, player_y = Player.GetXY() + except Exception: + yield from _yield_stop_with_status("Could not resolve the player spawn position in Embark Beach.", error=True) + return + + state.spawn_xy = (float(player_x), float(player_y)) + cluster = _select_cluster_for_spawn(state.spawn_xy) + if cluster is None: + yield from _yield_stop_with_status("Could not select a Zaishen cluster for the current Embark Beach spawn.", error=True) + return + + state.selected_cluster_name = str(cluster.get("cluster_name", "") or "") + state.quest_targets = _build_targets_for_cluster(cluster, state.spawn_xy) + if len(state.quest_targets) < QUEST_TARGET_COUNT: + yield from _yield_stop_with_status("Nearest Zaishen cluster did not produce all three target NPCs.", error=True) + return + + ordered_names = " -> ".join(str(target.get("npc_name", "") or "NPC") for target in state.quest_targets) + state.set_status( + f"Spawn at ({state.spawn_xy[0]:.0f}, {state.spawn_xy[1]:.0f}); selected {state.selected_cluster_name} trio. Fixed order: {ordered_names}." + ) + yield from _refresh_quest_log_names(timeout_ms=QUEST_LOG_REFRESH_TIMEOUT_MS) + yield from Routines.Yield.wait(100) + + +def _find_recorded_npc_match() -> tuple[int, float, float] | None: + normalized_target = _normalize_quest_name(state.current_npc_name) + matches: List[tuple[float, int, float, float]] = [] + for agent_id in AgentArray.GetNPCMinipetArray(): + try: + resolved_name = Agent.GetNameByID(agent_id) + if _normalize_quest_name(resolved_name) != normalized_target: + continue + if state.current_target_model_id and int(Agent.GetModelID(agent_id) or 0) != int(state.current_target_model_id): + continue + x, y = Agent.GetXY(agent_id) + distance = ((float(x) - float(state.npc_xy[0])) ** 2 + (float(y) - float(state.npc_xy[1])) ** 2) ** 0.5 + matches.append((distance, int(agent_id), float(x), float(y))) + except Exception: + continue + if not matches: + return None + matches.sort(key=lambda item: (item[0], item[1])) + _, agent_id, x, y = matches[0] + return agent_id, x, y + + +def _make_prepare_npc(quest_name: str): + def _prepare(): + target = _get_selected_target_by_name(quest_name) + if target is None: + yield from _yield_stop_with_status( + f"Selected Zaishen route is missing '{quest_name}'.", + error=True, + ) + return + state.begin_npc(target, pass_label="initial") + state.set_status(f"Preparing {_current_target_label()} in forced order.") + yield from Routines.Yield.wait(100) + return _prepare + + +def _make_prepare_retry_npc(quest_name: str): + def _prepare(): + target = _get_selected_target_by_name(quest_name) + if target is None: + yield from _yield_stop_with_status( + f"Selected Zaishen route is missing '{quest_name}' during the final retry pass.", + error=True, + ) + return + state.begin_npc(target, pass_label="final retry") + if quest_name not in state.final_retry_targets: + state.skip_current_npc = True + yield + return + state.set_status(f"Final retry pass: re-attempting {_current_target_label()} after another Zaishen quest succeeded.") + yield from Routines.Yield.wait(100) + return _prepare + + +def _resolve_current_npc(): + if not state.current_npc_name: + yield from _yield_skip_current_npc("no target NPC selected", error=True) + return + + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + match = _find_recorded_npc_match() + if match is not None: + agent_id, x, y = match + state.npc_agent_id = int(agent_id) + state.npc_xy = (x, y) + state.set_status( + f"Resolved {_current_target_label()} at ({state.npc_xy[0]:.0f}, {state.npc_xy[1]:.0f})." + ) + yield from Routines.Yield.wait(100) + return + yield from Routines.Yield.wait(200) + + yield from _yield_skip_current_npc( + f"could not find {_current_target_label()} in the current outpost", + error=True, + ) + + +def _move_to_current_npc(): + if state.skip_current_npc: + yield + return + if state.npc_agent_id == 0: + yield from _yield_skip_current_npc("NPC was not resolved before movement", error=True) + return + + approach_xy = state.current_route_points[0] if state.current_route_points else state.npc_xy + approach_tolerance = 180.0 + npc_tolerance = 240.0 + state.set_status(f"Approaching {_current_target_label()} via the recorded stop.") + + deadline = time.monotonic() + (max(1000, int(state.move_timeout_ms)) / 1000.0) + previous_approach_distance = float("inf") + stagnant_ticks = 0 + while time.monotonic() < deadline: + if _current_npc_dialog_is_ready(): + yield from Routines.Yield.wait(150) + return + + try: + player_xy = tuple(float(value) for value in Player.GetXY()) + except Exception: + player_xy = (0.0, 0.0) + + approach_distance = _distance_xy(player_xy, approach_xy) + npc_distance = _distance_to_current_npc() + if approach_distance <= approach_tolerance or npc_distance <= npc_tolerance: + yield from Routines.Yield.wait(150) + return + + if approach_distance >= previous_approach_distance - 20.0: + stagnant_ticks += 1 + else: + stagnant_ticks = 0 + previous_approach_distance = approach_distance + + if stagnant_ticks < 4: + Player.Move(float(approach_xy[0]), float(approach_xy[1])) + yield from Routines.Yield.wait(250) + continue + + yield from Routines.Yield.Player.ChangeTarget(state.npc_agent_id) + yield from Routines.Yield.wait(100) + yield from Routines.Yield.Player.InteractAgent(state.npc_agent_id) + yield from Routines.Yield.wait(350) + stagnant_ticks = 0 + + yield from _yield_skip_current_npc( + f"failed to approach '{_current_target_label()}'", + error=True, + ) + + +def _interact_with_current_npc(): + if state.skip_current_npc: + yield + return + if state.npc_agent_id == 0: + yield from _yield_skip_current_npc("NPC target is missing before interact", error=True) + return + + if _current_npc_dialog_is_ready(): + yield from Routines.Yield.wait(250) + return + + state.set_status(f"Interacting with {_current_target_label()}.") + deadline = time.monotonic() + 5.0 + interaction_attempts = 0 + while time.monotonic() < deadline: + interaction_attempts += 1 + yield from Routines.Yield.Player.ChangeTarget(state.npc_agent_id) + yield from Routines.Yield.wait(100) + yield from Routines.Yield.Player.InteractAgent(state.npc_agent_id) + settle_deadline = time.monotonic() + 1.2 + while time.monotonic() < settle_deadline: + if _current_npc_dialog_is_ready(): + yield from Routines.Yield.wait(250) + return + yield from Routines.Yield.wait(100) + + if _dialog_is_open(): + state.set_status( + f"{_current_target_label()}: stale dialog remained open after interact attempt {interaction_attempts}; retrying." + ) + else: + state.set_status(f"{_current_target_label()}: interact attempt {interaction_attempts} did not open dialog; retrying.") + yield from Routines.Yield.wait(250) + + yield from _yield_skip_current_npc(f"dialog did not open for '{_current_target_label()}'", error=True) + + +def _resolve_first_offer_dialog(): + if state.skip_current_npc: + yield + return + deadline = time.monotonic() + (max(1000, int(state.dialog_timeout_ms)) / 1000.0) + confirmation_label = _normalize_dialog_label(state.confirmation_text) + + while time.monotonic() < deadline: + if not _current_npc_dialog_is_ready(): + yield from Routines.Yield.wait(150) + continue + + active_message = _active_dialog_message() + rejection = _classify_zaishen_dialog_rejection(active_message) + if rejection is not None: + rejection_reason, retryable, counts_as_failure = rejection + yield from _yield_skip_current_npc( + f"{rejection_reason}; dialog says '{active_message}'", + retryable=retryable, + counts_as_failure=counts_as_failure, + ) + return + buttons = Dialog.get_active_dialog_buttons() + state.last_choices = [ + f"{_button_text(button) or ''} [0x{button.dialog_id:X}]" + for button in buttons + ] + visible_buttons = [ + button + for button in buttons + if int(getattr(button, "dialog_id", 0) or 0) != 0 + and not _is_decline_button_text(_button_text(button)) + ] + non_confirmation_buttons = [ + button + for button in visible_buttons + if _normalize_dialog_label(_button_text(button)) != confirmation_label + ] + + selected_button = None + if non_confirmation_buttons: + selected_button = non_confirmation_buttons[0] + state.offer_is_confirmation_step = False + elif visible_buttons: + selected_button = visible_buttons[0] + state.offer_is_confirmation_step = True + + if selected_button is not None: + dialog_id = int(selected_button.dialog_id) + state.first_offer_dialog_id = dialog_id + state.offered_quest_name = _resolve_offered_quest_name(selected_button) + if state.offer_is_confirmation_step: + state.confirmation_dialog_id = dialog_id + if state.offered_quest_name: + state.set_status( + f"Dialog opened directly at confirmation 0x{dialog_id:X}; inferred quest '{state.offered_quest_name}'." + ) + else: + state.set_status(f"Dialog opened directly at confirmation 0x{dialog_id:X}; quest name unavailable.") + else: + if state.offered_quest_name: + state.set_status( + f"Resolved first offered dialog 0x{state.first_offer_dialog_id:X} for '{state.offered_quest_name}'." + ) + else: + state.set_status(f"Resolved first offered dialog 0x{state.first_offer_dialog_id:X}.") + yield from Routines.Yield.wait(250) + return + + yield from Routines.Yield.wait(150) + + available = ", ".join(state.last_choices) if state.last_choices else "" + yield from _yield_skip_current_npc( + f"could not resolve the first quest dialog offered by '{_current_target_label()}'. Available choices: {available}", + error=True, + ) + + +def _skip_if_offered_quest_already_present(): + if state.skip_current_npc: + yield + return + if not state.offered_quest_name: + state.set_status(f"{_current_target_label()}: first offered dialog did not expose a quest name; duplicate guard is unavailable.") + yield + return + + target_name = _normalize_quest_name(state.offered_quest_name) + if any(_normalize_quest_name(name) == target_name for name in state.last_quest_log_names): + yield from _yield_skip_current_npc( + f"quest '{state.offered_quest_name}' is already in the quest log", + retryable=False, + counts_as_failure=False, + ) + return + + state.set_status(f"{_current_target_label()}: quest '{state.offered_quest_name}' is not in the quest log. Proceeding to accept it.") + yield + + +def _send_first_offer_dialog(): + if state.skip_current_npc: + yield + return + if state.offer_is_confirmation_step: + state.set_status(f"{_current_target_label()}: dialog is already at confirmation; skipping the initial send.") + yield + return + if state.first_offer_dialog_id == 0: + yield from _yield_skip_current_npc("first offered dialog ID was not resolved", error=True) + return + + state.set_status(f"Sending first offered dialog 0x{state.first_offer_dialog_id:X}.") + Player.SendDialog(state.first_offer_dialog_id) + yield from Routines.Yield.wait(POST_DIALOG_SEND_WAIT_MS) + + +def _resolve_confirmation_dialog(): + if state.skip_current_npc: + yield + return + if state.offer_is_confirmation_step and state.confirmation_dialog_id != 0: + state.set_status(f"Resolved confirmation dialog 0x{state.confirmation_dialog_id:X}.") + yield + return + target_label = _normalize_dialog_label(state.confirmation_text) + deadline = time.monotonic() + (max(1000, int(state.dialog_timeout_ms)) / 1000.0) + + while time.monotonic() < deadline: + if not _current_npc_dialog_is_ready(): + yield from Routines.Yield.wait(150) + continue + + active_message = _active_dialog_message() + rejection = _classify_zaishen_dialog_rejection(active_message) + if rejection is not None: + rejection_reason, retryable, counts_as_failure = rejection + yield from _yield_skip_current_npc( + f"{rejection_reason}; dialog says '{active_message}'", + retryable=retryable, + counts_as_failure=counts_as_failure, + ) + return + buttons = Dialog.get_active_dialog_buttons() + state.last_choices = [ + f"{_button_text(button) or ''} [0x{button.dialog_id:X}]" + for button in buttons + ] + for button in buttons: + if _normalize_dialog_label(_button_text(button)) == target_label and int(button.dialog_id) != 0: + state.confirmation_dialog_id = int(button.dialog_id) + state.set_status(f"Resolved confirmation dialog 0x{state.confirmation_dialog_id:X}.") + yield + return + yield from Routines.Yield.wait(150) + + available = ", ".join(state.last_choices) if state.last_choices else "" + yield from _yield_skip_current_npc( + f"could not find confirmation text '{state.confirmation_text}'. Available choices: {available}", + error=True, + ) + + +def _send_confirmation_dialog(): + if state.skip_current_npc: + yield + return + if state.confirmation_dialog_id == 0: + yield from _yield_skip_current_npc("confirmation dialog ID was not resolved", error=True) + return + + state.set_status(f"Sending confirmation dialog 0x{state.confirmation_dialog_id:X}.") + Player.SendDialog(state.confirmation_dialog_id) + yield from Routines.Yield.wait(POST_DIALOG_SEND_WAIT_MS) + + +def _verify_quest_added(): + if state.skip_current_npc: + yield + return + target_name = _normalize_quest_name(state.offered_quest_name) + if not target_name: + state.record_quest_success() + state.append_result(f"{_current_attempt_label()}: accepted quest flow but NPC did not expose a readable quest name for verification") + state.set_status(f"{_current_attempt_label()}: accepted quest flow but verification name was unavailable.") + yield + return + if not any(_normalize_quest_name(name) == target_name for name in state.last_quest_log_names): + state.last_quest_log_names.append(state.offered_quest_name) + state.record_quest_success() + result = f"{_current_attempt_label()}: sent acceptance for '{state.offered_quest_name}'" + state.append_result(result) + state.set_status(result) + yield + + +def _plan_final_retry_pass(): + state.current_pass_label = "initial" + state.final_retry_targets = [] + + if not state.retryable_failed_quest_names: + state.set_status("Initial Zaishen pass finished with no retryable failures.") + yield + return + + if not state.successful_quest_names: + state.set_status("Initial Zaishen pass had failures but no successful quest takes; skipping the final retry pass.") + yield + return + + state.final_retry_targets = [ + quest_name + for quest_name in QUEST_TYPE_SEQUENCE + if quest_name in state.retryable_failed_quest_names + ] + if not state.final_retry_targets: + state.set_status("Initial Zaishen pass did not leave any failed quest types pending for the final retry pass.") + yield + return + + retry_labels = ", ".join(state.final_retry_targets) + state.set_status(f"Initial Zaishen pass finished. Final retry pass scheduled for: {retry_labels}.") + yield from Routines.Yield.wait(100) + + +def _finish_run(): + summary = ", ".join(state.completed_results) if state.completed_results else "No quest actions were recorded." + yield from _yield_stop_with_status(f"Embark Beach Zaishen trio finished. {summary}") + + +def _create_bot_routine(bot_instance: Botting) -> None: + bot_instance.States.AddHeader("Initialize") + bot_instance.States.AddCustomState(_initialize_run, "Initialize run state") + + bot_instance.States.AddHeader("Travel to Embark Beach") + bot_instance.Map.Travel(target_map_id=EMBARK_BEACH) + bot_instance.Wait.ForTime(500) + bot_instance.States.AddCustomState(_select_targets_for_current_spawn, "Select nearest Zaishen trio") + + for quest_name in QUEST_TYPE_SEQUENCE: + bot_instance.States.AddHeader(f"Handle {quest_name}") + bot_instance.States.AddCustomState(_make_prepare_npc(quest_name), f"Prepare {quest_name}") + bot_instance.States.AddCustomState(_resolve_current_npc, f"Resolve {quest_name}") + bot_instance.States.AddCustomState(_move_to_current_npc, f"Move to {quest_name}") + bot_instance.States.AddCustomState(_interact_with_current_npc, f"Interact with {quest_name}") + bot_instance.States.AddCustomState(_resolve_first_offer_dialog, f"Resolve first offered dialog for {quest_name}") + bot_instance.States.AddCustomState(_skip_if_offered_quest_already_present, f"Skip {quest_name} if offered quest already exists") + bot_instance.States.AddCustomState(_send_first_offer_dialog, f"Send first offered dialog for {quest_name}") + bot_instance.States.AddCustomState(_resolve_confirmation_dialog, f"Resolve 'I can do that!' for {quest_name}") + bot_instance.States.AddCustomState(_send_confirmation_dialog, f"Send confirmation for {quest_name}") + bot_instance.States.AddCustomState(_verify_quest_added, f"Verify quest added for {quest_name}") + + bot_instance.States.AddHeader("Plan Final Retry Pass") + bot_instance.States.AddCustomState(_plan_final_retry_pass, "Plan final retry pass") + + for quest_name in QUEST_TYPE_SEQUENCE: + bot_instance.States.AddHeader(f"Retry {quest_name}") + bot_instance.States.AddCustomState(_make_prepare_retry_npc(quest_name), f"Prepare retry {quest_name}") + bot_instance.States.AddCustomState(_resolve_current_npc, f"Resolve retry {quest_name}") + bot_instance.States.AddCustomState(_move_to_current_npc, f"Move retry {quest_name}") + bot_instance.States.AddCustomState(_interact_with_current_npc, f"Interact retry {quest_name}") + bot_instance.States.AddCustomState(_resolve_first_offer_dialog, f"Resolve retry first offered dialog for {quest_name}") + bot_instance.States.AddCustomState(_skip_if_offered_quest_already_present, f"Skip retry {quest_name} if offered quest already exists") + bot_instance.States.AddCustomState(_send_first_offer_dialog, f"Send retry first offered dialog for {quest_name}") + bot_instance.States.AddCustomState(_resolve_confirmation_dialog, f"Resolve retry 'I can do that!' for {quest_name}") + bot_instance.States.AddCustomState(_send_confirmation_dialog, f"Send retry confirmation for {quest_name}") + bot_instance.States.AddCustomState(_verify_quest_added, f"Verify retry quest added for {quest_name}") + + bot_instance.States.AddHeader("Finish") + bot_instance.States.AddCustomState(_finish_run, "Finish Zaishen trio") + + +def _draw_main_status() -> None: + active_quests = state.last_quest_log_names + config_error = state.config_error() + + PyImGui.text(f"Target map: {Map.GetMapName(EMBARK_BEACH)} ({EMBARK_BEACH})") + if state.selected_cluster_name: + PyImGui.text(f"Selected cluster: {state.selected_cluster_name}") + else: + PyImGui.text("Selected cluster: ") + if state.spawn_xy != (0.0, 0.0): + PyImGui.text(f"Spawn position: ({state.spawn_xy[0]:.0f}, {state.spawn_xy[1]:.0f})") + else: + PyImGui.text("Spawn position: ") + PyImGui.text(f"Current NPC: {_current_target_label() if state.current_npc_name else ''}") + if state.quest_targets: + planned_order = " -> ".join(str(target.get("npc_name", "") or "NPC") for target in state.quest_targets) + PyImGui.text_wrapped(f"Planned trio order: {planned_order}") + else: + PyImGui.text("Planned trio order: ") + PyImGui.text(f"Offered quest name: {state.offered_quest_name or ''}") + PyImGui.separator() + PyImGui.text_wrapped(f"Status: {state.last_status}") + + if state.first_offer_dialog_id: + PyImGui.text(f"First offered dialog: 0x{state.first_offer_dialog_id:X}") + else: + PyImGui.text("First offered dialog: ") + + if state.confirmation_dialog_id: + PyImGui.text(f"Resolved confirmation dialog: 0x{state.confirmation_dialog_id:X}") + else: + PyImGui.text("Resolved confirmation dialog: ") + + if config_error: + PyImGui.separator() + PyImGui.text_colored(f"Config issue: {config_error}", Color(255, 120, 120, 255).to_tuple_normalized()) + + PyImGui.separator() + PyImGui.text_wrapped( + "The widget travels to Embark Beach, reads your actual spawn position, picks the nearest recorded Zaishen trio cluster, then handles that local trio in fixed order: " + "Mission -> Bounty -> Vanquish. Each stop resolves the nearest matching live NPC by recorded name/model/position, reads the offered quest " + "from the live dialog, compares that NPC-provided quest name against the current quest log, and only sends live dialog IDs when the quest is not already present. " + "If one quest take fails while another one succeeds, the widget schedules one final retry pass for the failed quest types at the end." + ) + if active_quests: + preview = ", ".join(active_quests[:6]) + suffix = " ..." if len(active_quests) > 8 else "" + PyImGui.text_wrapped(f"Quest log names: {preview}{suffix}") + else: + PyImGui.text("Quest log names: ") + + if state.last_choices: + PyImGui.separator() + PyImGui.text("Last visible dialog choices:") + for choice_label in state.last_choices[:6]: + PyImGui.bullet_text(choice_label) + + if state.completed_results: + PyImGui.separator() + PyImGui.text("Completed results:") + for result in state.completed_results[-6:]: + PyImGui.bullet_text(result) + + +def _draw_settings() -> None: + state.confirmation_text = PyImGui.input_text("Confirmation text", state.confirmation_text, 128) + state.move_timeout_ms = PyImGui.input_int("Move timeout (ms)", state.move_timeout_ms) + state.dialog_timeout_ms = PyImGui.input_int("Dialog timeout (ms)", state.dialog_timeout_ms) + state.move_timeout_ms = max(1000, int(state.move_timeout_ms)) + state.dialog_timeout_ms = max(1000, int(state.dialog_timeout_ms)) + + PyImGui.separator() + PyImGui.text_wrapped("Examples") + PyImGui.bullet_text("The widget does not require a quest ID, offer dialog ID, or quest name.") + PyImGui.bullet_text("It always travels to Embark Beach, selects the nearest recorded Zaishen trio for the current spawn, and handles the trio in fixed order: Mission -> Bounty -> Vanquish.") + PyImGui.bullet_text("The duplicate guard uses the label or catalog text of each NPC's first live offered dialog choice.") + PyImGui.bullet_text("If at least one quest take succeeds, any failed quest types get one final retry pass at the end.") + + +def _draw_help() -> None: + PyImGui.text_wrapped( + "Start the widget in any outpost or explorable area. It will travel to Embark Beach, detect which recorded Zaishen trio is nearest to the actual zone-in spawn, " + "then move through that local trio in fixed order: Mission -> Bounty -> Vanquish. It interacts, sends the first live dialog ID offered by each NPC, then resolves and sends the live choice whose text matches the confirmation text." + ) + PyImGui.separator() + PyImGui.text_wrapped( + "The duplicate guard reads the offered quest name from the live button text first, then falls back to dialog catalog/body text when the NPC opens " + "directly on the confirmation choice. If that quest name is already present in the quest log, that NPC is skipped and the widget continues to the next NPC in the selected local trio." + ) + PyImGui.separator() + PyImGui.text_wrapped( + "When one quest take fails but at least one other quest take succeeds during the same run, the widget adds one final retry pass at the end for the failed quest types." + ) + + +bot.SetMainRoutine(_create_bot_routine) +bot.UI.override_draw_config(_draw_settings) +bot.UI.override_draw_help(_draw_help) + + +def tooltip(): + PyImGui.begin_tooltip() + title_color = Color(255, 200, 100, 255) + ImGui.push_font("Regular", 20) + PyImGui.text_colored(MODULE_NAME, title_color.to_tuple_normalized()) + ImGui.pop_font() + PyImGui.spacing() + PyImGui.separator() + PyImGui.text("Travels to Embark Beach, picks the nearest recorded") + PyImGui.text("Zaishen trio for the current spawn, and uses the live") + PyImGui.text("dialog flow to confirm each quest with 'I can do that!'.") + PyImGui.end_tooltip() + + +def main(): + try: + if not Routines.Checks.Map.MapValid(): + return + + if Routines.Checks.Map.IsMapReady() and Routines.Checks.Party.IsPartyLoaded(): + bot.Update() + if state.stop_requested: + bot.Stop() + state.stop_requested = False + bot.UI.draw_window(icon_path=MODULE_ICON, additional_ui=_draw_main_status) + except Exception as exc: + Py4GW.Console.Log( + MODULE_NAME, + f"Unexpected error: {exc}", + Py4GW.Console.MessageType.Error, + ) + + +if __name__ == "__main__": + main() diff --git a/Widgets/Automation/Helpers/Dialogs/Dialog Monitor.py b/Widgets/Automation/Helpers/Dialogs/Dialog Monitor.py new file mode 100644 index 000000000..cdd359343 --- /dev/null +++ b/Widgets/Automation/Helpers/Dialogs/Dialog Monitor.py @@ -0,0 +1,1846 @@ +import json +import os +import re +import time +from typing import Any, Callable, Dict, List, Optional + +import Py4GW +from Py4GWCoreLib import Routines, PyImGui, Map, Agent, Dialog, Player + +MODULE_NAME = "Dialog Monitor" + +__widget__ = { + "enabled": False, + "category": "Dialog", + "subcategory": "Monitor", +} + +_ROOT_DIRECTORY = Py4GW.Console.get_projects_path() +_CONFIG_DIR = os.path.join(_ROOT_DIRECTORY, "Widgets", "Config") +_EXPORT_DIR = os.path.join(_ROOT_DIRECTORY, "Widgets", "Data", "Dialog", "Exports") +_DUMP_PREFIX = "DialogMonitor_dump_" + +_HISTORY_LIMIT = 140 +_INLINE_CHOICE_RE = re.compile(r"]+)>(.*?)", re.IGNORECASE | re.DOTALL) +_STORAGE_SYNC_INTERVAL_SECONDS = 0.5 +_QUERY_CACHE_TTL_SECONDS = 0.35 +_HEAVY_QUERY_CACHE_TTL_SECONDS = 0.75 +_TAB_LIVE = "Live" +_TAB_RECENT = "Recent" +_TAB_LOGS = "Logs" +_TAB_DEBUG = "Debug" +_LOGS_TAB_RAW = "Raw" +_LOGS_TAB_JOURNAL = "Journal" +_LOGS_TAB_BAR_ID = "DialogMonitorLogsTabsV2" +_DEFAULT_WINDOW_SIZE = (960.0, 720.0) +_PLAYER_NAME_PLACEHOLDER = "" +_REDACTION_BLOCKED_PLACEHOLDER = "