diff --git a/.env b/.env new file mode 100644 index 0000000..56a282d --- /dev/null +++ b/.env @@ -0,0 +1 @@ +PYTHONPATH=src diff --git a/.gitignore b/.gitignore index 51410e9..122e90e 100644 --- a/.gitignore +++ b/.gitignore @@ -79,9 +79,6 @@ celerybeat-schedule # SageMath parsed files *.sage.py -# dotenv -.env - # virtualenv .venv venv/ @@ -108,4 +105,8 @@ ENV/ /build # Gradle files -/.gradle \ No newline at end of file +/.gradle + +# VSCode +.vscode/ +*.code-workspace diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..9db66d4 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[TYPECHECK] +generated-members=QuickChats.* diff --git a/README.md b/README.md index 3771948..61129d2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # RLBotPythonExample -Example of a python bot using the RLBot framework +Example of a Python bot using the RLBot framework ## Quick Start -The easiest way to start a python bot is demonstrated here! +The easiest way to start a Python bot is demonstrated here! https://youtu.be/YJ69QZ-EX7k It shows you how to: @@ -15,16 +15,3 @@ It shows you how to: - Bot appearance is controlled by `src/appearance.cfg` See https://github.com/RLBot/RLBotPythonExample/wiki for documentation and tutorials. - -### Older Setup Technique - -**Please don't do this unless you've followed the quick start video and it doesn't work!** - -https://www.youtube.com/watch?v=UjsQFNN0nSA - -1. Make sure you've installed [Python 3.7 64 bit](https://www.python.org/ftp/python/3.7.4/python-3.7.4-amd64.exe). During installation: - - Select "Add Python to PATH" - - Make sure pip is included in the installation -1. Download or clone this repository -1. In the files from the previous step, find and double click on run-gui.bat -1. Click the 'Run' button diff --git a/requirements.txt b/requirements.txt index 25c0c97..008fac3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # Include everything the framework requires # You will automatically get updates for all versions starting with "1.". rlbot==1.* +rlbot_gui rlbottraining # This will cause pip to auto-upgrade and stop scaring people with warning messages diff --git a/rlbot.cfg b/rlbot.cfg index b8553d8..a568454 100644 --- a/rlbot.cfg +++ b/rlbot.cfg @@ -5,10 +5,13 @@ # Visit https://github.com/RLBot/RLBot/wiki/Config-File-Documentation to see what you can put here. [Match Configuration] -# Number of bots/players which will be spawned. We support up to max 10. +# Visit https://github.com/RLBot/RLBot/wiki/Config-File-Documentation to see what you can put here. +# Number of bots/players which will be spawned. We support up to max 64. num_participants = 2 game_mode = Soccer game_map = Mannfield +enable_rendering = True +enable_state_setting = True [Mutator Configuration] # Visit https://github.com/RLBot/RLBot/wiki/Config-File-Documentation to see what you can put here. diff --git a/run-gui.bat b/run-gui.bat deleted file mode 100644 index a1c00d9..0000000 --- a/run-gui.bat +++ /dev/null @@ -1,11 +0,0 @@ -@echo off - -@rem Change the working directory to the location of this file so that relative paths will work -cd /D "%~dp0" - -@rem Make sure the environment variables are up-to-date. This is useful if the user installed python a moment ago. -call ./RefreshEnv.cmd - -python run.py gui - -pause diff --git a/run.bat b/run.bat deleted file mode 100644 index b3318d9..0000000 --- a/run.bat +++ /dev/null @@ -1,11 +0,0 @@ -@echo off - -@rem Change the working directory to the location of this file so that relative paths will work -cd /D "%~dp0" - -@rem Make sure the environment variables are up-to-date. This is useful if the user installed python a moment ago. -call ./RefreshEnv.cmd - -python run.py - -pause diff --git a/run.py b/run.py index a679076..23ea98a 100644 --- a/run.py +++ b/run.py @@ -1,11 +1,6 @@ +import subprocess import sys -# https://stackoverflow.com/a/51704613 -try: - from pip import main as pipmain -except ImportError: - from pip._internal import main as pipmain - DEFAULT_LOGGER = 'rlbot' if __name__ == '__main__': @@ -18,7 +13,8 @@ logger.log(logging_utils.logging_level, 'Skipping upgrade check for now since it looks like you have no internet') elif public_utils.is_safe_to_upgrade(): - pipmain(['install', '-r', 'requirements.txt', '--upgrade', '--upgrade-strategy=eager']) + subprocess.call([sys.executable, "-m", "pip", "install", '-r', 'requirements.txt']) + subprocess.call([sys.executable, "-m", "pip", "install", 'rlbot', '--upgrade']) # https://stackoverflow.com/a/44401013 rlbots = [module for module in sys.modules if module.startswith('rlbot')] @@ -26,17 +22,11 @@ sys.modules.pop(rlbot_module) except ImportError: - pipmain(['install', '-r', 'requirements.txt', '--upgrade', '--upgrade-strategy=eager']) + subprocess.call([sys.executable, "-m", "pip", "install", '-r', 'requirements.txt', '--upgrade', '--upgrade-strategy=eager']) try: - if len(sys.argv) > 1 and sys.argv[1] == 'gui': - from rlbot.gui.qt_root import RLBotQTGui - - RLBotQTGui.main() - else: - from rlbot import runner - - runner.main() + from rlbot import runner + runner.main() except Exception as e: print("Encountered exception: ", e) print("Press enter to close.") diff --git a/run_gui.py b/run_gui.py new file mode 100644 index 0000000..a3bced8 --- /dev/null +++ b/run_gui.py @@ -0,0 +1,7 @@ +from rlbot_gui import gui + +# This is a useful way to start up RLBotGUI directly from your bot project. You can use it to +# arrange a match with the settings you like, and if you have a good IDE like PyCharm, +# you can do breakpoint debugging on your bot. +if __name__ == '__main__': + gui.start() diff --git a/src/appearance.cfg b/src/appearance.cfg index 468bbe3..45840e3 100644 --- a/src/appearance.cfg +++ b/src/appearance.cfg @@ -1,3 +1,7 @@ +# You don't have to manually edit this file! +# RLBotGUI has an appearance editor with a nice colorpicker, database of items and more! +# To open it up, simply click the (i) icon next to your bot's name and then click Edit Appearance + [Bot Loadout] team_color_id = 60 custom_color_id = 0 diff --git a/src/bot.cfg b/src/bot.cfg index c27f0fb..3aa2023 100644 --- a/src/bot.cfg +++ b/src/bot.cfg @@ -8,6 +8,9 @@ python_file = ./bot.py # Name of the bot in-game name = PythonExampleBot +# The maximum number of ticks per second that your bot wishes to receive. +maximum_tick_rate_preference = 120 + [Details] # These values are optional but useful metadata for helper programs # Name of the bot's creator/developer diff --git a/src/bot.py b/src/bot.py index 58511a2..8d21345 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,71 +1,89 @@ -import math - from rlbot.agents.base_agent import BaseAgent, SimpleControllerState +from rlbot.messages.flat.QuickChatSelection import QuickChatSelection from rlbot.utils.structures.game_data_struct import GameTickPacket -from util.orientation import Orientation +from util.ball_prediction_analysis import find_slice_at_time +from util.boost_pad_tracker import BoostPadTracker +from util.drive import steer_toward_target +from util.sequence import Sequence, ControlStep from util.vec import Vec3 class MyBot(BaseAgent): + def __init__(self, name, team, index): + super().__init__(name, team, index) + self.active_sequence: Sequence = None + self.boost_pad_tracker = BoostPadTracker() + def initialize_agent(self): - # This runs once before the bot starts up - self.controller_state = SimpleControllerState() + # Set up information about the boost pads now that the game is active and the info is available + self.boost_pad_tracker.initialize_boosts(self.get_field_info()) def get_output(self, packet: GameTickPacket) -> SimpleControllerState: - ball_location = Vec3(packet.game_ball.physics.location) - + """ + This function will be called by the framework many times per second. This is where you can + see the motion of the ball, etc. and return controls to drive your car. + """ + + # Keep our boost pad info updated with which pads are currently active + self.boost_pad_tracker.update_boost_status(packet) + + # This is good to keep at the beginning of get_output. It will allow you to continue + # any sequences that you may have started during a previous call to get_output. + if self.active_sequence is not None and not self.active_sequence.done: + controls = self.active_sequence.tick(packet) + if controls is not None: + return controls + + # Gather some information about our car and the ball my_car = packet.game_cars[self.index] car_location = Vec3(my_car.physics.location) + car_velocity = Vec3(my_car.physics.velocity) + ball_location = Vec3(packet.game_ball.physics.location) - car_to_ball = ball_location - car_location - - # Find the direction of our car using the Orientation class - car_orientation = Orientation(my_car.physics.rotation) - car_direction = car_orientation.forward - - steer_correction_radians = find_correction(car_direction, car_to_ball) - - if steer_correction_radians > 0: - # Positive radians in the unit circle is a turn to the left. - turn = -1.0 # Negative value for a turn to the left. - action_display = "turn left" - else: - turn = 1.0 - action_display = "turn right" - - self.controller_state.throttle = 1.0 - self.controller_state.steer = turn - - draw_debug(self.renderer, my_car, packet.game_ball, action_display) - - return self.controller_state - - -def find_correction(current: Vec3, ideal: Vec3) -> float: - # Finds the angle from current to ideal vector in the xy-plane. Angle will be between -pi and +pi. - - # The in-game axes are left handed, so use -x - current_in_radians = math.atan2(current.y, -current.x) - ideal_in_radians = math.atan2(ideal.y, -ideal.x) - - diff = ideal_in_radians - current_in_radians - - # Make sure that diff is between -pi and +pi. - if abs(diff) > math.pi: - if diff < 0: - diff += 2 * math.pi - else: - diff -= 2 * math.pi - - return diff - - -def draw_debug(renderer, car, ball, action_display): - renderer.begin_rendering() - # draw a line from the car to the ball - renderer.draw_line_3d(car.physics.location, ball.physics.location, renderer.white()) - # print the action that the bot is taking - renderer.draw_string_3d(car.physics.location, 2, 2, action_display, renderer.white()) - renderer.end_rendering() + # By default we will chase the ball, but target_location can be changed later + target_location = ball_location + + if car_location.dist(ball_location) > 1500: + # We're far away from the ball, let's try to lead it a little bit + ball_prediction = self.get_ball_prediction_struct() # This can predict bounces, etc + ball_in_future = find_slice_at_time(ball_prediction, packet.game_info.seconds_elapsed + 2) + + # ball_in_future might be None if we don't have an adequate ball prediction right now, like during + # replays, so check it to avoid errors. + if ball_in_future is not None: + target_location = Vec3(ball_in_future.physics.location) + self.renderer.draw_line_3d(ball_location, target_location, self.renderer.cyan()) + + # Draw some things to help understand what the bot is thinking + self.renderer.draw_line_3d(car_location, target_location, self.renderer.white()) + self.renderer.draw_string_3d(car_location, 1, 1, f'Speed: {car_velocity.length():.1f}', self.renderer.white()) + self.renderer.draw_rect_3d(target_location, 8, 8, True, self.renderer.cyan(), centered=True) + + if 750 < car_velocity.length() < 800: + # We'll do a front flip if the car is moving at a certain speed. + return self.begin_front_flip(packet) + + controls = SimpleControllerState() + controls.steer = steer_toward_target(my_car, target_location) + controls.throttle = 1.0 + # You can set more controls if you want, like controls.boost. + + return controls + + def begin_front_flip(self, packet): + # Send some quickchat just for fun + self.send_quick_chat(team_only=False, quick_chat=QuickChatSelection.Information_IGotIt) + + # Do a front flip. We will be committed to this for a few seconds and the bot will ignore other + # logic during that time because we are setting the active_sequence. + self.active_sequence = Sequence([ + ControlStep(duration=0.05, controls=SimpleControllerState(jump=True)), + ControlStep(duration=0.05, controls=SimpleControllerState(jump=False)), + ControlStep(duration=0.2, controls=SimpleControllerState(jump=True, pitch=-1)), + ControlStep(duration=0.8, controls=SimpleControllerState()), + ]) + + # Return the controls associated with the beginning of the sequence so we can start right away. + return self.active_sequence.tick(packet) diff --git a/src/util/__init__.py b/src/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/util/ball_prediction_analysis.py b/src/util/ball_prediction_analysis.py new file mode 100644 index 0000000..31f6e81 --- /dev/null +++ b/src/util/ball_prediction_analysis.py @@ -0,0 +1,48 @@ +from typing import Callable + +from rlbot.utils.structures.ball_prediction_struct import BallPrediction, Slice + +# field length(5120) + ball radius(93) = 5213 however that results in false positives +GOAL_THRESHOLD = 5235 + +# We will jump this number of frames when looking for a moment where the ball is inside the goal. +# Big number for efficiency, but not so big that the ball could go in and then back out during that +# time span. Unit is the number of frames in the ball prediction, and the prediction is at 60 frames per second. +GOAL_SEARCH_INCREMENT = 20 + + +def find_slice_at_time(ball_prediction: BallPrediction, game_time: float): + """ + This will find the future position of the ball at the specified time. The returned + Slice object will also include the ball's velocity, etc. + """ + start_time = ball_prediction.slices[0].game_seconds + approx_index = int((game_time - start_time) * 60) # We know that there are 60 slices per second. + if 0 <= approx_index < ball_prediction.num_slices: + return ball_prediction.slices[approx_index] + return None + + +def predict_future_goal(ball_prediction: BallPrediction): + """ + Analyzes the ball prediction to see if the ball will enter one of the goals. Only works on standard arenas. + Will return the first ball slice which appears to be inside the goal, or None if it does not enter a goal. + """ + return find_matching_slice(ball_prediction, 0, lambda s: abs(s.physics.location.y) >= GOAL_THRESHOLD, + search_increment=20) + + +def find_matching_slice(ball_prediction: BallPrediction, start_index: int, predicate: Callable[[Slice], bool], + search_increment=1): + """ + Tries to find the first slice in the ball prediction which satisfies the given predicate. For example, + you could find the first slice below a certain height. Will skip ahead through the packet by search_increment + for better efficiency, then backtrack to find the exact first slice. + """ + for coarse_index in range(start_index, ball_prediction.num_slices, search_increment): + if predicate(ball_prediction.slices[coarse_index]): + for j in range(max(start_index, coarse_index - search_increment), coarse_index): + ball_slice = ball_prediction.slices[j] + if predicate(ball_slice): + return ball_slice + return None diff --git a/src/util/boost_pad_tracker.py b/src/util/boost_pad_tracker.py new file mode 100644 index 0000000..3b6a7ef --- /dev/null +++ b/src/util/boost_pad_tracker.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import List + +from rlbot.utils.structures.game_data_struct import GameTickPacket, FieldInfoPacket + +from util.vec import Vec3 + + +@dataclass +class BoostPad: + location: Vec3 + is_full_boost: bool + is_active: bool # Active means it's available to be picked up + timer: float # Counts the number of seconds that the pad has been *inactive* + + +class BoostPadTracker: + """ + This class merges together the boost pad location info with the is_active info so you can access it + in one convenient list. For it to function correctly, you need to call initialize_boosts once when the + game has started, and then update_boost_status every frame so that it knows which pads are active. + """ + + def __init__(self): + self.boost_pads: List[BoostPad] = [] + self._full_boosts_only: List[BoostPad] = [] + + def initialize_boosts(self, game_info: FieldInfoPacket): + raw_boosts = [game_info.boost_pads[i] for i in range(game_info.num_boosts)] + self.boost_pads: List[BoostPad] = [BoostPad(Vec3(rb.location), rb.is_full_boost, False, 0) for rb in raw_boosts] + # Cache the list of full boosts since they're commonly requested. + # They reference the same objects in the boost_pads list. + self._full_boosts_only: List[BoostPad] = [bp for bp in self.boost_pads if bp.is_full_boost] + + def update_boost_status(self, packet: GameTickPacket): + for i in range(packet.num_boost): + our_pad = self.boost_pads[i] + packet_pad = packet.game_boosts[i] + our_pad.is_active = packet_pad.is_active + our_pad.timer = packet_pad.timer + + def get_full_boosts(self) -> List[BoostPad]: + return self._full_boosts_only diff --git a/src/util/drive.py b/src/util/drive.py new file mode 100644 index 0000000..b89160b --- /dev/null +++ b/src/util/drive.py @@ -0,0 +1,25 @@ +import math + +from rlbot.utils.structures.game_data_struct import PlayerInfo + +from util.orientation import Orientation, relative_location +from util.vec import Vec3 + + +def limit_to_safe_range(value: float) -> float: + """ + Controls like throttle, steer, pitch, yaw, and roll need to be in the range of -1 to 1. + This will ensure your number is in that range. Something like 0.45 will stay as it is, + but a value of -5.6 would be changed to -1. + """ + if value < -1: + return -1 + if value > 1: + return 1 + return value + + +def steer_toward_target(car: PlayerInfo, target: Vec3) -> float: + relative = relative_location(Vec3(car.physics.location), Orientation(car.physics.rotation), target) + angle = math.atan2(relative.y, relative.x) + return limit_to_safe_range(angle * 5) diff --git a/src/util/sequence.py b/src/util/sequence.py new file mode 100644 index 0000000..af53895 --- /dev/null +++ b/src/util/sequence.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +from typing import List + +from rlbot.agents.base_agent import SimpleControllerState +from rlbot.utils.structures.game_data_struct import GameTickPacket + + +@dataclass +class StepResult: + controls: SimpleControllerState + done: bool + + +class Step: + def tick(self, packet: GameTickPacket) -> StepResult: + """ + Return appropriate controls for this step in the sequence. If the step is over, you should + set done to True in the result, and we'll move on to the next step during the next frame. + If you panic and can't return controls at all, you may return None and we will move on to + the next step immediately. + """ + raise NotImplementedError + + +class ControlStep(Step): + """ + This allows you to repeat the same controls every frame for some specified duration. It's useful for + scheduling the button presses needed for kickoffs / dodges / etc. + """ + def __init__(self, duration: float, controls: SimpleControllerState): + self.duration = duration + self.controls = controls + self.start_time: float = None + + def tick(self, packet: GameTickPacket) -> StepResult: + if self.start_time is None: + self.start_time = packet.game_info.seconds_elapsed + elapsed_time = packet.game_info.seconds_elapsed - self.start_time + return StepResult(controls=self.controls, done=elapsed_time > self.duration) + + +class Sequence: + def __init__(self, steps: List[Step]): + self.steps = steps + self.index = 0 + self.done = False + + def tick(self, packet: GameTickPacket): + while self.index < len(self.steps): + step = self.steps[self.index] + result = step.tick(packet) + if result is None or result.controls is None or result.done: + self.index += 1 + if self.index >= len(self.steps): + # The bot will know not to use this sequence next frame, even though we may be giving it controls. + self.done = True + if result is not None and result.controls is not None: + # If the step was able to give us controls, return them to the bot. + return result.controls + # Otherwise we will loop to the next step in the sequence. + # If we reach here, we ran out of steps to attempt. + self.done = True + return None diff --git a/src/util/spikes.py b/src/util/spikes.py new file mode 100644 index 0000000..582c30a --- /dev/null +++ b/src/util/spikes.py @@ -0,0 +1,36 @@ +from rlbot.utils.structures.game_data_struct import PlayerInfo, GameTickPacket + +from util.vec import Vec3 + +# When the ball is attached to a car's spikes, the distance will vary a bit depending on whether the ball is +# on the front bumper, the roof, etc. It tends to be most far away when the ball is on one of the front corners +# and that distance is a little under 200. We want to be sure that it's never over 200, otherwise bots will +# suffer from bad bugs when they don't think the ball is spiked to them but it actually is; they'll probably +# drive in circles. The opposite problem, where they think it's spiked before it really is, is not so bad because +# they usually spike it for real a split second later. +MAX_DISTANCE_WHEN_SPIKED = 200 + +class SpikeWatcher: + def __init__(self): + self.carrying_car: PlayerInfo = None + self.spike_moment = 0 + self.carry_duration = 0 + + def read_packet(self, packet: GameTickPacket): + ball_location = Vec3(packet.game_ball.physics.location) + closest_candidate: PlayerInfo = None + closest_distance = 999999 + for i in range(packet.num_cars): + car = packet.game_cars[i] + car_location = Vec3(car.physics.location) + distance = car_location.dist(ball_location) + if distance < MAX_DISTANCE_WHEN_SPIKED: + if distance < closest_distance: + closest_candidate = car + closest_distance = distance + if closest_candidate != self.carrying_car and closest_candidate is not None: + self.spike_moment = packet.game_info.seconds_elapsed + + self.carrying_car = closest_candidate + if self.carrying_car is not None: + self.carry_duration = packet.game_info.seconds_elapsed - self.spike_moment diff --git a/src/util/vec.py b/src/util/vec.py index 6850e4f..9ce74ac 100644 --- a/src/util/vec.py +++ b/src/util/vec.py @@ -1,7 +1,9 @@ import math +from typing import Union + +from rlbot.utils.structures.game_data_struct import Vector3 -# This is a helper class for vector math. You can extend it or delete if you want. class Vec3: """ This class should provide you with all the basic vector operations that you need, but feel free to extend its @@ -13,8 +15,14 @@ class Vec3: When in doubt visit the wiki: https://github.com/RLBot/RLBot/wiki/Useful-Game-Values """ - - def __init__(self, x: float or 'Vec3'=0, y: float=0, z: float=0): + # https://docs.python.org/3/reference/datamodel.html#slots + __slots__ = [ + 'x', + 'y', + 'z' + ] + + def __init__(self, x: Union[float, 'Vec3', 'Vector3']=0, y: float=0, z: float=0): """ Create a new Vec3. The x component can alternatively be another vector with an x, y, and z component, in which case the created vector is a copy of the given vector and the y and z parameter is ignored. Examples: @@ -58,7 +66,10 @@ def __truediv__(self, scale: float) -> 'Vec3': return self * scale def __str__(self): - return "Vec3(" + str(self.x) + ", " + str(self.y) + ", " + str(self.z) + ")" + return f"Vec3({self.x:.2f}, {self.y:.2f}, {self.z:.2f})" + + def __repr__(self): + return self.__str__() def flat(self): """Returns a new Vec3 that equals this Vec3 but projected onto the ground plane. I.e. where z=0.""" diff --git a/training/hello_world_training.py b/training/hello_world_training.py index c1ea9e3..f1cba03 100644 --- a/training/hello_world_training.py +++ b/training/hello_world_training.py @@ -16,7 +16,7 @@ def make_match_config_with_my_bot() -> MatchConfig: # Makes a config which only has our bot in it for now. - # For more defails: https://youtu.be/uGFmOZCpel8?t=375 + # For more details: https://youtu.be/uGFmOZCpel8?t=375 match_config = make_empty_match_config() match_config.player_configs = [ PlayerConfig.bot_config( @@ -26,6 +26,17 @@ def make_match_config_with_my_bot() -> MatchConfig: ] return match_config + +def add_my_bot_to_playlist(exercises: Playlist) -> Playlist: + """ + Updates the match config for each excercise to include + the bot from this project + """ + for exercise in exercises: + exercise.match_config = make_match_config_with_my_bot() + return exercises + + @dataclass class StrikerPatience(StrikerExercise): """ @@ -90,7 +101,4 @@ def make_default_playlist() -> Playlist: DrivesToBallExercise('Get close to ball'), DrivesToBallExercise('Get close-ish to ball', grader=DriveToBallGrader(min_dist_to_pass=1000)) ] - for exercise in exercises: - exercise.match_config = make_match_config_with_my_bot() - - return exercises + return add_my_bot_to_playlist(exercises) diff --git a/training/unit_tests.py b/training/unit_tests.py index a147c35..9326280 100644 --- a/training/unit_tests.py +++ b/training/unit_tests.py @@ -3,7 +3,7 @@ from rlbot.training.training import Pass, Fail from rlbottraining.exercise_runner import run_playlist -from hello_world_training import StrikerPatience +from hello_world_training import StrikerPatience, add_my_bot_to_playlist class PatienceTest(unittest.TestCase): """ @@ -18,7 +18,8 @@ class PatienceTest(unittest.TestCase): """ def test_patience_required(self): - result_iter = run_playlist([StrikerPatience(name='patience required')]) + playlist = [StrikerPatience(name='patience required')] + result_iter = run_playlist(add_my_bot_to_playlist(playlist)) results = list(result_iter) self.assertEqual(len(results), 1) result = results[0] @@ -26,7 +27,8 @@ def test_patience_required(self): self.assertIsInstance(result.grade, Fail) # If you make the bot is smarter, update this assert that we pass. def test_no_patience_required(self): - result_iter = run_playlist([StrikerPatience(name='no patience required', car_start_x=-1000)]) + playlist = [StrikerPatience(name='no patience required', car_start_x=-1000)] + result_iter = run_playlist(add_my_bot_to_playlist(playlist)) results = list(result_iter) self.assertEqual(len(results), 1) result = results[0]