Pass kwargs from get_stages/dt to staging callable in ondemandhandler

This commit is contained in:
Griatch 2024-03-11 22:09:17 +01:00
parent 1853b29429
commit 34b5f1133c
5 changed files with 148 additions and 50 deletions

View file

@ -12,6 +12,8 @@
- Feature: Add `ON_DEMAND_HANDLER.set_dt(key, category, dt)` and
`.set_stage(key, category, stage)` to allow manual tweaking of task timings,
for example for a spell speeding a plant's growth (Griatch)
- Feature: Add `ON_DEMAND_HANDLER.get_dt/stages(key,category, **kwargs)`, where
the kwargs are passed into any stage-callable defined with the stages. (Griatch)
- Feature: Add `use_assertequal` kwarg to the `EvenniaCommandTestMixin` testing
class; this uses django's `assertEqual` over the default more lenient checker,
which can be useful for testing table whitespace (Griatch)

View file

@ -12,6 +12,8 @@
- Feature: Add `ON_DEMAND_HANDLER.set_dt(key, category, dt)` and
`.set_stage(key, category, stage)` to allow manual tweaking of task timings,
for example for a spell speeding a plant's growth (Griatch)
- Feature: Add `ON_DEMAND_HANDLER.get_dt/stages(key,category, **kwargs)`, where
the kwargs are passed into any stage-callable defined with the stages. (Griatch)
- Feature: Add `use_assertequal` kwarg to the `EvenniaCommandTestMixin` testing
class; this uses django's `assertEqual` over the default more lenient checker,
which can be useful for testing table whitespace (Griatch)

View file

