├── .gitignore ├── LICENSE ├── README.md ├── capture ├── __init__.py ├── file_loader.py ├── interface.py └── video_capture.py ├── controller ├── __init__.py ├── dummy.py ├── interface.py ├── nxbt.py ├── nxbt_server │ ├── README.md │ ├── app.py │ ├── copy.sh │ ├── nxbt │ │ ├── __init__.py │ │ ├── bluez.py │ │ ├── controller │ │ │ ├── __init__.py │ │ │ ├── controller.py │ │ │ ├── input.py │ │ │ ├── protocol.py │ │ │ ├── sdp │ │ │ │ └── switch-controller.xml │ │ │ ├── server.py │ │ │ └── utils.py │ │ ├── logging.py │ │ ├── nxbt.py │ │ └── tui.py │ └── requirement.txt └── r3.py ├── logger ├── __init__.py └── logger.py ├── main.py ├── portal ├── __init__.py ├── debug │ ├── __init__.py │ ├── debug.py │ ├── debugger.py │ └── templates │ │ ├── debug.html │ │ └── page.html ├── home │ ├── __init__.py │ ├── capture.py │ ├── closer.py │ ├── home.py │ ├── keymap.py │ └── templates │ │ └── home.html ├── profiles │ └── default.json └── util │ ├── __init__.py │ └── rwlock.py ├── requirements.txt ├── tableturf ├── __init__.py ├── ai │ ├── __init__.py │ ├── alpha │ │ ├── __init__.py │ │ ├── alpha.py │ │ └── util.py │ ├── interface.py │ └── simple.py ├── manager │ ├── __init__.py │ ├── action │ │ ├── __init__.py │ │ ├── card.py │ │ ├── deck.py │ │ ├── giveup.py │ │ ├── hands.py │ │ ├── redraw.py │ │ ├── replay.py │ │ └── util.py │ ├── closer │ │ ├── __init__.py │ │ ├── interface.py │ │ ├── stats_closer.py │ │ └── union_closer.py │ ├── data.py │ ├── detection │ │ ├── __init__.py │ │ ├── card.py │ │ ├── debugger │ │ │ ├── __init__.py │ │ │ ├── cv.py │ │ │ └── interface.py │ │ ├── deck.py │ │ ├── stage.py │ │ ├── ui.py │ │ └── util.py │ └── tableturf.py └── model │ ├── __init__.py │ ├── card.py │ ├── grid.py │ ├── stage.py │ ├── status.py │ └── step.py └── tests ├── __init__.py ├── test_capture ├── __init__.py └── test_video_capture.py ├── test_controller ├── __init__.py ├── test_interface.py └── test_nxbt.py └── test_tableturf ├── __init__.py ├── test_ai ├── __init__.py └── test_alpha.py ├── test_detection ├── __init__.py ├── test_card.py ├── test_deck.py ├── test_stage.py └── test_ui.py └── test_model ├── __init__.py ├── test_card.py ├── test_stage.py └── test_status.py /.gitignore: -------------------------------------------------------------------------------- 1 | temp 2 | venv 3 | __pycache__ 4 | .idea 5 | .vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NOTICE 2 | Due to the recent UI update in the game, all detection box positions must be updated before running this project. https://github.com/fga401/AutoTableTurf/issues/9 3 | 4 | # AutoTableTurf 5 | 6 | Automate the Tableturf game helping you reach Level 999 and get all sleeves. The script is based on image recognition and bluetooth emulator to auto play Tableturf. 7 | 8 | ![image](https://user-images.githubusercontent.com/36651740/194977551-2014cff7-5fe4-4964-aad9-7a467aba9aef.png) 9 | 10 | ## Features 11 | 12 | - Simple web portal. 13 | - AI that can beat Level 3 NPC. 14 | - Complete flow control. Once win a NPC 30 times, auto switch to the next one. 15 | 16 | ## Getting Started 17 | 18 | prerequisite: 19 | 20 | - Python >= 3.9.0 21 | - Bluetooth adapter. Tested on 22 | - Windows Subsystem for Linux with a motherboard built-in bluetooth device. 23 | - Raspberry 4B. 24 | - Capture card. Tested on Razer Ripsaw HD. 25 | 26 | > Note: all parameters about image recognition are based on Razer Ripsaw HD. It may need to finetune for other devices. 27 | 28 | Install the requirements: 29 | 30 | ```bash 31 | sudo pip3 install -r requirement.txt 32 | ``` 33 | 34 | Setup and run the virtual controller server on the device which has Bluetooth adapter. Please refer 35 | to: https://github.com/fga401/AutoTableTurf/tree/master/controller/nxbt_server 36 | 37 | Run the web portal: 38 | 39 | ```bash 40 | export FLASK_APP=portal 41 | sudo python3 -m flask run --host=0.0.0.0 42 | ``` 43 | 44 | On the web portal: 45 | 46 | 1. enter the virtual controller server endpoint and click `Connect`. If successful, you can control your Switch by 47 | keyboard. 48 | 2. Choose the correct webcam whose source is Switch. 49 | 3. Write the profile on the right side. 50 | 4. Go to the NPC selection page. 51 | 5. [Optional] Set the timer to auto stop. Also, you can check the checkbox `Turn off Switch after stop`. 52 | 6. Click 'Run'. 53 | 54 | ![image](https://user-images.githubusercontent.com/36651740/226627357-4169bf07-ee44-4739-915c-4413efcae0fe.png) 55 | 56 | Profile example: 57 | ```json 58 | [ 59 | { 60 | "current_level": 1, 61 | "current_win": 2, 62 | "target_level": 3, 63 | "target_win": 30, 64 | "deck": 0 65 | }, 66 | { 67 | "current_level": 3, 68 | "current_win": 12, 69 | "target_level": 3, 70 | "target_win": 30, 71 | "deck": 1 72 | } 73 | ] 74 | ``` 75 | Each block represents the configuration of an NPC. The above profile performs the following actions: 76 | 1. Use Deck 0 to play against the first NPC Level 1 until one win. 77 | 2. Use Deck 0 to play against the first NPC Level 2 until three wins. 78 | 3. Use Deck 0 to play against the first NPC Level 3 until thirty wins. 79 | 4. Use Deck 1 to play against the second NPC Level 3 until eighteen wins. 80 | 81 | ## Demo 82 | 1. [Splatoon3 AutoTableTurf Demo (1/2)](https://youtu.be/6ZauIWV1sGA) 83 | 2. [Splatoon3 AutoTableTurf Demo (2/2)](https://youtu.be/AXANkU0uDiA) 84 | 85 | ## Plan 86 | 87 | - [x] Virtual controller API 88 | - [x] Screen capturing 89 | - [x] Screen recognition & Game flow testing 90 | - [x] Smarter AI 91 | - [x] User-friendly interface 92 | 93 | ## Known issues 94 | 1. don't use Deck 11, which is prone to crash. [#4](https://github.com/fga401/AutoTableTurf/issues/4) 95 | 96 | ## Credits 97 | 98 | Many thanks to all below repositories: 99 | 100 | - https://github.com/Brikwerk/nxbt for implementing a Python API of Switch controller. 101 | -------------------------------------------------------------------------------- /capture/__init__.py: -------------------------------------------------------------------------------- 1 | from capture.file_loader import FileLoader 2 | from capture.interface import Capture 3 | from capture.video_capture import VideoCapture 4 | -------------------------------------------------------------------------------- /capture/file_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | from capture.interface import Capture 7 | 8 | 9 | class FileLoader(Capture): 10 | def __init__(self, *, files=None, path=None): 11 | """ 12 | Load .npy files as screen capture, which is used for test. 13 | 14 | :param files: List of paths to .npy. If provided, it will use these files as output and ignore the param path. 15 | :param path: A base path. If provided, it will load all .npy files under this path. 16 | """ 17 | self.__files = [] 18 | if files is not None: 19 | self.__files = files 20 | elif path is not None: 21 | self.__files = [os.path.join(path, f) for f in os.listdir(path) if os.path.isfile(os.path.join(path, f)) and f.endswith('.jpg')] 22 | self.__idx = 0 23 | 24 | def capture(self) -> np.ndarray: 25 | frame = cv2.imread(self.__files[self.__idx]) 26 | self.__idx = (self.__idx + 1) % len(self.__files) 27 | height, width, _ = frame.shape 28 | if height != 1080 or width != 1920: 29 | frame = cv2.resize(frame, (1920, 1080)) 30 | return frame 31 | 32 | def close(self): 33 | return 34 | -------------------------------------------------------------------------------- /capture/interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | import numpy as np 4 | 5 | 6 | class Capture(ABC): 7 | @abstractmethod 8 | def capture(self) -> np.ndarray: 9 | raise NotImplementedError 10 | 11 | @abstractmethod 12 | def close(self): 13 | raise NotImplementedError 14 | -------------------------------------------------------------------------------- /capture/video_capture.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from capture.interface import Capture 5 | from logger import logger 6 | 7 | 8 | class VideoCapture(Capture): 9 | def __init__(self, device_idx): 10 | """ 11 | Capture from the connected Camera. 12 | 13 | :param device_idx: Camera index. 14 | """ 15 | self.__cam = cv2.VideoCapture(device_idx) 16 | 17 | @property 18 | def width(self): 19 | return self.__cam.get(cv2.CAP_PROP_FRAME_WIDTH) 20 | 21 | @property 22 | def height(self): 23 | return self.__cam.get(cv2.CAP_PROP_FRAME_HEIGHT) 24 | 25 | def show(self, name='image'): 26 | cv2.namedWindow(name) 27 | ret, frame = self.__cam.read() 28 | if not ret: 29 | raise Exception('failed to grab frame') 30 | cv2.imshow(name, frame) 31 | 32 | def __print_debug_info(event, x, y, flags, param): 33 | if event == cv2.EVENT_LBUTTONDOWN: 34 | bgr = frame[y:y + 1, x:x + 1] 35 | hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) 36 | logger.debug(f'detection.debug: x={x}, y={y}, BGR={bgr.squeeze()}, HSV={hsv.squeeze()}') 37 | 38 | cv2.setMouseCallback(name, __print_debug_info) 39 | cv2.waitKey() 40 | cv2.destroyAllWindows() 41 | 42 | def save(self, name): 43 | img = self.capture() 44 | cv2.imwrite(name + '.jpg', img) 45 | 46 | def capture(self) -> np.ndarray: 47 | ret, frame = self.__cam.read() 48 | if not ret: 49 | raise Exception('failed to grab frame') 50 | return frame 51 | 52 | def close(self): 53 | self.__cam.release() 54 | -------------------------------------------------------------------------------- /controller/__init__.py: -------------------------------------------------------------------------------- 1 | from controller.dummy import DummyController 2 | from controller.interface import Controller 3 | from controller.nxbt import NxbtController 4 | -------------------------------------------------------------------------------- /controller/dummy.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from controller.interface import Controller 4 | from logger import logger 5 | 6 | 7 | class DummyController(Controller): 8 | def __init__(self, block=True): 9 | self.__block = block 10 | 11 | def press_buttons(self, buttons: List[Controller.Button], down: float = 0.1, up: float = 0.1, block=True) -> bool: 12 | logger.info(f'press_buttons: buttons={buttons}, down={down}, up={up}, block={block}') 13 | if self.__block: 14 | input() 15 | return True 16 | 17 | def tilt_stick(self, stick: Controller.Stick, x: int, y: int, tilted: float = 0.1, released: float = 0.1, block=True) -> bool: 18 | logger.info(f'tilt_stick: stick={stick}, x={x}, y={y}, tilted={tilted}, released={released}, block={block}') 19 | if self.__block: 20 | input() 21 | return True 22 | 23 | # def macro(self, macro: str, block=True) -> bool: 24 | # logger.info(f'macro: macro=\n"""\n{macro.strip()}\n"""\n, block={block}') 25 | # if self.__block: 26 | # input() 27 | # return True 28 | -------------------------------------------------------------------------------- /controller/interface.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from abc import ABC, abstractmethod 3 | from enum import Enum 4 | from typing import List 5 | 6 | from logger import logger 7 | 8 | 9 | class Controller(ABC): 10 | class Button(Enum): 11 | Y = 'Y' 12 | X = 'X' 13 | B = 'B' 14 | A = 'A' 15 | JCL_SR = 'JCL_SR' 16 | JCL_SL = 'JCL_SL' 17 | R = 'R' 18 | ZR = 'ZR' 19 | MINUS = 'MINUS' 20 | PLUS = 'PLUS' 21 | R_STICK_PRESS = 'R_STICK_PRESS' 22 | L_STICK_PRESS = 'L_STICK_PRESS' 23 | HOME = 'HOME' 24 | CAPTURE = 'CAPTURE' 25 | DPAD_DOWN = 'DPAD_DOWN' 26 | DPAD_UP = 'DPAD_UP' 27 | DPAD_RIGHT = 'DPAD_RIGHT' 28 | DPAD_LEFT = 'DPAD_LEFT' 29 | JCR_SR = 'JCR_SR' 30 | JCR_SL = 'JCR_SL' 31 | L = 'L' 32 | ZL = 'ZL' 33 | 34 | class Stick(Enum): 35 | R_STICK = 'R_STICK' 36 | L_STICK = 'L_STICK' 37 | 38 | @abstractmethod 39 | def press_buttons(self, buttons: List[Button], down: float = 0.08, up: float = 0.08, block=True) -> bool: 40 | raise NotImplementedError 41 | 42 | @abstractmethod 43 | def tilt_stick(self, stick: Stick, x: int, y: int, tilted: float = 0.1, released: float = 0.1, block=True) -> bool: 44 | raise NotImplementedError 45 | 46 | # Macro format: 47 | # https://github.com/Brikwerk/nxbt/blob/master/docs/Macros.md 48 | def macro(self, macro: str, block=True) -> bool: 49 | # TODO: validation 50 | try: 51 | parsed = [[s.strip() for s in row.split(' ') if s.strip() != ''] for row in macro.split("\n") if row.strip() != ''] 52 | for i, inputs in enumerate(parsed): 53 | print(inputs) 54 | if len(inputs) == 1: 55 | continue 56 | down_time = float(inputs[-1][:-1]) 57 | up_time = 0 if i + 1 >= len(parsed) or len(parsed[i + 1]) != 1 else parsed[i + 1][0][:-1] 58 | buttons = [self.Button(b) for b in inputs[:-1] if not b.startswith('R_STICK@') and not b.startswith('L_STICK@')] 59 | sticks = [b for b in inputs[:-1] if b.startswith('R_STICK@') or b.startswith('L_STICK@')] 60 | t_buttons = threading.Thread(target=self.press_buttons, args=(buttons, down_time, up_time, True)) 61 | t_sticks = [] 62 | for stick in sticks: 63 | stick, positions = stick.split("@") 64 | stick = self.Stick(stick) 65 | x = int(positions[0:4]) 66 | y = int(positions[4:8]) 67 | t_sticks.append(threading.Thread(target=self.tilt_stick, args=(stick, x, y, down_time, up_time, True))) 68 | t_buttons.start() 69 | for t in t_sticks: 70 | t.start() 71 | t_buttons.join() 72 | for t in t_sticks: 73 | t.join() 74 | # TODO: should fetch result from threads 75 | return True 76 | except Exception as e: 77 | logger.error(f'Controller.macro: exception={e}') 78 | return False 79 | -------------------------------------------------------------------------------- /controller/nxbt.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from urllib.parse import urljoin 3 | 4 | import requests 5 | 6 | from controller import Controller 7 | from logger import logger 8 | 9 | 10 | class NxbtController(Controller): 11 | def __init__(self, endpoint): 12 | self.__endpoint = endpoint 13 | self.__press_buttons_endpoint = urljoin(self.__endpoint, 'press_buttons') 14 | self.__tilt_stick_endpoint = urljoin(self.__endpoint, 'tilt_stick') 15 | self.__macro_endpoint = urljoin(self.__endpoint, 'macro') 16 | 17 | def press_buttons(self, buttons: List[Controller.Button], down: float = 0.05, up: float = 0.05, block=True) -> bool: 18 | logger.debug(f'press_buttons: buttons={buttons}, down={down}, up={up}, block={block}') 19 | buttons = ','.join([str(b.value) for b in buttons]) 20 | params = {'buttons': buttons, 'down': down, 'up': up, 'block': block} 21 | resp = requests.post(self.__press_buttons_endpoint, params=params) 22 | return resp.status_code == 200 23 | 24 | def tilt_stick(self, stick: Controller.Stick, x: int, y: int, tilted: float = 0.05, released: float = 0.05, block=True) -> bool: 25 | logger.debug(f'tilt_stick: stick={stick}, x={x}, y={y}, tilted={tilted}, released={released}, block={block}') 26 | params = {'stick': str(stick.value), 'x': x, 'y': y, 'tilted': tilted, 'released': released, 'block': block} 27 | resp = requests.post(self.__tilt_stick_endpoint, params=params) 28 | return resp.status_code == 200 29 | 30 | def macro(self, macro: str, block=True) -> bool: 31 | logger.debug(f'macro: macro=\n"""\n{macro.strip()}\n"""\n, block={block}') 32 | params = {'block': block} 33 | resp = requests.post(self.__macro_endpoint, params=params, data=macro) 34 | return resp.status_code == 200 35 | -------------------------------------------------------------------------------- /controller/nxbt_server/README.md: -------------------------------------------------------------------------------- 1 | # NXBT Server 2 | 3 | Simple HTTP server running on raspberry and invoking slim [NXBT](https://github.com/Brikwerk/nxbt) Python API to emulate 4 | a Nentendo Pro Controller. 5 | 6 | ## Getting Started 7 | 8 | Basically, you will need to run the flask app on a Unix-like system with bluetooth. Two platforms are tested. 9 | 10 | ### Run on Raspberry 4B 11 | 12 | Install requirements 13 | 14 | ```bash 15 | sudo pip3 install -r requirement.txt 16 | ``` 17 | 18 | Run the server on Raspberry 4B 19 | 20 | ```bash 21 | # change hci0's address for better connection. refer: https://github.com/Poohl/joycontrol/issues/4 22 | hcitool cmd 0x3f 0x001 0x66 0x55 0x44 0xCB 0x58 0x94 23 | sudo python3 -m flask run --host=0.0.0.0 24 | ``` 25 | 26 | ### Run on WSL 27 | 28 | Running on WSL is more complicated, but it's possible. 29 | I have successfully run it with a motherboard built-in bluetooth device. No extra USB bluetooth device is required. 30 | The basic steps are: 31 | 1. Build your own WSL kernel that enables [USB/IP](https://github.com/dorssel/usbipd-win) protocol and bluetooth support. 32 | 33 | 2. Share the bluetooth device from Windows to WSL by USB/IP protocol. 34 | 35 | #### Build WSL kernel 36 | 37 | Follow this [guide](https://github.com/dorssel/usbipd-win/wiki/WSL-support#building-your-own-usbip-enabled-wsl-2-kernel) to build a WSL kernel. 38 | You will also need to enable bluetooth support during the command `sudo make menuconfig`. 39 | - Networking Support -> All bluetooth supports 40 | - Add all Bluetooth Device drivers 41 | 42 | Remember to restart WSL after above steps. 43 | 44 | ```bash 45 | wsl --shutdown 46 | ``` 47 | 48 | #### Share bluetooth device 49 | 50 | Follow this [guide](https://devblogs.microsoft.com/commandline/connecting-usb-devices-to-wsl/) to attach your bluetooth device to WSL. 51 | 52 | #### Start NXBT server 53 | 54 | Start bluetooth device. 55 | ```bash 56 | sudo modprobe bluetooth 57 | sudo modprobe btusb 58 | sudo service bluetooth start 59 | ``` 60 | 61 | Install requirements and start server. 62 | 63 | ```bash 64 | sudo pip3 install -r requirement.txt 65 | sudo python3 -m flask run --host=0.0.0.0 66 | ``` -------------------------------------------------------------------------------- /controller/nxbt_server/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request 2 | 3 | import nxbt 4 | 5 | # Init NXBT 6 | nx = nxbt.Nxbt(debug=False) 7 | controller_idx = -1 8 | 9 | 10 | def init_nxbt(): 11 | global controller_idx 12 | # Get a list of all available Bluetooth adapters 13 | print('available Bluetooth adapters:') 14 | print(nx.get_available_adapters()) 15 | # Get a list of all connected Nintendo Switches 16 | switch_addresses = nx.get_switch_addresses() 17 | print('connected Nintendo Switchs:') 18 | print(switch_addresses) 19 | # create controllers. 20 | print('creating controller...') 21 | controller_idx = nx.create_controller(nxbt.PRO_CONTROLLER, reconnect_address=switch_addresses) 22 | print('connecting...') 23 | # Wait for the switch to connect to the controller 24 | nx.wait_for_connection(controller_idx) 25 | print('connected.') 26 | 27 | 28 | init_nxbt() 29 | 30 | button_map = { 31 | 'Y': nxbt.Buttons.Y, 32 | 'X': nxbt.Buttons.X, 33 | 'B': nxbt.Buttons.B, 34 | 'A': nxbt.Buttons.A, 35 | 'JCL_SR': nxbt.Buttons.JCL_SR, 36 | 'JCL_SL': nxbt.Buttons.JCL_SL, 37 | 'R': nxbt.Buttons.R, 38 | 'ZR': nxbt.Buttons.ZR, 39 | 'L': nxbt.Buttons.L, 40 | 'ZL': nxbt.Buttons.ZL, 41 | 'MINUS': nxbt.Buttons.MINUS, 42 | 'PLUS': nxbt.Buttons.PLUS, 43 | 'R_STICK_PRESS': nxbt.Buttons.R_STICK_PRESS, 44 | 'L_STICK_PRESS': nxbt.Buttons.L_STICK_PRESS, 45 | 'HOME': nxbt.Buttons.HOME, 46 | 'CAPTURE': nxbt.Buttons.CAPTURE, 47 | 'DPAD_DOWN': nxbt.Buttons.DPAD_DOWN, 48 | 'DPAD_UP': nxbt.Buttons.DPAD_UP, 49 | 'DPAD_RIGHT': nxbt.Buttons.DPAD_RIGHT, 50 | 'DPAD_LEFT': nxbt.Buttons.DPAD_LEFT, 51 | 'JCR_SR': nxbt.Buttons.JCR_SR, 52 | 'JCR_SL': nxbt.Buttons.JCR_SL, 53 | } 54 | 55 | stick_map = { 56 | 'L_STICK': nxbt.Sticks.LEFT_STICK, 57 | 'R_STICK': nxbt.Sticks.RIGHT_STICK, 58 | } 59 | 60 | app = Flask(__name__) 61 | 62 | 63 | @app.route('/press_buttons', methods=['POST']) 64 | def press_buttons(): 65 | buttons = request.args.get('buttons', default=None, type=str) 66 | if buttons is None: 67 | return 68 | buttons = [button_map[b] for b in buttons.upper().split(',')] 69 | down = request.args.get('down', default=0.3, type=float) 70 | up = request.args.get('up', default=0.3, type=float) 71 | block = request.args.get('block', default=True, type=bool) 72 | print('press_buttons', buttons, down, up, block) 73 | nx.press_buttons(controller_idx, buttons, down=down, up=up, block=block) 74 | return 'ok' 75 | 76 | 77 | @app.route('/tilt_stick', methods=['POST']) 78 | def tilt_stick(): 79 | stick = request.args.get('stick', default=None, type=str) 80 | x = request.args.get('x', default=None, type=int) 81 | y = request.args.get('y', default=None, type=int) 82 | if stick is None or x is None or y is None: 83 | return 84 | stick = stick_map[stick.upper()] 85 | tilted = request.args.get('tilted', default=0.3, type=float) 86 | released = request.args.get('released', default=0.3, type=float) 87 | block = request.args.get('block', default=True, type=bool) 88 | print('tilt_stick', stick, x, y, tilted, released, block) 89 | nx.tilt_stick(controller_idx, stick, x=x, y=y, tilted=tilted, released=released, block=block) 90 | return 'ok' 91 | 92 | 93 | @app.route('/macro', methods=['POST']) 94 | def macro(): 95 | macro = str(request.get_data(), 'utf-8') 96 | block = request.args.get('block', default=True, type=bool) 97 | print('macro', macro, block) 98 | nx.macro(controller_idx, macro, block=block) 99 | return 'ok' 100 | 101 | 102 | @app.route('/') 103 | def health_check(): 104 | return 'ok' 105 | -------------------------------------------------------------------------------- /controller/nxbt_server/copy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" || exit 1 3 | target="~/nxbt_server/" 4 | host="pi" 5 | echo "target_path: $target" 6 | ssh $host "sudo rm -rf $target && mkdir -p $target" 7 | scp -r * "$host:$target" -------------------------------------------------------------------------------- /controller/nxbt_server/nxbt/__init__.py: -------------------------------------------------------------------------------- 1 | from .bluez import * 2 | from .controller import Controller 3 | from .controller import ControllerProtocol 4 | from .controller import ControllerServer 5 | from .controller import SwitchReportParser 6 | from .controller import SwitchResponses 7 | from .nxbt import Buttons 8 | from .nxbt import JOYCON_L 9 | from .nxbt import JOYCON_R 10 | from .nxbt import Nxbt 11 | from .nxbt import PRO_CONTROLLER 12 | from .nxbt import Sticks 13 | -------------------------------------------------------------------------------- /controller/nxbt_server/nxbt/controller/__init__.py: -------------------------------------------------------------------------------- 1 | from .controller import Controller 2 | from .controller import ControllerTypes 3 | from .protocol import ControllerProtocol 4 | from .protocol import SwitchReportParser 5 | from .protocol import SwitchResponses 6 | from .server import ControllerServer 7 | -------------------------------------------------------------------------------- /controller/nxbt_server/nxbt/controller/controller.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from enum import Enum 4 | 5 | import dbus 6 | 7 | 8 | class ControllerTypes(Enum): 9 | """Controller type enumerations for initializing the controller server. 10 | """ 11 | 12 | JOYCON_L = 1 13 | JOYCON_R = 2 14 | PRO_CONTROLLER = 3 15 | 16 | 17 | class Controller(): 18 | GAMEPAD_CLASS = "0x002508" 19 | SDP_UUID = "00001000-0000-1000-8000-00805f9b34fb" 20 | SDP_RECORD_PATH = "/nxbt1/controller" 21 | ALIASES = { 22 | ControllerTypes.JOYCON_L: "Joy-Con (L)", 23 | ControllerTypes.JOYCON_R: "Joy-Con (R)", 24 | ControllerTypes.PRO_CONTROLLER: "Pro Controller" 25 | } 26 | 27 | def __init__(self, bluetooth, controller_type): 28 | 29 | self.bt = bluetooth 30 | self.logger = logging.getLogger('nxbt1') 31 | 32 | if controller_type not in self.ALIASES.keys(): 33 | raise ValueError("Unknown controller type specified") 34 | self.alias = self.ALIASES[controller_type] 35 | 36 | def setup(self): 37 | """Configures the specified Bluetooth device as the 38 | specified controller. 39 | """ 40 | 41 | # Setting up Bluetooth adapter options 42 | self.bt.set_powered(True) 43 | self.bt.set_pairable(True) 44 | self.bt.set_pairable_timeout(0) 45 | self.bt.set_discoverable_timeout(180) 46 | 47 | self.bt.set_alias(self.alias) 48 | 49 | # Adding the SDP record 50 | sdp_record_path = os.path.join( 51 | os.path.dirname(__file__), "sdp", "switch-controller.xml") 52 | sdp_record = None 53 | with open(sdp_record_path, "r") as f: 54 | sdp_record = f.read() 55 | 56 | opts = { 57 | "ServiceRecord": sdp_record, 58 | "Role": "server", 59 | "RequireAuthentication": False, 60 | "RequireAuthorization": False, 61 | "AutoConnect": True 62 | } 63 | # If the profile has already been registered, 64 | # catch the error and continue 65 | try: 66 | self.bt.register_profile(self.SDP_RECORD_PATH, self.SDP_UUID, opts) 67 | except dbus.exceptions.DBusException as e: 68 | self.logger.debug(e) 69 | -------------------------------------------------------------------------------- /controller/nxbt_server/nxbt/controller/input.py: -------------------------------------------------------------------------------- 1 | from json import dumps 2 | from time import perf_counter 3 | 4 | DIRECT_INPUT_IDLE_PACKET = { 5 | # Sticks 6 | "L_STICK": { 7 | "PRESSED": False, 8 | "X_VALUE": 0, 9 | "Y_VALUE": 0, 10 | # Keyboard position calculation values 11 | "LS_UP": False, 12 | "LS_LEFT": False, 13 | "LS_RIGHT": False, 14 | "LS_DOWN": False 15 | }, 16 | "R_STICK": { 17 | "PRESSED": False, 18 | "X_VALUE": 0, 19 | "Y_VALUE": 0, 20 | # Keyboard position calculation values 21 | "RS_UP": False, 22 | "RS_LEFT": False, 23 | "RS_RIGHT": False, 24 | "RS_DOWN": False 25 | }, 26 | # Dpad 27 | "DPAD_UP": False, 28 | "DPAD_LEFT": False, 29 | "DPAD_RIGHT": False, 30 | "DPAD_DOWN": False, 31 | # Triggers 32 | "L": False, 33 | "ZL": False, 34 | "R": False, 35 | "ZR": False, 36 | # Joy-Con Specific Buttons 37 | "JCL_SR": False, 38 | "JCL_SL": False, 39 | "JCR_SR": False, 40 | "JCR_SL": False, 41 | # Meta buttons 42 | "PLUS": False, 43 | "MINUS": False, 44 | "HOME": False, 45 | "CAPTURE": False, 46 | # Buttons 47 | "Y": False, 48 | "X": False, 49 | "B": False, 50 | "A": False 51 | } 52 | 53 | 54 | class InputParser(): 55 | # Left Stick calibration values 56 | LEFT_STICK_CALIBRATION = { 57 | "center_x": 2159, 58 | "center_y": 1916, 59 | # Zeroed Min/Max X and Y 60 | "min_x": -1466, 61 | "max_x": 1517, 62 | "min_y": -1583, 63 | "max_y": 1465, 64 | } 65 | # Right Stick calibration values 66 | RIGHT_STICK_CALIBRATION = { 67 | "center_x": 2070, 68 | "center_y": 2013, 69 | # Zeroed Min/Max X and Y 70 | "min_x": -1522, 71 | "max_x": 1414, 72 | "min_y": -1531, 73 | "max_y": 1510, 74 | } 75 | 76 | def __init__(self, protocol): 77 | 78 | self.protocol = protocol 79 | 80 | # Buffers a list of unparsed macros 81 | self.macro_buffer = [] 82 | 83 | # Keeps track of the entire current 84 | # list of macro commands. 85 | self.current_macro = None 86 | self.current_macro_id = None 87 | # Keeps track of the macro commands being 88 | # input over a period of time. 89 | self.current_macro_commands = None 90 | 91 | # The time length of the current macro 92 | self.macro_timer_length = 0 93 | 94 | # The start time for the current macro commands 95 | self.macro_timer_start = 0 96 | 97 | self.controller_input = None 98 | 99 | # Whether or not input has been entered 100 | # that would close the "Change Grip/Order" menu 101 | self.exited_grip_order_menu = False 102 | 103 | def buffer_macro(self, macro, macro_id): 104 | 105 | # Doesn't have any info 106 | if len(macro) < 4: 107 | return 108 | 109 | self.macro_buffer.append([macro, macro_id]) 110 | 111 | def stop_macro(self, macro_id, state=None): 112 | 113 | # Check if the macro is being input currently 114 | if macro_id == self.current_macro_id: 115 | # If so, reset the current macro 116 | self.current_macro = None 117 | self.current_macro_id = None 118 | self.current_macro_commands = None 119 | self.macro_timer_length = 0 120 | self.macro_timer_start = 0 121 | else: 122 | # Check if the macro is still in the buffer 123 | for i in range(0, len(self.macro_buffer)): 124 | if macro_id == self.macro_buffer[i][1]: 125 | del self.macro_buffer[i] 126 | 127 | # Ensure the stopped macro is added to the finished 128 | # macros so that any blocking parties listening can 129 | # continue. 130 | if state: 131 | finished = state["finished_macros"] 132 | finished.append(macro_id) 133 | state["finished_macros"] = finished 134 | 135 | return 136 | 137 | def clear_macros(self): 138 | 139 | self.current_macro = None 140 | self.current_macro_id = None 141 | self.current_macro_commands = None 142 | self.macro_timer_length = 0 143 | self.macro_timer_start = 0 144 | self.macro_buffer = [] 145 | 146 | return 147 | 148 | def set_controller_input(self, controller_input): 149 | 150 | self.controller_input = controller_input 151 | 152 | def commands_queued(self): 153 | check = dumps(self.controller_input) != dumps(DIRECT_INPUT_IDLE_PACKET) 154 | check = check or self.macro_buffer 155 | check = check or self.current_macro 156 | check = check or self.current_macro_commands 157 | return check 158 | 159 | def active_input_queued(self): 160 | """Checks if an active command input is queued. An active command 161 | is a depressed button or tilted stick. 162 | 163 | :return: True (on an active button) or False (no active buttons) 164 | :rtype: bool 165 | """ 166 | if (self.current_macro_commands is not None): 167 | if len(self.current_macro_commands) < 2: 168 | return False 169 | else: 170 | return True 171 | elif dumps(self.controller_input) != dumps(DIRECT_INPUT_IDLE_PACKET): 172 | return True 173 | else: 174 | return False 175 | 176 | def set_protocol_input(self, state=None): 177 | 178 | # Act on direct input if we're not getting idle packets 179 | if dumps(self.controller_input) != dumps(DIRECT_INPUT_IDLE_PACKET): 180 | self.parse_controller_input(self.controller_input) 181 | self.controller_input = None 182 | 183 | elif (self.macro_buffer or self.current_macro or 184 | self.current_macro_commands): 185 | # Check if we can start on a new macro. 186 | if not self.current_macro and self.macro_buffer: 187 | # Preprocess command lines of current macro 188 | macro = self.macro_buffer.pop(0) 189 | self.current_macro = self.parse_macro(macro[0]) 190 | self.current_macro_id = macro[1] 191 | 192 | # Check if we can load the next set of commands 193 | if not self.current_macro_commands and self.current_macro: 194 | self.current_macro_commands = ( 195 | self.current_macro.pop(0).strip(" ").split(" ")) 196 | 197 | # Timing metadata extraction 198 | timer_length = self.current_macro_commands[-1] 199 | timer_length = timer_length[0:len(timer_length) - 1] 200 | self.macro_timer_length = float(timer_length) 201 | self.macro_timer_start = perf_counter() 202 | 203 | self.set_macro_input(self.current_macro_commands) 204 | 205 | # Check if we're done inputting the current command 206 | time_delta = perf_counter() - self.macro_timer_start 207 | if time_delta > self.macro_timer_length: 208 | self.current_macro_commands = None 209 | # Check if we're done the current macro 210 | if not self.current_macro and state: 211 | finished = state["finished_macros"] 212 | finished.append(self.current_macro_id) 213 | state["finished_macros"] = finished 214 | 215 | def parse_controller_input(self, controller_input): 216 | 217 | # Check for input validity 218 | if type(controller_input) != dict: 219 | return 220 | 221 | # Check if the Grip/Order menu would be closed 222 | if not self.exited_grip_order_menu and ( 223 | controller_input["A"] or controller_input["B"] or controller_input["HOME"]): 224 | self.exited_grip_order_menu = True 225 | 226 | # Arrays representing the 3 button bytes in the 227 | # standard input report as binary. 228 | upper = ['0'] * 8 229 | shared = ['0'] * 8 230 | lower = ['0'] * 8 231 | # Upper Byte 232 | if controller_input["Y"]: 233 | upper[7] = '1' 234 | if controller_input["X"]: 235 | upper[6] = '1' 236 | if controller_input["B"]: 237 | upper[5] = '1' 238 | if controller_input["A"]: 239 | upper[4] = '1' 240 | if controller_input["JCL_SR"]: 241 | upper[3] = '1' 242 | if controller_input["JCL_SL"]: 243 | upper[2] = '1' 244 | if controller_input["R"]: 245 | upper[1] = '1' 246 | if controller_input["ZR"]: 247 | upper[0] = '1' 248 | 249 | # Shared byte 250 | if controller_input["MINUS"]: 251 | shared[7] = '1' 252 | if controller_input["PLUS"]: 253 | shared[6] = '1' 254 | if controller_input["R_STICK"]["PRESSED"]: 255 | shared[5] = '1' 256 | if controller_input["L_STICK"]["PRESSED"]: 257 | shared[4] = '1' 258 | if controller_input["HOME"]: 259 | shared[3] = '1' 260 | if controller_input["CAPTURE"]: 261 | shared[2] = '1' 262 | 263 | # Lower byte 264 | if controller_input["DPAD_DOWN"]: 265 | lower[7] = '1' 266 | if controller_input["DPAD_UP"]: 267 | lower[6] = '1' 268 | if controller_input["DPAD_RIGHT"]: 269 | lower[5] = '1' 270 | if controller_input["DPAD_LEFT"]: 271 | lower[4] = '1' 272 | if controller_input["JCR_SR"]: 273 | lower[3] = '1' 274 | if controller_input["JCR_SL"]: 275 | lower[2] = '1' 276 | if controller_input["L"]: 277 | lower[1] = '1' 278 | if controller_input["ZL"]: 279 | lower[0] = '1' 280 | 281 | # Analog Stick Positions 282 | stick_left = self.stick_ratio_to_calibrated_position( 283 | controller_input["L_STICK"]["X_VALUE"] / 100, 284 | controller_input["L_STICK"]["Y_VALUE"] / 100, 285 | "L_STICK" 286 | ) 287 | stick_right = self.stick_ratio_to_calibrated_position( 288 | controller_input["R_STICK"]["X_VALUE"] / 100, 289 | controller_input["R_STICK"]["Y_VALUE"] / 100, 290 | "R_STICK" 291 | ) 292 | 293 | # Converting binary strings to ints 294 | upper_byte = int("".join(upper), 2) 295 | shared_byte = int("".join(shared), 2) 296 | lower_byte = int("".join(lower), 2) 297 | 298 | self.protocol.set_button_inputs(upper_byte, shared_byte, lower_byte) 299 | self.protocol.set_left_stick_inputs(stick_left) 300 | self.protocol.set_right_stick_inputs(stick_right) 301 | 302 | return controller_input 303 | 304 | def parse_macro(self, macro): 305 | 306 | parsed = macro.split("\n") 307 | parsed = list(filter(lambda s: not s.strip() == "", parsed)) 308 | parsed = self.parse_loops(parsed) 309 | 310 | return parsed 311 | 312 | def parse_loops(self, macro): 313 | parsed = [] 314 | i = 0 315 | while i < len(macro): 316 | line = macro[i] 317 | if line.startswith("LOOP"): 318 | loop_count = int(line.split(" ")[1]) 319 | loop_buffer = [] 320 | 321 | # Detect delimiter and record 322 | if macro[i + 1].startswith("\t"): 323 | loop_delimiter = "\t" 324 | elif macro[i + 1].startswith(" "): 325 | loop_delimiter = " " 326 | else: 327 | loop_delimiter = " " 328 | 329 | # Gather looping commands 330 | for j in range(i + 1, len(macro)): 331 | loop_line = macro[j] 332 | if loop_line.startswith(loop_delimiter): 333 | # Replace the first instance of the delimiter 334 | loop_line = loop_line.replace(loop_delimiter, "", 1) 335 | loop_buffer.append(loop_line) 336 | # Set the new position if we either encounter the end 337 | # of the loop or we reach the end of the macro 338 | else: 339 | i = j - 1 340 | break 341 | if j + 1 >= len(macro): 342 | i = j 343 | 344 | # Recursively gather other loops if present 345 | if any(s.startswith("LOOP") for s in loop_buffer): 346 | loop_buffer = self.parse_loops(loop_buffer) 347 | # Multiply out the loop and concatenate 348 | parsed = parsed + (loop_buffer * loop_count) 349 | else: 350 | parsed.append(line) 351 | i += 1 352 | 353 | return parsed 354 | 355 | def set_macro_input(self, macro_input): 356 | 357 | # Checking if this is a wait macro command 358 | if len(macro_input) < 2: 359 | return 360 | 361 | # Check if the Grip/Order menu would be closed 362 | if not self.exited_grip_order_menu and ( 363 | 'A' in macro_input or 'B' in macro_input or 'HOME' in macro_input): 364 | self.exited_grip_order_menu = True 365 | 366 | # Arrays representing the 3 button bytes in the 367 | # standard input report as binary. 368 | upper = ['0'] * 8 369 | shared = ['0'] * 8 370 | lower = ['0'] * 8 371 | # Analog stick byte placeholders 372 | stick_left = None 373 | stick_right = None 374 | for i in range(0, len(macro_input) - 1): 375 | button = macro_input[i] 376 | # Upper Byte 377 | if button == "Y": 378 | upper[7] = '1' 379 | elif button == "X": 380 | upper[6] = '1' 381 | elif button == "B": 382 | upper[5] = '1' 383 | elif button == "A": 384 | upper[4] = '1' 385 | elif button == "JCL_SR": 386 | upper[3] = '1' 387 | elif button == "JCL_SL": 388 | upper[2] = '1' 389 | elif button == "R": 390 | upper[1] = '1' 391 | elif button == "ZR": 392 | upper[0] = '1' 393 | 394 | # Shared byte 395 | elif button == "MINUS": 396 | shared[7] = '1' 397 | elif button == "PLUS": 398 | shared[6] = '1' 399 | elif button == "R_STICK_PRESS": 400 | shared[5] = '1' 401 | elif button == "L_STICK_PRESS": 402 | shared[4] = '1' 403 | elif button == "HOME": 404 | shared[3] = '1' 405 | elif button == "CAPTURE": 406 | shared[2] = '1' 407 | 408 | # Lower byte 409 | elif button == "DPAD_DOWN": 410 | lower[7] = '1' 411 | elif button == "DPAD_UP": 412 | lower[6] = '1' 413 | elif button == "DPAD_RIGHT": 414 | lower[5] = '1' 415 | elif button == "DPAD_LEFT": 416 | lower[4] = '1' 417 | elif button == "JCR_SR": 418 | lower[3] = '1' 419 | elif button == "JCR_SL": 420 | lower[2] = '1' 421 | elif button == "L": 422 | lower[1] = '1' 423 | elif button == "ZL": 424 | lower[0] = '1' 425 | 426 | # Analog Stick Positions 427 | elif button.startswith("L_STICK@"): 428 | stick_left = self.parse_macro_stick_position(button) 429 | elif button.startswith("R_STICK@"): 430 | stick_right = self.parse_macro_stick_position(button) 431 | 432 | # Converting binary strings to ints 433 | upper_byte = int("".join(upper), 2) 434 | shared_byte = int("".join(shared), 2) 435 | lower_byte = int("".join(lower), 2) 436 | 437 | self.protocol.set_button_inputs(upper_byte, shared_byte, lower_byte) 438 | if stick_left: 439 | self.protocol.set_left_stick_inputs(stick_left) 440 | if stick_right: 441 | self.protocol.set_right_stick_inputs(stick_right) 442 | 443 | def parse_macro_stick_position(self, stick_pos): 444 | 445 | stick_type = stick_pos.split("@")[0] 446 | positions = stick_pos.split("@")[1] 447 | if len(positions) < 8: 448 | return None 449 | 450 | # Converting macro to proper ratios 451 | sign_x = positions[0] 452 | ratio_x = int(positions[1:4]) / 100 453 | if sign_x == "-": 454 | ratio_x = ratio_x * -1 455 | 456 | sign_y = positions[4] 457 | ratio_y = int(positions[5:8]) / 100 458 | if sign_y == "-": 459 | ratio_y = ratio_y * -1 460 | 461 | calibrated_position = self.stick_ratio_to_calibrated_position( 462 | ratio_x, ratio_y, stick_type) 463 | 464 | return calibrated_position 465 | 466 | def stick_ratio_to_calibrated_position(self, ratio_x, ratio_y, stick_type): 467 | 468 | # Using the appropriate calibration values for the stick type 469 | if stick_type == "L_STICK": 470 | cal = self.LEFT_STICK_CALIBRATION 471 | else: 472 | cal = self.RIGHT_STICK_CALIBRATION 473 | 474 | # Converting ratios to uint16 values 475 | if ratio_x < 0: 476 | data_x_converted = ( 477 | abs(ratio_x) * cal["min_x"] + cal["center_x"]) 478 | else: 479 | data_x_converted = ( 480 | abs(ratio_x) * cal["max_x"] + cal["center_x"]) 481 | data_x_converted = int(round(data_x_converted)) 482 | 483 | if ratio_y < 0: 484 | data_y_converted = ( 485 | abs(ratio_y) * cal["min_y"] + cal["center_y"]) 486 | else: 487 | data_y_converted = ( 488 | abs(ratio_y) * cal["max_y"] + cal["center_y"]) 489 | data_y_converted = int(round(data_y_converted)) 490 | 491 | # Converting the two X/Y uint16 values to 3 uint8 Little Endian values 492 | # using bitshifting techniques 493 | converted_values = [ 494 | # Get the last two hex digits 495 | data_x_converted & 0xFF, 496 | # Combine the last digit of the Y uint16 and the first digit 497 | # of the X uint16 498 | ((data_y_converted & 0xF) << 4) + (data_x_converted >> 8), 499 | # Get the first two digits of the Y uint16 500 | data_y_converted >> 4] 501 | 502 | return converted_values 503 | 504 | def reassign_protocol(self, protocol): 505 | 506 | self.protocol = protocol 507 | -------------------------------------------------------------------------------- /controller/nxbt_server/nxbt/controller/sdp/switch-controller.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /controller/nxbt_server/nxbt/controller/utils.py: -------------------------------------------------------------------------------- 1 | def replace_subarray(arr, start, num_elms, value=0, replace_arr=None): 2 | """Replaces a subsection within an array with another 3 | set of values. 4 | 5 | :param arr: The array to replace values within 6 | :type arr: list 7 | :param start: The starting index for replacement 8 | :type start: int 9 | :param num_elms: The number of elements to be replaced 10 | :type num_elms: int 11 | :param value: The value to replace elements within the 12 | subarray with, defaults to 0 13 | :type value: any, optional 14 | :param replace_arr: A subarray to insert within 15 | the passed array, defaults to None 16 | :type replace_arr: list, optional 17 | """ 18 | 19 | if replace_arr: 20 | arr[start:start + num_elms] = replace_arr 21 | else: 22 | arr[start:start + num_elms] = [value] * num_elms 23 | 24 | 25 | def format_message(data, split, name): 26 | """Formats a given byte message in hex format split 27 | into payload and subcommand sections. 28 | 29 | :param data: A series of bytes 30 | :type data: bytes 31 | :param split: The location of the payload/subcommand split 32 | :type split: integer 33 | :param name: The name featured in the start/end messages 34 | :type name: string 35 | :return: The formatted data 36 | :rtype: string 37 | """ 38 | 39 | payload = "" 40 | subcommand = "" 41 | for i in range(0, len(data)): 42 | data_byte = str(hex(data[i]))[2:].upper() 43 | if len(data_byte) < 2: 44 | data_byte = "0" + data_byte 45 | if i <= split: 46 | payload += "0x" + data_byte + " " 47 | else: 48 | subcommand += "0x" + data_byte + " " 49 | 50 | formatted = ( 51 | f"--- {name} Msg ---\n" + 52 | f"Payload: {payload}\n" + 53 | f"Subcommand: {subcommand}") 54 | 55 | return formatted 56 | 57 | 58 | def format_msg_controller(data): 59 | """Prints a formatted message from a controller 60 | 61 | :param data: The bytes from the controller message 62 | :type data: bytes 63 | """ 64 | 65 | return format_message(data, 13, "Controller") 66 | 67 | 68 | def format_msg_switch(data): 69 | """Prints a formatted message from a Switch 70 | 71 | :param data: The bytes from the Switch message 72 | :type data: bytes 73 | """ 74 | 75 | return format_message(data, 10, "Switch") 76 | -------------------------------------------------------------------------------- /controller/nxbt_server/nxbt/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | 5 | def create_logger(debug=False, log_to_file=False, disable_logging=False): 6 | logger = logging.getLogger('nxbt1') 7 | 8 | if disable_logging: 9 | null_handler = logging.NullHandler() 10 | logger.addHandler(null_handler) 11 | return logger 12 | 13 | if debug: 14 | logger.setLevel(logging.DEBUG) 15 | 16 | if log_to_file: 17 | file_handler = logging.FileHandler(f'./nxbt1 {datetime.now()}.log') 18 | file_handler.setFormatter( 19 | logging.Formatter("[%(asctime)s] %(levelname)s in %(module)s: %(message)s") 20 | ) 21 | logger.addHandler(file_handler) 22 | else: 23 | stream_handler = logging.StreamHandler() 24 | stream_handler.setFormatter( 25 | logging.Formatter("[%(asctime)s] %(levelname)s in %(module)s: %(message)s") 26 | ) 27 | logger.addHandler(stream_handler) 28 | 29 | return logger 30 | -------------------------------------------------------------------------------- /controller/nxbt_server/requirement.txt: -------------------------------------------------------------------------------- 1 | flask==1.1.2 2 | jinja2<3.1.0 3 | itsdangerous==2.0.1 4 | Werkzeug==2.0.3 5 | 6 | dbus-python==1.2.16 7 | blessed==1.17.10 8 | pynput==1.7.1 9 | psutil==5.6.6 10 | -------------------------------------------------------------------------------- /controller/r3.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from typing import List 3 | 4 | import serial 5 | import serial.tools.list_ports 6 | 7 | from controller import Controller 8 | from logger import logger 9 | 10 | """ 11 | The following code can use the serial port to control an Arduino UNO R3 that simulates a HORI GamePad. 12 | The following code is improved from https://github.com/wwwwwwzx/Switch-Fightstick 13 | 14 | Author: https://github.com/LiuJiLan 15 | """ 16 | 17 | 18 | class R3Controller(Controller): 19 | def __init__(self, serial_port=None): 20 | if serial_port is None: 21 | serial_port = self.__find_port() 22 | print(f'Using port: {serial_port[0]}') 23 | self.__ser = serial.Serial(serial_port[0], 9600) 24 | self.__chart = dict({ 25 | 'Y': 'Button Y', 26 | 'X': 'Button X', 27 | 'B': 'Button B', 28 | 'A': 'Button A', 29 | 'JCL_SR': None, 30 | 'JCL_SL': None, 31 | 'R': 'Button R', 32 | 'ZR': 'Button ZR', 33 | 'MINUS': 'Button MINUS', 34 | 'PLUS': 'Button PLUS', 35 | 'R_STICK_PRESS': 'Button RCLICK', 36 | 'L_STICK_PRESS': 'Button LCLICK', 37 | 'HOME': 'Button HOME', 38 | 'CAPTURE': 'Button CAPTURE', 39 | 'DPAD_DOWN': 'HAT BOTTOM', 40 | 'DPAD_UP': 'HAT TOP', 41 | 'DPAD_RIGHT': 'HAT RIGHT', 42 | 'DPAD_LEFT': 'HAT LEFT', 43 | 'JCR_SR': None, 44 | 'JCR_SL': None, 45 | 'L': 'Button L', 46 | 'ZL': 'Button ZL', 47 | 'R_STICK': 'R', 48 | 'L_STICK': 'L' 49 | }) 50 | 51 | @staticmethod 52 | def __find_port(): 53 | ports = [ 54 | p.device 55 | for p in serial.tools.list_ports.comports() 56 | if p.vid is not None and p.pid is not None 57 | ] 58 | if not ports: 59 | raise IOError('No device found') 60 | if len(ports) > 1: 61 | print('Found multiple devices:') 62 | for p in ports: 63 | print(p) 64 | return ports 65 | 66 | def press_buttons(self, buttons: List[Controller.Button], down: float = 0.1, up: float = 0.1, block=True) -> bool: 67 | logger.debug(f'press_buttons: buttons={buttons}, down={down}, up={up}, block={block}') 68 | tmp_ls = [self.__chart[str(b.value)] for b in buttons] 69 | msg = '\r\n'.join([x for x in tmp_ls if x is not None]) 70 | self.__ser.write(f'{msg}\r\n'.encode('utf-8')) 71 | sleep(down) 72 | self.__ser.write(b'RELEASE\r\n') 73 | sleep(up) 74 | return True 75 | 76 | def tilt_stick(self, stick: Controller.Stick, x: int, y: int, tilted: float = 0.1, released: float = 0.1, block=True) -> bool: 77 | logger.debug(f'tilt_stick: stick={stick}, x={x}, y={y}, tilted={tilted}, released={released}, block={block}') 78 | if 128 > x >= 0: 79 | x_msg = 'MIN' # 0 80 | elif 128 < x <= 255: 81 | x_msg = 'MAX' # 255 82 | else: 83 | x_msg = 'CENTER' 84 | 85 | if 128 > y >= 0: 86 | y_msg = 'MIN' # 0 87 | elif 128 < y <= 255: 88 | y_msg = 'MAX' # 255 89 | else: 90 | y_msg = 'CENTER' 91 | 92 | stick_msg = self.__chart[str(stick.value)] 93 | self.__ser.write(f'{stick_msg}X {x_msg}\r\n {stick_msg}Y {y_msg}\r\n'.encode('utf-8')) 94 | return True 95 | -------------------------------------------------------------------------------- /logger/__init__.py: -------------------------------------------------------------------------------- 1 | from logger.logger import logger 2 | -------------------------------------------------------------------------------- /logger/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s') 5 | 6 | handler = logging.StreamHandler(stream=sys.stdout) 7 | handler.setFormatter(formatter) 8 | 9 | logger = logging.getLogger('global') 10 | logger.setLevel(logging.DEBUG) 11 | logger.addHandler(handler) 12 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | if __name__ == '__main__': 2 | pass 3 | -------------------------------------------------------------------------------- /portal/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, redirect, url_for 2 | 3 | from portal.debug import debug 4 | from portal.home import home 5 | 6 | app = Flask(__name__) 7 | app.register_blueprint(home) 8 | app.register_blueprint(debug) 9 | 10 | 11 | @app.route('/') 12 | def hello_world(): 13 | return redirect(url_for('home.main')) 14 | -------------------------------------------------------------------------------- /portal/debug/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from portal.debug.debug import main, page, video_feed, list_pages 4 | 5 | debug = Blueprint('debug', __name__, template_folder='templates', url_prefix='/debug') 6 | debug.route('/')(main) 7 | debug.route('/')(page) 8 | debug.route('//video_feed')(video_feed) 9 | debug.route('/list_pages')(list_pages) 10 | -------------------------------------------------------------------------------- /portal/debug/debug.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | from flask import Response, render_template, jsonify 4 | 5 | from portal.debug.debugger import web_debugger 6 | 7 | 8 | def main(): 9 | return Response(render_template( 10 | 'debug.html', 11 | pages=web_debugger.list() 12 | )) 13 | 14 | 15 | def page(name): 16 | return Response(render_template( 17 | 'page.html', 18 | name=name, 19 | )) 20 | 21 | 22 | def list_pages(): 23 | pages = [page for page in web_debugger.list()] 24 | return jsonify(pages) 25 | 26 | 27 | def generate_frames(name: str): 28 | while True: 29 | buf = web_debugger.get(name) 30 | frame = bytes(buf.getbuffer()) 31 | yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + frame + b'\r\n' 32 | sleep(0.5) 33 | 34 | 35 | def video_feed(name): 36 | return Response(generate_frames(name), mimetype='multipart/x-mixed-replace; boundary=frame') 37 | -------------------------------------------------------------------------------- /portal/debug/debugger.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | from tableturf.manager.detection.debugger import Debugger 7 | 8 | 9 | class WebDebugger(Debugger): 10 | def __init__(self): 11 | __empty = np.zeros((1080, 1920, 3)) 12 | _, __empty_buf = cv2.imencode(".jpeg", __empty) 13 | self.__empty_buf = io.BytesIO(__empty_buf) 14 | # no lock here as Python has GIL and most dict operations are thread-safe 15 | self.__buffers = dict() 16 | 17 | def show(self, name: str, img: np.ndarray): 18 | _, buffer = cv2.imencode(".jpeg", img) 19 | buf = io.BytesIO(buffer) 20 | self.__buffers[name] = buf 21 | 22 | def get(self, name: str) -> io.BytesIO: 23 | return self.__buffers.setdefault(name, self.__empty_buf) 24 | 25 | def list(self): 26 | return self.__buffers.keys() 27 | 28 | 29 | web_debugger = WebDebugger() 30 | -------------------------------------------------------------------------------- /portal/debug/templates/debug.html: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /portal/debug/templates/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /portal/home/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from portal.home.home import main, change_source, video_feed, key_press, run, stop, connect_controller 4 | 5 | home = Blueprint('home', __name__, template_folder='templates', url_prefix='/home') 6 | home.route('/')(main) 7 | home.route('/source', methods=['POST'])(change_source) 8 | home.route('/video_feed')(video_feed) 9 | home.route('/keypress', methods=['POST'])(key_press) 10 | home.route('/run', methods=['POST'])(run) 11 | home.route('/stop', methods=['POST'])(stop) 12 | home.route('/connect_controller', methods=['POST'])(connect_controller) 13 | -------------------------------------------------------------------------------- /portal/home/capture.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from capture import Capture 4 | from portal.util.rwlock import RWLock 5 | 6 | 7 | class ThreadSafeCapture(Capture): 8 | def __init__(self, capture: Capture): 9 | self.lock = RWLock() 10 | self.__capture = capture 11 | 12 | def capture(self) -> np.ndarray: 13 | with self.lock.r_locked(): 14 | return self.__capture.capture() 15 | 16 | def update_capture(self, capture: Capture): 17 | with self.lock.w_locked(): 18 | self.__capture.close() 19 | self.__capture = capture 20 | 21 | def close(self): 22 | with self.lock.w_locked(): 23 | self.__capture.close() 24 | -------------------------------------------------------------------------------- /portal/home/closer.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from tableturf.manager import Closer 4 | from tableturf.manager.data import JobStats 5 | 6 | 7 | class WebCloser(Closer): 8 | def __init__(self, stop_at: datetime.datetime = None): 9 | self.__should_close = False 10 | self.__stop_at = stop_at 11 | 12 | def set_close(self): 13 | self.__should_close = True 14 | 15 | def close(self, job_stats: JobStats) -> bool: 16 | if self.__stop_at is not None and datetime.datetime.now() > self.__stop_at: 17 | return True 18 | return self.__should_close 19 | -------------------------------------------------------------------------------- /portal/home/home.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import io 3 | from typing import Optional 4 | 5 | import cv2 6 | import numpy as np 7 | from flask import Response, request, render_template 8 | 9 | from capture import VideoCapture 10 | from controller import DummyController, Controller, NxbtController 11 | from logger import logger 12 | from portal.debug.debugger import web_debugger 13 | from portal.home.capture import ThreadSafeCapture 14 | from portal.home.closer import WebCloser 15 | from portal.home.keymap import keymap 16 | from tableturf.ai.alpha import Alpha 17 | from tableturf.manager import TableTurfManager, Profile 18 | 19 | 20 | def list_available_source(): 21 | index = 0 22 | arr = [] 23 | while True: 24 | cap = cv2.VideoCapture(index) 25 | if not cap.read()[0]: 26 | break 27 | else: 28 | arr.append(index) 29 | cap.release() 30 | index += 1 31 | return arr 32 | 33 | 34 | available_sources = list_available_source() 35 | capture = ThreadSafeCapture(VideoCapture(0)) 36 | controller: Optional[Controller] = None 37 | closer: WebCloser = None 38 | 39 | 40 | def main(): 41 | global available_sources 42 | available_sources = list_available_source() 43 | return Response(render_template( 44 | 'home.html', 45 | url=request.url, 46 | sources=available_sources, 47 | keymap=keymap 48 | )) 49 | 50 | 51 | def run(): 52 | global closer 53 | debug = request.json['debug'] 54 | sleep = request.json['sleep'] 55 | profile = request.json['profile'] 56 | stop_at = request.json['stop_at'] 57 | try: 58 | time = datetime.datetime.fromisoformat(stop_at) 59 | except: 60 | time = None 61 | logger.debug(f'portal.home.run: profile={profile}, sleep={sleep}, debug={debug}, stop_at={stop_at}') 62 | alpha_ai = Alpha() 63 | manager = TableTurfManager( 64 | capture, 65 | controller if controller is not None else DummyController(), 66 | alpha_ai, 67 | web_debugger, 68 | ) 69 | if closer is None: 70 | closer = WebCloser(time) 71 | manager.run(profile=Profile.from_json(profile), closer=closer, debug=debug) 72 | closer = None 73 | if sleep: 74 | controller.press_buttons([Controller.Button.HOME], down=3) 75 | controller.press_buttons([Controller.Button.A]) 76 | return Response() 77 | 78 | 79 | def stop(): 80 | if closer is not None: 81 | closer.set_close() 82 | return Response() 83 | 84 | 85 | def change_source(): 86 | global capture 87 | source = int(request.json['source']) 88 | capture.update_capture(VideoCapture(source)) 89 | logger.debug(f'portal.home.source_on_change: source={source}') 90 | return Response() 91 | 92 | 93 | def generate_frames(): 94 | empty = np.zeros((1080, 1920, 3)) 95 | _, empty_buf = cv2.imencode(".jpeg", empty) 96 | empty_frame = bytes(io.BytesIO(empty_buf).getbuffer()) 97 | while True: 98 | try: 99 | img = capture.capture() 100 | _, buffer = cv2.imencode(".jpeg", img) 101 | frame = bytes(io.BytesIO(buffer).getbuffer()) 102 | yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + frame + b'\r\n' 103 | except Exception: 104 | yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + empty_frame + b'\r\n' 105 | 106 | 107 | def video_feed(): 108 | return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame') 109 | 110 | 111 | def key_press(): 112 | if controller is None: 113 | return Response() 114 | event_type = request.json['type'] 115 | if event_type == 'keydown': 116 | raw = request.json['key'] 117 | key = keymap.get(raw, None) 118 | if key is not None: 119 | if key == Controller.Stick.L_STICK: 120 | if raw == 'a': 121 | controller.tilt_stick(key, -100, 0, tilted=0.3) 122 | elif raw == 's': 123 | controller.tilt_stick(key, 0, -100, tilted=0.3) 124 | elif raw == 'd': 125 | controller.tilt_stick(key, 100, 0, tilted=0.3) 126 | elif raw == 'w': 127 | controller.tilt_stick(key, 0, 100, tilted=0.3) 128 | else: 129 | controller.press_buttons([key]) 130 | # TODO: support long press 131 | return Response() 132 | 133 | 134 | def connect_controller(): 135 | global controller 136 | endpoint = request.json['endpoint'] 137 | if endpoint != '': 138 | controller = NxbtController(endpoint) 139 | else: 140 | controller = DummyController() 141 | return Response() 142 | -------------------------------------------------------------------------------- /portal/home/keymap.py: -------------------------------------------------------------------------------- 1 | from controller import Controller 2 | 3 | keymap = { 4 | 'a': Controller.Stick.L_STICK, 5 | 's': Controller.Stick.L_STICK, 6 | 'd': Controller.Stick.L_STICK, 7 | 'w': Controller.Stick.L_STICK, 8 | 9 | 'j': Controller.Button.Y, 10 | 'k': Controller.Button.B, 11 | 'l': Controller.Button.A, 12 | 'i': Controller.Button.X, 13 | 14 | 'e': Controller.Button.MINUS, 15 | 'f': Controller.Button.CAPTURE, 16 | 'u': Controller.Button.PLUS, 17 | 'h': Controller.Button.HOME, 18 | 19 | 'v': Controller.Button.DPAD_LEFT, 20 | 'b': Controller.Button.DPAD_DOWN, 21 | 'n': Controller.Button.DPAD_RIGHT, 22 | 'g': Controller.Button.DPAD_UP, 23 | 24 | '2': Controller.Button.L, 25 | '3': Controller.Button.ZL, 26 | '9': Controller.Button.R, 27 | '8': Controller.Button.ZR, 28 | } 29 | -------------------------------------------------------------------------------- /portal/home/templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 14 | 122 | 123 | 124 | 125 | 126 | 127 |

