├── .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 | 
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 | 
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 | Keyboard |
166 | Controller |
167 |
168 | {% for k, v in keymap.items() %}
169 |
170 | {{k}} |
171 | {{v}} |
172 |
173 | {% endfor %}
174 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------