diff --git a/test/npc/test_npc_manager.py b/test/npc/test_npc_manager.py new file mode 100644 index 0000000..05dad5b --- /dev/null +++ b/test/npc/test_npc_manager.py @@ -0,0 +1,317 @@ +""" +Regression tests for open_npc_menu() and press_npc_btn(). + +Bugs from CLAUDE.md covered: + Bug 3: name tag threshold lowered; small ROI above hover prevents wide false positives + Bug 4: min_dist stored per result dict (was stale loop var) — body+pose gate works + Bug 6: dialogue confirmed via action buttons, not stale NPCDialogue/gold-tag template + Bug 7: name tag ROI gate — high-score white text outside NPC ROI does not trigger click +""" +import itertools +import numpy as np +import pytest +import npc_manager as npc_mod +from npc_manager import Npc, open_npc_menu, press_npc_btn + +# --------------------------------------------------------------------------- +# Shared fakes +# --------------------------------------------------------------------------- + +_BLANK = np.zeros((10, 10), dtype=np.uint8) +# Distinct arrays so template identity checks (`template is X`) don't false-match +_TRADE_WHITE = np.zeros((10, 10), dtype=np.uint8); _TRADE_WHITE[0, 0] = 1 +_TRADE_BLUE = np.zeros((10, 10), dtype=np.uint8); _TRADE_BLUE[0, 1] = 1 +_RES_WHITE = np.zeros((10, 10), dtype=np.uint8); _RES_WHITE[1, 0] = 1 +_RES_BLUE = np.zeros((10, 10), dtype=np.uint8); _RES_BLUE[1, 1] = 1 +_RES_RED = np.zeros((10, 10), dtype=np.uint8); _RES_RED[1, 2] = 1 + +# Minimal fake npcs entry for AKARA (has roi and poses — same values as production). +# Body threshold defaults to 0.40, pose tolerance to 150. +_FAKE_NPCS = { + Npc.AKARA: { + "name_tag_white": _BLANK, + "name_tag_gold": _BLANK, + "action_btns": { + "trade": { + "white": _TRADE_WHITE, + "blue": _TRADE_BLUE, + } + }, + "template_group": ["AKARA_FRONT"], + "roi": [605, 176, 399, 302], # x, y, w, h (x: 605–1004) + "poses": [[694, 377], [836, 378], [950, 345], [869, 290], [698, 317]], + }, + Npc.QUAL_KEHK: { + "name_tag_white": _BLANK, + "name_tag_gold": _BLANK, + "action_btns": { + "resurrect": { + "white": _RES_WHITE, + "blue": _RES_BLUE, + "red": _RES_RED, + } + }, + "template_group": ["QUAL_0"], + "roi": [225, 57, 625, 385], + "poses": [[350, 140], [481, 196]], + }, +} + + +class _Match: + def __init__(self, valid=False, score=0.0, center_monitor=(700, 300)): + self.valid = valid + self.score = score + self.center_monitor = center_monitor + + +def _patch_screen(monkeypatch): + """Patch every screen-touching call in npc_manager to a safe no-op.""" + import screen as screen_mod + monkeypatch.setattr(npc_mod, "npcs", _FAKE_NPCS) + monkeypatch.setattr(npc_mod, "grab", lambda *a, **k: _BLANK) + monkeypatch.setattr(npc_mod, "wait", lambda *a, **k: None) + monkeypatch.setattr(npc_mod, "wait_until_hidden", lambda *a, **k: True) + monkeypatch.setattr(npc_mod, "is_visible", lambda *a, **k: False) + monkeypatch.setattr(npc_mod, "color_filter", lambda img, *a, **k: (None, img)) + monkeypatch.setattr(npc_mod, "center_mouse", lambda: None) + monkeypatch.setattr(npc_mod.keyboard, "send", lambda *a, **k: None) + monkeypatch.setattr(npc_mod.mouse, "move", lambda *a, **k: None) + monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: None) + monkeypatch.setattr(npc_mod.mouse, "get_position", lambda: (700, 300)) + monkeypatch.setattr(screen_mod, "convert_monitor_to_screen", lambda pos: pos) + # Patch internal helpers so their time loops don't block + monkeypatch.setattr(npc_mod, "_action_btns_visible", lambda npc_key, img: False) + monkeypatch.setattr(npc_mod, "_wait_action_btns", lambda npc_key, timeout=2.5: False) + + +def _exhaust_time_after_one_iteration(monkeypatch): + """Make time.time() allow one main-loop pass then expire both the NPC loop and + the sweep budget, so tests finish in milliseconds not minutes.""" + # Indices: 0=start, 1=first-while, 2=roi-check, 3=second-while, 4=sweep_start, 5+=sweep-budget + seq = iter([0.0, 0.0, 0.0, 9999.0, 9999.0, 9999.0 + 26.0]) + monkeypatch.setattr(npc_mod.time, "time", lambda: next(seq, 9999.0 + 26.0)) + + +# --------------------------------------------------------------------------- +# _action_btns_visible (direct unit tests — restore real impl first) +# --------------------------------------------------------------------------- + +class TestActionBtnsVisible: + def setup_method(self): + # Restore the real function so we test its logic, not a patched stub. + import importlib + importlib.reload(npc_mod) # pick up any earlier monkeypatches being torn down + + def test_returns_false_when_npc_missing_from_dict(self, monkeypatch): + monkeypatch.setattr(npc_mod, "npcs", {}) + assert npc_mod._action_btns_visible(Npc.AKARA, _BLANK) is False + + def test_returns_true_when_white_btn_found(self, monkeypatch): + monkeypatch.setattr(npc_mod, "npcs", _FAKE_NPCS) + monkeypatch.setattr(npc_mod, "color_filter", lambda img, *a, **k: (None, img)) + monkeypatch.setattr(npc_mod.template_finder, "search", + lambda *a, **k: _Match(valid=True, score=0.9)) + assert npc_mod._action_btns_visible(Npc.AKARA, _BLANK) is True + + def test_returns_false_when_no_btn_found(self, monkeypatch): + monkeypatch.setattr(npc_mod, "npcs", _FAKE_NPCS) + monkeypatch.setattr(npc_mod, "color_filter", lambda img, *a, **k: (None, img)) + monkeypatch.setattr(npc_mod.template_finder, "search", + lambda *a, **k: _Match(valid=False)) + assert npc_mod._action_btns_visible(Npc.AKARA, _BLANK) is False + + +# --------------------------------------------------------------------------- +# open_npc_menu — decision-path tests +# --------------------------------------------------------------------------- + +class TestOpenNpcMenu: + def test_fast_path_returns_true_when_dialogue_already_open(self, monkeypatch): + """If action buttons are already visible on entry, return True with no clicks.""" + _patch_screen(monkeypatch) + clicks = [] + monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1)) + # Override the stub: dialogue IS open from the first grab + monkeypatch.setattr(npc_mod, "_action_btns_visible", lambda npc_key, img: True) + + result = open_npc_menu(Npc.AKARA) + + assert result is True + assert len(clicks) == 0 # no physical click needed (Bug 6: fast-path via action btns) + + def test_name_tag_in_roi_click_confirmed_by_action_btns(self, monkeypatch): + """Name tag confirmed inside ROI → click → action buttons appear → True. (Bug 3, Bug 6)""" + _patch_screen(monkeypatch) + _exhaust_time_after_one_iteration(monkeypatch) + clicks = [] + monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1)) + + # action_btns_visible: False pre-click, True post-click + visible_calls = [0] + def _avb(npc_key, img): + visible_calls[0] += 1 + return visible_calls[0] > 1 + monkeypatch.setattr(npc_mod, "_action_btns_visible", _avb) + monkeypatch.setattr(npc_mod, "_wait_action_btns", lambda npc_key, timeout=2.5: True) + + def _search(template, img, threshold=0.5, *args, **kwargs): + if isinstance(template, str): # body template search + # Position (700, 300) is inside Akara's ROI (x: 605–1004, y: 176–478) + return _Match(valid=True, score=0.85, center_monitor=(700, 300)) + return _Match(valid=True, score=0.9) # name tag also valid + + monkeypatch.setattr(npc_mod.template_finder, "search", _search) + + result = open_npc_menu(Npc.AKARA) + + assert result is True + assert len(clicks) >= 1 + + def test_bug7_name_tag_outside_roi_is_not_clicked(self, monkeypatch): + """Bug 7: high-score name tag at x=200 (outside Akara ROI x≥605) must not click. + The sweep phase is also blocked from clicking by returning invalid name tags.""" + _patch_screen(monkeypatch) + _exhaust_time_after_one_iteration(monkeypatch) + clicks = [] + monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1)) + + def _search(template, img, threshold=0.5, *args, **kwargs): + if isinstance(template, str): + # Body at x=200 — outside Akara's ROI [x: 605–1004] + return _Match(valid=True, score=0.98, center_monitor=(200, 300)) + # Name tag always invalid — sweep phase won't click either + return _Match(valid=False, score=0.1) + + monkeypatch.setattr(npc_mod.template_finder, "search", _search) + + result = open_npc_menu(Npc.AKARA) + + assert result is False + assert len(clicks) == 0 # ROI gate + invalid name tag = zero clicks + + def test_bug4_body_pose_too_far_is_not_clicked(self, monkeypatch): + """Bug 4: body match at (900,100) has min_dist≈192px > 150 tolerance → no click.""" + _patch_screen(monkeypatch) + _exhaust_time_after_one_iteration(monkeypatch) + clicks = [] + monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1)) + + def _search(template, img, threshold=0.5, *args, **kwargs): + if isinstance(template, str): + # Inside Akara's ROI but far from any pose: + # nearest pose (869,290) → dist=sqrt((900-869)²+(100-290)²)=sqrt(961+36100)≈192 > 150 + return _Match(valid=True, score=0.50, center_monitor=(900, 100)) + return _Match(valid=False) # no name tag visible + + monkeypatch.setattr(npc_mod.template_finder, "search", _search) + + result = open_npc_menu(Npc.AKARA) + + assert result is False + assert len(clicks) == 0 # pose-distance gate blocked the click (Bug 4 fix) + + def test_returns_false_when_no_npc_found_and_time_expires(self, monkeypatch): + """When no template matches at all, function returns False without hanging.""" + _patch_screen(monkeypatch) + _exhaust_time_after_one_iteration(monkeypatch) + monkeypatch.setattr(npc_mod.template_finder, "search", + lambda *a, **k: _Match(valid=False)) + + result = open_npc_menu(Npc.AKARA) + + assert result is False + + def test_body_and_pose_confirmed_triggers_click(self, monkeypatch): + """Body score ≥ 0.40 AND min_dist < 150 should click (no name tag required).""" + _patch_screen(monkeypatch) + _exhaust_time_after_one_iteration(monkeypatch) + clicks = [] + monkeypatch.setattr(npc_mod.mouse, "click", lambda **k: clicks.append(1)) + monkeypatch.setattr(npc_mod, "_wait_action_btns", lambda npc_key, timeout=2.5: True) + + def _search(template, img, threshold=0.5, *args, **kwargs): + if isinstance(template, str): + # Inside ROI, close to pose (694,377): dist≈0 + return _Match(valid=True, score=0.48, center_monitor=(694, 377)) + return _Match(valid=False) # no name tag + + monkeypatch.setattr(npc_mod.template_finder, "search", _search) + + result = open_npc_menu(Npc.AKARA) + + assert result is True + assert len(clicks) >= 1 + + +# --------------------------------------------------------------------------- +# press_npc_btn — search fallback chain +# --------------------------------------------------------------------------- + +class TestPressNpcBtn: + def _base(self, monkeypatch): + monkeypatch.setattr(npc_mod, "npcs", _FAKE_NPCS) + monkeypatch.setattr(npc_mod, "grab", lambda *a, **k: _BLANK) + monkeypatch.setattr(npc_mod, "wait", lambda *a, **k: None) + monkeypatch.setattr(npc_mod, "color_filter", lambda img, *a, **k: (None, img)) + monkeypatch.setattr(npc_mod, "center_mouse", lambda: None) + monkeypatch.setattr(npc_mod.keyboard, "send", lambda *a, **k: None) + monkeypatch.setattr(npc_mod.mouse, "move", lambda *a, **k: None) + self.clicks = [] + monkeypatch.setattr(npc_mod.mouse, "click", + lambda **k: self.clicks.append(1)) + + def test_white_button_found_returns_true(self, monkeypatch): + self._base(monkeypatch) + monkeypatch.setattr(npc_mod.template_finder, "search", + lambda *a, **k: _Match(valid=True, score=0.9, + center_monitor=(640, 360))) + assert press_npc_btn(Npc.AKARA, "trade") is True + assert len(self.clicks) == 1 + + def test_blue_fallback_returns_true(self, monkeypatch): + self._base(monkeypatch) + blue_tmpl = _TRADE_BLUE + + def _search(template, img, *args, use_grayscale=False, **kwargs): + if template is blue_tmpl and not use_grayscale: + return _Match(valid=True, score=0.9, center_monitor=(640, 360)) + return _Match(valid=False, score=0.1) + + monkeypatch.setattr(npc_mod.template_finder, "search", _search) + assert press_npc_btn(Npc.AKARA, "trade") is True + assert len(self.clicks) == 1 + + def test_grayscale_fallback_returns_true(self, monkeypatch): + self._base(monkeypatch) + + def _search(template, img, *args, use_grayscale=False, **kwargs): + if use_grayscale: + return _Match(valid=True, score=0.85, center_monitor=(640, 360)) + return _Match(valid=False, score=0.1) + + monkeypatch.setattr(npc_mod.template_finder, "search", _search) + assert press_npc_btn(Npc.AKARA, "trade") is True + assert len(self.clicks) == 1 + + def test_red_button_means_cannot_afford(self, monkeypatch): + """Red resurrect button means 'can't afford merc revival' → returns False.""" + self._base(monkeypatch) + + def _search(template, img, *args, use_grayscale=False, **kwargs): + # Only the distinct red template matches (not white or blue) + if template is _RES_RED and not use_grayscale: + return _Match(valid=True, score=0.9, center_monitor=(640, 360)) + return _Match(valid=False, score=0.1) + + monkeypatch.setattr(npc_mod.template_finder, "search", _search) + result = press_npc_btn(Npc.QUAL_KEHK, "resurrect") + assert result is False + assert len(self.clicks) == 0 # no click on red button + + def test_nothing_found_returns_false(self, monkeypatch): + self._base(monkeypatch) + monkeypatch.setattr(npc_mod.template_finder, "search", + lambda *a, **k: _Match(valid=False, score=0.1)) + assert press_npc_btn(Npc.AKARA, "trade") is False + assert len(self.clicks) == 0 diff --git a/test/town/test_town_manager.py b/test/town/test_town_manager.py new file mode 100644 index 0000000..11fd8c9 --- /dev/null +++ b/test/town/test_town_manager.py @@ -0,0 +1,348 @@ +""" +Regression tests for TownManager — the orchestrator of all town NPC interactions. + +Bugs from CLAUDE.md covered: + Bug 1: identify() isinstance(success, Location) guard — True never returned as a location + Bug 5: identify() returns new_loc (not False) when A5 Cain also fails — preserves act tracking + Bug 9: open_wp() per-game failure budget — budget exhausted → fail fast instead of looping + Bug 10: buy_consumables() propagates trade-menu failure → (False, items) +""" +import numpy as np +import pytest +from pather import Location +from town.town_manager import TownManager + +# --------------------------------------------------------------------------- +# Shared fakes +# --------------------------------------------------------------------------- + +class _FakeAct: + """Configurable fake IAct — override attributes per test.""" + _can_heal = False + _can_buy_pots = True + _can_resurrect = False + _can_identify = True + _can_stash = True + _can_trade_repair = False + _can_gamble = False + _wp_loc = Location.A5_WP + _identify_result = Location.A5_QUAL_KEHK # success by default + _trade_menu_result = Location.A5_MALAH # success by default + _open_stash_result = Location.A5_STASH + _open_wp_result = True + + def can_heal(self): return self._can_heal + def can_buy_pots(self): return self._can_buy_pots + def can_resurrect(self): return self._can_resurrect + def can_identify(self): return self._can_identify + def can_stash(self): return self._can_stash + def can_trade_and_repair(self):return self._can_trade_repair + def can_gamble(self): return self._can_gamble + def get_wp_location(self): return self._wp_loc + def open_wp(self, curr_loc, quick=False): return self._open_wp_result + def heal(self, curr_loc): return curr_loc + def open_trade_menu(self, curr_loc): return self._trade_menu_result + def resurrect(self, curr_loc): return False + def identify(self, curr_loc): return self._identify_result + def open_stash(self, curr_loc):return self._open_stash_result + def open_trade_and_repair_menu(self, curr_loc): return False + def gamble(self, curr_loc): return False + def wait_for_tp(self): return True + + +def _build_manager(a5_act=None): + """Return a TownManager wired with fake acts (A5 customisable, others generic).""" + fake = _FakeAct() + a5 = a5_act or fake + return TownManager(a1=fake, a2=fake, a3=fake, a4=fake, a5=a5) + + +def _patch_side_effects(monkeypatch): + """Silence every side-effecting call the TownManager makes during tests.""" + import health_manager + import town.town_manager as tm_mod + + monkeypatch.setattr(health_manager, "set_panel_check_paused", lambda v: None) + monkeypatch.setattr(tm_mod, "wait", lambda *a, **k: None) + monkeypatch.setattr(tm_mod, "grab", lambda *a, **k: np.zeros((4, 4, 3), dtype=np.uint8)) + monkeypatch.setattr(tm_mod.common, "wait_for_left_inventory", lambda: True) + monkeypatch.setattr(tm_mod.common, "close", lambda: None) + monkeypatch.setattr(tm_mod.keyboard, "send", lambda *a, **k: None) + monkeypatch.setattr(tm_mod.view, "return_to_play", lambda: None) + # consumables: no restocking needed + monkeypatch.setattr(tm_mod.consumables, "get_needs", lambda kind: 0) + # stash helpers + monkeypatch.setattr(tm_mod.personal, "stash_all_items", lambda items, **k: []) + monkeypatch.setattr(tm_mod, "convert_rejuv_potions", lambda: None) + # detect_current_act must not touch the real screen — return None (no correction) + monkeypatch.setattr(TownManager, "detect_current_act", lambda self, *a, **k: None) + + +# --------------------------------------------------------------------------- +# get_act_from_location — pure function, no mocking needed +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("loc, expected", [ + (Location.A1_TOWN_START, Location.A1_TOWN_START), + (Location.A2_TOWN_START, Location.A2_TOWN_START), + (Location.A3_TOWN_START, Location.A3_TOWN_START), + (Location.A4_TOWN_START, Location.A4_TOWN_START), + (Location.A5_TOWN_START, Location.A5_TOWN_START), + (Location.A5_QUAL_KEHK, Location.A5_TOWN_START), + (Location.A5_WP, Location.A5_TOWN_START), + (Location.A1_ANDY_SAFE_DIST, Location.A1_TOWN_START), +]) +def test_get_act_from_location_maps_correctly(loc, expected): + assert TownManager.get_act_from_location(loc) == expected + + +@pytest.mark.parametrize("loc", [None, True, 123, "UNKNOWN_LOC"]) +def test_get_act_from_location_returns_none_for_bad_input(loc): + result = TownManager.get_act_from_location(loc) + # "UNKNOWN_LOC" has no act prefix → None; non-strings → None too + assert result is None or isinstance(result, str) + + +# --------------------------------------------------------------------------- +# identify — Bug 1 (isinstance guard) and Bug 5 (preserve act on fallback) +# --------------------------------------------------------------------------- + +class TestIdentify: + def test_success_returns_location_not_bool(self, monkeypatch): + """Bug 1: identify() must never return True even if the act returns True. + Location is a plain class (not Enum), so isinstance(success, Location) is always + False — identify() always returns curr_loc for the current-act success path.""" + _patch_side_effects(monkeypatch) + mgr = _build_manager() + # Act returns True (the old bug value) — must NOT propagate + mgr._acts[Location.A5_TOWN_START]._identify_result = True + + result = mgr.identify(Location.A5_TOWN_START) + + assert result is not True # Bug 1: True must never escape identify() + assert isinstance(result, str) # must return a location string + + def test_cain_failure_falls_back_to_a5(self, monkeypatch): + """When A1 Cain fails, TownManager travels to A5 and tries Cain there. + Return value is the A5 location reached (go_to_act returns the WP loc).""" + _patch_side_effects(monkeypatch) + a1_act = _FakeAct() + a1_act._identify_result = False # A1 Cain unavailable + + import town.town_manager as tm_mod + monkeypatch.setattr(tm_mod.waypoint, "use_wp", lambda *a, **k: True) + a5_act = _FakeAct() + a5_act._identify_result = Location.A5_QUAL_KEHK + + mgr = TownManager(a1=a1_act, a2=_FakeAct(), a3=_FakeAct(), + a4=_FakeAct(), a5=a5_act) + monkeypatch.setattr(mgr, "detect_current_act", lambda *a, **k: None) + monkeypatch.setattr(mgr, "open_wp", lambda *a, **k: True) + + result = mgr.identify(Location.A1_TOWN_START) + + # Must have reached A5 (go_to_act returns a5_wp from get_wp_location()) + assert result is not False + assert isinstance(result, str) + assert result.startswith("a5_") + + def test_bug5_a5_cain_failure_returns_new_loc_not_false(self, monkeypatch): + """Bug 5: even when A5 Cain fails, identify() returns the travelled-to location + so on_maintenance() knows the char is now in A5 (not still in A1).""" + _patch_side_effects(monkeypatch) + import town.town_manager as tm_mod + monkeypatch.setattr(tm_mod.waypoint, "use_wp", lambda *a, **k: True) + + a5_act = _FakeAct() + a5_act._identify_result = False # A5 Cain also unavailable + + mgr = TownManager(a1=_FakeAct(), a2=_FakeAct(), a3=_FakeAct(), + a4=_FakeAct(), a5=a5_act) + monkeypatch.setattr(mgr, "detect_current_act", lambda *a, **k: None) + monkeypatch.setattr(mgr, "open_wp", lambda *a, **k: True) + + result = mgr.identify(Location.A1_TOWN_START) + + # Must NOT return False — caller would wrongly believe char is still in A1 + assert result is not False + assert isinstance(result, str) # a Location string + + def test_skips_act_in_cain_failed_set(self, monkeypatch): + """Acts in _cain_failed_acts are skipped without calling identify() on the act.""" + _patch_side_effects(monkeypatch) + import town.town_manager as tm_mod + monkeypatch.setattr(tm_mod.waypoint, "use_wp", lambda *a, **k: True) + + a1_act = _FakeAct() + identify_calls = [] + original_identify = a1_act.identify + a1_act.identify = lambda loc: identify_calls.append(loc) or original_identify(loc) + + a5_act = _FakeAct() + a5_act._identify_result = Location.A5_QUAL_KEHK + + mgr = TownManager(a1=a1_act, a2=_FakeAct(), a3=_FakeAct(), + a4=_FakeAct(), a5=a5_act) + monkeypatch.setattr(mgr, "detect_current_act", lambda *a, **k: None) + monkeypatch.setattr(mgr, "open_wp", lambda *a, **k: True) + mgr._cain_failed_acts.add(Location.A1_TOWN_START) + + mgr.identify(Location.A1_TOWN_START) + + assert len(identify_calls) == 0 # A1 Cain was skipped entirely + + +# --------------------------------------------------------------------------- +# open_wp — Bug 9: per-game WP failure budget +# --------------------------------------------------------------------------- + +class TestOpenWp: + def test_first_failure_increments_budget(self, monkeypatch): + _patch_side_effects(monkeypatch) + a5 = _FakeAct() + a5._open_wp_result = False + mgr = _build_manager(a5_act=a5) + + result = mgr.open_wp(Location.A5_TOWN_START) + + assert result is False + assert mgr._wp_fails_this_game == 1 + + def test_second_failure_exhausts_budget(self, monkeypatch): + _patch_side_effects(monkeypatch) + a5 = _FakeAct() + a5._open_wp_result = False + mgr = _build_manager(a5_act=a5) + mgr._wp_fails_this_game = 1 # already one failure + + result = mgr.open_wp(Location.A5_TOWN_START) + + assert result is False + assert mgr._wp_fails_this_game == 2 + + def test_budget_exhausted_returns_false_without_calling_act(self, monkeypatch): + """Bug 9: once budget is gone, open_wp bails immediately — no act.open_wp call.""" + _patch_side_effects(monkeypatch) + a5 = _FakeAct() + wp_calls = [] + a5.open_wp = lambda *a, **k: wp_calls.append(1) or False + mgr = _build_manager(a5_act=a5) + mgr._wp_fails_this_game = 2 # budget already gone + + result = mgr.open_wp(Location.A5_TOWN_START) + + assert result is False + assert len(wp_calls) == 0 # act never contacted + + def test_reset_wp_budget_clears_counter(self, monkeypatch): + _patch_side_effects(monkeypatch) + mgr = _build_manager() + mgr._wp_fails_this_game = 2 + + mgr.reset_wp_budget() + + assert mgr._wp_fails_this_game == 0 + + def test_success_does_not_increment_budget(self, monkeypatch): + _patch_side_effects(monkeypatch) + a5 = _FakeAct() + a5._open_wp_result = True + mgr = _build_manager(a5_act=a5) + + result = mgr.open_wp(Location.A5_TOWN_START) + + assert result is True + assert mgr._wp_fails_this_game == 0 + + +# --------------------------------------------------------------------------- +# buy_consumables — failure propagation +# --------------------------------------------------------------------------- + +class TestBuyConsumables: + def test_returns_false_for_unknown_location(self, monkeypatch): + _patch_side_effects(monkeypatch) + mgr = _build_manager() + + loc, items = mgr.buy_consumables("BOGUS_LOCATION") + + assert loc is False + + def test_returns_false_when_trade_menu_fails(self, monkeypatch): + """Bug 10: a failed open_trade_menu must propagate as (False, items).""" + _patch_side_effects(monkeypatch) + a5 = _FakeAct() + a5._trade_menu_result = False # vendor interaction failed + + mgr = _build_manager(a5_act=a5) + loc, items = mgr.buy_consumables(Location.A5_TOWN_START) + + assert loc is False + + def test_success_returns_new_location(self, monkeypatch): + _patch_side_effects(monkeypatch) + a5 = _FakeAct() + a5._trade_menu_result = Location.A5_MALAH + + mgr = _build_manager(a5_act=a5) + loc, items = mgr.buy_consumables(Location.A5_TOWN_START) + + assert loc == Location.A5_MALAH + + def test_skips_purchase_when_no_needs(self, monkeypatch): + """With all consumable needs = 0, no buy_item calls should be made.""" + _patch_side_effects(monkeypatch) + import town.town_manager as tm_mod + + buy_calls = [] + import inventory.vendor as vendor_mod + monkeypatch.setattr(vendor_mod, "buy_item", lambda *a, **k: buy_calls.append(1) or False) + + mgr = _build_manager() + mgr.buy_consumables(Location.A5_TOWN_START) + + assert len(buy_calls) == 0 + + +# --------------------------------------------------------------------------- +# stash — act delegation and A5 fallback +# --------------------------------------------------------------------------- + +class TestStash: + def test_delegates_to_current_act_when_stash_available(self, monkeypatch): + _patch_side_effects(monkeypatch) + a5 = _FakeAct() + a5._can_stash = True + a5._open_stash_result = Location.A5_STASH + open_stash_calls = [] + a5.open_stash = lambda loc: open_stash_calls.append(loc) or Location.A5_STASH + + mgr = _build_manager(a5_act=a5) + new_loc, items = mgr.stash(Location.A5_TOWN_START) + + assert len(open_stash_calls) == 1 + assert new_loc == Location.A5_STASH + + def test_goes_to_a5_when_current_act_cannot_stash(self, monkeypatch): + _patch_side_effects(monkeypatch) + import town.town_manager as tm_mod + monkeypatch.setattr(tm_mod.waypoint, "use_wp", lambda *a, **k: True) + + # A2 can't stash — should travel to A5 + class _NoStashAct(_FakeAct): + _can_stash = False + def open_stash(self, loc): return False + + a5 = _FakeAct() + a5._can_stash = True + a5_stash_calls = [] + a5.open_stash = lambda loc: a5_stash_calls.append(loc) or Location.A5_STASH + + mgr = TownManager(a1=_FakeAct(), a2=_NoStashAct(), a3=_FakeAct(), + a4=_FakeAct(), a5=a5) + monkeypatch.setattr(mgr, "detect_current_act", lambda *a, **k: None) + monkeypatch.setattr(mgr, "open_wp", lambda *a, **k: True) + + new_loc, items = mgr.stash(Location.A2_TOWN_START) + + assert len(a5_stash_calls) == 1 # traveled to A5 stash