diff --git a/Examples and tests/tests/local_bots/test_outpost_fighter_wipe_recovery.py b/Examples and tests/tests/local_bots/test_outpost_fighter_wipe_recovery.py new file mode 100644 index 000000000..620f5f865 --- /dev/null +++ b/Examples and tests/tests/local_bots/test_outpost_fighter_wipe_recovery.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +FIGHTER_PATH = ROOT / 'Widgets' / 'Automation' / 'Bots' / 'Runners' / 'OutpostFighter.py' +MOVE_PATH = ROOT / 'Py4GWCoreLib' / 'botting_src' / 'subclases_src' / 'MOVE_src.py' + + +class OutpostFighterWipeRecoveryTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.fighter = FIGHTER_PATH.read_text(encoding='utf-8') + cls.movement = MOVE_PATH.read_text(encoding='utf-8') + recovery_start = cls.fighter.index('def _on_party_wipe(') + recovery_end = cls.fighter.index('\ndef OnPartyWipe(', recovery_start) + cls.recovery = cls.fighter[recovery_start:recovery_end] + + def test_wipe_recovery_never_resigns_or_waits_for_an_outpost(self) -> None: + self.assertNotIn('_resignParty', self.recovery) + self.assertNotIn('_coro_until_on_outpost', self.recovery) + + def test_fighter_does_not_enable_the_return_on_defeat_widget(self) -> None: + widget_start = self.fighter.index('WIDGETS_TO_ENABLE') + widget_end = self.fighter.index('\n\nbot = Botting(', widget_start) + self.assertNotIn('Return to outpost on defeat', self.fighter[widget_start:widget_end]) + + def test_same_instance_recovery_resumes_the_reset_interrupted_state(self) -> None: + self.assertIn('fsm.current_state.reset()', self.fighter) + self.assertIn('Party revived at the resurrection shrine.', self.recovery) + self.assertIn('bot_instance.config.FSM.resume()', self.recovery) + + def test_game_forced_outpost_return_restarts_without_causing_the_return(self) -> None: + self.assertIn('if Map.IsOutpost() and _current_route_anchor:', self.recovery) + self.assertIn('jump_to_state_by_name(_current_route_anchor)', self.recovery) + + def test_route_paths_opt_into_nearest_forward_progress(self) -> None: + self.assertIn("resume_key_prefix=f'outpost-fighter:{_queue_version}:{index}'", self.fighter) + auto_start = self.movement.index(' def _coro_follow_auto_path(') + auto_end = self.movement.index(' def _coro_follow_path_to_aggro(', auto_start) + auto_path = self.movement[auto_start:auto_end] + self.assertIn('resume_key: Optional[str] = None', auto_path) + self.assertIn('range(floor_index, len(path_points))', auto_path) + self.assertIn('"next_index": index + 1', auto_path) + self.assertIn('current_uptime + 1_000 >= saved_uptime', auto_path) + + +if __name__ == '__main__': + unittest.main() diff --git a/Examples and tests/tests/local_bots/test_outpost_runner_route_mechanics.py b/Examples and tests/tests/local_bots/test_outpost_runner_route_mechanics.py new file mode 100644 index 000000000..1b1c128e3 --- /dev/null +++ b/Examples and tests/tests/local_bots/test_outpost_runner_route_mechanics.py @@ -0,0 +1,1264 @@ +from __future__ import annotations + +import ast +import importlib.util +import math +import sys +import types +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +ROUTE = ( + ROOT + / 'Sources' + / 'aC_Scripts' + / 'OutpostRunner' + / 'maps' + / 'NF - Desolation' + / '_1_BonePalace_To_BasaltGrotto.py' +) +LAIR_ROUTE = ROUTE.with_name('_2_BonePalace_To_LairOfTheForgotten.py') +MECHANICS = ROOT / 'Sources' / 'aC_Scripts' / 'OutpostRunner' / 'route_mechanics.py' +RUNNER = ROOT / 'Widgets' / 'Automation' / 'Bots' / 'Runners' / 'OutpostRunnerV2.py' +FIGHTER = ROOT / 'Widgets' / 'Automation' / 'Bots' / 'Runners' / 'OutpostFighter.py' +MAPS = ROOT / 'Sources' / 'aC_Scripts' / 'OutpostRunner' / 'maps' +JADE_SEA = MAPS / 'Cantha - The Jade Sea' +ECHOVALD = MAPS / 'Cantha - Echovald Forest' +ETERNAL_GROVE_ROUTE = ECHOVALD / '_9_VasburgArmory_To_TheEternalGrove.py' +SHING_JEA = MAPS / 'Cantha - Shing Jea Island' +TSUMEI_ROUTE = SHING_JEA / '_1_ShingJeaMonastery_To_TsumeiVillage.py' +RANKOR_DWC_ROUTE = ( + MAPS + / "Tyria - Beacon's Perch To Droknars Forge" + / '_5_CampRankor_To_DeldrimorWarCamp.py' +) +ELONA_SEEKERS_ROUTE = ( + MAPS + / 'Tyria - Desert Outposts' + / '_4_ElonaReach_to_SeekersPassage.py' +) +HARVEST_ROUTE = JADE_SEA / '_7_HarvestTemple_To_UnwakingWatersKurzick.py' +VABBI = MAPS / 'NF - Vabbi Tour' +TORMENT = MAPS / 'NF - Realm of Torment' +KOURNA = MAPS / 'NF - Kourna' +NIGHTFALL_ROUTE_EXPECTATIONS = { + VABBI / '_3_honurhill_to_dashavestibulepostcampaign.py': 'ROUTE-20260910-192540-375', + VABBI / '_4_HonurHill_To_YahnurMarket.py': 'ROUTE-20260910-202958-660', + VABBI / '_5_GrandCourtOfSebelkeh_To_DzagonurBastion.py': 'ROUTE-20260910-203705-316', + TORMENT / '_1_gateoftorment_to_gateofthenightfallenlands.py': 'ROUTE-20260910-193010-552', + TORMENT / '_2_gateoftorment_to_theshadownexus.py': 'ROUTE-20260910-193856-605', + KOURNA / '_1_pogahnpassage_to_camphojanu.py': 'ROUTE-20260910-195056-356', + KOURNA / '_2_NunduBay_To_DajkahInlet.py': 'ROUTE-20260910-202654-187', + KOURNA / '_3_KodonurCrossroads_To_RilohnRefuge.py': 'ROUTE-20260911-224924-163', +} +CANHTA_ROUTE_EXPECTATIONS = { + JADE_SEA / '_3_GyalaHatchery_To_EredonTerrace.py': 'ROUTE-20260910-172546-445', + JADE_SEA / '_4_EredonTerrace_To_BaiPaasuReach.py': 'ROUTE-20260910-172852-363', + ECHOVALD / '_1_HouseZuHeltzer_To_SaintAnjekasShrine.py': 'ROUTE-20260910-173207-900', + ECHOVALD / '_2_SaintAnjekasShrine_To_LutgardisConservatory.py': 'ROUTE-20260910-173531-803', + ECHOVALD / '_3_SaintAnjekasShrine_To_BrauerAcademy.py': 'ROUTE-20260910-173849-995', + ECHOVALD / '_4_UnwakingWatersKurzick_To_VasburgArmory.py': 'ROUTE-20260910-174117-968', + ECHOVALD / '_5_UnwakingWatersKurzick_To_DurheimArchives.py': 'ROUTE-20260910-174532-032', + ECHOVALD / '_6_VasburgArmory_To_AmatzBasin.py': 'ROUTE-20260910-174813-450', + ECHOVALD / '_7_HouseZuHeltzer_To_AspenwoodGateKurzick.py': 'ROUTE-20260910-213119-662', + ECHOVALD / '_8_LutgardisConservatory_To_JadeFlatsKurzick.py': 'ROUTE-20260910-214257-099', + ETERNAL_GROVE_ROUTE: 'ROUTE-20260914-211649-677', + JADE_SEA / '_5_UnwakingWatersLuxon_To_SeafarersRest.py': 'ROUTE-20260910-175318-524', + JADE_SEA / '_6_SeafarersRest_To_AuriosMines.py': 'ROUTE-20260910-175542-883', + HARVEST_ROUTE: 'ROUTE-20260910-180114-736', + JADE_SEA / '_8_Cavalon_To_JadeFlatsLuxon.py': 'ROUTE-20260910-181100-432', + TSUMEI_ROUTE: 'ROUTE-20260910-181601-806', +} + + +def _route_assignments(route: Path = ROUTE) -> dict[str, object]: + tree = ast.parse(route.read_text(encoding='utf-8')) + result: dict[str, object] = {} + + class ReplaceMapLookups(ast.NodeTransformer): + def visit_Subscript(self, node: ast.Subscript) -> ast.Constant: + return ast.copy_location(ast.Constant(value=0), node) + + for node in tree.body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + if not isinstance(target, ast.Name): + continue + if target.id.endswith(('_ids', '_outpost_path', '_segments')): + result[target.id] = ast.literal_eval(ReplaceMapLookups().visit(node.value)) + return result + + +def _load_mechanics_with_fakes(): + fake_core = types.ModuleType('Py4GWCoreLib') + + class Heroes: + flags: list[tuple[str, tuple[float, ...]]] = [] + + @classmethod + def FlagAllHeroes(cls, *xy): + cls.flags.append(('flag', xy)) + + @classmethod + def UnflagAllHeroes(cls): + cls.flags.append(('unflag', ())) + + fake_core.Agent = types.SimpleNamespace() + fake_core.ConsoleLog = lambda *_args, **_kwargs: None + fake_core.Effects = types.SimpleNamespace() + fake_core.GLOBAL_CACHE = types.SimpleNamespace() + fake_core.Map = types.SimpleNamespace(GetMapID=lambda: 298) + fake_core.Party = types.SimpleNamespace(Heroes=Heroes, GetHeroes=lambda: []) + fake_core.Player = types.SimpleNamespace() + fake_core.PySystem = types.SimpleNamespace( + Console=types.SimpleNamespace(MessageType=types.SimpleNamespace(Info=1, Error=2)) + ) + fake_core.Routines = types.SimpleNamespace(Yield=types.SimpleNamespace(wait=lambda _ms: iter(()))) + fake_core.SkillBar = types.SimpleNamespace() + + fake_ri = types.ModuleType('Py4GWCoreLib.routines_src.reliable_interaction') + + class Runtime: + def __init__(self, **_kwargs): + pass + + class Record: + def __init__(self, *args, **kwargs): + self.args = args + for key, value in kwargs.items(): + setattr(self, key, value) + + for name in ( + 'ApproachSpec', + 'DialogSpec', + 'InteractionSpec', + 'PostconditionSpec', + 'RetryPolicy', + 'TargetSpec', + ): + setattr(fake_ri, name, Record) + for name in ('InteractionProfile', 'PostconditionKind', 'TargetKind'): + setattr( + fake_ri, + name, + types.SimpleNamespace( + VISIBLE_CHOICE=1, + GADGET=2, + EFFECT_PRESENT=3, + CUSTOM=4, + NPC=5, + QUEST_CONVERSATION=6, + MAP_CHANGED=7, + ), + ) + fake_ri.Py4GWInteractionRuntime = Runtime + controllers = [] + + def controller(spec, runtime): + controllers.append((spec, runtime)) + return object() + + def adapter(*_args, **_kwargs): + yield + return True + + fake_ri.ReliableInteractionController = controller + fake_ri.run_coroutine_adapter = adapter + + saved = {name: sys.modules.get(name) for name in ('Py4GWCoreLib', 'Py4GWCoreLib.routines_src.reliable_interaction')} + sys.modules['Py4GWCoreLib'] = fake_core + sys.modules['Py4GWCoreLib.routines_src.reliable_interaction'] = fake_ri + try: + spec = importlib.util.spec_from_file_location('test_route_mechanics_runtime', MECHANICS) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + module._test_interaction_controllers = controllers + return module, Heroes + finally: + for name, previous in saved.items(): + if previous is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = previous + + +def _finish(generator): + while True: + try: + next(generator) + except StopIteration as done: + return done.value + + +class BonePalaceBasaltGrottoRouteTests(unittest.TestCase): + def test_route_preserves_capture_identity_and_destination(self) -> None: + data = _route_assignments() + ids = next(value for key, value in data.items() if key.endswith('_ids')) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + self.assertEqual(ids['source_route_id'], 'ROUTE-20260902-140340-849') + self.assertEqual(len(segments), 2) + self.assertEqual(segments[-1]['steps'], []) + + def test_route_keeps_named_mechanics_in_order(self) -> None: + data = _route_assignments() + segments = next(value for key, value in data.items() if key.endswith('_segments')) + steps = segments[0]['steps'] + self.assertEqual( + [step['type'] for step in steps], + [ + 'path', + 'blessing', + 'path', + 'enter_junundu', + 'path', + 'path', + 'enter_junundu', + 'path', + 'verify_human_form', + 'path', + ], + ) + self.assertEqual(steps[1]['capture_id'], 'NPC-20260902-140420-526') + self.assertEqual(steps[8]['capture_id'], 'LOC-20260902-141142-793') + + def test_first_sulfur_boundary_does_not_manually_leave_junundu(self) -> None: + segments = next(value for key, value in _route_assignments().items() if key.endswith('_segments')) + steps = segments[0]['steps'] + first_spoor = next(index for index, step in enumerate(steps) if step['name'] == 'Enter first Junundu party') + second_spoor = next(index for index, step in enumerate(steps) if step['name'] == 'Enter second Junundu party') + boundary_steps = steps[first_spoor + 1:second_spoor] + + self.assertEqual([step['type'] for step in boundary_steps], ['path', 'path']) + self.assertNotIn('leave_junundu', [step['type'] for step in boundary_steps]) + self.assertEqual(boundary_steps[-1]['name'], 'Stay in Junundu across boundary to second Wurm Spoor') + + def test_blessing_uses_captured_accept_choice_and_margonites_effect(self) -> None: + segments = next(value for key, value in _route_assignments().items() if key.endswith('_segments')) + blessing = segments[0]['steps'][1] + self.assertEqual(blessing['visible_button'], 1) + self.assertEqual(blessing['model_id'], 5666) + self.assertEqual(blessing['effect_ids'], (1849, 2036, 2037)) + + def test_recorded_combat_backtrack_is_removed(self) -> None: + segments = next(value for key, value in _route_assignments().items() if key.endswith('_segments')) + boundary_path = segments[0]['steps'][5]['path'] + self.assertNotIn((-2340.29, 10125.91), boundary_path) + self.assertNotIn((-3031.43, 8806.78), boundary_path) + self.assertIn((-2247.34, 10625.07), boundary_path) + self.assertIn((-1800.66, 10359.92), boundary_path) + + def test_final_wurm_spoor_regroups_the_party_at_the_live_target(self) -> None: + segments = next(value for key, value in _route_assignments().items() if key.endswith('_segments')) + final_spoor = segments[0]['steps'][6] + self.assertEqual(final_spoor['target_xy'], (6997.0, 9996.0)) + self.assertEqual(final_spoor['party_regroup_xy'], final_spoor['target_xy']) + self.assertEqual(final_spoor['party_regroup_radius'], 300.0) + self.assertEqual(final_spoor['party_regroup_settle_ms'], 1_500) + self.assertEqual(final_spoor['mount_attempts'], 3) + + def test_transient_empty_hero_snapshot_cannot_verify_full_wurm_party(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + mechanics.Party.GetHeroCount = lambda: 2 + mechanics.Party.GetHeroes = lambda: [] + mechanics.GLOBAL_CACHE.SkillBar = types.SimpleNamespace( + GetSkillIDBySlot=lambda _slot: mechanics.JUNUNDU_STRIKE_SKILL_ID + ) + + self.assertFalse(mechanics.full_hero_party_in_junundu(2)) + self.assertFalse( + mechanics._required_heroes_close( + 300.0, + (0.0, 0.0), + expected_hero_count=2, + ) + ) + + def test_both_consumers_use_shared_mechanic_registration(self) -> None: + for path in (RUNNER, FIGHTER): + source = path.read_text(encoding='utf-8') + self.assertIn('register_botting_segment', source) + + def test_shared_mechanics_have_bounded_party_verification_and_retry(self) -> None: + source = MECHANICS.read_text(encoding='utf-8') + self.assertIn('full_hero_party_in_junundu', source) + self.assertIn("Party.Heroes.FlagAllHeroes", source) + self.assertIn("yield from _leave_junundu()", source) + self.assertIn("yield from _mount_attempt(bot, data, expected_hero_count)", source) + self.assertIn('finally:', source) + self.assertIn('Party.Heroes.UnflagAllHeroes()', source) + + def test_partial_wurm_party_leaves_and_retries_once(self) -> None: + mechanics, heroes = _load_mechanics_with_fakes() + calls: list[str] = [] + + def wait_until(*_args, **_kwargs): + calls.append('regroup') + yield + return True + + def mount(*_args, **_kwargs): + calls.append('mount') + yield + return calls.count('mount') == 2 + + def leave(*_args): + calls.append('leave') + yield + return True + + mechanics.full_hero_party_in_junundu = lambda *_args: False + mechanics._wait_until = wait_until + mechanics._mount_attempt = mount + mechanics._leave_junundu = leave + result = _finish( + mechanics._enter_junundu( + object(), + { + 'target_xy': (1.0, 2.0), + 'party_regroup_xy': (3.0, 4.0), + 'player_approach_xy': (1.0, 2.0), + }, + ) + ) + self.assertTrue(result) + self.assertEqual(calls, ['regroup', 'mount', 'leave', 'regroup', 'mount']) + self.assertEqual(heroes.flags, [('flag', (3.0, 4.0)), ('unflag', ())]) + + def test_second_spoor_can_use_configured_third_mount_attempt(self) -> None: + mechanics, heroes = _load_mechanics_with_fakes() + calls: list[str] = [] + + def wait_until(*_args, **_kwargs): + calls.append('regroup') + yield + return True + + def mount(*_args, **_kwargs): + calls.append('mount') + yield + return calls.count('mount') == 3 + + def leave(*_args): + calls.append('leave') + yield + return True + + mechanics.full_hero_party_in_junundu = lambda *_args: False + mechanics._wait_until = wait_until + mechanics._mount_attempt = mount + mechanics._leave_junundu = leave + result = _finish( + mechanics._enter_junundu( + object(), + { + 'name': 'Enter second Junundu party', + 'target_xy': (1.0, 2.0), + 'party_regroup_xy': (1.0, 2.0), + 'player_approach_xy': (1.0, 2.0), + 'mount_attempts': 3, + }, + ) + ) + + self.assertTrue(result) + self.assertEqual( + calls, + ['regroup', 'mount', 'leave', 'regroup', 'mount', 'leave', 'regroup', 'mount'], + ) + self.assertEqual(heroes.flags, [('flag', (1.0, 2.0)), ('unflag', ())]) + + def test_wurm_regroup_defaults_to_the_spoor_target(self) -> None: + mechanics, heroes = _load_mechanics_with_fakes() + + def wait_until(*_args, **_kwargs): + yield + return True + + def mount(*_args, **_kwargs): + yield + return True + + mechanics.full_hero_party_in_junundu = lambda *_args: False + mechanics._wait_until = wait_until + mechanics._mount_attempt = mount + result = _finish( + mechanics._enter_junundu( + object(), + { + 'target_xy': (7.0, 9.0), + 'player_approach_xy': (1.0, 2.0), + }, + ) + ) + self.assertTrue(result) + self.assertEqual(heroes.flags, [('flag', (7.0, 9.0)), ('unflag', ())]) + + def test_party_regroup_proximity_is_measured_from_the_flag_point(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + hero = types.SimpleNamespace(agent_id=7) + mechanics.Party.GetHeroes = lambda: [hero] + mechanics.Player.GetXY = lambda: (5_000.0, 5_000.0) + mechanics.Agent.IsValid = lambda agent_id: agent_id == 7 + mechanics.Agent.IsDead = lambda _agent_id: False + mechanics.Agent.GetXY = lambda _agent_id: (110.0, 100.0) + + self.assertTrue(mechanics._required_heroes_close(20.0, (100.0, 100.0))) + self.assertFalse(mechanics._required_heroes_close(5.0, (100.0, 100.0))) + + def test_leave_junundu_retries_dropped_player_cast_and_commands_wurm_heroes(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + player_casts: list[int] = [] + hero_casts: list[tuple[int, int, int]] = [] + mechanics.Party.GetHeroes = lambda: [object()] + mechanics._party_slot_one = lambda *_args: ( + (mechanics.JUNUNDU_STRIKE_SKILL_ID, (mechanics.JUNUNDU_STRIKE_SKILL_ID,)) + if len(player_casts) < 2 + else (1, (2,)) + ) + mechanics._hero_slot_skill = lambda _position, slot: ( + mechanics.LEAVE_JUNUNDU_SKILL_ID if slot == 8 else None + ) + mechanics.GLOBAL_CACHE.SkillBar = types.SimpleNamespace( + GetSkillIDBySlot=lambda slot: mechanics.LEAVE_JUNUNDU_SKILL_ID if slot == 8 else 0 + ) + mechanics.SkillBar = types.SimpleNamespace( + UseSkillTargetless=lambda slot: player_casts.append(slot), + HeroUseSkill=lambda target, slot, hero: hero_casts.append((target, slot, hero)), + ) + + self.assertTrue(_finish(mechanics._leave_junundu())) + self.assertEqual(player_casts, [8, 8]) + self.assertEqual(hero_casts, [(0, 8, 1), (0, 8, 1)]) + + def test_mount_uses_horde_target_centered_approach_contract(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + hero_ai = types.SimpleNamespace(is_active=lambda: True, set_now=lambda *_args: None) + fake_bot = types.SimpleNamespace( + config=types.SimpleNamespace(upkeep=types.SimpleNamespace(hero_ai=hero_ai)) + ) + result = _finish( + mechanics._mount_attempt( + fake_bot, + { + 'name': 'Wurm Spoor', + 'target_xy': (-10867.0, 4322.0), + 'player_approach_xy': (-10867.0, 4322.0), + 'capture_id': 'wurm-spoor', + }, + ) + ) + self.assertTrue(result) + spec, _runtime = mechanics._test_interaction_controllers[0] + self.assertEqual(spec.approach.player_approach_xy, (-10867.0, 4322.0)) + self.assertEqual(spec.approach.tolerance, 120.0) + self.assertEqual(spec.approach.target_tolerance, 220.0) + self.assertEqual(spec.target.search_radius, 1_500.0) + + def test_cancelled_mount_always_clears_hero_flag(self) -> None: + mechanics, heroes = _load_mechanics_with_fakes() + + def wait_forever(*_args, **_kwargs): + while True: + yield + + mechanics.full_hero_party_in_junundu = lambda *_args: False + mechanics._wait_until = wait_forever + operation = mechanics._enter_junundu( + object(), + { + 'target_xy': (3.0, 4.0), + 'player_approach_xy': (3.0, 4.0), + }, + ) + next(operation) + operation.close() + self.assertEqual(heroes.flags, [('flag', (3.0, 4.0)), ('unflag', ())]) + + def test_required_mechanic_failure_stops_route(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + + class Bot: + stopped = False + + def Stop(self): + self.stopped = True + + def fail_leave(): + yield + return False + + bot = Bot() + mechanics._leave_junundu = fail_leave + result = _finish(mechanics._run_required_action(bot, {'type': 'leave_junundu', 'name': 'leave'})) + self.assertFalse(result) + self.assertTrue(bot.stopped) + + +class SharedPortalTransitionTests(unittest.TestCase): + @staticmethod + def _bot(): + calls: list[tuple[str, object, object]] = [] + + class Move: + @staticmethod + def FollowPath(points, step_name=''): + calls.append(('direct', list(points), step_name)) + + @staticmethod + def FollowAutoPath(points, step_name='', resume_key=None): + calls.append(('path', list(points), (step_name, resume_key))) + + @staticmethod + def FollowPathAndExitMap(points, target_map_id=0, step_name='', resume_key=None): + calls.append(('portal', list(points), (target_map_id, step_name, resume_key))) + + @staticmethod + def _coro_follow_path_and_exit_map( + points, + target_map_id=0, + step_name='', + resume_key=None, + ): + calls.append(('runtime_portal', list(points), (target_map_id, step_name, resume_key))) + yield + + class States: + @staticmethod + def AddCustomState(action, name): + calls.append(('state', action, name)) + + return types.SimpleNamespace(Move=Move, States=States), calls + + def test_portal_extension_matches_mission_runner_distance_and_heading(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + extended = mechanics.extended_portal_path([(0.0, 0.0), (300.0, 400.0)]) + self.assertEqual(extended[:2], [(0.0, 0.0), (300.0, 400.0)]) + self.assertAlmostEqual(math.dist(extended[-2], extended[-1]), 450.0) + self.assertAlmostEqual(extended[-1][0], 570.0) + self.assertAlmostEqual(extended[-1][1], 760.0) + + def test_single_point_portal_uses_live_approach_heading(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + extended = mechanics.extended_portal_path( + [(300.0, 400.0)], + approach_origin=(0.0, 0.0), + ) + self.assertEqual(extended[0], (300.0, 400.0)) + self.assertAlmostEqual(math.dist(extended[-2], extended[-1]), 450.0) + self.assertAlmostEqual(extended[-1][0], 570.0) + self.assertAlmostEqual(extended[-1][1], 760.0) + + def test_outpost_departure_resolves_single_point_heading_when_state_runs(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + bot, calls = self._bot() + mechanics.Player.GetXY = lambda: (0.0, 0.0) + + mechanics.register_outpost_departure( + bot, + [(300.0, 400.0)], + target_map_id=2, + step_name='Leave outpost', + ) + self.assertEqual(calls[0][0], 'state') + + _finish(calls[0][1]()) + self.assertEqual(calls[1][0], 'runtime_portal') + self.assertEqual(calls[1][1][0], (300.0, 400.0)) + self.assertAlmostEqual(calls[1][1][-1][0], 570.0) + self.assertAlmostEqual(calls[1][1][-1][1], 760.0) + self.assertEqual(calls[1][2][0], 2) + + def test_physical_transition_uses_extended_path_and_owns_map_load(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + bot, calls = self._bot() + owns_map_travel = mechanics.register_botting_segment( + bot, + 'Route', + 0, + {'map_id': 1, 'path': [(0.0, 0.0), (100.0, 0.0)]}, + target_map_id=2, + ) + self.assertTrue(owns_map_travel) + self.assertEqual(calls[0][0], 'portal') + self.assertEqual(calls[0][1][-1], (550.0, 0.0)) + self.assertEqual(calls[0][2][0], 2) + + def test_captured_portal_exit_replaces_straight_line_extension(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + bot, calls = self._bot() + owns_map_travel = mechanics.register_botting_segment( + bot, + 'Angled portal', + 0, + { + 'map_id': 1, + 'path': [(0.0, 0.0), (100.0, 0.0)], + 'portal_exit_xy': (150.0, 200.0), + }, + target_map_id=2, + ) + self.assertTrue(owns_map_travel) + self.assertEqual(calls[0][0], 'portal') + self.assertEqual(calls[0][1], [(0.0, 0.0), (100.0, 0.0), (150.0, 200.0)]) + self.assertEqual(calls[0][2][0], 2) + + def test_non_transition_segment_remains_an_ordinary_path(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + bot, calls = self._bot() + owns_map_travel = mechanics.register_botting_segment( + bot, + 'Route', + 0, + {'map_id': 1, 'path': [(0.0, 0.0), (100.0, 0.0)]}, + ) + self.assertFalse(owns_map_travel) + self.assertEqual(calls[0][0], 'path') + self.assertIsNone(calls[0][2][1]) + + def test_direct_path_uses_captured_points_without_autopath(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + bot, calls = self._bot() + owns_map_travel = mechanics.register_botting_segment( + bot, + 'Bridge route', + 0, + { + 'map_id': 1, + 'steps': [ + { + 'type': 'direct_path', + 'name': 'Bridge Start to Bridge End', + 'path': [(10.0, 20.0), (30.0, 40.0)], + }, + ], + }, + ) + self.assertFalse(owns_map_travel) + self.assertEqual( + calls, + [('direct', [(10.0, 20.0), (30.0, 40.0)], 'Bridge Start to Bridge End')], + ) + + def test_opt_in_resume_keys_are_stable_per_segment_step(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + bot, calls = self._bot() + owns_map_travel = mechanics.register_botting_segment( + bot, + 'Route', + 2, + { + 'map_id': 1, + 'steps': [ + {'type': 'path', 'path': [(0.0, 0.0)]}, + {'type': 'path', 'path': [(100.0, 0.0), (200.0, 0.0)]}, + ], + }, + target_map_id=2, + resume_key_prefix='outpost-fighter:4:1', + ) + self.assertTrue(owns_map_travel) + self.assertEqual(calls[0][2][1], 'outpost-fighter:4:1:segment:2:step:1') + self.assertEqual(calls[1][2][2], 'outpost-fighter:4:1:segment:2:step:2') + + def test_dialogue_transfer_does_not_extend_approach_path(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + bot, calls = self._bot() + owns_map_travel = mechanics.register_botting_segment( + bot, + 'Dialogue route', + 0, + { + 'map_id': 1, + 'steps': [ + {'type': 'path', 'path': [(0.0, 0.0), (100.0, 0.0)]}, + {'type': 'npc_dialog_sequence', 'name': 'NPC transfer'}, + ], + }, + target_map_id=2, + ) + self.assertFalse(owns_map_travel) + self.assertEqual(calls[0][0], 'path') + self.assertEqual(calls[0][1], [(0.0, 0.0), (100.0, 0.0)]) + self.assertEqual(calls[1][0], 'state') + + def test_short_or_coincident_geometry_is_not_invented(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + self.assertEqual(mechanics.extended_portal_path([(1.0, 2.0)]), [(1.0, 2.0)]) + self.assertEqual( + mechanics.extended_portal_path([(1.0, 2.0), (1.0, 2.0)]), + [(1.0, 2.0), (1.0, 2.0)], + ) + + def test_both_consumers_use_shared_extension_and_transition_ownership(self) -> None: + for path in (RUNNER, FIGHTER): + with self.subTest(path=path.name): + source = path.read_text(encoding='utf-8') + self.assertIn('extended_portal_path', source) + self.assertIn('register_outpost_departure', source) + self.assertIn('target_map_id=transition_map_id', source) + self.assertIn('not owns_map_travel', source) + + def test_both_consumers_reload_shared_mechanics_before_binding_exports(self) -> None: + for path in (RUNNER, FIGHTER): + with self.subTest(path=path.name): + source = path.read_text(encoding='utf-8') + self.assertIn('importlib.reload(_route_mechanics)', source) + self.assertIn('extended_portal_path = _route_mechanics.extended_portal_path', source) + self.assertIn('register_outpost_departure = _route_mechanics.register_outpost_departure', source) + + def test_camp_rankor_route_drops_stale_snake_dance_portal_threshold(self) -> None: + data = _route_assignments(RANKOR_DWC_ROUTE) + outpost_path = next( + value for key, value in data.items() if key.endswith('_outpost_path') + ) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + self.assertEqual( + outpost_path, + [ + (7156.76, -42409.7), + (6825.02, -42034.35), + (6466.88, -41678.51), + ], + ) + self.assertEqual(segments[0]['path'][0], (5680.65, -41273.91)) + self.assertNotIn((6163.73, -41408.86), segments[0]['path']) + + def test_routes_do_not_replay_stale_portal_thresholds(self) -> None: + stale_starts = { + ECHOVALD / '_2_SaintAnjekasShrine_To_LutgardisConservatory.py': { + (-9358.17, -21646.29), + }, + ECHOVALD / '_6_VasburgArmory_To_AmatzBasin.py': {(18505.81, 1979.94)}, + ECHOVALD / '_8_LutgardisConservatory_To_JadeFlatsKurzick.py': { + (-7676.80, 1805.39), + }, + JADE_SEA / '_4_EredonTerrace_To_BaiPaasuReach.py': {(18346.92, 11625.72)}, + JADE_SEA / '_6_SeafarersRest_To_AuriosMines.py': { + (-11134.94, -18376.52), + }, + HARVEST_ROUTE: {(3272.80, 2452.22)}, + ROUTE: {(-14622.84, 3444.11)}, + LAIR_ROUTE: {(-14614.51, 3470.76)}, + KOURNA / '_2_NunduBay_To_DajkahInlet.py': {(-15507.39, -4059.98)}, + VABBI / '_4_HonurHill_To_YahnurMarket.py': {(-18720.30, 13522.03)}, + VABBI / '_5_GrandCourtOfSebelkeh_To_DzagonurBastion.py': {(2705.86, 8103.76)}, + MAPS + / "Tyria - Beacon's Perch To Droknars Forge" + / '_1_BeaconsPerch_To_DeldrimorWarCamp.py': {(-18593, -9945)}, + MAPS + / "Tyria - Beacon's Perch To Droknars Forge" + / '_4_DeldrimorWarCamp_To_CampRankor.py': { + (-2835.64, -4288.15), + (7571.86, -2955.53), + }, + RANKOR_DWC_ROUTE: {(-18593, -9945)}, + MAPS + / "Tyria - Beacon's Perch To Droknars Forge" + / '_8_DeldrimorWarCamp_To_TheGraniteCitadel.py': {(-2908.64, -4574.38)}, + MAPS + / "Tyria - Beacon's Perch To Droknars Forge" + / '_9_TheGraniteCitadel_To_CopperhammerMines.py': {(-11514.46, 15385.34)}, + } + + for route, forbidden in stale_starts.items(): + segments = next( + value for key, value in _route_assignments(route).items() if key.endswith('_segments') + ) + starts = [] + for segment in segments: + if segment.get('path'): + starts.append(segment['path'][0]) + starts.extend( + step['path'][0] + for step in segment.get('steps', []) + if step.get('path') + ) + with self.subTest(route=route.name): + self.assertTrue(starts) + self.assertTrue(forbidden.isdisjoint(starts)) + + def test_camp_rankor_route_splices_captured_replacement_over_closed_detour(self) -> None: + data = _route_assignments(RANKOR_DWC_ROUTE) + ids = next(value for key, value in data.items() if key.endswith('_ids')) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + path = segments[0]['path'] + splice = path.index((785.8, -28165.28)) + replacement = [ + (335.99, -28006.2), + (-146.68, -27863.74), + (-657.08, -27810.81), + (-1170.03, -27827.37), + (-1667.49, -27925.54), + (-2167.71, -27998.39), + (-2667.36, -28038.94), + (-3167.11, -28071.2), + (-3669.71, -28108.58), + (-4170.24, -28139.68), + (-4673.2, -28118.27), + (-5147.58, -27937.02), + (-5453.73, -27534.42), + (-5644.42, -27063.74), + (-5775.51, -26579.78), + ] + self.assertEqual(ids['replacement_route_id'], 'ROUTE-20260911-202732-875') + self.assertEqual(path[splice + 1:splice + 16], replacement) + self.assertEqual(path[splice + 16], (-5757.33, -26449.17)) + for removed in ( + (452.79, -27789.46), + (-411.82, -26334.12), + (-631.29, -26066.41), + (-3932.8, -28138.77), + (-5278.18, -27810.96), + (-5724.72, -26952.08), + ): + self.assertNotIn(removed, path) + + def test_saint_anjeka_exit_has_stable_heading_and_avoids_ferndale_npcs(self) -> None: + route = ECHOVALD / '_2_SaintAnjekasShrine_To_LutgardisConservatory.py' + data = _route_assignments(route) + ids = next(value for key, value in data.items() if key.endswith('_ids')) + outpost_path = next( + value for key, value in data.items() if key.endswith('_outpost_path') + ) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + ferndale_path = segments[0]['path'] + replacement = [ + (-8901.0, -21640.4), + (-8501.25, -21333.81), + (-8105.0, -21024.71), + (-7712.53, -20708.52), + (-7319.51, -20390.7), + ] + + self.assertEqual( + ids['start_replacement_route_id'], + 'ROUTE-20260912-163352-904', + ) + self.assertEqual( + outpost_path, + [(-9785.0, -21673.0), (-9358.17, -21646.29)], + ) + self.assertEqual(ferndale_path[:5], replacement) + self.assertEqual(ferndale_path[5], (-7037.39, -20465.64)) + for blocked_start in ( + (-8177.0, -21440.0), + (-7863.2, -21042.6), + (-7458.76, -20742.54), + ): + self.assertNotIn(blocked_start, ferndale_path) + + def test_elona_reach_route_uses_smoothed_seekers_passage_capture(self) -> None: + data = _route_assignments(ELONA_SEEKERS_ROUTE) + ids = next(value for key, value in data.items() if key.endswith('_ids')) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + path = segments[1]['path'] + splice = path.index((-5938, 15359)) + replacement = [ + (-7794.48, 14427.95), + (-8212.75, 14139.39), + (-8635.64, 13863.79), + (-9017.77, 13534.27), + (-9352.86, 13162.49), + (-9667.11, 12768.51), + (-9961.17, 12363.46), + (-10255.68, 11957.78), + (-10551.14, 11550.8), + (-10884.24, 11089.74), + (-11190.28, 10687.65), + (-11500.66, 10293.86), + (-11734.9, 9851.13), + (-11909.36, 9376.21), + (-12087.99, 8908.21), + (-12260.87, 8433.27), + (-12430.99, 7958.46), + (-12544.54, 7809.47), + (-12797.41, 7375.08), + (-13063.87, 6945.42), + (-13415.51, 6585.91), + (-13875.13, 6388.07), + (-14371.68, 6318.42), + (-14851.53, 6472.3), + (-15295.84, 6706.54), + (-15739.69, 6943.62), + (-16040.12, 7350.31), + (-16318.77, 7771.75), + ] + self.assertEqual(ids['replacement_route_id'], 'ROUTE-20260911-210807-345') + self.assertEqual(path[splice + 1:], replacement) + for combat_noise in ( + (-12089.34, 8798.29), + (-12480.36, 8331.35), + (-12600.93, 8821.92), + (-12350.28, 8273.44), + ): + self.assertNotIn(combat_noise, path) + + def test_fighter_does_not_suppress_unmanaged_transition_failures(self) -> None: + source = FIGHTER.read_text(encoding='utf-8') + self.assertNotIn('set_on_unmanaged_fail(lambda: False)', source) + + +class BonePalaceLairOfTheForgottenRouteTests(unittest.TestCase): + def test_route_preserves_both_capture_sources_and_destination(self) -> None: + data = _route_assignments(LAIR_ROUTE) + ids = next(value for key, value in data.items() if key.endswith('_ids')) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + self.assertEqual(ids['source_route_id'], 'ROUTE-20260910-151459-766') + self.assertEqual(ids['prefix_source_route_id'], 'ROUTE-20260905-170837-570') + self.assertEqual(len(segments), 3) + self.assertEqual(segments[-1]['steps'], []) + + def test_route_reuses_blessing_and_full_party_wurm_boundaries(self) -> None: + segments = next( + value for key, value in _route_assignments(LAIR_ROUTE).items() if key.endswith('_segments') + ) + self.assertEqual( + [step['type'] for step in segments[0]['steps']], + ['path', 'blessing', 'path', 'enter_junundu', 'path'], + ) + self.assertEqual( + [step['type'] for step in segments[1]['steps']], + ['path', 'blessing', 'path', 'enter_junundu', 'path'], + ) + shattered_wurm = segments[1]['steps'][3] + self.assertEqual(shattered_wurm['target_xy'], (-3838.0, -8831.0)) + self.assertEqual(shattered_wurm['party_regroup_xy'], (-3838.0, -8831.0)) + self.assertEqual(shattered_wurm['party_regroup_radius'], 300.0) + self.assertEqual(shattered_wurm['player_approach_xy'], (-3838.0, -8831.0)) + self.assertEqual( + shattered_wurm['capture_id'], + 'user-screenshot-shattered-ravines-wurm-spoor-20260909', + ) + + def test_all_desolation_wurm_steps_regroup_at_the_spoor(self) -> None: + for route in (ROUTE, LAIR_ROUTE): + segments = next( + value for key, value in _route_assignments(route).items() if key.endswith('_segments') + ) + steps = [ + step + for segment in segments + for step in segment.get('steps', []) + if step.get('type') == 'enter_junundu' + ] + self.assertTrue(steps) + for step in steps: + with self.subTest(route=route.name, step=step['name']): + self.assertEqual(step['player_approach_xy'], step['target_xy']) + self.assertEqual(step['party_regroup_xy'], step['target_xy']) + self.assertEqual(step['party_regroup_radius'], 300.0) + self.assertNotIn('regroup_xy', step) + + def test_wrong_turn_and_recorded_backtrack_are_removed_at_rejoin(self) -> None: + segments = next( + value for key, value in _route_assignments(LAIR_ROUTE).items() if key.endswith('_segments') + ) + path = segments[1]['steps'][4]['path'] + rejoin_index = path.index((-4087.42, 5629.35)) + self.assertEqual(path[rejoin_index + 1], (-3763.87, 5700.3)) + self.assertEqual(path[rejoin_index + 2], (-3284.36, 5552.99)) + for removed in ( + (-3862.15, 6076.34), + (-2981.65, 7882.73), + (-1580.13, 7493.71), + (-3972.13, 6155.34), + ): + self.assertNotIn(removed, path) + + +class CanthaCapturedRouteTests(unittest.TestCase): + def test_all_requested_routes_preserve_capture_identity_and_finish_at_an_outpost(self) -> None: + self.assertEqual(len(CANHTA_ROUTE_EXPECTATIONS), 16) + for route, capture_id in CANHTA_ROUTE_EXPECTATIONS.items(): + with self.subTest(route=route.name): + data = _route_assignments(route) + ids = next(value for key, value in data.items() if key.endswith('_ids')) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + self.assertEqual(ids['source_route_id'], capture_id) + self.assertTrue(segments) + self.assertEqual(segments[-1].get('path', segments[-1].get('steps')), []) + source = route.read_text(encoding='utf-8') + self.assertNotIn('agent_id_runtime_only', source) + self.assertNotIn('captured_at', source) + + def test_no_stale_cross_map_coordinate_remains_inside_a_route_path(self) -> None: + for route in CANHTA_ROUTE_EXPECTATIONS: + segments = next( + value for key, value in _route_assignments(route).items() if key.endswith('_segments') + ) + for segment_index, segment in enumerate(segments[:-1]): + paths = [segment.get('path', [])] + paths.extend(step.get('path', []) for step in segment.get('steps', [])) + for path in paths: + for left, right in zip(path, path[1:]): + with self.subTest(route=route.name, segment=segment_index, left=left, right=right): + self.assertLessEqual(math.dist(left, right), 2_000.0) + + def test_gyala_resurrection_altar_is_stitched_out(self) -> None: + route = JADE_SEA / '_3_GyalaHatchery_To_EredonTerrace.py' + segments = next(value for key, value in _route_assignments(route).items() if key.endswith('_segments')) + path = segments[0]['path'] + stitch = path.index((19793.28, 9872.88)) + self.assertEqual(path[stitch + 1], (18892.01, 10907.63)) + self.assertNotIn((19189.0, 11311.0), path) + + def test_ferndale_bridge_has_only_named_start_and_end(self) -> None: + route = ECHOVALD / '_1_HouseZuHeltzer_To_SaintAnjekasShrine.py' + segments = next(value for key, value in _route_assignments(route).items() if key.endswith('_segments')) + path = segments[0]['path'] + start = path.index((-9305.57, 8852.48)) + self.assertEqual(path[start + 1], (-7429.13, 8685.64)) + for bridge_middle in ((-8807.14, 8797.72), (-8302.41, 8763.81), (-7800.25, 8719.11)): + self.assertNotIn(bridge_middle, path) + + def test_eternal_grove_route_stitches_death_and_crosses_bridge_directly(self) -> None: + data = _route_assignments(ETERNAL_GROVE_ROUTE) + outpost_path = next( + value for key, value in data.items() if key.endswith('_outpost_path') + ) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + steps = segments[0]['steps'] + + self.assertEqual(outpost_path[-2:], [(19173.32, 2631.56), (18817.39, 2273.60)]) + self.assertEqual([step['type'] for step in steps], ['path', 'direct_path', 'path']) + self.assertEqual( + steps[1]['path'], + [(1352.85, 11706.08), (-11.16, 10773.75)], + ) + self.assertEqual(steps[2]['path'][0], (-14.30, 9929.33)) + self.assertEqual(steps[2]['path'][-1], (-5414.19, 9461.30)) + self.assertEqual(segments[-1]['steps'], []) + + retained = [point for step in steps for point in step.get('path', [])] + self.assertNotIn((18568.48, 2017.34), retained) + self.assertNotIn((14692.08, 1762.97), retained) + self.assertNotIn((14551.83, 1282.04), retained) + self.assertNotIn((374.68, 10965.19), retained) + + def test_eternal_grove_route_uses_shared_450_unit_destination_portal(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + segments = next( + value + for key, value in _route_assignments(ETERNAL_GROVE_ROUTE).items() + if key.endswith('_segments') + ) + bot, calls = SharedPortalTransitionTests._bot() + + owns_map_travel = mechanics.register_botting_segment( + bot, + 'Vasburg Armory to The Eternal Grove', + 0, + segments[0], + target_map_id=222, + resume_key_prefix='outpost-fighter:route:9', + ) + + self.assertTrue(owns_map_travel) + self.assertEqual([call[0] for call in calls], ['path', 'direct', 'portal']) + portal_call = calls[-1] + self.assertEqual(portal_call[2][0], 222) + self.assertEqual(portal_call[1][-2], (-5414.19, 9461.30)) + self.assertAlmostEqual(math.dist(portal_call[1][-2], portal_call[1][-1]), 450.0) + + def test_aspenwood_route_omits_stale_cross_map_entry_coordinate(self) -> None: + route = ECHOVALD / '_7_HouseZuHeltzer_To_AspenwoodGateKurzick.py' + segments = next(value for key, value in _route_assignments(route).items() if key.endswith('_segments')) + self.assertEqual(segments[0]['path'][0], (-13245.00, 14888.00)) + self.assertNotIn((10338.95, -1119.16), segments[0]['path']) + self.assertEqual(segments[-1]['path'], []) + + def test_cavalon_route_reuses_the_proven_outpost_exit(self) -> None: + route = JADE_SEA / '_8_Cavalon_To_JadeFlatsLuxon.py' + outpost_path = next( + value for key, value in _route_assignments(route).items() if key.endswith('_outpost_path') + ) + self.assertEqual(outpost_path[0], (5872.52, 742.1)) + self.assertEqual(outpost_path[-1], (6650.0, -7550.0)) + + def test_dedrick_route_uses_shared_label_sequence_and_map_postcondition(self) -> None: + segments = next( + value for key, value in _route_assignments(HARVEST_ROUTE).items() if key.endswith('_segments') + ) + self.assertEqual([step['type'] for step in segments[0]['steps']], ['path', 'npc_dialog_sequence']) + dialog = segments[0]['steps'][1] + self.assertEqual(dialog['model_id'], 3435) + self.assertEqual(dialog['target_xy'], (-10449.0, -55.0)) + self.assertEqual( + dialog['visible_button_labels'], + ('We have business in Unwaking Waters.', 'We are ready.', 'Yes.'), + ) + self.assertEqual( + dialog['capture_ids'], + ('NPC-20260910-180722-960', 'NPC-20260910-180800-857', 'ROUTE-20260910-180114-736'), + ) + path = segments[0]['steps'][0]['path'] + stitch = path.index((-3506.39, 5543.48)) + self.assertEqual(path[stitch + 1], (-3817.59, 5024.07)) + + def test_dialog_adapter_delegates_to_shared_controller(self) -> None: + mechanics, _heroes = _load_mechanics_with_fakes() + mechanics._RouteInteractionRuntime = lambda *_args, **_kwargs: object() + result = _finish( + mechanics._npc_dialog_sequence( + object(), + { + 'name': 'Dedrick', + 'model_id': 3435, + 'target_name': 'Gatekeeper Dedrick', + 'target_xy': (-10449.0, -55.0), + 'player_approach_xy': (-10429.37, 21.51), + 'visible_button_labels': ('First', 'Second', 'Third'), + 'target_map_id': 298, + 'capture_ids': ('one', 'three', 'route'), + }, + ) + ) + self.assertTrue(result) + spec, _runtime = mechanics._test_interaction_controllers[0] + self.assertEqual(spec.dialog.visible_button_labels, ('First', 'Second', 'Third')) + self.assertEqual(spec.postcondition.args[1], 298) + self.assertEqual(spec.source_capture_ids, ('one', 'three', 'route')) + + def test_tsumei_uses_captured_point_across_angled_portal(self) -> None: + segments = next( + value for key, value in _route_assignments(TSUMEI_ROUTE).items() if key.endswith('_segments') + ) + sunqua = segments[0] + self.assertEqual(sunqua['portal_exit_xy'], (-4717.07, -13301.82)) + self.assertEqual(sunqua['path'][-2:], [(-3908.34, -12788.56), (-4392.07, -12929.78)]) + self.assertEqual(segments[-1]['path'], []) + + +class NightfallCapturedRouteTests(unittest.TestCase): + def test_all_requested_routes_preserve_capture_identity_and_destination(self) -> None: + self.assertEqual(len(NIGHTFALL_ROUTE_EXPECTATIONS), 8) + for route, capture_id in NIGHTFALL_ROUTE_EXPECTATIONS.items(): + with self.subTest(route=route.name): + data = _route_assignments(route) + ids = next(value for key, value in data.items() if key.endswith('_ids')) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + self.assertEqual(ids['source_route_id'], capture_id) + self.assertTrue(segments) + self.assertEqual(segments[-1]['path'], []) + + def test_routes_use_canonical_py4gw_map_names(self) -> None: + expected_lookups = { + VABBI / '_3_honurhill_to_dashavestibulepostcampaign.py': ( + 'outpost_name_to_id["Honur Hill"]', + 'explorable_name_to_id["The Mirror of Lyss"]', + 'outpost_name_to_id["Dasha Vestibule outpost"]', + ), + VABBI / '_4_HonurHill_To_YahnurMarket.py': ( + 'outpost_name_to_id["Honur Hill"]', + 'explorable_name_to_id["Resplendent Makuun"]', + 'outpost_name_to_id["Yahnur Market"]', + ), + VABBI / '_5_GrandCourtOfSebelkeh_To_DzagonurBastion.py': ( + 'outpost_name_to_id["Grand Court of Sebelkeh outpost"]', + 'explorable_name_to_id["The Mirror of Lyss"]', + 'outpost_name_to_id["Dzagonur Bastion outpost"]', + ), + TORMENT / '_1_gateoftorment_to_gateofthenightfallenlands.py': ( + 'outpost_name_to_id["Gate of Torment"]', + 'explorable_name_to_id["Nightfallen Jahai"]', + 'outpost_name_to_id["Gate of the Nightfallen Lands"]', + ), + TORMENT / '_2_gateoftorment_to_theshadownexus.py': ( + 'outpost_name_to_id["Gate of Torment"]', + 'outpost_name_to_id["The Shadow Nexus outpost"]', + ), + KOURNA / '_1_pogahnpassage_to_camphojanu.py': ( + 'outpost_name_to_id["Pogahn Passage outpost"]', + 'explorable_name_to_id["Dejarin Estate"]', + 'outpost_name_to_id["Camp Hojanu"]', + ), + KOURNA / '_2_NunduBay_To_DajkahInlet.py': ( + 'outpost_name_to_id["Nundu Bay outpost"]', + 'explorable_name_to_id["Marga Coast"]', + 'outpost_name_to_id["Dajkah Inlet outpost"]', + ), + KOURNA / '_3_KodonurCrossroads_To_RilohnRefuge.py': ( + 'outpost_name_to_id["Kodonur Crossroads outpost"]', + 'explorable_name_to_id["The Floodplain of Mahnkelon"]', + 'outpost_name_to_id["Rilohn Refuge outpost"]', + ), + } + for route, lookups in expected_lookups.items(): + source = route.read_text(encoding='utf-8') + for lookup in lookups: + with self.subTest(route=route.name, lookup=lookup): + self.assertIn(lookup, source) + + def test_no_cross_map_coordinate_jump_remains_in_a_route_path(self) -> None: + for route in NIGHTFALL_ROUTE_EXPECTATIONS: + data = _route_assignments(route) + outpost_path = next(value for key, value in data.items() if key.endswith('_outpost_path')) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + for path in [outpost_path, *(segment['path'] for segment in segments)]: + for left, right in zip(path, path[1:]): + with self.subTest(route=route.name, left=left, right=right): + self.assertLessEqual(math.dist(left, right), 2_000.0) + + def test_post_campaign_dasha_route_is_a_physical_portal_route(self) -> None: + route = VABBI / '_3_honurhill_to_dashavestibulepostcampaign.py' + data = _route_assignments(route) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + self.assertEqual(len(segments), 2) + self.assertTrue(segments[0]['path']) + self.assertNotIn('npc_dialog_sequence', route.read_text(encoding='utf-8')) + + def test_shadow_nexus_capture_contains_no_retained_closed_backtrack(self) -> None: + route = TORMENT / '_2_gateoftorment_to_theshadownexus.py' + outpost_path = next( + value for key, value in _route_assignments(route).items() if key.endswith('_outpost_path') + ) + for left_index, left in enumerate(outpost_path): + for right in outpost_path[left_index + 4:]: + self.assertGreaterEqual(math.dist(left, right), 650.0) + + def test_pogahn_closed_meander_is_spliced_out(self) -> None: + route = KOURNA / '_1_pogahnpassage_to_camphojanu.py' + segments = next(value for key, value in _route_assignments(route).items() if key.endswith('_segments')) + path = segments[0]['path'] + splice = path.index((-3255.90, -390.39)) + self.assertEqual(path[splice + 1], (-3252.19, -302.91)) + for removed in ( + (-3699.21, -150.45), + (-5389.77, -404.01), + (-6081.45, -1593.53), + (-3922.28, 7.07), + ): + self.assertNotIn(removed, path) + + def test_kodonur_route_smooths_combat_reversals_and_crosses_rilohn_portal(self) -> None: + route = KOURNA / '_3_KodonurCrossroads_To_RilohnRefuge.py' + data = _route_assignments(route) + segments = next(value for key, value in data.items() if key.endswith('_segments')) + floodplain = segments[0] + path = floodplain['path'] + self.assertEqual(floodplain['portal_exit_xy'], (-15393.56, 9062.19)) + self.assertEqual(path[-2:], [(-15288.94, 8208.61), (-15322.8, 8710.21)]) + self.assertNotIn((4639.08, 4416.47), path) + for combat_reversal in ( + (-18400.81, 3746.5), + (-17991.56, 3450.08), + (-18253.1, 3725.18), + ): + self.assertNotIn(combat_reversal, path) + + def test_grand_court_reversal_is_spliced_out(self) -> None: + route = VABBI / '_5_GrandCourtOfSebelkeh_To_DzagonurBastion.py' + segments = next(value for key, value in _route_assignments(route).items() if key.endswith('_segments')) + path = segments[0]['path'] + stitch = path.index((12085.30, 1083.32)) + self.assertEqual(path[stitch + 1], (12395.58, 1748.49)) + self.assertNotIn((12490.26, 1384.33), path) + self.assertNotIn((11991.52, 1450.88), path) + + +if __name__ == '__main__': + unittest.main() diff --git a/Examples and tests/tests/reliable_interaction/test_reliable_interaction.py b/Examples and tests/tests/reliable_interaction/test_reliable_interaction.py new file mode 100644 index 000000000..eb5742f78 --- /dev/null +++ b/Examples and tests/tests/reliable_interaction/test_reliable_interaction.py @@ -0,0 +1,1474 @@ +from __future__ import annotations + +import importlib.util +import math +import pathlib +import sys +import types +import unittest +from unittest.mock import patch + + +MODULE_PATH = ( + pathlib.Path(__file__).parents[3] + / "Py4GWCoreLib" + / "routines_src" + / "reliable_interaction.py" +) +SPEC = importlib.util.spec_from_file_location("reliable_interaction_under_test", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +ri = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = ri +SPEC.loader.exec_module(ri) + + +class FakeRuntime: + def __init__(self, behavior: str): + self.behavior = behavior + self.clock = 0.0 + self.player = (0.0, 0.0) + self.agent = 10 + self.agent_position = (100.0, 0.0) + self.visible = False + self.buttons = 0 + self.button_labels = () + self.satisfied = False + self.hostile = False + self.pause_count = 0 + self.restore_count = 0 + self.resolve_count = 0 + self.interact_count = 0 + self.click_count = 0 + self.sent_dialog_ids = [] + self.close_count = 0 + self.events = [] + self.popup_at = None + self.clicked_choices = [] + self.scroll_count = 0 + self.scroll_deltas = [] + + def now(self): + return self.clock + + def advance(self, seconds=0.11): + self.clock += seconds + + def pause_automation(self): + self.pause_count += 1 + + def restore_automation(self): + self.restore_count += 1 + + def cancel_movement(self): + return + + def close_stale_dialog(self): + self.visible = False + self.buttons = 0 + self.button_labels = () + self.close_count += 1 + + def resolve_target(self, _target): + self.resolve_count += 1 + return self.agent + + def agent_xy(self, _agent_id): + return self.agent_position + + def agent_model_id(self, _agent_id): + return 42 + + def player_xy(self): + return self.player + + def move_to(self, xy): + self.player = xy + if self.behavior == "walk_over": + self.satisfied = True + + def interact(self, _agent_id): + self.interact_count += 1 + if self.behavior in ( + "raw", + "visible", + "reward", + "challenge", + "delayed_visible", + "delayed_popup", + "two_page_visible", + "two_page_label_visible", + "two_page_scroll_visible", + "visible_then_raw", + ): + self.visible = True + self.buttons = 0 if self.behavior in ("raw", "delayed_visible") else 2 + if self.behavior == "two_page_label_visible": + self.button_labels = ("Captured", "Naga Oil", "Battle in the Sewers") + self.buttons = len(self.button_labels) + elif self.behavior == "two_page_scroll_visible": + self.button_labels = ("Stolen Eggs", "Fort Aspenwood", "The Jade Quarry") + self.buttons = len(self.button_labels) + if self.behavior in ("automatic", "gadget"): + self.satisfied = True + + def dialog_visible(self): + if self.popup_at is not None and self.clock >= self.popup_at: + self.visible = True + self.popup_at = None + return self.visible + + def dialog_button_count(self): + return self.buttons + + def dialog_button_labels(self): + return self.button_labels + + def scroll_dialog(self, delta): + self.scroll_count += 1 + self.scroll_deltas.append(delta) + if self.behavior == "two_page_scroll_visible": + self.buttons = 2 + return True + + def click_dialog_button(self, _choice): + self.click_count += 1 + self.clicked_choices.append(_choice) + if self.behavior == "two_page_label_visible" and self.click_count == 1: + self.button_labels = ( + "Just doing my duty to the emperor.", + "On second thought, it does sound foolhardy.", + ) + self.buttons = len(self.button_labels) + elif self.behavior == "two_page_label_visible" and self.click_count == 2: + self.satisfied = True + elif self.behavior == "two_page_scroll_visible" and self.click_count == 1: + self.button_labels = ( + "Hang on, little ones! I'll save you!", + "Have you ever tried pickled turtle eggs?", + ) + # The logical response exists, but the native button frame is + # below the viewport until the dialogue page is scrolled. + self.buttons = 0 + elif self.behavior == "two_page_scroll_visible" and self.click_count == 2: + self.satisfied = True + elif self.behavior == "two_page_visible" and self.click_count == 1: + # Page one is replaced by another populated page under the same + # visible dialog frame. The postcondition is not yet satisfied. + self.buttons = 2 + elif self.behavior == "two_page_visible" and self.click_count == 2: + self.satisfied = True + elif self.behavior == "visible_then_raw": + # The visible choice opens a buttonless conclusion page whose + # Continue response is exposed only by its raw context id. + self.visible = True + self.buttons = 0 + elif self.behavior == "challenge" and self.click_count == 1: + self.hostile = True + elif self.behavior == "delayed_popup": + self.satisfied = True + self.visible = False + self.buttons = 0 + self.popup_at = self.clock + 0.3 + else: + self.satisfied = True + return True + + def send_dialog(self, _dialog_id): + self.sent_dialog_ids.append(int(_dialog_id)) + self.satisfied = True + + def verify(self, _postcondition): + return self.satisfied + + def is_hostile(self, _agent_id): + return self.hostile + + def emit(self, event): + self.events.append(event) + + +def make_spec(profile, *, dialog=None, attempts=2): + return ri.InteractionSpec( + name=f"test {profile.value}", + profile=profile, + target=ri.TargetSpec( + kind=ri.TargetKind.LOCATION if profile == ri.InteractionProfile.WALK_OVER else ri.TargetKind.LIVING, + model_id=42, + expected_xy=(100.0, 0.0), + ), + approach=ri.ApproachSpec(player_approach_xy=(95.0, 0.0), tolerance=5.0), + dialog=dialog or ri.DialogSpec(), + postcondition=ri.PostconditionSpec(ri.PostconditionKind.CUSTOM, description="fake"), + retry=ri.RetryPolicy(max_attempts=attempts, pause_settle_ms=0, retry_delay_ms=0), + ) + + +def run(controller, runtime, limit=100): + for _ in range(limit): + status = controller.tick() + if status != ri.InteractionStatus.RUNNING: + return status + runtime.advance() + raise AssertionError(f"controller did not finish; stage={controller.stage}") + + +class FakeRouteExecutor: + def __init__(self, runtime, *, terminal=ri.InteractionStatus.SUCCESS, custom_exit_false=False): + self.runtime = runtime + self.terminal = terminal + self.custom_exit_false = custom_exit_false + self.points = () + self.tolerance = 0.0 + self.handoff_ready = None + self.start_count = 0 + self.tick_count = 0 + self.cancel_count = 0 + + def start(self, route_points, tolerance, handoff_ready): + self.points = route_points + self.tolerance = tolerance + self.handoff_ready = handoff_ready + self.start_count += 1 + + def tick(self): + self.tick_count += 1 + if self.terminal == ri.InteractionStatus.FAILURE: + return self.terminal + self.runtime.player = self.points[-1] + if self.custom_exit_false and self.handoff_ready and self.handoff_ready(): + return ri.InteractionStatus.FAILURE + return self.terminal + + def cancel(self): + self.cancel_count += 1 + + +def make_boundary(*, route_endpoint=(-100.0, 0.0), interaction=None): + return ri.InteractionBoundarySpec( + name="safe route boundary", + route_points=((-300.0, 0.0), route_endpoint), + route_standoff_xy=route_endpoint, + route_tolerance=30.0, + handoff_radius=75.0, + interaction=interaction or make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER), + ) + + +class ReliableInteractionTests(unittest.TestCase): + def test_controller_publishes_and_clears_interaction_ownership(self): + refreshed = [] + cleared = [] + coordination = types.SimpleNamespace( + refresh_local_reliable_interaction=lambda token, **fields: refreshed.append( + (token, fields) + ), + clear_local_reliable_interaction=lambda token: cleared.append(token), + ) + runtime = FakeRuntime("automatic") + controller = ri.ReliableInteractionController( + make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER), + runtime, + ) + + with patch.dict( + sys.modules, + {"Py4GWCoreLib.routines_src.shared_command_coordination": coordination}, + ): + self.assertEqual(run(controller, runtime), ri.InteractionStatus.SUCCESS) + + self.assertGreater(len(refreshed), 1) + tokens = {id(token) for token, _fields in refreshed} + self.assertEqual(len(tokens), 1) + self.assertEqual(refreshed[0][1]["name"], "test automatic_npc_or_beacon_trigger") + self.assertEqual(cleared, [refreshed[0][0]]) + + def test_controller_cancel_and_runtime_exception_clear_ownership(self): + active = set() + coordination = types.SimpleNamespace( + refresh_local_reliable_interaction=lambda token, **_fields: active.add(token), + clear_local_reliable_interaction=lambda token: active.discard(token), + ) + + with patch.dict( + sys.modules, + {"Py4GWCoreLib.routines_src.shared_command_coordination": coordination}, + ): + runtime = FakeRuntime("automatic") + cancelled = ri.ReliableInteractionController( + make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER), + runtime, + ) + cancelled.tick() + self.assertTrue(active) + cancelled.cancel("user stop") + self.assertFalse(active) + + broken_runtime = FakeRuntime("automatic") + broken_runtime.verify = lambda _postcondition: (_ for _ in ()).throw( + RuntimeError("boom") + ) + broken = ri.ReliableInteractionController( + make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER), + broken_runtime, + ) + self.assertEqual(broken.tick(), ri.InteractionStatus.FAILURE) + self.assertFalse(active) + + def test_coroutine_adapter_defers_initial_interaction_during_auxiliary_owner(self): + runtime = FakeRuntime("automatic") + controller = ri.ReliableInteractionController( + make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER), + runtime, + ) + waits = [] + + def wait(milliseconds): + waits.append(milliseconds) + yield None + + adapter = ri.run_coroutine_adapter(controller, wait) + with patch.object(ri, "_local_route_auxiliary_active", side_effect=[True, False]): + next(adapter) + self.assertEqual(controller.stage, "new") + self.assertEqual(runtime.pause_count, 0) + next(adapter) + + self.assertEqual(controller.stage, "pause_settle") + self.assertEqual(runtime.pause_count, 1) + self.assertEqual(len(waits), 2) + adapter.close() + + def test_boundary_adapter_cannot_handoff_during_auxiliary_owner(self): + runtime = FakeRuntime("automatic") + runtime.player = (-500.0, 0.0) + route = FakeRouteExecutor(runtime) + controller = ri.InteractionBoundaryController(make_boundary(), runtime, route) + waits = [] + + def wait(milliseconds): + waits.append(milliseconds) + yield None + + adapter = ri.run_boundary_coroutine_adapter(controller, wait) + with patch.object(ri, "_local_route_auxiliary_active", side_effect=[True, False]): + next(adapter) + self.assertEqual(controller.stage, "new") + self.assertEqual(route.start_count, 0) + self.assertEqual(runtime.pause_count, 0) + next(adapter) + + self.assertEqual(route.start_count, 1) + self.assertEqual(controller.stage, "interaction") + self.assertEqual(len(waits), 2) + adapter.close() + + def test_botting_tree_adapter_defers_initial_interaction_during_auxiliary_owner(self): + class NodeState: + RUNNING = "running" + SUCCESS = "success" + FAILURE = "failure" + + class ActionNode: + def __init__(self, name, action_fn, aftercast_ms=0): + self.name = name + self.action_fn = action_fn + + def reset(self): + return None + + class FakeBehaviorTree: + def __init__(self, root): + self.root = root + + def tick(self): + return self.root.action_fn() + + def reset(self): + self.root.reset() + + FakeBehaviorTree.NodeState = NodeState + FakeBehaviorTree.ActionNode = ActionNode + + runtime = FakeRuntime("automatic") + behavior_module = types.SimpleNamespace(BehaviorTree=FakeBehaviorTree) + with patch.dict( + sys.modules, + {"Py4GWCoreLib.py4gwcorelib_src.BehaviorTree": behavior_module}, + ): + tree = ri.build_botting_tree_action( + make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER), + runtime, + ) + with patch.object(ri, "_local_route_auxiliary_active", side_effect=[True, False]): + self.assertEqual(tree.tick(), NodeState.RUNNING) + self.assertEqual(runtime.pause_count, 0) + self.assertEqual(tree.tick(), NodeState.RUNNING) + + self.assertEqual(runtime.pause_count, 1) + tree.reset() + + def test_mamp_geometry_rejects_the_old_route_to_final_approach(self): + mamp = ri.InteractionSpec( + name="Start G.O.L.E.M. at Mamp", + profile=ri.InteractionProfile.VISIBLE_CHOICE, + target=ri.TargetSpec( + kind=ri.TargetKind.NPC, + model_id=6766, + expected_xy=(16051.0, 15183.0), + search_radius=1200.0, + ), + approach=ri.ApproachSpec(player_approach_xy=(16082.28, 15253.92)), + dialog=ri.DialogSpec(visible_buttons=(1, 1)), + postcondition=ri.PostconditionSpec(ri.PostconditionKind.MAP_CHANGED), + ) + boundary = ri.InteractionBoundarySpec( + name="G.O.L.E.M. route-to-Mamp", + route_points=((15000.0, 15000.0), (16082.28, 15253.92)), + route_standoff_xy=(16082.28, 15253.92), + interaction=mamp, + ) + with self.assertRaisesRegex( + ri.InteractionBoundaryValidationError, + "reuses the final player approach coordinate", + ): + boundary.validate() + + def test_mamp_geometry_accepts_a_distinct_resolvable_standoff(self): + mamp = ri.InteractionSpec( + name="Start G.O.L.E.M. at Mamp", + profile=ri.InteractionProfile.VISIBLE_CHOICE, + target=ri.TargetSpec( + kind=ri.TargetKind.NPC, + model_id=6766, + expected_xy=(16051.0, 15183.0), + search_radius=1200.0, + ), + approach=ri.ApproachSpec(player_approach_xy=(16082.28, 15253.92)), + dialog=ri.DialogSpec(visible_buttons=(1, 1)), + postcondition=ri.PostconditionSpec(ri.PostconditionKind.MAP_CHANGED), + ) + # This endpoint remains well clear of both Mamp and the model-51 NPC + # observed at (16043.93, 15260.60), while Mamp remains resolvable. + boundary = ri.InteractionBoundarySpec( + name="G.O.L.E.M. route-to-Mamp", + route_points=((15000.0, 15000.0), (15750.0, 15250.0)), + route_standoff_xy=(15750.0, 15250.0), + interaction=mamp, + ) + boundary.validate() + + def test_route_endpoint_equal_to_target_is_rejected_and_logged(self): + runtime = FakeRuntime("automatic") + spec = make_boundary(route_endpoint=(100.0, 0.0)) + route = FakeRouteExecutor(runtime) + with self.assertRaisesRegex( + ri.InteractionBoundaryValidationError, + "reuses the live target coordinate", + ): + ri.InteractionBoundaryController(spec, runtime, route) + failures = [ + event for event in runtime.events + if event["event"] == "boundary_construction_validation_failed" + ] + self.assertEqual(len(failures), 1) + + def test_valid_standoff_hands_off_and_interacts_successfully(self): + runtime = FakeRuntime("automatic") + runtime.player = (-500.0, 0.0) + route = FakeRouteExecutor(runtime) + controller = ri.InteractionBoundaryController(make_boundary(), runtime, route) + self.assertEqual(run(controller, runtime, limit=200), ri.InteractionStatus.SUCCESS) + self.assertEqual(runtime.interact_count, 1) + event_names = [event["event"] for event in runtime.events] + for event_name in ( + "route_boundary_entered", + "target_resolvable_from_standoff", + "handoff_to_interaction_controller", + "postcondition_verified", + ): + self.assertIn(event_name, event_names) + self.assertLess( + event_names.index("handoff_to_interaction_controller"), + event_names.index("interaction_sent"), + ) + + def test_custom_exit_false_at_safe_boundary_is_not_treated_as_route_failure(self): + runtime = FakeRuntime("automatic") + runtime.player = (-500.0, 0.0) + route = FakeRouteExecutor(runtime, custom_exit_false=True) + controller = ri.InteractionBoundaryController(make_boundary(), runtime, route) + self.assertEqual(run(controller, runtime, limit=200), ri.InteractionStatus.SUCCESS) + self.assertEqual(route.tick_count, 1) + self.assertEqual(runtime.interact_count, 1) + + def test_mamp_collision_proximity_cannot_trap_the_safe_route(self): + runtime = FakeRuntime("automatic") + runtime.player = (15000.0, 15000.0) + runtime.agent_position = (16051.0, 15183.0) + interaction = ri.InteractionSpec( + name="generic collision-sensitive NPC interaction", + profile=ri.InteractionProfile.AUTOMATIC_TRIGGER, + target=ri.TargetSpec( + kind=ri.TargetKind.NPC, + model_id=42, + expected_xy=(16051.0, 15183.0), + search_radius=1200.0, + ), + approach=ri.ApproachSpec( + player_approach_xy=(16082.28, 15253.92), + tolerance=90.0, + target_tolerance=100.0, + ), + postcondition=ri.PostconditionSpec(ri.PostconditionKind.CUSTOM), + retry=ri.RetryPolicy(max_attempts=1, pause_settle_ms=0), + ) + + class CollisionAwareRoute(FakeRouteExecutor): + def tick(self): + self.tick_count += 1 + blocker_xy = (16043.93, 15260.60) + if math.dist(self.points[-1], blocker_xy) <= 50.0: + return ri.InteractionStatus.RUNNING + self.runtime.player = self.points[-1] + return ri.InteractionStatus.SUCCESS + + boundary = ri.InteractionBoundarySpec( + name="generic safe NPC boundary", + route_points=((15000.0, 15000.0), (15750.0, 15250.0)), + route_standoff_xy=(15750.0, 15250.0), + route_tolerance=150.0, + handoff_radius=250.0, + interaction=interaction, + ) + route = CollisionAwareRoute(runtime) + controller = ri.InteractionBoundaryController(boundary, runtime, route) + self.assertEqual(run(controller, runtime, limit=200), ri.InteractionStatus.SUCCESS) + self.assertEqual(route.tick_count, 1) + self.assertEqual(runtime.interact_count, 1) + + def test_route_failure_blocks_before_required_interaction(self): + runtime = FakeRuntime("automatic") + runtime.player = (-500.0, 0.0) + route = FakeRouteExecutor(runtime, terminal=ri.InteractionStatus.FAILURE) + controller = ri.InteractionBoundaryController(make_boundary(), runtime, route) + self.assertEqual(run(controller, runtime), ri.InteractionStatus.FAILURE) + self.assertEqual(runtime.interact_count, 0) + self.assertTrue( + any( + event["event"] == "boundary_runtime_validation_failed" + for event in runtime.events + ) + ) + + def test_required_interaction_failure_blocks_after_successful_handoff(self): + runtime = FakeRuntime("never") + runtime.player = (-500.0, 0.0) + failed_interaction = make_spec( + ri.InteractionProfile.AUTOMATIC_TRIGGER, + attempts=1, + ) + failed_interaction = ri.InteractionSpec( + **{ + **failed_interaction.__dict__, + "retry": ri.RetryPolicy( + max_attempts=1, + pause_settle_ms=0, + verify_timeout_ms=100, + retry_delay_ms=0, + ), + } + ) + boundary = make_boundary(interaction=failed_interaction) + controller = ri.InteractionBoundaryController( + boundary, + runtime, + FakeRouteExecutor(runtime), + ) + self.assertEqual(run(controller, runtime, limit=100), ri.InteractionStatus.FAILURE) + self.assertEqual(runtime.interact_count, 1) + self.assertTrue( + any( + event["event"] == "boundary_result" + and event["success"] is False + and event["reason"] == "required interaction failed" + for event in runtime.events + ) + ) + + def test_boundary_cancel_during_interaction_restores_automation_and_movement(self): + runtime = FakeRuntime("never") + runtime.player = (-500.0, 0.0) + route = FakeRouteExecutor(runtime) + controller = ri.InteractionBoundaryController(make_boundary(), runtime, route) + for _ in range(20): + controller.tick() + if runtime.pause_count: + break + runtime.advance() + self.assertEqual(runtime.pause_count, 1) + controller.cancel("test restart") + self.assertEqual(controller.result, ri.InteractionStatus.FAILURE) + self.assertEqual(runtime.restore_count, 1) + self.assertGreaterEqual(route.cancel_count, 1) + + def test_boundary_coroutine_close_cancels_route_before_handoff(self): + runtime = FakeRuntime("never") + runtime.player = (-500.0, 0.0) + + class RunningRoute(FakeRouteExecutor): + def tick(self): + self.tick_count += 1 + return ri.InteractionStatus.RUNNING + + route = RunningRoute(runtime) + controller = ri.InteractionBoundaryController(make_boundary(), runtime, route) + + def wait(_milliseconds): + yield None + + adapter = ri.run_boundary_coroutine_adapter(controller, wait) + next(adapter) + adapter.close() + self.assertEqual(controller.result, ri.InteractionStatus.FAILURE) + self.assertGreaterEqual(route.cancel_count, 1) + + def test_runtime_only_resolution_is_an_explicit_escape_hatch(self): + interaction = make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER) + interaction = ri.InteractionSpec( + **{ + **interaction.__dict__, + "target": ri.TargetSpec( + kind=ri.TargetKind.LIVING, + model_id=42, + expected_xy=None, + ), + } + ) + boundary = ri.InteractionBoundarySpec( + name="runtime-only boundary", + route_points=((-300.0, 0.0), (-100.0, 0.0)), + route_standoff_xy=(-100.0, 0.0), + route_tolerance=30.0, + handoff_radius=75.0, + interaction=interaction, + resolution_strategy=ri.StandoffResolutionStrategy.RUNTIME_ONLY, + ) + boundary.validate() + + def test_py4gw_runtime_resolves_ground_item_model_through_item_data(self): + agent_module = types.ModuleType("Py4GWCoreLib.Agent") + agent_array_module = types.ModuleType("Py4GWCoreLib.AgentArray") + context_module = types.ModuleType("Py4GWCoreLib.Context") + item_module = types.ModuleType("Py4GWCoreLib.Item") + player_module = types.ModuleType("Py4GWCoreLib.Player") + + class Agent: + IsValid = staticmethod(lambda agent_id: agent_id == 77) + IsLiving = staticmethod(lambda _agent_id: False) + IsNPC = staticmethod(lambda _agent_id: False) + IsGadget = staticmethod(lambda _agent_id: False) + IsItem = staticmethod(lambda agent_id: agent_id == 77) + GetItemAgentItemID = staticmethod(lambda agent_id: 9001 if agent_id == 77 else 0) + GetModelID = staticmethod( + lambda _agent_id: (_ for _ in ()).throw( + AssertionError("living-agent model lookup must not be used for an item") + ) + ) + GetNameByID = staticmethod(lambda _agent_id: "") + GetXY = staticmethod(lambda _agent_id: (10.0, 20.0)) + + class AgentArray: + GetAgentArray = staticmethod(lambda: [77]) + + class ContextAgentArray: + GetContext = staticmethod(lambda: None) + + class GWContext: + AgentArray = ContextAgentArray + + class Item: + GetModelID = staticmethod(lambda item_id: 2569 if item_id == 9001 else 0) + + class Player: + GetXY = staticmethod(lambda: (0.0, 0.0)) + + agent_module.Agent = Agent + agent_array_module.AgentArray = AgentArray + context_module.GWContext = GWContext + item_module.Item = Item + player_module.Player = Player + fake_modules = { + "Py4GWCoreLib.Agent": agent_module, + "Py4GWCoreLib.AgentArray": agent_array_module, + "Py4GWCoreLib.Context": context_module, + "Py4GWCoreLib.Item": item_module, + "Py4GWCoreLib.Player": player_module, + } + runtime = ri.Py4GWInteractionRuntime( + pause_automation=lambda: None, + restore_automation=lambda: None, + verify=lambda _postcondition: False, + ) + target = ri.TargetSpec( + kind=ri.TargetKind.ITEM, + model_id=2569, + expected_xy=(10.0, 20.0), + search_radius=100.0, + ) + with patch.dict(sys.modules, fake_modules): + self.assertEqual(runtime.resolve_target(target), 77) + self.assertEqual(runtime.agent_model_id(77), 2569) + + def test_py4gw_runtime_resolves_mount_qinkai_priest_by_captured_location(self): + agent_module = types.ModuleType("Py4GWCoreLib.Agent") + agent_array_module = types.ModuleType("Py4GWCoreLib.AgentArray") + context_module = types.ModuleType("Py4GWCoreLib.Context") + player_module = types.ModuleType("Py4GWCoreLib.Player") + + captured_xy = { + 38: (-8345.0, -9758.0), + 24: (-8456.52, -9733.1), + 25: (-8448.95, -9873.77), + 30: (-8308.28, -9866.2), + } + + class Agent: + IsValid = staticmethod(lambda agent_id: agent_id in captured_xy) + IsLiving = staticmethod(lambda agent_id: agent_id in captured_xy) + IsNPC = staticmethod(lambda agent_id: agent_id in captured_xy) + IsGadget = staticmethod(lambda _agent_id: False) + IsItem = staticmethod(lambda _agent_id: False) + GetModelID = staticmethod(lambda agent_id: {38: 3692, 24: 1311, 25: 6025, 30: 4512}[agent_id]) + GetNameByID = staticmethod(lambda _agent_id: "") + GetXY = staticmethod(lambda agent_id: captured_xy[agent_id]) + + class AgentArray: + GetAgentArray = staticmethod(lambda: list(captured_xy)) + + class ContextAgentArray: + GetContext = staticmethod(lambda: None) + + class GWContext: + AgentArray = ContextAgentArray + + class Player: + GetXY = staticmethod(lambda: (-8354.51, -9776.93)) + + agent_module.Agent = Agent + agent_array_module.AgentArray = AgentArray + context_module.GWContext = GWContext + player_module.Player = Player + fake_modules = { + "Py4GWCoreLib.Agent": agent_module, + "Py4GWCoreLib.AgentArray": agent_array_module, + "Py4GWCoreLib.Context": context_module, + "Py4GWCoreLib.Player": player_module, + } + runtime = ri.Py4GWInteractionRuntime( + pause_automation=lambda: None, + restore_automation=lambda: None, + verify=lambda _postcondition: False, + ) + target = ri.TargetSpec( + kind=ri.TargetKind.NPC, + model_id=3692, + expected_xy=(-8345.0, -9758.0), + search_radius=700.0, + ) + with patch.dict(sys.modules, fake_modules): + self.assertEqual(runtime.resolve_target(target), 38) + + def test_normalizes_target_and_safe_approach_separately(self): + metadata = ri.normalize_capture_metadata( + { + "capture_id": "NPC-1", + "player": {"xy": [10, 20]}, + "target": {"xy": [30, 40], "model_id": 55, "name": "Target"}, + }, + interaction_kind=ri.InteractionProfile.VISIBLE_CHOICE, + postcondition=ri.PostconditionSpec(ri.PostconditionKind.EFFECT_PRESENT, 99), + ) + self.assertEqual(metadata.player_approach_xy, (10.0, 20.0)) + self.assertEqual(metadata.target_xy, (30.0, 40.0)) + self.assertEqual(metadata.source_capture_ids, ("NPC-1",)) + + def test_quest_conversation_uses_raw_context_and_verifies(self): + runtime = FakeRuntime("raw") + runtime.visible = True + spec = make_spec( + ri.InteractionProfile.QUEST_CONVERSATION, + dialog=ri.DialogSpec(raw_context_ids=(0x831801,)), + ) + controller = ri.ReliableInteractionController(spec, runtime) + self.assertEqual(run(controller, runtime), ri.InteractionStatus.SUCCESS) + self.assertGreaterEqual(runtime.resolve_count, 2) + self.assertEqual(runtime.restore_count, 1) + + def test_visible_choice_can_be_followed_by_raw_context_response(self): + runtime = FakeRuntime("visible_then_raw") + spec = make_spec( + ri.InteractionProfile.QUEST_CONVERSATION, + dialog=ri.DialogSpec( + visible_buttons=(1,), + raw_context_ids=(0x833404,), + chain_raw_after_visible=True, + visible_step_delay_ms=100, + ), + ) + controller = ri.ReliableInteractionController(spec, runtime) + self.assertEqual(run(controller, runtime), ri.InteractionStatus.SUCCESS) + self.assertEqual(runtime.click_count, 1) + self.assertEqual(runtime.sent_dialog_ids, [0x833404]) + events = [event["event"] for event in runtime.events] + self.assertIn("raw_dialog_after_visible_wait_started", events) + self.assertIn("raw_dialog_sent", events) + self.assertLess( + events.index("visible_button_clicked"), + events.index("raw_dialog_sent"), + ) + + def test_quest_conversation_accepts_immediate_trigger_without_dialog(self): + runtime = FakeRuntime("automatic") + spec = make_spec( + ri.InteractionProfile.QUEST_CONVERSATION, + dialog=ri.DialogSpec( + raw_context_ids=(0x84,), + allow_raw_without_visible_dialog=True, + ), + ) + spec = ri.InteractionSpec( + **{ + **spec.__dict__, + "allow_preexisting_postcondition": False, + } + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + self.assertEqual(runtime.sent_dialog_ids, []) + + def test_visible_choice_and_reward_accept_click_one_based_button(self): + for profile, behavior in ( + (ri.InteractionProfile.VISIBLE_CHOICE, "visible"), + (ri.InteractionProfile.REWARD_ACCEPT, "reward"), + ): + with self.subTest(profile=profile): + runtime = FakeRuntime(behavior) + spec = make_spec(profile, dialog=ri.DialogSpec(visible_button=1)) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.click_count, 1) + + def test_visible_choice_waits_for_populated_buttons_and_never_sends_raw_context(self): + runtime = FakeRuntime("delayed_visible") + spec = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec( + visible_button=1, + raw_context_ids=(0x84,), + response_timeout_ms=1_000, + ), + ) + controller = ri.ReliableInteractionController(spec, runtime) + for _ in range(20): + controller.tick() + runtime.advance(0.05) + if controller.stage == "wait_response": + break + self.assertEqual(controller.stage, "wait_response") + + for _ in range(5): + self.assertEqual(controller.tick(), ri.InteractionStatus.RUNNING) + runtime.advance(0.05) + self.assertEqual(runtime.sent_dialog_ids, []) + self.assertEqual(runtime.click_count, 0) + + runtime.buttons = 2 + self.assertEqual(controller.tick(), ri.InteractionStatus.RUNNING) + runtime.advance(0.05) + self.assertEqual(controller.tick(), ri.InteractionStatus.SUCCESS) + self.assertEqual(runtime.click_count, 1) + self.assertEqual(runtime.sent_dialog_ids, []) + events = [event["event"] for event in runtime.events] + self.assertIn("dialog_visible", events) + self.assertIn("dialog_buttons_populated", events) + self.assertIn("visible_button_clicked", events) + self.assertIn("postcondition_verified", events) + self.assertFalse(any(event.startswith("raw_dialog_sent") for event in events)) + + def test_visible_choice_accepts_stable_no_choice_alternate_without_retrying(self): + class NoChoiceRuntime(FakeRuntime): + def __init__(self): + super().__init__("raw") + + def verify(self, _postcondition): + return self.visible and self.buttons == 0 + + runtime = NoChoiceRuntime() + spec = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec( + visible_button=1, + response_timeout_ms=2_000, + allow_no_choice_success=True, + no_choice_settle_ms=500, + close_dialog_after_success=True, + ), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + self.assertEqual(runtime.click_count, 0) + self.assertTrue( + any(event["event"] == "no_choice_dialog_settle_started" for event in runtime.events) + ) + + def test_visible_choice_supports_two_page_button_sequence(self): + runtime = FakeRuntime("two_page_visible") + spec = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec( + visible_buttons=(1, 1), + visible_step_delay_ms=500, + response_timeout_ms=1_000, + ), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + self.assertEqual(runtime.click_count, 2) + self.assertEqual(runtime.sent_dialog_ids, []) + clicked = [ + event + for event in runtime.events + if event["event"] == "visible_button_clicked" + ] + self.assertEqual([event["button"] for event in clicked], [1, 1]) + self.assertEqual([event["sequence_index"] for event in clicked], [1, 2]) + self.assertIn( + "next_visible_dialog_wait_started", + [event["event"] for event in runtime.events], + ) + + def test_visible_choice_resolves_variable_menu_positions_by_label(self): + runtime = FakeRuntime("two_page_label_visible") + spec = make_spec( + ri.InteractionProfile.QUEST_CONVERSATION, + dialog=ri.DialogSpec( + visible_buttons=(1, 1), + visible_button_labels=( + "Battle in the Sewers", + "Just doing my duty to the emperor.", + ), + visible_step_delay_ms=500, + response_timeout_ms=1_000, + ), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.clicked_choices, [3, 1]) + clicked = [ + event + for event in runtime.events + if event["event"] == "visible_button_label_clicked" + ] + self.assertEqual([event["label"] for event in clicked], [ + "Battle in the Sewers", + "Just doing my duty to the emperor.", + ]) + + def test_long_dialog_page_scrolls_before_clicking_captured_response(self): + runtime = FakeRuntime("two_page_scroll_visible") + response = "Hang on, little ones! I'll save you!" + spec = make_spec( + ri.InteractionProfile.QUEST_CONVERSATION, + dialog=ri.DialogSpec( + visible_button_labels=("Stolen Eggs", response), + scroll_down_before_visible_button_labels=(response,), + visible_step_delay_ms=500, + scroll_step_delay_ms=350, + response_timeout_ms=1_000, + ), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.clicked_choices, [1, 1]) + self.assertEqual(runtime.scroll_count, 1) + self.assertEqual(runtime.scroll_deltas, [-120]) + self.assertIn( + "dialog_scrolled_for_visible_button_label", + [event["event"] for event in runtime.events], + ) + + def test_visible_choice_label_moves_to_first_position_when_other_quests_are_absent(self): + runtime = FakeRuntime("two_page_label_visible") + runtime.interact = lambda _agent_id: ( + setattr(runtime, "visible", True), + setattr(runtime, "button_labels", ("Battle in the Sewers",)), + setattr(runtime, "buttons", 1), + setattr(runtime, "interact_count", runtime.interact_count + 1), + ) + spec = make_spec( + ri.InteractionProfile.QUEST_CONVERSATION, + dialog=ri.DialogSpec( + visible_button_labels=( + "Battle in the Sewers", + "Just doing my duty to the emperor.", + ), + visible_step_delay_ms=500, + response_timeout_ms=1_000, + ), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.clicked_choices, [1, 1]) + + def test_optional_leading_label_clicks_menu_then_required_detail(self): + class ConditionalQuestRuntime(FakeRuntime): + def __init__(self, *, direct_detail: bool): + super().__init__("visible") + self.direct_detail = direct_detail + + def interact(self, _agent_id): + self.interact_count += 1 + self.visible = True + self.button_labels = ( + ("I'll seek out Elder Rhea.", "I haven't decided yet.") + if self.direct_detail + else ("Journey to House zu Heltzer", "Journey to Cavalon") + ) + self.buttons = len(self.button_labels) + + def click_dialog_button(self, choice): + self.click_count += 1 + self.clicked_choices.append(choice) + if self.button_labels[choice - 1] == "Journey to Cavalon": + self.button_labels = ( + "I'll seek out Elder Rhea.", + "I haven't decided yet.", + ) + self.buttons = len(self.button_labels) + else: + self.satisfied = True + return True + + runtime = ConditionalQuestRuntime(direct_detail=False) + spec = make_spec( + ri.InteractionProfile.QUEST_CONVERSATION, + dialog=ri.DialogSpec( + optional_leading_visible_button_labels=("Journey to Cavalon",), + visible_button_labels=("I'll seek out Elder Rhea.",), + visible_step_delay_ms=500, + response_timeout_ms=1_000, + ), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.clicked_choices, [2, 1]) + self.assertIn( + "optional_visible_button_label_clicked", + [event["event"] for event in runtime.events], + ) + + def test_optional_leading_label_skips_direct_detail_without_reinteraction(self): + class DirectQuestRuntime(FakeRuntime): + def __init__(self): + super().__init__("visible") + + def interact(self, _agent_id): + self.interact_count += 1 + self.visible = True + self.button_labels = ( + "I'll seek out Elder Rhea.", + "I haven't decided yet.", + ) + self.buttons = len(self.button_labels) + + runtime = DirectQuestRuntime() + spec = make_spec( + ri.InteractionProfile.QUEST_CONVERSATION, + dialog=ri.DialogSpec( + optional_leading_visible_button_labels=("Journey to Cavalon",), + visible_button_labels=("I'll seek out Elder Rhea.",), + visible_step_delay_ms=500, + response_timeout_ms=1_000, + ), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + self.assertEqual(runtime.clicked_choices, [1]) + self.assertIn( + "optional_visible_button_label_absent", + [event["event"] for event in runtime.events], + ) + + def test_stateful_visible_choice_accepts_remaining_opposite_label_without_reclick(self): + class AlreadyFollowingRuntime(FakeRuntime): + def __init__(self): + super().__init__("visible") + + def interact(self, _agent_id): + self.interact_count += 1 + self.visible = True + self.button_labels = ("Siege Mode",) + self.buttons = 1 + + def verify(self, _postcondition): + labels = tuple(label.casefold() for label in self.button_labels) + return self.visible and "siege mode" in labels and "follow me" not in labels + + runtime = AlreadyFollowingRuntime() + base = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec( + visible_button_labels=("Follow Me",), + response_timeout_ms=1_000, + ), + ) + spec = ri.InteractionSpec( + **{ + **base.__dict__, + "allow_preexisting_postcondition": True, + } + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + self.assertEqual(runtime.click_count, 0) + self.assertTrue( + any( + event["event"] == "postcondition_verified" + for event in runtime.events + ) + ) + + def test_visible_choice_supports_three_page_entry_sequence(self): + runtime = FakeRuntime("two_page_visible") + spec = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec( + visible_buttons=(1, 1, 1), + visible_step_delay_ms=500, + response_timeout_ms=1_000, + ), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + self.assertEqual(runtime.click_count, 3) + clicked = [ + event + for event in runtime.events + if event["event"] == "visible_button_clicked" + ] + self.assertEqual([event["button"] for event in clicked], [1, 1, 1]) + self.assertEqual([event["sequence_index"] for event in clicked], [1, 2, 3]) + + def test_visible_choice_waits_for_and_closes_post_success_popup(self): + runtime = FakeRuntime("delayed_popup") + spec = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec( + visible_button=1, + close_dialog_after_success=True, + post_success_dialog_timeout_ms=1_000, + ), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + self.assertEqual(runtime.click_count, 1) + self.assertEqual(runtime.close_count, 2) + self.assertEqual(runtime.restore_count, 1) + events = [event["event"] for event in runtime.events] + self.assertIn("postcondition_verified", events) + self.assertIn("post_success_dialog_visible", events) + self.assertIn("post_success_dialog_closed", events) + + def test_later_dungeon_beacon_leaves_success_response_open(self): + class LaterBeaconRuntime(FakeRuntime): + def interact(self, _agent_id): + self.interact_count += 1 + self.visible = True + self.buttons = 0 + self.satisfied = True + + runtime = LaterBeaconRuntime("later_beacon_response") + spec = make_spec( + ri.InteractionProfile.AUTOMATIC_TRIGGER, + dialog=ri.DialogSpec(close_dialog_after_success=False), + ) + + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + # One cleanup before the attempt is still required. The successful + # response remains visible for the following route movement to dismiss. + self.assertEqual(runtime.close_count, 1) + self.assertTrue(runtime.visible) + events = [event["event"] for event in runtime.events] + self.assertIn("postcondition_verified", events) + self.assertNotIn("post_success_dialog_wait_started", events) + self.assertNotIn("post_success_dialog_closed", events) + + def test_approach_logs_live_agent_distance_and_revalidates_target(self): + runtime = FakeRuntime("visible") + spec = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec(visible_button=1), + ) + spec = ri.InteractionSpec( + **{ + **spec.__dict__, + "approach": ri.ApproachSpec( + player_approach_xy=(80.0, 0.0), + tolerance=5.0, + target_tolerance=25.0, + ), + } + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + events = {event["event"]: event for event in runtime.events} + self.assertEqual(events["target_resolved"]["resolved_model_id"], 42) + self.assertIn("approach_started", events) + self.assertIn("approach_progress", events) + self.assertEqual(events["approach_reached"]["player_to_agent_distance"], 20.0) + self.assertEqual(events["target_reacquired"]["resolved_model_id"], 42) + self.assertEqual(events["target_reacquired"]["player_to_agent_distance"], 20.0) + event_order = [event["event"] for event in runtime.events] + expected_order = [ + "target_resolved", + "approach_started", + "approach_progress", + "approach_reached", + "target_reacquired", + "interaction_sent", + "dialog_visible", + "dialog_buttons_populated", + "visible_button_clicked", + "postcondition_verified", + ] + self.assertEqual( + [event_order.index(event) for event in expected_order], + sorted(event_order.index(event) for event in expected_order), + ) + + def test_explicit_approach_enforces_agent_distance_by_default(self): + runtime = FakeRuntime("visible") + runtime.agent_position = (120.0, 0.0) + spec = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec(visible_button=1), + attempts=1, + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime, limit=200), + ri.InteractionStatus.FAILURE, + ) + self.assertEqual(runtime.interact_count, 0) + progress = [event for event in runtime.events if event["event"] == "approach_progress"] + self.assertTrue(progress) + self.assertEqual(progress[-1]["target_tolerance"], 5.0) + self.assertGreater(progress[-1]["player_to_agent_distance"], 5.0) + + def test_required_interaction_does_not_short_circuit_on_preexisting_postcondition(self): + runtime = FakeRuntime("visible") + runtime.satisfied = True + spec = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec(visible_button=1), + ) + spec = ri.InteractionSpec( + **{ + **spec.__dict__, + "allow_preexisting_postcondition": False, + } + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + self.assertEqual(runtime.click_count, 1) + self.assertTrue( + any(event["event"] == "preexisting_postcondition_ignored" for event in runtime.events) + ) + + def test_idempotent_interaction_keeps_preexisting_postcondition_shortcut(self): + runtime = FakeRuntime("visible") + runtime.satisfied = True + spec = make_spec( + ri.InteractionProfile.VISIBLE_CHOICE, + dialog=ri.DialogSpec(visible_button=1), + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.resolve_count, 0) + self.assertEqual(runtime.interact_count, 0) + + def test_automatic_trigger_and_gadget_require_postcondition(self): + for profile, behavior in ( + (ri.InteractionProfile.AUTOMATIC_TRIGGER, "automatic"), + (ri.InteractionProfile.GADGET, "gadget"), + ): + with self.subTest(profile=profile): + runtime = FakeRuntime(behavior) + spec = make_spec(profile) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + + def test_toggle_follow_uses_one_interaction_then_moves_to_verify(self): + class ToggleFollowRuntime(FakeRuntime): + def __init__(self): + super().__init__("never") + self.following = False + + def interact(self, _agent_id): + self.interact_count += 1 + self.following = not self.following + + def move_to(self, xy): + self.player = xy + if self.following: + self.satisfied = True + + runtime = ToggleFollowRuntime() + base = make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER, attempts=1) + spec = ri.InteractionSpec( + **{ + **base.__dict__, + "verification_move_xy": (250.0, 0.0), + "verification_move_reissue_ms": 0, + } + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 1) + self.assertTrue(runtime.following) + self.assertEqual(runtime.player, (250.0, 0.0)) + self.assertTrue( + any(event["event"] == "verification_movement_sent" for event in runtime.events) + ) + + def test_walk_over_moves_without_interacting(self): + runtime = FakeRuntime("walk_over") + spec = make_spec(ri.InteractionProfile.WALK_OVER) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertEqual(runtime.interact_count, 0) + + def test_missing_safe_capture_uses_standoff_not_occupied_target(self): + runtime = FakeRuntime("automatic") + spec = make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER) + spec = ri.InteractionSpec( + **{ + **spec.__dict__, + "approach": ri.ApproachSpec(player_approach_xy=None, tolerance=5.0), + } + ) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.SUCCESS, + ) + self.assertNotEqual(runtime.player, runtime.agent_position) + self.assertAlmostEqual(runtime.player[0], 20.0) + + def test_challenge_restores_for_combat_then_reacquires_and_reinteracts(self): + runtime = FakeRuntime("challenge") + spec = make_spec( + ri.InteractionProfile.CHALLENGE_THEN_REINTERACT, + dialog=ri.DialogSpec(visible_button=1), + ) + controller = ri.ReliableInteractionController(spec, runtime) + for _ in range(100): + status = controller.tick() + if controller.stage == "wait_challenge": + runtime.hostile = False + if status != ri.InteractionStatus.RUNNING: + break + runtime.advance() + self.assertEqual(status, ri.InteractionStatus.SUCCESS) + self.assertEqual(runtime.click_count, 2) + self.assertGreaterEqual(runtime.resolve_count, 4) + self.assertEqual(runtime.pause_count, 2) + self.assertEqual(runtime.restore_count, 2) + + def test_failure_retries_and_always_restores(self): + runtime = FakeRuntime("never") + spec = make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER, attempts=2) + self.assertEqual( + run(ri.ReliableInteractionController(spec, runtime), runtime), + ri.InteractionStatus.FAILURE, + ) + self.assertEqual(runtime.interact_count, 2) + self.assertEqual(runtime.restore_count, 1) + self.assertTrue(any(event["event"] == "attempt_failed" for event in runtime.events)) + + def test_cancel_restores_automation(self): + runtime = FakeRuntime("never") + controller = ri.ReliableInteractionController( + make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER), runtime + ) + controller.tick() + controller.cancel("test cancel") + self.assertEqual(controller.result, ri.InteractionStatus.FAILURE) + self.assertEqual(runtime.restore_count, 1) + + def test_coroutine_adapter_restores_when_fsm_closes_state(self): + runtime = FakeRuntime("never") + controller = ri.ReliableInteractionController( + make_spec(ri.InteractionProfile.AUTOMATIC_TRIGGER), runtime + ) + + def wait(_milliseconds): + yield None + + adapter = ri.run_coroutine_adapter(controller, wait) + next(adapter) + adapter.close() + self.assertTrue(controller.finished) + self.assertEqual(runtime.restore_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/Py4GWCoreLib/botting_src/subclases_src/MOVE_src.py b/Py4GWCoreLib/botting_src/subclases_src/MOVE_src.py index 244d8c9dc..786265e7b 100644 --- a/Py4GWCoreLib/botting_src/subclases_src/MOVE_src.py +++ b/Py4GWCoreLib/botting_src/subclases_src/MOVE_src.py @@ -33,6 +33,7 @@ def _coro_follow_path_to( forced_timeout=-1, autopath: bool = True, fail_on_unmanaged: bool = True, + progress_callback: "Optional[Callable[[float], None]]" = None, ) -> Generator[Any, Any, bool]: from ...Routines import Routines from ...Map import Map @@ -129,6 +130,7 @@ def pause_condition() -> bool: tolerance=self._config.config_properties.movement_tolerance.get("value"), map_transition_exit_success=True, autopath=autopath, + progress_callback=progress_callback, ) self._config.config_properties.follow_path_succeeded.set_now("value", success_movement) @@ -221,12 +223,84 @@ def _coro_xy_and_interact_item(self, x: float, y: float, step_name: str="") -> G yield from self.parent.Interact._coro_with_item_at_xy(x, y) - def _coro_follow_path(self, path: List[Tuple[float, float]]) -> Generator[Any, Any, bool]: - yield from self._coro_set_path_to(path) - result = yield from self._coro_follow_path_to(autopath=False) + def _coro_follow_path( + self, + path: List[Tuple[float, float]], + resume_key: Optional[str] = None, + ) -> Generator[Any, Any, bool]: + """Follow an ordered path, optionally preserving forward progress.""" + path_points = list(path or []) + if not path_points: + return True + + progress_callback = None + if resume_key: + from ...Map import Map + from ...Player import Player + from ...Py4GWcorelib import Utils + + store = getattr(self._config, "_restart_safe_path_progress", None) + if not isinstance(store, dict): + store = {} + setattr(self._config, "_restart_safe_path_progress", store) + + current_map_id = int(Map.GetMapID()) + current_uptime = int(Map.GetInstanceUptime() or 0) + record = store.get(str(resume_key)) + floor_index = 0 + if isinstance(record, dict) and int(record.get("map_id", -1)) == current_map_id: + saved_uptime = int(record.get("instance_uptime", 0) or 0) + if current_uptime + 1_000 >= saved_uptime: + floor_index = max( + 0, + min(int(record.get("next_index", 0) or 0), len(path_points)), + ) + + current_xy = Player.GetXY() + tolerance = float(self._config.config_properties.movement_tolerance.get("value")) + if floor_index >= len(path_points): + if Utils.Distance(current_xy, path_points[-1]) <= tolerance: + return True + floor_index = 0 + + nearest_index = min( + range(floor_index, len(path_points)), + key=lambda index: Utils.Distance(current_xy, path_points[index]), + ) + remaining = path_points[nearest_index:] + store[str(resume_key)] = { + "map_id": current_map_id, + "instance_uptime": current_uptime, + "next_index": nearest_index, + } + + def _record_progress(progress: float) -> None: + completed = nearest_index + min( + len(remaining), + max(0, int(round(float(progress) * len(remaining)))), + ) + store[str(resume_key)] = { + "map_id": int(Map.GetMapID()), + "instance_uptime": int(Map.GetInstanceUptime() or 0), + "next_index": completed, + } + + progress_callback = _record_progress + path_points = remaining + + yield from self._coro_set_path_to(path_points) + result = yield from self._coro_follow_path_to( + autopath=False, + progress_callback=progress_callback, + ) return result - def _coro_follow_auto_path(self, points: List[Tuple[float, float]], step_name: str = "") -> Generator[Any, Any, None]: + def _coro_follow_auto_path( + self, + points: List[Tuple[float, float]], + step_name: str = "", + resume_key: Optional[str] = None, + ) -> Generator[Any, Any, None]: """ For each (x, y) target point, compute an autopath and follow it. Input format matches FollowPath, but each point is autpathed independently. @@ -234,9 +308,66 @@ def _coro_follow_auto_path(self, points: List[Tuple[float, float]], step_name: s if step_name == "": step_name = f"FollowAutoPath_{self._config.get_counter('FOLLOW_AUTOPATH')}" - for x, y in points: + path_points = list(points or []) + start_index = 0 + progress_store = None + if resume_key and path_points: + from ...Map import Map + from ...Player import Player + from ...Py4GWcorelib import Utils + + progress_store = getattr(self._config, "_restart_safe_auto_path_progress", None) + if not isinstance(progress_store, dict): + progress_store = {} + setattr(self._config, "_restart_safe_auto_path_progress", progress_store) + + current_map_id = int(Map.GetMapID()) + current_uptime = int(Map.GetInstanceUptime() or 0) + record = progress_store.get(str(resume_key)) + floor_index = 0 + if isinstance(record, dict) and int(record.get("map_id", -1)) == current_map_id: + saved_uptime = int(record.get("instance_uptime", 0) or 0) + if current_uptime + 1_000 >= saved_uptime: + floor_index = max( + 0, + min(int(record.get("next_index", 0) or 0), len(path_points)), + ) + + if floor_index >= len(path_points): + tolerance = float(self._config.config_properties.movement_tolerance.get("value")) + if Utils.Distance(Player.GetXY(), path_points[-1]) <= tolerance: + return + floor_index = 0 + + current_xy = Player.GetXY() + start_index = min( + range(floor_index, len(path_points)), + key=lambda index: Utils.Distance(current_xy, path_points[index]), + ) + progress_store[str(resume_key)] = { + "map_id": current_map_id, + "instance_uptime": current_uptime, + "next_index": start_index, + } + + for index in range(start_index, len(path_points)): + x, y = path_points[index] yield from self._coro_get_path_to(x, y) # autopath to this target yield from self._coro_follow_path_to() # then execute the path + if progress_store is not None: + from ...GlobalCache import GLOBAL_CACHE + from ...Map import Map + from ...Routines import Routines + + if not ( + Routines.Checks.Party.IsPartyWiped() + or GLOBAL_CACHE.Party.IsPartyDefeated() + ): + progress_store[str(resume_key)] = { + "map_id": int(Map.GetMapID()), + "instance_uptime": int(Map.GetInstanceUptime() or 0), + "next_index": index + 1, + } def _coro_follow_path_to_aggro( self, @@ -863,8 +994,8 @@ def _coro_follow_path_and_dialog(self, path: List[Tuple[float, float]], dialog_i last_point = path[-1] yield from self.parent.Dialogs._coro_at_xy(last_point[0], last_point[1], dialog_id) - def _coro_follow_path_and_exit_map(self, path: List[Tuple[float, float]], target_map_id: int = 0, target_map_name: str = "", step_name: str="") -> Generator[Any, Any, None]: - yield from self._coro_follow_path(path) + def _coro_follow_path_and_exit_map(self, path: List[Tuple[float, float]], target_map_id: int = 0, target_map_name: str = "", step_name: str="", resume_key: Optional[str] = None) -> Generator[Any, Any, None]: + yield from self._coro_follow_path(path, resume_key=resume_key) yield from self.parent.Wait._coro_for_map_load(target_map_id=target_map_id, target_map_name=target_map_name) #region Yield Steps (ys_) @@ -881,8 +1012,8 @@ def ys_follow_path(self, path): yield from self._coro_follow_path(path) @_yield_step(label="FollowAutoPath", counter_key="FOLLOW_AUTOPATH") - def ys_follow_auto_path(self, points: List[Tuple[float, float]], step_name: str = "") -> Generator[Any, Any, None]: - yield from self._coro_follow_auto_path(points, step_name) + def ys_follow_auto_path(self, points: List[Tuple[float, float]], step_name: str = "", resume_key: Optional[str] = None) -> Generator[Any, Any, None]: + yield from self._coro_follow_auto_path(points, step_name, resume_key=resume_key) @_yield_step(label="FollowAutoPathAggro", counter_key="FOLLOW_AUTOPATH_AGGRO") def ys_follow_auto_path_aggro( @@ -934,8 +1065,8 @@ def ys_follow_path_and_dialog(self, path: List[Tuple[float, float]], dialog_id: yield from self._coro_follow_path_and_dialog(path, dialog_id, step_name) @_yield_step(label="FollowPathAndExitMap", counter_key="FOLLOW_PATH_AND_EXIT_MAP") - def ys_follow_path_and_exit_map(self, path: List[Tuple[float, float]], target_map_id: int = 0, target_map_name: str = "", step_name: str="") -> Generator[Any, Any, None]: - yield from self._coro_follow_path_and_exit_map(path, target_map_id, target_map_name, step_name) + def ys_follow_path_and_exit_map(self, path: List[Tuple[float, float]], target_map_id: int = 0, target_map_name: str = "", step_name: str="", resume_key: Optional[str] = None) -> Generator[Any, Any, None]: + yield from self._coro_follow_path_and_exit_map(path, target_map_id, target_map_name, step_name, resume_key=resume_key) #region public Helpers @@ -964,11 +1095,11 @@ def FollowPath(self, path: List[Tuple[float, float]], step_name: str="") -> None def FollowPathAndDialog(self, path: List[Tuple[float, float]], dialog_id: int, step_name: str="") -> None: self.ys_follow_path_and_dialog(path, dialog_id, step_name=step_name) - def FollowPathAndExitMap(self, path: List[Tuple[float, float]], target_map_id: int = 0, target_map_name: str = "", step_name: str="") -> None: - self.ys_follow_path_and_exit_map(path, target_map_id, target_map_name, step_name=step_name) + def FollowPathAndExitMap(self, path: List[Tuple[float, float]], target_map_id: int = 0, target_map_name: str = "", step_name: str="", resume_key: Optional[str] = None) -> None: + self.ys_follow_path_and_exit_map(path, target_map_id, target_map_name, step_name=step_name, resume_key=resume_key) - def FollowAutoPath(self, points: List[Tuple[float, float]], step_name: str = "") -> None: - self.ys_follow_auto_path(points, step_name=step_name) + def FollowAutoPath(self, points: List[Tuple[float, float]], step_name: str = "", resume_key: Optional[str] = None) -> None: + self.ys_follow_auto_path(points, step_name=step_name, resume_key=resume_key) def FollowAutoPathAggro( self, diff --git a/Py4GWCoreLib/routines_src/reliable_interaction.py b/Py4GWCoreLib/routines_src/reliable_interaction.py new file mode 100644 index 000000000..ab0a6ff86 --- /dev/null +++ b/Py4GWCoreLib/routines_src/reliable_interaction.py @@ -0,0 +1,1801 @@ +"""Opt-in, profile-aware interaction reliability for BottingTree and coroutine bots. + +Legacy interaction wrappers intentionally do not import or call this module. Callers provide a +policy plus a runtime adapter and must opt in at each interaction site. +""" +from __future__ import annotations + +import math +import time +from collections.abc import Callable, Generator, Mapping +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any, Protocol + + +class InteractionProfile(str, Enum): + QUEST_CONVERSATION = "quest_conversation" + VISIBLE_CHOICE = "visible_choice" + REWARD_ACCEPT = "reward_accept" + AUTOMATIC_TRIGGER = "automatic_npc_or_beacon_trigger" + GADGET = "gadget_door_or_chest" + WALK_OVER = "walk_over_trigger" + CHALLENGE_THEN_REINTERACT = "challenge_then_reinteract" + + +class TargetKind(str, Enum): + LIVING = "living" + NPC = "npc" + NONLIVING_NPC = "nonliving_npc" + GADGET = "gadget" + ITEM = "item" + LOCATION = "location" + + +class PostconditionKind(str, Enum): + CUSTOM = "custom" + QUEST_ACTIVE = "quest_active" + QUEST_COMPLETED = "quest_completed" + QUEST_CLEARED = "quest_cleared" + EFFECT_PRESENT = "effect_present" + MAP_CHANGED = "map_changed" + ITEM_DROP_PRESENT = "item_drop_present" + BUNDLE_CHANGED = "bundle_changed" + HOSTILITY_CHANGED = "hostility_changed" + GADGET_STATE_CHANGED = "gadget_state_changed" + + +class InteractionStatus(str, Enum): + RUNNING = "running" + SUCCESS = "success" + FAILURE = "failure" + + +class StandoffResolutionStrategy(str, Enum): + """How a boundary proves that its target can be found from the standoff.""" + + EXPECTED_TARGET_RADIUS = "expected_target_radius" + RUNTIME_ONLY = "runtime_only" + + +@dataclass(frozen=True) +class TargetSpec: + kind: TargetKind + model_id: int = 0 + gadget_id: int = 0 + name_contains: str = "" + expected_xy: tuple[float, float] | None = None + search_radius: float = 1200.0 + + +@dataclass(frozen=True) +class ApproachSpec: + player_approach_xy: tuple[float, float] | None = None + tolerance: float = 90.0 + target_tolerance: float | None = None + timeout_ms: int = 12_000 + move_reissue_ms: int = 600 + + +@dataclass(frozen=True) +class DialogSpec: + visible_button: int | None = None + # One-based visible-button sequence for multi-page conversations. When + # populated, this takes precedence over visible_button. Example: (1, 1) + # clicks the first choice, waits for the next populated page, then clicks + # the first choice again. + visible_buttons: tuple[int, ...] = () + # Case-insensitive visible-label sequence for menus whose button position + # changes as other quests become available. When populated, labels take + # precedence over positional visible_buttons. + visible_button_labels: tuple[str, ...] = () + # Optional leading pages that may disappear when a sibling quest or other + # one-time choice is already complete. Each label is clicked when present; + # once a populated page proves it is absent, the controller continues with + # visible_button_labels on that same page. This keeps both the menu-first + # and direct-detail variants inside one restart-safe interaction attempt. + optional_leading_visible_button_labels: tuple[str, ...] = () + # Long dialogue pages can place their response buttons below the viewport. + # Labels listed here receive bounded downward mouse-wheel scrolls once the + # target's dialog is visible and before label discovery/click. Long pages + # may need several distinct wheel ticks before the response is exposed. + # This is opt-in because most NPC pages must not move the user's UI. + scroll_down_before_visible_button_labels: tuple[str, ...] = () + scroll_step_delay_ms: int = 350 + # PyMouse forwards this value directly as the signed high word of + # WM_MOUSEWHEEL. Use one native Windows wheel notch, not a legacy logical + # step count; sub-notch values such as -5 may be ignored by Guild Wars. + scroll_wheel_delta: int = -120 + raw_context_ids: tuple[int, ...] = () + # Opt in when a conversation intentionally changes from normal visible + # buttons to raw context responses on a later page. Without this flag, + # visible choices retain precedence and raw ids remain fallback metadata. + chain_raw_after_visible: bool = False + response_timeout_ms: int = 4_000 + visible_step_delay_ms: int = 500 + raw_step_delay_ms: int = 350 + allow_raw_without_visible_dialog: bool = False + close_dialog_after_success: bool = False + post_success_dialog_timeout_ms: int = 1_500 + # Some optional interactions answer with a stable, buttonless page when + # their eligibility requirement is not met. Callers must provide a + # postcondition that recognizes that alternate outcome. The settle delay + # prevents an asynchronously populating choice page from being mistaken + # for a final no-choice response. + allow_no_choice_success: bool = False + no_choice_settle_ms: int = 1_000 + + +@dataclass(frozen=True) +class PostconditionSpec: + kind: PostconditionKind + value: int | str | tuple[int, ...] | None = None + description: str = "" + + +@dataclass(frozen=True) +class RetryPolicy: + max_attempts: int = 3 + poll_ms: int = 100 + verify_timeout_ms: int = 4_000 + retry_delay_ms: int = 400 + pause_settle_ms: int = 350 + challenge_timeout_ms: int = 90_000 + + +@dataclass(frozen=True) +class InteractionSpec: + name: str + profile: InteractionProfile + target: TargetSpec + approach: ApproachSpec + postcondition: PostconditionSpec + dialog: DialogSpec = field(default_factory=DialogSpec) + retry: RetryPolicy = field(default_factory=RetryPolicy) + close_stale_dialog: bool = True + allow_preexisting_postcondition: bool = True + # Some interactions toggle an NPC into a following state without opening + # a dialog. Moving away after the single click provides the stimulus needed + # to verify that state without clicking again and toggling it back off. + verification_move_xy: tuple[float, float] | None = None + verification_move_reissue_ms: int = 600 + source_capture_ids: tuple[str, ...] = () + + def to_metadata(self) -> dict[str, Any]: + data = asdict(self) + data["profile"] = self.profile.value + data["target"]["kind"] = self.target.kind.value + data["postcondition"]["kind"] = self.postcondition.kind.value + return data + + +class InteractionBoundaryValidationError(ValueError): + """Raised when a route can enter an interaction's collision-sensitive area.""" + + +@dataclass(frozen=True) +class InteractionBoundarySpec: + """Own the ordinary route and its handoff to one reliable interaction. + + ``route_points`` ends at ``route_standoff_xy``. It must never end at the + captured live-target coordinate or at the final player approach. The + boundary stops that route early once the player is within ``handoff_radius`` + and the intended target is resolvable; the interaction controller then + exclusively owns the final approach and all later stages. + """ + + name: str + route_points: tuple[tuple[float, float], ...] + route_standoff_xy: tuple[float, float] + interaction: InteractionSpec + route_tolerance: float = 150.0 + handoff_radius: float = 250.0 + coordinate_epsilon: float = 1.0 + resolution_strategy: StandoffResolutionStrategy = ( + StandoffResolutionStrategy.EXPECTED_TARGET_RADIUS + ) + + def validate(self) -> None: + errors: list[str] = [] + if not self.route_points: + errors.append("ordinary route must contain at least one point") + if self.route_tolerance <= 0: + errors.append("route_tolerance must be positive") + if self.handoff_radius <= 0: + errors.append("handoff_radius must be positive") + elif self.handoff_radius < self.route_tolerance: + errors.append("handoff_radius must be at least route_tolerance") + if self.coordinate_epsilon < 0: + errors.append("coordinate_epsilon cannot be negative") + + route_endpoint = self.route_points[-1] if self.route_points else None + approach_xy = self.interaction.approach.player_approach_xy + target_xy = self.interaction.target.expected_xy + + def same(left: tuple[float, float] | None, right: tuple[float, float] | None) -> bool: + return left is not None and right is not None and math.dist(left, right) <= self.coordinate_epsilon + + if route_endpoint is not None and not same(route_endpoint, self.route_standoff_xy): + errors.append("ordinary route endpoint must be the declared route standoff") + if same(route_endpoint, approach_xy): + errors.append("ordinary route endpoint reuses the final player approach coordinate") + if same(route_endpoint, target_xy): + errors.append("ordinary route endpoint reuses the live target coordinate") + if same(self.route_standoff_xy, approach_xy): + errors.append("route standoff and final player approach must be distinct") + if same(self.route_standoff_xy, target_xy): + errors.append("route standoff and live target coordinate must be distinct") + if same(approach_xy, target_xy): + errors.append("final player approach and live target coordinate must be distinct") + + if self.resolution_strategy == StandoffResolutionStrategy.EXPECTED_TARGET_RADIUS: + if target_xy is None and self.interaction.target.kind != TargetKind.LOCATION: + errors.append( + "expected-target-radius resolution requires a captured live target coordinate" + ) + elif target_xy is not None: + standoff_to_target = math.dist(self.route_standoff_xy, target_xy) + if standoff_to_target > self.interaction.target.search_radius: + errors.append( + "route standoff is outside the target search radius " + f"({standoff_to_target:.2f} > {self.interaction.target.search_radius:.2f})" + ) + + if errors: + raise InteractionBoundaryValidationError("; ".join(errors)) + + def to_metadata(self) -> dict[str, Any]: + return { + "name": self.name, + "route_points": self.route_points, + "route_standoff_xy": self.route_standoff_xy, + "route_tolerance": self.route_tolerance, + "handoff_radius": self.handoff_radius, + "resolution_strategy": self.resolution_strategy.value, + "interaction": self.interaction.to_metadata(), + } + + +@dataclass(frozen=True) +class CaptureInteractionMetadata: + """Normalized capture metadata; runtime IDs are deliberately excluded.""" + + schema_version: int + interaction_kind: InteractionProfile + target_kind: TargetKind + target_xy: tuple[float, float] | None + player_approach_xy: tuple[float, float] | None + model_id: int + target_name: str + postcondition: PostconditionSpec + source_capture_ids: tuple[str, ...] = () + + def to_dict(self) -> dict[str, Any]: + result = asdict(self) + result["interaction_kind"] = self.interaction_kind.value + result["target_kind"] = self.target_kind.value + result["postcondition"]["kind"] = self.postcondition.kind.value + return result + + +def normalize_capture_metadata( + capture: Mapping[str, Any], + *, + interaction_kind: InteractionProfile, + postcondition: PostconditionSpec, + target_kind: TargetKind = TargetKind.LIVING, +) -> CaptureInteractionMetadata: + """Normalize either capture widget's record without requiring a second capture.""" + + target = capture.get("target") if isinstance(capture.get("target"), Mapping) else {} + player = capture.get("player") if isinstance(capture.get("player"), Mapping) else {} + point = capture.get("point") if isinstance(capture.get("point"), Mapping) else capture + target_xy = target.get("xy") or point.get("target_xy") or point.get("npc_xy") + approach_xy = ( + point.get("player_approach_xy") + or player.get("xy") + or point.get("xy") + ) + + def _xy(value: Any) -> tuple[float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) < 2: + return None + return float(value[0]), float(value[1]) + + capture_id = str(capture.get("capture_id") or "") + return CaptureInteractionMetadata( + schema_version=2, + interaction_kind=interaction_kind, + target_kind=target_kind, + target_xy=_xy(target_xy), + player_approach_xy=_xy(approach_xy), + model_id=int(target.get("model_id") or point.get("preferred_npc_model_id") or 0), + target_name=str(target.get("name") or point.get("npc_name") or ""), + postcondition=postcondition, + source_capture_ids=(capture_id,) if capture_id else (), + ) + + +class InteractionRuntime(Protocol): + def now(self) -> float: ... + def pause_automation(self) -> None: ... + def restore_automation(self) -> None: ... + def cancel_movement(self) -> None: ... + def close_stale_dialog(self) -> None: ... + def resolve_target(self, target: TargetSpec) -> int | None: ... + def agent_xy(self, agent_id: int) -> tuple[float, float]: ... + def agent_model_id(self, agent_id: int) -> int: ... + def agent_gadget_id(self, agent_id: int) -> int: ... + def player_xy(self) -> tuple[float, float]: ... + def move_to(self, xy: tuple[float, float]) -> None: ... + def interact(self, agent_id: int) -> None: ... + def dialog_visible(self) -> bool: ... + def dialog_button_count(self) -> int: ... + def dialog_button_labels(self) -> tuple[str, ...]: ... + def scroll_dialog(self, delta: int) -> bool: ... + def click_dialog_button(self, choice: int) -> bool: ... + def send_dialog(self, dialog_id: int) -> None: ... + def verify(self, postcondition: PostconditionSpec) -> bool: ... + def is_hostile(self, agent_id: int) -> bool: ... + def emit(self, event: dict[str, Any]) -> None: ... + + +class BoundaryRouteExecutor(Protocol): + """Tick-driven adapter for the bot's normal pathing implementation.""" + + def start( + self, + route_points: tuple[tuple[float, float], ...], + tolerance: float, + handoff_ready: Callable[[], bool], + ) -> None: ... + def tick(self) -> InteractionStatus: ... + def cancel(self) -> None: ... + + +class ReliableInteractionController: + """Tick-driven controller shared by both adapters.""" + + def __init__(self, spec: InteractionSpec, runtime: InteractionRuntime): + self.spec = spec + self.runtime = runtime + self.stage = "new" + self.attempt = 0 + self.agent_id = 0 + self.deadline = 0.0 + self.last_move_at = 0.0 + self.last_verification_move_at = 0.0 + self.raw_index = 0 + self.visible_index = 0 + self.optional_visible_index = 0 + self.scrolled_visible_indices: set[int] = set() + self.scroll_steps_by_visible_index: dict[int, int] = {} + self.visible_step_not_before = 0.0 + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self.no_choice_visible_since = 0.0 + self.paused = False + self.finished = False + self.result = InteractionStatus.RUNNING + self._ownership_token = object() + self._ownership_published = False + + def _refresh_ownership(self) -> None: + was_published = self._ownership_published + try: + from Py4GWCoreLib.routines_src.shared_command_coordination import ( + refresh_local_reliable_interaction, + ) + + refresh_local_reliable_interaction( + self._ownership_token, + name=self.spec.name, + ) + self._ownership_published = True + if not was_published: + self._event("movement_ownership_acquired") + except Exception: + # The standalone policy tests intentionally load this module + # without the full injected Py4GW package. Coordination is an + # active-runtime enhancement; interaction behavior stays usable + # if that optional process-local module is unavailable. + return + + def _clear_ownership(self) -> None: + if not self._ownership_published: + return + try: + try: + from Py4GWCoreLib.routines_src.shared_command_coordination import ( + clear_local_reliable_interaction, + ) + + clear_local_reliable_interaction(self._ownership_token) + except Exception: + pass + finally: + self._ownership_published = False + try: + self._event("movement_ownership_released") + except Exception: + pass + + def _event(self, event: str, **values: Any) -> None: + self.runtime.emit( + { + "component": "reliable_interaction", + "event": event, + "interaction": self.spec.name, + "profile": self.spec.profile.value, + "stage": self.stage, + "attempt": self.attempt, + "agent_id_runtime_only": self.agent_id, + **values, + } + ) + + def _finish(self, success: bool, reason: str) -> InteractionStatus: + if not self.finished: + try: + try: + self.runtime.cancel_movement() + finally: + if self.paused: + self.runtime.restore_automation() + self.paused = False + finally: + self._clear_ownership() + self.finished = True + self.result = InteractionStatus.SUCCESS if success else InteractionStatus.FAILURE + self.stage = "complete" if success else "failed" + self._event("result", success=success, reason=reason) + return self.result + + def _postcondition_succeeded(self, now: float, reason: str) -> InteractionStatus: + self._event( + "postcondition_verified", + postcondition=self.spec.postcondition.kind.value, + description=self.spec.postcondition.description, + ) + if self.spec.dialog.close_dialog_after_success: + self.stage = "wait_post_success_dialog" + self.deadline = now + self.spec.dialog.post_success_dialog_timeout_ms / 1000.0 + self._event( + "post_success_dialog_wait_started", + timeout_ms=self.spec.dialog.post_success_dialog_timeout_ms, + ) + return InteractionStatus.RUNNING + return self._finish(True, reason) + + def cancel(self, reason: str = "cancelled") -> None: + if not self.finished: + self._finish(False, reason) + + def _retry(self, reason: str) -> InteractionStatus: + self._event("attempt_failed", reason=reason) + if self.attempt >= max(1, self.spec.retry.max_attempts): + return self._finish(False, reason) + self.runtime.cancel_movement() + if self.spec.close_stale_dialog: + self.runtime.close_stale_dialog() + self.agent_id = 0 + self.raw_index = 0 + self.visible_index = 0 + self.optional_visible_index = 0 + self.scrolled_visible_indices.clear() + self.scroll_steps_by_visible_index.clear() + self.visible_step_not_before = 0.0 + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self.no_choice_visible_since = 0.0 + self.last_verification_move_at = 0.0 + self.stage = "retry_delay" + self.deadline = self.runtime.now() + self.spec.retry.retry_delay_ms / 1000.0 + return InteractionStatus.RUNNING + + def _destination(self) -> tuple[float, float] | None: + if self.spec.approach.player_approach_xy is not None: + return self.spec.approach.player_approach_xy + target_xy = self.runtime.agent_xy(self.agent_id) if self.agent_id else self.spec.target.expected_xy + if target_xy is None: + return None + if self.spec.target.kind == TargetKind.LOCATION: + return target_xy + player_xy = self.runtime.player_xy() + distance = math.dist(player_xy, target_xy) + stand_off = max(80.0, self.spec.approach.tolerance) + if distance <= stand_off: + return player_xy + scale = stand_off / distance + return ( + target_xy[0] + (player_xy[0] - target_xy[0]) * scale, + target_xy[1] + (player_xy[1] - target_xy[1]) * scale, + ) + + def _agent_model_id(self, agent_id: int) -> int | None: + getter = getattr(self.runtime, "agent_model_id", None) + return int(getter(agent_id)) if callable(getter) else None + + def _agent_gadget_id(self, agent_id: int) -> int | None: + getter = getattr(self.runtime, "agent_gadget_id", None) + return int(getter(agent_id)) if callable(getter) else None + + def _effective_target_tolerance(self) -> float | None: + if self.spec.target.kind == TargetKind.LOCATION: + return None + if self.spec.approach.target_tolerance is not None: + return self.spec.approach.target_tolerance + if self.spec.approach.player_approach_xy is not None: + return self.spec.approach.tolerance + return max(80.0, self.spec.approach.tolerance) + + def _position_snapshot(self, destination: tuple[float, float] | None) -> dict[str, Any]: + player_xy = self.runtime.player_xy() + agent_xy = self.runtime.agent_xy(self.agent_id) if self.agent_id else None + expected_xy = self.spec.target.expected_xy + return { + "player_xy": player_xy, + "agent_xy": agent_xy, + "expected_target_xy": expected_xy, + "player_approach_xy": destination, + "player_to_approach_distance": ( + round(math.dist(player_xy, destination), 2) if destination is not None else None + ), + "player_to_agent_distance": ( + round(math.dist(player_xy, agent_xy), 2) if agent_xy is not None else None + ), + "agent_to_expected_target_distance": ( + round(math.dist(agent_xy, expected_xy), 2) + if agent_xy is not None and expected_xy is not None + else None + ), + "resolved_model_id": self._agent_model_id(self.agent_id) if self.agent_id else None, + "expected_model_id": self.spec.target.model_id or None, + "resolved_gadget_id": self._agent_gadget_id(self.agent_id) if self.agent_id else None, + "expected_gadget_id": self.spec.target.gadget_id or None, + } + + def _resolved_target_failure(self, snapshot: dict[str, Any]) -> str | None: + expected_model_id = self.spec.target.model_id + resolved_model_id = snapshot["resolved_model_id"] + if expected_model_id and resolved_model_id != expected_model_id: + return f"resolved model {resolved_model_id!r} did not match expected model {expected_model_id}" + expected_gadget_id = self.spec.target.gadget_id + resolved_gadget_id = snapshot["resolved_gadget_id"] + if expected_gadget_id and resolved_gadget_id != expected_gadget_id: + return ( + f"resolved gadget {resolved_gadget_id!r} did not match " + f"expected gadget {expected_gadget_id}" + ) + expected_distance = snapshot["agent_to_expected_target_distance"] + if expected_distance is not None and expected_distance > self.spec.target.search_radius: + return ( + f"resolved target was {expected_distance:.2f} from expected location, " + f"outside search radius {self.spec.target.search_radius:.2f}" + ) + return None + + def tick(self) -> InteractionStatus: + if self.finished: + return self.result + try: + self._refresh_ownership() + return self._tick() + except Exception as error: + self._event("exception", error=f"{type(error).__name__}: {error}") + return self._finish(False, "runtime exception") + + def _tick(self) -> InteractionStatus: + now = self.runtime.now() + if self.stage == "new": + if self.runtime.verify(self.spec.postcondition): + if self.spec.allow_preexisting_postcondition: + return self._finish(True, "postcondition already satisfied") + self._event("preexisting_postcondition_ignored") + self.runtime.pause_automation() + self.paused = True + self.runtime.cancel_movement() + if self.spec.close_stale_dialog: + self.runtime.close_stale_dialog() + self.stage = "pause_settle" + self.deadline = now + self.spec.retry.pause_settle_ms / 1000.0 + self._event("start", policy=self.spec.to_metadata()) + return InteractionStatus.RUNNING + + if self.stage == "pause_settle": + if now < self.deadline: + return InteractionStatus.RUNNING + self.attempt += 1 + self.stage = "resolve" + + if self.stage == "retry_delay": + if now < self.deadline: + return InteractionStatus.RUNNING + self.attempt += 1 + self.stage = "resolve" + + if self.stage == "resolve": + if self.spec.target.kind != TargetKind.LOCATION: + resolved = self.runtime.resolve_target(self.spec.target) + if not resolved: + return self._retry("target not found") + self.agent_id = int(resolved) + destination = self._destination() + snapshot = self._position_snapshot(destination) + failure = self._resolved_target_failure(snapshot) + if failure: + return self._retry(failure) + self._event("target_resolved", **snapshot) + self.stage = "approach" + self.deadline = now + self.spec.approach.timeout_ms / 1000.0 + self.last_move_at = 0.0 + destination = self._destination() + self._event( + "approach_started", + tolerance=self.spec.approach.tolerance, + target_tolerance=self._effective_target_tolerance(), + configured_target_tolerance=self.spec.approach.target_tolerance, + **self._position_snapshot(destination), + ) + + if self.stage == "approach": + destination = self._destination() + if destination is None: + return self._retry("no safe approach coordinate") + snapshot = self._position_snapshot(destination) + approach_distance = float(snapshot["player_to_approach_distance"]) + agent_distance = snapshot["player_to_agent_distance"] + target_tolerance = self._effective_target_tolerance() + target_close_enough = ( + target_tolerance is None + or (agent_distance is not None and float(agent_distance) <= target_tolerance) + ) + if approach_distance <= self.spec.approach.tolerance and target_close_enough: + self.runtime.cancel_movement() + self._event( + "approach_reached", + tolerance=self.spec.approach.tolerance, + target_tolerance=target_tolerance, + configured_target_tolerance=self.spec.approach.target_tolerance, + **snapshot, + ) + self.stage = "verify" if self.spec.profile == InteractionProfile.WALK_OVER else "reacquire" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + elif now >= self.deadline: + return self._retry("safe approach timed out") + elif now - self.last_move_at >= self.spec.approach.move_reissue_ms / 1000.0: + self.runtime.move_to(destination) + self.last_move_at = now + self._event( + "approach_progress", + tolerance=self.spec.approach.tolerance, + target_tolerance=target_tolerance, + configured_target_tolerance=self.spec.approach.target_tolerance, + movement_command_sent=True, + **snapshot, + ) + return InteractionStatus.RUNNING + + if self.stage == "reacquire": + resolved = self.runtime.resolve_target(self.spec.target) + if not resolved: + return self._retry("target disappeared before interaction") + old_id = self.agent_id + self.agent_id = int(resolved) + destination = self._destination() + snapshot = self._position_snapshot(destination) + failure = self._resolved_target_failure(snapshot) + if failure: + return self._retry(f"reacquired target validation failed: {failure}") + self._event( + "target_reacquired", + previous_agent_id_runtime_only=old_id, + **snapshot, + ) + self.stage = "interact" + + if self.stage == "interact": + self.runtime.interact(self.agent_id) + self._event("interaction_sent", target_xy=self.runtime.agent_xy(self.agent_id)) + if self.spec.profile in ( + InteractionProfile.AUTOMATIC_TRIGGER, + InteractionProfile.GADGET, + ): + self.stage = "verify" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + else: + self.stage = "wait_response" + self.deadline = now + self.spec.dialog.response_timeout_ms / 1000.0 + return InteractionStatus.RUNNING + + if self.stage == "wait_response": + # Some quest conversations (for example Scout Ryder) complete as a + # direct consequence of Interact and never populate a dialog frame. + if ( + self.spec.profile == InteractionProfile.QUEST_CONVERSATION + and self.runtime.verify(self.spec.postcondition) + ): + return self._postcondition_succeeded( + now, + "postcondition verified immediately after interaction", + ) + visible = self.runtime.dialog_visible() + button_count = self.runtime.dialog_button_count() if visible else 0 + if visible and not self.dialog_visible_logged: + self.dialog_visible_logged = True + self._event("dialog_visible") + if visible and button_count > 0 and button_count != self.dialog_button_count_logged: + self.dialog_button_count_logged = button_count + self._event("dialog_buttons_populated", button_count=button_count) + if self.spec.dialog.allow_no_choice_success and visible and button_count == 0: + if self.no_choice_visible_since <= 0.0: + self.no_choice_visible_since = now + self._event( + "no_choice_dialog_settle_started", + settle_ms=self.spec.dialog.no_choice_settle_ms, + ) + elif ( + now - self.no_choice_visible_since + >= self.spec.dialog.no_choice_settle_ms / 1000.0 + and self.runtime.verify(self.spec.postcondition) + ): + return self._postcondition_succeeded( + now, + "stable no-choice dialog matched alternate postcondition", + ) + else: + self.no_choice_visible_since = 0.0 + + visible_buttons = ( + tuple(int(choice) for choice in self.spec.dialog.visible_buttons) + if self.spec.dialog.visible_buttons + else ( + (int(self.spec.dialog.visible_button),) + if self.spec.dialog.visible_button is not None + else () + ) + ) + visible_button_labels = tuple( + str(label).strip() + for label in self.spec.dialog.visible_button_labels + if str(label).strip() + ) + optional_leading_labels = tuple( + str(label).strip() + for label in self.spec.dialog.optional_leading_visible_button_labels + if str(label).strip() + ) + scroll_down_labels = { + str(label).strip().casefold() + for label in self.spec.dialog.scroll_down_before_visible_button_labels + if str(label).strip() + } + if self.optional_visible_index < len(optional_leading_labels): + if now < self.visible_step_not_before: + return InteractionStatus.RUNNING + if visible and button_count > 0: + labels = tuple(self.runtime.dialog_button_labels()) + labels_ready = ( + len(labels) >= button_count + and any(str(label).strip() for label in labels) + ) + if labels_ready: + desired = optional_leading_labels[self.optional_visible_index] + desired_folded = desired.casefold() + exact = [ + index + 1 + for index, label in enumerate(labels) + if str(label).strip().casefold() == desired_folded + ] + contains = [ + index + 1 + for index, label in enumerate(labels) + if desired_folded in str(label).strip().casefold() + ] + matches = exact or contains + if len(matches) == 1: + choice = matches[0] + if choice <= button_count and self.runtime.click_dialog_button(choice): + self.optional_visible_index += 1 + self._event( + "optional_visible_button_label_clicked", + button=choice, + label=desired, + observed_labels=labels, + button_count=button_count, + sequence_index=self.optional_visible_index, + sequence_length=len(optional_leading_labels), + ) + self.visible_step_not_before = ( + now + self.spec.dialog.visible_step_delay_ms / 1000.0 + ) + self.deadline = ( + self.visible_step_not_before + + self.spec.dialog.response_timeout_ms / 1000.0 + ) + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self._event( + "next_visible_dialog_wait_started", + next_sequence_index=self.visible_index + 1, + settle_ms=self.spec.dialog.visible_step_delay_ms, + ) + return InteractionStatus.RUNNING + elif len(matches) > 1: + self._event( + "optional_visible_button_label_ambiguous", + label=desired, + observed_labels=labels, + matching_buttons=matches, + ) + if now >= self.deadline: + return self._retry( + "optional visible dialog label was ambiguous" + ) + return InteractionStatus.RUNNING + else: + self.optional_visible_index += 1 + self._event( + "optional_visible_button_label_absent", + label=desired, + observed_labels=labels, + ) + # Evaluate the required label against this same + # populated page without another interaction. + if visible_button_labels and self.visible_index < len(visible_button_labels): + if now < self.visible_step_not_before: + return InteractionStatus.RUNNING + desired = visible_button_labels[self.visible_index] + desired_folded = desired.casefold() + if visible and desired_folded in scroll_down_labels: + labels = tuple(self.runtime.dialog_button_labels()) + exact_before_scroll = [ + index + 1 + for index, label in enumerate(labels) + if str(label).strip().casefold() == desired_folded + ] + contains_before_scroll = [ + index + 1 + for index, label in enumerate(labels) + if desired_folded in str(label).strip().casefold() + ] + matches_before_scroll = exact_before_scroll or contains_before_scroll + scroll_steps = self.scroll_steps_by_visible_index.get( + self.visible_index, 0 + ) + must_scroll = scroll_steps == 0 + matching_choice = ( + matches_before_scroll[0] + if len(matches_before_scroll) == 1 + else 0 + ) + # Native dialog metadata can expose every logical choice + # before any corresponding button frame has entered the + # scrolled viewport. Keep scrolling until the matched + # one-based choice is also represented by a visible frame; + # label uniqueness alone is not clickability proof. + matching_choice_dispatchable = ( + matching_choice > 0 and matching_choice <= button_count + ) + needs_more_scroll = not matching_choice_dispatchable + if (must_scroll or needs_more_scroll) and now < self.deadline: + if not self.runtime.scroll_dialog( + int(self.spec.dialog.scroll_wheel_delta) + ): + if now >= self.deadline: + return self._retry( + "dialog page could not be scrolled to its response buttons" + ) + return InteractionStatus.RUNNING + scroll_steps += 1 + self.scroll_steps_by_visible_index[self.visible_index] = scroll_steps + self.scrolled_visible_indices.add(self.visible_index) + self.visible_step_not_before = ( + now + self.spec.dialog.scroll_step_delay_ms / 1000.0 + ) + self.dialog_button_count_logged = 0 + self._event( + "dialog_scrolled_for_visible_button_label", + label=desired, + wheel_delta=self.spec.dialog.scroll_wheel_delta, + scroll_step=scroll_steps, + observed_labels=labels, + ) + return InteractionStatus.RUNNING + if visible and button_count > 0: + labels = tuple(self.runtime.dialog_button_labels()) + exact = [ + index + 1 + for index, label in enumerate(labels) + if str(label).strip().casefold() == desired_folded + ] + contains = [ + index + 1 + for index, label in enumerate(labels) + if desired_folded in str(label).strip().casefold() + ] + matches = exact or contains + # Stateful choice dialogs may remove the selected mode + # and leave only its opposite option. That remaining page + # is a concrete, restart-safe postcondition: do not click + # the toggle again merely because its label disappeared. + if ( + not matches + and self.spec.allow_preexisting_postcondition + and self.runtime.verify(self.spec.postcondition) + ): + return self._postcondition_succeeded( + now, + "visible dialog matched preexisting postcondition", + ) + if len(matches) == 1: + choice = matches[0] + if choice <= button_count and self.runtime.click_dialog_button(choice): + self.visible_index += 1 + self._event( + "visible_button_label_clicked", + button=choice, + label=desired, + observed_labels=labels, + button_count=button_count, + sequence_index=self.visible_index, + sequence_length=len(visible_button_labels), + ) + if self.visible_index < len(visible_button_labels): + self.visible_step_not_before = ( + now + self.spec.dialog.visible_step_delay_ms / 1000.0 + ) + self.deadline = ( + self.visible_step_not_before + + self.spec.dialog.response_timeout_ms / 1000.0 + ) + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self._event( + "next_visible_dialog_wait_started", + next_sequence_index=self.visible_index + 1, + settle_ms=self.spec.dialog.visible_step_delay_ms, + ) + elif ( + self.spec.dialog.chain_raw_after_visible + and self.raw_index < len(self.spec.dialog.raw_context_ids) + ): + # Some conversations change API mechanisms + # between pages: a normal visible choice opens + # a conclusion page acknowledged by raw context + # id. Settle that replacement page, then hand it + # to the shared raw-response branch below. + self.visible_step_not_before = ( + now + self.spec.dialog.visible_step_delay_ms / 1000.0 + ) + self.deadline = ( + self.visible_step_not_before + + self.spec.dialog.response_timeout_ms / 1000.0 + ) + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self._event( + "raw_dialog_after_visible_wait_started", + next_raw_index=self.raw_index + 1, + settle_ms=self.spec.dialog.visible_step_delay_ms, + ) + else: + self.stage = "verify" + self.deadline = ( + now + self.spec.retry.verify_timeout_ms / 1000.0 + ) + return InteractionStatus.RUNNING + elif len(matches) > 1: + self._event( + "visible_button_label_ambiguous", + label=desired, + observed_labels=labels, + matching_buttons=matches, + ) + if now >= self.deadline: + return self._retry( + "expected visible dialog label did not become uniquely clickable" + ) + return InteractionStatus.RUNNING + + if visible_buttons and self.visible_index < len(visible_buttons): + if now < self.visible_step_not_before: + return InteractionStatus.RUNNING + if visible and button_count > 0: + choice = visible_buttons[self.visible_index] + if choice <= button_count and self.runtime.click_dialog_button(choice): + self.visible_index += 1 + self._event( + "visible_button_clicked", + button=choice, + button_count=button_count, + sequence_index=self.visible_index, + sequence_length=len(visible_buttons), + ) + if self.visible_index < len(visible_buttons): + # The game may replace a dialogue page without an + # observable invisible frame. Match the proven + # dungeon pattern: settle, then wait again for a + # populated visible page before the next click. + self.visible_step_not_before = ( + now + self.spec.dialog.visible_step_delay_ms / 1000.0 + ) + self.deadline = ( + self.visible_step_not_before + + self.spec.dialog.response_timeout_ms / 1000.0 + ) + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self._event( + "next_visible_dialog_wait_started", + next_sequence_index=self.visible_index + 1, + settle_ms=self.spec.dialog.visible_step_delay_ms, + ) + elif ( + self.spec.dialog.chain_raw_after_visible + and self.raw_index < len(self.spec.dialog.raw_context_ids) + ): + self.visible_step_not_before = ( + now + self.spec.dialog.visible_step_delay_ms / 1000.0 + ) + self.deadline = ( + self.visible_step_not_before + + self.spec.dialog.response_timeout_ms / 1000.0 + ) + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self._event( + "raw_dialog_after_visible_wait_started", + next_raw_index=self.raw_index + 1, + settle_ms=self.spec.dialog.visible_step_delay_ms, + ) + else: + self.stage = "verify" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + return InteractionStatus.RUNNING + if now >= self.deadline: + return self._retry("expected visible dialog choice did not become clickable") + return InteractionStatus.RUNNING + + if now < self.visible_step_not_before: + return InteractionStatus.RUNNING + if visible and self.raw_index < len(self.spec.dialog.raw_context_ids): + dialog_id = self.spec.dialog.raw_context_ids[self.raw_index] + self.runtime.send_dialog(dialog_id) + self.raw_index += 1 + self._event("raw_dialog_sent", dialog_id=dialog_id, dialog_id_hex=f"0x{dialog_id:X}") + if self.raw_index >= len(self.spec.dialog.raw_context_ids): + self.stage = "verify" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + else: + self.deadline = now + self.spec.dialog.raw_step_delay_ms / 1000.0 + return InteractionStatus.RUNNING + if now >= self.deadline: + if ( + self.raw_index < len(self.spec.dialog.raw_context_ids) + and self.spec.dialog.allow_raw_without_visible_dialog + ): + dialog_id = self.spec.dialog.raw_context_ids[self.raw_index] + self.runtime.send_dialog(dialog_id) + self.raw_index += 1 + self._event( + "raw_dialog_sent_without_visible_frame", + dialog_id=dialog_id, + dialog_id_hex=f"0x{dialog_id:X}", + ) + self.stage = "verify" + self.deadline = now + self.spec.retry.verify_timeout_ms / 1000.0 + return InteractionStatus.RUNNING + return self._retry("expected dialogue response did not appear") + return InteractionStatus.RUNNING + + if self.stage == "verify": + if self.runtime.verify(self.spec.postcondition): + return self._postcondition_succeeded(now, "postcondition verified") + if self.spec.verification_move_xy is not None: + if ( + now - self.last_verification_move_at + >= self.spec.verification_move_reissue_ms / 1000.0 + ): + self.runtime.move_to(self.spec.verification_move_xy) + self.last_verification_move_at = now + self._event( + "verification_movement_sent", + verification_move_xy=self.spec.verification_move_xy, + ) + if self.spec.profile == InteractionProfile.CHALLENGE_THEN_REINTERACT and self.agent_id: + if self.runtime.is_hostile(self.agent_id): + self._event("challenge_hostile") + self.runtime.restore_automation() + self.paused = False + self.stage = "wait_challenge" + self.deadline = now + self.spec.retry.challenge_timeout_ms / 1000.0 + return InteractionStatus.RUNNING + if now >= self.deadline: + return self._retry("postcondition was not verified") + return InteractionStatus.RUNNING + + if self.stage == "wait_challenge": + if self.runtime.verify(self.spec.postcondition): + return self._postcondition_succeeded(now, "challenge postcondition verified") + resolved = self.runtime.resolve_target(self.spec.target) + if resolved and not self.runtime.is_hostile(int(resolved)): + self.runtime.pause_automation() + self.paused = True + self.agent_id = int(resolved) + # A challenge deliberately starts the same interaction again + # after combat, so its dialogue sequence must begin at page 1. + self.raw_index = 0 + self.visible_index = 0 + self.optional_visible_index = 0 + self.visible_step_not_before = 0.0 + self.dialog_visible_logged = False + self.dialog_button_count_logged = 0 + self.stage = "pause_settle" + self.deadline = now + self.spec.retry.pause_settle_ms / 1000.0 + self._event("challenge_finished_reinteract") + return InteractionStatus.RUNNING + if now >= self.deadline: + return self._finish(False, "challenge did not finish before timeout") + return InteractionStatus.RUNNING + + if self.stage == "wait_post_success_dialog": + if self.runtime.dialog_visible(): + self._event("post_success_dialog_visible") + self.runtime.close_stale_dialog() + self._event("post_success_dialog_closed") + return self._finish(True, "postcondition verified and follow-up dialog closed") + if now >= self.deadline: + self._event("post_success_dialog_not_seen") + return self._finish(True, "postcondition verified; no follow-up dialog appeared") + return InteractionStatus.RUNNING + + return self._finish(False, f"unknown stage {self.stage!r}") + + +class InteractionBoundaryController: + """Execute a safe ordinary-route handoff before a reliable interaction.""" + + def __init__( + self, + spec: InteractionBoundarySpec, + runtime: InteractionRuntime, + route_executor: BoundaryRouteExecutor, + ): + self.spec = spec + self.runtime = runtime + self.route_executor = route_executor + self.stage = "new" + self.finished = False + self.result = InteractionStatus.RUNNING + self.route_started = False + self.interaction_controller: ReliableInteractionController | None = None + self.handoff_agent_id = 0 + try: + self.spec.validate() + except InteractionBoundaryValidationError as error: + self._event("boundary_construction_validation_failed", reason=str(error)) + raise + + def _event(self, event: str, **values: Any) -> None: + self.runtime.emit( + { + "component": "reliable_interaction_boundary", + "event": event, + "boundary": self.spec.name, + "interaction": self.spec.interaction.name, + "stage": self.stage, + **values, + } + ) + + def _finish(self, success: bool, reason: str) -> InteractionStatus: + if not self.finished: + try: + self.route_executor.cancel() + finally: + self.runtime.cancel_movement() + self.finished = True + self.result = InteractionStatus.SUCCESS if success else InteractionStatus.FAILURE + self.stage = "complete" if success else "failed" + self._event("boundary_result", success=success, reason=reason) + return self.result + + def cancel(self, reason: str = "cancelled") -> None: + if self.finished: + return + if self.interaction_controller is not None and not self.interaction_controller.finished: + self.interaction_controller.cancel(f"interaction boundary cancelled: {reason}") + self._finish(False, reason) + + def _handoff_snapshot(self) -> tuple[bool, dict[str, Any]]: + player_xy = self.runtime.player_xy() + standoff_distance = math.dist(player_xy, self.spec.route_standoff_xy) + target = self.spec.interaction.target + agent_id = 0 + agent_xy = None + resolved_model_id = None + resolved_gadget_id = None + failure = None + + if target.kind != TargetKind.LOCATION: + resolved = self.runtime.resolve_target(target) + if resolved: + agent_id = int(resolved) + agent_xy = self.runtime.agent_xy(agent_id) + model_getter = getattr(self.runtime, "agent_model_id", None) + resolved_model_id = int(model_getter(agent_id)) if callable(model_getter) else None + gadget_getter = getattr(self.runtime, "agent_gadget_id", None) + resolved_gadget_id = int(gadget_getter(agent_id)) if callable(gadget_getter) else None + if target.model_id and resolved_model_id != target.model_id: + failure = ( + f"resolved model {resolved_model_id!r} did not match " + f"expected model {target.model_id}" + ) + elif target.gadget_id and resolved_gadget_id != target.gadget_id: + failure = ( + f"resolved gadget {resolved_gadget_id!r} did not match " + f"expected gadget {target.gadget_id}" + ) + elif target.expected_xy is not None: + target_offset = math.dist(agent_xy, target.expected_xy) + if target_offset > target.search_radius: + failure = ( + f"resolved target was {target_offset:.2f} from expected location, " + f"outside search radius {target.search_radius:.2f}" + ) + else: + failure = "intended live target was not resolvable" + + snapshot = { + "player_xy": player_xy, + "route_standoff_xy": self.spec.route_standoff_xy, + "player_to_standoff_distance": round(standoff_distance, 2), + "handoff_radius": self.spec.handoff_radius, + "agent_id_runtime_only": agent_id, + "agent_xy": agent_xy, + "player_to_agent_distance": ( + round(math.dist(player_xy, agent_xy), 2) if agent_xy is not None else None + ), + "resolved_model_id": resolved_model_id, + "expected_model_id": target.model_id or None, + "resolved_gadget_id": resolved_gadget_id, + "expected_gadget_id": target.gadget_id or None, + "failure": failure, + } + ready = standoff_distance <= self.spec.handoff_radius and failure is None + if ready: + self.handoff_agent_id = agent_id + return ready, snapshot + + def _handoff_ready(self) -> bool: + ready, _snapshot = self._handoff_snapshot() + return ready + + def _begin_handoff(self, snapshot: dict[str, Any]) -> InteractionStatus: + self.route_executor.cancel() + self.runtime.cancel_movement() + self._event("target_resolvable_from_standoff", **snapshot) + self.interaction_controller = ReliableInteractionController( + self.spec.interaction, + self.runtime, + ) + self.stage = "interaction" + self._event( + "handoff_to_interaction_controller", + final_approach_xy=self.spec.interaction.approach.player_approach_xy, + **snapshot, + ) + return InteractionStatus.RUNNING + + def tick(self) -> InteractionStatus: + if self.finished: + return self.result + try: + return self._tick() + except Exception as error: + self._event( + "boundary_runtime_validation_failed", + reason=f"{type(error).__name__}: {error}", + ) + if self.interaction_controller is not None and not self.interaction_controller.finished: + self.interaction_controller.cancel("boundary runtime exception") + return self._finish(False, "boundary runtime exception") + + def _tick(self) -> InteractionStatus: + if self.stage == "new": + self._event("route_boundary_entered", policy=self.spec.to_metadata()) + self.route_executor.start( + self.spec.route_points, + self.spec.route_tolerance, + self._handoff_ready, + ) + self.route_started = True + self.stage = "route" + + if self.stage == "route": + ready, snapshot = self._handoff_snapshot() + if ready: + return self._begin_handoff(snapshot) + + route_status = self.route_executor.tick() + if route_status != InteractionStatus.RUNNING: + # A coroutine route may report failure when its custom-exit + # predicate deliberately ended the route. Recheck readiness + # before interpreting the route's terminal status. + ready, snapshot = self._handoff_snapshot() + if ready: + return self._begin_handoff(snapshot) + reason = ( + "ordinary route completed without a resolvable handoff" + if route_status == InteractionStatus.SUCCESS + else "ordinary route failed before a resolvable handoff" + ) + self._event("boundary_runtime_validation_failed", reason=reason, **snapshot) + return self._finish(False, reason) + return InteractionStatus.RUNNING + + if self.stage == "interaction": + if self.interaction_controller is None: + self._event( + "boundary_runtime_validation_failed", + reason="interaction controller was not created", + ) + return self._finish(False, "interaction controller missing") + status = self.interaction_controller.tick() + if status == InteractionStatus.SUCCESS: + return self._finish(True, "route handoff and interaction succeeded") + if status == InteractionStatus.FAILURE: + return self._finish(False, "required interaction failed") + return InteractionStatus.RUNNING + + return self._finish(False, f"unknown boundary stage {self.stage!r}") + + +class GeneratorBoundaryRouteExecutor: + """Adapt a coroutine/FSM route generator to the boundary controller.""" + + def __init__( + self, + route_factory: Callable[ + [ + tuple[tuple[float, float], ...], + float, + Callable[[], bool], + ], + Generator[Any, Any, Any], + ], + ): + self.route_factory = route_factory + self.generator: Generator[Any, Any, Any] | None = None + self.finished = False + + def start( + self, + route_points: tuple[tuple[float, float], ...], + tolerance: float, + handoff_ready: Callable[[], bool], + ) -> None: + if self.generator is not None: + raise RuntimeError("boundary route executor was started more than once") + self.generator = self.route_factory(route_points, tolerance, handoff_ready) + + def tick(self) -> InteractionStatus: + if self.finished: + return InteractionStatus.SUCCESS + if self.generator is None: + raise RuntimeError("boundary route executor was ticked before start") + try: + next(self.generator) + return InteractionStatus.RUNNING + except StopIteration as stopped: + self.finished = True + return ( + InteractionStatus.SUCCESS + if stopped.value is not False + else InteractionStatus.FAILURE + ) + + def cancel(self) -> None: + generator = self.generator + self.generator = None + self.finished = True + if generator is not None: + generator.close() + + +def make_follow_path_boundary_executor( + follow_path: Callable[..., Generator[Any, Any, Any]], + **follow_path_kwargs: Any, +) -> GeneratorBoundaryRouteExecutor: + """Build a boundary executor around ``Routines.Yield.Movement.FollowPath``.""" + + def _route_factory( + route_points: tuple[tuple[float, float], ...], + tolerance: float, + handoff_ready: Callable[[], bool], + ) -> Generator[Any, Any, Any]: + return follow_path( + list(route_points), + custom_exit_condition=handoff_ready, + tolerance=tolerance, + **follow_path_kwargs, + ) + + return GeneratorBoundaryRouteExecutor(_route_factory) + + +def _local_route_auxiliary_active() -> bool: + """Observe Loot/Chest ownership only at adapter entry. + + The shared chest worker drives a controller directly, so keeping this gate + in route/mission adapters avoids making that worker observe its own inbox + transaction. + """ + + try: + from Py4GWCoreLib.routines_src.shared_command_coordination import ( + is_local_route_auxiliary_active, + ) + + return bool(is_local_route_auxiliary_active()) + except Exception: + return False + + +def _boundary_should_defer_for_auxiliary( + controller: InteractionBoundaryController, +) -> bool: + """Keep a boundary from handing off into a new interaction mid-command.""" + + if not _local_route_auxiliary_active(): + return False + interaction = controller.interaction_controller + return interaction is None or interaction.stage == "new" + + +def run_coroutine_adapter( + controller: ReliableInteractionController, + wait: Callable[[int], Generator[Any, Any, Any]], +) -> Generator[Any, Any, bool]: + """Run the shared controller inside a coroutine/FSM custom state.""" + + try: + while True: + if controller.stage == "new" and _local_route_auxiliary_active(): + yield from wait(max(10, controller.spec.retry.poll_ms)) + continue + status = controller.tick() + if status != InteractionStatus.RUNNING: + return status == InteractionStatus.SUCCESS + yield from wait(max(10, controller.spec.retry.poll_ms)) + finally: + if not controller.finished: + controller.cancel("coroutine closed") + + +def run_boundary_coroutine_adapter( + controller: InteractionBoundaryController, + wait: Callable[[int], Generator[Any, Any, Any]], +) -> Generator[Any, Any, bool]: + """Run the complete route boundary and interaction in a coroutine/FSM state.""" + + try: + while True: + if _boundary_should_defer_for_auxiliary(controller): + yield from wait(max(10, controller.spec.interaction.retry.poll_ms)) + continue + status = controller.tick() + if status != InteractionStatus.RUNNING: + return status == InteractionStatus.SUCCESS + yield from wait(max(10, controller.spec.interaction.retry.poll_ms)) + finally: + if not controller.finished: + controller.cancel("boundary coroutine closed") + + +class Py4GWInteractionRuntime: + """Active-runtime adapter. Persistence is delegated to the diagnostic sink.""" + + def __init__( + self, + *, + pause_automation: Callable[[], None], + restore_automation: Callable[[], None], + verify: Callable[[PostconditionSpec], bool], + diagnostic_sink: Callable[[dict[str, Any]], None] | None = None, + ): + self._pause = pause_automation + self._restore = restore_automation + self._verify = verify + self._sink = diagnostic_sink or (lambda _event: None) + + def now(self) -> float: + return time.monotonic() + + def pause_automation(self) -> None: + from Py4GWCoreLib.Py4GWcorelib import ActionQueueManager + + self._pause() + ActionQueueManager().ResetAllQueues() + + def restore_automation(self) -> None: + self._restore() + + def cancel_movement(self) -> None: + from Py4GWCoreLib.Py4GWcorelib import ActionQueueManager + + ActionQueueManager().ResetAllQueues() + + def close_stale_dialog(self) -> None: + from Py4GWCoreLib.UIManager import UIManager + from Py4GWCoreLib.enums_src.UI_enums import ControlAction + + if UIManager.IsNPCDialogVisible(): + keybind = ControlAction.ControlAction_CloseAllPanels.value + UIManager.Keydown(keybind, 0) + UIManager.Keyup(keybind, 0) + + def _all_agents(self) -> list[int]: + from Py4GWCoreLib.AgentArray import AgentArray + from Py4GWCoreLib.Context import GWContext + + shared = list(AgentArray.GetAgentArray() or []) + context = GWContext.AgentArray.GetContext() + context_agents = list(context.GetAgentArray() or []) if context is not None else [] + return list(dict.fromkeys([*shared, *context_agents])) + + def resolve_target(self, target: TargetSpec) -> int | None: + from Py4GWCoreLib.Agent import Agent + from Py4GWCoreLib.Player import Player + + origin = target.expected_xy or Player.GetXY() + wanted_name = target.name_contains.strip().lower() + candidates: list[int] = [] + for agent_id in self._all_agents(): + if not Agent.IsValid(agent_id): + continue + if target.kind == TargetKind.LIVING and not Agent.IsLiving(agent_id): + continue + if target.kind == TargetKind.NPC and not (Agent.IsLiving(agent_id) and Agent.IsNPC(agent_id)): + continue + if target.kind == TargetKind.NONLIVING_NPC and not ( + Agent.IsNPC(agent_id) and not Agent.IsLiving(agent_id) + ): + continue + if target.kind == TargetKind.GADGET and not Agent.IsGadget(agent_id): + continue + if target.kind == TargetKind.ITEM and not Agent.IsItem(agent_id): + continue + # Agent.GetModelID only resolves living agents. Ground-item agents + # require item-agent id -> item model id translation. + if target.model_id and self.agent_model_id(agent_id) != target.model_id: + continue + if target.gadget_id and int(Agent.GetGadgetID(agent_id) or 0) != target.gadget_id: + continue + if wanted_name and wanted_name not in str(Agent.GetNameByID(agent_id) or "").strip().lower(): + continue + if math.dist(origin, Agent.GetXY(agent_id)) > target.search_radius: + continue + candidates.append(int(agent_id)) + if not candidates: + return None + return min(candidates, key=lambda agent_id: math.dist(origin, Agent.GetXY(agent_id))) + + def agent_xy(self, agent_id: int) -> tuple[float, float]: + from Py4GWCoreLib.Agent import Agent + + xy = Agent.GetXY(agent_id) + return float(xy[0]), float(xy[1]) + + def agent_model_id(self, agent_id: int) -> int: + from Py4GWCoreLib.Agent import Agent + + if Agent.IsItem(agent_id): + from Py4GWCoreLib.Item import Item + + item_id = int(Agent.GetItemAgentItemID(agent_id) or 0) + if item_id <= 0: + return 0 + return int(Item.GetModelID(item_id) or 0) + return int(Agent.GetModelID(agent_id)) + + def agent_gadget_id(self, agent_id: int) -> int: + from Py4GWCoreLib.Agent import Agent + + if not Agent.IsGadget(agent_id): + return 0 + return int(Agent.GetGadgetID(agent_id) or 0) + + def player_xy(self) -> tuple[float, float]: + from Py4GWCoreLib.Player import Player + + xy = Player.GetXY() + return float(xy[0]), float(xy[1]) + + def move_to(self, xy: tuple[float, float]) -> None: + from Py4GWCoreLib.Player import Player + + Player.Move(*xy) + + def interact(self, agent_id: int) -> None: + from Py4GWCoreLib.Player import Player + + Player.ChangeTarget(agent_id) + Player.Interact(agent_id, call_target=False) + + def dialog_visible(self) -> bool: + from Py4GWCoreLib.UIManager import UIManager + + return bool(UIManager.IsNPCDialogVisible()) + + def dialog_button_count(self) -> int: + from Py4GWCoreLib.UIManager import UIManager + + return int(UIManager.GetDialogButtonCount()) + + def dialog_button_labels(self) -> tuple[str, ...]: + from Py4GWCoreLib.Dialog import get_active_dialog_buttons + + labels: list[str] = [] + for button in get_active_dialog_buttons(): + decoded = str(getattr(button, "message_decoded", "") or "").strip() + raw = str(getattr(button, "message", "") or "").strip() + labels.append(decoded or raw) + return tuple(labels) + + def scroll_dialog(self, delta: int) -> bool: + import PyMouse + + from Py4GWCoreLib.FrameTree import Frame, FrameId + + dialog = Frame(FrameId.NpcDialog) + if not dialog.exists or not dialog.is_visible: + return False + left, top, right, bottom = dialog.rect + if right <= left or bottom <= top: + return False + x = int((left + right) / 2) + y = int((top + bottom) / 2) + PyMouse.PyMouse().Scroll(int(delta), x, y) + return True + + def click_dialog_button(self, choice: int) -> bool: + from Py4GWCoreLib.UIManager import UIManager + + return bool(UIManager.ClickDialogButton(choice, debug=False)) + + def send_dialog(self, dialog_id: int) -> None: + from Py4GWCoreLib.Player import Player + + Player.SendDialog(dialog_id) + + def verify(self, postcondition: PostconditionSpec) -> bool: + return bool(self._verify(postcondition)) + + def is_hostile(self, agent_id: int) -> bool: + from Py4GWCoreLib.Agent import Agent + + allegiance = Agent.GetAllegiance(agent_id) + name = str(allegiance[1] if isinstance(allegiance, tuple) and len(allegiance) > 1 else allegiance) + return name.strip().lower() == "enemy" + + def emit(self, event: dict[str, Any]) -> None: + self._sink(dict(event)) + + +class BehaviorTreeBoundaryRouteExecutor: + """Adapt an existing BottingTree route to the boundary controller.""" + + def __init__(self, route_tree_factory: Callable[[tuple[tuple[float, float], ...], float], Any]): + self.route_tree_factory = route_tree_factory + self.route_tree: Any = None + + def start( + self, + route_points: tuple[tuple[float, float], ...], + tolerance: float, + _handoff_ready: Callable[[], bool], + ) -> None: + if self.route_tree is not None: + raise RuntimeError("BottingTree boundary route was started more than once") + self.route_tree = self.route_tree_factory(route_points, tolerance) + + def tick(self) -> InteractionStatus: + if self.route_tree is None: + raise RuntimeError("BottingTree boundary route was ticked before start") + state = self.route_tree.tick() + state_name = getattr(state, "name", "") + if state_name == "RUNNING": + return InteractionStatus.RUNNING + if state_name == "SUCCESS": + return InteractionStatus.SUCCESS + return InteractionStatus.FAILURE + + def cancel(self) -> None: + if self.route_tree is not None: + self.route_tree.reset() + self.route_tree = None + + +def build_botting_tree_action(spec: InteractionSpec, runtime: InteractionRuntime): + """Build a reset-safe BottingTree leaf without changing any legacy wrapper.""" + + from Py4GWCoreLib.py4gwcorelib_src.BehaviorTree import BehaviorTree + + controller_box = [ReliableInteractionController(spec, runtime)] + + class _ReliableInteractionActionNode(BehaviorTree.ActionNode): + def reset(self) -> None: + controller = controller_box[0] + if not controller.finished: + controller.cancel("BottingTree node reset") + controller_box[0] = ReliableInteractionController(spec, runtime) + super().reset() + + def _tick() -> BehaviorTree.NodeState: + controller = controller_box[0] + if controller.stage == "new" and _local_route_auxiliary_active(): + return BehaviorTree.NodeState.RUNNING + status = controller.tick() + if status == InteractionStatus.RUNNING: + return BehaviorTree.NodeState.RUNNING + if status == InteractionStatus.SUCCESS: + return BehaviorTree.NodeState.SUCCESS + return BehaviorTree.NodeState.FAILURE + + return BehaviorTree(_ReliableInteractionActionNode(name=spec.name, action_fn=_tick, aftercast_ms=0)) + + +def build_botting_tree_boundary_action( + spec: InteractionBoundarySpec, + runtime: InteractionRuntime, + route_tree_factory: Callable[[tuple[tuple[float, float], ...], float], Any] | None = None, +): + """Build a reset-safe BottingTree route-to-interaction composite.""" + + from Py4GWCoreLib.py4gwcorelib_src.BehaviorTree import BehaviorTree + + if route_tree_factory is None: + from Py4GWCoreLib.routines_src.behaviourtrees_src.movement import BTMovement + + def route_tree_factory( + route_points: tuple[tuple[float, float], ...], + tolerance: float, + ): + return BTMovement.MovePath(list(route_points), tolerance=tolerance) + + def _new_controller() -> InteractionBoundaryController: + return InteractionBoundaryController( + spec, + runtime, + BehaviorTreeBoundaryRouteExecutor(route_tree_factory), + ) + + controller_box = [_new_controller()] + + class _InteractionBoundaryActionNode(BehaviorTree.ActionNode): + def reset(self) -> None: + controller = controller_box[0] + if not controller.finished: + controller.cancel("BottingTree boundary node reset") + controller_box[0] = _new_controller() + super().reset() + + def _tick() -> BehaviorTree.NodeState: + if _boundary_should_defer_for_auxiliary(controller_box[0]): + return BehaviorTree.NodeState.RUNNING + status = controller_box[0].tick() + if status == InteractionStatus.RUNNING: + return BehaviorTree.NodeState.RUNNING + if status == InteractionStatus.SUCCESS: + return BehaviorTree.NodeState.SUCCESS + return BehaviorTree.NodeState.FAILURE + + return BehaviorTree( + _InteractionBoundaryActionNode( + name=spec.name, + action_fn=_tick, + aftercast_ms=0, + ) + ) diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_1_HouseZuHeltzer_To_SaintAnjekasShrine.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_1_HouseZuHeltzer_To_SaintAnjekasShrine.py new file mode 100644 index 000000000..09d368823 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_1_HouseZuHeltzer_To_SaintAnjekasShrine.py @@ -0,0 +1,137 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-173207-900. Recording timing and combat movement are omitted. +# Bridge points 32-34 are omitted so AutoPath crosses directly from the named Start to End markers. +_1_housezuheltzer_to_saintanjekasshrine_ids = { + 'outpost_id': outpost_name_to_id['House zu Heltzer'], + 'source_route_id': 'ROUTE-20260910-173207-900', +} + +_1_housezuheltzer_to_saintanjekasshrine_outpost_path = [ + (6522.00, 2529.00), + (6244.84, 2109.52), + (6031.08, 1654.17), + (6354.98, 1270.69), + (6728.85, 937.14), + (7058.37, 557.85), + (7383.68, 172.54), + (7703.61, -213.16), + (8022.33, -600.19), + (8321.10, -1006.08), + (8783.55, -1203.05), + (9284.68, -1251.14), + (9781.34, -1180.25), + (10280.28, -1109.03), +] + +_1_housezuheltzer_to_saintanjekasshrine_segments = [ + { + 'map_id': explorable_name_to_id['Ferndale'], + 'path': [ + (-13245.00, 14888.00), + (-12754.44, 15001.19), + (-12265.43, 14892.04), + (-12000.43, 14464.84), + (-11829.64, 13952.11), + (-11679.28, 13473.47), + (-11562.16, 12985.86), + (-11349.13, 12528.81), + (-11115.02, 12080.86), + (-10859.01, 11646.64), + (-10605.96, 11213.61), + (-10384.31, 10763.99), + (-10185.53, 10304.14), + (-9946.77, 9857.71), + (-9739.32, 9397.57), + (-9553.65, 8929.06), + (-9305.57, 8852.48), + (-7429.13, 8685.64), + (-6953.48, 8520.75), + (-6464.98, 8402.13), + (-6046.61, 8124.62), + (-5596.20, 7896.73), + (-5176.55, 7618.95), + (-4861.97, 7227.09), + (-4615.20, 6791.75), + (-4439.72, 6321.12), + (-4468.06, 5821.50), + (-4497.06, 5316.22), + (-4495.38, 4811.24), + (-4511.05, 4309.08), + (-4523.79, 3802.94), + (-4560.74, 3300.17), + (-4540.94, 2795.60), + (-4527.40, 2292.54), + (-4522.87, 1791.89), + (-4491.81, 1291.45), + (-4406.07, 792.02), + (-4285.29, 302.55), + (-4092.80, -166.16), + (-3885.55, -622.82), + (-3683.23, -1085.53), + (-3489.61, -1547.25), + (-3284.72, -2006.68), + (-3090.28, -2474.55), + (-2941.69, -2957.97), + (-2830.58, -3470.97), + (-2716.71, -3959.89), + (-2586.98, -4445.06), + (-2445.24, -4926.21), + (-2304.51, -5408.41), + (-2142.83, -5884.39), + (-1581.36, -6573.93), + (-1521.37, -7075.44), + (-1314.96, -7537.24), + (-1073.32, -7977.12), + (-823.82, -8414.70), + (-505.64, -8800.40), + (-141.13, -9148.92), + (223.75, -9493.41), + (692.57, -9667.92), + (1052.69, -10019.75), + (1394.66, -10390.33), + (1728.41, -10764.90), + (2058.06, -11146.50), + (2377.41, -11531.98), + (2609.66, -11975.57), + (2810.46, -12433.51), + (2883.74, -12929.08), + (2843.97, -13431.38), + (2738.47, -13926.46), + (2542.88, -14391.83), + (2364.16, -14864.55), + (2192.84, -15336.85), + (1954.05, -15782.20), + (1294.28, -16927.50), + (1077.71, -17380.10), + (711.33, -17723.01), + (307.46, -18018.54), + (-178.04, -18139.74), + (-664.43, -18271.99), + (-1139.27, -18438.53), + (-1603.92, -18634.11), + (-2100.85, -18691.74), + (-2606.50, -18877.20), + (-3038.41, -19138.18), + (-3521.92, -19324.84), + (-4004.23, -19478.77), + (-4484.53, -19622.42), + (-4972.96, -19734.48), + (-5463.39, -19851.87), + (-5950.58, -19992.42), + (-6420.77, -20174.23), + (-6884.04, -20363.80), + (-7318.50, -20611.97), + (-7735.72, -20894.21), + (-8153.29, -21176.69), + (-8626.53, -21339.14), + (-9118.17, -21440.42), + ], + }, + { + 'map_id': outpost_name_to_id["Saint Anjeka's Shrine"], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_2_SaintAnjekasShrine_To_LutgardisConservatory.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_2_SaintAnjekasShrine_To_LutgardisConservatory.py new file mode 100644 index 000000000..657d87dfa --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_2_SaintAnjekasShrine_To_LutgardisConservatory.py @@ -0,0 +1,97 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-173531-803. Recording timing and combat movement are omitted. +_2_saintanjekasshrine_to_lutgardisconservatory_ids = { + 'outpost_id': outpost_name_to_id["Saint Anjeka's Shrine"], + 'source_route_id': 'ROUTE-20260910-173531-803', + 'start_replacement_route_id': 'ROUTE-20260912-163352-904', +} + +_2_saintanjekasshrine_to_lutgardisconservatory_outpost_path = [ + (-9785.00, -21673.00), + # First captured Ferndale position supplies a stable portal heading when + # the character spawns exactly on the original single exit point. + (-9358.17, -21646.29), +] + +_2_saintanjekasshrine_to_lutgardisconservatory_segments = [ + { + 'map_id': explorable_name_to_id['Ferndale'], + 'path': [ + # Ferndale Temp Start from Shrine routes around the NPC cluster. + (-8901.00, -21640.40), + (-8501.25, -21333.81), + (-8105.00, -21024.71), + (-7712.53, -20708.52), + (-7319.51, -20390.70), + (-7037.39, -20465.64), + (-6580.32, -20262.31), + (-6123.27, -20058.92), + (-5649.65, -19848.15), + (-5179.61, -19669.32), + (-4694.64, -19524.81), + (-4219.73, -19360.72), + (-3736.26, -19207.72), + (-3229.69, -19189.42), + (-2746.53, -19043.63), + (-2281.23, -18850.65), + (-1798.50, -18706.66), + (-1346.71, -18479.45), + (-870.61, -18316.05), + (-428.98, -18073.28), + (62.40, -17958.24), + (544.82, -17803.20), + (1027.26, -17664.63), + (1480.90, -17445.62), + (1837.25, -17088.24), + (2498.65, -16241.58), + (2830.43, -15861.90), + (3099.75, -15432.71), + (3401.23, -15028.68), + (3664.24, -14596.53), + (3949.41, -14178.67), + (4345.58, -13868.16), + (4771.00, -13605.42), + (5262.21, -13491.96), + (5744.53, -13626.03), + (6195.83, -13843.83), + (6668.06, -14019.84), + (7174.80, -14018.08), + (7677.21, -14016.34), + (8041.50, -14363.72), + (8278.70, -14813.80), + (8449.84, -15285.01), + (8577.90, -15773.42), + (8923.53, -16141.73), + (8998.45, -16642.36), + (9264.60, -17069.51), + (9626.74, -17414.76), + (10104.57, -17562.73), + (10586.84, -17698.39), + (11083.82, -17762.18), + (11575.18, -17868.68), + (12054.89, -18017.74), + (12477.04, -18291.79), + (12890.52, -18580.18), + (13259.05, -18923.38), + (13574.72, -19312.85), + (13801.55, -19761.03), + (13978.96, -20230.31), + (14152.79, -20701.19), + (14331.83, -21169.64), + (14455.39, -21657.87), + (14599.79, -22140.66), + (14856.79, -22569.63), + (15218.48, -22918.99), + (15712.39, -23047.24), + (16216.68, -23021.02), + (16719.47, -22984.13), + ], + }, + { + 'map_id': outpost_name_to_id['Lutgardis Conservatory'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_3_SaintAnjekasShrine_To_BrauerAcademy.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_3_SaintAnjekasShrine_To_BrauerAcademy.py new file mode 100644 index 000000000..63adee5f9 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_3_SaintAnjekasShrine_To_BrauerAcademy.py @@ -0,0 +1,90 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-173849-995. Recording timing and combat movement are omitted. +_3_saintanjekasshrine_to_braueracademy_ids = { + 'outpost_id': outpost_name_to_id["Saint Anjeka's Shrine"], + 'source_route_id': 'ROUTE-20260910-173849-995', +} + +_3_saintanjekasshrine_to_braueracademy_outpost_path = [ + (-11322.10, -21906.63), + (-11284.04, -22405.23), + (-11237.56, -22907.09), +] + +_3_saintanjekasshrine_to_braueracademy_segments = [ + { + 'map_id': explorable_name_to_id['Drazach Thicket'], + 'path': [ + (-10531.00, 19116.00), + (-10053.32, 18968.21), + (-9556.83, 18902.44), + (-9065.22, 18806.03), + (-8570.09, 18712.68), + (-8087.68, 18556.35), + (-7647.73, 18298.86), + (-7256.80, 17980.52), + (-6907.03, 17602.31), + (-6560.53, 17234.94), + (-6190.98, 16889.41), + (-5810.06, 16560.24), + (-5378.41, 16299.57), + (-4100.35, 15647.26), + (-3635.57, 15845.87), + (-3135.15, 15895.82), + (-2649.13, 16017.88), + (-2145.37, 15965.15), + (-1624.20, 15777.12), + (-1223.98, 15476.20), + (-709.00, 15042.04), + (-233.90, 14880.80), + (262.92, 14965.26), + (744.41, 15113.26), + (1226.98, 15245.83), + (1730.16, 15280.50), + (2206.25, 15118.00), + (2690.06, 14990.81), + (3176.01, 14853.41), + (3674.15, 14770.58), + (4177.15, 14729.84), + (4673.36, 14812.38), + (5158.80, 14957.52), + (5642.05, 15105.32), + (6129.09, 15243.19), + (6629.62, 15316.38), + (7157.78, 15131.62), + (7514.87, 14777.31), + (7889.15, 14435.71), + (8262.12, 14101.73), + (8634.97, 13761.85), + (8993.22, 13405.93), + (9358.58, 13034.56), + (9717.78, 12683.59), + (10071.97, 12324.83), + (10424.86, 11969.05), + (10830.47, 11665.53), + (11284.80, 11448.41), + (11780.08, 11363.86), + (12276.58, 11286.12), + (12771.50, 11192.24), + (13225.59, 10966.58), + (13599.27, 10627.59), + (13948.80, 10269.28), + (14281.13, 9890.54), + (14509.66, 9443.54), + (14635.33, 8956.41), + (14725.49, 8458.63), + (14815.79, 7962.64), + (14906.00, 7467.07), + (14997.64, 6971.33), + (15092.01, 6476.10), + (15186.39, 5980.80), + ], + }, + { + 'map_id': outpost_name_to_id['Brauer Academy'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_4_UnwakingWatersKurzick_To_VasburgArmory.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_4_UnwakingWatersKurzick_To_VasburgArmory.py new file mode 100644 index 000000000..7bead3025 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_4_UnwakingWatersKurzick_To_VasburgArmory.py @@ -0,0 +1,151 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-174117-968. Recording timing and combat movement are omitted. +_4_unwakingwaterskurzick_to_vasburgarmory_ids = { + 'outpost_id': outpost_name_to_id['Unwaking Waters Kurzick'], + 'source_route_id': 'ROUTE-20260910-174117-968', +} + +_4_unwakingwaterskurzick_to_vasburgarmory_outpost_path = [ + (-8082.70, -1019.39), + (-8579.63, -1084.94), + (-9068.42, -1193.85), + (-9387.64, -1582.64), + (-9577.93, -2049.79), + (-9776.77, -2512.89), + (-10029.21, -2944.90), + (-10294.67, -3373.33), + (-10562.14, -3800.50), + (-10840.89, -4220.35), + (-11108.92, -4647.91), + (-11330.21, -5097.28), + (-11475.03, -5579.67), + (-11556.59, -6077.11), + (-11627.39, -6576.33), + (-11716.30, -7070.05), + (-12190.95, -7237.65), + (-12677.63, -7366.45), + (-13186.79, -7497.32), + (-13641.45, -7706.00), +] + +_4_unwakingwaterskurzick_to_vasburgarmory_segments = [ + { + 'map_id': explorable_name_to_id['Morostav Trail'], + 'path': [ + (22553.00, 13212.00), + (22119.06, 12959.60), + (21669.96, 12731.29), + (21202.58, 12549.97), + (20703.44, 12517.18), + (19328.57, 12450.31), + (18838.90, 12552.80), + (18391.17, 12324.29), + (18076.97, 11933.00), + (17768.35, 11530.79), + (17432.35, 11157.63), + (17093.26, 10782.61), + (16614.76, 10634.92), + (16126.04, 10748.83), + (15809.85, 11138.72), + (15341.77, 11328.91), + (14841.53, 11276.46), + (14473.58, 10937.14), + (14106.25, 10588.58), + (13694.53, 10303.80), + (13196.14, 10257.83), + (12696.05, 10263.15), + (12196.93, 10350.77), + (11738.23, 10565.87), + (11326.30, 10857.25), + (10920.49, 11158.55), + (10451.40, 11351.22), + (9989.80, 11555.70), + (9488.39, 11625.04), + (8986.96, 11643.59), + (7902.10, 11500.83), + (7412.68, 11603.80), + (6912.32, 11636.88), + (6407.64, 11643.15), + (5925.54, 11494.11), + (5449.15, 11329.48), + (4158.66, 10508.87), + (3716.30, 10263.83), + (3252.09, 10071.27), + (2798.87, 9858.20), + (2407.91, 9541.42), + (2076.55, 9160.18), + (1801.02, 8741.87), + (1336.77, 8540.02), + (847.10, 8437.02), + (429.34, 8157.92), + (109.67, 7764.34), + (-182.20, 7356.57), + (-491.56, 6955.78), + (-866.70, 6623.54), + (-1287.56, 6353.42), + (-1716.59, 6091.51), + (-2059.81, 5727.46), + (-2981.22, 5596.86), + (-2994.21, 5092.77), + (-3018.52, 4588.35), + (-3086.54, 4087.51), + (-3250.89, 3607.82), + (-3618.46, 3263.88), + (-4108.00, 3148.76), + (-4609.23, 3090.00), + (-5098.13, 3214.65), + (-5572.28, 3035.89), + (-6044.59, 2860.84), + (-6515.50, 2678.42), + (-6984.99, 2493.57), + (-7450.10, 2307.07), + (-7929.69, 2163.23), + (-8428.80, 2211.40), + (-8887.88, 2419.26), + (-9334.90, 2649.71), + (-9801.49, 2847.93), + (-10259.91, 3058.63), + (-10738.87, 3211.54), + (-11879.46, 2238.54), + (-12244.97, 1895.40), + (-12732.42, 1777.39), + (-13233.73, 1754.35), + (-13675.82, 1996.05), + (-13775.00, 2492.74), + (-14056.57, 2906.46), + (-13996.57, 3403.88), + (-14142.21, 3885.05), + (-14461.24, 4274.00), + (-14704.40, 4715.41), + (-14921.43, 5171.93), + (-15127.52, 5630.89), + (-15385.59, 6064.33), + (-15690.48, 6466.24), + (-16011.46, 6853.53), + (-16360.84, 7219.05), + (-16733.17, 7561.85), + (-17163.56, 8198.29), + (-17504.91, 8564.16), + (-17881.13, 8894.83), + (-18243.53, 9240.74), + (-18686.69, 9476.58), + (-19177.53, 9595.45), + (-19669.98, 9707.21), + (-20164.75, 9614.49), + (-20584.84, 9332.22), + (-20786.12, 8870.57), + (-20858.53, 8375.23), + (-20962.96, 7881.60), + (-21258.51, 7475.09), + (-21750.27, 7365.40), + (-22253.38, 7333.13), + ], + }, + { + 'map_id': outpost_name_to_id['Vasburg Armory'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_5_UnwakingWatersKurzick_To_DurheimArchives.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_5_UnwakingWatersKurzick_To_DurheimArchives.py new file mode 100644 index 000000000..de357d358 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_5_UnwakingWatersKurzick_To_DurheimArchives.py @@ -0,0 +1,110 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-174532-032. Recording timing and combat movement are omitted. +_5_unwakingwaterskurzick_to_durheimarchives_ids = { + 'outpost_id': outpost_name_to_id['Unwaking Waters Kurzick'], + 'source_route_id': 'ROUTE-20260910-174532-032', +} + +_5_unwakingwaterskurzick_to_durheimarchives_outpost_path = [ + (-9438.08, -431.91), + (-9556.00, -920.13), + (-9667.18, -1411.70), + (-9778.76, -1903.20), + (-9902.51, -2389.12), + (-10086.67, -2887.76), + (-10346.09, -3319.42), + (-10577.07, -3765.51), + (-10858.35, -4183.66), + (-11093.90, -4629.00), + (-11193.37, -5121.66), + (-11364.40, -5592.39), + (-11501.01, -6076.77), + (-11618.28, -6566.90), + (-11840.74, -7015.99), + (-12287.22, -7249.02), + (-12765.64, -7406.77), + (-13245.68, -7561.13), + (-13723.09, -7723.94), +] + +_5_unwakingwaterskurzick_to_durheimarchives_segments = [ + { + 'map_id': explorable_name_to_id['Morostav Trail'], + 'path': [ + (22553.00, 13212.00), + (22134.44, 12937.29), + (21678.98, 12720.90), + (21229.20, 12493.34), + (20759.64, 12311.85), + (20255.48, 12364.98), + (19186.55, 12542.53), + (18686.69, 12473.54), + (18310.58, 12143.96), + (18003.86, 11744.81), + (16939.14, 11138.80), + (16563.89, 10801.70), + (16202.23, 10447.53), + (15783.21, 10722.19), + (15579.36, 11186.04), + (15125.66, 11407.47), + (14657.88, 11224.36), + (14248.04, 10929.90), + (13836.75, 10636.62), + (13390.78, 10409.76), + (12952.61, 10166.65), + (12509.99, 9931.68), + (12166.69, 9560.74), + (11839.84, 9175.83), + (11525.69, 8777.67), + (11199.94, 8389.65), + (10821.96, 8054.23), + (10396.02, 7789.43), + (9977.88, 7507.20), + (9574.62, 7199.17), + (9190.58, 6871.92), + (8783.74, 6572.88), + (8645.93, 6080.25), + (8516.48, 5589.15), + (8372.39, 5110.18), + (8336.58, 4605.66), + (8440.38, 4112.00), + (8518.83, 3616.07), + (8544.03, 3111.93), + (8821.11, 1913.59), + (8947.51, 1423.39), + (9080.04, 934.54), + (9203.18, 448.05), + (9329.67, -39.82), + (9485.78, -515.44), + (9659.45, -992.01), + (9866.34, -1447.64), + (9999.39, -1936.45), + (10038.17, -2440.15), + (10179.33, -2924.32), + (10066.31, -3415.40), + (9856.40, -3875.59), + (9630.50, -4326.91), + (9344.16, -4738.44), + (9037.23, -5133.99), + (8733.48, -5537.97), + (8560.24, -6008.07), + (8503.01, -6506.24), + (8428.15, -7004.06), + (8380.66, -7506.46), + (8486.33, -7996.05), + (8680.28, -8461.39), + (8832.23, -8939.41), + (8826.06, -9441.82), + (8773.12, -9946.21), + (8480.66, -10356.90), + (8063.47, -10639.68), + ], + }, + { + 'map_id': outpost_name_to_id['Durheim Archives'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_6_VasburgArmory_To_AmatzBasin.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_6_VasburgArmory_To_AmatzBasin.py new file mode 100644 index 000000000..d252f4a40 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_6_VasburgArmory_To_AmatzBasin.py @@ -0,0 +1,141 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-174813-450. Recording timing and combat movement are omitted. +_6_vasburgarmory_to_amatzbasin_ids = { + 'outpost_id': outpost_name_to_id['Vasburg Armory'], + 'source_route_id': 'ROUTE-20260910-174813-450', +} + +_6_vasburgarmory_to_amatzbasin_outpost_path = [ + (21549.00, 6219.00), + (21079.30, 6036.82), + (20931.85, 5555.09), + (20450.74, 5416.78), + (19949.31, 5375.83), + (19445.31, 5375.17), + (18997.03, 5147.46), + (18878.07, 4660.27), + (18959.82, 4163.42), + (19104.41, 3681.30), + (19224.18, 3194.99), + (19146.78, 2700.70), + (18816.47, 2320.18), +] + +_6_vasburgarmory_to_amatzbasin_segments = [ + { + 'map_id': explorable_name_to_id['The Eternal Grove (explorable area)'], + 'path': [ + (17763.00, 1543.00), + (17261.94, 1501.45), + (16759.53, 1499.85), + (16257.86, 1469.30), + (15756.46, 1407.46), + (15287.91, 1225.80), + (14888.20, 915.46), + (14583.87, 499.46), + (14352.63, 54.66), + (14110.28, -387.93), + (13752.98, -743.62), + (13305.59, -978.54), + (12840.85, -1176.08), + (12374.30, -1363.20), + (11907.73, -1556.72), + (11443.80, -1752.94), + (10964.32, -1915.98), + (10489.07, -2085.36), + (10023.93, -2275.79), + (9575.81, -2507.70), + (9118.75, -2720.71), + (7933.38, -3300.16), + (7648.15, -3718.97), + (7502.21, -4198.51), + (7693.37, -4663.93), + (7941.76, -5102.62), + (8179.74, -5546.46), + (8388.80, -6001.40), + (8653.75, -6425.57), + (9087.60, -6679.58), + (9515.99, -6948.30), + (9946.12, -7214.54), + (10378.15, -7479.25), + (10817.57, -7721.52), + (11284.06, -7911.51), + (11728.75, -8154.08), + (12174.83, -8387.93), + (12605.51, -8649.04), + (13036.27, -8910.07), + (13111.19, -9407.00), + (13171.03, -9904.10), + ], + }, + { + 'map_id': explorable_name_to_id['Mourning Veil Falls'], + 'path': [ + (-8201.00, 11037.00), + (-7949.73, 10600.56), + (-7453.36, 10534.84), + (-6951.83, 10533.21), + (-6556.07, 10216.88), + (-6355.75, 9754.62), + (-6412.63, 9254.33), + (-6667.31, 8816.76), + (-6977.66, 8406.08), + (-7284.74, 8006.82), + (-7597.61, 7611.61), + (-7866.84, 7182.30), + (-8235.73, 6232.77), + (-8340.33, 5741.53), + (-8676.33, 5366.93), + (-9023.26, 4999.52), + (-9357.24, 4622.23), + (-9591.17, 4175.45), + (-9829.61, 3731.99), + (-10107.76, 3312.89), + (-10504.86, 3007.51), + (-10931.36, 2740.33), + (-11230.69, 2335.90), + (-11444.75, 1879.57), + (-11555.13, 1391.20), + (-11794.57, 951.01), + (-11767.69, 448.35), + (-11894.54, -39.48), + (-12068.33, -512.52), + (-12353.80, -925.72), + (-12749.04, -1236.98), + (-13221.73, -1056.00), + (-13693.99, -874.26), + (-14121.33, -606.78), + (-14545.42, -336.65), + (-15001.67, -130.66), + (-15502.88, -95.93), + (-15942.99, -337.02), + (-16246.04, -741.25), + (-16576.32, -1125.83), + (-16940.81, -1475.36), + (-17140.42, -2342.99), + (-17512.66, -2682.86), + (-17925.82, -2972.51), + (-18343.36, -3250.37), + (-18749.19, -3543.89), + (-19141.63, -3858.97), + (-19497.10, -4214.43), + (-19838.91, -4686.60), + (-19763.78, -5187.29), + (-19462.83, -5594.74), + (-19059.88, -5898.75), + (-18587.29, -6074.25), + (-18108.27, -6239.52), + (-17609.21, -6311.61), + (-17107.27, -6354.71), + (-16739.92, -6699.40), + (-16707.68, -7201.22), + ], + }, + { + 'map_id': outpost_name_to_id['Amatz Basin outpost'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_7_HouseZuHeltzer_To_AspenwoodGateKurzick.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_7_HouseZuHeltzer_To_AspenwoodGateKurzick.py new file mode 100644 index 000000000..8c3416990 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_7_HouseZuHeltzer_To_AspenwoodGateKurzick.py @@ -0,0 +1,119 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260910-213119-662. Recording timestamps and runtime-only agent data are omitted. +# The stale Ferndale map-entry sample still held the prior map's exit coordinate and is omitted. +_7_HouseZuHeltzer_To_AspenwoodGateKurzick_ids = { + "outpost_id": outpost_name_to_id["House zu Heltzer"], + "source_route_id": "ROUTE-20260910-213119-662", +} + + +_7_HouseZuHeltzer_To_AspenwoodGateKurzick_outpost_path = [ + (5992.97, 2934.04), + (5916.85, 2439.22), + (5889.20, 1939.74), + (6143.67, 1507.68), + (6520.11, 1176.59), + (6828.62, 773.75), + (7148.70, 388.99), + (7478.92, 10.99), + (7822.98, -376.01), + (8155.74, -750.31), + (8514.12, -1103.92), + (9001.91, -1218.69), + (9507.54, -1212.21), + (10008.14, -1153.80), +] + + +_7_HouseZuHeltzer_To_AspenwoodGateKurzick_segments = [ + { + "map_id": explorable_name_to_id["Ferndale"], + "path": [ + (-13245.00, 14888.00), + (-12845.14, 15196.89), + (-12440.18, 15493.48), + (-12025.41, 15778.61), + (-11685.75, 16155.47), + (-11439.37, 16595.48), + (-11143.80, 17002.97), + (-10902.11, 17442.73), + (-10579.47, 17830.55), + (-10176.46, 18127.47), + (-9893.48, 18545.64), + (-9466.26, 18816.56), + (-9028.54, 19074.59), + (-8575.09, 19290.88), + (-8113.31, 19497.04), + (-7613.12, 19637.60), + (-7125.03, 19757.43), + (-6630.47, 19670.95), + (-6178.92, 19452.33), + (-5748.65, 19194.51), + (-5275.52, 19031.07), + (-4776.16, 19114.26), + (-4286.08, 19239.00), + (-3816.52, 19419.40), + (-3352.21, 19616.53), + (-2864.45, 19741.04), + (-2366.75, 19833.64), + (-1903.93, 19637.98), + (-1489.36, 19356.42), + (-1156.68, 18980.62), + (-782.26, 18639.14), + (-490.90, 18231.16), + (-251.88, 17786.21), + (-61.52, 17320.38), + (96.53, 16843.75), + (264.87, 16366.82), + (466.71, 15888.24), + (704.39, 15446.22), + (1157.57, 15226.06), + (1653.88, 15133.79), + (2129.71, 14973.07), + (2596.25, 14777.20), + (3074.51, 14618.03), + (3552.52, 14464.75), + (4039.06, 14339.04), + (4535.11, 14262.14), + (5026.63, 14159.68), + (5516.85, 14052.57), + (6007.44, 13945.37), + (6502.90, 13837.11), + (6935.08, 13579.41), + (7438.67, 13542.63), + (7860.51, 13817.71), + (8233.86, 14153.71), + (8635.73, 14460.99), + (8930.04, 14871.89), + (9185.93, 15305.95), + (9442.94, 15736.94), + (9489.29, 16238.48), + (9885.74, 16553.50), + (10233.14, 16914.37), + (10523.75, 17322.74), + (10764.30, 17764.30), + (10991.74, 18212.71), + (11221.14, 18657.56), + (11443.42, 19106.00), + (11676.92, 19553.61), + (11908.20, 19998.16), + (12136.04, 20448.44), + (12373.97, 20893.14), + (12720.41, 21259.01), + (13104.35, 21580.41), + (13552.44, 21811.35), + (14054.37, 21852.38), + (14534.71, 21700.89), + (15013.42, 21555.80), + (15512.24, 21482.29), + (16012.39, 21549.31), + (16439.62, 21815.53), + ], + }, + { + "map_id": outpost_name_to_id["Aspenwood Gate - Kurzick"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_8_LutgardisConservatory_To_JadeFlatsKurzick.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_8_LutgardisConservatory_To_JadeFlatsKurzick.py new file mode 100644 index 000000000..216a10120 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_8_LutgardisConservatory_To_JadeFlatsKurzick.py @@ -0,0 +1,119 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260910-214257-099. Recording timestamps and runtime-only agent data are omitted. +_8_LutgardisConservatory_To_JadeFlatsKurzick_ids = { + "outpost_id": outpost_name_to_id["Lutgardis Conservatory"], + "source_route_id": "ROUTE-20260910-214257-099", +} + + +_8_LutgardisConservatory_To_JadeFlatsKurzick_outpost_path = [ + (-10315.51, 1452.36), + (-9843.21, 1283.51), + (-9590.20, 795.81), + (-9296.70, 384.51), + (-8807.13, 515.10), + (-8452.88, 868.99), + (-8132.13, 1256.77), + (-7810.42, 1644.36), +] + + +_8_LutgardisConservatory_To_JadeFlatsKurzick_segments = [ + { + "map_id": explorable_name_to_id["Melandru's Hope"], + "path": [ + (-7354.60, 2193.27), + (-7410.13, 2701.29), + (-7480.02, 3196.64), + (-7515.33, 3696.34), + (-7596.54, 4192.41), + (-7790.38, 4659.70), + (-8072.95, 5079.39), + (-8351.03, 5502.12), + (-8621.39, 5924.84), + (-8869.91, 6363.48), + (-9075.07, 6825.18), + (-9277.36, 7286.88), + (-9423.01, 7767.69), + (-9481.47, 8267.75), + (-9569.68, 8759.91), + (-9730.90, 9235.78), + (-9871.66, 9719.05), + (-9667.23, 10179.99), + (-9443.05, 10634.37), + (-9219.88, 11086.41), + (-8997.87, 11539.52), + (-8775.21, 11991.67), + (-8527.41, 12428.40), + (-8265.85, 12857.07), + (-7998.80, 13285.84), + (-7668.46, 13670.61), + (-7324.35, 14040.94), + (-6975.46, 14404.33), + (-6633.91, 14775.92), + (-6291.37, 15149.37), + (-5927.96, 15510.05), + (-5573.49, 15872.20), + (-5251.76, 16255.17), + (-5271.91, 16758.86), + (-5291.39, 17262.15), + (-5219.79, 17758.98), + (-5034.04, 18226.17), + (-4812.06, 18676.73), + (-4558.34, 19110.55), + (-4124.57, 19369.22), + (-3623.55, 19389.22), + (-3121.08, 19351.06), + (-2624.17, 19285.68), + (-2127.99, 19199.39), + (-1637.89, 19095.40), + (-1140.05, 19025.77), + (-649.74, 18897.68), + (-159.13, 18778.62), + (331.48, 18659.56), + (818.47, 18527.50), + (1306.29, 18400.47), + (1802.30, 18307.48), + (2183.60, 18631.33), + (2650.52, 18817.96), + (3120.66, 18642.37), + (3624.56, 18603.65), + (4110.59, 18477.16), + (4571.56, 18279.37), + (5073.36, 18320.55), + (5575.96, 18337.09), + (6072.66, 18405.75), + (6564.90, 18519.07), + (7070.35, 18635.00), + (7565.23, 18527.47), + (8062.03, 18463.35), + (8559.95, 18381.39), + (9058.46, 18299.34), + (9559.53, 18236.14), + (10063.87, 18278.33), + (10561.59, 18352.93), + (10970.88, 18647.08), + (11268.31, 19056.90), + (11560.22, 19469.26), + (11849.30, 19879.41), + (12151.28, 20279.53), + (12489.64, 20650.39), + (12827.54, 21019.55), + (13203.47, 21358.20), + (13595.34, 21672.77), + (13997.23, 21971.29), + (14400.95, 22267.33), + (14808.68, 22565.68), + (15241.97, 22820.36), + (15707.48, 23007.87), + (16184.50, 23171.91), + (16578.53, 23484.47), + ], + }, + { + "map_id": outpost_name_to_id["Jade Flats Kurzick"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_9_VasburgArmory_To_TheEternalGrove.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_9_VasburgArmory_To_TheEternalGrove.py new file mode 100644 index 000000000..6c2f77d4c --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Echovald Forest/_9_VasburgArmory_To_TheEternalGrove.py @@ -0,0 +1,80 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260914-211649-677. The automatic map-entry observations, +# failed point range 24-78, resurrection-shrine points 79-80, and destination +# outpost observations are omitted. The successful pass rejoins at point 81. +# Named points 75 and 78 are retained only as the direct bridge endpoints. +_9_vasburgarmory_to_theeternalgrove_ids = { + 'outpost_id': outpost_name_to_id['Vasburg Armory'], + 'source_route_id': 'ROUTE-20260914-211649-677', +} + +_9_vasburgarmory_to_theeternalgrove_outpost_path = [ + (21256.77, 8622.36), (21405.97, 8139.27), (21141.81, 7714.56), + (20712.84, 7450.84), (20330.05, 7128.08), (20146.04, 6661.27), + (19985.11, 6184.93), (19738.30, 5747.15), (19373.77, 5401.75), + (19045.07, 5023.40), (18851.97, 4561.31), (18994.95, 4077.42), + (19163.80, 3605.42), (19288.96, 3120.65), (19173.32, 2631.56), + (18817.39, 2273.60), +] + +_9_vasburgarmory_to_theeternalgrove_segments = [ + { + 'map_id': explorable_name_to_id['The Eternal Grove (explorable area)'], + 'steps': [ + { + 'type': 'path', + 'name': 'Vasburg portal to upper bridge approach', + 'path': [ + (17763.00, 1543.00), (17260.97, 1506.36), (16756.61, 1491.76), + (16252.32, 1474.81), (15751.34, 1462.81), (15286.81, 1272.31), + (14887.90, 969.00), (14487.56, 785.70), (14463.06, 282.15), + (14287.36, -190.48), (13911.62, -525.88), (13443.09, -700.82), + (12942.57, -707.76), (12513.30, -448.39), (12056.51, -234.76), + (11607.17, -14.88), (11146.31, 189.93), (10670.21, 354.28), + (10186.17, 499.18), (9710.01, 652.55), (9231.91, 804.05), + (8749.08, 952.06), (8274.55, 1126.62), (7846.40, 1394.33), + (7512.30, 1769.19), (7212.66, 2173.62), (6932.09, 2588.82), + (6656.00, 3010.65), (6392.97, 3440.32), (6162.13, 3888.10), + (5848.01, 4279.86), (5596.62, 4717.35), (5349.69, 5154.88), + (5274.64, 5650.99), (5326.00, 6154.22), (5371.90, 6654.97), + (5419.62, 7155.54), (5446.95, 7655.46), (5468.09, 8159.13), + (5457.37, 8666.35), (5439.06, 9170.16), (5424.45, 9672.84), + (5651.13, 10119.70), (6017.65, 10464.16), (6811.93, 11107.98), + (7012.70, 11568.25), (6928.60, 12062.71), (6928.02, 12568.58), + (6896.36, 13068.54), (6607.75, 13482.73), (6113.87, 13589.76), + (5608.37, 13586.78), (5115.70, 13481.06), (4630.15, 13350.32), + (4152.98, 13194.69), (3672.04, 13033.72), (3196.01, 12874.40), + (2723.31, 12706.91), (2279.64, 12463.12), (1888.33, 12141.23), + (1502.47, 11815.43), + ], + }, + { + 'type': 'direct_path', + 'name': 'Avoid Bridge Start to Avoid Bridge End', + 'path': [(1352.85, 11706.08), (-11.16, 10773.75)], + }, + { + 'type': 'path', + 'name': 'Lower bridge departure to The Eternal Grove outpost', + 'path': [ + (-14.30, 9929.33), (-185.53, 9386.43), (-530.99, 9016.75), + (-880.95, 8658.08), (-1250.94, 8321.32), (-1641.63, 8005.44), + (-2103.38, 7795.53), (-2604.13, 7757.63), (-3105.61, 7702.16), + (-3606.53, 7692.08), (-4103.78, 7777.60), (-4604.04, 7839.14), + (-5107.04, 7792.66), (-5611.54, 7713.91), (-7057.74, 7431.11), + (-7554.74, 7496.80), (-8059.15, 7549.37), (-8558.24, 7607.48), + (-9015.00, 7824.41), (-8658.68, 8177.08), (-8166.24, 8268.52), + (-7674.44, 8382.55), (-7181.02, 8502.95), (-6753.29, 8764.80), + (-6304.25, 9000.55), (-5858.88, 9232.14), (-5414.19, 9461.30), + ], + }, + ], + }, + { + 'map_id': outpost_name_to_id['The Eternal Grove outpost'], + 'steps': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Shing Jea Island/_1_ShingJeaMonastery_To_TsumeiVillage.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Shing Jea Island/_1_ShingJeaMonastery_To_TsumeiVillage.py new file mode 100644 index 000000000..758f1ecdf --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - Shing Jea Island/_1_ShingJeaMonastery_To_TsumeiVillage.py @@ -0,0 +1,96 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-181601-806. Recording timing and combat movement are omitted. +_1_shingjeamonastery_to_tsumeivillage_ids = { + 'outpost_id': outpost_name_to_id['Shing Jea Monastery'], + 'source_route_id': 'ROUTE-20260910-181601-806', +} + +_1_shingjeamonastery_to_tsumeivillage_outpost_path = [ + (-8798.12, 8553.83), + (-9271.61, 8392.28), + (-9382.92, 8880.04), + (-9519.71, 9364.12), + (-9745.64, 9813.15), + (-10074.23, 10191.34), + (-10532.09, 10399.28), + (-11031.10, 10331.55), + (-11523.30, 10223.27), + (-12012.61, 10105.61), + (-12516.74, 10091.34), + (-13005.95, 10212.55), + (-13471.86, 10397.52), + (-13898.27, 10666.39), + (-14313.04, 10951.70), + (-14728.76, 11237.66), +] + +_1_shingjeamonastery_to_tsumeivillage_segments = [ + { + 'map_id': explorable_name_to_id['Sunqua Vale'], + # Captured first position across the angled Tsumei portal. Extending the + # final two approach points in a straight line misses this boundary. + 'portal_exit_xy': (-4717.07, -13301.82), + 'path': [ + (20323.00, -8150.00), + (19901.76, -8431.54), + (19520.02, -8772.78), + (19114.53, -9065.90), + (18619.59, -9166.91), + (18128.98, -9038.40), + (17638.78, -8910.00), + (17151.09, -8782.25), + (16660.89, -8653.85), + (16191.81, -8838.55), + (15796.36, -9152.89), + (15340.27, -9369.48), + (14865.67, -9526.92), + (14396.95, -9714.14), + (13956.87, -9961.84), + (13511.31, -10201.30), + (13051.64, -10413.31), + (12590.70, -10617.51), + (12132.92, -10828.63), + (11677.01, -11042.80), + (11220.70, -11258.15), + (10761.34, -11465.84), + (10281.72, -11621.19), + (9792.67, -11745.30), + (9292.10, -11815.99), + (8812.40, -11669.66), + (8330.76, -11519.71), + (7852.15, -11361.31), + (7375.23, -11199.20), + (6879.08, -11113.82), + (6368.85, -11313.44), + (5919.05, -11546.64), + (5465.96, -11765.78), + (4997.31, -11948.89), + (4499.44, -12040.95), + (4027.87, -12226.48), + (3523.88, -12265.24), + (3023.35, -12190.24), + (2531.04, -12072.02), + (2038.60, -11966.79), + (1540.92, -11915.26), + (1041.54, -11879.10), + (538.98, -11875.25), + (39.97, -11911.17), + (-468.66, -12015.76), + (-953.06, -12158.20), + (-1445.13, -12269.78), + (-1937.52, -12379.99), + (-2429.69, -12476.51), + (-2922.04, -12573.64), + (-3415.11, -12678.32), + (-3908.34, -12788.56), + (-4392.07, -12929.78), + ], + }, + { + 'map_id': outpost_name_to_id['Tsumei Village'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_1_Cavalon_To_BreakerHollow.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_1_Cavalon_To_BreakerHollow.py new file mode 100644 index 000000000..b07d6e7cd --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_1_Cavalon_To_BreakerHollow.py @@ -0,0 +1,106 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260905-115546-323. Recording timestamps are intentionally +# omitted; the capture contains no route-shape backtracking to smooth out. +# The capture began away from Cavalon's map-travel spawn. Starting AutoPath at +# that captured position can choose the Zos Shivros portal, so the outpost leg +# uses Mission Runner's proven Cavalon-to-Archipelagos egress instead. +_1_cavalon_to_breakerhollow_ids = { + "outpost_id": outpost_name_to_id["Cavalon"], + "source_route_id": "ROUTE-20260905-115546-323", +} + +_1_cavalon_to_breakerhollow_outpost_path = [ + (5872.52, 742.10), + (6109.75, 417.17), + (6346.97, 92.24), + (6584.20, -232.68), + (6821.42, -557.61), + (7058.65, -882.54), + (7099.06, -1129.47), + (7139.48, -1376.40), + (7080.56, -1705.95), + (7021.64, -2035.50), + (6962.72, -2365.05), + (6762.00, -2712.46), + (6561.27, -3059.87), + (6360.55, -3407.28), + (6159.82, -3754.69), + (5959.10, -4102.10), + (5953.80, -4504.08), + (5948.50, -4906.07), + (5943.19, -5308.05), + (5937.89, -5710.04), + (5932.59, -6112.02), + (6050.01, -6414.61), + (6167.42, -6717.21), + (6284.84, -7019.80), + (6650.00, -7550.00), +] + +_1_cavalon_to_breakerhollow_segments = [ + { + "map_id": explorable_name_to_id["Archipelagos"], + "path": [ + (3970.00, 10743.00), + (3713.80, 10305.55), + (3359.02, 9952.92), + (2933.21, 9681.64), + (2468.58, 9481.25), + (1974.17, 9392.18), + (1477.29, 9472.43), + (977.77, 9553.10), + (481.65, 9633.23), + (-12.20, 9712.99), + (-510.97, 9793.54), + (-1006.24, 9864.77), + (-1505.43, 9922.87), + (-2004.05, 9980.91), + (-2503.49, 10023.59), + (-2997.92, 9945.78), + (-3487.33, 9838.13), + (-3964.28, 9675.03), + (-4405.85, 9434.86), + (-4854.48, 9203.66), + (-5331.83, 9041.23), + (-5795.90, 8854.45), + (-6263.95, 8667.33), + (-6734.28, 8477.77), + (-7212.80, 8316.48), + (-7715.08, 8291.21), + (-8223.32, 8283.54), + (-8710.41, 8432.74), + (-9171.20, 8640.04), + (-9639.73, 8824.70), + (-10123.38, 8960.36), + (-10629.46, 8977.97), + (-11134.76, 8995.55), + (-11624.72, 8882.88), + (-11725.48, 8389.75), + (-12027.17, 7986.81), + (-12312.36, 7574.57), + (-12646.07, 7193.74), + (-13107.06, 6987.16), + (-13608.90, 7040.61), + (-14070.91, 7242.43), + (-14512.66, 7482.07), + (-14954.40, 7721.72), + (-15396.90, 7961.78), + (-15840.92, 8202.66), + (-16300.27, 8411.19), + (-16745.14, 8648.19), + (-17185.80, 8895.40), + (-17626.36, 9142.55), + (-18066.42, 9389.42), + (-18504.09, 9634.66), + (-18941.57, 9878.48), + (-19381.66, 10119.34), + (-19687.43, 10303.75), + ], + }, + { + "map_id": outpost_name_to_id["Breaker Hollow"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_2_BreakerHollow_To_AspenwoodGateLuxon.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_2_BreakerHollow_To_AspenwoodGateLuxon.py new file mode 100644 index 000000000..b641df1cf --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_2_BreakerHollow_To_AspenwoodGateLuxon.py @@ -0,0 +1,98 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260905-120012-484. Recording timestamps are intentionally +# omitted; the capture contains no route-shape backtracking to smooth out. +_2_breakerhollow_to_aspenwoodgateluxon_ids = { + "outpost_id": outpost_name_to_id["Breaker Hollow"], + "source_route_id": "ROUTE-20260905-120012-484", +} + +_2_breakerhollow_to_aspenwoodgateluxon_outpost_path = [ + (19961.00, -4903.00), + (19521.92, -4661.62), + (19146.05, -4329.70), + (18776.92, -3989.50), + (18405.46, -3647.16), + (18037.18, -3307.75), + (17667.42, -2966.97), + (17299.14, -2627.55), + (16928.95, -2286.38), + (16856.95, -2220.02), +] + +_2_breakerhollow_to_aspenwoodgateluxon_segments = [ + { + "map_id": explorable_name_to_id["Mount Qinkai"], + "path": [ + (16542.65, -1827.29), + (16044.00, -1884.02), + (15633.16, -2174.96), + (15220.67, -2467.06), + (14810.53, -2757.50), + (14402.68, -3048.62), + (14002.60, -3351.43), + (13603.42, -3653.57), + (13204.24, -3955.71), + (12800.78, -4261.08), + (12405.41, -4570.66), + (12012.19, -4880.51), + (11615.33, -5192.97), + (11217.50, -5503.15), + (10824.05, -5812.71), + (10408.50, -6092.05), + (9995.04, -6374.03), + (9590.25, -6670.56), + (9184.14, -6967.89), + (8739.00, -7200.46), + (8284.76, -7416.80), + (7817.49, -7614.54), + (7407.21, -7900.45), + (6996.36, -8191.42), + (6553.78, -8424.13), + (6068.70, -8292.09), + (5727.23, -7924.80), + (5390.63, -7547.66), + (5001.56, -7222.85), + (4501.20, -7234.01), + (4020.30, -7371.74), + (3545.71, -7539.98), + (3115.17, -7796.13), + (2692.28, -8068.97), + (2262.43, -8332.96), + (1845.68, -8618.69), + (1412.38, -8884.79), + (947.10, -9070.63), + (459.17, -9194.16), + (-43.44, -9162.08), + (-544.17, -9111.08), + (-1027.21, -8974.43), + (-1508.00, -8817.67), + (-1991.88, -8666.10), + (-2482.57, -8555.70), + (-2970.68, -8437.26), + (-3452.59, -8299.79), + (-3938.49, -8166.33), + (-4441.70, -8121.40), + (-4937.81, -8041.63), + (-5422.84, -7914.54), + (-5908.11, -7787.40), + (-6397.22, -7659.25), + (-6883.67, -7530.31), + (-7361.02, -7378.04), + (-7835.39, -7547.93), + (-8256.28, -7826.99), + (-8436.09, -8298.54), + (-8496.90, -8797.01), + (-8558.17, -9299.23), + (-8618.93, -9797.32), + (-8643.18, -10297.00), + (-8620.51, -10800.53), + (-8463.00, -11082.56), + ], + }, + { + "map_id": outpost_name_to_id["Aspenwood Gate - Luxon"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_3_GyalaHatchery_To_EredonTerrace.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_3_GyalaHatchery_To_EredonTerrace.py new file mode 100644 index 000000000..a8b17aadd --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_3_GyalaHatchery_To_EredonTerrace.py @@ -0,0 +1,82 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-172546-445. Recording timing and combat movement are omitted. +# Point 55 was the resurrection-altar displacement; point 54 is stitched directly to point 56. +_3_gyalahatchery_to_eredonterrace_ids = { + 'outpost_id': outpost_name_to_id['Gyala Hatchery outpost'], + 'source_route_id': 'ROUTE-20260910-172546-445', +} + +_3_gyalahatchery_to_eredonterrace_outpost_path = [ + (773.41, 23059.35), + (1092.89, 23447.26), + (1482.23, 23763.97), + (1884.53, 24067.58), + (2285.15, 24371.48), + (2683.94, 24673.99), + (3085.48, 24978.59), + (3487.02, 25283.19), + (3884.22, 25587.27), +] + +_3_gyalahatchery_to_eredonterrace_segments = [ + { + 'map_id': explorable_name_to_id['Maishang Hills'], + 'path': [ + (14008.00, -10651.00), + (14442.60, -10399.34), + (14879.60, -10150.58), + (15322.26, -9917.65), + (15754.51, -9655.17), + (16157.08, -9351.01), + (16540.87, -9026.66), + (16920.51, -8695.40), + (17279.87, -8346.29), + (17622.20, -7977.00), + (17883.60, -7546.49), + (18124.62, -7104.18), + (18372.87, -6651.55), + (18616.56, -6210.71), + (18859.37, -5771.86), + (19088.01, -5321.64), + (19238.26, -4844.58), + (19399.17, -4364.52), + (19528.04, -3876.32), + (19557.88, -3373.74), + (19534.67, -2874.04), + (19439.58, -2377.27), + (19310.48, -1887.41), + (19154.18, -1410.38), + (18998.33, -934.72), + (18725.17, -417.69), + (18598.66, 69.69), + (18546.90, 568.88), + (18556.61, 1072.59), + (18654.94, 1565.04), + (18826.40, 2039.06), + (19004.37, 2507.03), + (19174.24, 2981.61), + (19343.69, 3456.74), + (19495.63, 3935.04), + (19585.48, 4430.40), + (19649.98, 4930.81), + (19724.41, 5432.24), + (19871.91, 5911.49), + (20019.18, 6389.35), + (20108.10, 6881.64), + (20173.47, 7380.81), + (20144.27, 7886.24), + (19913.64, 9387.18), + (19793.28, 9872.88), + (18892.01, 10907.63), + (18469.19, 11178.11), + (18254.09, 11636.79), + ], + }, + { + 'map_id': outpost_name_to_id['Eredon Terrace'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_4_EredonTerrace_To_BaiPaasuReach.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_4_EredonTerrace_To_BaiPaasuReach.py new file mode 100644 index 000000000..ef499a109 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_4_EredonTerrace_To_BaiPaasuReach.py @@ -0,0 +1,103 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-172852-363. Recording timing and combat movement are omitted. +_4_eredonterrace_to_baipaasureach_ids = { + 'outpost_id': outpost_name_to_id['Eredon Terrace'], + 'source_route_id': 'ROUTE-20260910-172852-363', +} + +_4_eredonterrace_to_baipaasureach_outpost_path = [ + (17886.17, 13172.65), + (17914.88, 12673.06), + (18094.23, 12203.09), + (18308.42, 11713.67), +] + +_4_eredonterrace_to_baipaasureach_segments = [ + { + 'map_id': explorable_name_to_id['Maishang Hills'], + 'path': [ + (18632.22, 11215.02), + (18403.45, 10769.29), + (17969.40, 10513.22), + (17522.62, 10283.49), + (17041.68, 10122.89), + (16563.27, 9958.47), + (16085.68, 9794.33), + (15609.07, 9635.83), + (15109.29, 9575.99), + (14604.49, 9579.01), + (14103.27, 9629.01), + (13606.05, 9688.37), + (13106.39, 9737.85), + (12606.26, 9730.21), + (12102.89, 9708.36), + (11614.92, 9594.60), + (11145.70, 9411.44), + (10707.42, 9161.02), + (10276.19, 8899.03), + (9845.70, 8637.50), + (9356.27, 8509.31), + (8864.29, 8604.62), + (8388.48, 8765.43), + (7908.58, 8921.38), + (7421.53, 9051.38), + (6931.42, 9178.40), + (6441.72, 9296.10), + (5949.49, 9405.07), + (5457.69, 9503.31), + (4961.22, 9602.61), + (4466.70, 9702.83), + (3972.39, 9801.91), + (3475.87, 9889.06), + (2978.21, 9966.56), + (2473.92, 9965.35), + (1978.81, 9879.11), + (1479.10, 9794.97), + (1006.27, 9629.95), + (545.97, 9420.71), + (79.82, 9222.64), + (-388.28, 9033.74), + (-830.86, 8791.31), + (-1236.38, 8489.68), + (-1664.79, 8221.15), + (-2114.72, 7998.76), + (-2615.08, 8025.05), + (-3118.79, 7978.77), + (-3498.22, 8306.89), + (-3756.76, 8735.43), + (-4038.47, 9150.35), + (-4451.06, 9436.39), + (-4910.08, 9644.19), + (-5342.83, 9895.26), + (-5714.64, 10234.95), + (-6020.29, 10636.28), + (-6402.79, 10963.17), + (-6878.27, 11132.25), + (-8030.62, 11095.73), + (-8529.27, 11054.31), + (-9030.29, 11059.68), + (-9536.03, 11070.97), + (-10036.00, 11110.51), + (-10531.20, 11197.34), + (-11027.67, 11300.91), + (-11516.64, 11423.12), + (-11975.21, 11631.65), + (-12406.80, 11890.19), + (-12833.22, 12165.91), + (-13250.29, 12449.59), + (-13677.24, 12716.32), + (-14129.84, 12937.59), + (-14605.55, 13105.58), + (-15081.57, 13271.61), + (-15557.18, 13437.50), + (-16034.01, 13603.82), + ], + }, + { + 'map_id': outpost_name_to_id['Bai Paasu Reach'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_5_UnwakingWatersLuxon_To_SeafarersRest.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_5_UnwakingWatersLuxon_To_SeafarersRest.py new file mode 100644 index 000000000..1d2a59f56 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_5_UnwakingWatersLuxon_To_SeafarersRest.py @@ -0,0 +1,101 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-175318-524. Recording timing and combat movement are omitted. +_5_unwakingwatersluxon_to_seafarersrest_ids = { + 'outpost_id': outpost_name_to_id['Unwaking Waters Luxon'], + 'source_route_id': 'ROUTE-20260910-175318-524', +} + +_5_unwakingwatersluxon_to_seafarersrest_outpost_path = [ + (13551.44, 7794.00), + (13948.43, 8103.21), + (14324.33, 8438.94), + (14700.22, 8774.68), + (15077.57, 9104.83), + (15459.85, 9433.27), + (15842.14, 9761.71), + (16224.17, 10086.89), + (16601.47, 10420.99), +] + +_5_unwakingwatersluxon_to_seafarersrest_segments = [ + { + 'map_id': explorable_name_to_id['Silent Surf'], + 'path': [ + (-9868.00, -13703.00), + (-9419.19, -13933.41), + (-8945.48, -14095.00), + (-8457.37, -14220.68), + (-7948.41, -14272.10), + (-7446.66, -14284.92), + (-6942.63, -14278.95), + (-6443.99, -14230.49), + (-5944.25, -14160.95), + (-5445.19, -14112.77), + (-4940.26, -14105.46), + (-4435.28, -14125.54), + (-3934.22, -14199.85), + (-3427.81, -14179.27), + (-3001.62, -13909.90), + (-2604.77, -13604.90), + (-2223.81, -13273.13), + (-1796.48, -13003.80), + (-1312.08, -12853.19), + (-820.88, -12948.73), + (-364.36, -13160.35), + (67.67, -13419.84), + (498.74, -13684.53), + (964.26, -13872.03), + (1467.50, -13836.46), + (1950.38, -13692.36), + (2428.44, -13545.64), + (3494.94, -13508.17), + (3820.86, -13121.43), + (4123.11, -12720.84), + (4436.21, -12330.38), + (4847.56, -12042.32), + (5307.68, -11844.95), + (5633.67, -11457.34), + (5877.76, -10952.68), + (6103.49, -10498.83), + (6375.53, -10078.33), + (6603.46, -9629.07), + (6851.22, -8739.05), + (6958.48, -8246.83), + (7145.85, -7782.20), + (7329.97, -7315.25), + (7623.20, -6904.69), + (7934.56, -6509.02), + (8235.81, -6104.42), + (8279.68, -5603.06), + (8370.16, -4841.72), + (8404.94, -4340.30), + (8480.28, -3845.20), + (8520.84, -3344.19), + (8496.10, -2839.11), + (8431.38, -2335.94), + (8390.01, -1831.35), + (8415.84, -1325.15), + (8566.78, -843.26), + (8852.17, -426.78), + (9195.44, -59.89), + (9649.57, 157.40), + (10131.60, 313.68), + (10630.85, 366.21), + (11127.45, 292.68), + (11606.50, 135.68), + (12084.51, -16.58), + (12586.53, -14.92), + (12939.23, 344.32), + (13253.24, 734.12), + (13588.92, 1109.34), + (13932.06, 1478.69), + ], + }, + { + 'map_id': outpost_name_to_id["Seafarer's Rest"], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_6_SeafarersRest_To_AuriosMines.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_6_SeafarersRest_To_AuriosMines.py new file mode 100644 index 000000000..0f13c9149 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_6_SeafarersRest_To_AuriosMines.py @@ -0,0 +1,109 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-175542-883. Recording timing and combat movement are omitted. +_6_seafarersrest_to_auriosmines_ids = { + 'outpost_id': outpost_name_to_id["Seafarer's Rest"], + 'source_route_id': 'ROUTE-20260910-175542-883', +} + +_6_seafarersrest_to_auriosmines_outpost_path = [ + (-12112.83, -19696.89), + (-11760.16, -19334.75), + (-11489.22, -18913.19), + (-11212.82, -18491.78), +] + +_6_seafarersrest_to_auriosmines_segments = [ + { + 'map_id': explorable_name_to_id["Rhea's Crater"], + 'path': [ + (-9980.00, -18074.00), + (-9524.97, -18283.13), + (-9033.66, -18157.40), + (-8635.14, -17855.21), + (-8274.89, -17501.95), + (-7872.23, -17198.63), + (-7458.60, -16915.26), + (-7033.75, -16639.35), + (-6156.33, -16211.53), + (-5669.65, -16342.19), + (-5284.08, -16664.51), + (-5043.54, -17108.86), + (-4548.02, -17213.68), + (-3954.59, -17193.54), + (-3515.84, -16944.09), + (-3122.39, -16629.42), + (-2714.79, -16339.13), + (-2271.71, -16099.58), + (-1836.98, -15844.35), + (-1395.74, -15600.54), + (-973.61, -15326.42), + (-644.66, -14949.06), + (-520.63, -14457.88), + (-614.31, -13963.60), + (-721.44, -13474.95), + (-1184.09, -12578.34), + (-1445.20, -12147.08), + (-1772.80, -11760.93), + (-2245.89, -11591.52), + (-2715.74, -11407.89), + (-3179.77, -11214.20), + (-3616.27, -10961.78), + (-3941.53, -10579.82), + (-4077.70, -10094.20), + (-4090.47, -9407.61), + (-4231.32, -8922.42), + (-3925.70, -8525.16), + (-3537.28, -8200.96), + (-3097.12, -7950.57), + (-2626.28, -7766.79), + (-2095.39, -7585.15), + (-1626.70, -7403.31), + (-1179.19, -7166.57), + (-767.23, -6880.38), + (-259.70, -6736.49), + (148.21, -6440.63), + (473.41, -6051.44), + (856.17, -5720.95), + (1244.17, -5395.01), + (1595.43, -5036.67), + (1803.21, -4578.76), + (1828.26, -4075.11), + (1851.98, -3571.97), + (1945.77, -3078.42), + (2308.06, -2732.46), + (2823.65, -2141.72), + (3249.03, -1868.78), + (3666.84, -1587.51), + (4090.72, -1314.73), + (4512.98, -1039.41), + (4937.48, -768.44), + (5392.69, -549.10), + (5875.73, -406.07), + (6357.25, -264.27), + (6843.29, -138.93), + (7334.57, -27.77), + (7832.45, 30.09), + (8333.16, 2.36), + (8827.10, -79.28), + (9304.54, -231.96), + (9757.12, -445.02), + (10211.07, -667.26), + (11110.56, -1159.47), + (11605.46, -1235.36), + (12094.21, -1350.89), + (12599.27, -1351.66), + (13100.32, -1301.82), + (13604.35, -1272.62), + (14108.15, -1265.22), + (14543.29, -1514.45), + (14848.44, -1915.57), + ], + }, + { + 'map_id': outpost_name_to_id['The Aurios Mines outpost'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_7_HarvestTemple_To_UnwakingWatersKurzick.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_7_HarvestTemple_To_UnwakingWatersKurzick.py new file mode 100644 index 000000000..eaffe3abd --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_7_HarvestTemple_To_UnwakingWatersKurzick.py @@ -0,0 +1,146 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-180114-736. Points 79-80 were combat/teleport reversal noise. +# Dedrick pages 1 and 3 are persisted NPC captures. Page 2 is user-supplied screenshot evidence. +_7_harvesttemple_to_unwakingwaterskurzick_ids = { + 'outpost_id': outpost_name_to_id['Harvest Temple'], + 'source_route_id': 'ROUTE-20260910-180114-736', +} + +_7_harvesttemple_to_unwakingwaterskurzick_outpost_path = [ + (3378.00, 2747.00), + (3320.00, 2010.00), +] + +_7_harvesttemple_to_unwakingwaterskurzick_segments = [ + { + 'map_id': explorable_name_to_id['Unwaking Waters (explorable area)'], + 'steps': [ + { + 'type': 'path', + 'name': 'Harvest Temple portal to Gatekeeper Dedrick', + 'path': [ + (3464.00, 1917.00), + (3917.59, 2167.83), + (4282.25, 2516.41), + (4577.71, 2927.63), + (4739.91, 3402.48), + (4665.32, 3898.39), + (4467.93, 4363.16), + (4112.15, 4720.94), + (3796.66, 5117.45), + (3352.80, 5349.55), + (2852.88, 5335.90), + (2348.95, 5305.26), + (1884.31, 5114.40), + (1500.28, 4788.31), + (1177.21, 4404.72), + (1102.54, 3906.52), + (1006.87, 3409.71), + (1013.45, 2909.43), + (1036.59, 2402.87), + (1093.39, 1904.71), + (1294.60, 1445.57), + (1237.07, 944.08), + (1520.03, 528.15), + (1811.41, 118.60), + (2182.36, -220.46), + (2564.45, -545.26), + (3002.45, -790.11), + (3459.46, -1000.01), + (3964.84, -987.97), + (4466.53, -918.06), + (4962.77, -855.69), + (5460.96, -797.48), + (5957.78, -717.35), + (6450.47, -611.71), + (7009.22, -441.15), + (7217.91, 16.15), + (7620.19, 320.77), + (7978.60, 674.42), + (8278.44, 1078.53), + (8552.69, 1498.47), + (8757.56, 1958.87), + (8894.16, 2444.66), + (9080.83, 2909.91), + (9375.00, 3322.04), + (9587.25, 3784.54), + (9544.28, 4284.22), + (9189.14, 4640.74), + (8786.17, 4938.61), + (8379.48, 5239.23), + (7984.66, 5550.07), + (7504.20, 5700.47), + (7113.12, 6016.28), + (6746.51, 6364.92), + (6387.30, 6714.31), + (6027.84, 7063.92), + (5631.41, 7376.05), + (5198.94, 7635.39), + (4803.58, 7946.22), + (4331.07, 8111.22), + (3837.20, 8219.83), + (3334.61, 8270.67), + (2839.98, 8165.62), + (2354.26, 8309.50), + (1856.18, 8232.44), + (1360.17, 8133.40), + (878.66, 7988.24), + (422.15, 7777.62), + (-23.47, 7536.39), + (-485.67, 7342.50), + (-923.82, 7090.20), + (-1342.13, 6816.13), + (-1784.99, 6576.75), + (-2230.04, 6348.79), + (-2669.48, 6106.61), + (-3083.53, 5816.64), + (-3506.39, 5543.48), + (-3817.59, 5024.07), + (-4243.82, 4747.87), + (-4631.40, 4429.17), + (-5088.56, 4172.94), + (-5532.84, 3941.19), + (-6003.56, 3771.59), + (-6484.31, 3625.27), + (-6956.61, 3451.09), + (-7375.40, 3173.65), + (-7800.77, 2910.05), + (-8256.71, 2697.78), + (-8579.77, 2307.58), + (-8754.02, 1833.03), + (-9143.96, 1516.47), + (-9466.32, 1128.29), + (-9828.58, 774.72), + (-10228.06, 467.43), + ], + }, + { + 'type': 'npc_dialog_sequence', + 'name': 'Gatekeeper Dedrick transfer to Unwaking Waters Kurzick', + 'model_id': 3435, + 'target_name': 'Gatekeeper Dedrick', + 'target_xy': (-10449.00, -55.00), + 'player_approach_xy': (-10429.37, 21.51), + 'visible_button_labels': ( + 'We have business in Unwaking Waters.', + 'We are ready.', + 'Yes.', + ), + 'target_map_id': outpost_name_to_id['Unwaking Waters Kurzick'], + 'capture_ids': ( + 'NPC-20260910-180722-960', + 'NPC-20260910-180800-857', + 'ROUTE-20260910-180114-736', + ), + 'middle_choice_source': 'user screenshot: Enter 2', + }, + ], + }, + { + 'map_id': outpost_name_to_id['Unwaking Waters Kurzick'], + 'steps': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_8_Cavalon_To_JadeFlatsLuxon.py b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_8_Cavalon_To_JadeFlatsLuxon.py new file mode 100644 index 000000000..777ca347b --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/Cantha - The Jade Sea/_8_Cavalon_To_JadeFlatsLuxon.py @@ -0,0 +1,128 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260910-181100-432. Recording timing and combat movement are omitted. +# Cavalon's already-proven Archipelagos egress is reused to avoid the nearby wrong portal. +_8_cavalon_to_jadeflatsluxon_ids = { + 'outpost_id': outpost_name_to_id['Cavalon'], + 'source_route_id': 'ROUTE-20260910-181100-432', +} + +_8_cavalon_to_jadeflatsluxon_outpost_path = [ + (5872.52, 742.10), + (6109.75, 417.17), + (6346.97, 92.24), + (6584.20, -232.68), + (6821.42, -557.61), + (7058.65, -882.54), + (7099.06, -1129.47), + (7139.48, -1376.40), + (7080.56, -1705.95), + (7021.64, -2035.50), + (6962.72, -2365.05), + (6762.00, -2712.46), + (6561.27, -3059.87), + (6360.55, -3407.28), + (6159.82, -3754.69), + (5959.10, -4102.10), + (5953.80, -4504.08), + (5948.50, -4906.07), + (5943.19, -5308.05), + (5937.89, -5710.04), + (5932.59, -6112.02), + (6050.01, -6414.61), + (6167.42, -6717.21), + (6284.84, -7019.80), + (6650.00, -7550.00), +] + +_8_cavalon_to_jadeflatsluxon_segments = [ + { + 'map_id': explorable_name_to_id['Archipelagos'], + 'path': [ + (3970.00, 10743.00), + (3669.70, 10338.50), + (3356.52, 9943.32), + (3107.74, 9507.82), + (2908.28, 9041.99), + (2806.25, 8546.67), + (2697.12, 8056.38), + (2525.50, 7580.20), + (2358.43, 7103.50), + (2112.90, 6662.30), + (1778.52, 6284.55), + (1347.21, 6028.84), + (904.67, 5788.26), + (470.55, 5530.59), + (50.74, 5251.45), + (-363.22, 4963.83), + (-767.11, 4657.10), + (-1174.87, 4360.74), + (-1597.91, 4085.18), + (-2028.22, 3823.35), + (-2476.73, 3591.02), + (-2979.74, 3556.39), + (-3447.46, 3744.25), + (-3908.18, 3946.70), + (-4371.71, 4145.57), + (-4823.00, 4361.40), + (-5271.34, 4588.86), + (-5732.76, 4795.14), + (-6227.48, 4878.48), + (-6717.74, 5004.98), + (-7242.44, 5141.42), + (-7743.89, 5202.79), + (-8240.41, 5114.78), + (-8693.54, 4899.85), + (-9150.69, 4684.36), + (-9593.07, 4449.63), + (-9957.80, 4103.60), + (-10276.42, 3683.23), + (-10588.01, 3285.88), + (-10940.77, 2925.95), + (-11311.04, 2589.52), + (-11737.19, 2317.74), + (-12175.66, 2066.08), + (-12639.24, 1802.00), + (-12864.00, 1350.72), + (-12832.23, 851.53), + (-12471.45, 505.11), + (-12255.48, 53.14), + (-12019.63, -389.84), + (-11625.55, -698.67), + (-11204.54, -978.18), + (-10716.26, -1102.14), + (-10228.58, -1228.31), + (-9736.94, -1326.78), + (-9334.91, -1627.02), + (-9051.18, -2046.42), + (-8755.60, -2454.15), + (-8480.24, -2878.84), + (-8210.59, -3304.75), + (-7959.73, -3742.04), + (-7640.66, -4143.77), + (-7197.34, -4377.39), + (-6388.17, -4743.02), + (-5993.02, -5054.15), + (-5607.97, -5379.51), + (-5205.23, -5687.70), + (-4837.30, -6033.68), + (-4596.64, -6476.36), + (-4881.82, -6894.81), + (-5233.83, -7259.57), + (-5669.51, -7517.01), + (-6154.04, -7656.26), + (-6575.92, -7925.07), + (-6542.63, -8424.06), + (-6395.52, -8906.24), + (-6241.49, -9387.19), + (-6088.29, -9864.62), + (-5935.08, -10342.05), + ], + }, + { + 'map_id': outpost_name_to_id['Jade Flats Luxon'], + 'path': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Desolation/_1_BonePalace_To_BasaltGrotto.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Desolation/_1_BonePalace_To_BasaltGrotto.py new file mode 100644 index 000000000..a93e9c310 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Desolation/_1_BonePalace_To_BasaltGrotto.py @@ -0,0 +1,205 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# Capture ROUTE-20260902-140340-849. Recording-time pauses are omitted, and +# the short point-43-to-50 reversal near the sulfur boundary is combat noise. +# Stay mounted through that exposed boundary; rocky ground later removes the +# Junundu disguise naturally before the second Wurm Spoor. +_1_bonepalace_to_basaltgrotto_ids = { + 'outpost_id': outpost_name_to_id['Bone Palace'], + 'source_route_id': 'ROUTE-20260902-140340-849', +} + +_1_bonepalace_to_basaltgrotto_outpost_path = [ + (-16182.79, 7384.88), + (-15750.76, 7127.34), + (-15401.01, 6767.79), + (-15045.80, 6411.07), + (-14762.80, 5997.11), + (-14721.83, 5498.20), + (-14696.80, 4998.10), + (-14672.30, 4494.12), + (-14648.73, 3993.74), + (-14625.10, 3492.15), +] + +_1_bonepalace_to_basaltgrotto_segments = [ + { + 'map_id': explorable_name_to_id["Joko's Domain"], + 'steps': [ + { + 'type': 'path', + 'name': 'Bone Palace portal to Margonite Battle shrine', + 'path': [ + (-14536.00, 2824.00), + (-14052.67, 2681.04), + (-13553.58, 2621.36), + (-13050.13, 2619.17), + (-12568.56, 2755.12), + (-12473.72, 3251.41), + (-12519.90, 3755.53), + ], + }, + { + 'type': 'blessing', + 'name': 'Joko\'s Domain Margonite Battle', + 'model_id': 5666, + 'target_name': 'Ghostly Priest', + 'target_xy': (-12536.00, 3878.00), + 'player_approach_xy': (-12526.54, 3806.04), + 'visible_button': 1, + 'effect_ids': (1849, 2036, 2037), + 'capture_id': 'NPC-20260902-140420-526', + }, + { + 'type': 'path', + 'name': 'Margonite Battle shrine to first Wurm Spoor', + 'path': [ + (-12112.71, 4052.32), + (-11627.41, 4175.32), + (-11133.48, 4256.99), + (-10686.65, 4481.70), + ], + }, + { + 'type': 'enter_junundu', + 'name': 'Enter first Junundu party', + 'target_xy': (-10867.00, 4322.00), + 'party_regroup_xy': (-10867.00, 4322.00), + 'party_regroup_radius': 300.0, + 'player_approach_xy': (-10867.00, 4322.00), + 'capture_id': 'user-screenshot-wurm-spoor-bone-palace', + }, + { + 'type': 'path', + 'name': 'First Wurm Spoor to exposed sulfur boundary', + 'path': [ + (-10193.48, 4572.06), + (-9698.72, 4672.54), + (-9206.21, 4772.56), + (-8716.40, 4885.37), + (-8247.13, 5063.73), + (-7808.89, 5306.76), + (-7363.34, 5539.06), + (-6882.98, 5689.70), + (-6413.25, 5861.69), + (-5950.28, 6057.14), + (-5528.45, 6328.67), + (-5135.16, 6638.22), + (-4749.51, 6957.19), + (-4446.18, 7357.48), + (-4169.36, 7776.92), + (-3895.72, 8201.18), + (-3539.93, 8556.82), + (-3250.92, 8629.57), + ], + }, + { + 'type': 'path', + 'name': 'Stay in Junundu across boundary to second Wurm Spoor', + 'path': [ + (-2783.64, 9224.85), + (-2532.57, 9661.27), + (-2390.27, 10142.06), + (-2247.34, 10625.07), + (-1800.66, 10359.92), + (-1667.54, 10846.15), + (-1520.06, 11328.57), + (-1503.78, 11853.68), + (-1435.90, 12351.82), + (-1216.08, 12815.56), + (-720.18, 12703.89), + (-392.40, 12316.91), + (-69.22, 11930.60), + (254.23, 11544.71), + (585.46, 11163.74), + (917.49, 10787.52), + (1267.99, 10425.87), + (1736.03, 10249.34), + (2224.63, 10378.54), + (2521.61, 10784.28), + (2756.32, 11227.92), + (3005.30, 11662.97), + (3248.19, 12102.04), + (3558.00, 12502.49), + (3973.62, 12816.65), + (4430.51, 13027.01), + (4930.16, 13077.12), + (5419.85, 12955.49), + (5689.03, 12531.82), + (5920.46, 12083.14), + (6152.24, 11633.78), + (6384.19, 11184.08), + (6616.32, 10734.04), + (6847.89, 10285.08), + ], + }, + { + 'type': 'enter_junundu', + 'name': 'Enter second Junundu party', + 'target_xy': (6997.00, 9996.00), + 'party_regroup_xy': (6997.00, 9996.00), + 'party_regroup_radius': 300.0, + 'party_regroup_settle_ms': 1_500, + 'mount_attempts': 3, + 'player_approach_xy': (6997.00, 9996.00), + 'capture_id': 'user-screenshot-wurm-spoor-basalt-route', + }, + { + 'type': 'path', + 'name': 'Second Wurm Spoor to rocky-ground exit', + 'path': [ + (7303.42, 10067.84), + (7803.37, 10022.48), + (8304.12, 10041.91), + (8674.54, 10379.92), + (9143.23, 10563.90), + (9483.03, 10937.30), + (9864.34, 11285.42), + (10313.29, 11506.04), + (10775.01, 11708.83), + (11239.10, 11912.66), + (11700.29, 12115.22), + (12158.80, 12321.93), + (12599.40, 12566.62), + (13027.14, 12835.74), + (13401.18, 13169.06), + (13800.63, 13475.50), + (14188.55, 13792.79), + (14380.65, 14254.58), + (14846.34, 14446.71), + (15328.79, 14587.36), + (15831.35, 14638.47), + (16333.04, 14656.30), + (16824.82, 14751.72), + (17317.48, 14850.91), + (17792.37, 14693.67), + (18155.67, 14412.56), + ], + }, + { + 'type': 'verify_human_form', + 'name': 'Forced to unwurm here due to rocks', + 'capture_id': 'LOC-20260902-141142-793', + 'timeout_s': 60.0, + }, + { + 'type': 'path', + 'name': 'Rocky-ground exit to Basalt Grotto portal', + 'path': [ + (18445.94, 13947.25), + (18828.12, 13618.30), + (19330.22, 13608.27), + (19824.05, 13717.19), + (20120.74, 14124.99), + (20312.43, 14591.19), + ], + }, + ], + }, + { + 'map_id': outpost_name_to_id['Basalt Grotto'], + 'steps': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Desolation/_2_BonePalace_To_LairOfTheForgotten.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Desolation/_2_BonePalace_To_LairOfTheForgotten.py new file mode 100644 index 000000000..6bb336dbb --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Desolation/_2_BonePalace_To_LairOfTheForgotten.py @@ -0,0 +1,231 @@ +from Py4GWCoreLib.enums import explorable_name_to_id +from Py4GWCoreLib.enums import outpost_name_to_id + + +# The Bone Palace-to-Shattered Ravines prefix is the live-proven Horde of +# Darkness route. Capture ROUTE-20260910-151459-766 continues from the exact +# Shattered Ravines Wurm Spoor; points 33-46 were a wrong turn and its return. +_2_bonepalace_to_lairoftheforgotten_ids = { + 'outpost_id': outpost_name_to_id['Bone Palace'], + 'source_route_id': 'ROUTE-20260910-151459-766', + 'prefix_source_route_id': 'ROUTE-20260905-170837-570', +} + +_2_bonepalace_to_lairoftheforgotten_outpost_path = [ + (-14320.86, 6356.66), + (-14399.16, 5858.59), + (-14445.26, 5347.44), + (-14490.47, 4846.13), + (-14535.82, 4343.31), + (-14580.81, 3844.41), +] + +_2_bonepalace_to_lairoftheforgotten_segments = [ + { + 'map_id': explorable_name_to_id["Joko's Domain"], + 'steps': [ + { + 'type': 'path', + 'name': "Bone Palace portal to Joko's Domain shrine", + 'path': [ + (-14536.00, 2824.00), + (-14066.45, 2650.48), + (-13578.56, 2507.44), + (-13070.96, 2544.19), + (-12577.43, 2667.13), + (-12455.59, 3157.18), + (-12512.12, 3656.34), + ], + }, + { + 'type': 'blessing', + 'name': "Joko's Domain Margonite Battle", + 'model_id': 5666, + 'target_name': 'Ghostly Priest', + 'target_xy': (-12536.00, 3878.00), + 'player_approach_xy': (-12525.57, 3792.71), + 'visible_button': 1, + 'effect_ids': (1849, 2036, 2037), + 'capture_id': 'NPC-20260905-170952-925', + }, + { + 'type': 'path', + 'name': "Joko's Domain shrine to Wurm Spoor", + 'path': [ + (-12106.01, 3975.44), + (-11596.90, 4113.14), + (-11120.08, 4264.57), + (-10648.91, 4438.51), + ], + }, + { + 'type': 'enter_junundu', + 'name': "Enter Joko's Domain Junundu party", + 'target_xy': (-10867.00, 4322.00), + 'party_regroup_xy': (-10867.00, 4322.00), + 'party_regroup_radius': 300.0, + 'player_approach_xy': (-10867.00, 4322.00), + 'capture_id': 'user-screenshot-wurm-spoor-bone-palace', + }, + { + 'type': 'path', + 'name': "Joko's Domain Wurm Spoor to Shattered Ravines portal", + 'path': [ + (-10159.56, 4554.17), + (-9672.31, 4681.88), + (-9178.48, 4793.22), + (-8690.12, 4904.54), + (-8217.05, 5086.05), + (-7920.85, 5490.39), + (-7781.35, 5976.32), + (-7753.91, 6478.35), + (-7690.81, 6976.92), + (-7640.29, 7476.89), + (-7649.14, 7977.10), + (-7704.86, 8495.26), + (-7768.31, 8993.87), + (-7815.96, 9496.47), + (-7918.51, 9994.74), + (-8147.73, 10450.92), + (-8380.48, 10896.76), + (-8568.25, 11372.58), + (-8624.21, 11877.20), + (-8392.24, 12324.11), + (-8101.93, 12734.17), + (-7806.46, 13147.07), + (-7455.66, 12788.34), + (-7164.40, 12373.76), + (-6885.79, 11958.56), + (-6888.55, 12466.86), + (-6758.28, 12961.96), + (-6647.90, 13456.55), + (-6524.83, 13948.54), + (-6409.13, 14441.40), + (-6303.97, 14931.75), + (-6183.28, 15425.85), + (-6041.28, 15915.57), + (-5875.77, 16388.27), + (-5665.37, 16847.51), + (-5440.62, 17295.22), + (-5206.96, 17738.09), + (-4936.13, 18164.44), + (-4706.15, 18609.66), + (-4548.36, 19088.01), + (-4518.90, 19590.08), + (-4518.16, 20095.77), + ], + }, + ], + }, + { + 'map_id': explorable_name_to_id['The Shattered Ravines'], + 'steps': [ + { + 'type': 'path', + 'name': 'Shattered Ravines portal to Margonite Battle shrine', + 'path': [ + (-9921.00, -10113.00), + (-9442.44, -10320.73), + (-8982.51, -10520.38), + (-8518.35, -10721.87), + ], + }, + { + 'type': 'blessing', + 'name': 'Shattered Ravines Margonite Battle', + 'model_id': 5666, + 'target_name': 'Ghostly Priest', + 'target_xy': (-8366.00, -10788.00), + 'player_approach_xy': (-8421.73, -10763.81), + 'visible_button': 1, + 'effect_ids': (1849, 2036, 2037), + 'capture_id': 'NPC-20260905-171535-150', + }, + { + 'type': 'path', + 'name': 'Shattered Ravines shrine to Wurm Spoor', + 'path': [ + (-8474.12, -10213.94), + (-8060.52, -9913.95), + (-7698.41, -9554.62), + (-7299.81, -9237.98), + (-6865.93, -8961.80), + (-6439.67, -8690.73), + (-5935.64, -8618.86), + (-5433.70, -8668.66), + (-4928.40, -8720.07), + (-4425.00, -8771.28), + ], + }, + { + 'type': 'enter_junundu', + 'name': 'Enter Shattered Ravines Junundu party', + 'target_xy': (-3838.00, -8831.00), + 'party_regroup_xy': (-3838.00, -8831.00), + 'party_regroup_radius': 300.0, + 'player_approach_xy': (-3838.00, -8831.00), + 'capture_id': 'user-screenshot-shattered-ravines-wurm-spoor-20260909', + }, + { + 'type': 'path', + 'name': 'Shattered Ravines Wurm Spoor to Lair of the Forgotten portal', + 'path': [ + (-4029.71, -8242.53), + (-4155.15, -7754.54), + (-4313.30, -7270.84), + (-4412.57, -6778.24), + (-4504.06, -6286.12), + (-4535.71, -5780.49), + (-4342.18, -5316.51), + (-4035.84, -4918.00), + (-3696.98, -4546.88), + (-3355.69, -4180.35), + (-3014.73, -3811.15), + (-2683.39, -3436.37), + (-2352.51, -3055.43), + (-2041.54, -2658.46), + (-1805.57, -2216.56), + (-1704.62, -1724.02), + (-1635.98, -1224.13), + (-1566.71, -715.63), + (-1704.55, -232.89), + (-1967.06, 199.47), + (-2219.08, 634.08), + (-2482.33, 1067.66), + (-2737.85, 1504.56), + (-2971.74, 1946.57), + (-3203.01, 2397.47), + (-3419.43, 2855.01), + (-3607.51, 3322.78), + (-3828.50, 3774.86), + (-4064.80, 4218.08), + (-4290.43, 4667.64), + (-4285.79, 5169.14), + (-4087.42, 5629.35), + (-3763.87, 5700.30), + (-3284.36, 5552.99), + (-2790.21, 5471.94), + (-2285.04, 5418.98), + (-1788.24, 5484.44), + (-1296.46, 5577.53), + (-835.81, 5776.61), + (-406.15, 6038.94), + (21.95, 6303.81), + (426.53, 6607.42), + (778.21, 6964.74), + (1096.09, 7353.14), + (1189.92, 7848.83), + (1084.67, 8338.18), + (958.94, 8890.59), + (811.69, 9374.57), + (669.07, 9856.42), + (758.50, 10354.28), + ], + }, + ], + }, + { + 'map_id': outpost_name_to_id['Lair of the Forgotten'], + 'steps': [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Kourna/_1_pogahnpassage_to_camphojanu.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Kourna/_1_pogahnpassage_to_camphojanu.py new file mode 100644 index 000000000..c986910fc --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Kourna/_1_pogahnpassage_to_camphojanu.py @@ -0,0 +1,159 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260910-195056-356. Recording timestamps and runtime-only agent data are omitted. +# Removes the closed Dejarin Estate meander between captured points 64 and 84. +_1_pogahnpassage_to_camphojanu_ids = { + "outpost_id": outpost_name_to_id["Pogahn Passage outpost"], + "source_route_id": "ROUTE-20260910-195056-356", +} + + +_1_pogahnpassage_to_camphojanu_outpost_path = [ + (3257.15, -508.94), + (3365.38, -20.29), + (3454.65, 472.48), + (3537.96, 966.04), + (3611.92, 1464.80), + (3710.63, 1958.07), + (3925.45, 2409.78), + (4149.26, 2860.07), + (4375.94, 3310.22), +] + +_1_pogahnpassage_to_camphojanu_segments = [ + { + "map_id": explorable_name_to_id["Dejarin Estate"], + "path": [ + (-18244.00, -19463.00), + (-18017.65, -19010.07), + (-17856.05, -18526.45), + (-17738.54, -18033.69), + (-17731.82, -17529.85), + (-17860.49, -17045.45), + (-18004.59, -16565.02), + (-17820.14, -16094.84), + (-17473.95, -15729.38), + (-17112.15, -15374.02), + (-16750.99, -15024.05), + (-16374.52, -14693.37), + (-15974.46, -14391.09), + (-15558.81, -14093.73), + (-15142.00, -13810.22), + (-14680.62, -13615.08), + (-14240.36, -13364.31), + (-13805.56, -13114.60), + (-13354.68, -12881.61), + (-12947.45, -12591.32), + (-12482.72, -12397.18), + (-11985.42, -12300.21), + (-11506.87, -12147.03), + (-11038.25, -11959.40), + (-10576.41, -11758.41), + (-10119.00, -11521.02), + (-9675.91, -11281.46), + (-9222.26, -11056.85), + (-8727.24, -10957.07), + (-8231.60, -10869.48), + (-7739.52, -10768.87), + (-7268.66, -10593.13), + (-6884.88, -10267.85), + (-6580.08, -9868.95), + (-6365.72, -9409.69), + (-6200.13, -8932.45), + (-6036.75, -8456.90), + (-5880.61, -7976.73), + (-5733.25, -7491.14), + (-5587.40, -7010.51), + (-5193.38, -6702.29), + (-4846.97, -6340.62), + (-4592.25, -5909.19), + (-4363.50, -5463.72), + (-4159.88, -5004.68), + (-3985.07, -4535.05), + (-3806.22, -4063.28), + (-3592.52, -3609.62), + (-3338.80, -3174.80), + (-3086.09, -2741.07), + (-2851.83, -2299.18), + (-2725.31, -1786.46), + (-2874.55, -1305.86), + (-2987.83, -818.13), + (-3255.90, -390.39), + (-3252.19, -302.91), + (-2769.14, -168.57), + (-2321.80, 61.00), + (-1833.96, 176.52), + (-1384.85, -45.97), + (-1027.52, -403.64), + (-704.62, -788.02), + (-400.65, -1192.26), + (-174.90, -1640.65), + (-88.99, -2134.93), + (-74.40, -2640.60), + (-100.32, -3143.57), + (0.37, -3634.89), + (255.13, -4074.89), + (642.90, -4397.28), + (1076.96, -4657.01), + (1501.44, -4931.00), + (1939.69, -5180.96), + (2309.78, -5520.42), + (2684.93, -5852.25), + (3091.52, -6154.72), + (3490.81, -6461.59), + (3904.19, -6744.32), + (4358.91, -6957.34), + (4803.81, -7187.39), + (5172.32, -7526.39), + (5586.67, -7809.93), + (6004.37, -8091.21), + (6416.32, -8382.44), + (6734.73, -8768.55), + (7136.33, -9070.07), + (7498.85, -9417.55), + (7673.88, -9888.85), + (7465.12, -10345.56), + (7201.84, -10777.52), + (7032.70, -11252.21), + (6911.93, -11739.84), + (6786.68, -12226.65), + (6758.79, -12727.06), + (6859.08, -13218.49), + (7095.48, -13662.17), + (7486.99, -13975.03), + (7902.34, -14256.94), + (8312.75, -14549.97), + (8716.42, -14848.63), + (9110.29, -15162.62), + (9510.06, -15466.53), + (9915.28, -15760.16), + (10339.00, -16035.33), + (10756.17, -16318.26), + (11185.08, -16578.45), + (11608.51, -16846.25), + (12035.97, -17114.26), + (12482.73, -17344.33), + (12924.70, -17582.47), + (13357.71, -17844.25), + (13816.42, -18051.72), + (14307.95, -18167.59), + (14809.29, -18194.37), + (15312.69, -18176.76), + (15818.72, -18160.34), + (16320.83, -18136.78), + (16822.51, -18137.97), + (17301.11, -18290.89), + (17765.97, -18487.14), + (18160.22, -18801.86), + (18552.55, -19114.04), + (18948.48, -19429.09), + (19346.06, -19740.21), + (19742.89, -20051.05), + ], + }, + { + "map_id": outpost_name_to_id["Camp Hojanu"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Kourna/_2_NunduBay_To_DajkahInlet.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Kourna/_2_NunduBay_To_DajkahInlet.py new file mode 100644 index 000000000..9f7c1ff9a --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Kourna/_2_NunduBay_To_DajkahInlet.py @@ -0,0 +1,85 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260910-202654-187. Recording timestamps and runtime-only agent data are omitted. +_2_NunduBay_To_DajkahInlet_ids = { + "outpost_id": outpost_name_to_id["Nundu Bay outpost"], + "source_route_id": "ROUTE-20260910-202654-187", +} + + +_2_NunduBay_To_DajkahInlet_outpost_path = [ + (-19586.00, -8336.00), + (-19184.53, -8033.49), + (-18883.64, -7630.50), + (-18445.97, -7382.08), + (-17970.16, -7223.38), + (-17483.57, -7092.23), + (-16994.53, -6969.12), + (-16529.93, -6782.55), + (-16136.11, -6472.22), + (-15827.79, -6074.47), + (-15629.50, -5612.98), + (-15578.70, -5111.05), + (-15544.59, -4608.21), + (-15510.66, -4108.21), +] + + +_2_NunduBay_To_DajkahInlet_segments = [ + { + "map_id": explorable_name_to_id["Marga Coast"], + "path": [ + (-15369.00, -3216.00), + (-15674.14, -2803.49), + (-16138.28, -2611.30), + (-16640.08, -2668.86), + (-17107.23, -2482.25), + (-17505.54, -2179.49), + (-17845.23, -1802.93), + (-18134.14, -1392.43), + (-18346.15, -938.56), + (-18545.67, -475.64), + (-18747.38, -11.19), + (-18966.98, 443.09), + (-19279.60, 833.40), + (-19531.20, 1269.54), + (-19832.58, 1673.99), + (-20141.70, 2074.58), + (-20343.46, 2533.45), + (-20447.67, 3027.56), + (-20520.37, 3524.82), + (-20588.65, 4022.31), + (-20717.67, 4508.20), + (-20954.52, 4953.84), + (-21211.58, 5389.22), + (-21558.42, 5751.07), + (-21718.12, 6228.48), + (-21790.56, 6725.02), + (-21815.00, 7226.55), + (-21817.59, 7731.41), + (-21826.55, 8234.97), + (-21881.55, 8734.85), + (-21915.94, 9236.00), + (-21919.07, 9737.29), + (-21946.14, 10240.41), + (-22027.22, 10735.77), + (-22064.67, 11240.48), + (-22080.46, 11746.51), + (-22062.84, 12251.05), + (-22045.29, 12753.29), + (-22027.71, 13256.68), + (-22016.37, 13760.93), + (-22003.26, 14263.46), + (-21962.96, 14766.04), + (-21914.89, 15268.32), + (-21990.02, 15763.02), + (-22291.14, 16165.14), + (-22572.96, 16582.99), + ], + }, + { + "map_id": outpost_name_to_id["Dajkah Inlet outpost"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Kourna/_3_KodonurCrossroads_To_RilohnRefuge.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Kourna/_3_KodonurCrossroads_To_RilohnRefuge.py new file mode 100644 index 000000000..6ebb6c5b5 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Kourna/_3_KodonurCrossroads_To_RilohnRefuge.py @@ -0,0 +1,118 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260911-224924-163. Recording timestamps and runtime-only +# agent data are omitted; combat reversals near the final approach are smoothed. +_3_kodonurcrossroads_to_rilohnrefuge_ids = { + "outpost_id": outpost_name_to_id["Kodonur Crossroads outpost"], + "source_route_id": "ROUTE-20260911-224924-163", +} + + +_3_kodonurcrossroads_to_rilohnrefuge_outpost_path = [ + (395.45, 541.54), + (531.08, 1026.24), + (714.93, 1493.25), + (923.33, 1948.02), + (1161.16, 2393.14), + (1510.09, 2753.42), + (1922.84, 3043.09), + (2371.34, 3266.95), + (2837.09, 3456.5), + (3310.24, 3623.97), + (3759.87, 3851.03), + (4206.93, 4076.79), + (4604.83, 4382.7), +] + + +_3_kodonurcrossroads_to_rilohnrefuge_segments = [ + { + "map_id": explorable_name_to_id["The Floodplain of Mahnkelon"], + # First captured position after the Rilohn boundary. This gives the + # transition an explicit crossing point instead of stopping at it. + "portal_exit_xy": (-15393.56, 9062.19), + "path": [ + (-22328.0, -16199.0), + (-21981.55, -15837.14), + (-21849.09, -15348.61), + (-21641.41, -14887.42), + (-21317.52, -14501.0), + (-20845.72, -14324.42), + (-20365.38, -14168.37), + (-19918.57, -13934.02), + (-19504.73, -13652.26), + (-19013.4, -13543.16), + (-18518.58, -13641.45), + (-18013.16, -13710.08), + (-17509.08, -13767.44), + (-17007.85, -13798.14), + (-16508.16, -13828.75), + (-16007.37, -13778.94), + (-15511.06, -13690.75), + (-15014.52, -13602.52), + (-14521.07, -13518.21), + (-14024.58, -13429.99), + (-13615.13, -13130.05), + (-13110.82, -13189.06), + (-12710.21, -12875.93), + (-12214.79, -12755.65), + (-11731.75, -12591.55), + (-11262.23, -12403.98), + (-10840.26, -12131.61), + (-10515.33, -11743.0), + (-10422.66, -11246.59), + (-10405.59, -10746.17), + (-10478.88, -10249.83), + (-10583.44, -9758.85), + (-10810.95, -9306.6), + (-11029.83, -8852.85), + (-11314.59, -8438.09), + (-11576.08, -8005.42), + (-11761.22, -7534.78), + (-11885.28, -7047.05), + (-11956.27, -6550.18), + (-12014.95, -6053.01), + (-12140.78, -5565.73), + (-12322.63, -5093.62), + (-12531.1, -4634.78), + (-12749.06, -4181.96), + (-13006.96, -3750.49), + (-13279.33, -3330.76), + (-13540.86, -2902.62), + (-13761.99, -2443.73), + (-13919.19, -1966.86), + (-13945.91, -1465.87), + (-14424.08, -1314.17), + (-14766.53, -949.25), + (-15111.17, -584.01), + (-15478.76, -243.49), + (-15845.7, 102.03), + (-16135.35, 513.76), + (-16419.73, 925.52), + (-16675.32, 1357.28), + (-16954.35, 1772.29), + (-17242.1, 2188.04), + (-17542.26, 2592.9), + (-17846.08, 2991.27), + (-17980.55, 3474.58), + (-17784.08, 3906.81), + (-18060.52, 4188.84), + (-17770.5, 4600.34), + (-17392.26, 4929.03), + (-16989.35, 5229.72), + (-16612.35, 5563.25), + (-16308.14, 5960.46), + (-15979.72, 6342.39), + (-15774.6, 6800.86), + (-15552.38, 7250.32), + (-15362.41, 7713.25), + (-15288.94, 8208.61), + (-15322.8, 8710.21), + ], + }, + { + "map_id": outpost_name_to_id["Rilohn Refuge outpost"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Realm of Torment/_1_gateoftorment_to_gateofthenightfallenlands.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Realm of Torment/_1_gateoftorment_to_gateofthenightfallenlands.py new file mode 100644 index 000000000..7bbb72e66 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Realm of Torment/_1_gateoftorment_to_gateofthenightfallenlands.py @@ -0,0 +1,116 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260910-193010-552. Recording timestamps and runtime-only agent data are omitted. +# Preserves the captured route shape; shared portal handling owns boundary extension. +_1_gateoftorment_to_gateofthenightfallenlands_ids = { + "outpost_id": outpost_name_to_id["Gate of Torment"], + "source_route_id": "ROUTE-20260910-193010-552", +} + + +_1_gateoftorment_to_gateofthenightfallenlands_outpost_path = [ + (-958.00, 6664.00), + (-1444.98, 6796.99), + (-1945.69, 6831.93), + (-2447.03, 6791.89), + (-2929.12, 6644.89), + (-3411.48, 6497.80), + (-3891.10, 6348.69), + (-4370.05, 6196.46), + (-4852.23, 6056.96), + (-5342.42, 5957.46), + (-5844.84, 5912.35), + (-6311.73, 6095.31), + (-6722.76, 6386.73), + (-7096.42, 6724.02), + (-7417.50, 7112.27), + (-7694.66, 7532.32), + (-7957.63, 7961.93), + (-8218.60, 8393.45), + (-8488.18, 8819.08), + (-8759.20, 9242.21), + (-8913.14, 9721.49), + (-8965.79, 10218.84), + (-8823.50, 10698.44), + (-8679.65, 11181.51), + (-8512.08, 11656.83), + (-8343.90, 12132.86), + (-8161.39, 12602.74), + (-8000.10, 13078.94), + (-7959.04, 13577.84), +] +_1_gateoftorment_to_gateofthenightfallenlands_segments = [ + { + "map_id": explorable_name_to_id["Nightfallen Jahai"], + "path": [ + (-1735.00, -15808.00), + (-1808.27, -15313.19), + (-1862.53, -14813.97), + (-2100.32, -14371.51), + (-2370.71, -13948.43), + (-2628.86, -13516.89), + (-2815.36, -13052.52), + (-2985.49, -12580.07), + (-3117.88, -12097.17), + (-3228.48, -11604.02), + (-3313.12, -11106.19), + (-3473.16, -10626.29), + (-3699.77, -10175.75), + (-3934.59, -9732.41), + (-4144.01, -9275.63), + (-4340.39, -8812.88), + (-4525.80, -8346.64), + (-4705.83, -7873.91), + (-4624.80, -7371.91), + (-4347.37, -6949.97), + (-4252.41, -6457.01), + (-4153.34, -5962.27), + (-4087.78, -5465.64), + (-3967.24, -4974.43), + (-3865.43, -4483.12), + (-3824.99, -3984.71), + (-3881.26, -3487.09), + (-3940.61, -2988.48), + (-4087.65, -2506.03), + (-4351.85, -2077.61), + (-4626.70, -1653.42), + (-4889.26, -1222.12), + (-5149.58, -793.02), + (-5419.76, -368.62), + (-5540.50, 117.30), + (-5915.27, 456.46), + (-6250.98, 831.94), + (-6449.20, 1293.31), + (-6141.51, 1691.45), + (-5905.41, 2134.97), + (-5639.68, 2560.59), + (-5424.17, 3012.81), + (-5265.76, 3489.27), + (-5090.23, 3960.71), + (-4906.63, 4427.84), + (-4733.99, 4899.51), + (-4565.63, 5371.20), + (-4388.23, 5842.65), + (-4242.30, 6325.00), + (-4193.55, 6825.00), + (-4242.25, 7326.22), + (-4317.19, 7821.72), + (-4503.58, 8290.05), + (-4819.88, 8682.08), + (-5179.63, 9035.05), + (-5580.86, 9335.30), + (-6062.25, 9477.95), + (-6548.42, 9346.40), + (-6865.56, 8953.94), + (-7182.51, 8561.71), + (-7499.28, 8169.70), + (-7805.77, 7772.56), + (-8112.10, 7372.34), + ], + }, + { + "map_id": outpost_name_to_id["Gate of the Nightfallen Lands"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Realm of Torment/_2_gateoftorment_to_theshadownexus.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Realm of Torment/_2_gateoftorment_to_theshadownexus.py new file mode 100644 index 000000000..90dfcc161 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Realm of Torment/_2_gateoftorment_to_theshadownexus.py @@ -0,0 +1,48 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260910-193856-605. Recording timestamps and runtime-only agent data are omitted. +# The brief mistaken mid-run backtrack did not produce a retained 500-unit capture point. +_2_gateoftorment_to_theshadownexus_ids = { + "outpost_id": outpost_name_to_id["Gate of Torment"], + "source_route_id": "ROUTE-20260910-193856-605", +} + + +_2_gateoftorment_to_theshadownexus_outpost_path = [ + (-958.00, 6664.00), + (-741.90, 7118.23), + (-379.52, 7465.72), + (-8.33, 7800.98), + (219.64, 8249.92), + (457.56, 8690.56), + (703.51, 9130.15), + (946.99, 9571.43), + (1217.89, 9991.76), + (1689.99, 10163.02), + (2157.13, 9973.83), + (2636.81, 9817.48), + (3103.42, 9627.78), + (3573.30, 9451.38), + (4051.75, 9293.46), + (4524.76, 9128.85), + (5001.03, 8963.11), + (5505.20, 8952.82), + (6007.68, 8991.89), + (6510.17, 9030.95), + (7012.65, 9070.02), + (7515.14, 9109.08), + (8017.76, 9153.15), + (8519.63, 9199.34), + (9021.86, 9241.49), + (9524.63, 9276.89), + (10028.55, 9279.26), + (10532.55, 9277.95), +] + +_2_gateoftorment_to_theshadownexus_segments = [ + { + "map_id": outpost_name_to_id["The Shadow Nexus outpost"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Vabbi Tour/_3_honurhill_to_dashavestibulepostcampaign.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Vabbi Tour/_3_honurhill_to_dashavestibulepostcampaign.py new file mode 100644 index 000000000..d28f524a9 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Vabbi Tour/_3_honurhill_to_dashavestibulepostcampaign.py @@ -0,0 +1,68 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260910-192540-375. Recording timestamps and runtime-only agent data are omitted. +# Post-campaign access is the ordinary hidden physical portal in The Mirror of Lyss; +# approaching the resurrection-shrine wall reveals the entrance automatically. +_3_honurhill_to_dashavestibulepostcampaign_ids = { + "outpost_id": outpost_name_to_id["Honur Hill"], + "source_route_id": "ROUTE-20260910-192540-375", +} + + +_3_honurhill_to_dashavestibulepostcampaign_outpost_path = [ + (-18194.34, 15715.91), + (-18111.46, 16210.28), + (-18034.38, 16708.35), + (-17957.29, 17206.42), + (-17886.15, 17703.18), + (-17945.77, 18202.46), + (-18257.83, 18596.17), + (-18635.13, 18930.32), + (-19012.43, 19264.47), + (-19381.01, 19603.72), + (-19754.49, 19942.15), +] + +_3_honurhill_to_dashavestibulepostcampaign_segments = [ + { + "map_id": explorable_name_to_id["The Mirror of Lyss"], + "path": [ + (22635.00, -16259.00), + (22232.34, -15956.89), + (21796.44, -15700.26), + (21300.52, -15624.61), + (20808.29, -15714.90), + (20319.25, -15819.38), + (19819.62, -15910.49), + (19314.71, -15872.76), + (18867.87, -15640.99), + (18481.24, -15319.29), + (18167.27, -14928.91), + (17855.12, -14532.93), + (17529.12, -14146.89), + (17193.24, -13774.65), + (16852.59, -13399.99), + (16514.25, -13027.88), + (16175.91, -12655.76), + (15836.54, -12282.52), + (15496.92, -11908.99), + (15159.99, -11538.43), + (14737.84, -11266.98), + (14303.93, -11010.91), + (13949.70, -10656.10), + (13569.39, -10328.18), + (13169.29, -10024.27), + (12749.35, -9746.23), + (12356.34, -9433.84), + (11968.47, -9112.02), + (11734.42, -8667.82), + (11555.68, -8196.70), + (11355.42, -7733.57), + ], + }, + { + "map_id": outpost_name_to_id["Dasha Vestibule outpost"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Vabbi Tour/_4_HonurHill_To_YahnurMarket.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Vabbi Tour/_4_HonurHill_To_YahnurMarket.py new file mode 100644 index 000000000..495b9bbf1 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Vabbi Tour/_4_HonurHill_To_YahnurMarket.py @@ -0,0 +1,116 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260910-202958-660. Recording timestamps and runtime-only agent data are omitted. +_4_HonurHill_To_YahnurMarket_ids = { + "outpost_id": outpost_name_to_id["Honur Hill"], + "source_route_id": "ROUTE-20260910-202958-660", +} + + +_4_HonurHill_To_YahnurMarket_outpost_path = [ + (-16349.00, 17888.00), + (-16381.22, 17378.44), + (-16453.84, 16883.48), + (-16538.34, 16388.82), + (-16676.26, 15906.92), + (-16971.04, 15501.30), + (-17468.60, 15421.00), + (-17966.93, 15347.44), + (-18403.33, 15100.67), + (-18504.99, 14607.08), + (-18603.09, 14112.72), + (-18700.62, 13621.18), +] + + +_4_HonurHill_To_YahnurMarket_segments = [ + { + "map_id": explorable_name_to_id["Resplendent Makuun"], + "path": [ + (-18636.00, 12697.00), + (-18534.56, 12199.68), + (-18424.86, 11708.08), + (-18258.83, 11232.64), + (-18090.34, 10760.83), + (-17947.20, 10280.02), + (-17814.58, 9797.25), + (-17711.05, 9304.21), + (-17597.80, 8811.11), + (-17513.93, 8312.24), + (-17321.27, 7845.25), + (-17109.08, 7390.57), + (-16914.54, 6926.29), + (-16731.63, 6459.77), + (-16568.01, 5982.39), + (-16454.08, 5489.97), + (-16406.92, 4987.77), + (-16386.89, 4487.65), + (-16426.79, 3984.13), + (-16451.99, 3479.19), + (-16563.04, 2988.03), + (-16848.53, 2576.32), + (-17135.83, 2162.12), + (-17422.46, 1748.90), + (-17778.22, 1396.56), + (-18082.54, 998.49), + (-18400.77, 608.37), + (-18550.37, 129.92), + (-18676.82, -358.83), + (-18807.79, -842.82), + (-18914.57, -1335.35), + (-18926.00, -1840.85), + (-18775.02, -2318.98), + (-18620.61, -2795.29), + (-18410.41, -3252.90), + (-18216.13, -3714.08), + (-17877.29, -4086.08), + (-17431.68, -4314.79), + (-16972.67, -4523.28), + (-16484.01, -4649.61), + (-15986.25, -4699.52), + (-15487.89, -4761.73), + (-14986.98, -4824.71), + (-14492.05, -4900.88), + (-14081.41, -5200.21), + (-13733.04, -5559.68), + (-13739.24, -6059.98), + (-13711.09, -6562.13), + (-13911.54, -7020.89), + (-14172.47, -7453.02), + (-14494.82, -7835.93), + (-14804.30, -8228.92), + (-15131.89, -8608.27), + (-15487.14, -8961.30), + (-15838.42, -9323.83), + (-16220.25, -9647.83), + (-16613.85, -9963.83), + (-16954.19, -10334.92), + (-17251.55, -10740.25), + (-17395.83, -11219.46), + (-17529.96, -11706.84), + (-17657.71, -12194.46), + (-17785.51, -12681.25), + (-17905.09, -13168.18), + (-17985.46, -13666.78), + (-18062.61, -14164.36), + (-18095.83, -14667.69), + (-18119.75, -15171.59), + (-18143.42, -15675.11), + (-18182.52, -16178.01), + (-18236.86, -16679.41), + (-18252.25, -17238.05), + (-18274.05, -17742.34), + (-18281.34, -18245.98), + (-18316.53, -18744.88), + (-18476.60, -19219.89), + (-18825.30, -19583.46), + (-19259.28, -19839.73), + (-19690.11, -20094.13), + ], + }, + { + "map_id": outpost_name_to_id["Yahnur Market"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/NF - Vabbi Tour/_5_GrandCourtOfSebelkeh_To_DzagonurBastion.py b/Sources/aC_Scripts/OutpostRunner/maps/NF - Vabbi Tour/_5_GrandCourtOfSebelkeh_To_DzagonurBastion.py new file mode 100644 index 000000000..b9be37184 --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/maps/NF - Vabbi Tour/_5_GrandCourtOfSebelkeh_To_DzagonurBastion.py @@ -0,0 +1,86 @@ +from Py4GWCoreLib.enums import explorable_name_to_id, outpost_name_to_id + + +# Capture ROUTE-20260910-203705-316. Recording timestamps and runtime-only agent data are omitted. +# Removes the brief captured reversal between points 30 and 31 in The Mirror of Lyss. +_5_GrandCourtOfSebelkeh_To_DzagonurBastion_ids = { + "outpost_id": outpost_name_to_id["Grand Court of Sebelkeh outpost"], + "source_route_id": "ROUTE-20260910-203705-316", +} + + +_5_GrandCourtOfSebelkeh_To_DzagonurBastion_outpost_path = [ + (1565.00, 8173.00), + (2062.01, 8106.82), + (2566.49, 8105.53), +] + + +_5_GrandCourtOfSebelkeh_To_DzagonurBastion_segments = [ + { + "map_id": explorable_name_to_id["The Mirror of Lyss"], + "path": [ + (3155.42, 7860.53), + (3322.50, 7376.92), + (3732.66, 7089.52), + (4235.20, 7038.49), + (4737.91, 7017.15), + (5236.74, 6972.96), + (5674.86, 6724.43), + (5825.70, 6240.98), + (5858.96, 5741.45), + (5872.44, 5239.54), + (6018.53, 4758.26), + (6481.32, 4565.11), + (6918.41, 4316.20), + (7285.15, 3970.16), + (7576.73, 3561.56), + (7838.48, 3133.03), + (8010.58, 2658.89), + (8177.33, 2186.44), + (8353.37, 1713.69), + (8528.26, 1241.22), + (8770.86, 803.51), + (9179.08, 505.79), + (9680.25, 448.42), + (10182.25, 505.28), + (10695.43, 581.63), + (11190.38, 682.98), + (11684.61, 780.96), + (12085.30, 1083.32), + (12395.58, 1748.49), + (12707.63, 2145.84), + (13016.59, 2539.27), + (13242.24, 2991.60), + (13418.65, 3459.60), + (13614.46, 3924.90), + (13872.24, 4355.60), + (14158.36, 4770.45), + (14440.41, 5187.66), + (14739.73, 5589.20), + (15149.40, 5882.76), + (15553.88, 6181.96), + (15956.67, 6485.22), + (16351.60, 6798.34), + (16744.38, 7110.00), + (17132.35, 7430.28), + (17510.55, 7763.28), + (17875.83, 8110.14), + (18282.57, 8408.19), + (18675.04, 8724.33), + (19102.14, 8989.09), + (19575.97, 9160.09), + (20066.07, 9277.60), + (20559.30, 9377.47), + (21051.56, 9491.27), + (21531.89, 9645.20), + (22010.68, 9803.78), + (22443.46, 10058.09), + (22821.87, 10390.78), + ], + }, + { + "map_id": outpost_name_to_id["Dzagonur Bastion outpost"], + "path": [], + }, +] diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_1_BeaconsPerch_To_DeldrimorWarCamp.py b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_1_BeaconsPerch_To_DeldrimorWarCamp.py index 94577632c..56e3e615d 100644 --- a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_1_BeaconsPerch_To_DeldrimorWarCamp.py +++ b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_1_BeaconsPerch_To_DeldrimorWarCamp.py @@ -198,7 +198,6 @@ { "map_id": explorable_name_to_id["Grenth's Footprint"], "path": [ - (-18593, -9945), (-18093.2, -9915.92), (-17594.12, -9830.67), (-17108.81, -9686.08), diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_4_DeldrimorWarCamp_To_CampRankor.py b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_4_DeldrimorWarCamp_To_CampRankor.py index 0dec361cc..aa70af8fd 100644 --- a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_4_DeldrimorWarCamp_To_CampRankor.py +++ b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_4_DeldrimorWarCamp_To_CampRankor.py @@ -16,7 +16,6 @@ { "map_id": explorable_name_to_id["Grenth's Footprint"], "path": [ - (-2835.64, -4288.15), (-2898.59, -3784.91), (-2969, -3283.09), (-2968.54, -2776.19), @@ -74,7 +73,6 @@ { "map_id": explorable_name_to_id["Snake Dance"], "path": [ - (7571.86, -2955.53), (7106.27, -2760.61), (6632.1, -2587.5), (6151.33, -2434.9), diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_5_CampRankor_To_DeldrimorWarCamp.py b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_5_CampRankor_To_DeldrimorWarCamp.py index a3d166b56..48fd38965 100644 --- a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_5_CampRankor_To_DeldrimorWarCamp.py +++ b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_5_CampRankor_To_DeldrimorWarCamp.py @@ -3,6 +3,7 @@ # 1) IDs _5_camprankor_to_deldrimorwarcamp_ids = { "outpost_id": outpost_name_to_id["Camp Rankor"], + "replacement_route_id": "ROUTE-20260911-202732-875", } # 2) Outpost exit path @@ -17,7 +18,8 @@ { "map_id": explorable_name_to_id["Snake Dance"], "path": [ - (6163.73, -41408.86), + # 6163.73, -41408.86 is the portal threshold. Retargeting it after + # Snake Dance loads can send the party straight back to Camp Rankor. (5680.65, -41273.91), (5193.57, -41142.44), (4735.49, -40929.66), @@ -50,29 +52,23 @@ (1068.77, -29114.98), (1041.33, -28606.77), (785.8, -28165.28), - (452.79, -27789.46), - (11.6, -27539.65), - (-429.27, -27292.45), - (-576, -26812.8), - (-411.82, -26334.12), - (-30.12, -26006.52), - (402.36, -25752.18), - (859.63, -25548.35), - (615.53, -25107.57), - (130.76, -25240.5), - (-318.99, -25467.3), - (-682.99, -25814.69), - (-1038.81, -26177.29), - (-1409.66, -26521.98), - (-1780.1, -26868.36), - (-2150.27, -27210.62), - (-2589.59, -27453.85), - (-3023.01, -27705.93), - (-3490.96, -27899.02), - (-3997.14, -27877.33), - (-5278.18, -27810.96), - (-5621.63, -27442.11), - (-5724.72, -26952.08), + # DWC Route Temp replaces the northern loop with the direct + # westbound pass captured from the junction. + (335.99, -28006.2), + (-146.68, -27863.74), + (-657.08, -27810.81), + (-1170.03, -27827.37), + (-1667.49, -27925.54), + (-2167.71, -27998.39), + (-2667.36, -28038.94), + (-3167.11, -28071.2), + (-3669.71, -28108.58), + (-4170.24, -28139.68), + (-4673.2, -28118.27), + (-5147.58, -27937.02), + (-5453.73, -27534.42), + (-5644.42, -27063.74), + (-5775.51, -26579.78), (-5757.33, -26449.17), (-5788.61, -25941.04), (-5836.75, -25439.79), @@ -154,7 +150,6 @@ { "map_id": explorable_name_to_id["Grenth's Footprint"], "path": [ - (-18593, -9945), (-18093.2, -9915.92), (-17594.12, -9830.67), (-17108.81, -9686.08), diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_8_DeldrimorWarCamp_To_TheGraniteCitadel.py b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_8_DeldrimorWarCamp_To_TheGraniteCitadel.py index c5bd767d3..a3c190c06 100644 --- a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_8_DeldrimorWarCamp_To_TheGraniteCitadel.py +++ b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_8_DeldrimorWarCamp_To_TheGraniteCitadel.py @@ -17,7 +17,6 @@ { "map_id": explorable_name_to_id["Grenth's Footprint"], "path": [ - (-2908.64, -4574.38), (-2800, -4077), (-2406.15, -3767.3), (-2045.27, -3414.51), diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_9_TheGraniteCitadel_To_CopperhammerMines.py b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_9_TheGraniteCitadel_To_CopperhammerMines.py index 2a5093ec5..a441b35ef 100644 --- a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_9_TheGraniteCitadel_To_CopperhammerMines.py +++ b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Beacon's Perch To Droknars Forge/_9_TheGraniteCitadel_To_CopperhammerMines.py @@ -17,7 +17,6 @@ { "map_id": explorable_name_to_id["Spearhead Peak"], "path": [ - (-11514.46, 15385.34), (-11496.9, 14881.48), (-11556.95, 14382.22), (-11658.1, 13890.26), diff --git a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Desert Outposts/_4_ElonaReach_to_SeekersPassage.py b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Desert Outposts/_4_ElonaReach_to_SeekersPassage.py index e615155b3..6bc3c23be 100644 --- a/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Desert Outposts/_4_ElonaReach_to_SeekersPassage.py +++ b/Sources/aC_Scripts/OutpostRunner/maps/Tyria - Desert Outposts/_4_ElonaReach_to_SeekersPassage.py @@ -3,6 +3,7 @@ # 1) IDs _4_elonareach_to_seekerspassage_ids = { "outpost_id": outpost_name_to_id["Elona Reach outpost"], + "replacement_route_id": "ROUTE-20260911-210807-345", } # 2) Outpost exit path @@ -59,23 +60,36 @@ (-2863, 12638), (-4505, 13884), (-5938, 15359), - (-7743, 14465), - (-9214, 13044), - (-10651, 11571), - (-11987, 10072), - (-12592, 8155), - # ROUTE-20260805-113153-349: recorded final approach avoids the wall. - (-12688.32, 7718.46), - (-12890.34, 7258.89), - (-13212.67, 6873.06), - (-13593.44, 6547.30), - (-14033.69, 6301.79), - (-14534.21, 6342.47), - (-14970.82, 6597.02), - (-15401.54, 6857.95), - (-15825.13, 7124.97), - (-16138.66, 7517.83), - (-16422.75, 7933.44), + # Seeker's Passage Temp replaces the wall-bound final approach. + # Combat-only reversals between the two nearby points are omitted. + (-7794.48, 14427.95), + (-8212.75, 14139.39), + (-8635.64, 13863.79), + (-9017.77, 13534.27), + (-9352.86, 13162.49), + (-9667.11, 12768.51), + (-9961.17, 12363.46), + (-10255.68, 11957.78), + (-10551.14, 11550.8), + (-10884.24, 11089.74), + (-11190.28, 10687.65), + (-11500.66, 10293.86), + (-11734.9, 9851.13), + (-11909.36, 9376.21), + (-12087.99, 8908.21), + (-12260.87, 8433.27), + (-12430.99, 7958.46), + (-12544.54, 7809.47), + (-12797.41, 7375.08), + (-13063.87, 6945.42), + (-13415.51, 6585.91), + (-13875.13, 6388.07), + (-14371.68, 6318.42), + (-14851.53, 6472.3), + (-15295.84, 6706.54), + (-15739.69, 6943.62), + (-16040.12, 7350.31), + (-16318.77, 7771.75), ], }, { diff --git a/Sources/aC_Scripts/OutpostRunner/route_mechanics.py b/Sources/aC_Scripts/OutpostRunner/route_mechanics.py new file mode 100644 index 000000000..917c1230b --- /dev/null +++ b/Sources/aC_Scripts/OutpostRunner/route_mechanics.py @@ -0,0 +1,568 @@ +from __future__ import annotations + +import math +import time +from typing import Any +from typing import Generator + +from Py4GWCoreLib import Agent +from Py4GWCoreLib import ConsoleLog +from Py4GWCoreLib import Effects +from Py4GWCoreLib import GLOBAL_CACHE +from Py4GWCoreLib import Map +from Py4GWCoreLib import Party +from Py4GWCoreLib import Player +from Py4GWCoreLib import PySystem +from Py4GWCoreLib import Routines +from Py4GWCoreLib import SkillBar +from Py4GWCoreLib.routines_src.reliable_interaction import ApproachSpec +from Py4GWCoreLib.routines_src.reliable_interaction import DialogSpec +from Py4GWCoreLib.routines_src.reliable_interaction import InteractionProfile +from Py4GWCoreLib.routines_src.reliable_interaction import InteractionSpec +from Py4GWCoreLib.routines_src.reliable_interaction import PostconditionKind +from Py4GWCoreLib.routines_src.reliable_interaction import PostconditionSpec +from Py4GWCoreLib.routines_src.reliable_interaction import Py4GWInteractionRuntime +from Py4GWCoreLib.routines_src.reliable_interaction import ReliableInteractionController +from Py4GWCoreLib.routines_src.reliable_interaction import RetryPolicy +from Py4GWCoreLib.routines_src.reliable_interaction import TargetKind +from Py4GWCoreLib.routines_src.reliable_interaction import TargetSpec +from Py4GWCoreLib.routines_src.reliable_interaction import run_coroutine_adapter + + +JUNUNDU_STRIKE_SKILL_ID = 1439 +LEAVE_JUNUNDU_SKILL_ID = 1443 +PORTAL_EXTENSION_DISTANCE = 450.0 + + +def _xy(data: dict[str, Any], key: str) -> tuple[float, float]: + value = data[key] + return float(value[0]), float(value[1]) + + +def segment_steps(segment: dict[str, Any]) -> tuple[dict[str, Any], ...]: + """Return ordered mechanic-aware steps, preserving legacy path-only routes.""" + + declared = segment.get('steps') + if isinstance(declared, (list, tuple)): + return tuple(dict(step) for step in declared) + path = list(segment.get('path', [])) + return ({'type': 'path', 'path': path},) if path else () + + +def extended_portal_path( + points: list[tuple[float, float]], + extension_distance: float = PORTAL_EXTENSION_DISTANCE, + approach_origin: tuple[float, float] | None = None, +) -> list[tuple[float, float]]: + """Add a forward point beyond a captured portal edge.""" + + route = [(float(x), float(y)) for x, y in points] + if not route: + return route + if len(route) >= 2: + previous, endpoint = route[-2], route[-1] + elif approach_origin is not None: + previous = (float(approach_origin[0]), float(approach_origin[1])) + endpoint = route[-1] + else: + return route + dx = endpoint[0] - previous[0] + dy = endpoint[1] - previous[1] + length = math.hypot(dx, dy) + if length <= 1.0: + return route + scale = float(extension_distance) / length + return [ + *route, + (endpoint[0] + dx * scale, endpoint[1] + dy * scale), + ] + + +def register_outpost_departure( + bot: Any, + points: list[tuple[float, float]], + target_map_id: int, + step_name: str, +) -> None: + """Register an outpost exit whose heading is resolved when the state runs.""" + + def _leave_outpost() -> Generator[Any, Any, None]: + portal_path = extended_portal_path(points, approach_origin=Player.GetXY()) + yield from bot.Move._coro_follow_path_and_exit_map( + portal_path, + target_map_id=target_map_id, + step_name=step_name, + ) + + bot.States.AddCustomState(_leave_outpost, step_name) + + +def register_botting_segment( + bot: Any, + route_name: str, + segment_index: int, + segment: dict[str, Any], + target_map_id: int = 0, + resume_key_prefix: str = '', +) -> bool: + """Register one shared route segment and report whether it owns map travel.""" + + steps = segment_steps(segment) + owns_map_travel = False + for step_index, step in enumerate(steps, start=1): + step_type = str(step.get('type', '')).strip().lower() + label = str(step.get('name') or f'{route_name} segment {segment_index + 1} step {step_index}') + resume_key = ( + f'{resume_key_prefix}:segment:{segment_index}:step:{step_index}' + if resume_key_prefix + else None + ) + if step_type == 'path': + points = list(step.get('path', [])) + if points: + if target_map_id and step_index == len(steps): + portal_path = ( + [*points, _xy(segment, 'portal_exit_xy')] + if 'portal_exit_xy' in segment + else extended_portal_path(points) + ) + exit_kwargs = { + 'target_map_id': target_map_id, + 'step_name': label, + } + if resume_key is not None: + exit_kwargs['resume_key'] = resume_key + bot.Move.FollowPathAndExitMap(portal_path, **exit_kwargs) + owns_map_travel = True + else: + path_kwargs = {'step_name': label} + if resume_key is not None: + path_kwargs['resume_key'] = resume_key + bot.Move.FollowAutoPath(points, **path_kwargs) + continue + if step_type == 'direct_path': + points = list(step.get('path', [])) + if points: + bot.Move.FollowPath(points, step_name=label) + continue + if step_type not in { + 'blessing', + 'enter_junundu', + 'leave_junundu', + 'npc_dialog_sequence', + 'verify_human_form', + }: + raise ValueError(f'Unsupported OutpostRunner route step type: {step_type!r}') + bot.States.AddCustomState( + lambda data=dict(step), owner=bot: _run_required_action(owner, data), + label, + ) + return owns_map_travel + + +def _hero_slot_skill(hero_position: int, slot: int) -> int | None: + try: + bar = list(GLOBAL_CACHE.SkillBar.GetHeroSkillbar(hero_position) or []) + if len(bar) < slot: + return None + skill = bar[slot - 1] + return int(getattr(getattr(skill, 'id', None), 'id', 0) or 0) + except Exception: + return None + + +def _expected_hero_count() -> int: + heroes = list(Party.GetHeroes() or []) + try: + declared_count = int(Party.GetHeroCount() or 0) + except Exception: + declared_count = 0 + return max(declared_count, len(heroes)) + + +def _party_slot_one( + expected_hero_count: int | None = None, +) -> tuple[int | None, tuple[int | None, ...]]: + try: + player_skill = int(GLOBAL_CACHE.SkillBar.GetSkillIDBySlot(1) or 0) + except Exception: + player_skill = None + heroes = list(Party.GetHeroes() or []) + required_count = ( + _expected_hero_count() + if expected_hero_count is None + else max(0, int(expected_hero_count)) + ) + if len(heroes) != required_count: + return player_skill, tuple(None for _ in range(required_count)) + hero_skills = tuple( + _hero_slot_skill(position, 1) + for position, _hero in enumerate(heroes, start=1) + ) + return player_skill, hero_skills + + +def full_hero_party_in_junundu(expected_hero_count: int | None = None) -> bool: + player_skill, hero_skills = _party_slot_one(expected_hero_count) + return bool( + player_skill == JUNUNDU_STRIKE_SKILL_ID + and all(skill == JUNUNDU_STRIKE_SKILL_ID for skill in hero_skills) + ) + + +def full_hero_party_in_human_form(expected_hero_count: int | None = None) -> bool: + player_skill, hero_skills = _party_slot_one(expected_hero_count) + observed = (player_skill,) + hero_skills + return bool( + all(skill is not None for skill in observed) + and all(skill != JUNUNDU_STRIKE_SKILL_ID for skill in observed) + ) + + +def _required_heroes_close( + radius: float = 700.0, + center_xy: tuple[float, float] | None = None, + expected_hero_count: int | None = None, +) -> bool: + center = center_xy if center_xy is not None else Player.GetXY() + heroes = list(Party.GetHeroes() or []) + if expected_hero_count is not None and len(heroes) != expected_hero_count: + return False + for hero in heroes: + agent_id = int(getattr(hero, 'agent_id', 0) or 0) + if ( + agent_id <= 0 + or not Agent.IsValid(agent_id) + or Agent.IsDead(agent_id) + or math.dist(center, Agent.GetXY(agent_id)) > radius + ): + return False + return True + + +class _RouteInteractionRuntime(Py4GWInteractionRuntime): + def __init__(self, bot: Any, verify: Any, action_name: str): + self.bot = bot + self.action_name = action_name + self.hero_ai_status = bot.config.upkeep.hero_ai.is_active() + self.hero_ai_pause_status = bool( + hasattr(bot.config.upkeep, 'hero_ai_paused') + and bot.config.upkeep.hero_ai_paused.is_active() + ) + super().__init__( + pause_automation=self._pause, + restore_automation=self._restore, + verify=lambda _postcondition: bool(verify()), + diagnostic_sink=self._record, + ) + + def _pause(self) -> None: + if hasattr(self.bot.config.upkeep, 'hero_ai_paused'): + self.bot.config.upkeep.hero_ai_paused.set_now('active', True) + else: + self.bot.config.upkeep.hero_ai.set_now('active', False) + + def _restore(self) -> None: + if hasattr(self.bot.config.upkeep, 'hero_ai_paused'): + self.bot.config.upkeep.hero_ai_paused.set_now('active', self.hero_ai_pause_status) + self.bot.config.upkeep.hero_ai.set_now('active', self.hero_ai_status) + + def _record(self, event: dict[str, Any]) -> None: + event_name = str(event.get('event', 'event')) + if event_name in { + 'attempt_failed', + 'dialog_buttons_populated', + 'visible_button_clicked', + 'postcondition_verified', + 'result', + }: + ConsoleLog( + 'Outpost Route Mechanics', + f'[{self.action_name}] {event_name}: {event}', + PySystem.Console.MessageType.Info, + ) + + +def _blessing_active(effect_ids: tuple[int, ...]) -> bool: + player_id = int(Player.GetAgentID() or 0) + return player_id > 0 and any(Effects.HasEffect(player_id, effect_id) for effect_id in effect_ids) + + +def _take_blessing(bot: Any, data: dict[str, Any]) -> Generator[Any, Any, bool]: + name = str(data['name']) + effect_ids = tuple(int(value) for value in data['effect_ids']) + target_xy = _xy(data, 'target_xy') + approach_xy = _xy(data, 'player_approach_xy') + runtime = _RouteInteractionRuntime(bot, lambda: _blessing_active(effect_ids), name) + spec = InteractionSpec( + name=name, + profile=InteractionProfile.VISIBLE_CHOICE, + target=TargetSpec( + kind=TargetKind.NPC, + model_id=int(data.get('model_id', 0)), + name_contains=str(data.get('target_name', '')), + expected_xy=target_xy, + search_radius=float(data.get('search_radius', 350.0)), + ), + approach=ApproachSpec( + player_approach_xy=approach_xy, + tolerance=100.0, + target_tolerance=100.0, + timeout_ms=15_000, + ), + dialog=DialogSpec( + visible_button=int(data.get('visible_button', 1)), + response_timeout_ms=8_000, + close_dialog_after_success=True, + allow_no_choice_success=True, + no_choice_settle_ms=1_000, + ), + postcondition=PostconditionSpec( + PostconditionKind.EFFECT_PRESENT, + effect_ids, + f'{name} bounty effect is active', + ), + retry=RetryPolicy( + max_attempts=3, + poll_ms=100, + verify_timeout_ms=5_000, + retry_delay_ms=750, + pause_settle_ms=350, + ), + allow_preexisting_postcondition=True, + source_capture_ids=(str(data.get('capture_id', name)),), + ) + return bool((yield from run_coroutine_adapter(ReliableInteractionController(spec, runtime), Routines.Yield.wait))) + + +def _mount_attempt( + bot: Any, + data: dict[str, Any], + expected_hero_count: int | None = None, +) -> Generator[Any, Any, bool]: + name = str(data['name']) + target_xy = _xy(data, 'target_xy') + approach_xy = _xy(data, 'player_approach_xy') + runtime = _RouteInteractionRuntime( + bot, + lambda: full_hero_party_in_junundu(expected_hero_count), + name, + ) + spec = InteractionSpec( + name=name, + profile=InteractionProfile.GADGET, + target=TargetSpec( + kind=TargetKind.GADGET, + name_contains='Wurm Spoor', + expected_xy=target_xy, + search_radius=float(data.get('search_radius', 1_500.0)), + ), + approach=ApproachSpec( + player_approach_xy=approach_xy, + tolerance=float(data.get('approach_tolerance', 120.0)), + target_tolerance=float(data.get('target_tolerance', 220.0)), + timeout_ms=15_000, + ), + postcondition=PostconditionSpec( + PostconditionKind.CUSTOM, + 'full_hero_party_in_junundu', + 'Junundu Strike is in player and every hero slot 1', + ), + retry=RetryPolicy( + max_attempts=1, + poll_ms=100, + verify_timeout_ms=8_000, + retry_delay_ms=750, + pause_settle_ms=350, + ), + allow_preexisting_postcondition=True, + source_capture_ids=(str(data.get('capture_id', name)),), + ) + return bool((yield from run_coroutine_adapter(ReliableInteractionController(spec, runtime), Routines.Yield.wait))) + + +def _npc_dialog_sequence(bot: Any, data: dict[str, Any]) -> Generator[Any, Any, bool]: + name = str(data['name']) + target_xy = _xy(data, 'target_xy') + approach_xy = _xy(data, 'player_approach_xy') + target_map_id = int(data['target_map_id']) + runtime = _RouteInteractionRuntime(bot, lambda: int(Map.GetMapID()) == target_map_id, name) + spec = InteractionSpec( + name=name, + profile=InteractionProfile.QUEST_CONVERSATION, + target=TargetSpec( + kind=TargetKind.NPC, + model_id=int(data.get('model_id', 0)), + name_contains=str(data.get('target_name', '')), + expected_xy=target_xy, + search_radius=float(data.get('search_radius', 350.0)), + ), + approach=ApproachSpec( + player_approach_xy=approach_xy, + tolerance=100.0, + target_tolerance=120.0, + timeout_ms=15_000, + ), + dialog=DialogSpec( + visible_button_labels=tuple(str(label) for label in data['visible_button_labels']), + visible_step_delay_ms=500, + response_timeout_ms=8_000, + close_dialog_after_success=False, + ), + postcondition=PostconditionSpec( + PostconditionKind.MAP_CHANGED, + target_map_id, + f'{name} completed and map {target_map_id} loaded', + ), + retry=RetryPolicy( + max_attempts=4, + poll_ms=100, + verify_timeout_ms=90_000, + retry_delay_ms=750, + pause_settle_ms=350, + ), + allow_preexisting_postcondition=True, + source_capture_ids=tuple(str(value) for value in data.get('capture_ids', ())), + ) + return bool((yield from run_coroutine_adapter(ReliableInteractionController(spec, runtime), Routines.Yield.wait))) + + +def _wait_until(predicate: Any, timeout_s: float, poll_ms: int = 200) -> Generator[Any, Any, bool]: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if predicate(): + return True + yield from Routines.Yield.wait(poll_ms) + return bool(predicate()) + + +def _leave_junundu( + expected_hero_count: int | None = None, +) -> Generator[Any, Any, bool]: + if full_hero_party_in_human_form(expected_hero_count): + return True + # Follow the same slot-8 Leave Junundu boundary used by Gate of + # Desolation, but drive every transformed party member and retry the + # command. A single request can be dropped while the preceding path is + # still settling or combat is finishing. + for _attempt in range(15): + player_skill, hero_skills = _party_slot_one(expected_hero_count) + if full_hero_party_in_human_form(expected_hero_count): + return True + try: + for hero_position, hero_skill in enumerate(hero_skills, start=1): + if ( + hero_skill == JUNUNDU_STRIKE_SKILL_ID + and _hero_slot_skill(hero_position, 8) == LEAVE_JUNUNDU_SKILL_ID + ): + SkillBar.HeroUseSkill(0, 8, hero_position) + if ( + player_skill == JUNUNDU_STRIKE_SKILL_ID + and int(GLOBAL_CACHE.SkillBar.GetSkillIDBySlot(8) or 0) + == LEAVE_JUNUNDU_SKILL_ID + ): + SkillBar.UseSkillTargetless(8) + except Exception as error: + ConsoleLog( + 'Outpost Route Mechanics', + f'[Leave Junundu] command error: {type(error).__name__}: {error}', + PySystem.Console.MessageType.Error, + ) + yield from Routines.Yield.wait(1_000) + + player_skill, hero_skills = _party_slot_one(expected_hero_count) + ConsoleLog( + 'Outpost Route Mechanics', + '[Leave Junundu] party did not return to human form after retries; ' + f'slot-1 snapshot was player={player_skill}, heroes={hero_skills}.', + PySystem.Console.MessageType.Error, + ) + return full_hero_party_in_human_form(expected_hero_count) + + +def _enter_junundu(bot: Any, data: dict[str, Any]) -> Generator[Any, Any, bool]: + expected_hero_count = _expected_hero_count() + if full_hero_party_in_junundu(expected_hero_count): + return True + regroup_xy = ( + _xy(data, 'party_regroup_xy') + if 'party_regroup_xy' in data + else _xy(data, 'target_xy') + ) + party_radius = float(data.get('party_regroup_radius', 300.0)) + settle_ms = max(0, int(data.get('party_regroup_settle_ms', 0))) + mount_attempts = max(1, int(data.get('mount_attempts', 2))) + + def _heroes_regrouped() -> bool: + return _required_heroes_close( + party_radius, + regroup_xy, + expected_hero_count, + ) + + def _regroup() -> Generator[Any, Any, bool]: + if not (yield from _wait_until(_heroes_regrouped, 45.0)): + return False + if settle_ms <= 0: + return True + yield from Routines.Yield.wait(settle_ms) + if _heroes_regrouped(): + return True + return bool((yield from _wait_until(_heroes_regrouped, 15.0))) + + Party.Heroes.FlagAllHeroes(*regroup_xy) + try: + for attempt in range(1, mount_attempts + 1): + if not (yield from _regroup()): + return False + if (yield from _mount_attempt(bot, data, expected_hero_count)): + return True + + player_skill, hero_skills = _party_slot_one(expected_hero_count) + ConsoleLog( + 'Outpost Route Mechanics', + f'[{data.get("name", "Enter Junundu")}] mount attempt ' + f'{attempt}/{mount_attempts} left a partial party; slot-1 snapshot ' + f'was player={player_skill}, heroes={hero_skills}.', + PySystem.Console.MessageType.Error, + ) + if attempt >= mount_attempts: + return False + if not (yield from _leave_junundu(expected_hero_count)): + return False + return False + finally: + Party.Heroes.UnflagAllHeroes() + + +def _run_required_action(bot: Any, data: dict[str, Any]) -> Generator[Any, Any, bool]: + action_type = str(data.get('type', '')).strip().lower() + name = str(data.get('name', action_type)) + try: + if action_type == 'blessing': + success = yield from _take_blessing(bot, data) + elif action_type == 'enter_junundu': + success = yield from _enter_junundu(bot, data) + elif action_type == 'npc_dialog_sequence': + success = yield from _npc_dialog_sequence(bot, data) + elif action_type == 'leave_junundu': + success = yield from _leave_junundu() + elif action_type == 'verify_human_form': + success = yield from _wait_until(full_hero_party_in_human_form, float(data.get('timeout_s', 60.0))) + else: + success = False + except Exception as error: + ConsoleLog( + 'Outpost Route Mechanics', + f'[{name}] {type(error).__name__}: {error}', + PySystem.Console.MessageType.Error, + ) + success = False + if not success: + ConsoleLog( + 'Outpost Route Mechanics', + f'[{name}] required route mechanic failed; stopping the bot.', + PySystem.Console.MessageType.Error, + ) + bot.Stop() + return bool(success) diff --git a/Widgets/Automation/Bots/Runners/OutpostFighter.py b/Widgets/Automation/Bots/Runners/OutpostFighter.py index 3396af6fb..e47d6b0f3 100644 --- a/Widgets/Automation/Bots/Runners/OutpostFighter.py +++ b/Widgets/Automation/Bots/Runners/OutpostFighter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib from dataclasses import dataclass from typing import Any @@ -15,13 +16,18 @@ from Sources.aC_Scripts.OutpostRunner.map_loader import get_regions from Sources.aC_Scripts.OutpostRunner.map_loader import get_runs from Sources.aC_Scripts.OutpostRunner.map_loader import load_map_data +from Sources.aC_Scripts.OutpostRunner import route_mechanics as _route_mechanics + +_route_mechanics = importlib.reload(_route_mechanics) +extended_portal_path = _route_mechanics.extended_portal_path +register_outpost_departure = _route_mechanics.register_outpost_departure +register_botting_segment = _route_mechanics.register_botting_segment BOT_NAME = 'Outpost Fighter' MODULE_NAME = BOT_NAME WIDGETS_TO_ENABLE: tuple[str, ...] = ( 'HeroAI', - 'Return to outpost on defeat', ) bot = Botting( @@ -146,26 +152,27 @@ def _build_route_steps(bot_instance: Botting, route: RoutePlan, index: int) -> N bot_instance.Multibox.ApplyWidgetPolicy(enable_widgets=WIDGETS_TO_ENABLE) first_map_id = int(route.segments[0].get('map_id', 0)) - bot_instance.Move.FollowPathAndExitMap( + register_outpost_departure( + bot_instance, route.outpost_path, target_map_id=first_map_id, step_name=f'{route.definition.run_name}_leave_outpost', ) for segment_index, segment in enumerate(route.segments): - segment_path = list(segment.get('path', [])) - if not segment_path: - continue - - bot_instance.Move.FollowAutoPath( - segment_path, - step_name=f'{route.definition.run_name}_segment_{segment_index + 1}', - ) - next_segment = route.segments[segment_index + 1] if segment_index + 1 < len(route.segments) else None current_map_id = int(segment.get('map_id', 0)) next_map_id = int(next_segment.get('map_id', 0)) if next_segment else 0 - if next_map_id and next_map_id != current_map_id: + transition_map_id = next_map_id if next_map_id and next_map_id != current_map_id else 0 + owns_map_travel = register_botting_segment( + bot_instance, + route.definition.run_name, + segment_index, + segment, + target_map_id=transition_map_id, + resume_key_prefix=f'outpost-fighter:{_queue_version}:{index}', + ) + if transition_map_id and not owns_map_travel: bot_instance.Wait.ForMapToChange(target_map_id=next_map_id) bot_instance.Wait.UntilOutOfCombat() @@ -182,7 +189,6 @@ def bot_routine(bot_instance: Botting) -> None: ConsoleLog(BOT_NAME, 'No routes queued.', PySystem.Console.MessageType.Error) return - bot_instance.helpers.Events.set_on_unmanaged_fail(lambda: False) bot_instance.Events.OnPartyWipeCallback(lambda: OnPartyWipe(bot_instance)) bot_instance.States.AddHeader(BOT_NAME) @@ -212,22 +218,22 @@ def _on_party_wipe(bot_instance: Botting): while not Routines.Checks.Map.MapValid() or not Player.IsPlayerLoaded(): yield from Routines.Yield.wait(500) - if not Map.IsOutpost(): - yield from bot_instance.helpers.Multibox._resignParty() - yield from bot_instance.Wait._coro_until_on_outpost() - - if not _current_route_anchor: + if Map.IsOutpost() and _current_route_anchor: + ConsoleLog( + BOT_NAME, + f'Party returned to an outpost. Restarting route {_current_route_index + 1}.', + PySystem.Console.MessageType.Warning, + ) + bot_instance.config.FSM.jump_to_state_by_name(_current_route_anchor) bot_instance.config.FSM.resume() + yield return ConsoleLog( BOT_NAME, - f'Party wiped. Restarting route {_current_route_index + 1} from its starting outpost.', + 'Party revived at the resurrection shrine. Resuming the current route without resigning.', PySystem.Console.MessageType.Warning, ) - if bot_instance.config.FSM.current_state: - bot_instance.config.FSM.current_state.reset() - bot_instance.config.FSM.jump_to_state_by_name(_current_route_anchor) bot_instance.config.FSM.resume() yield @@ -335,7 +341,7 @@ def _draw_help() -> None: PyImGui.bullet_text('Fights enemies that engage the party without chasing unrelated groups.') PyImGui.bullet_text('Eligible drops are collected according to your Loot Filters settings.') PyImGui.bullet_text('The bot always enters explorable areas in Normal Mode.') - PyImGui.bullet_text('A full party wipe restarts the current route from its starting outpost.') + PyImGui.bullet_text('After a full party wipe, the party resurrects at the shrine and continues forward.') bot.SetMainRoutine(bot_routine) diff --git a/Widgets/Automation/Bots/Runners/OutpostRunnerV2.py b/Widgets/Automation/Bots/Runners/OutpostRunnerV2.py index 73529bd3d..1001cef89 100644 --- a/Widgets/Automation/Bots/Runners/OutpostRunnerV2.py +++ b/Widgets/Automation/Bots/Runners/OutpostRunnerV2.py @@ -18,10 +18,17 @@ import math import time import PyImGui +import importlib import importlib.util projects_base_path = PySystem.Console.get_projects_path() ac_folder_path = os.path.join(projects_base_path, "Sources", "aC_Scripts") from Sources.aC_Scripts.aC_api import * +from Sources.aC_Scripts.OutpostRunner import route_mechanics as _route_mechanics + +_route_mechanics = importlib.reload(_route_mechanics) +extended_portal_path = _route_mechanics.extended_portal_path +register_outpost_departure = _route_mechanics.register_outpost_departure +register_botting_segment = _route_mechanics.register_botting_segment RUNS_DIR = os.path.join(ac_folder_path, "OutpostRunner", "maps") MODULE_NAME = "Outpost Runner v2" @@ -140,18 +147,29 @@ def _set_current_index(idx=run_idx): # -- Exit outpost -- first_map_id = run.segments[0]["map_id"] if run.segments else 0 - bot.Move.FollowPathAndExitMap(run.outpost_path, target_map_id=first_map_id) + register_outpost_departure( + bot, + run.outpost_path, + target_map_id=first_map_id, + step_name=f'{run.run_name}_leave_outpost', + ) # -- Follow explorable segments -- for seg_i, entry in enumerate(run.segments): - seg_path = entry.get("path", []) - if seg_path: - next_map_id = ( - run.segments[seg_i + 1]["map_id"] - if seg_i + 1 < len(run.segments) - else entry["map_id"] - ) - bot.Move.FollowAutoPath(seg_path) + next_map_id = ( + run.segments[seg_i + 1]["map_id"] + if seg_i + 1 < len(run.segments) + else entry["map_id"] + ) + transition_map_id = next_map_id if next_map_id != entry["map_id"] else 0 + owns_map_travel = register_botting_segment( + bot, + run.run_name, + seg_i, + entry, + target_map_id=transition_map_id, + ) + if transition_map_id and not owns_map_travel: bot.Wait.ForMapToChange(next_map_id) # All runs finished