diff --git a/docs/conf.py b/docs/conf.py index 18a2871d37..0e557a5d75 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -445,6 +445,7 @@ def populate_doc_sections(self): RGBColor='mpf.core.rgb_color.RGBColor', RGBAColor='mpf.core.rgba_color.RGBAColor', Randomizer='mpf.core.randomizer.Randomizer', + ListRandomizer='mpf.core.randomizer.ListRandomizer', Timers='mpf.core.timer.Timer', ) diff --git a/mpf/config_players/random_event_player.py b/mpf/config_players/random_event_player.py index 81756dd800..5531a59af2 100644 --- a/mpf/config_players/random_event_player.py +++ b/mpf/config_players/random_event_player.py @@ -1,6 +1,6 @@ """Random event config player.""" from mpf.core.config_player import ConfigPlayer -from mpf.core.randomizer import Randomizer +from mpf.core.randomizer import ListRandomizer from mpf.core.utility_functions import Util @@ -23,8 +23,9 @@ def is_entry_valid_outside_mode(settings) -> bool: """Return true if scope is not player.""" return settings['scope'] != "player" - def _build_randomizer(self, settings): - randomizer = Randomizer(settings['events'], self.machine, template_type="event") + def _build_randomizer(self, settings, name): + self.info_log(f"Instantiating ListRandomizer {name}") + randomizer = ListRandomizer(settings['events'], name=name, machine=self.machine, template_type="event") if settings['force_all']: randomizer.force_all = True @@ -38,35 +39,39 @@ def _build_randomizer(self, settings): randomizer.fallback_value = settings.get('fallback_event') return randomizer - def _get_randomizer(self, settings, context, calling_context): + def find_or_create_randomizer(self, settings, context, calling_context): + """Uses context and calling context to find a randomizer instance or create and register a new one.""" + '''player_var: random_(x).(y) + + desc: Holds references to ListRandomizer settings that need to be + tracked on a player basis. There is nothing you need to know + or do with this, rather this is just FYI on what the player + variables that start with "random_" are. + ''' + + '''machine_var: random_(x).(y) + + desc: Holds references to ListRandomizer settings that need to be + tracked on a machine basis. + ''' + key = "random_{}.{}".format(context, calling_context) + if settings['scope'] == "player": if not self.machine.game.player[key]: - self.machine.game.player[key] = self._build_randomizer(settings) + self.machine.game.player[key] = self._build_randomizer(settings, key) - '''player_var: random_(x).(y) - - desc: Holds references to Randomizer settings that need to be - tracked on a player basis. There is nothing you need to know - or do with this, rather this is just FYI on what the player - variables that start with "random_" are. - ''' return self.machine.game.player[key] if key not in self._machine_wide_dict: - self._machine_wide_dict[key] = self._build_randomizer(settings) + self._machine_wide_dict[key] = self._build_randomizer(settings, key) - '''machine_var: random_(x).(y) - - desc: Holds references to Randomizer settings that need to be - tracked on a machine basis. - ''' return self._machine_wide_dict[key] def play(self, settings, context, calling_context, priority=0, **kwargs): """Play a random event from list based on config.""" del priority - randomizer = self._get_randomizer(settings, context, calling_context) + randomizer = self.find_or_create_randomizer(settings, context, calling_context) # With conditional events in randomizer, there may not be a next event next_event = randomizer.get_next(kwargs) if next_event: diff --git a/mpf/core/machine.py b/mpf/core/machine.py index dc12ccbcff..cac4626d08 100644 --- a/mpf/core/machine.py +++ b/mpf/core/machine.py @@ -2,6 +2,7 @@ """Contains the MachineController base class.""" import asyncio import logging +import random import sys import threading from typing import Any, Callable, Dict, List, Set, Optional @@ -21,6 +22,7 @@ from mpf.core.utility_functions import Util from mpf.core.config_loader import MpfConfig from mpf.core.plugin import MpfPlugin +from mpf.core.randomizer import Randomizer # pylint: disable-msg=cyclic-import,unused-import MYPY = False if MYPY: # pragma: no cover @@ -106,7 +108,7 @@ class MachineController(LogMixin): "stop_future", "events", "switch_controller", "mode_controller", "settings", "bcp", "ball_controller", "show_controller", "placeholder_manager", "device_manager", "auditor", "tui", "service", "switches", "shows", "coils", "ball_devices", "lights", "playfield", "playfields", - "autofire_coils", "_crash_handlers", "__dict__", "mpf_config", "is_shutting_down"] + "autofire_coils", "_crash_handlers", "__dict__", "mpf_config", "is_shutting_down", "randomizers"] # pylint: disable-msg=too-many-statements def __init__(self, options: dict, config: MpfConfig) -> None: @@ -143,6 +145,7 @@ def __init__(self, options: dict, config: MpfConfig) -> None: self.mpf_config = config # type: MpfConfig self.config_validator = ConfigValidator(self, self.mpf_config.get_config_spec()) + self.randomizers = dict() # type: Dict[str, Randomizer] self.variables = MachineVariables(self) # type: MachineVariables # add some type hints @@ -219,6 +222,7 @@ def __init__(self, options: dict, config: MpfConfig) -> None: self.default_platform = None # type: Optional[SmartVirtualHardwarePlatform] self.clock = self._load_clock() + self._initialize_randomizers() self.stop_future = asyncio.Future() # type: asyncio.Future def add_crash_handler(self, handler: Callable): @@ -438,6 +442,11 @@ def _set_machine_path(self) -> None: """Add the machine folder to sys.path so we can import modules from it.""" sys.path.insert(0, self.machine_path) + def _initialize_randomizers(self) -> None: + """Register the root randomizer.""" + machine_seed = random.random() # NOSONAR + self.randomizers['root'] = Randomizer(machine=self, name='root', seed=machine_seed) + def verify_system_info(self): """Dump information about the Python installation to the log. diff --git a/mpf/core/randomizer.py b/mpf/core/randomizer.py index 09d9365046..0c07b90256 100644 --- a/mpf/core/randomizer.py +++ b/mpf/core/randomizer.py @@ -4,10 +4,35 @@ class Randomizer: - """Generic list randomizer.""" + """Generates randomness with a managed seed.""" - def __init__(self, items, machine=None, template_type='event'): + def __init__(self, name=None, machine=None, seed=None): """Initialize Randomizer.""" + self.name = name + self.machine = machine + self.set_seed(seed or machine and machine.randomizers['root'].random(10000)) + + def set_seed(self, seed): + """Reset the random generator to the start of the specified seed.""" + self.seed = seed + self._random = random.Random(seed) + if self.machine: + self.machine.log.info(f"Randomizer {self.name} seeded with seed: {seed}") + + def random(self, range_value): + """Returns number between 0 and Range-1.""" + return self._random.randrange(range_value) + + +class ListRandomizer(Randomizer): + + """Generic list randomizer.""" + + # pylint: disable-msg=too-many-arguments + def __init__(self, items, name=None, machine=None, template_type='event', seed=None): + """Initialize ListRandomizer.""" + super().__init__(name=name, machine=machine, seed=seed) + self.fallback_value = None self.force_different = True self.force_all = False @@ -41,7 +66,7 @@ def __init__(self, items, machine=None, template_type='event'): self.items.append((this_item, int(this_weight))) self.items.sort(key=lambda x: x[0].name or x[0]) else: - raise AssertionError("Invalid input for Randomizer") + raise AssertionError("Invalid input for ListRandomizer") self.data = dict() self._init_data(self.data) @@ -177,8 +202,7 @@ def generate_template(machine, template_type, value): # Add additional template_type support here, as needed return value - @staticmethod - def pick_weighted_random(items): + def pick_weighted_random(self, items): """Pick a random item. Args: @@ -186,7 +210,7 @@ def pick_weighted_random(items): items: Items to select from """ total_weights = sum([x[1] for x in items]) - value = random.randint(1, total_weights) + value = self._random.randint(1, total_weights) index_value = 0 for item in items: diff --git a/mpf/devices/achievement_group.py b/mpf/devices/achievement_group.py index c43b166eff..6420d0a722 100644 --- a/mpf/devices/achievement_group.py +++ b/mpf/devices/achievement_group.py @@ -221,7 +221,7 @@ def select_random_achievement(self): self._selected_member.unselect() try: - # todo change this to use our Randomizer class + # todo change this to use our ListRandomizer class if self.config['disable_random']: ach = self._get_available_achievements_for_selection()[0] self.debug_log("Picked new non-random achievement: %s", ach) diff --git a/mpf/tests/test_Randomizer.py b/mpf/tests/test_Randomizer.py index bdf29ad8f3..df547fb5eb 100644 --- a/mpf/tests/test_Randomizer.py +++ b/mpf/tests/test_Randomizer.py @@ -1,23 +1,50 @@ -"""Test Randomizer class.""" +"""Test Randomizer and ListRandomizer classes.""" +import unittest from mpf.tests.MpfTestCase import MpfTestCase -from mpf.core.randomizer import Randomizer +from mpf.core.randomizer import Randomizer, ListRandomizer + + +class TestRandomizer(unittest.TestCase): + def test_randomizer_seed(self): + r1 = Randomizer(seed=42) + r2 = Randomizer(seed=42) + self.assertEqual(r1.random(1000), r2.random(1000)) + self.assertEqual(r1.random(1000), r2.random(1000)) + self.assertEqual(r1.random(1000), r2.random(1000)) + + r1 = Randomizer(seed=42) + r2 = Randomizer(seed=43) + self.assertNotEqual(r1.random(1000), r2.random(1000)) + self.assertNotEqual(r1.random(1000), r2.random(1000)) + def standard_items(): return [ - ('1', 1), - ('2', 1), - ('3', 1) + ('a', 1), + ('b', 1), + ('c', 1) ] -class TestRandomizer(MpfTestCase): +class TestListRandomizer(MpfTestCase): def get_config_file(self): return 'randomizer.yaml' def get_machine_path(self): return 'tests/machine_files/randomizer/' + def test_seeded_list_randomizer(self): + r1 = ListRandomizer(standard_items(), seed=1337, machine=self.machine) + r2 = ListRandomizer(standard_items(), seed=1337, machine=self.machine) + + self.assertEqual(next(r1), next(r2)) + self.assertEqual(next(r1), next(r2)) + self.assertEqual(r1.random(1000), r2.random(1000)) + self.assertEqual(r1.random(1000), r2.random(1000)) + # Note that the two different random draw types each maintain their own sequencing + # i.e. a next() will not move a random() to the next value + def test_one_element_with_force_different(self): - r = Randomizer(['1'], self.machine) + r = ListRandomizer(['1'], machine=self.machine) self.assertTrue(r.force_different) # it has one element and should thereby always return it @@ -27,7 +54,7 @@ def test_one_element_with_force_different(self): def test_machine_randomizer(self): # no weights given case - r = Randomizer(['1', '2', '3'], self.machine) + r = ListRandomizer(['1', '2', '3'], machine=self.machine) results = list() for x in range(10000): @@ -38,7 +65,7 @@ def test_machine_randomizer(self): self.assertAlmostEqual(3333, results.count('3'), delta=500) def test_force_different(self): - r = Randomizer(standard_items(), self.machine) + r = ListRandomizer(standard_items(), machine=self.machine) r.force_different = True last_item = None @@ -48,7 +75,7 @@ def test_force_different(self): last_item = this_item def test_force_all(self): - r = Randomizer(standard_items(), self.machine) + r = ListRandomizer(standard_items(), machine=self.machine) r.force_all = True last_item = None @@ -62,7 +89,7 @@ def test_force_all(self): self.assertEqual(len(results), 3) def test_no_loop(self): - r = Randomizer(standard_items(), self.machine) + r = ListRandomizer(standard_items(), machine=self.machine) r.loop = False x = 0 @@ -72,7 +99,7 @@ def test_no_loop(self): self.assertEqual(3, x) # enumeration terminates after three def test_loop(self): - r = Randomizer(standard_items(), self.machine) + r = ListRandomizer(standard_items(), machine=self.machine) r.loop = True x = 0 @@ -84,18 +111,18 @@ def test_loop(self): self.assertEqual(50, x) # enumeration will never terminate def test_loop_no_random(self): - r = Randomizer(standard_items(), self.machine) + r = ListRandomizer(standard_items(), machine=self.machine) r.disable_random = True for i1 in range(50): - self.assertEqual(next(r), '1') - self.assertEqual(next(r), '2') - self.assertEqual(next(r), '3') + self.assertEqual(next(r), 'a') + self.assertEqual(next(r), 'b') + self.assertEqual(next(r), 'c') def test_no_loop_no_random(self): items = standard_items() for _ in range(50): - r = Randomizer(items, self.machine) + r = ListRandomizer(items, machine=self.machine) r.loop = False r.disable_random = True @@ -108,14 +135,15 @@ def test_no_loop_no_random(self): def test_conditionals(self): # Case 1 - generally working - r = Randomizer( + r = ListRandomizer( [ '1{True}', '2{False}', '3{2 == 1+1}', '4{1 == "whatever"}', ], - self.machine, + None, + machine=self.machine, template_type="event" ) r.force_different = False @@ -130,13 +158,14 @@ def test_conditionals(self): self.assertEqual(0, results.count('4')) # Case 2 - conditional items can have weights - r = Randomizer( + r = ListRandomizer( [ ('1{True}', 2), ('2{False}', 50), ('3{2 == 1+1}', 1), ], - self.machine, + None, + machine=self.machine, template_type="event" ) r.force_different = False @@ -151,13 +180,13 @@ def test_conditionals(self): def test_conditionals_no_random(self): # conditionals should loop consistently while all continue to resolve in order - r = Randomizer([ + r = ListRandomizer([ '1{True}', '2{False}', '3{2 == 1+1}', '4{1 == "whatever"}', ], - self.machine, + machine=self.machine, template_type="event" ) r.disable_random = True @@ -169,13 +198,13 @@ def test_conditionals_no_random(self): def test_conditionals_dynamic_updating_no_random(self): # conditionals should loop properly when conditional values change between draws self.machine.variables.set_machine_var('foo', 1) - r = Randomizer([ + r = ListRandomizer([ '1{machine.foo == 0}', '2{machine.foo == 1}', '3{machine.foo == 0}', '4{machine.foo == 1}', ], - self.machine, + machine=self.machine, template_type="event" ) r.disable_random = True @@ -193,7 +222,7 @@ def test_fallback_value(self): # This feature is intended for cases where conditional items all drop out of validity # Case 1 - no items at all falls back always - r = Randomizer([], self.machine) + r = ListRandomizer([], machine=self.machine) r.fallback_value = "foo" results = list() @@ -203,7 +232,7 @@ def test_fallback_value(self): self.assertEqual(10, results.count('foo')) # Case 2 - looping never falls back - r = Randomizer(['1', '2'], self.machine) + r = ListRandomizer(['1', '2'], machine=self.machine) r.loop = True r.force_all r.fallback_value = "foo" @@ -291,7 +320,7 @@ def test_weights(self): ('2', 1), ('3', 1), ] - r = Randomizer(items, self.machine) + r = ListRandomizer(items, machine=self.machine) r.force_different = False results = list() @@ -310,7 +339,7 @@ def test_weights(self): ('3', 3), ] - r = Randomizer(items, self.machine) + r = ListRandomizer(items, machine=self.machine) r.force_different = False results = list() @@ -330,7 +359,7 @@ def test_weights(self): ('3', 1), ] - r = Randomizer(items, self.machine) + r = ListRandomizer(items, machine=self.machine) r.force_all = True results = list() @@ -349,7 +378,7 @@ def test_weights(self): ('3', 1), ] - r = Randomizer(items, self.machine) + r = ListRandomizer(items, machine=self.machine) r.force_different = True results = list() @@ -367,7 +396,7 @@ def test_weights(self): ('3', 1), ] - r = Randomizer(items, self.machine) + r = ListRandomizer(items, machine=self.machine) r.force_all = True r.force_different = True