@ -67,6 +67,8 @@ class Flower(Object):
```
The `get_state(key, category=None, **kwargs)` methoid is used to get the current stage. The `get_dt(key, category=None, **kwargs)` method instead retrieves the currently passed time.
You could now create the rose and it would figure out its state only when you are actually looking at it. It will stay a seedling for 10 minutes (of in-game real time) before it sprouts. Within 12 hours it will be dead again.
If you had a `harvest` command in your game, you could equally have it check the stage of bloom and give you different results depending on if you pick the rose at the right time or not.
@ -94,13 +96,12 @@ This is important. If no-one checks in on the flower until a time when it's alr
```
- The `key` can be a string, but also a typeclassed object (its string representation will be used, which normally includes its `#dbref`). You can also pass a `callable` - this will be called without arguments and is expected to return a string to use for the `key`. Finally, you can also pass [OnDemandTask](evennia.scripts.ondemandhandler.OnDemandTask) entities - these are the objects the handler uses under the hood to represent each task.
- The `category` allows you to further categorize your demandhandler tasks to make sure they are unique. Since the handler is global, you need to make sure `key` + `category` is unique. While `category` is optional, if you use it you must also use it to retrieve your state later.
- `stages` is a `dict` `{dt: statename}` or `{dt: (statename, callable)}` that represents how much time (in seconds) from _the start of the task_ it takes for that stage to begin. In the flower example above, it was 10 hours until the `wilting` state began. If a `callable` is also included, this will be called *the first time* that state is checked for (only!). The callable takes a `evennia.OnDemandTask` as an argument and allows for tweaking the task on the fly. The `dt` can also be a `float` if you desire higher than per-second precision. Having `stages` is optional - sometimes you only want to know how much time has passed.
- `stages` is a `dict` `{dt: statename}` or `{dt: (statename, callable)}` that represents how much time (in seconds) from _the start of the task_ it takes for that stage to begin. In the flower example above, it was 10 hours until the `wilting` state began. If a callable is included, it will fire the first time that stage is reached. This callable takes the current `OnDemandTask` and `**kwargs` as arguments; the keywords are passed on from the `get_stages/dt` methods. [See below](#stage-callables) for information about the allowed callables. Having `stages` is optional - sometimes you only want to know how much time has passed.
- `.get_dt()` - get the current time (in seconds) since the task started. This is a `float`.
- `.get_stage()` - get the current state name, such as "flowering" or "seedling". If you didn't specify any `stages`, this will return `None`, and you need to interpret the `dt` yourself to determine which state you are in.
Under the hood, the handler uses [OnDemandTask](evennia.scripts.ondemandhandler.OnDemandTask) objects. It can sometimes be practical to create tasks directly with these, and pass them to the handler in bulk:
```python
from evennia import ON_DEMAND_HANDLER, OnDemandTask
@ -118,11 +119,49 @@ task2 = ON_DEMAND_HANDLER.get("key1", category="state-category")
ON_DEMAND_HANDLER.batch_remove(task1, task2)
```
### Stage callables
If you define one or more of your `stages` dict keys as `{dt: (statename, callable)}`, this callable will be called when that stage is checked for the first time. This 'stage callable' have a few requirements:
- The stage callable must be [possible to pickle](https://docs.python.org/3/library/pickle.html#pickle-picklable) because it will be saved to the database. This basically means your callable needs to be a stand-alone function or a method decorated with `@staticmethod`. You won't be able to access the object instance as `self` directly from such a method or function - you need to pass it explicitly.
- The callable must always take `task` as its first element. This is the `OnDemandTask` object firing this callable.
- It may optionally take `**kwargs` . This will be passed down from your call of `get_dt` or `get_stages`.
Here's an example:
```python
from evennia DefaultObject, ON_DEMAND_HANDLER
def mycallable(task, **kwargs)
# this function is outside the class and is pickleable just fine
obj = kwargs.get("obj")
# do something with the object
class SomeObject(DefaultObject):
def at_object_creation(self):
ON_DEMAND_HANDLER.add(
"key1",
stages={0: "new", 10: ("old", mycallable)}
)
def do_something(self):
# pass obj=self into the handler; to be passed into
# mycallable if we are in the 'old' stage.
state = ON_DEMAND_HANDLER.get_state("key1", obj=self)
```
Above, the `obj=self` will passed into `mycallable` once we reach the 'old' state. If we are not in the 'old' stage, the extra kwargs go nowhere. This way a function can be made aware of the object calling it while still being possible to pickle. You can also pass any other information into the callable this way.
> If you don't want to deal with the complexity of callables you can also just read off the current stage and do all the logic outside of the handler. This can often be easier to read and maintain.
### Looping repeatedly
Normally, when a sequence of `stages` have been cycled through, the task will just stop at the last stage indefinitely.
`evennia.OnDemandTask.stagefunc_loop` is an included static-method callable you can use to make the task loop. Here's an example of how to use it:
`evennia.OnDemandTask.stagefunc_loop` is an included static-method stage callable you can use to make the task loop. Here's an example of how to use it:
```python
from evennia import ON_DEMAND_HANDLER, OnDemandTask

View file

@ -3,10 +3,10 @@ Helper to handle on-demand requests, allowing a system to change state only when
actually needs the information. This is a very efficient way to handle gradual changes, requiring
not computer resources until the state is actually needed.
For example, consider a flowering system, where a seed sprouts, grows and blooms over a certain time.
One _could_ implement this with e.g. a Script or a ticker that gradually moves the flower along
its stages of growth. But what if that flower is in a remote location, and no one is around to see it?
You are then wasting computational resources on something that no one is looking at.
For example, consider a flowering system, where a seed sprouts, grows and blooms over a certain
time. One _could_ implement this with e.g. a Script or a ticker that gradually moves the flower
along its stages of growth. But what if that flower is in a remote location, and no one is around to
see it? You are then wasting computational resources on something that no one is looking at.
The truth is that most of the time, players are not looking at most of the things in the game. They
_only_ need to know about which state the flower is in when they are actually looking at it, or
@ -28,8 +28,8 @@ This is the basic principle, using the flowering system as an example.
since too long time has passed and the plant has died.
With a system like this you could have growing plants all over your world and computing usage would
only scale by how many players you have exploring your world. The players will not know the difference
between this and a system that is always running, but your server will thank you.
only scale by how many players you have exploring your world. The players will not know the
difference between this and a system that is always running, but your server will thank you.
There is only one situation where this system is not ideal, and that is when a player should be
informed of the state change _even if they perform no action_. That is, even if they are just idling
@ -69,6 +69,7 @@ from evennia.utils.utils import is_iter
_RUNTIME = None
ON_DEMAND_HANDLER = None
ONDEMAND_HANDLER_SAVE_NAME = "on_demand_timers"
class OnDemandTask:
@ -76,10 +77,10 @@ class OnDemandTask:
Stores information about an on-demand task.
Default property:
- `default_stage_function (callable)`: This is called if no stage function is given in the stages dict.
This is meant for changing the task itself (such as restarting it). Actual game code should
be handled elsewhere, by checking this task. See the `stagefunc_*` static methods for examples
of how to manipulate the task when a stage is reached.
- `default_stage_function (callable)`: This is called if no stage function is given in the
stages dict. This is meant for changing the task itself (such as restarting it). Actual
game code should be handled elsewhere, by checking this task. See the `stagefunc_*` static
methods for examples of how to manipulate the task when a stage is reached.
"""
@ -100,7 +101,7 @@ class OnDemandTask:
return _RUNTIME()
@staticmethod
def stagefunc_loop(task):
def stagefunc_loop(task, **kwargs):
"""
Attach this to the last stage to have the task start over from
the beginning
@ -131,7 +132,7 @@ class OnDemandTask:
task.start_time = now - current_loop_time
@staticmethod
def stagefunc_bounce(task):
def stagefunc_bounce(task, **kwargs):
"""
This endfunc will have the task reverse direction and go through the stages in
reverse order. This stage-function must be placed at both 'ends' of the stage sequence
@ -161,7 +162,8 @@ class OnDemandTask:
stages = task.stages
task.stages = {abs(k - max_dt): v for k, v in sorted(stages.items())}
# default fallback stage function. This is called if no stage function is given in the stages dict.
# default fallback stage function. This is called if no stage function is given in the stages
# dict.
default_stage_function = None
def __init__(self, key, category, stages=None, autostart=True):
@ -239,13 +241,14 @@ class OnDemandTask:
return False
return (self.key, self.category) == (other.key, other.category)
def check(self, autostart=True):
def check(self, autostart=True, **kwargs):
"""
Check the current stage of the task and return the time-delta to the next stage.
Args:
Keyword Args:
autostart (bool, optional): If this is set, and the task has not been started yet,
it will be started by this check. This is mainly used internally.
**kwargs: Will be passed to the stage function, if one is called.
Returns:
tuple: A tuple (dt, stage) where `dt` is the time-delta (in seconds) since the test
@ -268,7 +271,7 @@ class OnDemandTask:
if stage_func:
try:
stage_func(self)
stage_func(self, **kwargs)
except Exception as err:
logger.log_trace(
f"Error getting stage of on-demand task {self} "
@ -302,16 +305,17 @@ class OnDemandTask:
return dt, stage
def get_dt(self):
def get_dt(self, **kwargs):
"""
Get the time-delta since last check.
Returns:
int: The time since the last check, or 0 if this is the first time the task is checked.
**kwargs: Will be passed to the stage function, if one is called.
"""
return self.check()[0]
return self.check(autostart=True, **kwargs)[0]
def set_dt(self, dt):
"""
@ -330,16 +334,17 @@ class OnDemandTask:
"""
self.start_time = OnDemandTask.runtime() - dt
def get_stage(self):
def get_stage(self, **kwargs):
"""
Get the current stage of the task. If no stage was given, this will return `None` but
still update the last_checked time.
Returns:
str or None: The current stage of the task, or `None` if no stages are set.
**kwargs: Will be passed to the stage function, if one is called.
"""
return self.check()[1]
return self.check(autostart=True, **kwargs)[1]
def set_stage(self, stage=None):
"""
@ -386,14 +391,14 @@ class OnDemandHandler:
This should be automatically called when Evennia starts.
"""
self.tasks = dict(ServerConfig.objects.conf("on_demand_timers", default=dict))
self.tasks = dict(ServerConfig.objects.conf(ONDEMAND_HANDLER_SAVE_NAME, default=dict))
def save(self):
"""
Save the on-demand timers to ServerConfig storage. Should be called when Evennia shuts down.
"""
ServerConfig.objects.conf("on_demand_timers", self.tasks)
ServerConfig.objects.conf(ONDEMAND_HANDLER_SAVE_NAME, self.tasks)
def _build_key(self, key, category):
"""
@ -404,7 +409,8 @@ class OnDemandHandler:
called without arguments. If an Object, will be converted to a string. If
an `OnDemandTask`, then all other arguments are ignored and the task will be used
to build the internal storage key.
category (str or callable): The task category. If callable, it will be called without arguments.
category (str or callable): The task category. If callable, it will be called without
arguments.
Returns:
tuple (str, str or None): The unique key.
@ -437,7 +443,8 @@ class OnDemandHandler:
Returns:
OnDemandTask: The created task (or the same that was added, if given an `OnDemandTask`
as a `key`). Use `task.get_dt()` and `task.get_stage()` to get data from it manually.
as a `key`). Use `task.get_dt()` and `task.get_stage()` to get data from it
manually.
"""
if isinstance(key, OnDemandTask):
@ -463,9 +470,10 @@ class OnDemandHandler:
Remove an on-demand task.
Args:
key (str, callable, OnDemandTask or Object): The unique identifier for the task. If a callable, will
be called without arguments. If an Object, will be converted to a string. If an `OnDemandTask`,
then all other arguments are ignored and the task will be used to identify the task to remove.
key (str, callable, OnDemandTask or Object): The unique identifier for the task. If a
callable, will be called without arguments. If an Object, will be converted to a
string. If an `OnDemandTask`, then all other arguments are ignored and the task
will be used to identify the task to remove.
category (str or callable, optional): The category of the task.
Returns:
@ -517,10 +525,10 @@ class OnDemandHandler:
Clear all on-demand tasks.
Args:
category (str, optional): The category of the tasks to clear. What `None` means is determined
by the `all_on_none` kwarg.
all_on_none (bool, optional): Determines what to clear if `category` is `None`. If `True`,
clear all tasks, if `False`, only clear tasks with no category.
category (str, optional): The category of the tasks to clear. What `None` means is
determined by the `all_on_none` kwarg.
all_on_none (bool, optional): Determines what to clear if `category` is `None`. If
`True`, clear all tasks, if `False`, only clear tasks with no category.
"""
if category is None and all_on_none:
@ -538,9 +546,9 @@ class OnDemandHandler:
Args:
key (str, callable, OnDemandTask or Object): The unique identifier for the task. If a
callable, will be called without arguments. If an Object, will be converted to a string.
If an `OnDemandTask`, then all other arguments are ignored and the task will be used
(only useful to check the task is the same).
callable, will be called without arguments. If an Object, will be converted to a
string. If an `OnDemandTask`, then all other arguments are ignored and the task
will be used (only useful to check the task is the same).
category (str, optional): The category of the task. If unset, this will only return
tasks with no category.
@ -551,22 +559,23 @@ class OnDemandHandler:
"""
return self.tasks.get(self._build_key(key, category))
def get_dt(self, key, category=None):
def get_dt(self, key, category=None, **kwargs):
"""
Get the time-delta since the task started.
Args:
key (str, callable, OnDemandTask or Object): The unique identifier for the task. If a
callable, will be called without arguments. If an Object, will be converted to a string.
If an `OnDemandTask`, then all other arguments are ignored and the task will be used
to identify the task to get the time-delta from.
callable, will be called without arguments. If an Object, will be converted to a
string. If an `OnDemandTask`, then all other arguments are ignored and the task
will be used to identify the task to get the time-delta from.
**kwargs: Will be passed to the stage function, if one is called.
Returns:
int or None: The time since the last check, or `None` if no task was found.
"""
task = self.get(key, category)
return task.get_dt() if task else None
return task.get_dt(**kwargs) if task else None
def set_dt(self, key, category, dt):
"""
@ -576,9 +585,9 @@ class OnDemandHandler:
Args:
key (str, callable, OnDemandTask or Object): The unique identifier for the task. If a
callable, will be called without arguments. If an Object, will be converted to a string.
If an `OnDemandTask`, then all other arguments are ignored and the task will be used
to identify the task to set the time-delta for.
callable, will be called without arguments. If an Object, will be converted to a
string. If an `OnDemandTask`, then all other arguments are ignored and the task will
be used to identify the task to set the time-delta for.
category (str, optional): The category of the task.
dt (int): The time-delta to set. This is an absolute value in seconds, same as returned
by `get_dt`.
@ -592,22 +601,24 @@ class OnDemandHandler:
if task:
task.set_dt(dt)
def get_stage(self, key, category=None):
def get_stage(self, key, category=None, **kwargs):
"""
Get the current stage of an on-demand task.
Args:
key (str, callable, OnDemandTask or Object): The unique identifier for the task. If a
callable, will be called without arguments. If an Object, will be converted to a string.
If an `OnDemandTask`, then all other arguments are ignored and the task will be used
to identify the task to get the stage from.
callable, will be called without arguments. If an Object, will be converted to a
string. If an `OnDemandTask`, then all other arguments are ignored and the task
will be used to identify the task to get the stage from.
category (str, optional): The category of the task.
**kwargs: Will be passed to the stage function, if one is called.
Returns:
str or None: The current stage of the task, or `None` if no task was found.
"""
task = self.get(key, category)
return task.get_stage() if task else None
return task.get_stage(**kwargs) if task else None
def set_stage(self, key, category=None, stage=None):
"""

View file

@ -675,3 +675,47 @@ class TestOnDemandHandler(EvenniaTest):
self.assertEqual(self.handler.get_dt("daffodil", "flower"), 150)
self.assertEqual(self.handler.get_stage("rose", "flower"), "bud")
self.assertEqual(self.handler.get_stage("daffodil", "flower"), "wilted")
@staticmethod
def _do_decay(task, **kwargs):
task.stored_kwargs = kwargs
def test_handler_save(self):
"""
Testing the save method of the OnDemandHandler class for reported pickling issue
"""
self.handler.add(
key="foo",
category="decay",
stages={
0: "new",
10: ("old", self._do_decay),
},
)
self.handler.save()
self.handler.clear()
self.handler.save()
@mock.patch("evennia.scripts.ondemandhandler.OnDemandTask.runtime")
def test_call_staging_function_with_kwargs(self, mock_runtime):
""" """
mock_runtime.return_value = 0
self.handler.add(
key="foo",
category="decay",
stages={
0: "new",
10: ("old", self._do_decay),
},
)
self.handler.set_dt("foo", "decay", 10)
self.handler.get_stage("foo", "decay", foo="bar", bar="foo")
self.assertEqual(
self.handler.get("foo", "decay").stored_kwargs, {"foo": "bar", "bar": "foo"}
)