diff --git a/evennia/contrib/cooldowns.py b/evennia/contrib/cooldowns.py index 44d59eeab9..4a565d6c8e 100644 --- a/evennia/contrib/cooldowns.py +++ b/evennia/contrib/cooldowns.py @@ -15,18 +15,21 @@ state. They do not fire callbacks, so are not a good fit for use cases where something needs to happen on a specific schedule (use delay or a TickerHandler for that instead). +See also the evennia documentation for command cooldowns +(https://github.com/evennia/evennia/wiki/Command-Cooldown) for more information +about the concept. + Installation: -To use, simply add the following property to the typeclass definition of -any object type that you want to support cooldowns. It will expose a -new `cooldowns` property that persists data to the object's attribute -storage. You can set this on your base `Object` typeclass to enable cooldown -tracking on every kind of object, or just put it on your `Character` -typeclass. +To use, simply add the following property to the typeclass definition of any +object type that you want to support cooldowns. It will expose a new `cooldowns` +property that persists data to the object's attribute storage. You can set this +on your base `Object` typeclass to enable cooldown tracking on every kind of +object, or just put it on your `Character` typeclass. -By default the CooldownHandler will use the `cooldowns` property, but you -can customize this if desired by passing a different value for the -db_attribute parameter. +By default the CooldownHandler will use the `cooldowns` property, but you can +customize this if desired by passing a different value for the db_attribute +parameter. from evennia.contrib.cooldowns import Cooldownhandler from evennia.utils.utils import lazy_property @@ -37,14 +40,16 @@ db_attribute parameter. Example: -Assuming you've installed cooldowns on your Character typeclasses, you can -use a cooldown to limit how often you can perform a command: +Assuming you've installed cooldowns on your Character typeclasses, you can use a +cooldown to limit how often you can perform a command. The following code +snippet will limit the use of a Power Attack command to once every 10 seconds +per character. class PowerAttack(Command): def func(self): if self.caller.cooldowns.ready("power attack"): self.do_power_attack() - self.caller.cooldowns.set("power attack", 10) + self.caller.cooldowns.add("power attack", 10) else: self.caller.msg("That's not ready yet!") """ @@ -55,42 +60,47 @@ import time class CooldownHandler: """ - Handler for cooldowns. This can be attached to any object that - supports DB attributes (like a Character or Account). + Handler for cooldowns. This can be attached to any object that supports DB + attributes (like a Character or Account). - A cooldown is a timer that is usually used to limit how often - some action can be performed or some effect can trigger. When a - cooldown is first set, it counts down from the amount of time - provided back to zero, at which point it is considered ready again. + A cooldown is a timer that is usually used to limit how often some action + can be performed or some effect can trigger. When a cooldown is first added, + it counts down from the amount of time provided back to zero, at which point + it is considered ready again. - Cooldowns are named with an arbitrary string, and that string is used - to check on the progression of the cooldown. Each cooldown is tracked - separately and independently. + Cooldowns are named with an arbitrary string, and that string is used to + check on the progression of the cooldown. Each cooldown is tracked + separately and independently from other cooldowns on that same object. A + cooldown is unique per-object. - Cooldowns are saved persistently, so they survive reboots. This - module does not register or provide callback functionality for when - a cooldown becomes ready again. Users of cooldowns are expected to - query the state of any cooldowns they are interested in. + Cooldowns are saved persistently, so they survive reboots. This module does + not register or provide callback functionality for when a cooldown becomes + ready again. Users of cooldowns are expected to query the state of any + cooldowns they are interested in. Methods: - ready(name): Checks whether a given cooldown name is ready. - time_left(name): Returns how much time is left on a cooldown. - - set(name, seconds): Sets a given cooldown to last for a certain + - add(name, seconds): Sets a given cooldown to last for a certain amount of time. Until then, ready() will return False for that - cooldown name. - - extend(name, seconds): Like set, but adds time to the given - cooldown name. If it doesn't exist yet, calling this is equivalent - to calling set. + cooldown name. set() is an alias. + - extend(name, seconds): Like add(), but adds more time to the given + cooldown if it already exists. If it doesn't exist yet, calling + this is equivalent to calling add(). - reset(cooldown): Resets a given cooldown, causing ready() to return True for that cooldown immediately. - clear(): Resets all cooldowns. """ + __slots__ = ("data", "db_attribute", "obj") + def __init__(self, obj, db_attribute="cooldowns"): if not obj.attributes.has(db_attribute): obj.attributes.add(db_attribute, {}) self.data = obj.attributes.get(db_attribute) + self.obj = obj + self.db_attribute = db_attribute self.cleanup() @property @@ -102,63 +112,69 @@ class CooldownHandler: def ready(self, *args): """ - Checks whether all of the provided cooldowns are ready (expired). - If a requested cooldown does not exist, it is considered ready. + Checks whether all of the provided cooldowns are ready (expired). If a + requested cooldown does not exist, it is considered ready. Args: - any (str): One or more cooldown names to check. + *args (str): One or more cooldown names to check. Returns: - (bool): True if each cooldown has expired or does not exist. + bool: True if each cooldown has expired or does not exist. """ - return self.time_left(*args) <= 0 + return self.time_left(*args, use_int=True) <= 0 - def time_left(self, *args): + def time_left(self, *args, use_int=False): """ - Returns the maximum amount of time left on one or more given - cooldowns. If a requested cooldown does not exist, it is - considered to have 0 time left. + Returns the maximum amount of time left on one or more given cooldowns. + If a requested cooldown does not exist, it is considered to have 0 time + left. Args: - any (str): One or more cooldown names to check. + *args (str): One or more cooldown names to check. + use_int (bool): True to round the return value up to an int, + False (default) to return a more precise float. Returns: - (int): Number of seconds until all provided cooldowns are - ready. Returns 0 if all cooldowns are ready (or don't - exist.) + float or int: Number of seconds until all provided cooldowns are + ready. Returns 0 if all cooldowns are ready (or don't exist.) """ now = time.time() cooldowns = [self.data[x] - now for x in args if x in self.data] if not cooldowns: - return 0 - return math.ceil(max(max(cooldowns), 0)) + return 0 if use_int else 0.0 + left = max(max(cooldowns), 0) + return math.ceil(left) if use_int else left - def set(self, cooldown, seconds): + def add(self, cooldown, seconds): """ - Sets a given cooldown to last for a specific amount of time. + Adds/sets a given cooldown to last for a specific amount of time. - If this cooldown is already set, this replaces it. + If this cooldown already exits, this call replaces it. Args: cooldown (str): The name of the cooldown. - seconds (int): The number of seconds before this cooldown is - ready again. + seconds (float or int): The number of seconds before this cooldown + is ready again. """ now = time.time() - self.data[cooldown] = int(now) + (max(seconds, 0) if seconds else 0) + self.data[cooldown] = now + (max(seconds, 0) if seconds else 0) + + set = add def extend(self, cooldown, seconds): """ Adds a specific amount of time to an existing cooldown. - If this cooldown is already ready, this is equivalent to calling - set. If the cooldown is not ready, it will be extended by the - provided duration. + If this cooldown is already ready, this is equivalent to calling set. If + the cooldown is not ready, it will be extended by the provided duration. Args: cooldown (str): The name of the cooldown. - seconds (int): The number of seconds to extend this cooldown. + seconds (float or int): The number of seconds to extend this cooldown. + Returns: + float: The number of seconds until the cooldown will be ready again. """ - time_left = self.time_left(cooldown) - self.set(cooldown, time_left + (seconds if seconds else 0)) + time_left = self.time_left(cooldown) + (seconds if seconds else 0) + self.set(cooldown, time_left) + return max(time_left, 0) def reset(self, cooldown): """ @@ -174,8 +190,7 @@ class CooldownHandler: """ Resets all cooldowns. """ - for cooldown in list(self.data.keys()): - del self.data[cooldown] + self.data.clear() def cleanup(self): """ @@ -183,6 +198,10 @@ class CooldownHandler: requirements small. """ now = time.time() - keys = [x for x in self.data.keys() if self.data[x] - now < 0] + cooldowns = dict(self.data) + keys = [x for x in cooldowns.keys() if cooldowns[x] - now < 0] for key in keys: - del self.data[key] + del cooldowns[key] + if keys: + self.obj.attributes.add(self.db_attribute, cooldowns) + self.data = self.obj.attributes.get(self.db_attribute) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 718d788275..3b31c04c58 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -3447,7 +3447,7 @@ class TestLegacyMuxComms(CommandTest): from evennia.contrib import cooldowns -@patch("evennia.contrib.cooldowns.time.time", return_value=0) +@patch("evennia.contrib.cooldowns.time.time", return_value=0.0) class TestCooldowns(EvenniaTest): def setUp(self): super().setUp() @@ -3458,23 +3458,31 @@ class TestCooldowns(EvenniaTest): self.assertTrue(self.handler.ready("a", "b", "c")) self.assertEqual(self.handler.time_left("a", "b", "c"), 0) - def test_set(self, mock_time): - self.handler.set("a", 10) + def test_add(self, mock_time): + self.assertEqual(self.handler.add, self.handler.set) + self.handler.add("a", 10) self.assertFalse(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 10) - - mock_time.return_value = 9 + mock_time.return_value = 9.0 self.assertFalse(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 1) - - mock_time.return_value = 10 + mock_time.return_value = 10.0 self.assertTrue(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 0) - def test_set_multi(self, mock_time): - self.handler.set("a", 10) - self.handler.set("b", 5) - self.handler.set("c", 3) + def test_add_float(self, mock_time): + self.assertEqual(self.handler.time_left("a"), 0) + self.assertEqual(self.handler.time_left("a", use_int=False), 0) + self.assertEqual(self.handler.time_left("a", use_int=True), 0) + self.handler.add("a", 5.5) + self.assertEqual(self.handler.time_left("a"), 5.5) + self.assertEqual(self.handler.time_left("a", use_int=False), 5.5) + self.assertEqual(self.handler.time_left("a", use_int=True), 6) + + def test_add_multi(self, mock_time): + self.handler.add("a", 10) + self.handler.add("b", 5) + self.handler.add("c", 3) self.assertFalse(self.handler.ready("a", "b", "c")) self.assertEqual(self.handler.time_left("a", "b", "c"), 10) self.assertEqual(self.handler.time_left("a", "b"), 10) @@ -3482,47 +3490,59 @@ class TestCooldowns(EvenniaTest): self.assertEqual(self.handler.time_left("b", "c"), 5) self.assertEqual(self.handler.time_left("c", "c"), 3) - def test_set_none(self, mock_time): - self.handler.set("a", None) + def test_add_none(self, mock_time): + self.handler.add("a", None) self.assertTrue(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 0) - def test_set_negative(self, mock_time): - self.handler.set("a", -5) + def test_add_negative(self, mock_time): + self.handler.add("a", -5) self.assertTrue(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 0) - def test_set_overwrite(self, mock_time): - self.handler.set("a", 5) - self.handler.set("a", 10) - self.handler.set("a", 3) + def test_add_overwrite(self, mock_time): + self.handler.add("a", 5) + self.handler.add("a", 10) + self.handler.add("a", 3) self.assertFalse(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 3) def test_extend(self, mock_time): - self.handler.extend("a", 10) + self.assertEqual(self.handler.extend("a", 10), 10) self.assertFalse(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 10) - self.handler.extend("a", 10) + self.assertEqual(self.handler.extend("a", 10), 20) self.assertFalse(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 20) def test_extend_none(self, mock_time): - self.handler.extend("a", None) + self.assertEqual(self.handler.extend("a", None), 0) self.assertTrue(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 0) - self.handler.set("a", 10) - self.handler.extend("a", None) + self.handler.add("a", 10) + self.assertEqual(self.handler.extend("a", None), 10) self.assertEqual(self.handler.time_left("a"), 10) def test_extend_negative(self, mock_time): - self.handler.extend("a", -5) + self.assertEqual(self.handler.extend("a", -5), 0) self.assertTrue(self.handler.ready("a")) self.assertEqual(self.handler.time_left("a"), 0) - self.handler.set("a", 10) - self.handler.extend("a", -5) + self.handler.add("a", 10) + self.assertEqual(self.handler.extend("a", -5), 5) self.assertEqual(self.handler.time_left("a"), 5) + def test_extend_float(self, mock_time): + self.assertEqual(self.handler.extend("a", -5.5), 0) + self.assertTrue(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 0.0) + self.assertEqual(self.handler.time_left("a", use_int=False), 0.0) + self.assertEqual(self.handler.time_left("a", use_int=True), 0) + self.handler.add("a", 10.5) + self.assertEqual(self.handler.extend("a", -5.25), 5.25) + self.assertEqual(self.handler.time_left("a"), 5.25) + self.assertEqual(self.handler.time_left("a", use_int=False), 5.25) + self.assertEqual(self.handler.time_left("a", use_int=True), 6) + def test_reset_non_existent(self, mock_time): self.handler.reset("a") self.assertTrue(self.handler.ready("a")) @@ -3535,20 +3555,32 @@ class TestCooldowns(EvenniaTest): self.assertEqual(self.handler.time_left("a"), 0) def test_clear(self, mock_time): - self.handler.set("a", 10) - self.handler.set("b", 10) - self.handler.set("c", 10) + self.handler.add("a", 10) + self.handler.add("b", 10) + self.handler.add("c", 10) self.handler.clear() self.assertTrue(self.handler.ready("a", "b", "c")) self.assertEqual(self.handler.time_left("a", "b", "c"), 0) def test_cleanup(self, mock_time): - self.handler.set("a", 10) - self.handler.set("b", 5) - self.handler.set("c", 5) - mock_time.return_value = 6 + self.handler.add("a", 10) + self.handler.add("b", 5) + self.handler.add("c", 5) + self.handler.add("d", 3.5) + mock_time.return_value = 6.0 self.handler.cleanup() - self.assertEqual(self.handler.time_left("b", "c"), 0) + self.assertEqual(self.handler.time_left("b", "c", "d"), 0) self.assertEqual(self.handler.time_left("a"), 4) - self.assertNotIn("b", self.handler.data) - self.assertNotIn("c", self.handler.data) + self.assertEqual(list(self.handler.data.keys()), ["a"]) + + def test_cleanup_doesnt_delete_anything(self, mock_time): + self.handler.add("a", 10) + self.handler.add("b", 5) + self.handler.add("c", 5) + self.handler.add("d", 3.5) + mock_time.return_value = 1.0 + self.handler.cleanup() + self.assertEqual(self.handler.time_left("d"), 2.5) + self.assertEqual(self.handler.time_left("b", "c"), 4) + self.assertEqual(self.handler.time_left("a"), 9) + self.assertEqual(list(self.handler.data.keys()), ["a", "b", "c", "d"])