diff --git a/module/shop/shop_core.py b/module/shop/shop_core.py index cebf31f331..de56add9ba 100644 --- a/module/shop/shop_core.py +++ b/module/shop/shop_core.py @@ -1,10 +1,23 @@ -from module.base.decorator import cached_property +import cv2 +import numpy as np + +from module.base.button import ButtonGrid +from module.base.decorator import cached_property, del_cached_property +from module.base.template import Template from module.logger import logger +from module.map_detection.utils import Points from module.shop.assets import * from module.shop.base import ShopItemGrid, ShopItemGrid_250814 from module.shop.clerk import ShopClerk from module.shop.shop_status import ShopStatus +TEMPLATE_CORE_ICON = Template('./assets/shop/cost/Core_4.png') +# Item list, used to swipe and to tell whether list has moved +CORE_SHOP_ITEM_AREA = (220, 195, 1050, 640) +# About 1.7 rows, small enough not to skip a row, large enough to make progress +CORE_SHOP_SWIPE_DISTANCE = 380 +CORE_SHOP_SWIPE_LIMIT = 20 + class CoreShop_250814(ShopClerk, ShopStatus): shop_template_folder = './assets/shop/core' @@ -18,6 +31,59 @@ def shop_filter(self): return self.config.CoreShop_Filter.strip() # New UI in 2025-08-14 + def _get_cores(self): + """ + Returns: + np.array: [[x1, y1], [x2, y2]], location of the core icon upper-left corner. + """ + area = (250, 190, 1000, 645) + # copy image because matchTemplate needs a continuous array + image = self.image_crop(area, copy=True) + cores = TEMPLATE_CORE_ICON.match_multi(image, similarity=0.8, threshold=5) + cores = Points([(0., c.area[1]) for c in cores]).group(threshold=5) + logger.attr('Core_icon', len(cores)) + return cores + + @cached_property + def shop_grid(self): + """ + Item rows are only aligned with the static grid when list is at top, + so locate them by the core icon in price bar, like medal shop does. + + Returns: + ButtonGrid: + """ + cores = self._get_cores() + count = len(cores) + if count == 0: + logger.warning('Unable to find core icon, assume item list is at top') + origin_y = 238 + delta_y = 223 + row = 2 + elif count == 1: + y_list = cores[:, 1] + # +190, top of the crop area in _get_cores() + # -129, from the top of core icon to the top of shop item + origin_y = y_list[0] + 190 - 129 + delta_y = 223 + row = 1 + elif count == 2: + y_list = cores[:, 1] + y1, y2 = y_list[0], y_list[1] + origin_y = min(y1, y2) + 190 - 129 + delta_y = abs(y1 - y2) + row = 2 + else: + logger.warning(f'Unexpected core icon match result: {[c for c in cores]}') + origin_y = 238 + delta_y = 223 + row = 2 + + shop_grid = ButtonGrid( + origin=(265, origin_y), delta=(169, delta_y), button_shape=(64, 64), grid_shape=(5, row), + name='SHOP_GRID') + return shop_grid + @cached_property def shop_core_items(self): """ @@ -87,6 +153,58 @@ def shop_buy_handle(self, item): return False + def shop_swipe(self, distance, name='CORE_SHOP_SWIPE'): + """ + Swipe item list and tell whether it actually moved. + + Swiping the list instead of dragging the scrollbar, because the lower half + of the scrollbar overlaps a dark character illustration and loses contrast + there, making scroll position unreadable near the bottom. + + Args: + distance (int): Pixels to swipe, negative to show following items. + name (str): + + Returns: + bool: True if item list moved, False if it already reached the end. + """ + before = self.image_crop(CORE_SHOP_ITEM_AREA, copy=True) + x = (CORE_SHOP_ITEM_AREA[0] + CORE_SHOP_ITEM_AREA[2]) // 2 + y = (CORE_SHOP_ITEM_AREA[1] + CORE_SHOP_ITEM_AREA[3]) // 2 + # drag instead of swipe, so the shake at the end cancels inertia, + # otherwise list keeps sliding and skips rows that were never detected + self.device.drag((x, y - distance // 2), (x, y + distance // 2), segments=2, shake=(0, 25), + point_random=(0, 0, 0, 0), shake_random=(0, -5, 0, 5), name=name) + # Going through the whole list takes more swipes than the too-many-click + # guard allows, drop them as they are expected repetitions + self.device.click_record_remove(name) + self.device.sleep(0.5) + self.device.screenshot() + after = self.image_crop(CORE_SHOP_ITEM_AREA, copy=True) + + # Item cards are static, moving the list gives a difference of tens + difference = float(np.mean(cv2.absdiff(before, after))) + moved = difference > 5 + logger.attr(name, f'{"moved" if moved else "stuck"}, difference={difference:.1f}') + if moved: + del_cached_property(self, 'shop_grid') + del_cached_property(self, 'shop_core_items') + return moved + + def shop_swipe_top(self): + """ + Swipe item list back to top, so no item is missed no matter where the + list was left at. + + Returns: + bool: True if item list reached top, False if swipe limit was reached. + """ + for _ in range(CORE_SHOP_SWIPE_LIMIT): + if not self.shop_swipe(CORE_SHOP_SWIPE_DISTANCE, name='CORE_SHOP_SWIPE_TOP'): + return True + logger.warning('Failed to swipe core shop to top') + return False + def run(self): """ Run Core Shop @@ -99,5 +217,17 @@ def run(self): # correct Core Shop interface logger.hr('Core Shop', level=1) - # Execute buy operations - self.shop_buy() + # Core monthly shop holds about 20 rows of items while only 2 rows are + # visible, items must be scrolled through page by page + if not self.shop_swipe_top(): + return + for _ in range(CORE_SHOP_SWIPE_LIMIT): + # Execute buy operations + if not self.shop_buy(): + break + + if not self.shop_swipe(-CORE_SHOP_SWIPE_DISTANCE, name='CORE_SHOP_SWIPE_NEXT'): + logger.info('Core shop reach bottom, stop') + break + else: + logger.warning('Too many pages in core shop, stopped')