128 | 129 | 130 | 135 | 136 | 137 |

138 | 139 | 140 |
141 | 142 | 143 |
144 | 145 | 146 |

147 | 148 | 149 | 150 | 151 |

152 | 153 | 154 | 155 | 156 | 157 | 158 |

159 | 160 | 161 | 162 |

163 | 164 | 165 | 166 | 167 | 168 | {% for k, v in keymap.items() %} 169 | 170 | 171 | 172 | 173 | {% endfor %} 174 |
KeyboardController
{{k}}{{v}}
-------------------------------------------------------------------------------- /portal/profiles/default.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "current_level": 2, 4 | "current_win": 2, 5 | "target_level": 2, 6 | "target_win": 3, 7 | "deck": 3 8 | }, 9 | { 10 | "current_level": 1, 11 | "current_win": 0, 12 | "target_level": 3, 13 | "target_win": 1, 14 | "deck": 3 15 | } 16 | ] -------------------------------------------------------------------------------- /portal/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fga401/AutoTableTurf/74b19d9e6173f3b12827d9818b18f1720502abad/portal/util/__init__.py -------------------------------------------------------------------------------- /portal/util/rwlock.py: -------------------------------------------------------------------------------- 1 | """ rwlock.py 2 | A class to implement read-write locks on top of the standard threading 3 | library. 4 | This is implemented with two mutexes (threading.Lock instances) as per this 5 | wikipedia pseudocode: 6 | https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock#Using_two_mutexes 7 | Code written by Tyler Neylon at Unbox Research. 8 | This file is public domain. 9 | 10 | source: https://gist.github.com/tylerneylon/a7ff6017b7a1f9a506cf75aa23eacfd6 11 | """ 12 | 13 | # _______________________________________________________________________ 14 | # Imports 15 | 16 | from contextlib import contextmanager 17 | from threading import Lock 18 | 19 | 20 | # _______________________________________________________________________ 21 | # Class 22 | 23 | class RWLock(object): 24 | """ RWLock class; this is meant to allow an object to be read from by 25 | multiple threads, but only written to by a single thread at a time. See: 26 | https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock 27 | Usage: 28 | from rwlock import RWLock 29 | my_obj_rwlock = RWLock() 30 | # When reading from my_obj: 31 | with my_obj_rwlock.r_locked(): 32 | do_read_only_things_with(my_obj) 33 | # When writing to my_obj: 34 | with my_obj_rwlock.w_locked(): 35 | mutate(my_obj) 36 | """ 37 | 38 | def __init__(self): 39 | 40 | self.w_lock = Lock() 41 | self.num_r_lock = Lock() 42 | self.num_r = 0 43 | 44 | # ___________________________________________________________________ 45 | # Reading methods. 46 | 47 | def r_acquire(self): 48 | self.num_r_lock.acquire() 49 | self.num_r += 1 50 | if self.num_r == 1: 51 | self.w_lock.acquire() 52 | self.num_r_lock.release() 53 | 54 | def r_release(self): 55 | assert self.num_r > 0 56 | self.num_r_lock.acquire() 57 | self.num_r -= 1 58 | if self.num_r == 0: 59 | self.w_lock.release() 60 | self.num_r_lock.release() 61 | 62 | @contextmanager 63 | def r_locked(self): 64 | """ This method is designed to be used via the `with` statement. """ 65 | try: 66 | self.r_acquire() 67 | yield 68 | finally: 69 | self.r_release() 70 | 71 | # ___________________________________________________________________ 72 | # Writing methods. 73 | 74 | def w_acquire(self): 75 | self.w_lock.acquire() 76 | 77 | def w_release(self): 78 | self.w_lock.release() 79 | 80 | @contextmanager 81 | def w_locked(self): 82 | """ This method is designed to be used via the `with` statement. """ 83 | try: 84 | self.w_acquire() 85 | yield 86 | finally: 87 | self.w_release() 88 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy == 1.23.3 2 | opencv-python == 4.5.5.62 3 | requests == 2.28.1 4 | pyserial == 3.5 5 | 6 | flask==1.1.2 7 | jinja2<3.1.0 8 | itsdangerous==2.0.1 9 | Werkzeug==2.0.3 10 | -------------------------------------------------------------------------------- /tableturf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fga401/AutoTableTurf/74b19d9e6173f3b12827d9818b18f1720502abad/tableturf/__init__.py -------------------------------------------------------------------------------- /tableturf/ai/__init__.py: -------------------------------------------------------------------------------- 1 | from tableturf.ai.interface import AI 2 | from tableturf.ai.simple import SimpleAI 3 | -------------------------------------------------------------------------------- /tableturf/ai/alpha/__init__.py: -------------------------------------------------------------------------------- 1 | from tableturf.ai.alpha.alpha import Alpha 2 | -------------------------------------------------------------------------------- /tableturf/ai/alpha/alpha.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Optional 3 | 4 | import cv2 5 | import numpy as np 6 | 7 | from logger import logger 8 | from tableturf.ai import AI 9 | from tableturf.ai.alpha import util 10 | from tableturf.model import Card, Stage, Status, Step, Grid 11 | 12 | 13 | class Alpha(AI): 14 | # @util.pause 15 | def redraw(self, hands: List[Card], stage: Optional[Stage] = None, my_deck: Optional[List[Card]] = None, his_deck: Optional[List[Card]] = None) -> bool: 16 | return self.__score_redraw(hands, stage, my_deck, his_deck) < 0 17 | 18 | @staticmethod 19 | def __score_redraw(hands: List[Card], stage: Optional[Stage] = None, my_deck: Optional[List[Card]] = None, his_deck: Optional[List[Card]] = None) -> float: 20 | hand_quantile = 2 # [0, 3] 21 | deck_quantile = 8 # [0, 14] 22 | cards = sorted([card for card in hands], key=lambda c: c.size) 23 | if my_deck is None: 24 | return 1 if cards[hand_quantile].size >= 10 else -1 25 | deck = sorted([card for card in hands + my_deck], key=lambda c: c.size) 26 | 27 | if logger.isEnabledFor(logging.DEBUG): 28 | hand_sizes = [card.size for card in cards] 29 | deck_sizes = [card.size for card in deck] 30 | logger.debug(f'tableturf.ai.alpha.redraw: hand_sizes={hand_sizes}, deck_sizes={deck_sizes}') 31 | 32 | if cards[hand_quantile].size >= deck[deck_quantile].size: 33 | if np.all([util.is_special_card(card) for card in cards[hand_quantile:]]): 34 | return -1 35 | else: 36 | return 1 37 | else: 38 | return -1 39 | 40 | # @util.pause 41 | def next_step(self, status: Status) -> Step: 42 | logger.debug(f'tableturf.ai.alpha.next_step: round={status.round}') 43 | if status.round > 2: 44 | remaining_cost = sorted([card.sp_cost for card in status.hands + status.my_deck], reverse=True) 45 | sp_threshold = np.sum(remaining_cost[:2]) - 1 46 | # only can drop cards or special attack 47 | steps = status.get_possible_steps(action=Step.Action.Place) 48 | if len(steps) == 0 or status.my_sp >= sp_threshold: 49 | steps = status.get_possible_steps() 50 | scores = np.array([self.__score_special_attack_step(status, step, sp_threshold) for step in steps]) 51 | logger.debug(f'tableturf.ai.alpha.next_step.special_attack: scores={scores}, steps={steps}, case=special_attack') 52 | return steps[np.argmax(np.sum(scores, axis=1))] 53 | 54 | if status.round >= 5: 55 | # try to expand 56 | current_score = self.__score_current_stage(status) 57 | cards = self.__sort_hands_for_expanding(status) 58 | logger.debug(f'tableturf.ai.alpha.next_step.expanding: cards={cards}, case=expanding') 59 | for card in cards: 60 | steps = status.get_possible_steps(card, action=Step.Action.Place) 61 | scores = np.array([self.__score_expanding_step(current_score, status, step) for step in steps]) 62 | logger.debug(f'tableturf.ai.alpha.next_step.expanding: scores={scores}, steps={steps}, case=expanding') 63 | if self.__is_good_for_expanding(scores, steps): 64 | return steps[np.argmax(np.sum(scores, axis=1))] 65 | 66 | # try to consolidate 67 | remaining_sp_card = len([card.sp_cost for card in status.hands + status.my_deck if util.is_special_card(card)]) 68 | steps = status.get_possible_steps(action=Step.Action.Place) 69 | scores = np.array([self.__score_consolidating_step(status, step, remaining_sp_card) for step in steps]) 70 | logger.debug(f'tableturf.ai.alpha.next_step.consolidating: scores={scores}, steps={steps}, case=consolidating') 71 | return steps[np.argmax(np.sum(scores, axis=1))] 72 | 73 | elif status.round == 2: 74 | result = dict() 75 | for card in status.hands: 76 | steps = status.get_possible_steps(card) 77 | scores = np.array([self.__score_round_2_step(status, step) for step in steps]) 78 | possible_sp = np.unique(scores[:, 2]) 79 | for sp in possible_sp: 80 | sub_group = scores[:, 2] == sp 81 | sub_steps = np.array(steps)[sub_group] 82 | sub_scores = scores[sub_group] 83 | step = sub_steps[np.argmax(np.sum(sub_scores[:, :2], axis=1))] 84 | next_status = util.estimate_status(status, step, expand=True)[0] 85 | # pick the max one who can use special attack 86 | _cards = [c for c in next_status.hands if len(next_status.get_possible_steps(c)) > 1] 87 | if len(_cards) == 0: 88 | continue 89 | _card = max(_cards, key=lambda c: c.size) 90 | _steps = next_status.get_possible_steps(_card) 91 | _scores = np.array([self.__score_round_1_step(next_status, _step) for _step in _steps]) 92 | result[step] = np.max(np.sum(_scores, axis=1)) 93 | logger.debug(f'tableturf.ai.alpha.next_step.round_2: result={result}, case=round_2') 94 | if len(result) == 0: 95 | return status.get_possible_steps()[0] 96 | return max(result, key=result.get) 97 | 98 | elif status.round == 1: 99 | steps = status.get_possible_steps() 100 | scores = np.array([self.__score_round_1_step(status, step) for step in steps]) 101 | logger.debug(f'tableturf.ai.alpha.next_step.round_1: scores={scores}, steps={steps}, case=round_1') 102 | return steps[np.argmax(np.sum(scores, axis=1))] 103 | 104 | logger.error(f'tableturf.ai.alpha.next_step: unexpected behavior') 105 | return status.get_possible_steps()[0] # should not be here 106 | 107 | @staticmethod 108 | def __score_current_stage(status: Status): 109 | occupied_grids_1 = Evaluation.occupied_grids(status.stage, my_dilate=1, his_dilate=1, connectivity=8) 110 | occupied_grids_2 = Evaluation.occupied_grids(status.stage, my_dilate=1, his_dilate=3, connectivity=8) 111 | occupied_grids_3 = Evaluation.occupied_grids(status.stage, my_dilate=2, his_dilate=1, connectivity=8) 112 | conflict_grids = Evaluation.conflict_grids(status.stage, my_dilate=3, his_dilate=3) 113 | return occupied_grids_1, occupied_grids_2, occupied_grids_3, conflict_grids 114 | 115 | @staticmethod 116 | def __score_expanding_step(current_score, status: Status, step: Step): 117 | occupied_grids_1, occupied_grids_2, occupied_grids_3, conflict_grids = current_score 118 | next_stage = util.estimate_stage(status.stage, step) 119 | estimated_occupied_grids_1 = Evaluation.occupied_grids(next_stage, my_dilate=1, his_dilate=1, connectivity=8) - occupied_grids_1 120 | estimated_occupied_grids_2 = Evaluation.occupied_grids(next_stage, my_dilate=1, his_dilate=3, connectivity=8) - occupied_grids_2 121 | estimated_occupied_grids_3 = Evaluation.occupied_grids(next_stage, my_dilate=2, his_dilate=1, connectivity=8) - occupied_grids_3 122 | estimated_conflict_grids = Evaluation.conflict_grids(next_stage, my_dilate=3, his_dilate=3) - conflict_grids 123 | size = Evaluation.ink_size(next_stage) 124 | my_sp = status.my_sp + util.estimate_my_sp_diff(status.stage, next_stage, step) 125 | his_sp_diff = util.estimate_his_sp_diff(status.stage, next_stage, step) 126 | 127 | pattern = step.card.get_pattern(step.rotate) 128 | distance = np.min([Evaluation.square_distance(status.stage, pos) for pos in pattern.offset + step.pos]) 129 | if status.round > 11 and distance > 2: 130 | distance = distance * -100 131 | else: 132 | distance = distance * -1 133 | 134 | return estimated_occupied_grids_1, estimated_occupied_grids_2 * 0.8, estimated_occupied_grids_3 * 0.8, estimated_conflict_grids * -0.5, distance, size, my_sp * 6, his_sp_diff * -4 135 | 136 | @staticmethod 137 | def __is_good_for_expanding(scores: np.ndarray, steps: List[Step]) -> bool: 138 | _idx = np.argmax(np.sum(scores[:, :3], axis=1)) 139 | if not Alpha.__is_good_card_for_expanding(steps[_idx].card): 140 | if np.sum(scores[_idx, :3]) < steps[_idx].card.size * 3: 141 | return False 142 | if np.sum(scores[_idx, :3]) < steps[_idx].card.size * 2: 143 | return False 144 | return True 145 | 146 | @staticmethod 147 | def __score_consolidating_step(status: Status, step: Step, remaining_sp_card: int): 148 | next_stage = util.estimate_stage(status.stage, step) 149 | my_sp = status.my_sp + util.estimate_my_sp_diff(status.stage, next_stage, step) 150 | his_sp_diff = util.estimate_his_sp_diff(status.stage, next_stage, step) 151 | estimated_occupied_grids_1 = Evaluation.occupied_grids(next_stage, my_dilate=1, his_dilate=0, connectivity=8) 152 | area = len(next_stage.my_ink) 153 | dilated_area = Evaluation.dilated_area(next_stage) 154 | 155 | sp_card_penalty = 0 156 | if not util.is_special_card(step.card): 157 | pattern = step.card.get_pattern(step.rotate) 158 | pos = pattern.offset[pattern.squares == Grid.MySpecial.value] + step.pos 159 | distance = Evaluation.square_distance(status.stage, pos) 160 | else: 161 | distance = 0 162 | if remaining_sp_card <= 1: 163 | sp_card_penalty = -18 164 | elif remaining_sp_card <= 2 and status.my_sp + status.round >= 6: 165 | sp_card_penalty = -12 166 | 167 | return my_sp * 6, area, estimated_occupied_grids_1 * 0.6, distance * -0.01, his_sp_diff * -4, sp_card_penalty, dilated_area * -0.1 168 | 169 | @staticmethod 170 | def __score_special_attack_step(status: Status, step: Step, sp_threshold): 171 | next_stage = util.estimate_stage(status.stage, step) 172 | my_ink = len(next_stage.my_ink) 173 | his_ink = len(next_stage.his_ink) 174 | sp = status.my_sp + util.estimate_my_sp_diff(status.stage, next_stage, step) 175 | if sp > sp_threshold: 176 | sp = -sp 177 | drop_card = Evaluation.drop_card_penalty(status, step) 178 | return my_ink, his_ink * -1, sp * 6, drop_card 179 | 180 | @staticmethod 181 | def __score_round_2_step(status: Status, step: Step): 182 | next_stage = util.estimate_stage(status.stage, step) 183 | my_ink = len(next_stage.my_ink) 184 | his_ink = len(next_stage.his_ink) 185 | sp = status.my_sp + util.estimate_my_sp_diff(status.stage, next_stage, step) 186 | return my_ink, his_ink * -1, sp 187 | 188 | @staticmethod 189 | def __score_round_1_step(status: Status, step: Step): 190 | next_stage = util.estimate_stage(status.stage, step) 191 | my_ink = len(next_stage.my_ink) 192 | his_ink = len(next_stage.his_ink) 193 | return my_ink, his_ink * -1 194 | 195 | @staticmethod 196 | def __sort_hands_for_expanding(status: Status) -> List[Card]: 197 | good_cards = [card for card in status.hands if len(status.get_possible_steps(card, action=Step.Action.Place)) > 0 and card.size > 3] 198 | sorted_cards = sorted(good_cards, key=lambda c: (c.size, max(c.get_pattern().width, c.get_pattern().height)), reverse=True) 199 | sp_cards = [card for card in sorted_cards if util.is_special_card(card)] 200 | not_sp_cards = [card for card in sorted_cards if not util.is_special_card(card)] 201 | good_not_sp_cards = [card for card in not_sp_cards if Alpha.__is_good_card_for_expanding(card)] 202 | bad_not_sp_cards = [card for card in not_sp_cards if not Alpha.__is_good_card_for_expanding(card)] 203 | return good_not_sp_cards + sp_cards + bad_not_sp_cards 204 | 205 | @staticmethod 206 | def __is_good_card_for_expanding(card: Card) -> bool: 207 | return card.size >= 9 or card.get_pattern().height >= 6 or card.get_pattern().width >= 6 208 | 209 | @staticmethod 210 | def __sort_hands_for_consolidating(status: Status) -> List[Card]: 211 | good_cards = [card for card in status.hands if len(status.get_possible_steps(card, action=Step.Action.Place)) > 0] 212 | sorted_cards = sorted(good_cards, key=lambda c: c.size, reverse=True) 213 | sp_cards = [card for card in sorted_cards if util.is_special_card(card)] 214 | not_sp_cards = [card for card in sorted_cards if not util.is_special_card(card)] 215 | return not_sp_cards + sp_cards 216 | 217 | def reset(self): 218 | return 219 | 220 | 221 | class Evaluation: 222 | @staticmethod 223 | def occupied_grids(stage: Stage, my_dilate=0, his_dilate=0, connectivity=4): 224 | """ 225 | Return the number of occupied squares that is not connected with opponent's squares. 226 | """ 227 | grid = stage.grid.copy() 228 | for _ in range(his_dilate): 229 | his_mask = (np.bitwise_and(grid, Grid.HisInk.value | Grid.HisSpecial.value) > 0).astype(np.uint8) * 255 230 | his_mask = cv2.dilate(his_mask, kernel=np.ones((3, 3), dtype=np.uint8)) 231 | his_mask = np.bitwise_and(his_mask == 255, grid == Grid.Empty.value) 232 | grid[his_mask] = Grid.HisInk.value 233 | for _ in range(my_dilate): 234 | my_mask = (np.bitwise_and(grid, Grid.MyInk.value | Grid.MySpecial.value) > 0).astype(np.uint8) * 255 235 | my_mask = cv2.dilate(my_mask, kernel=np.ones((3, 3), dtype=np.uint8)) 236 | my_mask = np.bitwise_and(my_mask == 255, grid == Grid.Empty.value) 237 | grid[my_mask] = Grid.MyInk.value 238 | his_ink_idx = np.argwhere(np.bitwise_and(grid, Grid.HisInk.value | Grid.HisSpecial.value)) 239 | empty_idx = np.argwhere(grid == Grid.Empty.value) 240 | mask = np.zeros(stage.shape, dtype=np.uint8) 241 | mask[his_ink_idx[:, 0], his_ink_idx[:, 1]] = 255 242 | mask[empty_idx[:, 0], empty_idx[:, 1]] = 255 243 | # opencv bug: https://github.com/opencv/opencv-python/issues/602 244 | # num_labels, labels = cv2.connectedComponents(mask, connectivity=connectivity) 245 | try: 246 | num_labels, labels = cv2.connectedComponents(mask, connectivity=connectivity) 247 | except Exception: 248 | logger.error(f'tableturf.ai.alpha.next_step: failed to calculate connectedComponents with connectivity={connectivity}') 249 | num_labels, labels = cv2.connectedComponents(mask) 250 | unoccupied_labels = np.concatenate([[0], np.unique(labels[his_ink_idx[:, 0], his_ink_idx[:, 1]])]) # background + connected with his inks 251 | return np.sum(np.isin(labels, unoccupied_labels, invert=True)) + np.sum(np.bitwise_and(grid, Grid.MyInk.value | Grid.MySpecial.value) > 0) 252 | 253 | @staticmethod 254 | def conflict_grids(stage: Stage, my_dilate, his_dilate): 255 | """ 256 | Return the number of conflict squares if dilate our squares. 257 | """ 258 | grid = stage.grid.copy() 259 | if his_dilate > 0: 260 | mask = (np.bitwise_and(grid, Grid.HisInk.value | Grid.HisSpecial.value) > 0).astype(np.uint8) * 255 261 | mask = cv2.dilate(mask, kernel=np.ones((his_dilate * 2 + 1, his_dilate * 2 + 1), dtype=np.uint8)) 262 | his_overlap = np.bitwise_and(mask == 255, np.bitwise_and(grid, Grid.MyInk.value | Grid.MySpecial.value) > 0) 263 | mask = np.bitwise_and(mask == 255, grid == Grid.Empty.value) 264 | grid[mask] = Grid.HisInk.value 265 | mask = (np.bitwise_and(grid, Grid.MyInk.value | Grid.MySpecial.value) > 0).astype(np.uint8) * 255 266 | mask = cv2.dilate(mask, kernel=np.ones((my_dilate * 2 + 1, my_dilate * 2 + 1), dtype=np.uint8)) 267 | overlap = np.bitwise_and(mask == 255, np.bitwise_and(grid, Grid.HisInk.value | Grid.HisSpecial.value) > 0) 268 | if his_dilate > 0: 269 | overlap = np.bitwise_or(his_overlap, overlap) 270 | return np.sum(overlap) 271 | 272 | @staticmethod 273 | def dilated_area(stage: Stage, dilate=1): 274 | grid = stage.grid.copy() 275 | for _ in range(dilate): 276 | mask = (np.bitwise_and(grid, Grid.MyInk.value | Grid.MySpecial.value) > 0).astype(np.uint8) * 255 277 | mask = cv2.dilate(mask, kernel=np.ones((3, 3), dtype=np.uint8)) 278 | mask = np.bitwise_and(mask == 255, grid == Grid.Empty.value) 279 | grid[mask] = Grid.MyInk.value 280 | return np.sum(np.bitwise_and(grid, Grid.MyInk.value | Grid.MySpecial.value) > 0) 281 | 282 | @staticmethod 283 | def ink_size(stage: Stage) -> float: 284 | return np.linalg.norm(np.max(stage.my_ink, axis=0) - np.min(stage.my_ink, axis=0)) 285 | 286 | @staticmethod 287 | def square_distance(stage: Stage, pos: np.ndarray) -> float: 288 | return np.min(np.linalg.norm(stage.his_ink - pos[np.newaxis, ...], axis=1)) 289 | 290 | @staticmethod 291 | def possible_steps(next_status: Status) -> float: 292 | cards = next_status.hands 293 | return np.sum([len(next_status.get_possible_steps(card=card, action=Step.Action.Place)) * np.power(2, card.size) for card in cards]) 294 | 295 | @staticmethod 296 | def drop_card_penalty(status: Status, step: Step): 297 | if step.action != Step.Action.Skip: 298 | return 0 299 | if step.card.sp_cost - status.my_sp <= 1: 300 | return -step.card.size 301 | return -step.card.size / step.card.sp_cost + 0.1 * step.card.sp_cost 302 | 303 | @staticmethod 304 | def recursive_area(status: Status, step: Step, depth: int): 305 | if depth == 1: 306 | return len(util.estimate_stage(status.stage, step).my_ink) 307 | next_status = util.estimate_status(status, step, expand=True) 308 | score = np.mean([np.max([Evaluation.recursive_area(_status, _step, depth - 1) for _step in _status.get_possible_steps()]) for _status in next_status]) 309 | return score 310 | -------------------------------------------------------------------------------- /tableturf/ai/alpha/util.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import List 3 | 4 | import numpy as np 5 | 6 | from tableturf.model import Card, Stage, Step, Status 7 | 8 | 9 | def is_special_card(card: Card) -> bool: 10 | return card.size == 12 and card.sp_cost == 3 11 | 12 | 13 | def has_special_card(cards: List[Card]): 14 | return next((card for card in cards if is_special_card(card)), None) is not None 15 | 16 | 17 | def estimate_stage(stage: Stage, step: Step) -> Stage: 18 | if step.action == Step.Action.Skip: 19 | return stage 20 | grid = stage.grid.copy() 21 | pattern = step.card.get_pattern(step.rotate) 22 | offset = pattern.offset + step.pos[np.newaxis, ...] 23 | grid[offset[:, 0], offset[:, 1]] = pattern.squares 24 | return Stage(grid) 25 | 26 | 27 | def estimate_my_sp_diff(current_stage: Stage, next_stage: Stage, step: Step) -> int: 28 | if step.action == Step.Action.Skip: 29 | return 1 30 | elif step.action == Step.Action.SpecialAttack: 31 | return len(next_stage.my_fiery_sp) - len(current_stage.my_fiery_sp) - step.card.sp_cost 32 | else: # step.action == Step.Action.Place: 33 | return len(next_stage.my_fiery_sp) - len(current_stage.my_fiery_sp) 34 | 35 | 36 | def estimate_his_sp_diff(current_stage: Stage, next_stage: Stage, step: Step): 37 | if step.action == Step.Action.Skip: 38 | return 0 39 | else: 40 | return len(next_stage.his_fiery_sp) - len(current_stage.his_fiery_sp) 41 | 42 | 43 | def estimate_status(status: Status, step: Step, expand: bool) -> List[Status]: 44 | hands = copy.deepcopy(status.hands) 45 | hands.remove(step.card) 46 | next_stage = estimate_stage(status.stage, step) 47 | my_sp = status.my_sp + estimate_my_sp_diff(status.stage, next_stage, step) 48 | 49 | if not expand or len(status.my_deck) == 0: 50 | return [Status(stage=next_stage, hands=hands, round=status.round - 1, my_sp=my_sp, his_sp=status.his_sp, my_deck=status.my_deck, his_deck=status.his_deck)] 51 | else: 52 | result = [] 53 | for card in status.my_deck: 54 | _hands = hands + [card] 55 | _my_deck = copy.deepcopy(status.my_deck) 56 | _my_deck.remove(card) 57 | result.append(Status(stage=next_stage, hands=_hands, round=status.round - 1, my_sp=my_sp, his_sp=status.his_sp, my_deck=_my_deck, his_deck=status.his_deck)) 58 | return result 59 | 60 | 61 | def min_max_normalization(arr: np.ndarray) -> np.ndarray: 62 | min_val = np.min(arr) 63 | max_val = np.max(arr) 64 | diff = max_val - min_val 65 | if diff == 0: 66 | return np.ones_like(arr) 67 | return (arr - min_val) / diff 68 | 69 | 70 | def pause(fn): 71 | def wrapper(*args, **kwargs): 72 | result = fn(*args, **kwargs) 73 | input() 74 | return result 75 | 76 | return wrapper 77 | -------------------------------------------------------------------------------- /tableturf/ai/interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, Optional 3 | 4 | from tableturf.model import Status, Step, Card, Stage 5 | 6 | 7 | class AI(ABC): 8 | @abstractmethod 9 | def redraw(self, hands: List[Card], stage: Optional[Stage] = None, my_deck: Optional[List[Card]] = None, his_deck: Optional[List[Card]] = None) -> bool: 10 | raise NotImplementedError 11 | 12 | @abstractmethod 13 | def next_step(self, status: Status) -> Step: 14 | raise NotImplementedError 15 | 16 | @abstractmethod 17 | def reset(self): 18 | raise NotImplementedError 19 | -------------------------------------------------------------------------------- /tableturf/ai/simple.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import List, Optional 3 | 4 | from logger import logger 5 | from tableturf.ai import AI 6 | from tableturf.model import Status, Step, Stage, Card 7 | 8 | 9 | class SimpleAI(AI): 10 | def redraw(self, hands: List[Card], stage: Optional[Stage] = None, my_deck: Optional[List[Card]] = None, his_deck: Optional[List[Card]] = None) -> bool: 11 | return True 12 | 13 | def next_step(self, status: Status) -> Step: 14 | step = random.choice(status.get_possible_steps()) 15 | logger.debug(f'SimpleAI.next_step: return={step}') 16 | return step 17 | 18 | def reset(self): 19 | return 20 | -------------------------------------------------------------------------------- /tableturf/manager/__init__.py: -------------------------------------------------------------------------------- 1 | from tableturf.manager.closer import Closer, TaskStatsCloser 2 | from tableturf.manager.data import Profile 3 | from tableturf.manager.tableturf import TableTurfManager 4 | -------------------------------------------------------------------------------- /tableturf/manager/action/__init__.py: -------------------------------------------------------------------------------- 1 | from tableturf.manager.action.card import rotate_card_marco, move_card_marco, compare_pattern 2 | from tableturf.manager.action.deck import move_deck_cursor_marco 3 | from tableturf.manager.action.giveup import move_giveup_cursor_marco 4 | from tableturf.manager.action.hands import move_hands_cursor_marco 5 | from tableturf.manager.action.redraw import move_redraw_cursor_marco 6 | from tableturf.manager.action.replay import move_replay_cursor_marco 7 | -------------------------------------------------------------------------------- /tableturf/manager/action/card.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from controller import Controller 4 | from logger import logger 5 | from tableturf.manager.action import util 6 | from tableturf.model import Pattern, Grid, Stage, Step 7 | 8 | 9 | def rotate_card_marco(rotate: int) -> str: 10 | return util.buttons_to_marco([Controller.Button.Y] * rotate) 11 | 12 | 13 | def __remove_special_squares(pattern: Pattern): 14 | if pattern is None: 15 | return None 16 | ink = pattern.grid.copy() 17 | ink[ink != Grid.MyInk.value] = Grid.Empty.value 18 | if np.all(ink == Grid.Empty.value): 19 | return None 20 | return Pattern(ink) 21 | 22 | 23 | def compare_pattern(a: Pattern, b: Pattern) -> bool: 24 | if a == b: 25 | return True 26 | a = __remove_special_squares(a) 27 | b = __remove_special_squares(b) 28 | return a == b 29 | 30 | 31 | def move_card_marco(current: np.ndarray, preview: Pattern, stage: Stage, step: Step) -> str: 32 | target = step.pos.copy() 33 | logger.debug(f'action.move_card_marco: target={target}, current={current}') 34 | expected_pattern = step.card.get_pattern(step.rotate) 35 | if preview != expected_pattern: 36 | # compare trivial squares 37 | actual_ink = __remove_special_squares(preview) 38 | expected_ink = __remove_special_squares(expected_pattern) 39 | if expected_ink is None or actual_ink != expected_ink: 40 | logger.warn(f'action.move_card_marco: unmatch pattern') 41 | return '' 42 | current_offset = preview.offset[np.argmax(preview.squares == Grid.MyInk.value)] 43 | target_offset = expected_pattern.offset[np.argmax(expected_pattern == Grid.MyInk.value)] 44 | current = current + current_offset 45 | target = target + target_offset 46 | logger.debug(f'action.move_card_marco: fix current. offset={current_offset}, current={current}') 47 | logger.debug(f'action.move_card_marco: fix target. offset={target_offset}, target={target}') 48 | diff_y, diff_x = target - current 49 | if diff_x > 0: 50 | step_x = 1 51 | buttons_x = [Controller.Button.DPAD_RIGHT] * diff_x 52 | else: 53 | step_x = -1 54 | buttons_x = [Controller.Button.DPAD_LEFT] * -diff_x 55 | if diff_y > 0: 56 | step_y = 1 57 | buttons_y = [Controller.Button.DPAD_DOWN] * diff_y 58 | else: 59 | step_y = -1 60 | buttons_y = [Controller.Button.DPAD_UP] * -diff_y 61 | stage_grid = stage.grid 62 | row_first_wall_count = np.sum(stage_grid[current[0], current[1]:target[1] + 1:step_x] == Grid.Wall.value) + np.sum(stage_grid[current[0]:target[0] + 1:step_y, target[1]] == Grid.Wall.value) 63 | col_first_wall_count = np.sum(stage_grid[current[0]:target[0] + 1:step_y, current[1]] == Grid.Wall.value) + np.sum(stage_grid[target[0], current[1]:target[1] + 1:step_x] == Grid.Wall.value) 64 | if row_first_wall_count < col_first_wall_count: 65 | buttons = buttons_x + buttons_y 66 | else: 67 | buttons = buttons_y + buttons_x 68 | return util.buttons_to_marco(buttons) 69 | -------------------------------------------------------------------------------- /tableturf/manager/action/deck.py: -------------------------------------------------------------------------------- 1 | from controller import Controller 2 | from logger import logger 3 | from tableturf.manager.action import util 4 | 5 | 6 | def move_deck_cursor_marco(target: int, current: int) -> str: 7 | logger.debug(f'action.move_deck_cursor_marco: target={target}, current={current}') 8 | current_x = current // 8 9 | current_y = current % 8 10 | target_x = target // 8 11 | target_y = target % 8 12 | buttons = [] 13 | if current_x > target_x: 14 | buttons += [Controller.Button.DPAD_LEFT] * (current_x - target_x) 15 | else: 16 | buttons += [Controller.Button.DPAD_RIGHT] * (target_x - current_x) 17 | if current_y > target_y: 18 | buttons += [Controller.Button.DPAD_UP] * (current_y - target_y) 19 | else: 20 | buttons += [Controller.Button.DPAD_DOWN] * (target_y - current_y) 21 | return util.buttons_to_marco(buttons) 22 | -------------------------------------------------------------------------------- /tableturf/manager/action/giveup.py: -------------------------------------------------------------------------------- 1 | from controller import Controller 2 | from logger import logger 3 | from tableturf.manager.action import util 4 | 5 | 6 | def move_giveup_cursor_marco(target: int, current: int) -> str: 7 | logger.debug(f'action.move_giveup_cursor_marco: target={target}, current={current}') 8 | buttons = [] 9 | if target > current: 10 | buttons += [Controller.Button.DPAD_RIGHT] 11 | elif target < current: 12 | buttons += [Controller.Button.DPAD_LEFT] 13 | return util.buttons_to_marco(buttons) 14 | -------------------------------------------------------------------------------- /tableturf/manager/action/hands.py: -------------------------------------------------------------------------------- 1 | from controller import Controller 2 | from logger import logger 3 | from tableturf.manager.action import util 4 | 5 | 6 | def move_hands_cursor_marco(target: int, current: int) -> str: 7 | logger.debug(f'action.move_hands_cursor_marco: target={target}, current={current}') 8 | current_x = current % 2 9 | current_y = current // 2 10 | target_x = target % 2 11 | target_y = target // 2 12 | buttons = [] 13 | if current_x > target_x: 14 | buttons += [Controller.Button.DPAD_LEFT] * (current_x - target_x) 15 | else: 16 | buttons += [Controller.Button.DPAD_RIGHT] * (target_x - current_x) 17 | if current_y > target_y: 18 | buttons += [Controller.Button.DPAD_UP] * (current_y - target_y) 19 | else: 20 | buttons += [Controller.Button.DPAD_DOWN] * (target_y - current_y) 21 | return util.buttons_to_marco(buttons) 22 | -------------------------------------------------------------------------------- /tableturf/manager/action/redraw.py: -------------------------------------------------------------------------------- 1 | from controller import Controller 2 | from logger import logger 3 | from tableturf.manager.action import util 4 | 5 | 6 | def move_redraw_cursor_marco(target: int, current: int) -> str: 7 | logger.debug(f'action.move_redraw_cursor_marco: target={target}, current={current}') 8 | buttons = [] 9 | if target > current: 10 | buttons += [Controller.Button.DPAD_RIGHT] 11 | elif target < current: 12 | buttons += [Controller.Button.DPAD_LEFT] 13 | return util.buttons_to_marco(buttons) 14 | -------------------------------------------------------------------------------- /tableturf/manager/action/replay.py: -------------------------------------------------------------------------------- 1 | from controller import Controller 2 | from logger import logger 3 | from tableturf.manager.action import util 4 | 5 | 6 | def move_replay_cursor_marco(target: int, current: int) -> str: 7 | logger.debug(f'action.move_replay_cursor_marco: target={target}, current={current}') 8 | buttons = [] 9 | if target > current: 10 | buttons += [Controller.Button.DPAD_RIGHT] 11 | elif target < current: 12 | buttons += [Controller.Button.DPAD_LEFT] 13 | return util.buttons_to_marco(buttons) 14 | -------------------------------------------------------------------------------- /tableturf/manager/action/util.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from controller import Controller 4 | 5 | 6 | def buttons_to_marco(buttons: List[Controller.Button], down=0.05, up=0.05) -> str: 7 | return ''.join([f'{str(b.value)} {down}s\n{up}s\n' for b in buttons]) 8 | -------------------------------------------------------------------------------- /tableturf/manager/closer/__init__.py: -------------------------------------------------------------------------------- 1 | from tableturf.manager.closer.interface import Closer 2 | from tableturf.manager.closer.stats_closer import TaskStatsCloser 3 | from tableturf.manager.closer.union_closer import UnionCloser -------------------------------------------------------------------------------- /tableturf/manager/closer/interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | from tableturf.manager.data import JobStats 4 | 5 | 6 | class Closer(ABC): 7 | def close(self, job_stats: JobStats) -> bool: 8 | return False 9 | -------------------------------------------------------------------------------- /tableturf/manager/closer/stats_closer.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from tableturf.manager.closer.interface import Closer 4 | from tableturf.manager.data import JobStats 5 | 6 | 7 | class TaskStatsCloser(Closer): 8 | def __init__(self, max_win: Optional[int] = None, max_battle: Optional[int] = None, max_time: Optional[int] = None): 9 | self.__max_win = max_win 10 | self.__max_battle = max_battle 11 | self.__max_time = max_time 12 | 13 | def close(self, job_stats: JobStats) -> bool: 14 | if self.__max_win is not None and self.__max_win <= job_stats.task_stats.win: 15 | return True 16 | if self.__max_battle is not None and self.__max_battle <= job_stats.task_stats.battle: 17 | return True 18 | if self.__max_time is not None and self.__max_time <= job_stats.task_stats.time: 19 | return True 20 | return False 21 | -------------------------------------------------------------------------------- /tableturf/manager/closer/union_closer.py: -------------------------------------------------------------------------------- 1 | from tableturf.manager.closer.interface import Closer 2 | from tableturf.manager.data import JobStats 3 | 4 | 5 | class UnionCloser(Closer): 6 | def __init__(self, this: Closer, that: Closer): 7 | self.__this = this 8 | self.__that = that 9 | 10 | def close(self, job_stats: JobStats) -> bool: 11 | return self.__this.close(job_stats) or self.__that.close(job_stats) 12 | -------------------------------------------------------------------------------- /tableturf/manager/data.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from enum import Enum 4 | 5 | 6 | class Result(Enum): 7 | Win = 'win' 8 | Loss = 'loss' 9 | Draw = 'draw' 10 | 11 | 12 | class JobStats: 13 | def __init__(self): 14 | self.task_stats = TaskStats() 15 | self.task_id = 0 16 | 17 | def __repr__(self): 18 | return f'JobStats(task_id={self.task_id}, task_stats={self.task_stats})' 19 | 20 | def __str__(self): 21 | return repr(self) 22 | 23 | 24 | class TaskStats: 25 | def __init__(self): 26 | self.win = 0 27 | self.battle = 0 28 | self.time = 0 29 | self.start_time = 0 30 | 31 | def __repr__(self): 32 | return f'TaskStats(win={self.win}, battle={self.battle}, time={self.time}, start_time={self.start_time})' 33 | 34 | def __str__(self): 35 | return repr(self) 36 | 37 | 38 | @dataclass 39 | class Profile: 40 | @dataclass 41 | class Task: 42 | current_level: int 43 | current_win: int 44 | target_level: int 45 | target_win: int 46 | deck: int 47 | 48 | tasks: list[Task] 49 | 50 | @staticmethod 51 | def from_json(text: str): 52 | obj = json.loads(text) 53 | tasks = [Profile.Task( 54 | current_level=node['current_level'], 55 | current_win=node['current_win'], 56 | target_level=node['target_level'], 57 | target_win=node['target_win'], 58 | deck=node['deck'], 59 | ) for node in obj] 60 | return Profile(tasks=tasks) 61 | -------------------------------------------------------------------------------- /tableturf/manager/detection/__init__.py: -------------------------------------------------------------------------------- 1 | from tableturf.manager.detection.card import hands 2 | from tableturf.manager.detection.deck import deck 3 | from tableturf.manager.detection.stage import stage_rois, stage, preview, sp 4 | from tableturf.manager.detection.ui import deck_cursor, hands_cursor, redraw_cursor, special_on, skip, replay_cursor, giveup_cursor, result, level, start 5 | -------------------------------------------------------------------------------- /tableturf/manager/detection/card.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | from logger import logger 7 | from tableturf.manager.detection.debugger import Debugger 8 | from tableturf.manager.detection import util 9 | from tableturf.manager.detection.ui import hands_cursor 10 | from tableturf.model import Card, Grid 11 | 12 | # card grid 13 | HANDS_GRID_TOP_LEFTS = np.array([[192, 51], [203, 305], [516, 51], [516, 305]]) 14 | HANDS_GRID_ROI_WIDTH = 14 15 | HANDS_GRID_ROI_HEIGHT = 14 16 | HANDS_GRID_ROI_WIDTH_STEPS = [25.9, 24.7, 25.9, 24.7] 17 | HANDS_GRID_ROI_HEIGHT_STEPS = [23.9, 23, 23.9, 23] 18 | HANDS_GRID_ROI_HEIGHT_OFFSETS = [1, 0.8, 0, 0] 19 | HANDS_GRID_NUMPY_ROI_TOP_LEFTS = np.array([util.grid_roi_top_lefts(top_left, 8, 8, HANDS_GRID_ROI_WIDTH_STEPS[i], HANDS_GRID_ROI_HEIGHT_STEPS[i], 0, HANDS_GRID_ROI_HEIGHT_OFFSETS[i]) for i, top_left in enumerate(HANDS_GRID_TOP_LEFTS)]).reshape((4, 64, 2)) 20 | # card cost 21 | HANDS_COST_TOP_LEFTS = np.array([[410, 123], [414, 373], [731, 123], [725, 373]]) 22 | HANDS_COST_ROI_WIDTH = 12 23 | HANDS_COST_ROI_HEIGHT = 12 24 | HANDS_COST_ROI_WIDTH_STEPS = [22.9, 21.5, 22.9, 21.5] 25 | HANDS_COST_ROI_HEIGHT_OFFSETS = [0, 0, -0.6, -0.6] 26 | HANDS_COST_OPENCV_ROI_LEFT_TOP = np.array([util.numpy_to_opencv(idx) for idx in HANDS_COST_TOP_LEFTS]) # shape: (4, 2) 27 | HANDS_COST_NUMPY_ROI_TOP_LEFTS = np.array([util.grid_roi_top_lefts(top_left, 6, 1, HANDS_COST_ROI_WIDTH_STEPS[i], 0, 0, HANDS_COST_ROI_HEIGHT_OFFSETS[i]) for i, top_left in enumerate(HANDS_COST_TOP_LEFTS)]).reshape((4, 6, 2)) 28 | 29 | # focus card grid 30 | FOCUS_GRID_TOP_LEFTS = np.array([[192, 42], [200, 295], [514, 43], [515, 295]]) 31 | FOCUS_GRID_ROI_WIDTH = 14 32 | FOCUS_GRID_ROI_HEIGHT = 14 33 | FOCUS_GRID_ROI_WIDTH_STEPS = [26.9, 25.7, 26.9, 25.7] 34 | FOCUS_GRID_ROI_HEIGHT_STEPS = [24.9, 24, 24.9, 24] 35 | FOCUS_GRID_ROI_WIDTH_OFFSETS = [1, 1, 1, 1] 36 | FOCUS_GRID_ROI_HEIGHT_OFFSETS = [-0.6, -0.4, -1, -1] 37 | FOCUS_GRID_NUMPY_ROI_TOP_LEFTS = np.array([util.grid_roi_top_lefts(top_left, 8, 8, FOCUS_GRID_ROI_WIDTH_STEPS[i], FOCUS_GRID_ROI_HEIGHT_STEPS[i], FOCUS_GRID_ROI_WIDTH_OFFSETS[i], FOCUS_GRID_ROI_HEIGHT_OFFSETS[i]) for i, top_left in enumerate(FOCUS_GRID_TOP_LEFTS)]).reshape((4, 64, 2)) 38 | # focus card cost 39 | FOCUS_COST_TOP_LEFTS = np.array([[415, 128], [417, 375], [735, 127], [730, 376]]) 40 | FOCUS_COST_ROI_WIDTH = 12 41 | FOCUS_COST_ROI_HEIGHT = 12 42 | FOCUS_COST_ROI_WIDTH_STEPS = [22.9, 22.5, 23.9, 22.5] 43 | FOCUS_COST_ROI_HEIGHT_OFFSETS = [-0.5, 0, -1, -1] 44 | FOCUS_COST_NUMPY_ROI_TOP_LEFTS = np.array([util.grid_roi_top_lefts(top_left, 6, 1, FOCUS_COST_ROI_WIDTH_STEPS[i], 0, 0, FOCUS_COST_ROI_HEIGHT_OFFSETS[i]) for i, top_left in enumerate(FOCUS_COST_TOP_LEFTS)]).reshape((4, 6, 2)) 45 | 46 | MY_INK_LIGHTER_COLOR_HSV_UPPER_BOUND = (35, 255, 255) 47 | MY_INK_LIGHTER_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 48 | MY_SPECIAL_LIGHTER_COLOR_HSV_UPPER_BOUND = (25, 255, 255) 49 | MY_SPECIAL_LIGHTER_COLOR_HSV_LOWER_BOUND = (20, 100, 150) 50 | MY_INK_DARKER_COLOR_HSV_UPPER_BOUND = (35, 255, 140) 51 | MY_INK_DARKER_COLOR_HSV_LOWER_BOUND = (27, 100, 100) 52 | MY_SPECIAL_DARKER_COLOR_HSV_UPPER_BOUND = (27, 255, 140) 53 | MY_SPECIAL_DARKER_COLOR_HSV_LOWER_BOUND = (15, 100, 100) 54 | GRID_PIXEL_RATIO = 0.3 55 | 56 | 57 | def hands(img: np.ndarray, cursor=None, debug: Optional[Debugger] = None) -> List[Card]: 58 | def __grid_ratios(top_left: np.ndarray, lower_bound, upper_bound) -> List[Card]: 59 | roi = img[top_left[0]:top_left[0] + HANDS_GRID_ROI_HEIGHT, top_left[1]:top_left[1] + HANDS_GRID_ROI_WIDTH] 60 | hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) 61 | mask = cv2.inRange(hsv, lower_bound, upper_bound) 62 | return np.sum(mask == 255) / (HANDS_GRID_ROI_WIDTH * HANDS_GRID_ROI_HEIGHT) 63 | 64 | if cursor is None: 65 | cursor = hands_cursor(img, debug) 66 | grid_rois = HANDS_GRID_NUMPY_ROI_TOP_LEFTS.copy() 67 | cost_rois = HANDS_COST_NUMPY_ROI_TOP_LEFTS.copy() 68 | if 0 <= cursor < 4: 69 | grid_rois[cursor] = FOCUS_GRID_NUMPY_ROI_TOP_LEFTS[cursor] 70 | cost_rois[cursor] = FOCUS_COST_NUMPY_ROI_TOP_LEFTS[cursor] 71 | 72 | ink_lower_bound, ink_upper_bound = MY_INK_LIGHTER_COLOR_HSV_LOWER_BOUND, MY_INK_LIGHTER_COLOR_HSV_UPPER_BOUND 73 | special_lower_bound, special_upper_bound = MY_SPECIAL_LIGHTER_COLOR_HSV_LOWER_BOUND, MY_SPECIAL_LIGHTER_COLOR_HSV_UPPER_BOUND 74 | grid_ink_ratios = np.array([__grid_ratios(idx, ink_lower_bound, ink_upper_bound) for grid in grid_rois for idx in grid]).reshape(4, 64) 75 | grid_special_ratios = np.array([__grid_ratios(idx, special_lower_bound, special_upper_bound) for grid in grid_rois for idx in grid]).reshape(4, 64) 76 | dark_grid_ink_ratios = np.array([__grid_ratios(idx, MY_INK_DARKER_COLOR_HSV_LOWER_BOUND, MY_INK_DARKER_COLOR_HSV_UPPER_BOUND) for grid in grid_rois for idx in grid]).reshape(4, 64) 77 | dark_grid_special_ratios = np.array([__grid_ratios(idx, MY_SPECIAL_DARKER_COLOR_HSV_LOWER_BOUND, MY_SPECIAL_DARKER_COLOR_HSV_UPPER_BOUND) for grid in grid_rois for idx in grid]).reshape(4, 64) 78 | 79 | cost_ratios = np.array([__grid_ratios(idx, special_lower_bound, special_upper_bound) for grid in cost_rois for idx in grid]).reshape(4, 6) 80 | dark_cost_ratios = np.array([__grid_ratios(idx, MY_SPECIAL_DARKER_COLOR_HSV_LOWER_BOUND, MY_SPECIAL_DARKER_COLOR_HSV_UPPER_BOUND) for grid in cost_rois for idx in grid]).reshape(4, 6) 81 | cost_ratios = np.maximum(cost_ratios, dark_cost_ratios) 82 | 83 | grids = np.full((4, 64), Grid.Empty.value, dtype=int) 84 | grids[grid_ink_ratios > GRID_PIXEL_RATIO] = Grid.MyInk.value 85 | grids[grid_special_ratios > GRID_PIXEL_RATIO] = Grid.MySpecial.value 86 | grids[dark_grid_ink_ratios > GRID_PIXEL_RATIO] = Grid.MyInk.value 87 | grids[dark_grid_special_ratios > GRID_PIXEL_RATIO] = Grid.MySpecial.value 88 | costs = np.sum(cost_ratios > GRID_PIXEL_RATIO, axis=1) 89 | 90 | if debug: 91 | img2 = img.copy() 92 | grid_rois = np.array([util.numpy_to_opencv(idx) for grid in grid_rois for idx in grid]).reshape((4, 64, 2)) 93 | cost_rois = np.array([util.numpy_to_opencv(idx) for grid in cost_rois for idx in grid]).reshape((4, 6, 2)) 94 | hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) 95 | ink_mask = cv2.inRange(hsv, ink_lower_bound, ink_upper_bound) 96 | special_mask = cv2.inRange(hsv, special_lower_bound, special_upper_bound) 97 | dark_ink_mask = cv2.inRange(hsv, MY_INK_DARKER_COLOR_HSV_LOWER_BOUND, MY_INK_DARKER_COLOR_HSV_UPPER_BOUND) 98 | dark_special_mask = cv2.inRange(hsv, MY_SPECIAL_DARKER_COLOR_HSV_LOWER_BOUND, MY_SPECIAL_DARKER_COLOR_HSV_UPPER_BOUND) 99 | # mask = np.maximum(ink_mask, special_mask) 100 | mask = np.max([ink_mask, special_mask, dark_ink_mask, dark_special_mask], axis=0) 101 | mask = cv2.merge((mask, mask, mask)) 102 | for i, grid in enumerate(grid_rois): 103 | for k, roi in enumerate(grid): 104 | cv2.rectangle(img2, roi, roi + (HANDS_GRID_ROI_WIDTH, HANDS_GRID_ROI_HEIGHT), (0, 255, 0), 1) 105 | cv2.putText(img2, f'{k}', roi + np.rint([HANDS_GRID_ROI_WIDTH / 10, HANDS_GRID_ROI_HEIGHT / 1.3]).astype(int), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (0, 255, 0), 1) 106 | cv2.rectangle(mask, roi, roi + (HANDS_GRID_ROI_WIDTH, HANDS_GRID_ROI_HEIGHT), (0, 255, 0), 1) 107 | cv2.putText(mask, f'{k}', roi + np.rint([HANDS_GRID_ROI_WIDTH / 10, HANDS_GRID_ROI_HEIGHT / 1.3]).astype(int), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (0, 255, 0), 1) 108 | if grids[i][k] == Grid.MyInk.value: 109 | cv2.rectangle(img2, roi, roi + (HANDS_GRID_ROI_WIDTH, HANDS_GRID_ROI_HEIGHT), (0, 0, 255), 1) 110 | cv2.putText(img2, f'{k}', roi + np.rint([HANDS_GRID_ROI_WIDTH / 10, HANDS_GRID_ROI_HEIGHT / 1.3]).astype(int), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (0, 0, 255), 1) 111 | cv2.rectangle(mask, roi, roi + (HANDS_GRID_ROI_WIDTH, HANDS_GRID_ROI_HEIGHT), (0, 0, 255), 1) 112 | cv2.putText(mask, f'{k}', roi + np.rint([HANDS_GRID_ROI_WIDTH / 10, HANDS_GRID_ROI_HEIGHT / 1.3]).astype(int), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (0, 0, 255), 1) 113 | if grids[i][k] == Grid.MySpecial.value: 114 | cv2.rectangle(img2, roi, roi + (HANDS_GRID_ROI_WIDTH, HANDS_GRID_ROI_HEIGHT), (255, 0, 0), 1) 115 | cv2.putText(img2, f'{k}', roi + np.rint([HANDS_GRID_ROI_WIDTH / 10, HANDS_GRID_ROI_HEIGHT / 1.3]).astype(int), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 0, 0), 1) 116 | cv2.rectangle(mask, roi, roi + (HANDS_GRID_ROI_WIDTH, HANDS_GRID_ROI_HEIGHT), (255, 0, 0), 1) 117 | cv2.putText(mask, f'{k}', roi + np.rint([HANDS_GRID_ROI_WIDTH / 10, HANDS_GRID_ROI_HEIGHT / 1.3]).astype(int), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 0, 0), 1) 118 | for i, cost in enumerate(cost_rois): 119 | for k, roi in enumerate(cost): 120 | cv2.rectangle(img2, roi, roi + (HANDS_COST_ROI_WIDTH, HANDS_COST_ROI_HEIGHT), (0, 255, 0), 1) 121 | cv2.rectangle(mask, roi, roi + (HANDS_COST_ROI_WIDTH, HANDS_COST_ROI_HEIGHT), (0, 255, 0), 1) 122 | if cost_ratios[i][k] > GRID_PIXEL_RATIO: 123 | cv2.rectangle(img2, roi, roi + (HANDS_COST_ROI_WIDTH, HANDS_COST_ROI_HEIGHT), (255, 0, 0), 1) 124 | cv2.rectangle(mask, roi, roi + (HANDS_COST_ROI_WIDTH, HANDS_COST_ROI_HEIGHT), (255, 0, 0), 1) 125 | cv2.putText(mask, f'{costs[i]}', HANDS_COST_OPENCV_ROI_LEFT_TOP[i] + np.rint([-HANDS_GRID_ROI_WIDTH * 1.5, HANDS_COST_ROI_HEIGHT]).astype(int), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1) 126 | debug.show('hands.image', img2) 127 | debug.show('hands.color_mask', mask) 128 | grids = grids.reshape((4, 8, 8)) 129 | cards = [Card(grids[i], costs[i]) for i in range(4)] 130 | logger.debug(f'detection.hands: return={cards}') 131 | return cards 132 | -------------------------------------------------------------------------------- /tableturf/manager/detection/debugger/__init__.py: -------------------------------------------------------------------------------- 1 | from tableturf.manager.detection.debugger.interface import Debugger 2 | from tableturf.manager.detection.debugger.cv import OpenCVDebugger -------------------------------------------------------------------------------- /tableturf/manager/detection/debugger/cv.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | 4 | from logger import logger 5 | from tableturf.manager.detection.debugger.interface import Debugger 6 | 7 | 8 | class OpenCVDebugger(Debugger): 9 | def show(self, name: str, img: np.ndarray): 10 | def __print_debug_info(event, x, y, flags, param): 11 | if event == cv2.EVENT_LBUTTONDOWN: 12 | bgr = img[y:y + 1, x:x + 1] 13 | hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) 14 | logger.debug(f'detection.debug: x={x}, y={y}, BGR={bgr.squeeze()}, HSV={hsv.squeeze()}') 15 | 16 | cv2.imshow(name, img) 17 | cv2.setMouseCallback(name, __print_debug_info) 18 | cv2.waitKey() 19 | cv2.destroyAllWindows() 20 | -------------------------------------------------------------------------------- /tableturf/manager/detection/debugger/interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | import numpy as np 4 | 5 | 6 | class Debugger(ABC): 7 | def show(self, name: str, img: np.ndarray): 8 | raise NotImplementedError 9 | 10 | def __bool__(self): 11 | return True 12 | -------------------------------------------------------------------------------- /tableturf/manager/detection/deck.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | from logger import logger 7 | from tableturf.manager.detection.debugger import Debugger 8 | from tableturf.manager.detection import util 9 | from tableturf.model import Card, Grid 10 | 11 | # deck grid 12 | DECK_GRID_TOP_LEFTS = np.array([[102, 1357], [99, 1493], [96, 1629], [282, 1357], [280, 1492], [278, 1628], [463, 1357], [462, 1491], [461, 1628], [643, 1357], [644, 1492], [644, 1629], [824, 1357], [826, 1492], [827, 1629]]) 13 | DECK_GRID_ROI_WIDTH = 8 14 | DECK_GRID_ROI_HEIGHT = 8 15 | DECK_GRID_ROI_WIDTH_STEPS = [14, 14.3, 14.6] 16 | DECK_GRID_ROI_HEIGHT_OFFSETS = [-0.5, -0.3, 0, 0, 0.2] 17 | DECK_GRID_NUMPY_ROI_TOP_LEFTS = np.array([util.grid_roi_top_lefts(top_left, 8, 8, DECK_GRID_ROI_WIDTH_STEPS[i % 3], 14, 0, DECK_GRID_ROI_HEIGHT_OFFSETS[i // 3]) for i, top_left in enumerate(DECK_GRID_TOP_LEFTS)]).reshape((15, 64, 2)) 18 | # deck cost 19 | DECK_COST_TOP_LEFTS = np.array([[227, 1395], [225, 1531], [223, 1668], [408, 1395], [407, 1531], [406, 1668], [589, 1395], [589, 1531], [589, 1668], [770, 1395], [771, 1531], [773, 1668], [950, 1395], [953, 1531], [956, 1668]]) 20 | DECK_COST_ROI_WIDTH = 8 21 | DECK_COST_ROI_HEIGHT = 8 22 | DECK_COST_ROI_HEIGHT_OFFSETS = [-0.3, 0, 0, 0, 0.3] 23 | DECK_COST_OPENCV_ROI_LEFT_TOP = np.array([util.numpy_to_opencv(idx) for idx in DECK_COST_TOP_LEFTS]) # shape: (15, 2) 24 | DECK_COST_NUMPY_ROI_TOP_LEFTS = np.array([util.grid_roi_top_lefts(top_left, 6, 1, 13, 0, 0, DECK_COST_ROI_HEIGHT_OFFSETS[i // 3]) for i, top_left in enumerate(DECK_COST_TOP_LEFTS)]).reshape((15, 6, 2)) 25 | 26 | MY_INK_LIGHTER_COLOR_HSV_UPPER_BOUND = (35, 255, 255) 27 | MY_INK_LIGHTER_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 28 | MY_SPECIAL_LIGHTER_COLOR_HSV_UPPER_BOUND = (25, 255, 255) 29 | MY_SPECIAL_LIGHTER_COLOR_HSV_LOWER_BOUND = (20, 100, 150) 30 | MY_INK_DARKER_COLOR_HSV_UPPER_BOUND = (35, 255, 140) 31 | MY_INK_DARKER_COLOR_HSV_LOWER_BOUND = (27, 100, 100) 32 | MY_SPECIAL_DARKER_COLOR_HSV_UPPER_BOUND = (27, 255, 140) 33 | MY_SPECIAL_DARKER_COLOR_HSV_LOWER_BOUND = (15, 100, 100) 34 | GRID_PIXEL_RATIO = 0.3 35 | 36 | 37 | def deck(img: np.ndarray, debug: Optional[Debugger] = None) -> List[Card]: 38 | def __grid_ratios(top_left: np.ndarray, lower_bound, upper_bound) -> List[Card]: 39 | roi = img[top_left[0]:top_left[0] + DECK_GRID_ROI_HEIGHT, top_left[1]:top_left[1] + DECK_GRID_ROI_WIDTH] 40 | hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) 41 | mask = cv2.inRange(hsv, lower_bound, upper_bound) 42 | return np.sum(mask == 255) / (DECK_GRID_ROI_WIDTH * DECK_GRID_ROI_HEIGHT) 43 | 44 | ink_lower_bound, ink_upper_bound = MY_INK_LIGHTER_COLOR_HSV_LOWER_BOUND, MY_INK_LIGHTER_COLOR_HSV_UPPER_BOUND 45 | special_lower_bound, special_upper_bound = MY_SPECIAL_LIGHTER_COLOR_HSV_LOWER_BOUND, MY_SPECIAL_LIGHTER_COLOR_HSV_UPPER_BOUND 46 | grid_ink_ratios = np.array([__grid_ratios(idx, ink_lower_bound, ink_upper_bound) for grid in DECK_GRID_NUMPY_ROI_TOP_LEFTS for idx in grid]).reshape(15, 64) 47 | grid_special_ratios = np.array([__grid_ratios(idx, special_lower_bound, special_upper_bound) for grid in DECK_GRID_NUMPY_ROI_TOP_LEFTS for idx in grid]).reshape(15, 64) 48 | dark_grid_ink_ratios = np.array([__grid_ratios(idx, MY_INK_DARKER_COLOR_HSV_LOWER_BOUND, MY_INK_DARKER_COLOR_HSV_UPPER_BOUND) for grid in DECK_GRID_NUMPY_ROI_TOP_LEFTS for idx in grid]).reshape(15, 64) 49 | dark_grid_special_ratios = np.array([__grid_ratios(idx, MY_SPECIAL_DARKER_COLOR_HSV_LOWER_BOUND, MY_SPECIAL_DARKER_COLOR_HSV_UPPER_BOUND) for grid in DECK_GRID_NUMPY_ROI_TOP_LEFTS for idx in grid]).reshape(15, 64) 50 | 51 | cost_ratios = np.array([__grid_ratios(idx, special_lower_bound, special_upper_bound) for grid in DECK_COST_NUMPY_ROI_TOP_LEFTS for idx in grid]).reshape(15, 6) 52 | dark_cost_ratios = np.array([__grid_ratios(idx, MY_SPECIAL_DARKER_COLOR_HSV_LOWER_BOUND, MY_SPECIAL_DARKER_COLOR_HSV_UPPER_BOUND) for grid in DECK_COST_NUMPY_ROI_TOP_LEFTS for idx in grid]).reshape(15, 6) 53 | cost_ratios = np.maximum(cost_ratios, dark_cost_ratios) 54 | 55 | grids = np.full((15, 64), Grid.Empty.value, dtype=int) 56 | grids[grid_ink_ratios > GRID_PIXEL_RATIO] = Grid.MyInk.value 57 | grids[grid_special_ratios > GRID_PIXEL_RATIO] = Grid.MySpecial.value 58 | grids[dark_grid_ink_ratios > GRID_PIXEL_RATIO] = Grid.MyInk.value 59 | grids[dark_grid_special_ratios > GRID_PIXEL_RATIO] = Grid.MySpecial.value 60 | costs = np.sum(cost_ratios > GRID_PIXEL_RATIO, axis=1) 61 | 62 | if debug: 63 | img2 = img.copy() 64 | grid_rois = np.array([util.numpy_to_opencv(idx) for grid in DECK_GRID_NUMPY_ROI_TOP_LEFTS for idx in grid]).reshape((15, 64, 2)) 65 | cost_rois = np.array([util.numpy_to_opencv(idx) for grid in DECK_COST_NUMPY_ROI_TOP_LEFTS for idx in grid]).reshape((15, 6, 2)) 66 | hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) 67 | ink_mask = cv2.inRange(hsv, ink_lower_bound, ink_upper_bound) 68 | special_mask = cv2.inRange(hsv, special_lower_bound, special_upper_bound) 69 | dark_ink_mask = cv2.inRange(hsv, MY_INK_DARKER_COLOR_HSV_LOWER_BOUND, MY_INK_DARKER_COLOR_HSV_UPPER_BOUND) 70 | dark_special_mask = cv2.inRange(hsv, MY_SPECIAL_DARKER_COLOR_HSV_LOWER_BOUND, MY_SPECIAL_DARKER_COLOR_HSV_UPPER_BOUND) 71 | # mask = np.maximum(ink_mask, special_mask) 72 | mask = np.max([ink_mask, special_mask, dark_ink_mask, dark_special_mask], axis=0) 73 | mask = cv2.merge((mask, mask, mask)) 74 | for i, grid in enumerate(grid_rois): 75 | for k, roi in enumerate(grid): 76 | cv2.rectangle(img2, roi, roi + (DECK_GRID_ROI_WIDTH, DECK_GRID_ROI_HEIGHT), (0, 255, 0), 1) 77 | cv2.rectangle(mask, roi, roi + (DECK_GRID_ROI_WIDTH, DECK_GRID_ROI_HEIGHT), (0, 255, 0), 1) 78 | if grids[i][k] == Grid.MyInk.value: 79 | cv2.rectangle(img2, roi, roi + (DECK_GRID_ROI_WIDTH, DECK_GRID_ROI_HEIGHT), (0, 0, 255), 1) 80 | cv2.rectangle(mask, roi, roi + (DECK_GRID_ROI_WIDTH, DECK_GRID_ROI_HEIGHT), (0, 0, 255), 1) 81 | if grids[i][k] == Grid.MySpecial.value: 82 | cv2.rectangle(img2, roi, roi + (DECK_GRID_ROI_WIDTH, DECK_GRID_ROI_HEIGHT), (255, 0, 0), 1) 83 | cv2.rectangle(mask, roi, roi + (DECK_GRID_ROI_WIDTH, DECK_GRID_ROI_HEIGHT), (255, 0, 0), 1) 84 | for i, cost in enumerate(cost_rois): 85 | for k, roi in enumerate(cost): 86 | cv2.rectangle(img2, roi, roi + (DECK_COST_ROI_WIDTH, DECK_COST_ROI_HEIGHT), (0, 255, 0), 1) 87 | cv2.rectangle(mask, roi, roi + (DECK_COST_ROI_WIDTH, DECK_COST_ROI_HEIGHT), (0, 255, 0), 1) 88 | if cost_ratios[i][k] > GRID_PIXEL_RATIO: 89 | cv2.rectangle(img2, roi, roi + (DECK_COST_ROI_WIDTH, DECK_COST_ROI_HEIGHT), (255, 0, 0), 1) 90 | cv2.rectangle(mask, roi, roi + (DECK_COST_ROI_WIDTH, DECK_COST_ROI_HEIGHT), (255, 0, 0), 1) 91 | cv2.putText(mask, f'{costs[i]}', DECK_COST_OPENCV_ROI_LEFT_TOP[i] + np.rint([-DECK_GRID_ROI_WIDTH * 1.5, DECK_COST_ROI_HEIGHT]).astype(int), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1) 92 | debug.show('deck.image', img2) 93 | debug.show('deck.color_mask', mask) 94 | grids = grids.reshape((15, 8, 8)) 95 | deck = [Card(grids[i], costs[i]) for i in range(15)] 96 | logger.debug(f'detection.deck: return={deck}') 97 | return deck 98 | -------------------------------------------------------------------------------- /tableturf/manager/detection/ui.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import numpy as np 4 | 5 | from logger import logger 6 | from tableturf.manager.data import Result 7 | from tableturf.manager.detection import util 8 | from tableturf.manager.detection.debugger import Debugger 9 | 10 | DECK_CURSOR_ROI_TOP_LEFT = np.array([285, 450]) 11 | DECK_CURSOR_ROI_TOP = 285 12 | DECK_CURSOR_ROI_WIDTH_STEP = 345 13 | DECK_CURSOR_ROI_HEIGHT_STEP = 95 14 | DECK_CURSOR_ROI_WIDTH = 15 15 | DECK_CURSOR_ROI_HEIGHT = 15 16 | DECK_CURSOR_NUMPY_ROI_TOP_LEFTS = util.grid_roi_top_lefts(DECK_CURSOR_ROI_TOP_LEFT, 2, 8, DECK_CURSOR_ROI_WIDTH_STEP, DECK_CURSOR_ROI_HEIGHT_STEP, 0, 0).transpose((1, 0, 2)).reshape((16, 2)) 17 | DECK_CURSOR_COLOR_HSV_UPPER_BOUND = (35, 255, 255) 18 | DECK_CURSOR_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 19 | DECK_CURSOR_PIXEL_RATIO = 0.2 20 | 21 | 22 | def deck_cursor(img, debug: Optional[Debugger] = None) -> int: 23 | pos = util.detect_cursor( 24 | img, 25 | DECK_CURSOR_NUMPY_ROI_TOP_LEFTS, 26 | DECK_CURSOR_ROI_WIDTH, 27 | DECK_CURSOR_ROI_HEIGHT, 28 | [(DECK_CURSOR_COLOR_HSV_LOWER_BOUND, DECK_CURSOR_COLOR_HSV_UPPER_BOUND)], 29 | DECK_CURSOR_PIXEL_RATIO, 30 | debug, 31 | 'deck_cursor' 32 | ) 33 | logger.debug(f'detection.deck_cursor: return={pos}') 34 | return pos 35 | 36 | 37 | HANDS_CURSOR_NUMPY_ROI_TOP_LEFTS = np.array([[470, 14], [480, 266], [793, 14], [797, 266], [909, 14], [898, 266]]) 38 | HANDS_CURSOR_ROI_WIDTH = 30 39 | HANDS_CURSOR_ROI_HEIGHT = 30 40 | HANDS_CURSOR_COLOR_HSV_UPPER_BOUND = (35, 255, 255) 41 | HANDS_CURSOR_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 42 | HANDS_CURSOR_PIXEL_RATIO = 0.3 43 | 44 | 45 | def hands_cursor(img: np.ndarray, debug: Optional[Debugger] = None) -> int: 46 | pos = util.detect_cursor( 47 | img, 48 | HANDS_CURSOR_NUMPY_ROI_TOP_LEFTS, 49 | HANDS_CURSOR_ROI_WIDTH, 50 | HANDS_CURSOR_ROI_HEIGHT, 51 | [(HANDS_CURSOR_COLOR_HSV_LOWER_BOUND, HANDS_CURSOR_COLOR_HSV_UPPER_BOUND)], 52 | HANDS_CURSOR_PIXEL_RATIO, 53 | debug, 54 | 'hands_cursor' 55 | ) 56 | logger.debug(f'detection.hands_cursor: return={pos}') 57 | return pos 58 | 59 | 60 | REDRAW_CURSOR_NUMPY_ROI_TOP_LEFTS = np.array([[760, 900], [760, 1250]]) 61 | REDRAW_CURSOR_ROI_WIDTH = 180 62 | REDRAW_CURSOR_ROI_HEIGHT = 70 63 | REDRAW_CURSOR_COLOR_HSV_UPPER_BOUND = (50, 255, 255) 64 | REDRAW_CURSOR_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 65 | REDRAW_CURSOR_PIXEL_RATIO = 0.5 66 | 67 | 68 | def redraw_cursor(img: np.ndarray, debug: Optional[Debugger] = None) -> int: 69 | pos = util.detect_cursor( 70 | img, 71 | REDRAW_CURSOR_NUMPY_ROI_TOP_LEFTS, 72 | REDRAW_CURSOR_ROI_WIDTH, 73 | REDRAW_CURSOR_ROI_HEIGHT, 74 | [(REDRAW_CURSOR_COLOR_HSV_LOWER_BOUND, REDRAW_CURSOR_COLOR_HSV_UPPER_BOUND)], 75 | REDRAW_CURSOR_PIXEL_RATIO, 76 | debug, 77 | 'redraw_cursor' 78 | ) 79 | logger.debug(f'detection.redraw_cursor: return={pos}') 80 | return pos 81 | 82 | 83 | SPECIAL_ON_CURSOR_NUMPY_ROI_TOP_LEFTS = np.array([[840, 295]]) 84 | SPECIAL_ON_CURSOR_ROI_WIDTH = 200 85 | SPECIAL_ON_CURSOR_ROI_HEIGHT = 50 86 | SPECIAL_ON_CURSOR_COLOR_HSV_UPPER_BOUND = (40, 255, 240) 87 | SPECIAL_ON_CURSOR_COLOR_HSV_LOWER_BOUND = (20, 150, 200) 88 | SPECIAL_ON_CURSOR_DARK_COLOR_HSV_UPPER_BOUND = (115, 50, 150) 89 | SPECIAL_ON_CURSOR_DARK_COLOR_HSV_LOWER_BOUND = (70, 0, 100) 90 | SPECIAL_ON_CURSOR_PIXEL_RATIO = 0.4 91 | 92 | 93 | def special_on(img: np.ndarray, debug: Optional[Debugger] = None) -> bool: 94 | result = util.detect_cursor( 95 | img, 96 | SPECIAL_ON_CURSOR_NUMPY_ROI_TOP_LEFTS, 97 | SPECIAL_ON_CURSOR_ROI_WIDTH, 98 | SPECIAL_ON_CURSOR_ROI_HEIGHT, 99 | [(SPECIAL_ON_CURSOR_COLOR_HSV_LOWER_BOUND, SPECIAL_ON_CURSOR_COLOR_HSV_UPPER_BOUND), (SPECIAL_ON_CURSOR_DARK_COLOR_HSV_LOWER_BOUND, SPECIAL_ON_CURSOR_DARK_COLOR_HSV_UPPER_BOUND)], 100 | SPECIAL_ON_CURSOR_PIXEL_RATIO, 101 | debug, 102 | 'special_on' 103 | ) != -1 104 | logger.debug(f'detection.special_on: return={result}') 105 | return result 106 | 107 | 108 | SKIP_CURSOR_NUMPY_ROI_TOP_LEFTS = np.array([[850, 45]]) 109 | SKIP_CURSOR_ROI_WIDTH = 200 110 | SKIP_CURSOR_ROI_HEIGHT = 50 111 | SKIP_CURSOR_COLOR_HSV_UPPER_BOUND = (40, 255, 240) 112 | SKIP_CURSOR_COLOR_HSV_LOWER_BOUND = (20, 150, 200) 113 | SKIP_CURSOR_PIXEL_RATIO = 0.4 114 | 115 | 116 | def skip(img: np.ndarray, debug: Optional[Debugger] = None) -> bool: 117 | result = util.detect_cursor( 118 | img, 119 | SKIP_CURSOR_NUMPY_ROI_TOP_LEFTS, 120 | SKIP_CURSOR_ROI_WIDTH, 121 | SKIP_CURSOR_ROI_HEIGHT, 122 | [(SKIP_CURSOR_COLOR_HSV_LOWER_BOUND, SKIP_CURSOR_COLOR_HSV_UPPER_BOUND)], 123 | SKIP_CURSOR_PIXEL_RATIO, 124 | debug, 125 | 'skip' 126 | ) != -1 127 | logger.debug(f'detection.skip: return={result}') 128 | return result 129 | 130 | 131 | REPLAY_CURSOR_NUMPY_ROI_TOP_LEFTS = np.array([[760, 705], [760, 1050]]) 132 | REPLAY_CURSOR_ROI_WIDTH = 180 133 | REPLAY_CURSOR_ROI_HEIGHT = 70 134 | REPLAY_CURSOR_COLOR_HSV_UPPER_BOUND = (50, 255, 255) 135 | REPLAY_CURSOR_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 136 | REPLAY_CURSOR_PIXEL_RATIO = 0.5 137 | 138 | 139 | def replay_cursor(img: np.ndarray, debug: Optional[Debugger] = None) -> int: 140 | pos = util.detect_cursor( 141 | img, 142 | REPLAY_CURSOR_NUMPY_ROI_TOP_LEFTS, 143 | REPLAY_CURSOR_ROI_WIDTH, 144 | REPLAY_CURSOR_ROI_HEIGHT, 145 | [(REPLAY_CURSOR_COLOR_HSV_LOWER_BOUND, REPLAY_CURSOR_COLOR_HSV_UPPER_BOUND)], 146 | REPLAY_CURSOR_PIXEL_RATIO, 147 | debug, 148 | 'replay_cursor' 149 | ) 150 | logger.debug(f'detection.replay_cursor: return={pos}') 151 | return pos 152 | 153 | 154 | GIVEUP_CURSOR_NUMPY_ROI_TOP_LEFTS = np.array([[760, 705], [760, 1050]]) 155 | GIVEUP_CURSOR_ROI_WIDTH = 180 156 | GIVEUP_CURSOR_ROI_HEIGHT = 70 157 | GIVEUP_CURSOR_COLOR_HSV_UPPER_BOUND = (50, 255, 255) 158 | GIVEUP_CURSOR_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 159 | GIVEUP_CURSOR_PIXEL_RATIO = 0.5 160 | 161 | 162 | def giveup_cursor(img: np.ndarray, debug: Optional[Debugger] = None) -> int: 163 | pos = util.detect_cursor( 164 | img, 165 | GIVEUP_CURSOR_NUMPY_ROI_TOP_LEFTS, 166 | GIVEUP_CURSOR_ROI_WIDTH, 167 | GIVEUP_CURSOR_ROI_HEIGHT, 168 | [(GIVEUP_CURSOR_COLOR_HSV_LOWER_BOUND, GIVEUP_CURSOR_COLOR_HSV_UPPER_BOUND)], 169 | GIVEUP_CURSOR_PIXEL_RATIO, 170 | debug, 171 | 'giveup_cursor' 172 | ) 173 | logger.debug(f'detection.giveup_cursor: return={pos}') 174 | return pos 175 | 176 | 177 | LOSE_NUMPY_ROI_TOP_LEFTS = np.array([[660, 320]]) 178 | LOSE_ROI_WIDTH = 8 179 | LOSE_ROI_HEIGHT = 8 180 | LOSE_COLOR_HSV_UPPER_BOUND = (50, 255, 255) 181 | LOSE_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 182 | LOSE_PIXEL_RATIO = 0.6 183 | 184 | DRAW_NUMPY_ROI_TOP_LEFTS = np.array([[640, 300]]) 185 | DRAW_ROI_WIDTH = 8 186 | DRAW_ROI_HEIGHT = 8 187 | DRAW_COLOR_HSV_UPPER_BOUND = (255, 255, 255) 188 | DRAW_COLOR_HSV_LOWER_BOUND = (0, 0, 230) 189 | DRAW_PIXEL_RATIO = 0.6 190 | 191 | 192 | def result(img: np.ndarray, debug: Optional[Debugger] = None) -> Result: 193 | ret = Result.Win 194 | loss = util.detect_cursor( 195 | img, 196 | LOSE_NUMPY_ROI_TOP_LEFTS, 197 | LOSE_ROI_WIDTH, 198 | LOSE_ROI_HEIGHT, 199 | [(LOSE_COLOR_HSV_LOWER_BOUND, LOSE_COLOR_HSV_UPPER_BOUND)], 200 | LOSE_PIXEL_RATIO, 201 | debug, 202 | 'lose' 203 | ) 204 | draw = util.detect_cursor( 205 | img, 206 | DRAW_NUMPY_ROI_TOP_LEFTS, 207 | DRAW_ROI_WIDTH, 208 | DRAW_ROI_HEIGHT, 209 | [(DRAW_COLOR_HSV_LOWER_BOUND, DRAW_COLOR_HSV_UPPER_BOUND)], 210 | DRAW_PIXEL_RATIO, 211 | debug, 212 | 'draw' 213 | ) 214 | if loss == 0: 215 | ret = Result.Loss 216 | if draw == 0: 217 | ret = Result.Draw 218 | logger.debug(f'detection.result: return={ret}') 219 | return ret 220 | 221 | 222 | LEVEL_CURSOR_NUMPY_ROI_TOP_LEFTS = np.array([[525, 1375]]) 223 | LEVEL_CURSOR_ROI_WIDTH = 10 224 | LEVEL_CURSOR_ROI_HEIGHT = 10 225 | LEVEL_CURSOR_COLOR_HSV_UPPER_BOUND = (50, 255, 255) 226 | LEVEL_CURSOR_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 227 | LEVEL_CURSOR_PIXEL_RATIO = 0.5 228 | 229 | 230 | def level(img: np.ndarray, debug: Optional[Debugger] = None) -> bool: 231 | pos = util.detect_cursor( 232 | img, 233 | LEVEL_CURSOR_NUMPY_ROI_TOP_LEFTS, 234 | LEVEL_CURSOR_ROI_WIDTH, 235 | LEVEL_CURSOR_ROI_HEIGHT, 236 | [(LEVEL_CURSOR_COLOR_HSV_LOWER_BOUND, LEVEL_CURSOR_COLOR_HSV_UPPER_BOUND)], 237 | LEVEL_CURSOR_PIXEL_RATIO, 238 | debug, 239 | 'level' 240 | ) 241 | result = pos == 0 242 | logger.debug(f'detection.level_cursor: return={result}') 243 | return result 244 | 245 | 246 | START_CURSOR_NUMPY_ROI_TOP_LEFTS = np.array([[640, 1430]]) 247 | START_CURSOR_ROI_WIDTH = 180 248 | START_CURSOR_ROI_HEIGHT = 70 249 | START_CURSOR_COLOR_HSV_UPPER_BOUND = (50, 255, 255) 250 | START_CURSOR_COLOR_HSV_LOWER_BOUND = (30, 100, 150) 251 | START_CURSOR_PIXEL_RATIO = 0.5 252 | 253 | 254 | def start(img: np.ndarray, debug: Optional[Debugger] = None) -> bool: 255 | pos = util.detect_cursor( 256 | img, 257 | START_CURSOR_NUMPY_ROI_TOP_LEFTS, 258 | START_CURSOR_ROI_WIDTH, 259 | START_CURSOR_ROI_HEIGHT, 260 | [(START_CURSOR_COLOR_HSV_LOWER_BOUND, START_CURSOR_COLOR_HSV_UPPER_BOUND)], 261 | START_CURSOR_PIXEL_RATIO, 262 | debug, 263 | 'start' 264 | ) 265 | result = pos == 0 266 | logger.debug(f'detection.start_cursor: return={result}') 267 | return result 268 | -------------------------------------------------------------------------------- /tableturf/manager/detection/util.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | from tableturf.manager.detection.debugger import Debugger 7 | 8 | 9 | def numpy_to_opencv(idx: np.ndarray) -> np.ndarray: 10 | return idx[::-1] 11 | 12 | 13 | def opencv_to_numpy(idx: np.ndarray) -> np.ndarray: 14 | return idx[::-1] 15 | 16 | 17 | def grid_roi_top_lefts(top_left, width, height, width_step, height_step, width_offset, height_offset): 18 | return np.array([(top_left[0] + np.round(height_step * h) + np.round(height_offset * w), top_left[1] + np.round(width_step * w) + np.round(width_offset * h)) for h in range(height) for w in range(width)]).astype(int).reshape((height, width, 2)) 19 | 20 | 21 | def detect_cursor(img: np.ndarray, top_lefts, width, height, hsv_ranges, threshold, debug: Optional[Debugger] = None, debug_prefix: str = ''): 22 | def __cursor_ratio(top_left: np.ndarray) -> float: 23 | # print(classify_color(img[top_left[0]:top_left[0] + height, top_left[1]:top_left[1] + width], k=2)) 24 | roi = img[top_left[0]:top_left[0] + height, top_left[1]:top_left[1] + width] 25 | hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) 26 | masks = [cv2.inRange(hsv, lower_bound, upper_bound) for (lower_bound, upper_bound) in hsv_ranges] 27 | mask = np.max(masks, axis=0) 28 | return np.sum(mask == 255) / (width * height) 29 | 30 | ratios = np.array([__cursor_ratio(top_left) for top_left in top_lefts]) 31 | pos = np.argmax(ratios) 32 | if ratios[pos] < threshold: 33 | pos = -1 34 | if debug: 35 | img2 = img.copy() 36 | hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) 37 | masks = [cv2.inRange(hsv, lower_bound, upper_bound) for (lower_bound, upper_bound) in hsv_ranges] 38 | mask = np.max(masks, axis=0) 39 | mask = cv2.merge((mask, mask, mask)) 40 | top_lefts = [numpy_to_opencv(idx) for idx in top_lefts] 41 | for i, roi in enumerate(top_lefts): 42 | cv2.rectangle(img2, roi, roi + (width, height), (0, 255, 0), 1) 43 | cv2.rectangle(mask, roi, roi + (width, height), (0, 255, 0), 1) 44 | font_size = np.min((width, height)) / 40 45 | cv2.putText(mask, f'{ratios[i]:.3}', roi + (0, -5), cv2.FONT_HERSHEY_SIMPLEX, font_size, (0, 0, 255), 1) 46 | cv2.putText(mask, f'{i}', roi + np.rint([width / 10, height / 1.3]).astype(int), cv2.FONT_HERSHEY_SIMPLEX, font_size, (0, 255, 0), 1) 47 | debug.show(f'{debug_prefix}.image', img2) 48 | debug.show(f'{debug_prefix}.color_mask', mask) 49 | return pos 50 | 51 | 52 | def kmeans(data: np.ndarray, k=3, normalize=False, limit=5000): 53 | if normalize: 54 | stats = (data.mean(axis=0), data.std(axis=0)) 55 | data = (data - stats[0]) / stats[1] 56 | centers = data[:k] 57 | 58 | for i in range(limit): 59 | classifications = np.argmin(((data[..., np.newaxis] - centers.T[np.newaxis, ...]) ** 2).sum(axis=1), axis=1) 60 | new_centers = np.array([data[classifications == j].mean(axis=0) for j in range(k)]) 61 | if np.all(new_centers == centers): 62 | break 63 | else: 64 | centers = new_centers 65 | else: 66 | raise RuntimeError(f'Clustering algorithm did not complete within {limit} iterations') 67 | 68 | if normalize: 69 | centers = centers * stats[1] + stats[0] 70 | return classifications, centers 71 | 72 | 73 | def classify_color(roi: np.ndarray, k=5): 74 | h, w, _ = roi.shape 75 | hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) 76 | classifications, centers = kmeans(hsv.reshape((-1, 3)), k, normalize=False) 77 | return classifications.reshape((h, w)), centers 78 | -------------------------------------------------------------------------------- /tableturf/manager/tableturf.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import random 3 | from datetime import datetime 4 | from time import sleep 5 | from typing import List, Optional 6 | 7 | import cv2 8 | import numpy as np 9 | 10 | from capture import Capture 11 | from controller import Controller 12 | from logger import logger 13 | from tableturf.ai import AI 14 | from tableturf.manager import action 15 | from tableturf.manager import detection 16 | from tableturf.manager.closer import Closer, TaskStatsCloser, UnionCloser 17 | from tableturf.manager.data import TaskStats, Profile, JobStats, Result 18 | from tableturf.manager.detection.debugger import Debugger 19 | from tableturf.model import Status, Card, Step, Stage, Grid 20 | 21 | 22 | class TableTurfManager: 23 | @staticmethod 24 | def __resize(capture_fn): 25 | """ 26 | Resize the captured image to (1920, 1080) to ensure that ROIs work correctly. 27 | """ 28 | 29 | def wrapper(): 30 | img = capture_fn() 31 | height, width, _ = img.shape 32 | if height != 1080 or width != 1920: 33 | img = cv2.resize(img, (1920, 1080)) 34 | return img 35 | 36 | return wrapper 37 | 38 | @staticmethod 39 | def __equal(a, b) -> bool: 40 | if isinstance(a, np.ndarray) and isinstance(b, np.ndarray): 41 | return np.all(a == b) 42 | else: 43 | return a == b 44 | 45 | def __multi_detect(self, detect_fn, sleep_time=0.1, max_loop=100): 46 | def wrapper(*args, **kwargs): 47 | previous = detect_fn(self.__capture(), *args, **kwargs) 48 | for _ in range(max_loop): 49 | sleep(sleep_time) 50 | current = detect_fn(self.__capture(), *args, **kwargs) 51 | if isinstance(previous, tuple) and isinstance(current, tuple): 52 | if len(previous) == len(current) and np.all([self.__equal(a, b) for a, b in zip(previous, current)]): 53 | return current 54 | elif current == previous: 55 | return current 56 | previous = current 57 | logger.warn(f'tableturf.multi_detect: exceeded the maximum number of loops') 58 | return previous 59 | 60 | return wrapper 61 | 62 | def __init__(self, capture: Capture, controller: Controller, ai: AI, debugger: Optional[Debugger] = None): 63 | self.__capture = self.__resize(capture.capture) 64 | self.__controller = controller 65 | self.__ai = ai 66 | self.__debugger = debugger 67 | self.job_stats = JobStats() 68 | self.__session = dict() 69 | 70 | def run(self, profile: Profile, closer: Closer = None, debug=False): 71 | self.__session = { 72 | 'debug': self.__debugger if debug else None, 73 | } 74 | self.job_stats = JobStats() 75 | for task in profile.tasks: 76 | if task.current_level < task.target_level or (task.current_level == task.target_level and task.current_win < task.target_win): 77 | self.__start() 78 | current_level = task.current_level 79 | current_win = task.current_win 80 | while current_level < task.target_level: 81 | if current_level < 3: 82 | to_win = 3 - current_win 83 | if to_win > 0: 84 | task_closer = TaskStatsCloser(max_win=to_win) 85 | if closer is not None: 86 | task_closer = UnionCloser(closer, task_closer) 87 | self.run_once(task.deck, closer=task_closer, debug=debug) 88 | if closer.close(self.job_stats): 89 | return 90 | self.__switch_level() 91 | current_level += 1 92 | current_win = 0 93 | if current_level == task.target_level: 94 | to_win = task.target_win - current_win 95 | if to_win > 0: 96 | task_closer = TaskStatsCloser(max_win=to_win) 97 | if closer is not None: 98 | task_closer = UnionCloser(closer, task_closer) 99 | self.run_once(task.deck, closer=task_closer, debug=debug) 100 | if closer.close(self.job_stats): 101 | return 102 | self.__switch_npc() 103 | self.job_stats.task_id += 1 104 | 105 | def run_once(self, deck: int, stage: Optional[Stage] = None, his_deck: Optional[List[Card]] = None, closer: Closer = Closer(), debug=False): 106 | self.__session = { 107 | 'empty_stage': stage, 108 | 'his_deck': his_deck, 109 | 'debug': self.__debugger if debug else None, 110 | } 111 | self.job_stats.task_stats = TaskStats() 112 | self.job_stats.task_stats.start_time = datetime.now().timestamp() 113 | while True: 114 | self.__init_battle() 115 | self.__select_deck(deck) 116 | self.__redraw() 117 | self.__init_roi() 118 | for round in range(12, 0, -1): 119 | status = self.__get_status(round) 120 | step = self.__ai.next_step(status) 121 | force_restart = self.__move(status, step) 122 | if force_restart: 123 | self.__give_up() 124 | break 125 | self.__update_stats() 126 | close = closer.close(self.job_stats) 127 | self.__close(close) 128 | if close: 129 | break 130 | 131 | def __init_battle(self): 132 | self.__ai.reset() 133 | 134 | def __select_deck(self, deck: int): 135 | target = deck 136 | while True: 137 | current = self.__multi_detect(detection.deck_cursor)(debug=self.__session['debug']) 138 | if current == target: 139 | break 140 | if current != -1: 141 | macro = action.move_deck_cursor_marco(target, current) 142 | self.__controller.macro(macro) 143 | else: 144 | sleep(0.5) 145 | if current == target: # only detect deck if deck is found 146 | deck = self.__multi_detect(detection.deck)(debug=self.__session['debug']) 147 | self.__session['my_deck'] = deck 148 | self.__controller.press_buttons([Controller.Button.A]) 149 | self.__controller.press_buttons([Controller.Button.A]) # in case command is lost 150 | 151 | def __redraw(self): 152 | while self.__multi_detect(detection.redraw_cursor)(debug=self.__session['debug']) == -1: 153 | sleep(0.5) 154 | hands = self.__multi_detect(detection.hands)(debug=self.__session['debug']) 155 | stage = self.__session['empty_stage'] 156 | my_deck, his_deck = self.__session['my_deck'], self.__session['his_deck'] 157 | my_remaining_deck = copy.deepcopy(my_deck) 158 | for card in hands: 159 | try: 160 | my_remaining_deck.remove(card) 161 | except ValueError: 162 | pass 163 | redraw = self.__ai.redraw(hands, stage, my_remaining_deck, his_deck) 164 | target = 1 if redraw else 0 165 | while True: 166 | current = self.__multi_detect(detection.redraw_cursor)(debug=self.__session['debug']) 167 | if current == target: 168 | break 169 | macro = action.move_redraw_cursor_marco(target, current) 170 | self.__controller.macro(macro) 171 | self.__controller.press_buttons([Controller.Button.A]) 172 | self.__controller.press_buttons([Controller.Button.A]) # in case command is lost 173 | 174 | def __init_roi(self): 175 | while self.__multi_detect(detection.hands_cursor)(debug=self.__session['debug']) == -1: 176 | sleep(0.5) 177 | rois, roi_width, roi_height = detection.stage_rois(self.__capture(), debug=self.__session['debug']) 178 | self.__session['rois'] = rois 179 | self.__session['roi_width'] = roi_width 180 | self.__session['roi_height'] = roi_height 181 | self.__session['last_stage'] = None 182 | stage = self.__multi_detect(detection.stage)(rois=rois, roi_width=roi_width, roi_height=roi_height, last_stage=None, debug=self.__session['debug']) 183 | self.__session['empty_stage'] = stage 184 | 185 | def __get_status(self, round: int) -> Status: 186 | my_deck, his_deck = self.__session['my_deck'], self.__session['his_deck'] 187 | while self.__multi_detect(detection.hands_cursor)(debug=self.__session['debug']) == -1: 188 | # TODO: update his deck here 189 | sleep(0.5) 190 | rois, roi_width, roi_height, last_stage = self.__session['rois'], self.__session['roi_width'], self.__session['roi_height'], self.__session['last_stage'] 191 | stage = self.__multi_detect(detection.stage)(rois=rois, roi_width=roi_width, roi_height=roi_height, last_stage=last_stage, debug=self.__session['debug']) 192 | self.__session['last_stage'] = stage 193 | hands = self.__multi_detect(detection.hands)(debug=self.__session['debug']) 194 | for card in hands: 195 | try: 196 | my_deck.remove(card) 197 | except ValueError: 198 | pass 199 | self.__session['my_deck'] = my_deck 200 | my_sp, his_sp = self.__multi_detect(detection.sp)(debug=self.__session['debug']) 201 | return Status(stage=stage, hands=hands, round=round, my_sp=my_sp, his_sp=his_sp, my_deck=my_deck, his_deck=his_deck) 202 | 203 | def __move_hands_cursor(self, target): 204 | while True: 205 | current = self.__multi_detect(detection.hands_cursor)(debug=self.__session['debug']) 206 | if current == target: 207 | break 208 | macro = action.move_hands_cursor_marco(target, current) 209 | self.__controller.macro(macro) 210 | 211 | def __move(self, status: Status, step: Step) -> Optional[bool]: 212 | if step.action == step.Action.Skip: 213 | self.__move_hands_cursor(4) 214 | while not self.__multi_detect(detection.skip)(debug=self.__session['debug']): 215 | self.__controller.press_buttons([Controller.Button.A]) 216 | self.__move_hands_cursor(status.hands.index(step.card)) 217 | self.__controller.press_buttons([Controller.Button.A]) 218 | self.__controller.press_buttons([Controller.Button.A]) # in case command is lost 219 | return 220 | 221 | if step.action == step.Action.SpecialAttack: 222 | self.__move_hands_cursor(5) 223 | while not self.__multi_detect(detection.special_on)(debug=self.__session['debug']): 224 | self.__controller.press_buttons([Controller.Button.A]) 225 | # select card 226 | self.__move_hands_cursor(status.hands.index(step.card)) 227 | expected_preview = step.card.get_pattern(0) 228 | for x in range(51): 229 | if x == 50: 230 | return True 231 | self.__controller.press_buttons([Controller.Button.A]) 232 | preview, current_index = self.__multi_detect(detection.preview)(stage=status.stage, rois=self.__session['rois'], roi_width=self.__session['roi_width'], roi_height=self.__session['roi_height'], debug=self.__session['debug']) 233 | if action.compare_pattern(preview, expected_preview): 234 | break 235 | # rotate card 236 | if step.rotate > 0: 237 | target_rotate = step.rotate 238 | all_patterns = [step.card.get_pattern(i) for i in range(4)] 239 | for x in range(21): 240 | if x == 20: 241 | return True 242 | actual, _ = self.__multi_detect(detection.preview)(stage=status.stage, rois=self.__session['rois'], roi_width=self.__session['roi_width'], roi_height=self.__session['roi_height'], debug=self.__session['debug']) 243 | current_rotate = np.argmax([pattern == actual for pattern in all_patterns]) 244 | if current_rotate == 0 and all_patterns[0] != actual: 245 | current_rotate = np.argmax([action.compare_pattern(pattern, actual) for pattern in all_patterns]) 246 | rotate = (target_rotate + 4 - current_rotate) % 4 247 | logger.debug(f'tableturf.rotate: current_rotate={current_rotate}, target_rotate={target_rotate}, step={rotate}') 248 | if rotate == 0: 249 | break 250 | macro = action.rotate_card_marco(rotate) 251 | self.__controller.macro(macro) 252 | # move card 253 | expected_preview = step.card.get_pattern(step.rotate) 254 | # in case missing Button.A command 255 | for x in range(10): 256 | # keep moving until preview is in the target position 257 | for y in range(10): 258 | # keep detecting until preview is found 259 | for z in range(10): 260 | preview, current_index = self.__multi_detect(detection.preview)(stage=status.stage, rois=self.__session['rois'], roi_width=self.__session['roi_width'], roi_height=self.__session['roi_height'], debug=self.__session['debug']) 261 | if action.compare_pattern(preview, expected_preview): 262 | break 263 | macro = action.move_card_marco(current_index, preview, status.stage, step) 264 | if macro.strip() != '': 265 | self.__controller.macro(macro) 266 | else: 267 | break 268 | self.__controller.press_buttons([Controller.Button.A]) 269 | self.__controller.press_buttons([Controller.Button.A]) # in case command is lost 270 | sleep(3) 271 | # flow didn't go ahead -> card was not placed -> randomly move and re-detect 272 | for i in range(25): 273 | if status.round == 1: 274 | preview, _ = self.__multi_detect(detection.preview)(stage=status.stage, rois=self.__session['rois'], roi_width=self.__session['roi_width'], roi_height=self.__session['roi_height'], debug=self.__session['debug']) 275 | if preview is None or np.all(preview.squares == Grid.MySpecial.value): 276 | return 277 | elif self.__multi_detect(detection.hands_cursor)(debug=self.__session['debug']) != -1: 278 | return 279 | sleep(0.5) 280 | disturbance = random.choice([Controller.Button.DPAD_RIGHT, Controller.Button.DPAD_UP, Controller.Button.DPAD_LEFT, Controller.Button.DPAD_DOWN]) 281 | self.__controller.press_buttons([disturbance] * 2) 282 | return True 283 | 284 | def __give_up(self): 285 | self.__controller.press_buttons([Controller.Button.PLUS]) 286 | self.__controller.press_buttons([Controller.Button.PLUS]) # in case command is lost 287 | target = 1 288 | while True: 289 | current = self.__multi_detect(detection.giveup_cursor)(debug=self.__session['debug']) 290 | if current == target: 291 | break 292 | macro = action.move_giveup_cursor_marco(target, current) 293 | self.__controller.macro(macro) 294 | self.__controller.press_buttons([Controller.Button.A]) 295 | self.__controller.press_buttons([Controller.Button.A]) # in case command is lost 296 | sleep(2) 297 | self.__controller.press_buttons([Controller.Button.A]) 298 | self.__controller.press_buttons([Controller.Button.A]) # in case command is lost 299 | 300 | def __update_stats(self): 301 | sleep(10) 302 | result = self.__multi_detect(detection.result)(debug=self.__session['debug']) 303 | if result == Result.Win: 304 | self.job_stats.task_stats.win += 1 305 | now = datetime.now().timestamp() 306 | self.job_stats.time = now - self.job_stats.task_stats.start_time 307 | self.job_stats.task_stats.battle += 1 308 | logger.debug(f'tableturf.update_stats: stats={self.job_stats}') 309 | 310 | def __close(self, close: bool): 311 | self.__controller.press_buttons([Controller.Button.A]) 312 | target = 0 if close else 1 313 | count = 0 314 | for i in range(101): 315 | current = self.__multi_detect(detection.replay_cursor)(debug=self.__session['debug']) 316 | if current == target: 317 | break 318 | if current != -1: 319 | macro = action.move_replay_cursor_marco(target, current) 320 | self.__controller.macro(macro) 321 | else: 322 | sleep(0.5) 323 | # press A when unlock new items 324 | count = (count + 1) % 6 325 | if count == 0: 326 | self.__controller.press_buttons([Controller.Button.A]) 327 | self.__controller.press_buttons([Controller.Button.A]) 328 | self.__controller.press_buttons([Controller.Button.A]) # in case command is lost 329 | 330 | def __start(self): 331 | while not self.__multi_detect(detection.level)(debug=self.__session['debug']): 332 | self.__controller.press_buttons([Controller.Button.A]) 333 | sleep(2) 334 | self.__controller.press_buttons([Controller.Button.DPAD_RIGHT]) 335 | self.__controller.press_buttons([Controller.Button.DPAD_RIGHT]) 336 | while not self.__multi_detect(detection.start)(debug=self.__session['debug']): 337 | self.__controller.press_buttons([Controller.Button.DPAD_DOWN]) 338 | sleep(0.5) 339 | self.__controller.press_buttons([Controller.Button.A]) 340 | self.__controller.press_buttons([Controller.Button.A]) # in case command is lost 341 | sleep(2) 342 | # while self.__multi_detect(detection.deck_cursor)(debug=self.__session['debug']) == -1: 343 | # self.__controller.press_buttons([Controller.Button.A]) 344 | # sleep(0.5) 345 | 346 | def __switch_level(self): 347 | sleep(3) 348 | while not self.__multi_detect(detection.level)(debug=self.__session['debug']): 349 | self.__controller.press_buttons([Controller.Button.A]) 350 | sleep(2) 351 | self.__controller.press_buttons([Controller.Button.DPAD_RIGHT]) 352 | self.__controller.press_buttons([Controller.Button.DPAD_RIGHT]) 353 | while not self.__multi_detect(detection.start)(debug=self.__session['debug']): 354 | self.__controller.press_buttons([Controller.Button.DPAD_DOWN]) 355 | sleep(0.5) 356 | self.__controller.press_buttons([Controller.Button.A]) 357 | self.__controller.press_buttons([Controller.Button.A]) # in case command is lost 358 | sleep(2) 359 | # while self.__multi_detect(detection.deck_cursor)(debug=self.__session['debug']) == -1: 360 | # self.__controller.press_buttons([Controller.Button.A]) 361 | # sleep(0.5) 362 | 363 | def __switch_npc(self): 364 | sleep(3) 365 | while not self.__multi_detect(detection.level)(debug=self.__session['debug']): 366 | self.__controller.press_buttons([Controller.Button.A]) 367 | sleep(2) 368 | self.__controller.press_buttons([Controller.Button.B]) 369 | self.__controller.press_buttons([Controller.Button.B]) # in case command is lost 370 | sleep(2) 371 | self.__controller.press_buttons([Controller.Button.DPAD_DOWN]) 372 | -------------------------------------------------------------------------------- /tableturf/model/__init__.py: -------------------------------------------------------------------------------- 1 | from tableturf.model.card import Card, Pattern 2 | from tableturf.model.grid import Grid 3 | from tableturf.model.stage import Stage 4 | from tableturf.model.status import Status 5 | from tableturf.model.step import Step 6 | -------------------------------------------------------------------------------- /tableturf/model/card.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from logger import logger 4 | from tableturf.model.grid import Grid 5 | 6 | 7 | class Pattern: 8 | def __init__(self, grid: np.ndarray): 9 | """ 10 | :param grid: Pattern. 11 | """ 12 | if isinstance(grid[0][0], Grid): 13 | grid = np.vectorize(lambda x: x.value)(grid) 14 | inks = grid != Grid.Empty.value 15 | row_mask = np.any(inks, axis=1) 16 | col_mask = np.any(inks, axis=0) 17 | self.__grid = grid[row_mask][:, col_mask].copy() 18 | if np.minimum(*self.__grid.shape) > 8: 19 | logger.warn(f"Pattern width/height > 8. grid={self.__grid}") 20 | self.__grid.setflags(write=False) 21 | 22 | indexes = np.argwhere(np.bitwise_and(self.__grid, Grid.MyInk.value | Grid.MySpecial.value)) 23 | self.__squares = self.__grid[indexes[:, 0], indexes[:, 1]] 24 | self.__offsets = indexes - indexes[0][np.newaxis, ...] 25 | self.__size, _ = indexes.shape 26 | self.__height, self.__width = self.__grid.shape 27 | 28 | @property 29 | def width(self) -> int: 30 | """ 31 | Width of the pattern. 32 | """ 33 | return self.__width 34 | 35 | @property 36 | def height(self) -> int: 37 | """ 38 | Height of the pattern. 39 | """ 40 | return self.__height 41 | 42 | @property 43 | def size(self) -> int: 44 | """ 45 | The number of squares the pattern covers. 46 | """ 47 | return self.__size 48 | 49 | @property 50 | def offset(self) -> np.ndarray: 51 | """ 52 | All square offsets of the pattern. The top-left square is the origin point. 53 | """ 54 | return self.__offsets 55 | 56 | @property 57 | def squares(self) -> np.ndarray: 58 | """ 59 | All squares from left to right, then from up to down. 60 | """ 61 | return self.__squares 62 | 63 | @property 64 | def grid(self) -> np.ndarray: 65 | """ 66 | Trimmed grid. 67 | """ 68 | return self.__grid 69 | 70 | def rotate(self, rotate): 71 | return Pattern(np.rot90(self.__grid, rotate)) 72 | 73 | def __hash__(self): 74 | return hash(str(self.__offsets)) 75 | 76 | def __eq__(self, other): 77 | if isinstance(other, Pattern): 78 | return np.all(self.__offsets == other.__offsets) and np.all(self.__squares == other.__squares) 79 | return NotImplemented 80 | 81 | def __repr__(self): 82 | return str(self.__grid) 83 | 84 | def __str__(self): 85 | return repr(self) 86 | 87 | 88 | class Card: 89 | # counterclockwise 90° 90 | __ROTATION_MATRIX = np.array([ 91 | [0, -1], 92 | [1, 0], 93 | ]) 94 | __INVERSE_ROTATION_MATRIX = np.linalg.inv(__ROTATION_MATRIX).astype(np.int) 95 | 96 | def __init__(self, grid: np.ndarray, sp_cost: int): 97 | """ 98 | Represent a card. Each inked square is assigned an ID, which numbers first from left to right, then from up to down. 99 | 100 | :param grid: Card pattern. 101 | :param sp_cost: Special Points that a Special Attack costs. 102 | """ 103 | self.__sp_cost = sp_cost 104 | self.__patterns = [Pattern(np.rot90(grid, i)) for i in range(4)] 105 | self.__size = self.__patterns[0].size 106 | 107 | @property 108 | def size(self) -> int: 109 | """ 110 | The number of squares the pattern covers. 111 | """ 112 | return self.__size 113 | 114 | @property 115 | def sp_cost(self) -> int: 116 | """ 117 | Special Points that a Special Attack costs. 118 | """ 119 | return self.__sp_cost 120 | 121 | def get_pattern(self, rotate: int = 0) -> Pattern: 122 | """ 123 | Get the pattern of the card. 124 | 125 | :param rotate: The times of rotation (counterclockwise 90°) 126 | """ 127 | return self.__patterns[rotate % 4] 128 | 129 | def __hash__(self): 130 | return hash(self.__patterns[0]) 131 | 132 | def __eq__(self, other): 133 | if isinstance(other, Card): 134 | return np.all(self.__patterns[0] == other.__patterns[0]) 135 | return False 136 | 137 | def __repr__(self): 138 | return f'Card(pattern={self.__patterns[0]}, sp_cost={self.__sp_cost})' 139 | 140 | def __str__(self): 141 | return repr(self) 142 | -------------------------------------------------------------------------------- /tableturf/model/grid.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Grid(Enum): 5 | Empty = 1 6 | MyInk = 2 7 | MySpecial = 4 8 | HisInk = 8 9 | HisSpecial = 16 10 | Neutral = 32 11 | Wall = 64 12 | -------------------------------------------------------------------------------- /tableturf/model/stage.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from tableturf.model.grid import Grid 4 | 5 | 6 | class Stage: 7 | __NEIGHBOURHOOD_OFFSETS = np.array([ 8 | [-1, -1], 9 | [-1, 0], 10 | [-1, 1], 11 | [0, -1], 12 | [0, 1], 13 | [1, -1], 14 | [1, 0], 15 | [1, 1] 16 | ]) 17 | 18 | def __init__(self, grid: np.ndarray): 19 | """ 20 | Represent a stage. 21 | 22 | :param grid: Stage pattern. 23 | """ 24 | if isinstance(grid[0][0], Grid): 25 | self.__grid = np.vectorize(lambda x: x.value)(grid) 26 | else: 27 | self.__grid = grid.copy() 28 | self.__size = np.count_nonzero(self.grid != Grid.Wall.value) 29 | 30 | def neighborhoods(idx: np.ndarray) -> np.ndarray: 31 | return Stage.__NEIGHBOURHOOD_OFFSETS + idx 32 | 33 | def within_grid(indexes: np.ndarray) -> np.ndarray: 34 | m, n = self.shape 35 | xs = indexes[:, 0] 36 | ys = indexes[:, 1] 37 | return indexes[(xs >= 0) & (xs < m) & (ys >= 0) & (ys < n)] 38 | 39 | def is_fiery(idx: np.ndarray) -> bool: 40 | nbhd = neighborhoods(idx) 41 | valid_nbhd = within_grid(nbhd) 42 | return np.all(self.__grid[valid_nbhd[:, 0], valid_nbhd[:, 1]] != Grid.Empty.value) 43 | 44 | def split_sp(sp: np.ndarray) -> tuple: 45 | if sp.size == 0: 46 | return np.zeros((0, 2), dtype=int), np.zeros((0, 2), dtype=int) 47 | is_sp_fiery = np.array([is_fiery(idx) for idx in sp]) 48 | return sp[is_sp_fiery], sp[np.bitwise_not(is_sp_fiery)] 49 | 50 | def ink_neighborhoods(idx: np.ndarray) -> np.ndarray: 51 | nbhd = within_grid(Stage.__NEIGHBOURHOOD_OFFSETS + idx) 52 | valid_nbhd = nbhd[self.__grid[nbhd[:, 0], nbhd[:, 1]] == Grid.Empty.value] 53 | invalid = np.full(shape=(8 - valid_nbhd.shape[0], 2), fill_value=-1) 54 | return np.concatenate((valid_nbhd, invalid), axis=0) 55 | 56 | def collect_ink_neighborhoods(ink: np.ndarray) -> np.ndarray: 57 | return within_grid(np.unique(np.array([ink_neighborhoods(idx) for idx in ink]).reshape(-1, 2), axis=0)) 58 | 59 | def sp_neighborhoods(idx: np.ndarray) -> np.ndarray: 60 | nbhd = within_grid(Stage.__NEIGHBOURHOOD_OFFSETS + idx) 61 | values = self.__grid[nbhd[:, 0], nbhd[:, 1]] 62 | valid_nbhd = nbhd[np.bitwise_and(values, Grid.Empty.value | Grid.MyInk.value | Grid.HisInk.value) > 0] 63 | invalid = np.full(shape=(8 - valid_nbhd.shape[0], 2), fill_value=-1) 64 | return np.concatenate((valid_nbhd, invalid), axis=0) 65 | 66 | def collect_sp_neighborhoods(sp: np.ndarray) -> np.ndarray: 67 | return within_grid(np.unique(np.array([sp_neighborhoods(idx) for idx in sp]).reshape(-1, 2), axis=0)) 68 | 69 | self.__my_ink = np.argwhere(np.bitwise_and(self.__grid, Grid.MyInk.value | Grid.MySpecial.value)) 70 | self.__my_sp = np.argwhere(self.__grid == Grid.MySpecial.value) 71 | self.__my_fiery_sp, self.__my_unfiery_sp = split_sp(self.__my_sp) 72 | self.__my_neighborhoods = collect_ink_neighborhoods(self.__my_ink) 73 | self.__my_sp_neighborhoods = collect_sp_neighborhoods(self.__my_sp) 74 | 75 | self.__his_ink = np.argwhere(np.bitwise_and(self.__grid, Grid.HisInk.value | Grid.HisSpecial.value)) 76 | self.__his_sp = np.argwhere(self.__grid == Grid.HisSpecial.value) 77 | self.__his_fiery_sp, self.__his_unfiery_sp = split_sp(self.__his_sp) 78 | self.__his_neighborhoods = collect_ink_neighborhoods(self.__his_ink) 79 | self.__his_sp_neighborhoods = collect_sp_neighborhoods(self.__his_sp) 80 | 81 | self.__fiery_grid = np.zeros_like(self.__grid, dtype=bool) 82 | self.__fiery_grid[self.__my_fiery_sp[:, 0], self.__my_fiery_sp[:, 1]] = True 83 | self.__fiery_grid[self.__his_fiery_sp[:, 0], self.__his_fiery_sp[:, 1]] = True 84 | 85 | self.__grid.setflags(write=False) 86 | self.__fiery_grid.setflags(write=False) 87 | self.__my_ink.setflags(write=False) 88 | self.__my_sp.setflags(write=False) 89 | self.__my_fiery_sp.setflags(write=False) 90 | self.__my_neighborhoods.setflags(write=False) 91 | self.__my_sp_neighborhoods.setflags(write=False) 92 | self.__his_ink.setflags(write=False) 93 | self.__his_sp.setflags(write=False) 94 | self.__his_fiery_sp.setflags(write=False) 95 | self.__his_neighborhoods.setflags(write=False) 96 | self.__his_sp_neighborhoods.setflags(write=False) 97 | 98 | @property 99 | def grid(self) -> np.ndarray: 100 | """ 101 | Pattern of the Stage. (h w) 102 | """ 103 | return self.__grid 104 | 105 | @property 106 | def fiery_grid(self) -> np.ndarray: 107 | """ 108 | Indicate which square is fiery. (h w) 109 | """ 110 | return self.__fiery_grid 111 | 112 | @property 113 | def shape(self) -> tuple: 114 | """ 115 | A tuple. Height and width of the stage. 116 | """ 117 | return self.__grid.shape 118 | 119 | @property 120 | def size(self) -> int: 121 | """ 122 | The number of empty square that the stage contains. 123 | """ 124 | return self.__size 125 | 126 | @property 127 | def my_sp(self) -> np.ndarray: 128 | """ 129 | Indexes of my Special Space on the stage. shape = (N, 2) 130 | """ 131 | return self.__my_sp 132 | 133 | @property 134 | def my_ink(self) -> np.ndarray: 135 | """ 136 | Indexes of my ink on the stage. shape = (N, 2) 137 | """ 138 | return self.__my_ink 139 | 140 | @property 141 | def my_fiery_sp(self) -> np.ndarray: 142 | """ 143 | Indexes of my fiery Special Space on the stage. shape = (N, 2) 144 | """ 145 | return self.__my_fiery_sp 146 | 147 | @property 148 | def my_unfiery_sp(self) -> np.ndarray: 149 | """ 150 | Indexes of my un-fiery Special Space on the stage. shape = (N, 2) 151 | """ 152 | return self.__my_unfiery_sp 153 | 154 | @property 155 | def my_neighborhoods(self) -> np.ndarray: 156 | """ 157 | Indexes of the empty squares nearby my ink on the stage. shape = (N, 2) 158 | """ 159 | return self.__my_neighborhoods 160 | 161 | @property 162 | def my_sp_neighborhoods(self) -> np.ndarray: 163 | """ 164 | Indexes of the squares nearby my Special Space on the stage. The squares are Empty, MyInk or HisInk. shape = (N, 2) 165 | """ 166 | return self.__my_sp_neighborhoods 167 | 168 | @property 169 | def his_sp(self) -> np.ndarray: 170 | """ 171 | Indexes of opponent's Special Space on the stage. shape = (N, 2) 172 | """ 173 | return self.__his_sp 174 | 175 | @property 176 | def his_ink(self) -> np.ndarray: 177 | """ 178 | Indexes of opponent's ink on the stage. shape = (N, 2) 179 | """ 180 | return self.__his_ink 181 | 182 | @property 183 | def his_fiery_sp(self) -> np.ndarray: 184 | """ 185 | Indexes of opponent's fiery Special Space on the stage. shape = (N, 2) 186 | """ 187 | return self.__his_fiery_sp 188 | 189 | @property 190 | def his_unfiery_sp(self) -> np.ndarray: 191 | """ 192 | Indexes of opponent's un-fiery Special Space on the stage. shape = (N, 2) 193 | """ 194 | return self.__his_unfiery_sp 195 | 196 | @property 197 | def his_neighborhoods(self) -> np.ndarray: 198 | """ 199 | Indexes of the empty squares nearby opponent's ink on the stage. shape = (N, 2) 200 | """ 201 | return self.__his_neighborhoods 202 | 203 | @property 204 | def his_sp_neighborhoods(self) -> np.ndarray: 205 | """ 206 | Indexes of the squares nearby opponent's Special Space on the stage. The squares are Empty, MyInk or HisInk. shape = (N, 2) 207 | """ 208 | return self.__his_sp_neighborhoods 209 | 210 | def __repr__(self): 211 | return f'Stage(grid={self.__grid})' 212 | 213 | def __str__(self): 214 | return repr(self) 215 | 216 | def __hash__(self): 217 | return hash(str(self.__grid)) 218 | 219 | def __eq__(self, other): 220 | if isinstance(other, Stage): 221 | return np.all(self.__grid == other.__grid) 222 | return False 223 | -------------------------------------------------------------------------------- /tableturf/model/status.py: -------------------------------------------------------------------------------- 1 | from typing import List, Set, Optional 2 | 3 | import numpy as np 4 | 5 | from tableturf.model.card import Card 6 | from tableturf.model.grid import Grid 7 | from tableturf.model.stage import Stage 8 | from tableturf.model.step import Step 9 | 10 | 11 | class Status: 12 | def __init__(self, stage: Stage, hands: List[Card], round: int, my_sp: int, his_sp: int, my_deck: List[Card], his_deck: List[Card]): 13 | self.__stage = stage 14 | self.__hands = hands 15 | self.__round = round 16 | self.__my_sp = my_sp 17 | self.__his_sp = his_sp 18 | self.__my_deck = my_deck 19 | self.__his_deck = his_deck 20 | 21 | self.__all_possible_steps_by_card = { 22 | card: list(self.__possible_steps_without_special_attack(card).union(self.__possible_steps_with_special_attack(card))) for card in hands 23 | } 24 | self.__all_possible_steps = [step for steps_set in self.__all_possible_steps_by_card.values() for step in steps_set] 25 | 26 | def __possible_steps_without_special_attack(self, card: Card) -> Set[Step]: 27 | m, n = self.stage.shape 28 | result = set() 29 | result.add(Step(Step.Action.Skip, card, None, None)) 30 | for idx in self.stage.my_neighborhoods: 31 | for rotate in range(4): 32 | offset = card.get_pattern(rotate).offset 33 | for origin in range(card.size): 34 | pattern_indexes = offset - offset[origin] + idx 35 | # pattern is out of boundary 36 | xs = pattern_indexes[:, 0] 37 | ys = pattern_indexes[:, 1] 38 | if not np.all((xs >= 0) & (xs < m) & (ys >= 0) & (ys < n)): 39 | continue 40 | # squares are not empty 41 | if not np.all(self.stage.grid[xs, ys] == Grid.Empty.value): 42 | continue 43 | result.add(Step(Step.Action.Place, card, rotate, pattern_indexes[0])) 44 | return result 45 | 46 | def __possible_steps_with_special_attack(self, card: Card) -> Set[Step]: 47 | m, n = self.stage.shape 48 | result = set() 49 | if card.sp_cost > self.__my_sp: 50 | return result 51 | for idx in self.stage.my_sp_neighborhoods: 52 | for rotate in range(4): 53 | offset = card.get_pattern(rotate).offset 54 | for origin in range(card.size): 55 | pattern_indexes = offset - offset[origin] + idx 56 | xs = pattern_indexes[:, 0] 57 | ys = pattern_indexes[:, 1] 58 | # pattern is out of boundary 59 | if not np.all((xs >= 0) & (xs < m) & (ys >= 0) & (ys < n)): 60 | continue 61 | # squares are not empty 62 | values = self.stage.grid[xs, ys] 63 | if not np.all(np.bitwise_and(values, Grid.Empty.value | Grid.MyInk.value | Grid.HisInk.value)): 64 | continue 65 | result.add(Step(Step.Action.SpecialAttack, card, rotate, pattern_indexes[0])) 66 | return result 67 | 68 | @property 69 | def stage(self) -> Stage: 70 | return self.__stage 71 | 72 | @property 73 | def hands(self) -> List[Card]: 74 | return self.__hands 75 | 76 | @property 77 | def round(self) -> int: 78 | return self.__round 79 | 80 | @property 81 | def my_sp(self) -> int: 82 | return self.__my_sp 83 | 84 | @property 85 | def his_sp(self) -> int: 86 | return self.__his_sp 87 | 88 | @property 89 | def my_deck(self) -> List[Card]: 90 | """ 91 | Return remaining cards in my deck. 92 | """ 93 | return self.__my_deck 94 | 95 | @property 96 | def his_deck(self) -> List[Card]: 97 | """ 98 | Return remaining cards in opponent's deck. 99 | """ 100 | return self.__his_deck 101 | 102 | def get_possible_steps(self, card: Optional[Card] = None, action: Step.Action = None) -> List[Step]: 103 | """ 104 | Return all possible steps in the current status. 105 | 106 | :param card: if not None, return possible steps of the given card. 107 | :param action: if not None, only return steps with the given action. 108 | """ 109 | if card is None: 110 | steps = self.__all_possible_steps 111 | else: 112 | steps = self.__all_possible_steps_by_card[card] 113 | if action is not None: 114 | steps = [step for step in steps if step.action == action] 115 | return steps 116 | 117 | def __repr__(self): 118 | return f'Stage(stage={self.__stage}, hands={self.__hands}, round={self.__round}, my_sp={self.__my_sp}, his_sp={self.__his_sp}, my_deck={self.__my_deck}, his_deck={self.__his_deck})' 119 | 120 | def __str__(self): 121 | return repr(self) 122 | 123 | def __hash__(self): 124 | return hash((repr(self.__hands), self.__round, self.__my_sp, self.__his_sp)) 125 | 126 | def __eq__(self, other): 127 | if isinstance(other, Status): 128 | return self.__stage == other.__stage and self.__hands == other.__hands and self.__round == other.__round and self.__my_sp == other.__my_sp and self.__his_sp == other.__his_sp and self.__my_deck == other.__my_deck and self.__his_deck == other.__his_deck 129 | return False 130 | -------------------------------------------------------------------------------- /tableturf/model/step.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | import numpy as np 5 | 6 | from tableturf.model.card import Card 7 | 8 | 9 | class Step: 10 | class Action(Enum): 11 | Place = 0 12 | SpecialAttack = 1 13 | Skip = 2 14 | 15 | def __init__(self, action: Action, card: Card, rotate: Optional[int], pos: Optional[np.ndarray]): 16 | """ 17 | :param pos: The numpy index, i.e. (y, x), of the first square of the rotated pattern. 18 | """ 19 | self.__card = card 20 | self.__rotate = rotate if rotate is not None else None 21 | self.__pos = pos.copy() if pos is not None else None 22 | self.__action = action 23 | 24 | if pos is not None: 25 | self.__pos.setflags(write=False) 26 | 27 | @property 28 | def card(self) -> Card: 29 | return self.__card 30 | 31 | @property 32 | def rotate(self) -> Optional[int]: 33 | return self.__rotate 34 | 35 | @property 36 | def pos(self) -> Optional[np.ndarray]: 37 | return self.__pos 38 | 39 | @property 40 | def action(self) -> Action: 41 | return self.__action 42 | 43 | def __hash__(self): 44 | return hash((self.card, self.__action)) 45 | 46 | def __eq__(self, other): 47 | if isinstance(other, Step): 48 | self_rotate = 0 if self.__rotate is None else self.__rotate 49 | other_rotate = 0 if other.__rotate is None else other.__rotate 50 | return (self.card.get_pattern(self_rotate), self.__action) == (other.card.get_pattern(other_rotate), other.__action) and np.all(self.__pos == other.__pos) 51 | return False 52 | 53 | def __repr__(self): 54 | return f'Step(action={self.__action}, card={self.__card}, rotate={self.__rotate}, pos={self.__pos})' 55 | 56 | def __str__(self): 57 | return repr(self) 58 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fga401/AutoTableTurf/74b19d9e6173f3b12827d9818b18f1720502abad/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_capture/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fga401/AutoTableTurf/74b19d9e6173f3b12827d9818b18f1720502abad/tests/test_capture/__init__.py -------------------------------------------------------------------------------- /tests/test_capture/test_video_capture.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from capture import VideoCapture 5 | 6 | 7 | def ready(): 8 | VideoCapture(0).capture() 9 | return True 10 | 11 | 12 | @unittest.skipIf(not ready(), 'nxbt server not ready') 13 | class TestCard(unittest.TestCase): 14 | def setUp(self) -> None: 15 | self.capture = VideoCapture(0) 16 | 17 | def test_width(self): 18 | self.assertEqual(self.capture.width, 1920) 19 | 20 | def test_height(self): 21 | self.assertEqual(self.capture.height, 1080) 22 | 23 | def test_show(self): 24 | self.capture.show() 25 | 26 | def test_save(self): 27 | cur = os.path.dirname(os.path.realpath(__file__)) 28 | root = os.path.join(cur, os.pardir, os.pardir) 29 | target = os.path.join(root, 'temp', 'level', '3') 30 | print(target) 31 | self.capture.save(target) 32 | -------------------------------------------------------------------------------- /tests/test_controller/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fga401/AutoTableTurf/74b19d9e6173f3b12827d9818b18f1720502abad/tests/test_controller/__init__.py -------------------------------------------------------------------------------- /tests/test_controller/test_interface.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from controller import DummyController 4 | 5 | 6 | @unittest.skipIf(False, "nxbt server not ready") 7 | class TestCard(unittest.TestCase): 8 | def setUp(self) -> None: 9 | self.client = DummyController(block=False) 10 | 11 | def test_macro(self): 12 | macro = """ 13 | A B 0.2s 14 | A X 0.1s 15 | 0.2s 16 | 17 | HOME L_STICK@-100+000 0.2s 18 | 0.2s 19 | """ 20 | self.assertTrue(self.client.macro(macro)) 21 | -------------------------------------------------------------------------------- /tests/test_controller/test_nxbt.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests 4 | 5 | from controller import NxbtController, Controller 6 | 7 | endpoint = "http://192.168.50.101:5000/" 8 | 9 | 10 | def ready() -> bool: 11 | resp = requests.get(endpoint) 12 | return resp.status_code == 200 13 | 14 | 15 | @unittest.skipIf(not ready(), "nxbt server not ready") 16 | class TestCard(unittest.TestCase): 17 | def setUp(self) -> None: 18 | self.client = NxbtController(endpoint) 19 | 20 | def test_press_buttons(self): 21 | self.assertTrue(self.client.press_buttons([Controller.Button.A, Controller.Button.HOME])) 22 | 23 | def test_tilt_stick(self): 24 | self.assertTrue(self.client.tilt_stick(Controller.Stick.L_STICK, 100, 0)) 25 | 26 | def test_macro(self): 27 | macro = """ 28 | A 0.2s 29 | 0.2s 30 | HOME 0.2s 31 | 0.2s 32 | """ 33 | self.assertTrue(self.client.macro(macro)) 34 | -------------------------------------------------------------------------------- /tests/test_tableturf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fga401/AutoTableTurf/74b19d9e6173f3b12827d9818b18f1720502abad/tests/test_tableturf/__init__.py -------------------------------------------------------------------------------- /tests/test_tableturf/test_ai/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fga401/AutoTableTurf/74b19d9e6173f3b12827d9818b18f1720502abad/tests/test_tableturf/test_ai/__init__.py -------------------------------------------------------------------------------- /tests/test_tableturf/test_ai/test_alpha.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from tableturf.ai.alpha.alpha import Alpha, Evaluation 6 | from tableturf.model import Stage, Grid, Card, Status, Step 7 | 8 | 9 | class TestAlpha(unittest.TestCase): 10 | 11 | def test_next_step(self): 12 | stage = Stage(np.array([ 13 | [Grid.Empty, Grid.Empty, Grid.Empty], 14 | [Grid.Empty, Grid.MySpecial, Grid.Empty], 15 | [Grid.Empty, Grid.Empty, Grid.Empty], 16 | ])) 17 | card_0 = Card(np.array([ 18 | [Grid.MySpecial], 19 | ]), 10) 20 | card_1 = Card(np.array([ 21 | [Grid.MyInk], 22 | ]), 20) 23 | card_2 = Card(np.array([ 24 | [Grid.MySpecial], 25 | [Grid.MyInk], 26 | ]), 40) 27 | card_3 = Card(np.array([ 28 | [Grid.MyInk], 29 | [Grid.MyInk], 30 | ]), 40) 31 | card_4 = Card(np.array([ 32 | [Grid.MySpecial, Grid.MyInk], 33 | [Grid.MyInk, Grid.Empty], 34 | ]), 40) 35 | card_5 = Card(np.array([ 36 | [Grid.MyInk, Grid.MySpecial], 37 | [Grid.MyInk, Grid.Empty], 38 | ]), 40) 39 | status = Status( 40 | stage=stage, 41 | hands=[card_0, card_1, card_2, card_3], 42 | round=3, 43 | my_sp=2, 44 | his_sp=0, 45 | my_deck=[card_4, card_5], 46 | his_deck=[], 47 | ) 48 | 49 | ai = Alpha() 50 | print(ai.next_step(status)) 51 | ai.reset() 52 | 53 | def test_evaluation_occupation(self): 54 | stage = Stage(np.array([ 55 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.HisSpecial, Grid.Wall], 56 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Wall, Grid.Wall], 57 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty], 58 | [Grid.Wall, Grid.Empty, Grid.MySpecial, Grid.Empty, Grid.Wall], 59 | [Grid.Wall, Grid.Wall, Grid.Empty, Grid.Wall, Grid.Wall], 60 | ])) 61 | 62 | self.assertEqual(1, Evaluation.occupied_grids(stage, my_dilate=0, his_dilate=0, connectivity=8)) 63 | self.assertEqual(2, Evaluation.occupied_grids(stage, my_dilate=0, his_dilate=0, connectivity=4)) 64 | self.assertEqual(8, Evaluation.occupied_grids(stage, my_dilate=1, his_dilate=0, connectivity=8)) 65 | self.assertEqual(9, Evaluation.occupied_grids(stage, my_dilate=1, his_dilate=0, connectivity=4)) 66 | self.assertEqual(8, Evaluation.occupied_grids(stage, my_dilate=1, his_dilate=1, connectivity=8)) 67 | self.assertEqual(9, Evaluation.occupied_grids(stage, my_dilate=1, his_dilate=1, connectivity=4)) 68 | self.assertEqual(11, Evaluation.occupied_grids(stage, my_dilate=2, his_dilate=0, connectivity=8)) 69 | self.assertEqual(11, Evaluation.occupied_grids(stage, my_dilate=2, his_dilate=0, connectivity=4)) 70 | 71 | self.assertEqual(6, Evaluation.conflict_grids(stage, my_dilate=2, his_dilate=2)) 72 | self.assertEqual(13, Evaluation.conflict_grids(stage, my_dilate=3, his_dilate=3)) 73 | 74 | def test_evaluation_distance(self): 75 | stage = Stage(np.array([ 76 | [Grid.Wall, Grid.HisInk, Grid.Empty, Grid.Empty, Grid.HisSpecial], 77 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Wall], 78 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty], 79 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Wall], 80 | [Grid.Wall, Grid.MySpecial, Grid.Empty, Grid.Wall, Grid.Wall], 81 | ])) 82 | 83 | self.assertEqual(4, Evaluation.square_distance(stage, np.array([4, 1]))) 84 | self.assertEqual(1, Evaluation.square_distance(stage, np.array([0, 3]))) 85 | 86 | stage = Stage(np.array([ 87 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Empty, Grid.HisSpecial], 88 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Wall], 89 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty], 90 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Wall], 91 | [Grid.Wall, Grid.MySpecial, Grid.Empty, Grid.Wall, Grid.Wall], 92 | ])) 93 | self.assertEqual(5, Evaluation.square_distance(stage, np.array([4, 1]))) 94 | 95 | def test_evaluation_ink_size(self): 96 | stage = Stage(np.array([ 97 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Empty, Grid.MyInk], 98 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Wall], 99 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty], 100 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Wall], 101 | [Grid.Wall, Grid.MySpecial, Grid.Empty, Grid.Wall, Grid.Wall], 102 | ])) 103 | 104 | self.assertEqual(5, Evaluation.ink_size(stage)) 105 | 106 | def test_evaluation_compaction(self): 107 | stage = Stage(np.array([ 108 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.HisSpecial, Grid.Wall], 109 | [Grid.Wall, Grid.Empty, Grid.Empty, Grid.Wall, Grid.Wall], 110 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty], 111 | [Grid.Wall, Grid.Empty, Grid.MySpecial, Grid.Empty, Grid.Wall], 112 | [Grid.Wall, Grid.Wall, Grid.Empty, Grid.Wall, Grid.Wall], 113 | ])) 114 | 115 | self.assertEqual(7, Evaluation.dilated_area(stage, dilate=1)) 116 | self.assertEqual(11, Evaluation.dilated_area(stage, dilate=2)) 117 | -------------------------------------------------------------------------------- /tests/test_tableturf/test_detection/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fga401/AutoTableTurf/74b19d9e6173f3b12827d9818b18f1720502abad/tests/test_tableturf/test_detection/__init__.py -------------------------------------------------------------------------------- /tests/test_tableturf/test_detection/test_card.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from capture import FileLoader 5 | from tableturf.manager.detection.debugger import OpenCVDebugger 6 | from tableturf.manager import detection 7 | 8 | path = os.path.join(os.path.realpath(__file__), '..', '..', '..', '..', 'temp') 9 | 10 | 11 | @unittest.skipIf(not os.path.exists(path), 'images not existed') 12 | class TestCard(unittest.TestCase): 13 | def test_hands_cursor(self): 14 | capture = FileLoader(path=os.path.join(path, 'stage2')) 15 | for _ in range(10): 16 | detection.hands_cursor(capture.capture(), OpenCVDebugger()) 17 | 18 | def test_hands_in_battle_1(self): 19 | capture = FileLoader(path=os.path.join(path, 'stage2')) 20 | for _ in range(10): 21 | detection.hands(capture.capture(), None, OpenCVDebugger()) 22 | 23 | def test_hands_in_battle_2(self): 24 | capture = FileLoader(path=os.path.join(path, 'card')) 25 | for _ in range(10): 26 | detection.hands(capture.capture(), None, OpenCVDebugger()) 27 | 28 | def test_hands_in_redraw(self): 29 | capture = FileLoader(path=os.path.join(path, 'redraw')) 30 | for _ in range(10): 31 | detection.hands(capture.capture(), None, OpenCVDebugger()) 32 | -------------------------------------------------------------------------------- /tests/test_tableturf/test_detection/test_deck.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from capture import FileLoader 5 | from tableturf.manager.detection.debugger import OpenCVDebugger 6 | from tableturf.manager import detection 7 | 8 | path = os.path.join(os.path.realpath(__file__), '..', '..', '..', '..', 'temp') 9 | 10 | 11 | @unittest.skipIf(not os.path.exists(path), 'images not existed') 12 | class TestDeck(unittest.TestCase): 13 | def test_deck(self): 14 | capture = FileLoader(path=os.path.join(path, 'deck')) 15 | for _ in range(10): 16 | deck = detection.deck(capture.capture(), OpenCVDebugger()) 17 | print(deck) 18 | -------------------------------------------------------------------------------- /tests/test_tableturf/test_detection/test_stage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from capture import FileLoader 5 | from tableturf.manager.detection.debugger import OpenCVDebugger 6 | from tableturf.manager import detection 7 | 8 | path = os.path.join(os.path.realpath(__file__), '..', '..', '..', '..', 'temp') 9 | 10 | 11 | @unittest.skipIf(not os.path.exists(path), 'images not existed') 12 | class TestStage(unittest.TestCase): 13 | 14 | def test_find_stage_roi_1(self): 15 | capture = FileLoader(path=os.path.join(path, 'stage1')) 16 | for _ in range(10): 17 | detection.stage_rois(capture.capture(), OpenCVDebugger()) 18 | 19 | def test_find_stage_roi_2(self): 20 | capture = FileLoader(path=os.path.join(path, 'stage2')) 21 | for _ in range(10): 22 | detection.stage_rois(capture.capture(), OpenCVDebugger()) 23 | 24 | def test_find_stage_roi_3(self): 25 | capture = FileLoader(path=os.path.join(path, 'stage3')) 26 | for _ in range(10): 27 | detection.stage_rois(capture.capture(), OpenCVDebugger()) 28 | 29 | def test_stage_4(self): 30 | roi_capture = FileLoader(files=[os.path.join(path, 'stage4', 'roi.jpg')]) 31 | capture = FileLoader(path=os.path.join(path, 'stage4')) 32 | rois, width, height = detection.stage_rois(roi_capture.capture()) 33 | for _ in range(20): 34 | detection.stage(capture.capture(), rois, width, height, debug=OpenCVDebugger()) 35 | 36 | def test_stage_5(self): 37 | roi_capture = FileLoader(files=[os.path.join(path, 'stage2', '0.jpg')]) 38 | capture = FileLoader(path=os.path.join(path, 'stage5')) 39 | rois, width, height = detection.stage_rois(roi_capture.capture()) 40 | for _ in range(20): 41 | detection.stage(capture.capture(), rois, width, height, debug=OpenCVDebugger()) 42 | 43 | def test_preview_4(self): 44 | roi_capture = FileLoader(files=[os.path.join(path, 'stage4', 'roi.jpg')]) 45 | stage_capture = FileLoader(files=[os.path.join(path, 'stage4', 'sp_off_10.jpg')]) 46 | capture = FileLoader(path=os.path.join(path, 'stage4')) 47 | rois, width, height = detection.stage_rois(roi_capture.capture()) 48 | for _ in range(30): 49 | detected_stage = detection.stage(stage_capture.capture(), rois, width, height) 50 | detection.preview(capture.capture(), detected_stage, rois, width, height, OpenCVDebugger()) 51 | 52 | def test_preview_6(self): 53 | roi_capture = FileLoader(files=[os.path.join(path, 'stage1', 'i1.jpg')]) 54 | stage_capture = FileLoader(files=[os.path.join(path, 'stage6', '10.jpg')]) 55 | capture = FileLoader(path=os.path.join(path, 'stage6')) 56 | rois, width, height = detection.stage_rois(roi_capture.capture()) 57 | for _ in range(30): 58 | detected_stage = detection.stage(stage_capture.capture(), rois, width, height, debug=None) 59 | is_fiery = detected_stage.fiery_grid 60 | detection.preview(capture.capture(), detected_stage, rois, width, height, OpenCVDebugger()) 61 | 62 | def test_sp(self): 63 | capture = FileLoader(path=os.path.join(path, 'stage5')) 64 | for _ in range(10): 65 | detection.sp(capture.capture(), debug=OpenCVDebugger()) 66 | -------------------------------------------------------------------------------- /tests/test_tableturf/test_detection/test_ui.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from capture import FileLoader 5 | from tableturf.manager.detection.debugger import OpenCVDebugger 6 | from tableturf.manager import detection 7 | 8 | path = os.path.join(os.path.realpath(__file__), '..', '..', '..', '..', 'temp') 9 | 10 | 11 | @unittest.skipIf(not os.path.exists(path), 'images not existed') 12 | class TestUI(unittest.TestCase): 13 | def test_deck_cursor(self): 14 | capture = FileLoader(path=os.path.join(path, 'deck')) 15 | for _ in range(10): 16 | detection.deck_cursor(capture.capture(), OpenCVDebugger()) 17 | 18 | def test_hands_cursor(self): 19 | capture = FileLoader(path=os.path.join(path, 'stage2')) 20 | for _ in range(10): 21 | detection.hands_cursor(capture.capture(), OpenCVDebugger()) 22 | 23 | def test_redraw_cursor(self): 24 | capture = FileLoader(path=os.path.join(path, 'redraw')) 25 | for _ in range(10): 26 | detection.redraw_cursor(capture.capture(), OpenCVDebugger()) 27 | 28 | def test_special_on(self): 29 | capture = FileLoader(path=os.path.join(path, 'stage4')) 30 | for _ in range(20): 31 | detection.special_on(capture.capture(), OpenCVDebugger()) 32 | 33 | def test_skip(self): 34 | capture = FileLoader(path=os.path.join(path, 'skip')) 35 | for _ in range(20): 36 | detection.skip(capture.capture(), OpenCVDebugger()) 37 | 38 | def test_replay_cursor(self): 39 | capture = FileLoader(path=os.path.join(path, 'replay')) 40 | for _ in range(10): 41 | detection.replay_cursor(capture.capture(), OpenCVDebugger()) 42 | 43 | def test_result(self): 44 | capture = FileLoader(path=os.path.join(path, 'result')) 45 | for _ in range(10): 46 | detection.result(capture.capture(), OpenCVDebugger()) 47 | 48 | def test_level(self): 49 | capture = FileLoader(path=os.path.join(path, 'level')) 50 | for _ in range(10): 51 | detection.level(capture.capture(), OpenCVDebugger()) 52 | 53 | def test_start(self): 54 | capture = FileLoader(path=os.path.join(path, 'level')) 55 | for _ in range(10): 56 | detection.start(capture.capture(), OpenCVDebugger()) 57 | -------------------------------------------------------------------------------- /tests/test_tableturf/test_model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fga401/AutoTableTurf/74b19d9e6173f3b12827d9818b18f1720502abad/tests/test_tableturf/test_model/__init__.py -------------------------------------------------------------------------------- /tests/test_tableturf/test_model/test_card.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from tableturf.model import Card, Grid 6 | 7 | 8 | class TestCard(unittest.TestCase): 9 | def setUp(self) -> None: 10 | self.card = Card( 11 | np.array([ 12 | [Grid.MyInk, Grid.MyInk, Grid.MyInk], 13 | [Grid.Empty, Grid.Empty, Grid.MySpecial], 14 | ]), 15 | 5 16 | ) 17 | 18 | def test_card_pattern_squares(self): 19 | self.assertListEqual(self.card.get_pattern(0).squares.tolist(), [ 20 | Grid.MyInk.value, Grid.MyInk.value, Grid.MyInk.value, Grid.MySpecial.value, 21 | ]) 22 | self.assertListEqual(self.card.get_pattern(1).squares.tolist(), [ 23 | Grid.MyInk.value, Grid.MySpecial.value, Grid.MyInk.value, Grid.MyInk.value, 24 | ]) 25 | self.assertListEqual(self.card.get_pattern(2).squares.tolist(), [ 26 | Grid.MySpecial.value, Grid.MyInk.value, Grid.MyInk.value, Grid.MyInk.value, 27 | ]) 28 | self.assertListEqual(self.card.get_pattern(3).squares.tolist(), [ 29 | Grid.MyInk.value, Grid.MyInk.value, Grid.MySpecial.value, Grid.MyInk.value, 30 | ]) 31 | 32 | def test_card_offset(self): 33 | self.assertListEqual(self.card.get_pattern(0).offset.tolist(), [ 34 | [0, 0], 35 | [0, 1], 36 | [0, 2], 37 | [1, 2], 38 | ]) 39 | self.assertListEqual(self.card.get_pattern(1).offset.tolist(), [ 40 | [0, 0], 41 | [0, 1], 42 | [1, 0], 43 | [2, 0], 44 | ]) 45 | self.assertListEqual(self.card.get_pattern(2).offset.tolist(), [ 46 | [0, 0], 47 | [1, 0], 48 | [1, 1], 49 | [1, 2], 50 | ]) 51 | self.assertListEqual(self.card.get_pattern(3).offset.tolist(), [ 52 | [0, 0], 53 | [1, 0], 54 | [2, -1], 55 | [2, 0], 56 | ]) 57 | -------------------------------------------------------------------------------- /tests/test_tableturf/test_model/test_stage.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from tableturf.model import Stage, Grid 6 | 7 | 8 | class TestStage(unittest.TestCase): 9 | def setUp(self) -> None: 10 | self.stage = Stage(np.array([ 11 | [Grid.Wall.value, Grid.MySpecial.value, Grid.MyInk.value], 12 | [Grid.HisSpecial.value, Grid.MyInk.value, Grid.Empty.value], 13 | [Grid.MySpecial.value, Grid.MyInk.value, Grid.HisInk.value] 14 | ])) 15 | 16 | def test_stage_my_ink(self): 17 | self.assertListEqual(self.stage.my_ink.tolist(), [ 18 | [0, 1], 19 | [0, 2], 20 | [1, 1], 21 | [2, 0], 22 | [2, 1], 23 | ]) 24 | 25 | def test_stage_my_sp(self): 26 | self.assertListEqual(self.stage.my_sp.tolist(), [ 27 | [0, 1], 28 | [2, 0], 29 | ]) 30 | 31 | def test_stage_my_fiery_sp(self): 32 | self.assertListEqual(self.stage.my_fiery_sp.tolist(), [ 33 | [2, 0], 34 | ]) 35 | 36 | def test_stage_my_unfiery_sp(self): 37 | self.assertListEqual(self.stage.my_unfiery_sp.tolist(), [ 38 | [0, 1], 39 | ]) 40 | 41 | def test_stage_my_neighborhoods(self): 42 | self.assertListEqual(self.stage.my_neighborhoods.tolist(), [ 43 | [1, 2], 44 | ]) 45 | 46 | def test_stage_my_sp_neighborhoods(self): 47 | self.assertListEqual(self.stage.my_sp_neighborhoods.tolist(), [ 48 | [0, 2], 49 | [1, 1], 50 | [1, 2], 51 | [2, 1], 52 | ]) 53 | 54 | def test_stage_his_ink(self): 55 | self.assertListEqual(self.stage.his_ink.tolist(), [ 56 | [1, 0], 57 | [2, 2], 58 | ]) 59 | 60 | def test_stage_his_sp(self): 61 | self.assertListEqual(self.stage.his_sp.tolist(), [ 62 | [1, 0], 63 | ]) 64 | 65 | def test_stage_his_fiery_sp(self): 66 | self.assertListEqual(self.stage.his_fiery_sp.tolist(), [ 67 | [1, 0], 68 | ]) 69 | 70 | def test_stage_his_unfiery_sp(self): 71 | self.assertListEqual(self.stage.his_unfiery_sp.tolist(), []) 72 | 73 | def test_stage_his_neighborhoods(self): 74 | self.assertListEqual(self.stage.his_neighborhoods.tolist(), [ 75 | [1, 2], 76 | ]) 77 | 78 | def test_stage_his_sp_neighborhoods(self): 79 | self.assertListEqual(self.stage.his_sp_neighborhoods.tolist(), [ 80 | [1, 1], 81 | [2, 1], 82 | ]) 83 | 84 | def test_stage_fiery_grid(self): 85 | expected = np.array([ 86 | [False, False, False], 87 | [True, False, False], 88 | [True, False, False], 89 | ]) 90 | self.assertTrue(np.all(self.stage.fiery_grid == expected)) 91 | -------------------------------------------------------------------------------- /tests/test_tableturf/test_model/test_status.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from tableturf.model import Stage, Grid, Card, Status, Step 6 | 7 | 8 | class TestStatus(unittest.TestCase): 9 | def setUp(self) -> None: 10 | pass 11 | 12 | def test_status_steps_v1(self): 13 | stage = Stage(np.array([ 14 | [Grid.Wall, Grid.Empty, Grid.HisSpecial], 15 | [Grid.Neutral, Grid.MySpecial, Grid.MyInk], 16 | [Grid.Empty, Grid.Empty, Grid.HisInk], 17 | ])) 18 | card_0 = Card(np.array([ 19 | [Grid.Empty, Grid.Empty, Grid.Empty], 20 | [Grid.Empty, Grid.MySpecial, Grid.MyInk], 21 | [Grid.Empty, Grid.Empty, Grid.Empty], 22 | ]), 1) 23 | card_1 = Card(np.array([ 24 | [Grid.Empty, Grid.Empty, Grid.MyInk], 25 | [Grid.Empty, Grid.MySpecial, Grid.MyInk], 26 | [Grid.Empty, Grid.Empty, Grid.Empty], 27 | ]), 2) 28 | card_2 = Card(np.array([ 29 | [Grid.Empty, Grid.Empty, Grid.Empty], 30 | [Grid.Empty, Grid.MySpecial, Grid.Empty], 31 | [Grid.Empty, Grid.Empty, Grid.Empty], 32 | ]), 4) 33 | status = Status( 34 | stage=stage, 35 | hands=[card_0, card_1, card_2], 36 | round=12, 37 | my_sp=2, 38 | his_sp=0, 39 | my_deck=[], 40 | his_deck=[], 41 | ) 42 | steps_0 = status.get_possible_steps(card_0) 43 | self.assertIn(Step(Step.Action.Place, card_0, rotate=0, pos=np.array([2, 0])), steps_0) 44 | self.assertIn(Step(Step.Action.Place, card_0, rotate=2, pos=np.array([2, 0])), steps_0) 45 | self.assertIn(Step(Step.Action.SpecialAttack, card_0, rotate=0, pos=np.array([2, 0])), steps_0) 46 | self.assertIn(Step(Step.Action.SpecialAttack, card_0, rotate=0, pos=np.array([2, 1])), steps_0) 47 | self.assertIn(Step(Step.Action.SpecialAttack, card_0, rotate=2, pos=np.array([2, 0])), steps_0) 48 | self.assertIn(Step(Step.Action.SpecialAttack, card_0, rotate=2, pos=np.array([2, 1])), steps_0) 49 | self.assertIn(Step(Step.Action.SpecialAttack, card_0, rotate=1, pos=np.array([1, 2])), steps_0) 50 | self.assertIn(Step(Step.Action.SpecialAttack, card_0, rotate=3, pos=np.array([1, 2])), steps_0) 51 | self.assertIn(Step(Step.Action.Skip, card_0, rotate=None, pos=None), steps_0) 52 | self.assertEqual(len(steps_0), 9) 53 | 54 | steps_1 = status.get_possible_steps(card_1) 55 | self.assertIn(Step(Step.Action.SpecialAttack, card_1, rotate=0, pos=np.array([1, 2])), steps_1) 56 | self.assertIn(Step(Step.Action.Skip, card_1, rotate=None, pos=None), steps_1) 57 | self.assertEqual(len(steps_1), 2) 58 | 59 | steps_2 = status.get_possible_steps(card_2) 60 | self.assertIn(Step(Step.Action.Place, card_2, rotate=0, pos=np.array([0, 1])), steps_2) 61 | self.assertIn(Step(Step.Action.Place, card_2, rotate=0, pos=np.array([2, 0])), steps_2) 62 | self.assertIn(Step(Step.Action.Place, card_2, rotate=0, pos=np.array([2, 1])), steps_2) 63 | self.assertIn(Step(Step.Action.Skip, card_2, rotate=None, pos=None), steps_2) 64 | self.assertEqual(len(steps_2), 4) 65 | 66 | self.assertEqual(len(status.get_possible_steps()), 15) 67 | 68 | def test_status_steps_v2(self): 69 | stage = Stage(np.array([ 70 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty], 71 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty], 72 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty], 73 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty], 74 | [Grid.Empty, Grid.Empty, Grid.Empty, Grid.Empty, Grid.MySpecial], 75 | ])) 76 | card_0 = Card(np.array([ 77 | [Grid.MyInk, Grid.MyInk], 78 | [Grid.MyInk, Grid.MyInk], 79 | ]), 10) 80 | card_1 = Card(np.array([ 81 | [Grid.MyInk, Grid.MyInk, Grid.MyInk, Grid.MyInk], 82 | ]), 10) 83 | status = Status( 84 | stage=stage, 85 | hands=[card_0, card_1], 86 | round=12, 87 | my_sp=2, 88 | his_sp=0, 89 | my_deck=[], 90 | his_deck=[], 91 | ) 92 | steps_0 = status.get_possible_steps(card_0) 93 | self.assertIn(Step(Step.Action.Place, card_0, rotate=0, pos=np.array([3, 2])), steps_0) 94 | self.assertIn(Step(Step.Action.Place, card_0, rotate=0, pos=np.array([2, 2])), steps_0) 95 | self.assertIn(Step(Step.Action.Place, card_0, rotate=0, pos=np.array([2, 3])), steps_0) 96 | self.assertIn(Step(Step.Action.Skip, card_0, rotate=None, pos=None), steps_0) 97 | 98 | steps_1 = status.get_possible_steps(card_1) 99 | self.assertIn(Step(Step.Action.Place, card_1, rotate=0, pos=np.array([4, 0])), steps_1) 100 | self.assertIn(Step(Step.Action.Place, card_1, rotate=0, pos=np.array([3, 0])), steps_1) 101 | self.assertIn(Step(Step.Action.Place, card_1, rotate=0, pos=np.array([3, 1])), steps_1) 102 | self.assertIn(Step(Step.Action.Place, card_1, rotate=1, pos=np.array([1, 3])), steps_1) 103 | self.assertIn(Step(Step.Action.Place, card_1, rotate=1, pos=np.array([0, 3])), steps_1) 104 | self.assertIn(Step(Step.Action.Place, card_1, rotate=1, pos=np.array([0, 4])), steps_1) 105 | self.assertIn(Step(Step.Action.Skip, card_1, rotate=None, pos=None), steps_1) 106 | --------------------------------------------------------------------------------