diff --git a/Py4GWCoreLib/BottingTree.py b/Py4GWCoreLib/BottingTree.py index eaab763f2..e11c2b3e7 100644 --- a/Py4GWCoreLib/BottingTree.py +++ b/Py4GWCoreLib/BottingTree.py @@ -194,7 +194,7 @@ def Reset(self): self.headless_heroai.reset() if self._service_steps: self._service_trees = [ - (step_name, self._coerce_runtime_tree(subtree_or_builder)) + (step_name, BehaviorTree.resolve_tree(subtree_or_builder)) for step_name, subtree_or_builder in self._service_steps ] self._rebuild_root_tree() diff --git a/Py4GWCoreLib/modular/hero_setup.py b/Py4GWCoreLib/botting_tree_src/hero_setup.py similarity index 96% rename from Py4GWCoreLib/modular/hero_setup.py rename to Py4GWCoreLib/botting_tree_src/hero_setup.py index 1989ed269..654b8cc7d 100644 --- a/Py4GWCoreLib/modular/hero_setup.py +++ b/Py4GWCoreLib/botting_tree_src/hero_setup.py @@ -1,4 +1,4 @@ -"""Public hero setup facade for modular party loading and UI.""" +"""Public hero setup facade for BottingTree party loading and UI.""" from __future__ import annotations from .hero_setup_model import ( @@ -41,6 +41,7 @@ def __getattr__(name: str): globals()[name] = value return value + __all__ = [ "DEFAULT_HERO_PRIORITY", "default_hero_config", diff --git a/Py4GWCoreLib/modular/hero_setup_model.py b/Py4GWCoreLib/botting_tree_src/hero_setup_model.py similarity index 92% rename from Py4GWCoreLib/modular/hero_setup_model.py rename to Py4GWCoreLib/botting_tree_src/hero_setup_model.py index d3189134c..c4d34ba9a 100644 --- a/Py4GWCoreLib/modular/hero_setup_model.py +++ b/Py4GWCoreLib/botting_tree_src/hero_setup_model.py @@ -1,4 +1,4 @@ -"""Account-scoped hero team setup model for modular party loading.""" +"""Account-scoped hero team setup model for BottingTree party loading.""" from __future__ import annotations import json @@ -8,8 +8,6 @@ from Py4GWCoreLib import Player -from .paths import modular_settings_root - HERO_CATALOG = [ (0, "Empty"), @@ -92,6 +90,22 @@ def _build_default_hero_priority() -> list[int]: DEFAULT_HERO_PRIORITY = _build_default_hero_priority() +def _project_root() -> str: + try: + import Py4GW + + root = str(Py4GW.Console.get_projects_path() or "").strip() + if root: + return os.path.normpath(root) + except Exception: + pass + return os.path.normpath(os.getcwd()) + + +def _botting_tree_settings_root() -> str: + return os.path.join(_project_root(), "Settings", "BottingTree") + + def safe_account_key() -> str: try: account_email = str(Player.GetAccountEmail() or "").strip() @@ -104,7 +118,7 @@ def safe_account_key() -> str: def hero_config_path(account_key: str | None = None) -> str: - configs_dir = os.path.join(modular_settings_root(), "configs") + configs_dir = os.path.join(_botting_tree_settings_root(), "configs") os.makedirs(configs_dir, exist_ok=True) return os.path.join(configs_dir, f"{account_key or safe_account_key()}.json") diff --git a/Py4GWCoreLib/modular/hero_setup_ui.py b/Py4GWCoreLib/botting_tree_src/hero_setup_ui.py similarity index 94% rename from Py4GWCoreLib/modular/hero_setup_ui.py rename to Py4GWCoreLib/botting_tree_src/hero_setup_ui.py index e5bd49e20..584d94bdb 100644 --- a/Py4GWCoreLib/modular/hero_setup_ui.py +++ b/Py4GWCoreLib/botting_tree_src/hero_setup_ui.py @@ -1,4 +1,4 @@ -"""PyImGui controls for modular hero team setup.""" +"""PyImGui controls for BottingTree hero team setup.""" from __future__ import annotations import PyImGui @@ -28,15 +28,6 @@ def _ensure_loaded() -> None: _ui_loaded = True -def _ui_input_text(label: str, value: str, max_len: int = 256) -> str: - try: - result = PyImGui.input_text(label, str(value), 0) - except Exception: - result = PyImGui.input_text(label, str(value)) - text = str(result[1]) if isinstance(result, tuple) and len(result) == 2 else str(result) - return text[: int(max_len)] if int(max_len) > 0 else text - - def _begin_child(child_id: str, height: int = 290, border: bool = True) -> bool: try: h = int(height) diff --git a/Py4GWCoreLib/botting_tree_src/planner.py b/Py4GWCoreLib/botting_tree_src/planner.py index f0822fa2a..3a788f310 100644 --- a/Py4GWCoreLib/botting_tree_src/planner.py +++ b/Py4GWCoreLib/botting_tree_src/planner.py @@ -115,95 +115,18 @@ def SetCurrentTree( elif reset: self.Reset() - def _build_sequence_from_children( - self, - children: Sequence[object], - name: str = 'MainRoutine', - ) -> BehaviorTree: - return BehaviorTree( - BehaviorTree.SequenceNode( - name=name, - children=[ - BehaviorTree.SubtreeNode( - name=f'{name} Step {index + 1}', - subtree_fn=lambda node, child=child: self._coerce_runtime_tree(child), - ) - for index, child in enumerate(children) - ], - ) + @staticmethod + def _mark_current_step(step_name: str) -> BehaviorTree.Node: + def _mark(node: BehaviorTree.Node, step_name: str = step_name) -> BehaviorTree.NodeState: + node.blackboard['current_step_name'] = step_name + return BehaviorTree.NodeState.SUCCESS + + return BehaviorTree.ActionNode( + name=f'MarkCurrentStep({step_name})', + action_fn=_mark, + aftercast_ms=0, ) - def _build_named_planner_tree( - self, - steps: Sequence[tuple[str, Callable[[], object] | object]], - start_from: str | None = None, - name: str = 'PlannerSequence', - repeat: bool = False, - ) -> BehaviorTree: - if not steps: - return BehaviorTree(BehaviorTree.SequenceNode(name=name, children=[])) - - step_names = [step_name for step_name, _ in steps] - start_index = 0 - if start_from is not None: - if start_from not in step_names: - raise ValueError(f"Unknown planner step '{start_from}'. Valid values: {', '.join(step_names)}") - start_index = step_names.index(start_from) - - def _as_tree(subtree_or_builder: Callable[[], object] | object) -> BehaviorTree: - subtree = subtree_or_builder() if callable(subtree_or_builder) else subtree_or_builder - if isinstance(subtree, BehaviorTree): - return subtree - if isinstance(subtree, BehaviorTree.Node): - return BehaviorTree(subtree) - if hasattr(subtree, 'root') and hasattr(subtree, 'tick') and hasattr(subtree, 'reset'): - return cast(BehaviorTree, subtree) - raise TypeError(f'Planner step returned invalid type {type(subtree).__name__}.') - - def _mark_current_step(step_name: str) -> BehaviorTree.Node: - def _mark(node: BehaviorTree.Node, step_name: str = step_name) -> BehaviorTree.NodeState: - node.blackboard['current_step_name'] = step_name - return BehaviorTree.NodeState.SUCCESS - - return BehaviorTree.ActionNode( - name=f'MarkCurrentStep({step_name})', - action_fn=_mark, - aftercast_ms=0, - ) - - children: list[BehaviorTree.Node] = [ - BehaviorTree.SequenceNode( - name=f'Step: {step_name}', - children=[ - _mark_current_step(step_name), - BehaviorTree.SubtreeNode( - name=step_name, - subtree_fn=lambda node, subtree_or_builder=subtree_or_builder: _as_tree(subtree_or_builder), - ), - ], - ) - for step_name, subtree_or_builder in steps[start_index:] - ] - if repeat: - full_pass = self._build_named_planner_tree(steps, start_from=None, name=f'{name} Full Pass', repeat=False) - children.append( - BehaviorTree.RepeaterForeverNode( - full_pass.root, - name='Loop: restart routine', - ) - ) - return BehaviorTree(BehaviorTree.SequenceNode(name=name, children=children)) - - def _coerce_runtime_tree(self, subtree_or_builder: Callable[[], object] | object) -> BehaviorTree: - subtree = subtree_or_builder() if callable(subtree_or_builder) else subtree_or_builder - if isinstance(subtree, BehaviorTree): - return subtree - if isinstance(subtree, BehaviorTree.Node): - return BehaviorTree(subtree) - if hasattr(subtree, 'root') and hasattr(subtree, 'tick') and hasattr(subtree, 'reset'): - return cast(BehaviorTree, subtree) - raise TypeError(f'Service step returned invalid type {type(subtree).__name__}.') - def SetMainRoutine( self, routine: BehaviorTree | BehaviorTree.Node | Callable[[], object] | Sequence[object] | None, @@ -215,7 +138,7 @@ def SetMainRoutine( if routine is None: self.SetPlannerTree(None) elif callable(routine): - self.SetPlannerTree(self._coerce_runtime_tree(routine)) + self.SetPlannerTree(BehaviorTree.resolve_tree(routine)) elif isinstance(routine, RuntimeSequence) and not isinstance(routine, (str, bytes)): routine_items = list(routine) if routine_items and all( @@ -233,9 +156,15 @@ def SetMainRoutine( self._planner_steps = [] self._planner_sequence_name = name self.planner_repeat = False - self.SetPlannerTree(self._build_sequence_from_children(routine_items, name=name)) + self.SetPlannerTree( + BehaviorTree.build_sequence( + routine_items, + name=name, + step_name_fn=lambda index, _child: f'{name} Step {index + 1}', + ) + ) else: - self.SetPlannerTree(self._coerce_runtime_tree(routine)) + self.SetPlannerTree(BehaviorTree.resolve_tree(routine)) if auto_start: self.Start() @@ -252,7 +181,15 @@ def SetNamedPlannerSteps( self._planner_steps = list(steps) self._planner_sequence_name = name self.planner_repeat = repeat - self._set_planner_tree(self._build_named_planner_tree(self._planner_steps, start_from=start_from, name=name, repeat=repeat)) + self._set_planner_tree( + BehaviorTree.build_named_sequence( + self._planner_steps, + start_from=start_from, + name=name, + before_step=self._mark_current_step, + repeat=repeat, + ) + ) self.EnsurePartyWipeRecoveryService( default_step_name=lambda: (self.GetNamedPlannerStepNames() or [None])[0], ) @@ -289,12 +226,15 @@ def RestartFromNamedPlannerStep( if not self._planner_steps: return False sequence_name = name or self._planner_sequence_name - self._set_planner_tree(self._build_named_planner_tree( - self._planner_steps, - start_from=step_name, - name=sequence_name, - repeat=self.planner_repeat, - )) + self._set_planner_tree( + BehaviorTree.build_named_sequence( + self._planner_steps, + start_from=step_name, + name=sequence_name, + before_step=self._mark_current_step, + repeat=self.planner_repeat, + ) + ) self.Reset() if auto_start: self.Start() @@ -308,7 +248,12 @@ def BuildAllSequences( if not self._planner_steps: return self._build_default_planner_tree() sequence_name = name or self._planner_sequence_name - return self._build_named_planner_tree(self._planner_steps, start_from=start_from, name=sequence_name) + return BehaviorTree.build_named_sequence( + self._planner_steps, + start_from=start_from, + name=sequence_name, + before_step=self._mark_current_step, + ) def RestartFromSequence( self, diff --git a/Py4GWCoreLib/botting_tree_src/upkeep.py b/Py4GWCoreLib/botting_tree_src/upkeep.py index 587cfac7c..f05e6e31b 100644 --- a/Py4GWCoreLib/botting_tree_src/upkeep.py +++ b/Py4GWCoreLib/botting_tree_src/upkeep.py @@ -10,14 +10,14 @@ def SetServiceTrees( ): self._service_steps = list(steps) self._service_trees = [ - (step_name, self._coerce_runtime_tree(subtree_or_builder)) + (step_name, BehaviorTree.resolve_tree(subtree_or_builder)) for step_name, subtree_or_builder in self._service_steps ] self._rebuild_root_tree() def AddServiceTree(self, name: str, subtree_or_builder: Callable[[], object] | object): self._service_steps.append((name, subtree_or_builder)) - self._service_trees.append((name, self._coerce_runtime_tree(subtree_or_builder))) + self._service_trees.append((name, BehaviorTree.resolve_tree(subtree_or_builder))) self._rebuild_root_tree() def ClearServiceTrees(self): @@ -69,7 +69,7 @@ def EnsurePartyWipeRecoveryService( if service_name != 'PartyWipeRecoveryService': continue self._service_steps[index] = (service_name, subtree_or_builder) - self._service_trees[index] = (service_name, self._coerce_runtime_tree(subtree_or_builder)) + self._service_trees[index] = (service_name, BehaviorTree.resolve_tree(subtree_or_builder)) self._rebuild_root_tree() return self.AddServiceTree('PartyWipeRecoveryService', subtree_or_builder) diff --git a/Py4GWCoreLib/modular/__init__.py b/Py4GWCoreLib/modular/__init__.py index f1c398ee1..7884c4dc9 100644 --- a/Py4GWCoreLib/modular/__init__.py +++ b/Py4GWCoreLib/modular/__init__.py @@ -2,45 +2,27 @@ from __future__ import annotations from .json_bt_compiler import CANONICAL_STEP_TYPES -from .json_bt_compiler import CompiledRecipeStep -from .json_bt_compiler import JsonBTCompilerContext from .json_bt_compiler import LEGACY_STEP_TYPES from .json_bt_compiler import RecipeCompileError from .json_bt_compiler import RecipeStepMetadata from .json_bt_compiler import UnknownRecipeStepType from .json_bt_compiler import audit_recipe_vocabulary -from .json_bt_compiler import compile_file_to_bt -from .json_bt_compiler import compile_recipe_to_bt -from .json_bt_compiler import compile_recipe_step_to_bt -from .json_bt_compiler import compile_recipe_steps_to_bt -from .json_bt_compiler import compile_step_to_bt +from .json_bt_compiler import compile_recipe_steps_to_named_planner_steps from .json_bt_compiler import get_json_bt_step_types from .json_bt_compiler import load_recipe from .json_bt_compiler import recipe_step_metadata -from .runner import BTRecipeRunner -from .runner import RecipeSpec -from .runner import specs_from_campaign_rows __all__ = [ "CANONICAL_STEP_TYPES", - "CompiledRecipeStep", - "JsonBTCompilerContext", "LEGACY_STEP_TYPES", "RecipeCompileError", "RecipeStepMetadata", "UnknownRecipeStepType", "audit_recipe_vocabulary", - "compile_file_to_bt", - "compile_recipe_to_bt", - "compile_recipe_step_to_bt", - "compile_recipe_steps_to_bt", - "compile_step_to_bt", + "compile_recipe_steps_to_named_planner_steps", "get_json_bt_step_types", "load_recipe", "recipe_step_metadata", - "BTRecipeRunner", - "RecipeSpec", - "specs_from_campaign_rows", ] __version__ = "2.0.0" diff --git a/Py4GWCoreLib/modular/json_bt_compiler.py b/Py4GWCoreLib/modular/json_bt_compiler.py index 7c93c6956..b4bc42e4f 100644 --- a/Py4GWCoreLib/modular/json_bt_compiler.py +++ b/Py4GWCoreLib/modular/json_bt_compiler.py @@ -83,7 +83,8 @@ ) DEFAULT_INTERACT_DELAY_MS = 250 -DEFAULT_DIALOG_DELAY_MS = 250 +DEFAULT_DIALOG_READY_TIMEOUT_MS = 5000 +DEFAULT_DIALOG_READY_POLL_MS = 100 class RecipeCompileError(ValueError): @@ -134,6 +135,9 @@ class CompiledRecipeStep: source_step: dict[str, Any] context: JsonBTCompilerContext + def build_tree(self) -> BehaviorTree: + return _compile_recipe_step_to_bt(dict(self.source_step), self.context) + StepBuilder = Callable[[dict[str, Any], JsonBTCompilerContext], BehaviorTree] @@ -156,13 +160,13 @@ def load_recipe(path_or_name: str | Path) -> dict[str, Any]: return data -def compile_file_to_bt(path: str | Path, *, recipe_name: str | None = None) -> BehaviorTree: +def _compile_file_to_bt(path: str | Path, *, recipe_name: str | None = None) -> BehaviorTree: recipe = load_recipe(path) - return compile_recipe_to_bt(recipe, recipe_name=recipe_name or str(recipe.get("name") or Path(path).stem)) + return _compile_recipe_to_bt(recipe, recipe_name=recipe_name or str(recipe.get("name") or Path(path).stem)) -def compile_recipe_to_bt(recipe: dict[str, Any], *, recipe_name: str) -> BehaviorTree: - compiled_steps = compile_recipe_steps_to_bt(recipe, recipe_name=recipe_name) +def _compile_recipe_to_bt(recipe: dict[str, Any], *, recipe_name: str) -> BehaviorTree: + compiled_steps = _compile_recipe_steps_to_bt(recipe, recipe_name=recipe_name) if not compiled_steps: return BehaviorTree(BehaviorTree.SucceederNode(name=f"{recipe_name or 'Recipe'}::NoOp")) metadata = tuple(compiled.metadata for compiled in compiled_steps) @@ -179,7 +183,7 @@ def compile_recipe_to_bt(recipe: dict[str, Any], *, recipe_name: str) -> Behavio return BehaviorTree(root) -def compile_recipe_steps_to_bt(recipe: dict[str, Any], *, recipe_name: str) -> tuple[CompiledRecipeStep, ...]: +def _compile_recipe_steps_to_bt(recipe: dict[str, Any], *, recipe_name: str) -> tuple[CompiledRecipeStep, ...]: if not isinstance(recipe, dict): raise RecipeCompileError("Recipe must be a JSON object.") @@ -201,7 +205,7 @@ def compile_recipe_steps_to_bt(recipe: dict[str, Any], *, recipe_name: str) -> t return tuple( CompiledRecipeStep( metadata=metadata[index], - tree=compile_recipe_step_to_bt(step, context), + tree=_compile_recipe_step_to_bt(step, context), source_step=dict(step), context=context, ) @@ -209,21 +213,40 @@ def compile_recipe_steps_to_bt(recipe: dict[str, Any], *, recipe_name: str) -> t ) +def compile_recipe_steps_to_named_planner_steps( + recipe: dict[str, Any], + *, + recipe_name: str, + planner_prefix: str = "", +) -> list[tuple[str, Callable[[], BehaviorTree]]]: + planner_steps: list[tuple[str, Callable[[], BehaviorTree]]] = [] + for compiled_step in _compile_recipe_steps_to_bt(recipe, recipe_name=recipe_name): + step_name = _planner_step_name(compiled_step.metadata, planner_prefix=planner_prefix) + + def _build_step_tree(compiled_step: CompiledRecipeStep = compiled_step) -> BehaviorTree: + return compiled_step.build_tree() + + _build_step_tree.modular_step_metadata = compiled_step.metadata + _build_step_tree.modular_compiled_step = compiled_step + planner_steps.append((step_name, _build_step_tree)) + return planner_steps + + def recipe_step_metadata(recipe: dict[str, Any], *, recipe_name: str | None = None) -> tuple[RecipeStepMetadata, ...]: name = str(recipe_name or recipe.get("name") or "Recipe") if isinstance(recipe, dict) else "Recipe" steps = _recipe_steps(recipe, name) if isinstance(recipe, dict) else [] return _step_metadata(_expand_steps(steps)) -def compile_step_to_bt(step: dict[str, Any], context: JsonBTCompilerContext) -> BehaviorTree: +def _compile_step_to_bt(step: dict[str, Any], context: JsonBTCompilerContext) -> BehaviorTree: if not isinstance(step, dict): raise RecipeCompileError("Step must be a JSON object.") step_type = _validate_step_type(step.get("type"), recipe_name=context.recipe_name, step_idx=0) return _BUILDERS[step_type](step, context) -def compile_recipe_step_to_bt(step: dict[str, Any], context: JsonBTCompilerContext) -> BehaviorTree: - return _with_post_wait(compile_step_to_bt(step, context), step) +def _compile_recipe_step_to_bt(step: dict[str, Any], context: JsonBTCompilerContext) -> BehaviorTree: + return _with_post_wait(_compile_step_to_bt(step, context), step) def audit_recipe_vocabulary(recipe: dict[str, Any], *, recipe_name: str | None = None) -> VocabularyAudit: @@ -297,15 +320,9 @@ def _build_interact(step: dict[str, Any], context: JsonBTCompilerContext) -> Beh if not ids: raise RecipeCompileError(f"Recipe {context.recipe_name!r} interact dialog requires id.") interval_ms = max(0, _int(step.get("interval_ms"), 0)) - trees: list[BehaviorTree] = [] - for dialog_id in ids: - if _has_selector(step): - trees.append(_interact_and_dialog_tree(step, context, dialog_id)) - else: - trees.append(BT.Player.SendDialog(dialog_id=dialog_id, log=_bool(step.get("log"), False))) - if interval_ms: - trees.append(BT.Player.Wait(interval_ms, log=False)) - return _sequence("InteractDialog", trees) + if _has_selector(step): + return _interact_and_dialog_tree(step, context, ids, interval_ms=interval_ms) + return _sequence("SendDialogs", _dialog_send_trees(step, ids, interval_ms=interval_ms)) if action == "auto_dialog": button = _int(step.get("button", step.get("button_number")), 0) return BT.Player.SendAutomaticDialog(button_number=button, log=log) @@ -477,6 +494,10 @@ def _step_title(step: dict[str, Any], index: int) -> str: return f"{index + 1}. {step_type}{suffix}" +def _planner_step_name(metadata: RecipeStepMetadata, *, planner_prefix: str = "") -> str: + return f"{planner_prefix}{int(metadata.index):03d} {metadata.title}" + + def _as_subtree( name: str, tree: BehaviorTree, @@ -505,12 +526,23 @@ def _sequence(name: str, trees: list[BehaviorTree]) -> BehaviorTree: return BT.Composite.Sequence(*trees, name=name) -def _action_tree(name: str, action: Callable[[], BehaviorTree.NodeState | None]) -> BehaviorTree: +def _action_tree( + name: str, + action: Callable[[], BehaviorTree.NodeState | None], + *, + aftercast_ms: int = 0, +) -> BehaviorTree: def _run() -> BehaviorTree.NodeState: result = action() return result if isinstance(result, BehaviorTree.NodeState) else BehaviorTree.NodeState.SUCCESS - return BehaviorTree(BehaviorTree.ActionNode(name=name, action_fn=_run)) + return BehaviorTree( + BehaviorTree.ActionNode( + name=name, + action_fn=_run, + aftercast_ms=max(0, int(aftercast_ms)), + ) + ) def _interact_tree(step: dict[str, Any], context: JsonBTCompilerContext, target: str) -> BehaviorTree: @@ -548,7 +580,13 @@ def _route_to_target_tree(step: dict[str, Any], context: JsonBTCompilerContext) ) -def _interact_and_dialog_tree(step: dict[str, Any], context: JsonBTCompilerContext, dialog_id: str | int) -> BehaviorTree: +def _interact_and_dialog_tree( + step: dict[str, Any], + context: JsonBTCompilerContext, + dialog_ids: list[str | int], + *, + interval_ms: int = 0, +) -> BehaviorTree: log = _bool(step.get("log"), False) target = _choice(step, "target", _choice(step, "kind", "npc")) if "gadget" in step and "target" not in step and "kind" not in step: @@ -568,12 +606,62 @@ def _interact_and_dialog_tree(step: dict[str, Any], context: JsonBTCompilerConte _interact_delay_tree(step), _target_tree(step, context, target), BT.Player.InteractTarget(log=log), - _dialog_delay_tree(step), - BT.Player.SendDialog(dialog_id=dialog_id, log=log), + *_dialog_send_trees(step, dialog_ids, interval_ms=interval_ms), name="InteractDialog", ) +def _dialog_send_trees( + step: dict[str, Any], + dialog_ids: list[str | int], + *, + interval_ms: int = 0, +) -> list[BehaviorTree]: + log = _bool(step.get("log"), False) + trees: list[BehaviorTree] = [] + for index, dialog_id in enumerate(dialog_ids): + trees.append(_wait_for_dialog_ready_tree(step, log=log)) + trees.append(BT.Player.SendDialog(dialog_id=_coerce_dialog_id(dialog_id), log=log)) + if interval_ms > 0 and index < len(dialog_ids) - 1: + trees.append(BT.Player.Wait(interval_ms, log=False)) + return trees + + +def _wait_for_dialog_ready_tree(step: dict[str, Any], *, log: bool) -> BehaviorTree: + timeout_ms = max(0, _int(step.get("dialog_ready_timeout_ms"), DEFAULT_DIALOG_READY_TIMEOUT_MS)) + poll_ms = max(1, _int(step.get("dialog_ready_poll_ms"), DEFAULT_DIALOG_READY_POLL_MS)) + + def _dialog_ready(_node: BehaviorTree.Node) -> BehaviorTree.NodeState: + from Py4GWCoreLib.Dialog import get_active_dialog + from Py4GWCoreLib.Dialog import get_active_dialog_buttons + + try: + active_dialog = get_active_dialog() + if active_dialog is not None: + return BehaviorTree.NodeState.SUCCESS + if get_active_dialog_buttons(): + return BehaviorTree.NodeState.SUCCESS + except Exception: + return BehaviorTree.NodeState.RUNNING + + return BehaviorTree.NodeState.RUNNING + + return BehaviorTree( + BehaviorTree.WaitUntilNode( + name="WaitForDialogReady", + condition_fn=_dialog_ready, + throttle_interval_ms=poll_ms, + timeout_ms=timeout_ms, + ) + ) + + +def _coerce_dialog_id(value: str | int) -> int: + if isinstance(value, int): + return value + return int(str(value).strip(), 0) + + def _move_to_point_tree(step: dict[str, Any], point: tuple[float, float], *, log: bool) -> BehaviorTree: return BT.Movement.Move( x=point[0], @@ -611,11 +699,6 @@ def _interact_delay_tree(step: dict[str, Any]) -> BehaviorTree: return BT.Player.Wait(delay_ms, log=False) if delay_ms > 0 else BehaviorTree(BehaviorTree.SucceederNode(name="NoInteractDelay")) -def _dialog_delay_tree(step: dict[str, Any]) -> BehaviorTree: - delay_ms = max(0, _int(step.get("dialog_delay_ms"), DEFAULT_DIALOG_DELAY_MS)) - return BT.Player.Wait(delay_ms, log=False) if delay_ms > 0 else BehaviorTree(BehaviorTree.SucceederNode(name="NoDialogDelay")) - - def _target_tree(step: dict[str, Any], context: JsonBTCompilerContext, target: str) -> BehaviorTree: log = _bool(step.get("log"), False) point = _point(step) @@ -666,7 +749,7 @@ def _target() -> BehaviorTree.NodeState: return BehaviorTree.NodeState.SUCCESS return BehaviorTree.NodeState.FAILURE - return _action_tree(f"Target{kind.title()}::{key}", _target) + return _action_tree(f"Target{kind.title()}::{key}", _target, aftercast_ms=250) def _agents_for_kind(kind: str) -> list[int]: @@ -727,7 +810,7 @@ def _target() -> BehaviorTree.NodeState: Player.ChangeTarget(int(gadgets[0])) return BehaviorTree.NodeState.SUCCESS - return _action_tree("TargetNearestGadget", _target) + return _action_tree("TargetNearestGadget", _target, aftercast_ms=250) def _optional_item_by_model_interact_tree(step: dict[str, Any], point: tuple[float, float] | None, *, log: bool) -> BehaviorTree: @@ -764,22 +847,26 @@ def _interact_if_found(_node: BehaviorTree.Node) -> BehaviorTree: return BT.Composite.Sequence( _move_to_point_tree(step, point, log=log), _interact_delay_tree(step), - _action_tree(f"OptionalTargetItemByModel::{model_id}", _target_optional_item), + _action_tree(f"OptionalTargetItemByModel::{model_id}", _target_optional_item, aftercast_ms=250), BehaviorTree(BehaviorTree.SubtreeNode(name="OptionalInteractItemByModel", subtree_fn=_interact_if_found)), name=f"OptionalInteractItemByModel::{model_id}", ) return BT.Composite.Sequence( - _action_tree(f"OptionalTargetItemByModel::{model_id}", _target_optional_item), + _action_tree(f"OptionalTargetItemByModel::{model_id}", _target_optional_item, aftercast_ms=250), BehaviorTree(BehaviorTree.SubtreeNode(name="OptionalMoveToItemByModel", subtree_fn=_move_if_found)), _interact_delay_tree(step), - _action_tree(f"OptionalRetargetItemByModel::{model_id}", _target_optional_item), + _action_tree(f"OptionalRetargetItemByModel::{model_id}", _target_optional_item, aftercast_ms=250), BehaviorTree(BehaviorTree.SubtreeNode(name="OptionalInteractItemByModel", subtree_fn=_interact_if_found)), name=f"OptionalInteractItemByModel::{model_id}", ) def _target_item_by_model(model_id: int, *, max_dist: float) -> BehaviorTree: - return _action_tree(f"TargetItemByModel::{model_id}", lambda: _target_item_by_model_action(model_id, max_dist)) + return _action_tree( + f"TargetItemByModel::{model_id}", + lambda: _target_item_by_model_action(model_id, max_dist), + aftercast_ms=250, + ) def _target_item_by_model_action(model_id: int, max_dist: float) -> BehaviorTree.NodeState: @@ -892,8 +979,8 @@ def _party_hero_ids(step: dict[str, Any], context: JsonBTCompilerContext) -> lis if explicit: return explicit - from .hero_setup_model import get_team_by_priority - from .hero_setup_model import resolve_hero_ids + from Py4GWCoreLib.botting_tree_src.hero_setup_model import get_team_by_priority + from Py4GWCoreLib.botting_tree_src.hero_setup_model import resolve_hero_ids required_source = step.get("required_hero", context.required_hero) required = resolve_hero_ids(required_source) diff --git a/Py4GWCoreLib/modular/runner.py b/Py4GWCoreLib/modular/runner.py deleted file mode 100644 index e599b8688..000000000 --- a/Py4GWCoreLib/modular/runner.py +++ /dev/null @@ -1,321 +0,0 @@ -"""BottingTree-backed runner wrapper for modular JSON recipe groups.""" -from __future__ import annotations - -import traceback -from dataclasses import dataclass -from pathlib import Path -from typing import Callable -from typing import Sequence - -from Py4GWCoreLib.BottingTree import BottingTree -from Py4GWCoreLib.py4gwcorelib_src.BehaviorTree import BehaviorTree - -from .json_bt_compiler import CompiledRecipeStep -from .json_bt_compiler import RecipeStepMetadata -from .json_bt_compiler import compile_recipe_step_to_bt -from .json_bt_compiler import compile_recipe_steps_to_bt -from .json_bt_compiler import load_recipe - - -@dataclass(frozen=True) -class RecipeSpec: - kind: str - key: str - title: str - - -@dataclass(frozen=True) -class RecipePhaseView: - name: str - - -@dataclass(frozen=True) -class CompiledRecipe: - spec: RecipeSpec - steps: tuple[CompiledRecipeStep, ...] - - -@dataclass(frozen=True) -class RuntimeStepView: - planner_step_name: str - spec: RecipeSpec - phase_index: int - recipe_title: str - step_index: int - step_total: int - metadata: RecipeStepMetadata - compiled_step: CompiledRecipeStep - - -class BTRecipeRunner: - def __init__( - self, - name: str, - specs: Sequence[RecipeSpec], - *, - start_index: int = 0, - start_step_index: int = 0, - loop: bool = False, - debug_hook: Callable[[str], None] | None = None, - ) -> None: - self.name = str(name) - self._specs = list(specs) - self._start_index = max(0, min(int(start_index), max(0, len(self._specs) - 1))) - self._start_step_index = max(0, int(start_step_index)) - self._loop = bool(loop) - self._debug_hook = debug_hook - self._last_state: BehaviorTree.NodeState | None = None - self._last_debug_phase_index: int | None = None - self._last_debug_step_index: int | None = None - self._last_debug_state: str | None = None - self._last_active_step_name = "" - self._phases = [ - RecipePhaseView(name=f"{index + 1:02d}. {spec.kind.title()}: {spec.title}") - for index, spec in enumerate(self._specs) - ] - self._recipes = self._compile_recipes() - self._runtime_steps = self._build_runtime_steps() - self._step_by_name = {step.planner_step_name: step for step in self._runtime_steps} - self._botting_tree = BottingTree( - bot_name=self.name, - pause_on_combat=False, - isolation_enabled=False, - ) - self._install_planner_steps(reset=True) - self._debug( - f"Initialized runner specs={len(self._specs)} compiled={len(self._recipes)} " - f"runtime_steps={len(self._runtime_steps)} " - f"start_index={self._start_index + 1 if self._specs else 0}/{len(self._specs)} " - f"start_step_index={self._start_step_index + 1 if self._start_step_index else 1} loop={self._loop}." - ) - - def start(self) -> None: - self.reset() - self._botting_tree.Start() - self._debug("Started.") - - def stop(self) -> None: - if self.is_running() or self.is_paused(): - self._debug(f"Stopped by user. {self.debug_snapshot()}") - self._botting_tree.Stop() - - def pause(self) -> None: - if not self.is_running(): - return - self._botting_tree.Pause(True) - self._debug(f"Paused. {self.debug_snapshot()}") - - def resume(self) -> None: - if not self.is_paused(): - return - self._botting_tree.Pause(False) - self._debug(f"Resumed. {self.debug_snapshot()}") - - def reset(self) -> None: - if self._botting_tree.IsStarted(): - self._botting_tree.Stop() - else: - self._botting_tree.Reset() - self._last_state = None - self._last_debug_phase_index = None - self._last_debug_step_index = None - self._last_debug_state = None - self._last_active_step_name = "" - self._install_planner_steps(reset=True) - - def is_running(self) -> bool: - return self._botting_tree.IsStarted() and not self._botting_tree.IsPaused() - - def is_paused(self) -> bool: - return self._botting_tree.IsPaused() - - def update(self) -> None: - if not self._botting_tree.IsStarted(): - return - self._debug_progress() - try: - self._last_state = self._botting_tree.tick() - except Exception as exc: - self._debug(f"Tick exception: {type(exc).__name__}: {exc}. {self.debug_snapshot()}") - self._debug(traceback.format_exc()) - self._botting_tree.Stop() - raise - self._debug_progress() - self._debug_state() - if not self._botting_tree.IsStarted(): - status = str(self._botting_tree.GetBlackboardValue("PLANNER_STATUS", "") or "") - if status == "PLANNER: Failed": - self._debug(f"Planner failed. {self.debug_snapshot()}") - elif status == "PLANNER: Completed": - self._debug("Completed all recipes.") - - def get_current_step_name(self) -> str: - _index, _total, _recipe_title, step_title = self.get_step_progress() - return step_title or self.name - - def get_current_step_metadata(self) -> RecipeStepMetadata | None: - view = self._active_step_view() - return view.metadata if view is not None else None - - def get_phase_title(self, index: int) -> str: - if 0 <= int(index) < len(self._phases): - return self._phases[int(index)].name - return f"Phase {int(index) + 1}" - - def get_phase_progress(self) -> tuple[int, int, str]: - total = len(self._specs) - view = self._active_step_view() - if view is None: - return 0, total, "" - return view.phase_index + 1, total, view.spec.title - - def get_step_progress(self) -> tuple[int, int, str, str]: - view = self._active_step_view() - if view is None: - return 0, 0, "", "" - return view.step_index, view.step_total, view.recipe_title, view.metadata.title - - def get_runtime_blackboard(self) -> dict: - return dict(self._botting_tree.blackboard) - - def debug_snapshot(self) -> str: - phase_current, phase_total, phase_title = self.get_phase_progress() - step_current, step_total, recipe_title, step_title = self.get_step_progress() - metadata = self.get_current_step_metadata() - anchor = bool(metadata.anchor) if metadata is not None else False - state = self._last_state.name if isinstance(self._last_state, BehaviorTree.NodeState) else str(self._last_state) - return ( - f"runner={self.name!r} running={self.is_running()} state={state} " - f"phase={phase_current}/{phase_total} {phase_title!r} " - f"recipe={recipe_title!r} step={step_current}/{step_total} {step_title!r} anchor={anchor}" - ) - - def _compile_recipes(self) -> list[CompiledRecipe]: - recipes: list[CompiledRecipe] = [] - for absolute_index, spec in enumerate(self._specs[self._start_index :], start=self._start_index): - path = _recipe_path(spec) - recipe_name = f"{spec.kind.title()}: {spec.title}" - self._debug(f"Compiling phase {absolute_index + 1}/{len(self._specs)} {spec.kind}:{spec.key} from {path}.") - try: - recipe = load_recipe(path) - steps = compile_recipe_steps_to_bt(recipe, recipe_name=recipe_name) - recipes.append( - CompiledRecipe( - spec=spec, - steps=steps, - ) - ) - self._debug(f"Compiled {spec.kind}:{spec.key} title={recipe_name!r} steps={len(steps)}.") - except Exception as exc: - self._debug(f"Compile failed for {spec.kind}:{spec.key} path={path}: {type(exc).__name__}: {exc}") - self._debug(traceback.format_exc()) - raise - return recipes - - def _build_runtime_steps(self) -> list[RuntimeStepView]: - runtime_steps: list[RuntimeStepView] = [] - for recipe_offset, recipe in enumerate(self._recipes): - phase_index = self._start_index + recipe_offset - step_total = len(recipe.steps) - first_step_index = 0 - if recipe_offset == 0 and step_total > 0: - first_step_index = min(self._start_step_index, step_total - 1) - for step_offset, compiled_step in enumerate(recipe.steps[first_step_index:], start=first_step_index): - step_index = step_offset + 1 - title = compiled_step.metadata.title - planner_step_name = f"{phase_index + 1:02d}.{step_index:03d} {title}" - runtime_steps.append( - RuntimeStepView( - planner_step_name=planner_step_name, - spec=recipe.spec, - phase_index=phase_index, - recipe_title=recipe.spec.title, - step_index=step_index, - step_total=step_total, - metadata=compiled_step.metadata, - compiled_step=compiled_step, - ) - ) - return runtime_steps - - def _install_planner_steps(self, *, reset: bool) -> None: - planner_steps: list[tuple[str, Callable[[], object]]] = [ - (step.planner_step_name, self._make_step_builder(step)) - for step in self._runtime_steps - ] - self._botting_tree.SetCurrentNamedPlannerSteps( - planner_steps, - name="ModularRecipeRunner", - auto_start=False, - reset=reset, - repeat=self._loop, - ) - - def _make_step_builder(self, step: RuntimeStepView) -> Callable[[], BehaviorTree]: - def _build_step_tree(step: RuntimeStepView = step) -> BehaviorTree: - return compile_recipe_step_to_bt( - dict(step.compiled_step.source_step), - step.compiled_step.context, - ) - - return _build_step_tree - - def _active_step_view(self) -> RuntimeStepView | None: - current_step_name = str(self._botting_tree.GetBlackboardValue("current_step_name", "") or "") - if current_step_name in self._step_by_name: - self._last_active_step_name = current_step_name - return self._step_by_name[current_step_name] - if self._last_active_step_name in self._step_by_name: - return self._step_by_name[self._last_active_step_name] - if self._runtime_steps: - return self._runtime_steps[0] - return None - - def _debug_progress(self) -> None: - view = self._active_step_view() - if view is None: - return - if ( - self._last_debug_phase_index == view.phase_index + 1 - and self._last_debug_step_index == view.step_index - ): - return - self._last_debug_phase_index = view.phase_index + 1 - self._last_debug_step_index = view.step_index - anchor_label = " anchor=true" if bool(view.metadata.anchor) else "" - self._debug( - f"Progress phase={view.phase_index + 1}/{len(self._specs)} {view.spec.kind}:{view.spec.key} " - f"step={view.step_index}/{view.step_total} {view.metadata.title!r}{anchor_label}." - ) - - def _debug_state(self) -> None: - status = str(self._botting_tree.GetBlackboardValue("PLANNER_STATUS", "") or "") - if status == self._last_debug_state: - return - self._last_debug_state = status - if status: - self._debug(f"Planner status={status}.") - - def _debug(self, message: str) -> None: - if self._debug_hook is None: - return - try: - self._debug_hook(str(message)) - except Exception: - pass - - -def specs_from_campaign_rows(rows: Sequence[tuple[str, str, str, str]]) -> list[RecipeSpec]: - return [RecipeSpec(kind=str(kind), key=str(key), title=str(title)) for _region, kind, key, title in rows] - - -def _recipe_path(spec: RecipeSpec) -> Path: - folder_by_kind = { - "dungeon": "dungeons", - "farm": "farms", - "mission": "missions", - "quest": "quests", - "route": "routes", - } - folder = folder_by_kind.get(spec.kind, spec.kind) - return Path(folder) / f"{spec.key}.json" diff --git a/Py4GWCoreLib/modular/widget_runtime.py b/Py4GWCoreLib/modular/widget_runtime.py deleted file mode 100644 index 5a4a7fc3e..000000000 --- a/Py4GWCoreLib/modular/widget_runtime.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Small widget helpers for BT-native modular tools.""" -from __future__ import annotations - -import time -import traceback -from typing import Any, Callable - -from Py4GWCoreLib import Console -from Py4GWCoreLib import ConsoleLog - - -_LAST_MAIN_EXCEPTION_AT: dict[str, float] = {} - - -def guarded_widget_main( - widget_name: str, - run_fn: Callable[[], None], - *, - get_bot: Callable[[], Any] | None = None, - throttle_seconds: float = 2.0, -) -> None: - try: - run_fn() - except Exception as exc: - now = time.monotonic() - last_at = float(_LAST_MAIN_EXCEPTION_AT.get(widget_name, 0.0)) - if (now - last_at) < float(throttle_seconds): - return - _LAST_MAIN_EXCEPTION_AT[widget_name] = now - ConsoleLog(widget_name, f"Widget main failed: {exc}", Console.MessageType.Error) - ConsoleLog(widget_name, traceback.format_exc(), Console.MessageType.Error) - - -def start_widget_bot( - widget_name: str, - build_bot_fn: Callable[[], Any], - *, - post_build_fn: Callable[[Any], None] | None = None, -) -> Any | None: - try: - tree_or_bot = build_bot_fn() - if callable(post_build_fn): - post_build_fn(tree_or_bot) - start = getattr(tree_or_bot, "start", None) - if callable(start): - start() - return tree_or_bot - except Exception as exc: - ConsoleLog(widget_name, f"Failed to start modular BT widget: {exc}", Console.MessageType.Error) - ConsoleLog(widget_name, traceback.format_exc(), Console.MessageType.Error) - return None diff --git a/Py4GWCoreLib/py4gwcorelib_src/BehaviorTree.py b/Py4GWCoreLib/py4gwcorelib_src/BehaviorTree.py index d1bd887f2..97fd6e933 100644 --- a/Py4GWCoreLib/py4gwcorelib_src/BehaviorTree.py +++ b/Py4GWCoreLib/py4gwcorelib_src/BehaviorTree.py @@ -210,22 +210,9 @@ def _normalize_state(result) -> Optional["BehaviorTree.NodeState"]: @staticmethod def _coerce_node(value) -> "BehaviorTree.Node": - if isinstance(value, BehaviorTree): - return value.root - if hasattr(value, "root") and hasattr(getattr(value, "root"), "tick") and hasattr(getattr(value, "root"), "get_children"): - return value.root if isinstance(value, BehaviorTree.Node): return value - if ( - hasattr(value, "tick") - and hasattr(value, "reset") - and hasattr(value, "get_children") - and hasattr(value, "blackboard") - ): - return value - raise TypeError( - f"Expected a BehaviorTree or BehaviorTree.Node, got {type(value).__name__}." - ) + return BehaviorTree.as_tree(value).root @staticmethod def _coerce_children(children) -> List["BehaviorTree.Node"]: @@ -396,6 +383,90 @@ def draw(self, indent: int = 0) -> None: PyImGui.tree_pop() + @staticmethod + def as_tree(value) -> "BehaviorTree": + if isinstance(value, BehaviorTree): + return value + if isinstance(value, BehaviorTree.Node): + return BehaviorTree(value) + if ( + hasattr(value, "root") + and hasattr(value, "tick") + and hasattr(value, "reset") + and hasattr(getattr(value, "root"), "tick") + ): + return cast("BehaviorTree", value) + raise TypeError(f"Expected a BehaviorTree or BehaviorTree.Node, got {type(value).__name__}.") + + @staticmethod + def resolve_tree(value_or_builder) -> "BehaviorTree": + value = value_or_builder() if callable(value_or_builder) else value_or_builder + return BehaviorTree.as_tree(value) + + @staticmethod + def build_sequence( + children: Sequence[object], + name: str = "Sequence", + step_name_fn: Callable[[int, object], str] | None = None, + ) -> "BehaviorTree": + nodes = [ + BehaviorTree.SubtreeNode( + name=step_name_fn(index, child) if step_name_fn is not None else f"Step{index + 1}", + subtree_fn=lambda node, child=child: BehaviorTree.resolve_tree(child), + ) + for index, child in enumerate(children) + ] + return BehaviorTree(BehaviorTree.SequenceNode(name=name, children=nodes)) + + @staticmethod + def build_named_sequence( + steps: Sequence[tuple[str, object]], + start_from: str | None = None, + name: str = "NamedSequence", + before_step: Callable[[str], "BehaviorTree | BehaviorTree.Node | None"] | None = None, + repeat: bool = False, + ) -> "BehaviorTree": + step_list = list(steps) + if not step_list: + return BehaviorTree(BehaviorTree.SequenceNode(name=name, children=[])) + + step_names = [step_name for step_name, _ in step_list] + start_index = 0 + if start_from is not None: + if start_from not in step_names: + raise ValueError(f"Unknown sequence step '{start_from}'. Valid values: {', '.join(step_names)}") + start_index = step_names.index(start_from) + + children: list[BehaviorTree.Node] = [] + for step_name, subtree_or_builder in step_list[start_index:]: + step_children: list[BehaviorTree.Node] = [] + if before_step is not None: + marker = before_step(step_name) + if marker is not None: + step_children.append(BehaviorTree.Node._coerce_node(marker)) + step_children.append( + BehaviorTree.SubtreeNode( + name=step_name, + subtree_fn=lambda node, subtree_or_builder=subtree_or_builder: BehaviorTree.resolve_tree( + subtree_or_builder + ), + ) + ) + children.append(BehaviorTree.SequenceNode(name=f"Step: {step_name}", children=step_children)) + + if repeat: + full_pass = BehaviorTree.build_named_sequence( + step_list, + start_from=None, + name=f"{name} Full Pass", + before_step=before_step, + repeat=False, + ) + children.append(BehaviorTree.RepeaterForeverNode(full_pass.root, name="Loop: restart routine")) + + return BehaviorTree(BehaviorTree.SequenceNode(name=name, children=children)) + + # -------------------------------------------------------- #region ActionNode @@ -1323,22 +1394,13 @@ def _ensure_subtree(self): subtree = self._factory(self) if subtree is None: raise ValueError("subtree_fn() returned None; expected a BehaviorTree or BehaviorTree.Node.") - if isinstance(subtree, BehaviorTree): - self._subtree = subtree - elif isinstance(subtree, BehaviorTree.Node): - self._subtree = BehaviorTree(subtree) - elif ( - hasattr(subtree, "root") - and hasattr(subtree, "tick") - and hasattr(subtree, "reset") - and hasattr(getattr(subtree, "root"), "tick") - ): - self._subtree = cast("BehaviorTree", subtree) - else: + try: + self._subtree = BehaviorTree.as_tree(subtree) + except TypeError as exc: raise TypeError( f"subtree_fn() returned invalid type {type(subtree).__name__}; " "expected a BehaviorTree or BehaviorTree.Node." - ) + ) from exc def get_children(self) -> List["BehaviorTree.Node"]: if self._subtree is not None: diff --git a/Py4GWCoreLib/routines_src/behaviourtrees_src/composite.py b/Py4GWCoreLib/routines_src/behaviourtrees_src/composite.py index f1d1f42ea..09459bd44 100644 --- a/Py4GWCoreLib/routines_src/behaviourtrees_src/composite.py +++ b/Py4GWCoreLib/routines_src/behaviourtrees_src/composite.py @@ -81,11 +81,7 @@ def as_tree(subtree: BehaviorTree | BehaviorTree.Node) -> BehaviorTree: UserDescription: Internal support routine. Notes: Wraps raw nodes in `BehaviorTree` and raises when the value is not tree-compatible. """ - if isinstance(subtree, BehaviorTree): - return subtree - if isinstance(subtree, BehaviorTree.Node): - return BehaviorTree(subtree) - raise TypeError("Composite helpers expect a BehaviorTree or BehaviorTree.Node.") + return BehaviorTree.as_tree(subtree) @staticmethod def resolve_subtree_factory( @@ -102,8 +98,7 @@ def resolve_subtree_factory( UserDescription: Internal support routine. Notes: Calls the builder when needed and then delegates normalization to `as_tree`. """ - subtree = subtree_or_builder() if callable(subtree_or_builder) else subtree_or_builder - return BTCompositeHelpers.as_tree(subtree) + return BehaviorTree.resolve_tree(subtree_or_builder) @staticmethod def move_and_target(move_tree: BehaviorTree, target_tree: BehaviorTree) -> BehaviorTree: @@ -310,14 +305,7 @@ def Sequence(*subtrees: BehaviorTree | BehaviorTree.Node, name: str = "Composite UserDescription: Internal support routine. Notes: Each child is wrapped as a subtree step named `Step1`, `Step2`, and so on. """ - children = [ - BehaviorTree.SubtreeNode( - name=f"Step{index + 1}", - subtree_fn=lambda node, subtree=subtree: BTCompositeHelpers.as_tree(subtree), - ) - for index, subtree in enumerate(subtrees) - ] - return BehaviorTree(BehaviorTree.SequenceNode(name=name, children=children)) + return BehaviorTree.build_sequence(subtrees, name=name) @staticmethod def SequenceNames(steps: list[tuple[str, "BTComposite.SequenceBuildable"]]) -> list[str]: @@ -351,21 +339,4 @@ def SequenceFrom( UserDescription: Internal support routine. Notes: Raises a `ValueError` if `start_from` does not match one of the provided step names. """ - if not steps: - return BehaviorTree(BehaviorTree.SequenceNode(name=name, children=[])) - - start_index = 0 - if start_from is not None: - step_names = BTComposite.SequenceNames(steps) - if start_from not in step_names: - raise ValueError(f"Unknown sequence step '{start_from}'. Valid values: {', '.join(step_names)}") - start_index = step_names.index(start_from) - - children = [ - BehaviorTree.SubtreeNode( - name=step_name, - subtree_fn=lambda node, subtree_or_builder=subtree_or_builder: BTCompositeHelpers.resolve_subtree_factory(subtree_or_builder), - ) - for step_name, subtree_or_builder in steps[start_index:] - ] - return BehaviorTree(BehaviorTree.SequenceNode(name=name, children=children)) + return BehaviorTree.build_named_sequence(steps, start_from=start_from, name=name) diff --git a/Py4GWCoreLib/routines_src/behaviourtrees_src/party.py b/Py4GWCoreLib/routines_src/behaviourtrees_src/party.py index 250ae8285..caf261b4a 100644 --- a/Py4GWCoreLib/routines_src/behaviourtrees_src/party.py +++ b/Py4GWCoreLib/routines_src/behaviourtrees_src/party.py @@ -309,6 +309,16 @@ def LoadParty( hero_ids = [int(h) for h in (hero_ids or []) if int(h) > 0] henchman_ids = [int(h) for h in (henchman_ids or []) if int(h) > 0] + def _party_hero_id(hero) -> int: + raw_hero_id = getattr(hero, "hero_id", 0) + get_id = getattr(raw_hero_id, "GetID", None) + if callable(get_id): + raw_hero_id = get_id() + try: + return int(raw_hero_id or 0) + except (TypeError, ValueError): + return 0 + def _load_party() -> BehaviorTree.NodeState: if not Party.IsPartyLeader(): _fail_log("BTParty.LoadParty", "Failed to load party: local player is not party leader.") @@ -323,7 +333,7 @@ def _load_party() -> BehaviorTree.NodeState: existing_heroes = set() for hero in Party.GetHeroes() or []: - hid = int(getattr(hero, "hero_id", 0) or 0) + hid = _party_hero_id(hero) if hid > 0: existing_heroes.add(hid) diff --git a/Sources/modular_data/prebuilt/__init__.py b/Sources/modular_data/prebuilt/__init__.py index 6551a064b..808bee36d 100644 --- a/Sources/modular_data/prebuilt/__init__.py +++ b/Sources/modular_data/prebuilt/__init__.py @@ -1,4 +1,4 @@ -"""Prebuilt BT recipe runner exports.""" +"""Prebuilt modular BottingTree exports.""" from .fow import FOW_QUEST_ORDER from .fow import create_modular_fow_bot from .modular_eotn import EOTN_PHASE_SPECS diff --git a/Sources/modular_data/prebuilt/_botting.py b/Sources/modular_data/prebuilt/_botting.py new file mode 100644 index 000000000..a915208fd --- /dev/null +++ b/Sources/modular_data/prebuilt/_botting.py @@ -0,0 +1,72 @@ +"""Shared helpers for prebuilt modular BottingTree recipes.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Sequence + +from Py4GWCoreLib.BottingTree import BottingTree +from Py4GWCoreLib.modular import compile_recipe_steps_to_named_planner_steps +from Py4GWCoreLib.modular import load_recipe + + +@dataclass(frozen=True) +class RecipeSpec: + kind: str + key: str + title: str + + +def specs_from_campaign_rows(rows: Sequence[tuple[str, str, str, str]]) -> list[RecipeSpec]: + return [RecipeSpec(kind=str(kind), key=str(key), title=str(title)) for _region, kind, key, title in rows] + + +def recipe_path(spec: RecipeSpec) -> Path: + folder_by_kind = { + "dungeon": "dungeons", + "farm": "farms", + "mission": "missions", + "quest": "quests", + "route": "routes", + } + folder = folder_by_kind.get(spec.kind, spec.kind) + return Path(folder) / f"{spec.key}.json" + + +def create_modular_botting_tree( + *, + name: str, + specs: Sequence[RecipeSpec], + start_index: int = 0, + loop: bool = False, + debug_hook: Callable[[str], None] | None = None, +) -> BottingTree: + selected_specs = list(specs)[max(0, int(start_index)) :] + planner_steps = [] + for offset, spec in enumerate(selected_specs, start=max(0, int(start_index))): + path = recipe_path(spec) + recipe_name = f"{spec.kind.title()}: {spec.title}" + if debug_hook is not None: + debug_hook(f"Compiling phase {offset + 1}/{len(specs)} {spec.kind}:{spec.key} from {path}.") + recipe = load_recipe(path) + planner_steps.extend( + compile_recipe_steps_to_named_planner_steps( + recipe, + recipe_name=recipe_name, + planner_prefix=f"{offset + 1:02d}.", + ) + ) + + botting_tree = BottingTree( + bot_name=name, + pause_on_combat=False, + isolation_enabled=False, + ) + botting_tree.SetCurrentNamedPlannerSteps( + planner_steps, + name=name, + auto_start=False, + reset=True, + repeat=bool(loop), + ) + return botting_tree diff --git a/Sources/modular_data/prebuilt/fow.py b/Sources/modular_data/prebuilt/fow.py index 94d5f1f32..fd6e1f36a 100644 --- a/Sources/modular_data/prebuilt/fow.py +++ b/Sources/modular_data/prebuilt/fow.py @@ -1,8 +1,10 @@ -"""BT-native FoW prebuilt runner.""" +"""BT-native FoW prebuilt BottingTree setup.""" from __future__ import annotations -from Py4GWCoreLib.modular import BTRecipeRunner -from Py4GWCoreLib.modular import RecipeSpec +from Py4GWCoreLib.BottingTree import BottingTree + +from ._botting import RecipeSpec +from ._botting import create_modular_botting_tree FOW_QUEST_ORDER: list[tuple[str, str]] = [ @@ -19,11 +21,11 @@ ("reward_time", "Reward Time"), ] -def create_modular_fow_bot(*, debug_hook=None) -> BTRecipeRunner: +def create_modular_fow_bot(*, debug_hook=None) -> BottingTree: if debug_hook is not None: - debug_hook("FoW BT runner initialized.") + debug_hook("FoW BottingTree initialized.") specs = [RecipeSpec(kind="quest", key=f"FoW/{key}", title=title) for key, title in FOW_QUEST_ORDER] - return BTRecipeRunner( + return create_modular_botting_tree( name="ModularFow", specs=specs, loop=True, diff --git a/Sources/modular_data/prebuilt/modular_eotn.py b/Sources/modular_data/prebuilt/modular_eotn.py index 2a17ede74..0414fad03 100644 --- a/Sources/modular_data/prebuilt/modular_eotn.py +++ b/Sources/modular_data/prebuilt/modular_eotn.py @@ -1,9 +1,11 @@ -"""EotN campaign BT recipe runner.""" +"""EotN campaign BottingTree recipe setup.""" from __future__ import annotations -from Py4GWCoreLib.modular import BTRecipeRunner -from Py4GWCoreLib.modular import RecipeSpec -from Py4GWCoreLib.modular import specs_from_campaign_rows +from Py4GWCoreLib.BottingTree import BottingTree + +from ._botting import RecipeSpec +from ._botting import create_modular_botting_tree +from ._botting import specs_from_campaign_rows # tuple format: (region, kind, key, title) @@ -97,11 +99,11 @@ def create_eotn_campaign_bot( options: EotnCampaignOptions | None = None, name: str = "Modular EotN", debug_hook=None, -) -> BTRecipeRunner: +) -> BottingTree: opts = options or EotnCampaignOptions() specs = build_eotn_campaign_specs() clamped_start = apply_eotn_start_index(specs, opts.start_phase_index) - return BTRecipeRunner( + return create_modular_botting_tree( name=name, specs=specs, start_index=clamped_start, diff --git a/Sources/modular_data/prebuilt/modular_nightfall.py b/Sources/modular_data/prebuilt/modular_nightfall.py index 6ade0e185..a7cdbf2da 100644 --- a/Sources/modular_data/prebuilt/modular_nightfall.py +++ b/Sources/modular_data/prebuilt/modular_nightfall.py @@ -1,10 +1,12 @@ -"""Nightfall campaign BT recipe runner.""" +"""Nightfall campaign BottingTree recipe setup.""" from __future__ import annotations +from Py4GWCoreLib.BottingTree import BottingTree from Py4GWCoreLib.enums_src.Title_enums import TITLE_TIERS, TitleID -from Py4GWCoreLib.modular import BTRecipeRunner -from Py4GWCoreLib.modular import RecipeSpec -from Py4GWCoreLib.modular import specs_from_campaign_rows + +from ._botting import RecipeSpec +from ._botting import create_modular_botting_tree +from ._botting import specs_from_campaign_rows SUNSPEAR_REPEAT_FARM_KEY = "sunspear/yohlon_insects" @@ -127,11 +129,11 @@ def create_nightfall_campaign_bot( options: NightfallCampaignOptions | None = None, name: str = "Modular Nightfall", debug_hook=None, -) -> BTRecipeRunner: +) -> BottingTree: opts = options or NightfallCampaignOptions() specs = build_nightfall_campaign_specs() clamped_start = apply_nightfall_start_index(specs, opts.start_phase_index) - return BTRecipeRunner( + return create_modular_botting_tree( name=name, specs=specs, start_index=clamped_start, diff --git a/Sources/modular_data/prebuilt/modular_prophecies.py b/Sources/modular_data/prebuilt/modular_prophecies.py index 8fe945583..5e6e3d4a0 100644 --- a/Sources/modular_data/prebuilt/modular_prophecies.py +++ b/Sources/modular_data/prebuilt/modular_prophecies.py @@ -1,9 +1,11 @@ -"""Prophecies campaign BT recipe runner.""" +"""Prophecies campaign BottingTree recipe setup.""" from __future__ import annotations -from Py4GWCoreLib.modular import BTRecipeRunner -from Py4GWCoreLib.modular import RecipeSpec -from Py4GWCoreLib.modular import specs_from_campaign_rows +from Py4GWCoreLib.BottingTree import BottingTree + +from ._botting import RecipeSpec +from ._botting import create_modular_botting_tree +from ._botting import specs_from_campaign_rows # tuple format: (region, kind, key, title) @@ -100,11 +102,11 @@ def create_prophecies_campaign_bot( options: PropheciesCampaignOptions | None = None, name: str = "Modular Prophecies", debug_hook=None, -) -> BTRecipeRunner: +) -> BottingTree: opts = options or PropheciesCampaignOptions() specs = build_prophecies_campaign_specs() clamped_start = apply_prophecies_start_index(specs, opts.start_phase_index) - return BTRecipeRunner( + return create_modular_botting_tree( name=name, specs=specs, start_index=clamped_start, diff --git a/Sources/modular_data/tools/compile_json_bt_recipes.py b/Sources/modular_data/tools/compile_json_bt_recipes.py index 8d50d7e02..8e6abc3eb 100644 --- a/Sources/modular_data/tools/compile_json_bt_recipes.py +++ b/Sources/modular_data/tools/compile_json_bt_recipes.py @@ -1,5 +1,5 @@ """ -Compile every modular JSON recipe with the canonical JSON-to-BT compiler. +Compile every modular JSON recipe through the canonical BottingTree planner-step adapter. This imports Py4GWCoreLib runtime modules, so it is expected to run in an environment where the Py4GW bindings are importable. @@ -28,7 +28,7 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) try: - from Py4GWCoreLib.modular.json_bt_compiler import compile_recipe_to_bt + from Py4GWCoreLib.modular import compile_recipe_steps_to_named_planner_steps except ModuleNotFoundError as exc: print(f"Cannot import Py4GW runtime bindings: {exc}") return 2 @@ -38,7 +38,12 @@ def main(argv: list[str] | None = None) -> int: for path in sorted(args.root.rglob("*.json")): try: recipe = json.loads(path.read_text(encoding="utf-8-sig")) - compile_recipe_to_bt(recipe, recipe_name=str(recipe.get("name", path.stem))) + planner_steps = compile_recipe_steps_to_named_planner_steps( + recipe, + recipe_name=str(recipe.get("name", path.stem)), + ) + for _step_name, builder in planner_steps: + builder() compiled += 1 except Exception as exc: failures.append(f"{path}: {type(exc).__name__}: {exc}") diff --git a/Sources/modular_data/tools/run_modular_action_smoke_tests.py b/Sources/modular_data/tools/run_modular_action_smoke_tests.py index deb2373e3..c67b2fa1b 100644 --- a/Sources/modular_data/tools/run_modular_action_smoke_tests.py +++ b/Sources/modular_data/tools/run_modular_action_smoke_tests.py @@ -1,7 +1,7 @@ """Runtime smoke runner for canonical modular JSON actions. This is intended for injected/PyGW runtime use. It builds tiny recipes on the -fly, compiles them through the same JSON-to-BT compiler used by Modular Tester, +fly, installs them into BottingTree through the modular planner-step adapter, and reports PASS/SKIP/FAIL per action. """ from __future__ import annotations @@ -90,8 +90,8 @@ def main(argv: list[str] | None = None) -> int: return 1 try: - from Py4GWCoreLib.modular.json_bt_compiler import compile_recipe_to_bt - from Py4GWCoreLib.py4gwcorelib_src.BehaviorTree import BehaviorTree + from Py4GWCoreLib.BottingTree import BottingTree + from Py4GWCoreLib.modular import compile_recipe_steps_to_named_planner_steps except ModuleNotFoundError as exc: print(f"Cannot import PyGW runtime bindings: {exc}") return 2 @@ -104,33 +104,48 @@ def main(argv: list[str] | None = None) -> int: continue recipe = {"name": f"Smoke: {case.name}", "steps": [step]} try: - tree = compile_recipe_to_bt(recipe, recipe_name=str(recipe["name"])) + planner_steps = compile_recipe_steps_to_named_planner_steps(recipe, recipe_name=str(recipe["name"])) + for _step_name, builder in planner_steps: + builder() except Exception as exc: results.append(SmokeResult(case.name, "FAIL", f"compile failed: {type(exc).__name__}: {exc}", "compile")) continue if args.dry_run: results.append(SmokeResult(case.name, "PASS", "compiled", "compile")) continue - results.append(_tick_case(case, tree, BehaviorTree, context.timeout_ms)) + results.append(_tick_case(case, planner_steps, BottingTree, context.timeout_ms)) _print_results(results) return 1 if any(result.status == "FAIL" for result in results) else 0 -def _tick_case(case: SmokeCase, tree: Any, behavior_tree_cls: Any, timeout_ms: int) -> SmokeResult: +def _tick_case(case: SmokeCase, planner_steps: list[tuple[str, Any]], botting_tree_cls: Any, timeout_ms: int) -> SmokeResult: deadline = time.monotonic() + (timeout_ms / 1000.0) - last_state = None + planner_status = "" try: - tree.reset() + bot = botting_tree_cls( + bot_name=f"Smoke: {case.name}", + pause_on_combat=False, + isolation_enabled=False, + ) + bot.SetCurrentNamedPlannerSteps( + planner_steps, + name=f"Smoke: {case.name}", + auto_start=False, + reset=True, + repeat=False, + ) + bot.Start() while time.monotonic() < deadline: - last_state = tree.tick() - if last_state == behavior_tree_cls.NodeState.RUNNING: + bot.tick() + planner_status = str(bot.GetBlackboardValue("PLANNER_STATUS", "") or "") + if bot.IsStarted(): time.sleep(0.05) continue - if last_state == behavior_tree_cls.NodeState.SUCCESS: - return SmokeResult(case.name, "PASS", "BT returned SUCCESS", case.detection) - return SmokeResult(case.name, "FAIL", f"BT returned {last_state}", case.detection) - return SmokeResult(case.name, "FAIL", f"timed out after {timeout_ms} ms; last_state={last_state}", case.detection) + if "Completed" in planner_status: + return SmokeResult(case.name, "PASS", "BottingTree planner completed", case.detection) + return SmokeResult(case.name, "FAIL", f"BottingTree stopped with planner_status={planner_status!r}", case.detection) + return SmokeResult(case.name, "FAIL", f"timed out after {timeout_ms} ms; planner_status={planner_status!r}", case.detection) except Exception as exc: return SmokeResult(case.name, "FAIL", f"runtime exception: {type(exc).__name__}: {exc}", case.detection) diff --git a/Sources/modular_data/tools/test_json_bt_compile_shape.py b/Sources/modular_data/tools/test_json_bt_compile_shape.py index a58ea2c48..3fa54859b 100644 --- a/Sources/modular_data/tools/test_json_bt_compile_shape.py +++ b/Sources/modular_data/tools/test_json_bt_compile_shape.py @@ -61,12 +61,17 @@ def __init__(self, name: str = "Subtree", subtree_fn=None) -> None: self.subtree_fn = subtree_fn +class _WaitUntilNode(_ActionNode): + pass + + class _BehaviorTree: NodeState = _NodeState Node = _Node ActionNode = _ActionNode SequenceNode = _SequenceNode SubtreeNode = _SubtreeNode + WaitUntilNode = _WaitUntilNode SucceederNode = _Node def __init__(self, root: _Node) -> None: @@ -94,6 +99,8 @@ def main() -> int: _install_stubs() compiler = _load_compiler() _assert_route_pause_contract(compiler) + _assert_named_dialog_target_settles_before_interact(compiler) + _assert_multi_dialog_interacts_once(compiler) failures: list[str] = [] compiled = 0 party_loads = 0 @@ -102,16 +109,13 @@ def main() -> int: for path in sorted(MODULAR_DATA_ROOT.rglob("*.json")): try: recipe = json.loads(path.read_text(encoding="utf-8-sig")) - tree = compiler.compile_recipe_to_bt(recipe, recipe_name=str(recipe.get("name") or path.stem)) - compiled_steps = compiler.compile_recipe_steps_to_bt( + planner_steps = compiler.compile_recipe_steps_to_named_planner_steps( recipe, recipe_name=str(recipe.get("name") or path.stem), ) - if not isinstance(tree, _BehaviorTree): - raise AssertionError(f"expected BehaviorTree, got {type(tree).__name__}") - _assert_per_step_compile_shape(tree, compiled_steps, recipe) - party_loads += _assert_party_load_shape(tree, recipe) - route_moves += _assert_route_move_shape(tree, recipe) + _assert_adapter_compile_shape(planner_steps, recipe) + party_loads += _assert_party_load_shape(planner_steps, recipe) + route_moves += _assert_route_move_shape(planner_steps, recipe) compiled += 1 except Exception as exc: failures.append(f"{path.relative_to(REPO_ROOT)}: {type(exc).__name__}: {exc}") @@ -162,10 +166,14 @@ def _install_stubs() -> None: bt_mod.BT = bt sys.modules["Py4GWCoreLib.routines_src.BehaviourTrees"] = bt_mod - hero_setup_model = types.ModuleType("Py4GWCoreLib.modular.hero_setup_model") + botting_pkg = types.ModuleType("Py4GWCoreLib.botting_tree_src") + botting_pkg.__path__ = [str(REPO_ROOT / "Py4GWCoreLib" / "botting_tree_src")] + sys.modules["Py4GWCoreLib.botting_tree_src"] = botting_pkg + + hero_setup_model = types.ModuleType("Py4GWCoreLib.botting_tree_src.hero_setup_model") hero_setup_model.get_team_by_priority = _stub_get_team_by_priority hero_setup_model.resolve_hero_ids = _stub_resolve_hero_ids - sys.modules["Py4GWCoreLib.modular.hero_setup_model"] = hero_setup_model + sys.modules["Py4GWCoreLib.botting_tree_src.hero_setup_model"] = hero_setup_model enum_pkg = types.ModuleType("Py4GWCoreLib.enums_src") enum_pkg.__path__ = [str(REPO_ROOT / "Py4GWCoreLib" / "enums_src")] @@ -233,16 +241,13 @@ def _expanded_steps(recipe: dict[str, Any]) -> list[dict[str, Any]]: return expanded -def _assert_party_load_shape(tree: _BehaviorTree, recipe: dict[str, Any]) -> int: - root = tree.root - children = list(getattr(root, "children", []) or []) +def _assert_party_load_shape(planner_steps: list[tuple[str, Any]], recipe: dict[str, Any]) -> int: verified = 0 for index, step in enumerate(_expanded_steps(recipe)): if step.get("type") != "party" or step.get("action") != "load": continue - child = children[index] - subtree_fn = getattr(child, "subtree_fn", None) - step_tree = subtree_fn(child) if callable(subtree_fn) else None + _step_name, builder = planner_steps[index] + step_tree = builder() node = getattr(step_tree, "root", None) if getattr(node, "name", "") != "LoadParty": raise AssertionError(f"party load step {index + 1} did not compile to BT.Party.LoadParty") @@ -260,16 +265,13 @@ def _assert_party_load_shape(tree: _BehaviorTree, recipe: dict[str, Any]) -> int return verified -def _assert_route_move_shape(tree: _BehaviorTree, recipe: dict[str, Any]) -> int: - root = tree.root - children = list(getattr(root, "children", []) or []) +def _assert_route_move_shape(planner_steps: list[tuple[str, Any]], recipe: dict[str, Any]) -> int: verified = 0 for index, step in enumerate(_expanded_steps(recipe)): if step.get("type") != "route" or step.get("mode", "move") not in {"move", "exit"}: continue - child = children[index] - subtree_fn = getattr(child, "subtree_fn", None) - step_tree = subtree_fn(child) if callable(subtree_fn) else None + _step_name, builder = planner_steps[index] + step_tree = builder() node = _primary_step_node(step_tree) kwargs = getattr(node, "kwargs", {}) if kwargs.get("pause_on_combat") is not False: @@ -290,34 +292,110 @@ def _assert_route_pause_contract(compiler) -> None: {"type": "route", "mode": "move", "points": [[1, 2]], "pause_on_combat": True}, ], } - tree = compiler.compile_recipe_to_bt(recipe, recipe_name="Route Pause Contract") + planner_steps = compiler.compile_recipe_steps_to_named_planner_steps( + recipe, + recipe_name="Route Pause Contract", + ) expected = [False, False, True, True] for index, expected_pause in enumerate(expected): - child = tree.root.children[index] - step_tree = child.subtree_fn(child) + _step_name, builder = planner_steps[index] + step_tree = builder() node = _primary_step_node(step_tree) actual = getattr(node, "kwargs", {}).get("pause_on_combat") if actual is not expected_pause: raise AssertionError(f"route pause contract step {index + 1}: expected {expected_pause}, got {actual}") -def _assert_per_step_compile_shape(tree: _BehaviorTree, compiled_steps: tuple, recipe: dict[str, Any]) -> None: +def _assert_named_dialog_target_settles_before_interact(compiler) -> None: + recipe = { + "name": "Named Dialog Target Settle Contract", + "steps": [ + { + "type": "interact", + "id": ["0x89"], + "point": [-2540, 16210], + "npc": "MHENLO", + "name": "Dialog - Mhenlo", + "action": "dialog", + "target": "npc", + }, + ], + } + planner_steps = compiler.compile_recipe_steps_to_named_planner_steps( + recipe, + recipe_name="Named Dialog Target Settle Contract", + ) + _step_name, builder = planner_steps[0] + step_tree = builder() + target_node = _find_node_by_name(getattr(step_tree, "root", None), "TargetNpc::MHENLO") + if target_node is None: + raise AssertionError("named dialog target did not compile to TargetNpc::MHENLO") + aftercast_ms = getattr(target_node, "kwargs", {}).get("aftercast_ms") + if int(aftercast_ms or 0) < 250: + raise AssertionError(f"named dialog target aftercast too short: {aftercast_ms!r}") + + +def _assert_multi_dialog_interacts_once(compiler) -> None: + recipe = { + "name": "Multi Dialog Single Interact Contract", + "steps": [ + { + "type": "interact", + "id": ["0x830E04", "0x81", "0x84"], + "npc": "LIONGUARD_FIGO", + "point": [-432, 3486], + "name": "Dialog - Lionguard Figo", + "action": "dialog", + "target": "npc", + }, + ], + } + planner_steps = compiler.compile_recipe_steps_to_named_planner_steps( + recipe, + recipe_name="Multi Dialog Single Interact Contract", + ) + _step_name, builder = planner_steps[0] + step_tree = builder() + root = getattr(step_tree, "root", None) + move_count = _count_nodes_by_name(root, "Move") + interact_count = _count_nodes_by_name(root, "InteractTarget") + send_count = _count_nodes_by_name(root, "SendDialog") + wait_dialog_count = _count_nodes_by_name(root, "WaitForDialogReady") + if move_count != 1: + raise AssertionError(f"multi-dialog step should move once, got {move_count}") + if interact_count != 1: + raise AssertionError(f"multi-dialog step should interact once, got {interact_count}") + if wait_dialog_count != 3: + raise AssertionError(f"multi-dialog step should wait for dialog before each send, got {wait_dialog_count}") + if send_count != 3: + raise AssertionError(f"multi-dialog step should send three dialogs, got {send_count}") + children = list(getattr(root, "children", []) or []) + names = [str(getattr(child, "name", "")) for child in children] + interact_index = names.index("InteractTarget") + tail = names[interact_index + 1 :] + expected_tail = [ + "WaitForDialogReady", + "SendDialog", + "WaitForDialogReady", + "SendDialog", + "WaitForDialogReady", + "SendDialog", + ] + if tail != expected_tail: + raise AssertionError(f"dialog send ordering changed: expected {expected_tail}, got {tail}") + + +def _assert_adapter_compile_shape(planner_steps: list[tuple[str, Any]], recipe: dict[str, Any]) -> None: expanded = _expanded_steps(recipe) - if len(compiled_steps) != len(expanded): - raise AssertionError(f"per-step compile count mismatch: {len(compiled_steps)} != {len(expanded)}") - children = list(getattr(tree.root, "children", []) or []) - if len(children) != len(compiled_steps): - raise AssertionError(f"full tree child count mismatch: {len(children)} != {len(compiled_steps)}") - for index, compiled_step in enumerate(compiled_steps): - metadata = getattr(compiled_step, "metadata", None) + if len(planner_steps) != len(expanded): + raise AssertionError(f"planner-step count mismatch: {len(planner_steps)} != {len(expanded)}") + for index, (_step_name, builder) in enumerate(planner_steps): + metadata = getattr(builder, "modular_step_metadata", None) if metadata is None or int(metadata.index) != index + 1: - raise AssertionError(f"per-step metadata index mismatch at {index + 1}") - full_step_tree = children[index].subtree_fn(children[index]) - per_step_tree = getattr(compiled_step, "tree", None) - full_root_name = getattr(getattr(full_step_tree, "root", None), "name", "") - per_step_root_name = getattr(getattr(per_step_tree, "root", None), "name", "") - if full_root_name != per_step_root_name: - raise AssertionError(f"per-step tree shape mismatch at {index + 1}") + raise AssertionError(f"planner-step metadata index mismatch at {index + 1}") + step_tree = builder() + if not isinstance(step_tree, _BehaviorTree): + raise AssertionError(f"planner-step builder {index + 1} returned {type(step_tree).__name__}") def _primary_step_node(step_tree: _BehaviorTree | None) -> _Node | None: @@ -329,5 +407,26 @@ def _primary_step_node(step_tree: _BehaviorTree | None) -> _Node | None: return node +def _find_node_by_name(node: _Node | None, name: str) -> _Node | None: + if node is None: + return None + if getattr(node, "name", "") == name: + return node + for child in list(getattr(node, "children", []) or []): + found = _find_node_by_name(child, name) + if found is not None: + return found + return None + + +def _count_nodes_by_name(node: _Node | None, name: str) -> int: + if node is None: + return 0 + count = 1 if getattr(node, "name", "") == name else 0 + for child in list(getattr(node, "children", []) or []): + count += _count_nodes_by_name(child, name) + return count + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/Sources/modular_data/tools/test_json_bt_compiler_contract.py b/Sources/modular_data/tools/test_json_bt_compiler_contract.py index e45fa41b3..0171c823a 100644 --- a/Sources/modular_data/tools/test_json_bt_compiler_contract.py +++ b/Sources/modular_data/tools/test_json_bt_compiler_contract.py @@ -27,13 +27,21 @@ def main() -> int: functions = {node.name for node in compiler_tree.body if isinstance(node, ast.FunctionDef)} assert { + "compile_recipe_steps_to_named_planner_steps", + "load_recipe", + "_compile_recipe_to_bt", + "_compile_recipe_steps_to_bt", + "_compile_recipe_step_to_bt", + "_compile_step_to_bt", + "_compile_file_to_bt", + } <= functions + assert not { "compile_recipe_to_bt", "compile_recipe_steps_to_bt", "compile_recipe_step_to_bt", "compile_step_to_bt", "compile_file_to_bt", - "load_recipe", - } <= functions + } & functions assert "build_action_step_tree" not in COMPILER_PATH.read_text(encoding="utf-8") audit = _load_audit_module() diff --git a/Sources/modular_data/tools/test_bt_recipe_runner_botting_tree.py b/Sources/modular_data/tools/test_modular_botting_tree_adapter.py similarity index 61% rename from Sources/modular_data/tools/test_bt_recipe_runner_botting_tree.py rename to Sources/modular_data/tools/test_modular_botting_tree_adapter.py index 315911e41..802812c0f 100644 --- a/Sources/modular_data/tools/test_bt_recipe_runner_botting_tree.py +++ b/Sources/modular_data/tools/test_modular_botting_tree_adapter.py @@ -1,7 +1,6 @@ -"""Offline contract check for the BottingTree-backed modular recipe runner.""" +"""Offline contract check for modular compiler planner-step adapter.""" from __future__ import annotations -import ast import importlib import json import sys @@ -13,7 +12,6 @@ REPO_ROOT = Path(__file__).resolve().parents[3] -RUNNER_PATH = REPO_ROOT / "Py4GWCoreLib" / "modular" / "runner.py" class _NodeState(Enum): @@ -33,6 +31,9 @@ def reset(self) -> None: def tick(self): return _NodeState.SUCCESS + def get_children(self): + return [] + class _ActionNode(_Node): def __init__(self, name: str = "Action", action_fn=None, args=None, **kwargs: Any) -> None: @@ -41,18 +42,14 @@ def __init__(self, name: str = "Action", action_fn=None, args=None, **kwargs: An self.args = list(args or []) self.kwargs = dict(kwargs) - def tick(self): - if callable(self.action_fn): - result = self.action_fn() - return result if isinstance(result, _NodeState) else _NodeState.SUCCESS - return _NodeState.SUCCESS - class _SequenceNode(_Node): def __init__(self, children=None, name: str = "Sequence") -> None: super().__init__(name=name) self.children = list(children or []) - self._current_child_index = 0 + + def get_children(self): + return list(self.children) class _SubtreeNode(_Node): @@ -72,17 +69,67 @@ class _BehaviorTree: def __init__(self, root: _Node) -> None: self.root = root self.blackboard: dict = {} - self.tick_count = 0 + + @staticmethod + def as_tree(value): + if isinstance(value, _BehaviorTree): + return value + if isinstance(value, _Node): + return _BehaviorTree(value) + raise TypeError(type(value).__name__) + + @staticmethod + def resolve_tree(value_or_builder): + value = value_or_builder() if callable(value_or_builder) else value_or_builder + return _BehaviorTree.as_tree(value) + + @staticmethod + def build_sequence(children, name="Sequence", step_name_fn=None): + nodes = [ + _SubtreeNode( + name=step_name_fn(index, child) if step_name_fn else f"Step{index + 1}", + subtree_fn=lambda _node, child=child: _BehaviorTree.resolve_tree(child), + ) + for index, child in enumerate(children) + ] + return _BehaviorTree(_SequenceNode(children=nodes, name=name)) + + @staticmethod + def build_named_sequence(steps, start_from=None, name="NamedSequence", before_step=None, repeat=False): + step_list = list(steps) + if start_from is not None: + names = [step_name for step_name, _builder in step_list] + step_list = step_list[names.index(start_from) :] + nodes = [ + _SubtreeNode( + name=step_name, + subtree_fn=lambda _node, builder=builder: _BehaviorTree.resolve_tree(builder), + ) + for step_name, builder in step_list + ] + return _BehaviorTree(_SequenceNode(children=nodes, name=name)) def reset(self) -> None: self.root.reset() def tick(self): - self.tick_count += 1 - self.root.blackboard = self.blackboard return self.root.tick() +class _BTNamespace: + def __getattr__(self, name: str): + def _factory(*args: Any, **kwargs: Any) -> _BehaviorTree: + return _BehaviorTree(_ActionNode(name=name, args=args, **kwargs)) + + return _factory + + +class _CompositeNamespace: + @staticmethod + def Sequence(*trees: _BehaviorTree, name: str = "Sequence") -> _BehaviorTree: + return _BehaviorTree.build_sequence(trees, name=name) + + class _BottingTree: instances: list["_BottingTree"] = [] @@ -91,11 +138,9 @@ def __init__(self, bot_name: str = "Botting Tree", pause_on_combat: bool = True, self.pause_on_combat = pause_on_combat self.isolation_enabled = isolation_enabled self.blackboard: dict = {} - self.started = False - self.paused = False self.steps: list[tuple[str, Any]] = [] self.repeat = False - self.current_index = 0 + self.started = False _BottingTree.instances.append(self) def SetCurrentNamedPlannerSteps( @@ -109,72 +154,17 @@ def SetCurrentNamedPlannerSteps( ): self.steps = list(steps) self.repeat = bool(repeat) - self.current_index = 0 - if reset: - self.Reset() if auto_start: self.Start() def Start(self): self.started = True - self.paused = False - self._publish_current_step() - - def Stop(self): - self.started = False - self.paused = False - - def Reset(self): - self.blackboard.clear() - self.current_index = 0 - - def Pause(self, pause=True): - self.paused = bool(pause) def IsStarted(self): return self.started - def IsPaused(self): - return self.paused - - def tick(self): - if not self.started or self.paused: - return _NodeState.RUNNING - self._publish_current_step() - if self.steps: - _name, builder = self.steps[self.current_index] - tree = builder() if callable(builder) else builder - self.blackboard["runner_used_botting_tree_blackboard"] = True - tick = getattr(tree, "tick", None) - if callable(tick): - tick() - self.blackboard["PLANNER_STATUS"] = "TICK" - return _NodeState.RUNNING - - def GetBlackboardValue(self, key, default=None): - return self.blackboard.get(key, default) - - def _publish_current_step(self): - if self.steps: - self.blackboard["current_step_name"] = self.steps[self.current_index][0] - - -class _BTNamespace: - def __getattr__(self, name: str): - def _factory(*args: Any, **kwargs: Any) -> _BehaviorTree: - return _BehaviorTree(_ActionNode(name=name, args=args, **kwargs)) - - return _factory - - -class _CompositeNamespace: - @staticmethod - def Sequence(*trees: _BehaviorTree, name: str = "Sequence") -> _BehaviorTree: - return _BehaviorTree(_SequenceNode(children=[tree.root for tree in trees], name=name)) - def main() -> int: - _assert_runner_source_contract() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) (root / "routes").mkdir() @@ -191,44 +181,28 @@ def main() -> int: encoding="utf-8", ) _install_stubs(root) - runner_mod = importlib.import_module("Py4GWCoreLib.modular.runner") - runner = runner_mod.BTRecipeRunner( - name="Runner Fixture", - specs=[runner_mod.RecipeSpec(kind="route", key="fixture", title="Fixture Route")], - start_step_index=1, - loop=True, + compiler = importlib.import_module("Py4GWCoreLib.modular.json_bt_compiler") + recipe = compiler.load_recipe("routes/fixture.json") + planner_steps = compiler.compile_recipe_steps_to_named_planner_steps( + recipe, + recipe_name="Fixture Route", + planner_prefix="01.", ) - botting_tree = _BottingTree.instances[-1] - assert botting_tree.pause_on_combat is False - assert botting_tree.repeat is True - assert len(botting_tree.steps) == 1 - assert botting_tree.steps[0][0].startswith("01.002 ") - runner.start() - assert runner.is_running() - assert runner.get_step_progress()[0:2] == (2, 2) - runner.update() - blackboard = runner.get_runtime_blackboard() - assert blackboard["runner_used_botting_tree_blackboard"] is True - assert blackboard["current_step_name"] == botting_tree.steps[0][0] - print("bt_recipe_runner_botting_tree: ok") + assert [name for name, _builder in planner_steps] == ["01.001 Warmup", "01.002 Move"] + metadata = getattr(planner_steps[1][1], "modular_step_metadata") + assert metadata.index == 2 + assert metadata.title == "Move" + assert isinstance(planner_steps[1][1](), _BehaviorTree) + + botting_mod = importlib.import_module("Py4GWCoreLib.BottingTree") + bot = botting_mod.BottingTree(bot_name="Fixture", pause_on_combat=False, isolation_enabled=False) + bot.SetCurrentNamedPlannerSteps(planner_steps[1:], name="Fixture", repeat=True) + assert bot.steps[0][0] == "01.002 Move" + assert bot.repeat is True + print("modular_botting_tree_adapter: ok") return 0 -def _assert_runner_source_contract() -> None: - text = RUNNER_PATH.read_text(encoding="utf-8") - tree = ast.parse(text) - imported_names = { - alias.name - for node in tree.body - if isinstance(node, ast.ImportFrom) and node.module == "Py4GWCoreLib.BottingTree" - for alias in node.names - } - assert "BottingTree" in imported_names - assert "SetCurrentNamedPlannerSteps" in text - assert "_refresh_runtime_blackboard" not in text - assert "recipe.tree.tick" not in text - - def _install_stubs(modular_root: Path) -> None: for name in list(sys.modules): if name == "Py4GWCoreLib" or name.startswith("Py4GWCoreLib."): diff --git a/Sources/modular_data/tools/validate_modular_architecture.py b/Sources/modular_data/tools/validate_modular_architecture.py index 8d968d60d..8155d44a5 100644 --- a/Sources/modular_data/tools/validate_modular_architecture.py +++ b/Sources/modular_data/tools/validate_modular_architecture.py @@ -12,19 +12,33 @@ MODULAR_DIR = REPO_ROOT / "Py4GWCoreLib" / "modular" MODULAR_CORE_DIR = REPO_ROOT / "Py4GWCoreLib" / "routines_src" / "behaviourtrees_src" / "modular_core" MODULAR_DATA_DIR = REPO_ROOT / "Sources" / "modular_data" -MODULAR_WIDGET_DIR = REPO_ROOT / "Widgets" / "Automation" / "modularbot" +MODULAR_WIDGET_DIR = REPO_ROOT / "Widgets" / "Automation" / "modular" COMPILER_PATH = MODULAR_DIR / "json_bt_compiler.py" -RUNNER_PATH = MODULAR_DIR / "runner.py" +BT_PATH = REPO_ROOT / "Py4GWCoreLib" / "py4gwcorelib_src" / "BehaviorTree.py" +BOTTING_PLANNER_PATH = REPO_ROOT / "Py4GWCoreLib" / "botting_tree_src" / "planner.py" +COMPOSITE_PATH = REPO_ROOT / "Py4GWCoreLib" / "routines_src" / "behaviourtrees_src" / "composite.py" +PREBUILT_DIR = MODULAR_DATA_DIR / "prebuilt" +BOTTING_HERO_SETUP_PATH = REPO_ROOT / "Py4GWCoreLib" / "botting_tree_src" / "hero_setup.py" +BOTTING_HERO_SETUP_MODEL_PATH = REPO_ROOT / "Py4GWCoreLib" / "botting_tree_src" / "hero_setup_model.py" EXPECTED_CANONICAL = {"behavior", "interact", "inventory", "map", "party", "route", "wait"} -REMOVED_PUBLIC_NAMES = {"ModularBot", "Phase", "register_action_node"} +REMOVED_PUBLIC_NAMES = {"ModularBot", "Phase", "register_action_node", "BTRecipeRunner"} +RAW_COMPILE_EXPORTS = { + "compile_file_to_bt", + "compile_recipe_to_bt", + "compile_recipe_step_to_bt", + "compile_recipe_steps_to_bt", + "compile_step_to_bt", +} def main() -> int: failures: list[str] = [] failures.extend(_check_compiler_contract()) - failures.extend(_check_runner_contract()) + failures.extend(_check_removed_runner_contract()) + failures.extend(_check_composition_ownership()) failures.extend(_check_removed_runtime_paths()) failures.extend(_check_broken_widget_references()) + failures.extend(_check_modular_tools_runtime_paths()) failures.extend(_check_json_types()) failures.extend(_check_json_audit()) @@ -53,23 +67,82 @@ def _check_compiler_contract() -> list[str]: for banned in ("build_action_step_tree", "@modular_step", "StepNodeRequest"): if banned in text: failures.append(f"[COMPILER] compiler still references legacy symbol {banned!r}.") + if "compile_recipe_steps_to_named_planner_steps" not in text: + failures.append("[COMPILER] missing compile_recipe_steps_to_named_planner_steps adapter.") + functions = {node.name for node in tree.body if isinstance(node, ast.FunctionDef)} + raw_public_functions = sorted(RAW_COMPILE_EXPORTS & functions) + if raw_public_functions: + failures.append(f"[COMPILER] raw compile-to-BT functions must be internal-only: {raw_public_functions}.") + if "Py4GWCoreLib.botting_tree_src.hero_setup_model" not in text: + failures.append("[COMPILER] party-load hero setup must come from BottingTree ownership.") init_text = (MODULAR_DIR / "__init__.py").read_text(encoding="utf-8") for name in REMOVED_PUBLIC_NAMES: if name in init_text: failures.append(f"[PUBLIC_API] __init__.py still exports removed name {name!r}.") + for name in RAW_COMPILE_EXPORTS: + if name in init_text: + failures.append(f"[PUBLIC_API] __init__.py must not export raw compile helper {name!r}.") return failures -def _check_runner_contract() -> list[str]: +def _check_removed_runner_contract() -> list[str]: failures: list[str] = [] - text = RUNNER_PATH.read_text(encoding="utf-8") - if "from Py4GWCoreLib.BottingTree import BottingTree" not in text: - failures.append("[RUNNER] BTRecipeRunner must import the BottingTree wrapper layer.") - if "SetCurrentNamedPlannerSteps" not in text: - failures.append("[RUNNER] BTRecipeRunner must install modular steps through BottingTree named planner steps.") - for banned in ("recipe.tree.tick", "_refresh_runtime_blackboard"): - if banned in text: - failures.append(f"[RUNNER] BTRecipeRunner still contains raw-runtime symbol {banned!r}.") + runner_path = MODULAR_DIR / "runner.py" + if runner_path.exists(): + failures.append("[RUNNER] Py4GWCoreLib/modular/runner.py must be removed.") + widget_runtime_path = MODULAR_DIR / "widget_runtime.py" + if widget_runtime_path.exists(): + failures.append("[RUNTIME] Py4GWCoreLib/modular/widget_runtime.py must be removed.") + for rel in ("hero_setup.py", "hero_setup_model.py", "hero_setup_ui.py"): + path = MODULAR_DIR / rel + if path.exists(): + failures.append(f"[OWNERSHIP] modular hero setup shim/file must be removed: {path.relative_to(REPO_ROOT)}") + if not BOTTING_HERO_SETUP_PATH.exists() or not BOTTING_HERO_SETUP_MODEL_PATH.exists(): + failures.append("[OWNERSHIP] BottingTree hero setup model/facade must exist.") + for root in (MODULAR_DIR, MODULAR_WIDGET_DIR, PREBUILT_DIR): + if not root.exists(): + continue + for path in root.rglob("*.py"): + text = path.read_text(encoding="utf-8") + for banned in ("BTRecipeRunner", "RuntimeStepView", "RecipePhaseView"): + if banned in text: + failures.append( + f"[RUNNER] {path.relative_to(REPO_ROOT)} still references removed runner symbol {banned!r}." + ) + if "class CompiledRecipe:" in text: + failures.append( + f"[RUNNER] {path.relative_to(REPO_ROOT)} still defines removed runner symbol 'CompiledRecipe'." + ) + for banned in ("recipe.tree.tick", "_refresh_runtime_blackboard"): + if banned in text: + failures.append( + f"[RUNNER] {path.relative_to(REPO_ROOT)} still contains raw-runtime symbol {banned!r}." + ) + for banned in ( + "Py4GWCoreLib.modular.widget_runtime", + "Py4GWCoreLib.modular.hero_setup", + "Py4GWCoreLib.modular.hero_setup_model", + "Py4GWCoreLib.modular.hero_setup_ui", + ): + if banned in text: + failures.append(f"[OWNERSHIP] {path.relative_to(REPO_ROOT)} still imports {banned!r}.") + return failures + + +def _check_composition_ownership() -> list[str]: + failures: list[str] = [] + bt_text = BT_PATH.read_text(encoding="utf-8") + for required in ("def as_tree", "def resolve_tree", "def build_sequence", "def build_named_sequence"): + if required not in bt_text: + failures.append(f"[BEHAVIOR_TREE] missing canonical helper {required}.") + planner_text = BOTTING_PLANNER_PATH.read_text(encoding="utf-8") + if "BehaviorTree.build_named_sequence" not in planner_text: + failures.append("[BOTTING_TREE] planner must delegate named sequence construction to BehaviorTree.") + if "_build_named_planner_tree" in planner_text or "_coerce_runtime_tree" in planner_text: + failures.append("[BOTTING_TREE] planner still contains duplicate sequence/coercion helpers.") + composite_text = COMPOSITE_PATH.read_text(encoding="utf-8") + if "BehaviorTree.build_sequence" not in composite_text or "BehaviorTree.build_named_sequence" not in composite_text: + failures.append("[BT_COMPOSITE] composite helpers must delegate sequence construction to BehaviorTree.") return failures @@ -102,6 +175,31 @@ def _check_removed_runtime_paths() -> list[str]: return failures +def _check_modular_tools_runtime_paths() -> list[str]: + failures: list[str] = [] + runtime_smoke = MODULAR_DATA_DIR / "tools" / "run_modular_action_smoke_tests.py" + if runtime_smoke.exists(): + text = runtime_smoke.read_text(encoding="utf-8") + for banned in ( + "compile_recipe_to_bt", + "from Py4GWCoreLib.py4gwcorelib_src.BehaviorTree import BehaviorTree", + "tree.tick()", + ): + if banned in text: + failures.append(f"[TOOLS] runtime smoke tool still bypasses BottingTree via {banned!r}.") + if "BottingTree" not in text or "SetCurrentNamedPlannerSteps" not in text: + failures.append("[TOOLS] runtime smoke tool must install generated recipes into BottingTree.") + + compile_tool = MODULAR_DATA_DIR / "tools" / "compile_json_bt_recipes.py" + if compile_tool.exists(): + text = compile_tool.read_text(encoding="utf-8") + if "compile_recipe_to_bt" in text: + failures.append("[TOOLS] compile_json_bt_recipes.py must compile through the planner-step adapter.") + if "compile_recipe_steps_to_named_planner_steps" not in text: + failures.append("[TOOLS] compile_json_bt_recipes.py must use the planner-step adapter.") + return failures + + def _check_json_audit() -> list[str]: audit_path = MODULAR_DATA_DIR / "tools" / "audit_json_bt_vocabulary.py" spec = importlib.util.spec_from_file_location("_audit_json_bt_vocabulary", audit_path) diff --git a/Widgets/Automation/modular/Modular Coder.py b/Widgets/Automation/modular/Modular Coder.py index 4406462bf..9a8187dc5 100644 --- a/Widgets/Automation/modular/Modular Coder.py +++ b/Widgets/Automation/modular/Modular Coder.py @@ -7,8 +7,8 @@ import PyImGui -from Py4GWCoreLib.modular.widget_runtime import guarded_widget_main from Sources.modular_data.tools import script_helper +from Widgets.Automation.modular.widget_guard import guarded_widget_main module_name = "Modular Coder" diff --git a/Widgets/Automation/modular/Modular Tester.py b/Widgets/Automation/modular/Modular Tester.py index c86c2f93b..588cc6edd 100644 --- a/Widgets/Automation/modular/Modular Tester.py +++ b/Widgets/Automation/modular/Modular Tester.py @@ -1,7 +1,6 @@ """Run a single modular JSON recipe through the BT-native compiler.""" from __future__ import annotations -import json from dataclasses import dataclass from pathlib import Path from typing import Any @@ -10,11 +9,13 @@ from Py4GWCoreLib import Console from Py4GWCoreLib import ConsoleLog +from Py4GWCoreLib.BottingTree import BottingTree from Py4GWCoreLib.botting_tree_src.ui import BottingTreeUIMovePathMixin -from Py4GWCoreLib.modular import BTRecipeRunner -from Py4GWCoreLib.modular import RecipeSpec +from Py4GWCoreLib.modular import RecipeStepMetadata +from Py4GWCoreLib.modular import compile_recipe_steps_to_named_planner_steps +from Py4GWCoreLib.modular import load_recipe from Py4GWCoreLib.modular.paths import modular_data_root -from Py4GWCoreLib.modular.widget_runtime import guarded_widget_main +from Widgets.Automation.modular.widget_guard import guarded_widget_main MODULE_NAME = "Modular Tester" @@ -52,7 +53,10 @@ class RecipeSummary: _selected_recipe = "" _browser_path: list[str] = [] _filter_text = "" -_runner: BTRecipeRunner | None = None +_runner: BottingTree | None = None +_planner_step_metadata: dict[str, RecipeStepMetadata] = {} +_planner_step_total = 0 +_last_active_step_name = "" _status = "" _last_recipe = "" _loop = False @@ -89,7 +93,8 @@ def _debug(message: str) -> None: def _refresh_recipe_files() -> None: - global _recipe_files, _recipe_tree, _recipe_titles, _recipe_summaries, _selected_recipe, _preview_step_index, _start_step_index + global _recipe_files, _recipe_tree, _recipe_titles, _recipe_summaries + global _selected_recipe, _preview_step_index, _start_step_index root = Path(modular_data_root()) recipes: list[str] = [] titles: dict[str, str] = {} @@ -166,9 +171,8 @@ def _browser_label() -> str: def _read_recipe(relative_path: str) -> dict[str, Any]: if not relative_path: return {} - path = Path(modular_data_root()) / relative_path try: - recipe = json.loads(path.read_text(encoding="utf-8-sig")) + recipe = load_recipe(relative_path) except Exception: return {} return recipe if isinstance(recipe, dict) else {} @@ -269,7 +273,7 @@ def _recipe_route_points(relative_path: str, *, start_step_index: int = 0) -> li def _path_draw_blackboard() -> dict: if _runner is not None: - blackboard = _runner.get_runtime_blackboard() + blackboard = dict(_runner.blackboard) points = blackboard.get("move_path_points") if isinstance(points, list) and points: state = str(blackboard.get("move_state") or "") @@ -307,31 +311,42 @@ def _draw_move_path_overlay() -> None: _path_drawer.DrawMovePathIfEnabled() -def _spec_from_relative_path(relative_path: str) -> RecipeSpec: - rel = Path(relative_path) - if len(rel.parts) < 2: - raise ValueError("Recipe must be inside a modular_data subfolder.") - kind = rel.parts[0] - key = Path(*rel.parts[1:]).with_suffix("").as_posix() - title = _recipe_titles.get(relative_path) or Path(relative_path).stem.replace("_", " ").title() - return RecipeSpec(kind=kind, key=key, title=title) - - def _start_selected_recipe() -> None: - global _runner, _status, _last_recipe + global _runner, _status, _last_recipe, _planner_step_metadata, _planner_step_total, _last_active_step_name relative_path = _selected_recipe_path() if not relative_path: _status = "No recipe selected." return try: - runner = BTRecipeRunner( - name=f"Modular Tester: {relative_path}", - specs=[_spec_from_relative_path(relative_path)], - start_step_index=_start_step_index, - loop=bool(_loop), - debug_hook=_debug, + recipe = load_recipe(relative_path) + recipe_name = str(recipe.get("name") or _recipe_titles.get(relative_path) or Path(relative_path).stem) + all_steps = compile_recipe_steps_to_named_planner_steps( + recipe, + recipe_name=recipe_name, + planner_prefix="01.", ) - runner.start() + _planner_step_total = len(all_steps) + start_index = min(max(0, int(_start_step_index)), max(0, len(all_steps) - 1)) + selected_steps = all_steps[start_index:] + _planner_step_metadata = { + step_name: getattr(builder, "modular_step_metadata") + for step_name, builder in all_steps + if hasattr(builder, "modular_step_metadata") + } + _last_active_step_name = "" + runner = BottingTree( + bot_name=f"Modular Tester: {relative_path}", + pause_on_combat=False, + isolation_enabled=False, + ) + runner.SetCurrentNamedPlannerSteps( + selected_steps, + name="ModularTester", + auto_start=False, + reset=True, + repeat=bool(_loop), + ) + runner.Start() _runner = runner _last_recipe = relative_path _status = f"Started {relative_path}." @@ -343,36 +358,59 @@ def _start_selected_recipe() -> None: def _stop_runner() -> None: global _status if _runner is not None: - _runner.stop() + _runner.Stop() _status = "Stopped." def _pause_runner() -> None: global _status if _runner is not None: - _runner.pause() + _runner.Pause(True) _status = "Paused." def _resume_runner() -> None: global _status if _runner is not None: - _runner.resume() + _runner.Pause(False) _status = "Resumed." def _runner_is_running() -> bool: - return _runner is not None and bool(_runner.is_running()) + return _runner is not None and bool(_runner.IsStarted()) and not bool(_runner.IsPaused()) def _runner_is_paused() -> bool: - return _runner is not None and bool(_runner.is_paused()) + return _runner is not None and bool(_runner.IsPaused()) + + +def _active_step_name() -> str: + global _last_active_step_name + if _runner is None: + return "" + current_step_name = str(_runner.GetBlackboardValue("current_step_name", "") or "") + if current_step_name: + _last_active_step_name = current_step_name + return current_step_name + return _last_active_step_name + + +def _current_step_metadata() -> RecipeStepMetadata | None: + step_name = _active_step_name() + return _planner_step_metadata.get(step_name) + + +def _step_progress() -> tuple[int, int, str, str]: + metadata = _current_step_metadata() + if metadata is None: + return 0, _planner_step_total, _last_recipe, "" + return metadata.index, _planner_step_total, _last_recipe, metadata.title def _progress_fraction() -> float: if _runner is None: return 0.0 - step_current, step_total, _recipe_title, _step_title = _runner.get_step_progress() + step_current, step_total, _recipe_title, _step_title = _step_progress() if step_total <= 0: return 0.0 return max(0.0, min(1.0, float(step_current) / float(step_total))) @@ -691,7 +729,8 @@ def _draw_recipe_overview() -> None: _draw_section_title(summary.title, summary.kind) PyImGui.text_wrapped(selected) PyImGui.spacing() - if PyImGui.begin_table("##modular_tester_metrics", 3, PyImGui.TableFlags.SizingStretchSame | PyImGui.TableFlags.NoSavedSettings): + metric_flags = PyImGui.TableFlags.SizingStretchSame | PyImGui.TableFlags.NoSavedSettings + if PyImGui.begin_table("##modular_tester_metrics", 3, metric_flags): PyImGui.table_next_row() PyImGui.table_set_column_index(0) _draw_metric("Steps", str(summary.steps), ACCENT) @@ -703,7 +742,8 @@ def _draw_recipe_overview() -> None: start_label = "1" steps = _recipe_steps_for_display(selected) if steps: - start_label = f"{_start_step_index + 1}: {_step_label(steps[min(_start_step_index, len(steps) - 1)], min(_start_step_index, len(steps) - 1))}" + start_index = min(_start_step_index, len(steps) - 1) + start_label = f"{_start_step_index + 1}: {_step_label(steps[start_index], start_index)}" _draw_label_value("Start at", start_label, ACCENT) route_points = _recipe_route_points(selected, start_step_index=_start_step_index) _draw_label_value("Path preview", f"{len(route_points)} waypoint(s)", GOOD if route_points else MUTED) @@ -722,16 +762,16 @@ def _draw_progress_panel() -> None: running = _runner_is_running() paused = _runner_is_paused() - phase_current, phase_total, phase_title = _runner.get_phase_progress() - step_current, step_total, recipe_title, step_title = _runner.get_step_progress() - metadata = _runner.get_current_step_metadata() + step_current, step_total, recipe_title, step_title = _step_progress() + metadata = _current_step_metadata() + planner_status = str(_runner.GetBlackboardValue("PLANNER_STATUS", "") or "") overlay = f"{step_current}/{step_total}" if step_total > 0 else _status_text() PyImGui.progress_bar(_progress_fraction(), -1.0, 0.0, overlay) PyImGui.spacing() run_state = "Running" if running else "Paused" if paused else "Stopped" _draw_label_value("Run", run_state, GOOD if running else WARN) - if phase_total > 0: - _draw_label_value("Phase", f"{phase_current}/{phase_total} {phase_title}") + if planner_status: + _draw_label_value("Planner", planner_status) if recipe_title: _draw_label_value("Recipe", recipe_title) if step_total > 0: @@ -775,7 +815,7 @@ def _draw_step_timeline() -> None: return active_index = 0 if _runner is not None and selected == _last_recipe: - step_current, _step_total, _recipe_title, _step_title = _runner.get_step_progress() + step_current, _step_total, _recipe_title, _step_title = _step_progress() active_index = int(step_current or 0) if _preview_step_index >= len(steps): _preview_step_index = max(0, len(steps) - 1) @@ -816,7 +856,8 @@ def _draw_step_detail() -> None: _start_step_index = index PyImGui.end_disabled() PyImGui.spacing() - if PyImGui.begin_table("##modular_tester_step_detail", 2, PyImGui.TableFlags.SizingStretchProp | PyImGui.TableFlags.RowBg): + detail_flags = PyImGui.TableFlags.SizingStretchProp | PyImGui.TableFlags.RowBg + if PyImGui.begin_table("##modular_tester_step_detail", 2, detail_flags): _draw_detail_row("Type", str(step.get("type") or "")) _draw_detail_row("Action", str(step.get("action") or step.get("mode") or "")) _draw_detail_row("Target", str(step.get("target") or step.get("npc") or step.get("gadget") or "")) @@ -875,8 +916,8 @@ def _draw_main_layout() -> None: def _main_impl() -> None: - if _runner is not None and _runner.is_running(): - _runner.update() + if _runner is not None and _runner_is_running(): + _runner.tick() PyImGui.set_next_window_size((880, 640), PyImGui.ImGuiCond.FirstUseEver) PyImGui.set_next_window_bg_alpha(1.0) @@ -893,7 +934,7 @@ def _main_impl() -> None: def main() -> None: - guarded_widget_main(MODULE_NAME, _main_impl, get_bot=lambda: _runner) + guarded_widget_main(MODULE_NAME, _main_impl) def tooltip() -> None: diff --git a/Widgets/Automation/modular/widget_guard.py b/Widgets/Automation/modular/widget_guard.py new file mode 100644 index 000000000..8b6414caf --- /dev/null +++ b/Widgets/Automation/modular/widget_guard.py @@ -0,0 +1,30 @@ +"""Local exception guard for modular widgets.""" +from __future__ import annotations + +import time +import traceback +from typing import Callable + +from Py4GWCoreLib import Console +from Py4GWCoreLib import ConsoleLog + + +_LAST_MAIN_EXCEPTION_AT: dict[str, float] = {} + + +def guarded_widget_main( + widget_name: str, + run_fn: Callable[[], None], + *, + throttle_seconds: float = 2.0, +) -> None: + try: + run_fn() + except Exception as exc: + now = time.monotonic() + last_at = float(_LAST_MAIN_EXCEPTION_AT.get(widget_name, 0.0)) + if (now - last_at) < float(throttle_seconds): + return + _LAST_MAIN_EXCEPTION_AT[widget_name] = now + ConsoleLog(widget_name, f"Widget main failed: {exc}", Console.MessageType.Error) + ConsoleLog(widget_name, traceback.format_exc(), Console.MessageType.Error) diff --git a/docs/modular/architecture.md b/docs/modular/architecture.md index 69d0c6cb0..d1585af6f 100644 --- a/docs/modular/architecture.md +++ b/docs/modular/architecture.md @@ -1,33 +1,38 @@ -# Modular JSON BT Architecture +# Modular JSON BottingTree Architecture -Modular JSON now compiles directly into `BehaviorTree` trees. The runtime path is intentionally short: +Modular JSON now compiles into `BehaviorTree` planner steps that are installed directly into `BottingTree`. +The runtime path is intentionally short: ```text Sources/modular_data JSON - -> Py4GWCoreLib.modular.compile_recipe_steps_to_bt - -> BTRecipeRunner facade - -> Py4GWCoreLib.BottingTree planner/services + -> Py4GWCoreLib.modular.compile_recipe_steps_to_named_planner_steps + -> Py4GWCoreLib.BottingTree.SetCurrentNamedPlannerSteps -> Py4GWCoreLib.routines_src.BehaviourTrees.BT ``` `BottingTree` is the runtime owner for planner ticking, blackboard state, HeroAI integration, -movement pause flags, services, and recovery. The modular runner must not tick compiled recipe -trees directly. +movement pause flags, services, party setup configuration, and recovery. Modular code must not tick +compiled recipe trees directly. -There is no `ModularBot`, `Phase`, action registry, `@modular_step`, or `modular_core` execution path. +There is no `ModularBot`, `Phase`, `BTRecipeRunner`, action registry, `@modular_step`, or `modular_core` +execution path. + +`BehaviorTree` is the only owner of BT node, coercion, and composition semantics. `BT.Composite` +remains as a non-modular compatibility surface, but its implementation delegates sequence construction +to `BehaviorTree`. ## Public Surface Supported callers should import from `Py4GWCoreLib.modular`: -- `compile_recipe_to_bt` -- `compile_recipe_steps_to_bt` -- `compile_recipe_step_to_bt` -- `compile_step_to_bt` -- `compile_file_to_bt` +- `compile_recipe_steps_to_named_planner_steps` - `load_recipe` - `audit_recipe_vocabulary` -- `BTRecipeRunner` +- `recipe_step_metadata` +- vocabulary/type metadata and compiler error/data types needed by the adapter + +Raw compile-to-`BehaviorTree` helpers are compiler internals only. Runtime code must install planner +steps into `BottingTree`; it must not import or tick full-recipe compiled trees. The only supported JSON step types are: @@ -44,19 +49,22 @@ The only supported JSON step types are: ```text Py4GWCoreLib/modular/ json_bt_compiler.py JSON validation and BT construction - runner.py BottingTree-backed wrapper for compiled recipe groups selectors.py Selector helper used by BT adapters and MerchantRules paths.py Project/data/settings path helpers - hero_setup*.py Account-scoped hero team setup data/UI domain/target_registry.py Named NPC/enemy/gadget definitions ``` +Hero team priority/configuration is owned by `Py4GWCoreLib.botting_tree_src.hero_setup*`. + Obsolete orchestration and registry packages were removed: - `Py4GWCoreLib/modular/actions` - `Py4GWCoreLib/modular/compiler` - `Py4GWCoreLib/modular/recipes` +- `Py4GWCoreLib/modular/runner.py` - `Py4GWCoreLib/modular/runtime_native` +- `Py4GWCoreLib/modular/widget_runtime.py` +- `Py4GWCoreLib/modular/hero_setup*.py` - `Py4GWCoreLib/routines_src/behaviourtrees_src/modular_core` ## JSON Data @@ -71,6 +79,8 @@ Use focused checks: python -m py_compile python Sources/modular_data/tools/audit_json_bt_vocabulary.py --fail-on-issues python Sources/modular_data/tools/test_json_bt_compiler_contract.py +python Sources/modular_data/tools/test_json_bt_compile_shape.py +python Sources/modular_data/tools/test_modular_botting_tree_adapter.py python Sources/modular_data/tools/validate_modular_architecture.py ```